SDK behavior contract
Every Relay SDK, in any language, implements the same protocol pin and the same observable
semantics. This document states those semantics without reference to any one language. The
language READMEs describe how each API spells them. This file says what must be true, and
vocabulary.md fixes the public names every SDK uses for the concepts named here.
protocol.json holds the machine-readable pins. Each SDK has a test that checks its constants
against that file.
Connection
Section titled “Connection”- The MOQT version is the draft pinned in
protocol.json, currently draft-ietf-moq-transport-22 with wire identifiermoqt-22, and draft-ietf-moq-transport-20 (moqt-20) as a fallback. An SDK offersmoqt-22, thenmoqt-20, and no other protocol identifier, and speaks whichever the relay selects. The two drafts differ on the wire only in the LOCATION_FILTER parameter, which the core encodes for the negotiated one. moq-relay selectsmoqt-22from 0.15.0, so every relay version the SDKs support speaks it, but before 0.17.3 it frames LOCATION_FILTER the draft-20 way onmoqt-22too (draft-22 LOCATION_FILTER framing). - Authentication is a JWT supplied by the application. The SDK sends it as the
jwtquery parameter on the endpoint URL. An explicitjwtparameter already present on the URL takes precedence over a token callback, and the callback is not called. An emptyjwtparameter (?jwt=or a bare?jwt), including one among severaljwtparameters, is an argument error (see “Arguments”) rather than a way to connect with no credential. The SDK does not treat it as absent. A whitespace-only value is not empty and is sent as given, as a token callback’s would be. - The endpoint is optional. A connect call given none dials the hosted Relay rooted at its token’s root, the token-rooted endpoint. A connection given an endpoint is unchanged.
- SDK-generated logs and error text do not include the token, and SDK tooling does not persist it
to disk. Tooling redacts the supplied token from browser diagnostics it forwards. The browser
itself is outside that guarantee: Chromium can log the full authenticated WebTransport URL on
handshake failure. Applications must sanitize captured diagnostics before storing or forwarding
them. See
ts/GUIDE.md, “Browser token exposure”. - Connecting has one deadline that covers token resolution and both handshakes, transport and MOQT setup.
- The browser transport may end a silent connection after about 5 s while data is outstanding.
The SDK reports it as
TRANSPORT_ERRORin phaseconnectionand cannot tell it apart from other losses. See browser transport limits.
Transport fallback
Section titled “Transport fallback”- The connection’s transport is the application’s choice, through the transport factory. The
default stays the browser’s WebTransport (TypeScript) or native QUIC (Python, and
@caprid/relay/node): the SDK never falls back to another transport by itself. - The TypeScript SDK’s opt-in
@caprid/relay-websocketpackage carries MOQT on QMux over a WebSocket, TCP to the same host and port as the endpoint (https:becomeswss:), for networks that block UDP and browsers without a usable WebTransport.createWebSocketTransport()always dials it. withWebSocketFallback()dials WebTransport first. If that handshake has not completed withinwebSocketDelayMs(default 250 ms, the Connection Attempt Delay of Happy Eyeballs, RFC 8305), it dials QMux over WebSocket alongside it, and a WebTransport handshake that fails sooner starts the WebSocket dial at once. The first dial to be ready carries the session and the other is closed. The connection fails only once both dials have failed, with the WebSocket dial’s failure. Where the runtime has no WebTransport that can negotiate protocols, it dials WebSocket at once. Each connection, a supervisor’s reconnects and rotations included, decides afresh. Nothing is remembered between connections. A WebTransport session that comes up and is then refused (for example over its credentials) is not retried over WebSocket.- Both transports run the same session. The differences come from the relay and from TCP
(QMux over WebSocket). Streams share one TCP connection, so
a lost packet holds up every stream (head-of-line blocking). Datagrams are unavailable. A
credentials refusal arrives as a failed WebSocket handshake, reported as
TRANSPORT_ERRORin phaseconnecting, notUNAUTHORIZED. A send stream’s close resolves once its FIN reaches the socket, not the relay (transport adapter). - The application can give the SDK a cancellation primitive, such as an abort signal or a cancellation token, that governs the connection’s entire lifetime rather than only setup.
- Every connection exposes a way to observe its closure and the error that caused it, if any.
- A GOAWAY from the relay ends the connection with an error. The application opens a replacement. Subscriptions still awaiting their first response end as terminated by the closure (see “Subscription lifecycle”). Accepted or cancelled requests do not receive a second initial response. The core’s own track source still fails pending requests through their error callback. Opt-in supervised connections reconnect. The primitives never do.
Credential lifetime
Section titled “Credential lifetime”- A session lasts until its credential expires, and no longer. moq-relay ends a session at its
grant’s deadline: the JWT’s
expwith the reference auth server (moq auth serve), and no later with Mux’s. A live session cannot take a new token: the relay ignores AUTHORIZATION_TOKEN on later requests, and revalidation re-checks the credential the session connected with (token expiry). Plan to replace a connection before its credential expires. - The token provider runs once per connection, while connecting. It returns a token, or a
RelayCredential: the token together withexpiresAt/expires_at, the time the relay stops honoring it. An empty token sends none, as before. A credential of the wrong shape is aTypeError. An invalidDate(TypeScript) or a naivedatetime(Python) is aRangeError/ValueError. - Every connection exposes
expiresAt/expires_at: the credential’s expiry when the provider gave one, otherwise theexpclaim of the JWT it connected with (a bare token from the provider, or ajwtalready on the URL), read without verifying it. A token that is not a JWT with a numericexpleaves it unknown (undefined/None). The primitives only report it. They never replace a connection. - At that time the relay closes the session with MOQT session termination code
UNAUTHORIZED(0x2), reasonunauthorized. Both SDKs report it asUNAUTHORIZEDin phaseconnectionon the connection’s closure, and operations still pending fail with it. On moq-relay 0.17.0 the close arrives about 80 ms afterexp. One exception: a TypeScript publisher in Chromium sees the relay abort its control stream before WebTransport reports the close code, and reportsTRANSPORT_ERRORin phaseconnectioninstead (issue #279). A Chromium subscriber reportsUNAUTHORIZED. - Supervised connections replace their connection make-before-break before the credential expires. See rotation.
Error identities and operation phases
Section titled “Error identities and operation phases”Branch on code and phase, not message wording. SDK lifecycle errors use these stable codes:
| Code | Application response |
|---|---|
CONNECTION_TIMEOUT |
Connecting exceeded its deadline; consider a new connection with backoff. |
WRITE_TIMEOUT |
Inspect the phase below, then choose the next object or group explicitly. |
DELIVERY_TIMEOUT |
An object outlived its delivery timeout before it reached the transport. The data is stale: do not retry it as it is; choose fresh data or the next random-access point. |
CLOSED |
The handle is closed; use an open connection, track, or group. |
WRITE_PENDING |
Await the outstanding operation or finish/abort the open group before starting another. |
NAMESPACE_NOT_SOLICITED |
Received namespace solicitations do not cover this publisher; check the namespace and token configuration. A later matching solicitation can restore demand. |
NAMESPACE_ENDED |
The relay ended the namespace announcement; open a replacement publisher if appropriate. |
SUBSCRIPTION_REQUEST_FAILED |
An async subscription request failed; inspect its preserved MOQT request error and the connection state. |
SUBSCRIPTION_OVERFLOW |
The event buffer filled; the subscription was cancelled. Choose a suitable capacity or faster consumption before subscribing again. |
PREFIX_OVERLAP |
namespaceEvents / namespace_events was given a prefix that equals, contains or falls under one this connection is already discovering (draft-22 §9.15). Nothing was sent. Widen one iterator’s prefix and filter, end the other iterator first, or use another connection. |
UPDATE_QUEUE_FULL |
More than eight updates were issued before the subscription was accepted; await acceptance (onSubscribed / on_subscribed) before updating again. Nothing was sent for the refused update. |
SUBSCRIPTION_CHANGED |
Upstream demand ended or changed during an operation; choose fresh data or a new random-access point. It can be group-level: the relay may stop one group’s stream while its subscription continues, so check hasDemand / has_demand rather than assume demand ended. |
RECONNECTING |
A supervised publisher had no live generation for the operation, or the connection under it ended (phase connection). Nothing is replayed: write the next object once the publisher is live again. |
GOAWAY |
The relay requested migration; open a replacement connection. |
TRANSPORT_ERROR |
Inspect the original cause and connection state before deciding whether to reconnect or publish again. In TypeScript, a connecting-phase TRANSPORT_ERROR usually means the browser abandoned the handshake, so a retry policy should treat it like CONNECTION_TIMEOUT (see browser transport limits). |
TRANSPORT_UNAVAILABLE |
Supply a transport implementation in a runtime without browser WebTransport; in Node, import from @caprid/relay/node with @caprid/relay-quic installed (TypeScript). |
UNAUTHORIZED |
The relay refused the connection’s credentials: no token, a bad, malformed or expired one, or an endpoint path the token does not grant. Supply a valid token before connecting again. In phase connection, the relay ended a live session because its credential expired or its grant was revoked (credential lifetime); connect again with a fresh token. TypeScript reports it in browsers and over native QUIC (@caprid/relay/node); against relays before moq-relay 0.17.0 a browser does not see the close, so there it arrives as TRANSPORT_ERROR (#14). |
connecting covers token resolution and connection setup. connection marks a connection-wide
failure. It makes no claim about individual writes that were in flight. subscription marks an
async subscription request failure or local event-buffer overflow. Publishing records
waiting-for-demand, opening-stream, writing, or closing before entering each step.
A WRITE_TIMEOUT or DELIVERY_TIMEOUT in waiting-for-demand means this attempt sent no object bytes. For timeouts or transport/subscription failures in a transport
phase, conservatively allow for partial delivery. closing can fail after all object bytes were
sent. Opening a group can start sending its header before the first object write. A phase records
the failure’s origin, and a later call on the failed handle can report that same error.
For WRITE_PENDING and calls rejected because a handle is already closed, phase describes the
refused operation. That call sends nothing. A write or group refused with CLOSED because its
track or group is already closed reports phase waiting-for-demand, the phase that means nothing
was sent. CLOSED can also interrupt an operation already in flight. It then carries that
operation’s phase, so the code alone does not rule out partial delivery.
No code or phase promises safe replay. Account for previous attempts, data freshness, and your
application’s deduplication policy. Successful writes mean transport acceptance, not viewer
acknowledgment. The SDK does not automatically retry or reconnect, outside the opt-in
supervised connections. UNAUTHORIZED is the one code that
says the relay refused the credentials: the relay closed the session with MOQT session termination
code UNAUTHORIZED (0x2; MALFORMED_AUTH_TOKEN 0x16 and EXPIRED_AUTH_TOKEN 0x18 map to it too).
Draft-22 §12.2 defines it as refusing to establish a session, but moq-relay also closes a live
session with it when the session’s grant expires or is revoked, so both SDKs map it in any phase:
connecting before the relay’s SETUP, connection after it (#227, #261). A close in the turn SETUP
arrives stays TRANSPORT_ERROR. From moq-relay 0.17.0 a
browser sees the relay’s close (CLOSE_WEBTRANSPORT_SESSION with code 2 and reason unauthorized), so
TypeScript reports it in browsers as well as over native QUIC. Against older relays the browser lost
the close, and a refusal arrived as TRANSPORT_ERROR (#14). Neither another connection failure nor
an uncovered namespace proves authentication was denied. A subscription outside the token’s grant
is refused per request, through onError / on_error (MOQT UNAUTHORIZED, 0x1), and leaves the connection open.
Clean connection shutdown still resolves closed without an error. Subsequent operations fail
with CLOSED. After a failed connection, closed and subsequent connection operations expose the
same failure. Caller cancellation reasons and token-provider exceptions retain their identity.
Argument and serialization errors keep their idiomatic language types. Subscription onError /
on_error callbacks retain the separate MOQT RequestError contract and numeric protocol codes.
Both SDKs export one Relay error type, RelayError, with a stable code discriminant and a phase
field, plus the three subscription errors that carry payload (SubscriptionRequestError,
SubscriptionOverflowError, SubscriptionConnectionClosedError). There are no other subclasses.
Callers branch on code. Human-readable messages are not a compatibility contract. Wrapped
transport and session failures retain the original cause: TypeScript in RelayError.cause, Python
chained as __cause__. Transport-layer exceptions are not Relay errors. A Relay operation that fails
on the transport raises TRANSPORT_ERROR with the transport exception as its cause.
Local argument errors remain TypeError/RangeError in TypeScript and TypeError/ValueError in
Python: a value of the wrong type or shape is a TypeError in both languages. A value of the right
type outside its bound is a RangeError in TypeScript and a ValueError in Python. The
Arguments table lists the rules. A refused concurrent operation uses WRITE_PENDING
and does not cancel the active operation. Caller-provided cancellation reasons bypass wrapping,
including non-Error JavaScript values and Python task cancellation. Connection failures are shared
with pending publishing operations. Their connection phase does not establish whether those writes
sent bytes.
Arguments
Section titled “Arguments”The values an application hands the SDK that end up on the wire are checked in their original
container, copied once, and only then used. Both roles apply the same rules, so a namespace the
publisher accepts is one the subscriber accepts. Arguments are checked before connection or slot
state at every entry point: a bad argument is reported the same way on a closed connection or a busy
track, and it never reaches the token provider, the transport factory, a request id or a stream. The
token root is the exception: the provider supplies it, so it is checked after the provider runs, and
still before the transport factory. The
portable cases live in vectors/arguments.json and run in every SDK’s suite. The table also lists
the local checks that stay where they are consumed (deadlines, track registration, object status,
event capacity). They follow the same class rule, and only track registration is checked after
connection state, because it depends on the connection’s registry.
| Argument | Rule | TypeError in both languages |
RangeError (TypeScript) / ValueError (Python) |
Checked at |
|---|---|---|---|---|
| Namespace | 1 to 32 non-empty string fields in an array (TypeScript) or a list or tuple (Python); sent verbatim, never split or joined on /. The 32-field limit is limits.maxNamespaceFields in protocol.json. A discovery prefix follows the same rule. |
any other container, including a single string; no fields; a field that is not a non-empty string | more than 32 fields | connectRelayPublisher / connect_relay_publisher; subscribe, subscribeEvents / subscribe_events, trackSource.subscribe / track_source.subscribe; the prefix of namespaceEvents / namespace_events |
| Track name | A non-empty string, in both roles. | anything else | registerTrack / register_track; the three subscriber entry points |
|
| Properties | A list of {type, value} pairs. type is a non-negative integer; an even type carries a non-negative integer, an odd type carries bytes, which are copied. Integers are bounded by what the language’s core varint carries: 2^53-1 in TypeScript, 2^64-1 in Python. Each byte value is at most limits.maxPropertyValueBytes (its 16-bit wire length). A track’s properties encode to at most limits.maxTrackPropertiesBytes (65,525 bytes, the room SUBSCRIBE_OK leaves them), hold at most relay.limits.maxTrackProperties (64) pairs, and keep Default Publisher Priority (0x0E) at most 255 and Default Publisher Group Order (0x22) at 1 or 2 (trackProperties, draft-22 §10.4 and §10.5), because otherwise the track registers but every subscribe to it fails while the publisher’s writes time out. An object’s properties on write encode to at most relay.limits.maxObjectPropertiesBytes (64 KiB), because the relay drops a larger object without an error to either side. The SDK measures the encoding with the core’s own encoder before any slot or stream is taken. |
not a list; a pair without the shape; a type or integer value that is not a non-negative integer the varint carries; bytes where an integer belongs or an integer where bytes belong | a byte value longer than its wire length; a list whose encoding exceeds its bound; track properties with more than 64 pairs, or a 0x0E or 0x22 value draft-22 rules out |
registerTrack / register_track; write on a track or group writer |
| Endpoint | A string holding an absolute https URL with a host and no fragment or userinfo. A jwt query parameter, when present, is not empty; this holds for every jwt parameter when there are several. Or no endpoint at all (undefined in TypeScript, None in Python), when the token roots it (Token root). |
not a string, including null in TypeScript; a string that does not parse as an absolute URL, including an https URL with no host |
another scheme; a fragment or userinfo; an empty jwt parameter |
connectRelaySubscriber / connect_relay_subscriber, connectRelayPublisher / connect_relay_publisher |
| Token root | With no endpoint, a token provider whose token is a JWT: three .-separated parts whose second is a JSON object in unpadded base64url, read without verifying the token. Its root claim is a string. The root without its leading and trailing /, split on /, follows the Room rule; an empty root roots nothing (token-rooted endpoint). Checked after the provider runs, before the transport factory. |
no token provider; no token or an empty one; a token that is not such a JWT; a root claim missing or not a string; as Room, such as an empty field (acme//rooms) |
as Room: a field with a character the Room rule refuses, . or .., or more than 32 fields |
the four connect calls with no endpoint |
| Room | The endpoint follows the Endpoint rule and the room the Namespace rule. Each room field is also made only of the characters a URL path carries unchanged and the relay reads back as the same field, A–Z, a–z, 0–9 and -._~!$&'()+,;=:@, and is not . or .. (room-rooted endpoints). Checked endpoint first, then room. |
as Endpoint and Namespace | as Endpoint and Namespace; a room field with any other character, such as a space, %, /, *, ?, #, \ or a non-ASCII character, or a field that is . or .. |
roomEndpoint / room_endpoint |
| Deadline | A number in (0, 2147483647] milliseconds in TypeScript, (0, 2147483.647] seconds in Python: the largest delay a JavaScript timer holds, and the same bound in both SDKs. The connection, request and write deadlines share one check. | not a number, including a boolean | zero, negative, NaN, infinite, or above the bound | connectTimeoutMs / connect_timeout on both connect calls; requestTimeoutMs / request_timeout; writeTimeoutMs / write_timeout on registerTrack / register_track |
| Track registration | A track name not yet registered on this publisher connection, including after its writer closed. Checked after the track name, properties and deadline, and after connection state. | a name already registered | registerTrack / register_track |
|
| Media track timing | A media kind, 'audio' or 'video'; every duration a positive integer number of microseconds (a safe integer in TypeScript, an int other than a boolean in Python) of at most 2147483647000, the deadline bound in whole milliseconds. Video needs a keyframe interval, and a target latency is at least the keyframe interval. A publisher priority, when given, is an integer from 0 to 255. |
a kind that is not a string; video without a keyframe interval; a duration or publisher priority that is not a number (TypeScript) or not an int, including a boolean and any float (Python) |
another kind; a target latency below the keyframe interval; a duration that is zero, negative or above the bound, and in TypeScript also fractional, NaN or infinite; a publisher priority below 0 or above 255, and in TypeScript also fractional, NaN or infinite | mediaTrackProperties / media_track_properties |
| Media subscribe parameters | A media kind, 'audio' or 'video'; overrides that are message parameters (an object in TypeScript, MessageParameters in Python); a subscriber priority in them, when given, is an integer from 0 to 255. |
a kind that is not a string; overrides that are not an object (TypeScript) or not MessageParameters (Python); a subscriber priority that is not a number (TypeScript) or not an int, including a boolean and any float (Python) |
another kind; a subscriber priority below 0 or above 255, and in TypeScript also fractional, NaN or infinite | mediaSubscribeParameters / media_subscribe_parameters |
| Object status | normal, end-of-group or end-of-track; defaults to normal. |
not a string | any other string | write, writeText, writeJSON on a group writer |
| Event capacity | A positive integer (a safe integer in TypeScript); defaults to 64. | not a number, including a boolean | zero, negative, fractional, NaN, or infinite | subscribeEvents / subscribe_events, namespaceEvents / namespace_events |
| Payload | A byte sequence: any ArrayBufferView in TypeScript; any object supporting the buffer protocol in Python (bytes, bytearray, memoryview, …). Copied once. At most limits.maxObjectPayloadBytes. |
a string, an ArrayBuffer, a list of integers, anything else |
longer than the object payload limit | write, writeText, writeJSON on a track or group writer |
Messages are not a compatibility contract. The class and the point of rejection are.
Subscribing
Section titled “Subscribing”- Namespaces are ordered tuples of fields sent verbatim. The SDK adds no prefix and performs no
normalization. The relay resolves a namespace relative to the endpoint URL’s path. With no path
(
https://relay.mux.global), fields are absolute: the token’s root prefix is part of the namespace the application supplies. With a path the token grants, such as a room-rooted endpoint whose path is the token’s root (https://relay.mux.global/acme/rooms/42), the path is the prefix and the application supplies only the fields below it:['p', 'a']there addresses what['acme', 'rooms', '42', 'p', 'a']addresses on the bare host. The room-rooted endpoint is the recommended form: namespaces leave the token’s root out, and a connection is confined to the path it dialed. The bare host suits a client that spans several rooms under one grant, such as a lobby that discovers every room. Both forms are supported, and they meet on the relay: a subscriber on either form receives a track published on the other, and discovery on a room-rooted connection names namespaces relative to its path, as its prefix is. A connection to a room-rooted endpoint cannot address anything outside that room: an absolute namespace there names one below the room (['acme', 'rooms', '42', 'acme', ...]), so a subscribe reaches only a publisher that announced that longer namespace, and is refused as unroutable when there is none. A..field is an ordinary field. An endpoint path the token does not grant fails the connection withUNAUTHORIZED, where on the bare host a namespace outside the grant fails only its own subscribe, and its publish never announces (issue #119). The token remains the access boundary: the path confines a connection, not its token, which can dial any path it grants. Measured on*.relay.mux.global(moq-relay 0.17.0) on one relay and across regions, on 2026-10-05 and again on 2026-10-20 in both SDKs over every transport, which the live smoke test now repeats (issue #266). Namespaces and track names follow the Arguments table in both roles: an invalid one is rejected locally, before connecting a publisher or sending a subscription. roomEndpoint(endpoint, room)/room_endpoint(endpoint, room)builds the room-rooted endpoint, so an application does not assemble the URL itself. Given only the room, it rootsDEFAULT_ENDPOINT(room-rooted endpoints). A connect call given no endpoint dials the room-rooted endpoint of its token’s root onDEFAULT_ENDPOINT(token-rooted endpoint), so the shortest call is the recommended form.- A known track can be subscribed directly. The SDK does not interpret payloads and does not assume a track carries audio, video, or timestamps.
- Object callbacks run as the stream layer delivers objects. The callback path adds no application queue, no ordering across streams, no automatic payload decoding, no clock synchronization, and no media scheduling.
- Each new subscription is independent. Re-subscribing does not resume the preceding request. Relay caching and location filters can deliver the same object again across subscriptions, including after a publisher restart. Applications own retry backoff and any cross-subscription continuity policy. See publisher restarts and resubscription.
- A subscription without a location filter, whether it sends no LOCATION_FILTER or an explicit zero-field one, carries the draft’s meaning: it passes all Objects published or received via upstream subscriptions (draft-22 Section 3.3.1). Whether that reaches back into what a relay cached before the subscription is the relay’s choice. The Mux relay (moq-relay 0.17.0) starts it at the beginning of its latest group, and starts a Next Object filter after its largest object, skipping the rest of that group (late subscribers). A subscriber that needs earlier groups asks for them with a relative-group or absolute filter. An absolute start is served from the requested group while the relay still caches it. The SDK sends the filter it is given and adds none.
- The SDK sends every subscription parameter the application sets. Which ones take effect is the relay’s choice: moq-relay 0.17.0 ignores GROUP_ORDER, NEW_GROUP_REQUEST and the delivery timeouts on a SUBSCRIBE, and refuses FORWARD = 0 and range filters (subscription parameters). The SDK does not warn about an ignored parameter.
- One parameter is the exception: MAX_AGE (
0x915C6), the MoQ Timestamp extension’s subscriber max age, whichmaxAge/max_agecarries in milliseconds (0 to 2^53-1). The extension forbids sending it to a peer whose SETUP did not declare its MAX_AGE option (0x915C4), and on draft 17 and later a relay that does not know the parameter fails the SUBSCRIBE. So both SDKs send it on SUBSCRIBE and REQUEST_UPDATE only when the relay’s SETUP declared the option (any value: the value is reserved), leave it off otherwise and on a request issued before the relay’s SETUP arrived, and never send it on FETCH, TRACK_STATUS or SUBSCRIBE_NAMESPACE, for which it is not defined. The caller’s parameters are not changed.maxAgeSupported/max_age_supportedon the subscriber connection says whether the relay declared it; it is known beforeconnectRelaySubscriber/connect_relay_subscriberreturns and does not change. Neither SDK declares the option itself: it would invite a relay to send MAX_AGE to an SDK publisher, which does not act on it. moq-dev main declares it and enforces the budget per subscription; moq-relay 0.17.x does neither (subscriber max age). Implemented in both SDKs. - The request timeout bounds only the subscription’s initial response. An accepted subscription
has no application inactivity deadline. The SDK schedules no application-level keepalive: keeping
the transport connected is left to peer traffic and the transport’s own keepalive, which
draft-22 Section 15.6.1
names as one way to keep an idle session open. In a browser that is the browser’s own QUIC PING,
and
@caprid/relay-quichas quinn send a keep-alive every 4 seconds. Python’s native QUIC transport sends a QUIC PING once it has sent nothing for 5 seconds. That PING also lets a receive-only connection survive a NAT rebinding: the relay learns the new address from it. The Python transport uses aioquic’s default 60-second local idle timeout, subject to peer negotiation. update()sends REQUEST_UPDATE, which the draft allows only on an accepted subscription (draft-22 Section 3.1). An update issued before acceptance is queued in order and sent when SUBSCRIBE_OK arrives. At most eight wait, and a further one raisesUPDATE_QUEUE_FULLwithout sending anything. A subscription that ends before acceptance discards its queue, and its ending is what the application observes. An update on an ended subscription raisesCLOSED. Each update consumes its own Request ID. A relay that acknowledges an update with REQUEST_OK reachesonUpdateOk/on_update_ok. One that refuses it with REQUEST_ERROR ends the subscription (issue #94). Implemented in both SDKs. moq-relay, however, never answers a REQUEST_UPDATE: any byte on the request stream after SUBSCRIBE_OK ends the subscription with PUBLISH_DONETRACK_ENDED, so against it an update ends delivery (relay interop deviation 4). REQUEST_UPDATE carries no track either, so it cannot change tracks against any relay. A switchable subscription changes the track, the filter or the priority with a new subscription, make-before-break.- A subscribe for a namespace the relay has not seen announced is rejected, not parked: moq-relay
0.15.8 and 0.17.0 answer request error 16 (0x10,
DOES_NOT_EXIST) with reasonunroutable, and moq-relay through 0.15.1 answered its literal 404. The same code 16 with reasonnot foundmeans the namespace is announced but the track is not, which is not the race. The relay routes subscribes to a namespace only once it has processed the publisher’s announce, about one round trip after the publisher’sannouncedsettles, and later on a subscriber’s relay that learns the route from a cluster peer (announce propagation), so a subscribe that races the announce is rejected the same way. The SDK reports that rejection like any other, throughsubscribedandon_error, and does not retry a subscribe. An application avoids the race by subscribing once its discovery reports the namespaceannounced, or else retries the rejection within its own budget rather than treating it as fatal. A supervised subscriber’s want retries it: 16unroutableand the older 404 without reporting them, never treating 16not foundas the race (issue #136). - The SDK surfaces every subgroup reset it receives, and connection closure, explicitly. It surfaces a reset when the relay forwards it, which may be well after the publisher’s abort: moq-relay 0.15.8 forwarded each one only when the publisher’s next group began, about 0.5 s later, or 3 s after a 3 s publisher pause. It cannot surface a reset the relay does not forward: a relay may drop a subgroup that was reset upstream before it began forwarding it (seen before 0.15.8, unverified on it), and then the subscriber sees neither objects nor a reset for that group, only a gap in group IDs that cannot be told apart from a group never published (subgroup resets). There is no exactly-once or gap-free delivery promise.
- The publisher’s done notification is a control message and may arrive before trailing objects
still in transit. It does not end the subscription: the request stays open until the application
cancels it or the connection ends, and the SDK does not resubscribe on its own.
It is not a prompt publisher-death detector. The SDK reports it when the relay sends it. On
moq-relay 0.17.0 a publisher’s clean close, or its connection closing, reaches existing
subscriptions within 0.1 s, as status 2 after
close()and as 0 when the connection drops without MoQ cleanup. A crashed publisher’s arrives as status 0 only after the relay’s QUIC idle timeout, about 11 s (it was about 30-35 s on 0.15.8, whose idle timeout was 30 s). - Replacing a publisher on the same namespace does not hand off existing subscriptions: moq-relay
0.15.8 and 0.17.0 never move them to the replacement. After a clean end, new subscriptions fail
with request error 16
unroutableuntil the replacement announces, and are served by it at once after that. After a crash, the relay still holds the dead publisher’s announcement until its QUIC idle timeout (about 11 s on 0.17.0), so new subscriptions are accepted and served only the old publisher’s cached last object. On 0.17.0 that can continue after the replacement announces, until the timeout, because the relay does not prefer the newer announcement (duplicate announce). Re-subscribing to a track that finished while its namespace stays announced replays the cached last group and another done, one round trip later each time, because the relay keeps a finished track cached by design. Once nobody has read it for about 30 s, moq-relay 0.17.0 forgets it and a subscribe gets request error 16not found. Do not re-subscribe to a track the publisher ended. A done that followed the subscription’s ownupdate()is different: the track is live, and re-subscribing is how to apply the change against moq-relay (publisher restarts). Applications own freshness checks and bounded recovery. See publisher recovery. - Subscriptions cancel independently of each other. The application owns connection shutdown.
subscribe()returns aRelaySubscription: the request handle plus two awaitables.subscribedsettles when the relay answers, andendedsettles once when the subscription can no longer deliver. Neither implies exactly-once or gap-free delivery, and publisher completion settles neither. The table below is the shared contract for both.
Subscription lifecycle
Section titled “Subscription lifecycle”The application’s subscription (see CONTEXT.md) is pending until the relay answers, accepted after
SUBSCRIBE_OK, and ended once it can no longer deliver. Every ending is recorded as a
SubscriptionEnd with a kind (rejected, terminated, cancelled), the protocol cause
(request-error, connection-closed, cancelled), whether the subscription had been accepted,
and the error. Implemented in both SDKs.
| State | Event | subscribed |
ended |
Callbacks |
|---|---|---|---|---|
| pending | SUBSCRIBE_OK | resolves with the acceptance info | stays pending | onSubscribed |
| pending | REQUEST_ERROR: rejection, request timeout, or the request stream failing before an answer | rejects SubscriptionRequestError, preserving the request error |
resolves kind: rejected, cause: request-error |
onError |
| pending | connection ends | rejects SubscriptionConnectionClosedError, preserving the connection error as its cause when known |
resolves kind: terminated, cause: connection-closed, accepted: false |
nothing on the subscription; the connection’s closed carries the reason |
| pending | cancel(reason) |
rejects with reason, or a CLOSED Relay error when none was given |
resolves kind: cancelled, with the reason as error |
nothing |
| accepted | objects, subgroup end or reset, state notify, update acknowledged (moq-relay never acknowledges one, see deviation 4), GOAWAY on the request stream | settled | stays pending | the matching callback |
| accepted | PUBLISH_DONE | settled | stays pending: publisher completion is not an ending, trailing objects can still arrive | onPublisherDone |
| accepted | REQUEST_ERROR, which is how a relay refuses an update() (issue #94); moq-relay sends none and ends the subscription with PUBLISH_DONE instead (deviation 4) |
settled | resolves kind: terminated, cause: request-error, accepted: true |
onError |
| accepted | connection ends | settled | resolves kind: terminated, cause: connection-closed, accepted: true |
nothing on the subscription; the connection’s closed carries the reason |
| accepted | cancel(reason) |
settled | resolves kind: cancelled |
nothing; trailing objects stop |
| ended | anything, including a second cancel() |
settled | settled, unchanged | nothing; update() raises CLOSED; the events iterator has already raised or reached EOF |
The core fails a pending request before it reports the closure that killed it: in the same turn
when the session notices the closure, or turns earlier when the transport fails its streams before
the session learns of it. A pre-acceptance request failure is therefore never recorded before the
turn ends, and one that arrives after the transport has already ended is left to the closure, so
the closure is the ending the subscription describes, with the connection error preserved. A
genuine rejection reaches subscribed, ended and onError one turn after the core delivers it.
A REQUEST_ERROR after acceptance is recorded at once.
Python asks the transport whether it has already ended. A browser’s WebTransport reports its end
only through the closed promise, and Chromium settles it a task or more after it aborts the
request stream of a connection lost while that stream opens
(issue #102). So TypeScript holds a
pre-acceptance failure the core synthesized from a stream event (FIN before a response, a stream
reset or a failed stream open, but not the response timeout) until the transport’s closed settles, and
records it as a rejection only if the connection is still up 250 ms later. A REQUEST_ERROR message
and the response timeout keep the one-turn hold.
A readiness awaitable nobody awaits cannot surface as an unhandled rejection: the SDK pre-handles it. Awaiting it later still observes the same result.
Room-rooted endpoints
Section titled “Room-rooted endpoints”roomEndpoint(endpoint, room) / room_endpoint(endpoint, room) returns the room-rooted endpoint
for a room, the namespace prefix its path names
(issue #348). Given only the room,
roomEndpoint(room) / room_endpoint(room) roots DEFAULT_ENDPOINT, the
relay.defaultEndpoint pinned in protocol.json. It is a pure function: it reads no
token, opens no connection and changes nothing about connect*. The cases live in
vectors/room-endpoint.json and run in every SDK’s suite.
- Validation. The endpoint follows the Endpoint rule and the room the Namespace rule of the
Arguments table, with the same error classes, endpoint first. A room field must
also be one or more of
A–Z,a–z,0–9and-._~!$&'()+,;=:@, and not.or... Anything else is aRangeError(TypeScript) orValueError(Python) that names the field. - No encoding. The helper percent-encodes nothing. moq-relay takes each segment of the path
as a namespace field without percent-decoding it, so
a%20b,a%25b,a%2Fband%C3%A9root a connection at fields that still hold the%sequence, not ata b,a%b,a/boré(endpoint paths). An encoded/is therefore neither a separator nor a refusal: it is part of a literal field. A browser’s URL parser always encodes a space or a non-ASCII character and folds.and..segments into the path, and moq-relay refuses a path containing*withUNAUTHORIZED. No URL can root a connection at a field like these, so the helper refuses them. A namespace with such a field is still valid, and the application reaches it from the bare host with absolute fields. - Path. The result keeps the endpoint’s scheme, host and port, as the language’s URL parser
writes them. It takes the endpoint’s path without its trailing slashes and appends
/and each room field, sohttps://relay.exampleandhttps://relay.example/give the same URL. An existing path stays in front of the room: withhttps://relay.example/acmeand['rooms', '42']the result ishttps://relay.example/acme/rooms/42. - Query. The endpoint’s query is kept unchanged after the new path, including a present but
empty
?. Ajwtparameter stays in the query, never in the path, and the connect calls read it there as usual. The endpoint has no fragment to keep, because the Endpoint rule refuses one.
Token-rooted endpoint
Section titled “Token-rooted endpoint”A connect call given no endpoint (url left out in TypeScript, None in Python) dials the hosted
Relay rooted at its token’s root (issue #374).
This holds for connectRelaySubscriber, connectRelayPublisher, connectSupervisedSubscriber and
connectSupervisedPublisher, and their Python spellings. The cases live in
vectors/token-rooted-endpoint.json and run in every SDK’s suite.
- Endpoint. The connection runs the token provider as usual, then reads the token’s
rootclaim without verifying the token. The relay verifies it, so a root the token was not signed with fails at connect withUNAUTHORIZED, as a wrong path does. The root is a/-separated path: without its leading and trailing/, its fields are the room, and the connection dials whatroomEndpoint(DEFAULT_ENDPOINT, fields)/room_endpoint(DEFAULT_ENDPOINT, fields)returns, with the token injwt. Namespaces are then relative to the root, as on any room-rooted endpoint. An empty root, a grant of the whole relay, dials the bareDEFAULT_ENDPOINT: namespaces relative to an empty root are absolute. - Credential. A
RelayCredentialroots the endpoint at itstoken’s root. The token’s expiry is read as on any connection (credential lifetime). - Failures. A missing token provider, no token, a token that is not a JWT with a JSON object
payload, a
rootclaim that is missing or not a string, and a root the Room rule refuses all fail locally, before the transport factory runs, by the Token root row of the Arguments table. The message says to pass the endpoint instead. - Supervisors. A supervised connection given no endpoint keeps the endpoint its first
connection dialed once that connection is up, and every reconnect and rotation dials
it with a fresh token. A later token whose grant does not cover that path fails
UNAUTHORIZEDinstead of moving the connection to another room. Until the first connection is up, nothing has joined a room, so each attempt roots the endpoint at its own token. - Scope.
rootis moq-token’s claim, which the hosted Relay’s tokens carry. Any other relay, or a room other than the token’s root, takes an explicit endpoint, which the connection uses as given.
Async subscription consumption
Section titled “Async subscription consumption”The Relay product API provides subscriber.subscribeEvents(options, { capacity, signal }) in TS and
subscriber.subscribe_events(namespace, track, capacity=..., cancellation=...) in Python. These create
and own a new subscription immediately. They yield typed data and lifecycle events, using the same
payload objects as callbacks. The generic MoqTrackSource and existing subscribe callbacks remain
available and retain their synchronous delivery model. The handle’s subscribed and ended
awaitables (see the subscription lifecycle table above) are the readiness and termination signals.
The iterator’s raise or EOF is its own. It follows the product vocabulary from #63. Its explicit
publisherDone / publisher_done event distinguishes publisher completion from final delivery.
capacitydefaults to 64 events and must be a positive integer (a safe integer in TS). Every queued object and lifecycle event consumes one slot. This is an event-count limit, not a byte budget: an object may contain up to 16 MiB of payload and 1 MiB of encoded properties, plus decoded metadata overhead. Choose a smaller capacity for large objects. Protocol decoding limits also bound individual control events. The queue owns at mostcapacityevent references. Objects retained by application code and transport buffers are outside this limit. An event handed directly to a waiting reader is delivered and no longer counts against queue capacity, even before that reader resumes. Later teardown cannot retract that handoff.- Overflow fails immediately with
SubscriptionOverflowError, discards the whole queue, cancels only the owned request and its active subgroup readers, and detaches observers. It does not silently drop objects and continue. Buffered data is also discarded on any other termination. These limits provide local buffering, not transport-wide backpressure. - Events preserve callback arrival order. They do not sort groups or invent an order across streams. No exactly-once, gap-free, or fully drained delivery guarantee is implied.
| Event / condition | Iterator result |
|---|---|
| Subscription accepted | subscribed, with SubscribeOk |
| Object (including protocol object status) | object, with MoqtObject |
| Publisher control completion | publisherDone / publisher_done, with PublishDone, including status, stream count and reason; iteration stays open for trailing objects |
| Subgroup FIN | subgroupEnd / subgroup_end; only that subgroup ended |
| Subgroup reset | subgroupReset / subgroup_reset; a gap may have occurred; iteration continues |
| Publisher state change | stateNotify / state_notify, with changed parameters |
| Update acknowledged | updateOk / update_ok |
| Request GOAWAY | goaway, with relocation details; informational, no automatic retry |
| Request rejection, initial-response timeout, or initial request failure | Raises SubscriptionRequestError, preserving requestError / request_error |
| Connection ends at any point, including a clean local close | Raises SubscriptionConnectionClosedError, preserving the connection error as cause / __cause__ if available; never a claim of drained EOF |
Iterator close() / TS return() / Python aclose() |
Discards buffered events, cancels the owned subscription, pending reads settle as clean EOF |
| Per-iterator abort signal / cancellation token | Discards buffered events and raises the supplied reason (or the platform’s default cancellation reason) |
Before acceptance, a connection failure reaches the core’s request-error callback before the
connection observer. The subscription holds that failure until the turn ends (see “Subscription
lifecycle”), so the iterator raises SubscriptionConnectionClosedError with the connection
exception preserved, the same as after acceptance, rather than a SubscriptionRequestError
carrying the core’s reason string.
The iterator errors are RelayError subclasses with stable codes: SubscriptionRequestError
uses SUBSCRIPTION_REQUEST_FAILED, and SubscriptionOverflowError uses SUBSCRIPTION_OVERFLOW,
both in the subscription phase. SubscriptionConnectionClosedError uses CLOSED in the
connection phase and retains the underlying connection failure as its cause when present.
The callback API still receives unwrapped MOQT RequestError values.
A failure remains observable on subsequent reads. The first termination wins. Do not close the shared connection merely to stop this consumer. Publisher completion never auto-cancels or resubscribes. Choose a timeout or explicit application policy if no more data is expected.
Only one pending iterator read is supported. A concurrent read fails without disrupting the first.
It is a state error, not an argument error: a plain Error in TypeScript and a RuntimeError in
Python, and not a RelayError.
There is no background consumer task. TS for await calls return() on break or a
consumer-body exception. Use a finally { events.close(); } if cancelling while the body awaits
other work. A per-iterator signal can also interrupt a pending read. Python requires async with events: (or try/finally: events.close()) around async for for deterministic cleanup on
early break, consumer exception, or task cancellation in the loop body: Python’s loop alone does
not call aclose(). Cancelling a Python task blocked in anext() also closes the iterator.
Consequently, asyncio.timeout() or asyncio.wait_for(anext(events), ...) expiring cancels the
subscription. The caller receives TimeoutError and subsequent reads return EOF. Use those
deadlines to end consumption, not to skip an idle interval and resume the same subscription.
In TS, Promise.race does not cancel the losing read: use the iterator’s abort signal to end
consumption, or retain and await that same pending read before requesting another. Consumer
exceptions propagate to the caller. The SDK never retries or swallows them. Stop using abandoned
iterators explicitly. Garbage collection is not the ownership policy. Connection observation and
cancellation listeners are removed on teardown, and pending reads settle.
Synchronous callback policy
Section titled “Synchronous callback policy”Subscription callbacks must be synchronous in both APIs, including the generic core. A declared
async callback is rejected with TypeError during subscribe, before allocating a request ID.
For a synchronous function that returns an awaitable dynamically, Python emits RuntimeWarning,
closes a returned coroutine, or cancels a returned Future/Task and observes its eventual
exception. Other custom awaitables are diagnosed and discarded without running them. TS reports a
TypeError to console.error once per handler per subscription and observes every returned
promise’s rejection. JS promises cannot be cancelled, so already-started work may continue.
Delivery never awaits these results, and such callbacks are unsupported. Ordinary synchronous
return values remain ignored, and ordinary synchronous callback exceptions retain the existing
callback error behavior. Use the iterator for awaited work. If starting independent work
deliberately, use a synchronous callback that returns nothing and arrange its task lifetime and
error handling explicitly.
Handlers are validated and snapshotted when subscribe is called. Set them all before subscribing.
Later assignments to the original handler object do not change an existing subscription. Request
rejection and local/session teardown now cancel the request stream and active subgroup readers.
Teardown suppresses subsequent end/reset callbacks and protocol errors from those cancelled readers.
It does not report cancellation as a network gap. An actual reset while the subscription is active
still invokes the gap callback. Publisher completion leaves readers active for trailing objects.
Switching tracks
Section titled “Switching tracks”Changing what a viewer receives (a simulcast or ABR layer, a location filter, a priority) takes a
new SUBSCRIBE: draft-22 REQUEST_UPDATE names no track, and moq-relay ends a subscription on any
update. The draft recommends subscribing to the alternate track before cancelling the old one
(draft-22 Section 3.7.2).
subscriber.subscribeSwitchable(options, handlers) / subscriber.subscribe_switchable(namespace, track, handlers, parameters=...) does that for the application
(issue #264). Implemented in both SDKs, on the
primitive subscriber. A supervised want moves the same way when it follows a newer generation or
is wanted again with another track or parameters (moves).
-
One subscription at a time is active, and only its events reach the handlers. Each handler gets the active track’s name as its last argument. Every subscription underneath is an ordinary subscription with the lifecycle above. The handle has no
update(). -
switchTo(track, { parameters, until, drainMs, signal })/switch_to(track, parameters=..., until=..., drain=..., cancellation=...)subscribes totrackin the same namespace. The track may be the active one, which is how to change parameters.parametersare not inherited: omitted means none. The new subscription is pending, and the active one keeps delivering. The arguments, the connection and the switchable’s own state are checked at once. A failed check leaves any pending switch alone. -
Cutover. Each object of the pending subscription is offered to
until.'first-object'(the default) accepts the first object. A synchronous predicate accepts each object it returns true for. Objects it refuses, and the pending subscription’s other events, are discarded, with one exception. Its objects, subgroup ends and resets are held (the latest 256), and those that come after the accepted object in its own subscription follow it. Those are its later groups, and its group’s later objects, subgroup ends and resets. Across relays a later group can arrive before the object that cuts over, and the predicate refuses it as too far ahead (issue #282). On the first accepted object:- The old subscription is cancelled.
onSwitched/on_switchedruns with the old track, the new track and the new subscription’s SUBSCRIBE_OK.- The accepted object is delivered, then the held events that come after it, in arrival order.
switchToresolves with the same record. -
Avoiding a gap needs a timeline. The SDK knows no media time, and group IDs are not one. Each track numbers its own groups, and the SDK’s publisher counts them from the track’s first sent group, so layers that gained demand at different times disagree. An application that must not skip media supplies a predicate. It compares the object’s own timeline (a timestamp property, a frame number) with what the active subscription delivered, and accepts an object within a few frames of it. The same check refuses a stale first object. The relay starts a subscription at its cached latest group, and that group is old if the publisher skipped the track while nobody watched it. A subscriber on another relay can also lose the first object altogether (issue #243).
-
Drain. Groups arrive out of order across streams, especially through another relay, so an older group of the old track can still be in flight at the cutover.
drainMs/drain(default 0) keeps the old subscription open for that long. It takes 0 up to the bound every SDK deadline has (2147483647 ms, 2147483.647 s), the longest a JavaScript timer holds. Beyond it, or negative, is aRangeError/ValueError, and a non-number aTypeError. While it drains, its objects, subgroup ends and resets still reach the handlers, with the old track’s name, if they belong to a group no later than the largest it had delivered. Its later groups and other events are discarded. When the time is up, it is cancelled. An old subscription that had delivered nothing is cancelled at once. -
A failed switch leaves the active subscription delivering. A switch fails, reaching no handler, when:
- The pending subscription ends before its cutover. The switch fails as
subscribedwould. - A newer
switchTosupersedes it. The switch fails withCLOSED. - The signal or token cancels it. The switch fails with the signal’s or token’s reason.
- The predicate throws. The switch fails with that error.
- The pending subscription ends before its cutover. The switch fails as
-
Endings.
subscribedsettles with the SUBSCRIBE_OK of the first subscription to become active. If the switchable ends before that, it fails as the first subscription’ssubscribeddoes.endedsettles with that subscription’sSubscriptionEndwhen the active subscription ends other than by a cutover, or oncancel(). A REQUEST_ERROR ending also reachesonErrorwith the track.- Any pending switch then fails, with
CLOSEDor with the connection error if the connection ended. Any drain stops. - Publisher completion is not an ending, as for any subscription.
switchToon an ended switchable raisesCLOSED.
-
Measured on moq-relay 0.17.0 at
*.relay.mux.global(2026-10-06). The test switched between two layers written frame for frame every 50 ms, with a frame-window predicate and a 1 s drain. The cutover came about 140 ms afterswitchTowith publisher and subscriber on one relay (sjc), and about 1 to 1.6 s with the subscriber on another (fra). The live smoke test asserts that no frame is missing across the cutover, in both runtimes of each SDK. Without the predicate and drain, a'first-object'switch skipped a frame in most cross-relay runs.
Discovery
Section titled “Discovery”A subscriber can learn which namespaces under a prefix are announced, without an application
channel of its own. subscriber.namespaceEvents({ prefix, capacity, signal }) in TS and
subscriber.namespace_events(prefix, capacity=..., cancellation=...) in Python send one
SUBSCRIBE_NAMESPACE for the prefix. They return a bounded iterator with the same ownership,
capacity, overflow and cancellation rules as async subscription consumption.
The iterator owns the request, never the connection.
-
Each event is
announcedorunannounced, with the full namespace: the prefix’s fields followed by the rest, sent verbatim. The relay names each namespace relative to the prefix, and the SDK joins the two. A namespace equal to the prefix is reported as the prefix itself. -
The events are edge-triggered per namespace. Draft-22 lets a relay name a namespace again, and moq-relay does when its route changes. Those repeats are dropped, so an application that tracks the
announcedset holds the relay’s current view. A NAMESPACE_DONE for a namespace the relay never named closes the connection withPROTOCOL_VIOLATION, as draft-22 §9.15 requires. -
The prefix follows the namespace argument rules (1 to 32 non-empty fields), checked before connection state. Draft-22 also allows an empty prefix, meaning every namespace. The SDK does not. The prefix matches whole fields:
['room', 'a']does not cover['room', 'ab']. -
One connection discovers each prefix tree once. Draft-22 §9.15 has a relay answer a SUBSCRIBE_NAMESPACE whose prefix overlaps one already open on the session (equal, shorter or longer, field by field) with PREFIX_OVERLAP. moq-relay 0.17.0 does not enforce this, so the SDK refuses such a prefix itself, with
PREFIX_OVERLAPin phasesubscription, before anything is sent, and behaves the same against every relay. The prefix is free again once its iterator has ended. To watch both['room']and['room', 'a'], discover['room']and filter. -
The subscriber connection’s SETUP carries the SOLICIT option (
0x40B5A= 1, draft-lcurley-moq-solicit-00), so the relay advertises namespaces only on these requests. Without it, moq-relay 0.17.0 pushes an unsolicited PUBLISH_NAMESPACE for everything in scope right after SETUP, and a SUBSCRIBE_NAMESPACE stream then carries nothing. The publisher connection does not send SOLICIT: it never asks for namespaces. -
The relay sends no marker between the initial snapshot and later changes. Measured on moq-relay 0.17.0 at
*.relay.mux.global(2026-10-06):What Same relay (sjc) Subscriber on another relay (fra) Already-announced namespaces arrive after the request ~50 ms ~110 ms A new announce arrives after the publisher’s announcedsettles~50 ms ~140 ms unannouncedafter the publisher’s cleanclose()~50 to 300 ms ~150 to 400 ms unannouncedafter the publisher process is killed~12 s (QUIC idle timeout) ~12 s An announce learned from a cluster peer looks the same as a local one. Nothing in the event says which relay holds the publisher.
-
A prefix the token does not cover is accepted and stays silent. The relay does not reject it. An event is about the relay’s routing table, not a publisher’s health:
announceddoes not mean any track has content, and a namespace two publishers announce (duplicate announce) is reported once. -
Discovery is how a subscriber avoids the announce race. A subscribe sent on the same connection once
announcedarrives was never rejectedunroutableon moq-relay 0.17.0 (0 of 160 trials on 2026-10-06, both SDKs, same relay and another relay), while one sent as soon as the publisher’sannouncedsettled was rejected 6 to 14 of 40 times on the same relay and 40 of 40 on another (announce propagation). This is an observation of that relay, not a protocol guarantee. A subscriber that waits forannouncedneeds no timed retry for the race. A laterunroutablemeans the namespace has gone since.unannouncedfollows, and the nextannouncedis the cue to subscribe again.not foundstill means the track is not there. -
Endings: a REQUEST_ERROR, the request timeout, or the request’s stream failing raises
SubscriptionRequestError. A stream that fails without a REQUEST_ERROR first waits up to the same closure grace as a subscription. The connection ending raisesSubscriptionConnectionClosedError, a full queueSubscriptionOverflowError, and the signal or cancellation its reason. A relay that finishes the request cleanly ends the iterator without an error, afterunannouncedfor every namespace still announced (draft-22 §9.15 treats the end of the response stream as a withdrawal of each), and after any events still queued, which a clean end does not discard. After an error ending, treat every namespace as withdrawn. A GOAWAY on the request stream is not surfaced. The connection’s GOAWAY ends the connection.
Supervised connections
Section titled “Supervised connections”The primitives above never reconnect or retry. Applications can opt into a supervision layer
built only on them: connectSupervisedPublisher / connect_supervised_publisher and
connectSupervisedSubscriber / connect_supervised_subscriber. It owns the recovery policy in
publisher recovery, so live applications don’t each write it again
(issue #260). Neither supervisor changes what
the primitives do. Python gives the backoff bounds and stale_after in seconds, and its want
handlers take the namespace as a second argument.
Both supervisors:
- Resolve once their first connection is up. A first attempt that fails on its arguments, or
because the relay refused the credentials (
UNAUTHORIZED), rejects with that failure. Any other failure is retried with backoff, bounded only by the cancellation primitive, whose abort rejects with its reason. After the first connection, every ending is retried,UNAUTHORIZEDincluded, since the token provider runs again on each attempt. - Reconnect whenever the connection ends without the application closing it: error, GOAWAY, or a clean close by the relay. The delay starts at 500 ms and doubles up to 10 s. Each delay is drawn from the upper half of its ceiling (jitter), and the backoff starts over after a connection that stayed up longer than the longest delay. Both bounds are options.
- Report
connecting,live,reconnectingandclosedthrough a status callback, with the error that ended the previous connection or failed the last attempt.closedsettles once the supervisor stops:close()(no error), the cancellation primitive (its reason), or a first attempt that is not retried (that failure). It never rejects. A rotation reports nothing: the status stayslive. - Replace their connection before its credential expires, make-before-break (rotation).
- Given no endpoint, dial the token-rooted endpoint their first connection
dialed, once it is up, for every later connection, so a later token whose grant does not cover it
fails
UNAUTHORIZEDinstead of moving to another room.
Rotation
Section titled “Rotation”A rotation replaces a supervisor’s live connection make-before-break, so that no connection reaches its credential’s expiry (credential lifetime) and viewers see a switch instead of a drop. It is not a reconnect: the old connection keeps serving until the new one has taken over.
- When.
rotateBeforeMs/rotate_before(default 60 s) before the live connection’sexpiresAt/expires_at, but never earlier than halfway through the time left when it connected, so a credential shorter than the margin still serves half its life. A connection whose expiry is unknown never rotates on its own.rotate()rotates now, for an application that revokes credentials or wants a fresh token. It is a no-op while no connection is up. - The replacement connects with the same options, so the token provider runs again. A failed
attempt is retried with the reconnection backoff while the old connection lives. If the old one
ends first, the supervisor reconnects as after any other ending. When a rotation the supervisor
started on its own gets a credential that expires no later than the live one, it discards the
replacement and lets the live connection run to its end: two connections the relay ends together
replace nothing.
rotate()always takes the replacement. - Overlap. The old connection closes once everything has moved to the new one, after 10 s at most, or sooner if it ends on its own.
- Publisher. The replacement is the next generation. Every registered track is registered on it,
and
onGeneration/on_generationruns for it once it is up.generationnames it from then on. Each track’s writer keeps writing to the old generation until the track has demand on the new one or has none on the old one, and then moves at the start of its next write or group, never under one: a group opened on the old generation finishes there. Subscribers following generations move to the new one when it is announced, which creates its demand.hasDemandanddemandChanges()follow the generation the writer is on, and report nofalsewhen demand carries over. They reporttrueagain on the move, as they do when the relay replaces its upstream subscription, so state an application writes on demand (a catalog) reaches the new generation. When the overlap runs out, every track moves regardless of demand, and the old generation closes asclose()closes it. - Subscriber. The replacement is a new connection, and every want moves its subscription to
it make-before-break, as a move does: the replacement opens pending on
the new connection while the old subscription keeps delivering. A want following generations
discovers the root on the new connection first. Then:
- Same target (namespace, track and parameters): the same publisher’s stream continues. The
replacement starts at the latest group, so its objects at or before the last location the old
subscription delivered are dropped, and the first one after it cuts over without
until. No callback marks it, noonSwitched,onRestartedoronSubscribed, and nothing is lost or repeated, so decoders keep their state. The old subscription is cancelled, not drained. Once any of the want’s subscriptions has heardPUBLISH_DONE, nothing is dropped: a restarted publisher numbers its groups from 0 again. - Another target, a newer generation announced on the new connection or a re-want: an
ordinary move, with
until, the drain andonSwitched. - The old connection closing under the old subscription leaves the replacement to carry on, and
a replacement refused or ended before its cutover is retried after the retry backoff, as a
move’s is. The new connection ending restarts the want with
connection. A want with no accepted subscription starts over on the new connection at once. A rotation to a connection that is itself rotated away before the cutover moves on to the newest one.
- Same target (namespace, track and parameters): the same publisher’s stream continues. The
replacement starts at the latest group, so its objects at or before the last location the old
subscription delivered are dropped, and the first one after it cuts over without
Supervised publisher
Section titled “Supervised publisher”- Each connection is a generation. It serves the application’s root namespace followed by one
generation field the SDK chooses: the start time in fixed-width base 36, then four random
characters. Generations therefore sort by age as strings. The token must grant the root. The
root plus one field must satisfy the namespace rules, checked when the supervisor starts.
generationexposes the live generation’s field and namespace, and a callback runs once each generation is up and every track is bound to it. - A fresh namespace per connection is what
publisher-recovery.mdrecommends for predictable handoff on moq-relay 0.17.0: a reconnect on the same namespace is a new publisher to the relay, and duplicate announces are ranked arbitrarily (duplicate announce). If the relay later supports taking over a route, the same API can keep one namespace across reconnects without applications changing. registerTrackchecks its options at once, under the same rules as the primitive’s, and returns a writer that outlives the generations. The supervisor registers the track again on every generation. Track names are unique per supervised publisher, including after their writer closes. A track closed by the application is never registered again.- Writes are never replayed. While no generation is up,
write,writeText,writeJSONandopenGroupfail at once withRECONNECTINGin phaseconnection, andhasDemandis false. An operation that the connection’s end interrupts also fails withRECONNECTING, neverCLOSED. A frame writer therefore treats it as an interruption, not a retirement, and resumes at the next keyframe on the next generation. The writer’sclosedis true only once the application closed the track or the supervisor stopped. demandChanges()spans generations. It reports the current state first,falsewhen a generation that had demand is lost, and the next generation’s changes after it. It ends when the track closes.close()stops reconnecting and closes the live generation, as the primitive’sclose()does.
Supervised subscriber
Section titled “Supervised subscriber”want(key, options, handlers)keeps a subscription to one track open untilunwant(key). Arguments are checked at once. Wanting a key again with the same namespace and generation-following updates the want in place. The handlers, the retry backoff, the stale threshold,untiland the drain are swapped. Another track or other parameters move the subscription (below). Another namespace or generation-following replaces the want.- Each want runs one subscription at a time on the live connection, two while a move is pending:
- Retried at once, without backoff: the connection ended (the want moves to the replacement connection), the followed generation was withdrawn with none newer, or a move came before the subscription was accepted.
- Retried after backoff (from 250 ms, doubling up to 8 s, with jitter): the announce race
(16
unroutable, or the older 404), every other rejection not listed below,PUBLISH_DONE, a REQUEST_ERROR after acceptance, and a subscription that went stale. Only an object from a later group than the subscription’s first resets the backoff. Cached objects followed by a done are not progress, so a finished track still announced is replayed no faster than the backoff allows (issue #21). - Surfaced, after which the want stops and is forgotten: a rejection that no wait fixes.
These are REQUEST_ERROR 0x1
UNAUTHORIZED, 0x3NOT_SUPPORTED, 0x4MALFORMED_AUTH_TOKEN, 0x5EXPIRED_AUTH_TOKEN, 0x11INVALID_RANGEand 0x12MALFORMED_TRACK, reported asSubscriptionRequestError. Discovery refusing the root is surfaced the same way, and so isPREFIX_OVERLAPbetween two followed roots on one connection.
staleAfterMs/stale_afterresubscribes when no object arrived for that long, measured from acceptance or the last object. It is for tracks that are never silent while live: after a publisher crash, moq-relay 0.17.0 can hold an accepted subscription on the dead publisher’s cached object for about 11 s. Without it, silence is not a failure.- Following generations. With
generations: true, the want’s namespace is a supervised publisher’s root. The supervisor discovers the root (discovery) and subscribes on the newest generation announced one field below it. Deeper namespaces are ignored. One discovery per root per connection is shared by every want on it. The want moves as soon as a newer generation is announced, even while the old one is still announced, as a crashed publisher’s is until the relay’s idle timeout. It also moves when the current generation is withdrawn and an older one is still announced. With none announced, it waits. No application channel is needed. - Moves are made before the break (issue #282),
as on a switchable subscription. A move is a newer generation, or a re-want
with another track or parameters. Once the current subscription has been accepted, the want opens
the replacement as pending while the current one keeps delivering:
- Each object of the pending subscription is offered to
until(WantOptions.until/until=).'first-object', the default, accepts the first. A synchronous predicate accepts each object it returns true for. The objects it refuses are discarded, except those that come after the accepted object, which follow it as on a switchable. A new generation’s namespace is fresh, so its first group is its publisher’s first. The first-object default is never worse than a break, and an application with a timeline passes a predicate that compares it. Group IDs are not a shared timeline: each generation and track numbers its own groups. A predicate that throws is logged and accepts the object, so application code never stalls a move. - Cutover. On the first accepted object, the old subscription is cancelled, or drains for
drainMs/drain(default 0) as a switchable’s does. ThenonSwitched/on_switchedruns with the old and new namespace and track and the new SUBSCRIBE_OK, and then the accepted object is delivered. A move runs neitheronRestartednoronSubscribed. - The old subscription ends first (
PUBLISH_DONE, as when a supervised publisher replaces its connection, a REQUEST_ERROR, or the stale threshold). The pending subscription carries on and cuts over on its next object, whateveruntilsays, since there is nothing left to compare.onPublisherDonestill runs for the old one. The stale threshold counts objects from either subscription. When it passes during a move, only the old subscription ends, and the replacement gets a full threshold of its own before the want restarts withstale. - The pending subscription fails before its cutover. A rejection no wait fixes is surfaced,
and the want stops, unless the key was wanted again with another target first. Then the want
reports
onRestartedfor the subscription it cancelled (its own ending’s reason, elseterminated), and tries the new target at once. Any other failure leaves the current subscription delivering, and the move is retried after the retry backoff, sooner if the want asks for something else. If the old subscription had already ended,onRestartedreports its reason instead, and the want resubscribes as after any restart. - A newer move supersedes a pending one. A re-want back to the current track and parameters
cancels the pending one. A move before the current subscription is accepted replaces it at
once, since there is nothing to overlap. The connection’s end takes both subscriptions, and
the want restarts with
connection.
- Each object of the pending subscription is offered to
- Catch-up (TypeScript only).
catchUp(key)replaces the want’s accepted subscription at once, break-before-make: it cancels it, which makes the relay drop what it still holds queued for it, and subscribes again with the want’s options, so a want with no location filter starts at the newest group.onRestartedreportscatch-up. The replacement opens without delay when the cancelled subscription had progressed (delivered an object from a later group than its first), and otherwise after the retry backoff, so a link too slow for the track cannot make it spin. It is ignored for an unknown key and while no subscription is accepted. During a move it ends both subscriptions, and the replacement is made for whatever the want asks for then. It exists because moq-relay 0.17.5 (measured) and 0.17.7 (read in source) drop nothing for a late subscriber, whatever delivery timeout the subscriber sends (relay-interop.md). It is meant to work with a relay that honours that timeout too (moq-dev/moq#5881): such a relay drops the late groups itself, and a catch-up then clears only what still arrives late. A frame reader’sonCatchUpis its usual caller (media subscribing). Measured on 2026-10-17 against a local moq-relay 0.17.5, with a 3 s outage on a 600 kbit/s link carrying a 400 kbit/s audio track (ts/scripts/probe-catch-up.mjs, two runs each): without catch-up, the listener got 401 and 407 of 1,200 frames: once delivery resumed (about 5 s after the link came back, as QUIC recovered), it got a few frames a second, about 800 ms late, to the end of the run 8 s later. WithcatchUpAfterUs300 ms and a late-drop limit of 100 ms, one catch-up followed the resumption, the listener got 796 and 801 of 1,200 frames, and latency was back at 21 ms within a second. - Handlers receive the current subscription’s objects, subgroup endings and resets, and its
PUBLISH_DONE.onSubscribedreceives the namespace the subscription was made on: the generation’s, when following one.onRestarted({ reason, namespace })runs when an accepted subscription ends and will be replaced, before any object of the replacement. Its reason ispublisher-done,stale,generation,connectionorterminated, or in TypeScriptcatch-up.generationremains for a withdrawal with none newer announced. Decoders drop state that assumed continuity there, and atonSwitched. Rejections before acceptance, the announce race included, never reach a handler unless they are surfaced. - Nothing is fetched to fill a gap. A replacement subscription after a restart starts live with
the want’s parameters, so objects published between two subscriptions are not delivered. A move
leaves no gap only as far as its
untiland drain allow. Trailing objects after aPUBLISH_DONEare discarded when the subscription is cancelled for its replacement. close()cancels every want and closes the connection.
Publishing
Section titled “Publishing”- Publishing uses a dedicated connection with a token authorized to publish the namespace. One publisher connection serves one namespace and any number of tracks.
- Publishing follows the relay’s announce-and-serve flow. Connecting a publisher announces its namespace. Registering a track makes it servable and sends nothing. The relay learns of the track when a subscriber asks for it. A write waits for the relay to subscribe upstream before sending bytes. A subscriber creates that demand. If namespace solicitations have arrived but none currently covers the publisher namespace when the demand deadline expires, the write reports a namespace coverage error instead of an ordinary demand timeout. Check the token and namespace before retrying. This does not prove authorization was denied: solicitations can arrive independently, and a later matching prefix permits writes.
publisher.announcedsettles once the namespace has been handed to the transport on a solicitation that covers it, no earlier than the first registered track. It is the strongest fact the publisher has: the relay acknowledges an announce with nothing and routes subscribes about a round trip later. It stays pending while no solicitation covers the namespace (the first write reportsNAMESPACE_NOT_SOLICITED), and the connection’s closure terminates it if that comes first, with the closure’s error orCLOSEDafter a clean shutdown.- Two publishers may announce the same namespace, and nothing warns either one or any subscriber. On moq-relay 0.17.0 the relay picks one of the two announcing connections for new subscribers, effectively at random (a hash of the namespace and each connection’s random hop id ranks the routes before their age does). The choice is stable while both stay connected, and the other publisher gets no demand at all. Existing viewers stay on the publisher that served them, and when it retracts they get done status 2 (or, if it crashed, done status 0 about 11 s later) and do not move. New subscribers then go to the remaining one. After a crash, new subscribers may be routed to the dead publisher and served its cached last object until the relay’s idle timeout. moq-relay 0.15.8 instead gave every new subscriber to the newest announcer. Neither SDK can detect which publisher serves a viewer (issue #37). Keep one publisher per namespace.
- The default write deadline is 10 seconds and covers both waiting for demand and the transport write.
- A timeout, cancellation, transport write failure, or relay subscription change resets the affected write’s stream and leaves the track open while the publisher connection remains open. A stream that opens after cancellation is reset without sending payload bytes. The next explicit write uses a new group ID. Failed groups are never reused. After demand disappears, later writes wait for a new relay subscription. Applications choose fresh data or the next random-access point after a failure. Already delivered bytes cannot be withdrawn. There is no automatic retry.
- A write’s payload must be a byte sequence in the language’s own terms, and its properties must follow the Arguments table. Anything else is rejected before the write starts, which is a local argument error and leaves the track usable. An oversized property is such an error too, not a transport failure once demand arrives.
- Each write copies its bytes and sends one independent group containing one object. Only one write may be pending per track. A concurrent write is rejected rather than queued.
- Write completion means the transport accepted the complete group, not that any viewer received
it. The transport accepted the group once its FIN was handed to the transport, not once the relay
acknowledged it, so a track is not limited to one group per round trip. Until that
acknowledgment the group is a settling group (see
CONTEXT.md). Closing a track does not wait for its settling groups. A settling group is never reset when the relay subscription changes or ends. Only its own delivery timeout resets it. One that fails later (STOP_SENDING, a reset, its delivery timeout, the session ending) is dropped without an error on the track, and frees its place under the limit below: the cause surfaces as demand, a new relay subscription, or the connection’s closure. While a publisher connection has 64 settling groups, the next write or group waits inopening-streamunder its write deadline before opening its stream, so a stall is aWRITE_TIMEOUT, not a transport failure. Open groups do not count toward that limit. This also bounds how much a Python publisher accepts into a dead uplink, since its writes complete once the transport has sent the FIN.publisher.close()waits for the connection’s settling groups for up to the largest write deadline among its tracks before it ends the session. In Python the same wait also covers each ended track’s PUBLISH_DONE (see deviation 2). Delivery timeouts end some settling groups sooner and leave this bound unchanged. Groups still unacknowledged by then may be lost, and the close still succeeds. A write that completed just before the relay ended its subscription may reach no viewer, and no error follows. - There is no publisher-side retention or replay. Late-subscriber behavior depends on relay caching and subscription filters.
- A publisher connection answers TRACK_STATUS
(draft-22 Section 9.13)
for its tracks, on
moqt-22andmoqt-20alike, as the SUBSCRIBE for the same track would be answered, without creating a subscription or sending objects. A registered track that is not closed gets TRACK_STATUS_OK (a REQUEST_OK) with the track’s properties, as SUBSCRIBE_OK carries them, and LARGEST_OBJECT once the track has written an object: the largest group and object ID whose write the transport took, across every relay subscription the track has had on this connection. Any other track gets REQUEST_ERRORDOES_NOT_EXIST(unknown track), as a SUBSCRIBE does. An answer that does not fit its control message (track properties at the size limit together with a long LARGEST_OBJECT) getsINTERNAL_ERROR. The publisher then FINs the request stream and reads nothing more from it. Demand, the write slot and every track writer are unaffected, and the application is not told. SUBSCRIBE_OK still carries no LARGEST_OBJECT (see Draft-22 protocol boundaries). moq-relaymainfrom moq-dev/moq#5991 asks the publisher for a track’s status before it subscribes on behalf of another relay, and fails that relay’s subscribe when the publisher refuses, so a track crosses two relays there only because the publisher answers (a viewer on another relay). Implemented in both SDKs. - A failed write may have delivered partial data. There is no automatic retry.
- The publisher encodes supplied track and object properties as protocol metadata. The raw transport API adds no default timescale, timestamp, or LOC media packaging. Subscribers expose the properties received from the relay, which may differ from those sent by the publisher. End-to-end preservation of arbitrary properties is not guaranteed. See Relay property handling.
- Object payloads, encoded object properties and individual property values are limited to the
sizes in
protocol.json. - Closing a track writer cancels an outstanding write and ends that track. Other tracks continue.
Closing the publisher ends all tracks, retracts the namespace, and waits for transport shutdown.
A track’s subscription ends with PUBLISH_DONE (
TRACK_ENDED) after its data streams close, then a FIN, which needs moq-relay 0.15.4 or later. Neither connection sends GOAWAY (relay interop deviations). - Track names cannot be reused within a publisher connection, including after their writer closes.
- A track writer may open a multi-object group: objects are appended with object IDs 0, 1, 2, … on one stream, and closing the group FINs the stream. Object 0 is the group’s random-access point, which is how LOC/MSF media (one GOP per group) is published. A group that fails part-way is reset rather than completed, and the track stays open for the next group. Whether a subscriber learns of the reset depends on the relay (see Subscribing). Only one write or group may be pending per track. The write deadline applies separately to opening a group (including demand), each object write, and closing the group. Idle time between operations is unrestricted. Aborting a group after it closed cleanly has no effect: the FIN already ended the stream, and a reset after complete delivery is ignorable (draft-22 Section 11.3.2). Implemented in both SDKs.
- A track writer exposes whether the relay currently demands the track and lets the application
observe every change, so a state-shaped track such as a catalog can re-send its document on each
relay subscription.
Demand observers emit the current state first, include replacement subscriptions (a repeated
“demanded” value), and finish when the track ends. A closed track reports no demand. Demand is
the relay’s single upstream subscription for the track: the relay shares it across every
downstream viewer, so additional viewers produce no further notification, and when it ends is
the relay’s choice (moq-relay 0.15 and later cancel it about one round trip after the last viewer
leaves, and earlier relays held it for about 30 seconds). Demand is therefore neither a viewer
count nor a liveness signal. A document re-sent on each demanded value reaches the viewers who
join during one relay subscription from the relay’s cache, not as a re-send, so a state-shaped
track writes only state that is safe to serve from cache and to receive more than once
(relay demand). When the first viewer is on another relay, or
is a moq-lite viewer on any relay, the publisher’s relay first subscribes briefly to answer a
track-info query and then subscribes again, so objects written during the first subscription reach
no viewer, with or without a
SUBSCRIPTION_CHANGEDerror. A live publisher cannot rely on its first objects after demand begins being delivered (a viewer on another relay). That is moq-relay 0.17.mainasks with TRACK_STATUS instead, which the publisher answers (see above), and subscribes once (TRACK_STATUS on moq-relay main). Implemented in both SDKs.
Delivery timeouts
Section titled “Delivery timeouts”Draft-22 Section 5.2 gives each group two delivery timeouts, in milliseconds:
SUBGROUP_DELIVERY_TIMEOUT (0x06) bounds how long a group’s FIN may wait for acknowledgment, and
OBJECT_DELIVERY_TIMEOUT (0x02) bounds how old an object may be when it reaches the transport.
There are no options for them: an application sets the publisher’s values with the track and object
properties it already passes.
- The value. Each group’s value is fixed just before its stream opens, after any wait for settling room. For a group opened without its first object, the publisher’s side is fixed when that object is written. The publisher’s is the property on the group’s first object when present, and the track property otherwise. On any later object the property is ignored. The subscriber’s is the parameter on the relay subscription’s SUBSCRIBE, as updated by any REQUEST_UPDATE so far. When both are non-zero the smaller applies, and otherwise the non-zero one. A value of 0 means no timeout. A value above 2147483647 ms is clamped to that bound, and registration refuses neither 0 nor a clamped value. Both types are even, so their values are integers: the Arguments table already refuses bytes there. A REQUEST_UPDATE carrying either parameter is acknowledged with REQUEST_OK, and the new value applies to groups whose stream opens after it. Groups already open or settling keep theirs.
- The subgroup timeout starts when the group’s FIN is handed to the transport, the moment its
operation completes and it becomes a settling group. If the relay has not acknowledged the FIN
when it expires, the stream is reset with
DELIVERY_TIMEOUT(0x2), as Section 5.2 requires before the stream reaches “all data committed”. Nothing is reported to the track: the group leaves the settling set like any other late failure and frees its place under the 64-group limit. The timer belongs to the group, so it keeps running through a relay subscription change or end and through the track’s PUBLISH_DONE. Only the connection’s end stops it. - The object timeout counts from the call that hands the object over (
write(),openGroup()/open_group()given the group’s first object, or the group’swrite()) to the moment the transport takes the object, so waiting for demand, for settling room, for the stream and behind the group’s earlier bytes all count. Section 5.2 starts the clock when the application provides the object, so a bareopenGroup()has none to time: only the write deadline bounds its wait for demand, settling room and the stream. Opening a group with its first object (payload, with optionalproperties) puts that wait on the object’s clock, and its0x02property is known before the stream opens. The call returns once the transport takes the object, withobjectCount/object_countat 1, or fails like awrite()and returns no group (#306). Properties without a payload are a TypeError. The age is checked again just before the object is handed over, as Section 5.2 requires. Until the group’s value is fixed only the publisher’s value times the wait. An object already older than the fixed value fails then, before any stream opens. When it expires the operation fails withDELIVERY_TIMEOUTin its current phase, and a stream already open is reset with code 0x2. In a group the whole group is reset and its later writes fail with the same error. No stream is opened again for it (Section 5.2: SHOULD NOT). The track stays open and the next write uses a new group ID, as after any interruption. The object timeout runs alongside the write deadline: whichever expires first fails the write, withDELIVERY_TIMEOUTorWRITE_TIMEOUT. Once the transport has taken an object, its bytes cannot be timed out. In TypeScript the transport has taken an object when its stream write resolves, so an object queued behind stream flow control is still timed. In Python, when the stream write returns, once aioquic has transmitted the bytes. - On moq-relay 0.15.8, 0.17.0 and 0.17.5 (and 0.17.7, read in source) neither value has any
effect beyond the publisher.
A subscriber’s values are sent on SUBSCRIBE as
parameters.objectDeliveryTimeout/subgroupDeliveryTimeout(object_delivery_timeout/subgroup_delivery_timeout) and the relay drops nothing for them: measured on 0.17.5, a listener with a 100 ms timeout behind a link too slow for the track received every group, up to 11 s late (relay-interop.md). A late listener’s remedies are the sequencer’s late drop and catch-up (media subscribing). The relay never sends either parameter upstream: its SUBSCRIBE carries only0x10,0x20,0x21,0x22and0x23, plus0x35only to opt out of properties, which the relay never does (subscribe.rs), its REQUEST_UPDATE only0x10,0x20and0x21, and it states that it never negotiates the Section 5.2 parameter (error.rs). It drops both track properties and the0x02object property, and reads an object’s0x06as a Timestamp (Relay property handling), so a first-object0x06reaches viewers as a Timestamp. In production the publisher’s own values are therefore the only ones that apply, and a track property is the safer way to set the subgroup timeout. The reverse also holds: a publisher that still sends timestamps as0x06(the draft-ietf-moq-loc-03 id) now gives each group a subgroup timeout equal to its first object’s timestamp. Applications must not send a timestamp as0x06. LOC Timestamp is0x10. Evidence: issue #167. - Defaults belong to the media tier, not the write slot:
registerTracksets none, and media publishing gives media tracks a subgroup timeout.
Implemented in both SDKs.
Send order
Section titled “Send order”The TypeScript publisher opens every data stream of a track that declares a Default Publisher
Priority (0x0E) with a send order of -1 - p, for the first integer p among its track
properties: from -1 for priority 0 down to -256 for 255
(#336). A transport adapter takes it as
createUnidirectionalStream({ sendOrder }) (TA-28), where a
higher value goes first and every other stream has 0.
-
Under a congested uplink the publisher’s own transport then sends a more urgent track’s bytes first: with the media defaults, audio (-17) ahead of video (-97). Without it, the audio streams share the uplink with the video streams that have bytes queued, and wait behind them before the relay sees them.
-
Draft-22’s lower-is-more-urgent priority (§5.1) is turned around into WebTransport’s higher-first order, and kept below 0, because the control and request streams have 0: a backlogged track never holds up SUBSCRIBE_OK, PUBLISH_DONE or a request.
-
A track without the property gets no send order, so its streams share 0 with the control streams and go ahead of every track that declares a priority. A catalog or a media timeline registered without one reaches a joining viewer ahead of the media it describes.
-
Every stream of a track has the same send order, so the mapping prefers no group of a track over another. The transport decides how equal streams share the uplink, and a later group’s stream can still finish first.
-
Three transports apply it (TA-28): Firefox’s
WebTransportfrom 155,@caprid/relay-websocket, whose QMux scheduler sends highest first and round-robin among equals, and@caprid/relay-quic, as quinn’s stream priority. Chromium’s does not: Chromium 153 has noWebTransportSendStreamand drops the option, so a Chromium publisher sends as before. The Python SDK sets none: aioquic has no stream priorities. -
It orders only what the publisher still holds. A backlog already in the network, in a deep bottleneck queue, delays audio and video alike. What the relay sends each viewer is ordered by the viewer’s subscriber priority (#154).
-
Measured on 2026-10-20 with
ts/scripts/probe-send-order.mjs: a Firefox 155 page published 50 fps audio (160-byte frames, one group per frame) and about 1 Mbit/s of 30 fps video (a 40 kB keyframe each second) to sjc through an uplink capped at 800 kbit/s, and a subscriber on a clean path timed the audio from capture to arrival. “Off” is the publisher before #336, with the send order stripped from every stream:uplink queue send order runs audio p50 p95 p99 max none (no cap) on 1 51 ms 52 ms 53 ms 54 ms 50 ms on 3 89 ms 105–106 ms 108–109 ms 110–115 ms 50 ms off 4 120–125 ms 207–229 ms 264–302 ms 326–444 ms 300 ms on 1 328 ms 354 ms 359 ms 361 ms 300 ms off 1 352 ms 463 ms 655 ms 923 ms A fifth run without it, on the 50 ms queue, delivered no audio at all: every audio group waited out its 500 ms subgroup timeout behind video. With the order turned around (video first), no audio arrived either, which shows Firefox schedules by it. In Chromium 153 on, off and turned around measured the same (audio p50 119–121 ms on the 50 ms queue). Video arrived alike either way.
Write phases and interruptions
Section titled “Write phases and interruptions”Every write and every open group is one operation occupying the track’s write slot (see
CONTEXT.md). The operation records its phase before each step, and this table is the shared test
list both SDKs implement: what the caller sees, what reaches the wire, and what state the track is
left in. The Source column separates what the protocol rules from SDK policy. Section numbers refer
to draft-ietf-moq-transport-22
unless stated otherwise.
| Phase | Interruption | Reported to the caller | Wire effect | Track afterwards | Source |
|---|---|---|---|---|---|
| waiting-for-demand | write deadline expires | WRITE_TIMEOUT, or NAMESPACE_NOT_SOLICITED when solicitations arrived but none covers the namespace; phase waiting-for-demand |
nothing sent | open; the next write waits for demand again | SDK policy: the protocol has no timer for awaiting a subscription (§15.6 only warns of idle timeout) |
| waiting-for-demand | object timeout expires: the publisher’s value, the only one that applies before the group’s value is fixed | DELIVERY_TIMEOUT, phase waiting-for-demand |
nothing sent | open; the next write waits for demand again | §5.2: an object’s age counts from when the application provided it, and is checked before it reaches the transport |
| waiting-for-demand | relay subscription ends while waiting | the wait continues until the deadline | nothing sent | open | SDK policy |
| waiting-for-demand | caller cancels | the caller’s reason, unwrapped; phase waiting-for-demand |
nothing sent | open | SDK policy |
| waiting-for-demand | track closed | CLOSED, phase waiting-for-demand |
nothing sent | ended | SDK policy |
| waiting-for-demand | connection ends | the connection’s own error (phase connection), or CLOSED after a clean shutdown |
nothing sent | ended | §6.6: streams and subscriptions end with the session |
| opening-stream | write deadline expires or caller cancels | WRITE_TIMEOUT or the caller’s reason; phase opening-stream |
a stream that opens after the failure is reset before any object is written, code DELIVERY_TIMEOUT (0x2) or CANCELLED (0x1); its header may precede the reset (RESET_STREAM_AT) |
open; the next write uses a new group ID | §11.3.2: a stream closed before all objects are delivered MUST be reset, and the reset SHOULD include the header; the deadline itself is SDK policy |
| opening-stream | object timeout expires, while waiting for settling room or for the stream, or the object is already older than the value fixed just before the stream opens | DELIVERY_TIMEOUT, phase opening-stream |
no stream opens, or a stream that opens after the failure is reset before any object is written, code DELIVERY_TIMEOUT (0x2) |
open; the next write uses a new group ID | §5.2 MUST reset with DELIVERY_TIMEOUT; §11.3.2 |
| writing | object timeout expires before the transport takes the object | DELIVERY_TIMEOUT, phase writing; a group’s earlier objects may have been delivered. Never closing: the object is with the transport by then |
stream reset, code DELIVERY_TIMEOUT (0x2); the group’s later writes and close() fail with the same error, and no stream is opened again for it |
open; the next write uses a new group ID | §5.2: MUST reset with DELIVERY_TIMEOUT, SHOULD NOT open a new stream for the subgroup |
| writing, closing | write deadline expires | WRITE_TIMEOUT, phase writing or closing; object bytes may have been delivered, and closing can fail after all of them were. In TypeScript closing can only fail before the FIN is handed to the transport; Python’s closing can still wait on transport backpressure |
stream reset, code DELIVERY_TIMEOUT (0x2) |
open; the next write uses a new group ID | §11.3.2 MUST reset; §5.2 and §16.11.4 for the code; the deadline is SDK policy |
| opening-stream, writing, closing | caller cancels (AbortSignal, task cancellation, or group.abort()) |
the caller’s reason unwrapped, or CLOSED when abort() gave none; the current phase |
stream reset, code CANCELLED (0x1) |
open | §11.3.2 MUST reset; code choice is SDK policy (§12.5 SHOULD be relevant) |
| opening-stream, writing, closing | the transport refuses the stream or fails a write, other than the relay stopping the operation’s own stream | TRANSPORT_ERROR with the cause preserved; the current phase |
stream reset, code INTERNAL_ERROR (0x0), when a stream exists |
open | SDK policy |
| opening-stream, writing, closing | relay subscription replaced or ended, or the relay stopped the operation’s stream (STOP_SENDING, §11.3.2) | SUBSCRIPTION_CHANGED; the current phase; after a stop the transport error is kept as the cause. The same code whichever of the stop and the subscription change arrives first, and nothing further is reported for the operation |
stream reset, code CANCELLED (0x1); after a stop the QUIC stack has already answered with its own RESET_STREAM (RFC 9000 §3.5) |
open; the next write waits for new demand only if the relay subscription ended, and proceeds at once while it is still live | §3.1.2: the publisher MUST reset open streams of a cancelled subscription; §11.3.2: a subscriber MAY stop a subgroup stream whose group is no longer of interest, which does not by itself end the subscription; treating a new SUBSCRIBE as a replacement rather than a concurrent subscription is SDK policy (§3.1, ADR-0001) |
| opening-stream, writing, closing | track closed | CLOSED; the current phase |
stream reset, code CANCELLED (0x1) |
ended | §9.9: every stream is closed or reset before the track ends |
| opening-stream, writing, closing | connection ends | the connection’s own error (phase connection), or CLOSED |
streams end with the session; a reset that is still sent carries SESSION_CLOSED (0x3), or GOING_AWAY (0x4) after GOAWAY |
ended | §12.2; §16.11.4 for the codes |
| none: refused before an operation starts | a write or group on a track that already ended, or a write on a group already closed with its FIN | CLOSED, phase waiting-for-demand |
nothing sent | unchanged | SDK policy: the phase that means nothing was sent |
| any | a second write or group is started | WRITE_PENDING, phase writing; the active operation is unaffected |
nothing sent | unchanged | SDK policy: one pending operation per track |
| after the FIN is handed over (settling group) | subgroup timeout expires before the relay acknowledges the FIN | nothing; the operation had completed | stream reset, code DELIVERY_TIMEOUT (0x2); the group leaves the settling set |
open, whether or not the subscription or the track has ended since | §5.2: MUST reset the stream when the timer expires before all data is acknowledged |
| after a clean group close | abort() |
nothing; closed stays true and a repeated close() returns |
nothing sent | open | §11.3.2: a reset after all data and the FIN is ignorable |
A failed group’s ID is never reused, which is stricter than §2.2 (a subgroup may resume on a new
stream after a premature reset). Group IDs increase monotonically and object IDs start at 0 within a
group (draft-ietf-moq-msf-01 §6). A group may end without an END_OF_GROUP marker (§12.1). There
is no automatic retry, and the application chooses the next random-access point after a failure.
MSF and LOC say nothing about an abandoned group. Implemented in both SDKs.
Relay property handling
Section titled “Relay property handling”These are deployment observations, separate from the SDK guarantees above. Tests against
production moq-relay 0.15.8 on 2026-09-28 (3 of 3 runs identical) found the following, and the
same matrix was identical on production 0.17.0 on 2026-10-05. moq-relay 0.17.5 and earlier strip
object properties: the relay stores each object as its size and a timestamp only, so no other
object property can survive it (a cluster interop run through 0.17.5 also delivered only 0x10). This
is fixed upstream in moq-dev/moq#5494, which keeps
object properties through the relay, but no release has it yet
(issue #262).
- Of the object properties, only Timestamp (
0x10) reaches a subscriber. The relay also reads0x06, the Timestamp id of draft-ietf-moq-loc-03, as a Timestamp and forwards it as0x10. Every other object property tested was dropped, even- and odd-typed alike: Timescale (0x08), Video Config (0x0D),0x02,0x04,0x05,0x0B, and0x40to0x43. Object payloads and IDs were preserved. - Timestamps are rescaled to microseconds using the declared Timescale: with track Timescale 90000, a Timestamp of 123456 arrives as 1371733. An object-level Timescale takes precedence over the track’s. With Timescale 1,000,000 a supplied Timestamp passes through unchanged.
- A track without a declared Timescale gets relay-clock timestamps on every object, replacing any Timestamp the publisher supplied. So does an object without a Timestamp on a declared track. This departs from LOC, which defines a Timestamp without a Timescale as wall-clock microseconds since the Unix epoch. Such a stamp from a publisher does not survive this relay.
SUBSCRIBE_OKcarries the relay’s own track properties for every track: Timescale (0x08) = 1,000,000, Default Publisher Priority (0x0E) = 128, and Default Publisher Group Order (0x22, decimal 34) = 2 (descending). A publisher’s own0x0Epasses through, its0x22is replaced with 2, and its other track properties (for example0x0D,0x40,0x41) are dropped.- A track registered with a Default Publisher Priority (
0x0E) above 255 or a Default Publisher Group Order (0x22) other than 1 or 2 makes the relay refuse every subscribe to it with REQUEST_ERROR 18invalid value. The publisher gets no error. Its writes time out waiting for demand. Draft-22 rules both values out (§10.4, §10.5), so the SDKs refuse them at registration (issue #252). - On 0.17.0, a track registered with more than 64 track properties makes the relay refuse
every subscribe to it with REQUEST_ERROR 18
too many(MAX_PROPERTIES, an implementation limit), again with no error to the publisher. The relay accepts 64 pairs, or one byte value filling the rest of a maximalSUBSCRIBE_OK. The SDKs refuse such a track at registration (relay.limits.maxTrackProperties, issue #252). - From 0.17.0, an object whose property block is larger than 64 KiB (65,536 bytes) is dropped
by the relay: it stops that stream, and the subscriber sees a gap with no reset, while the
publisher’s write succeeds. The SDKs refuse such a write (
relay.limits.maxObjectPropertiesBytes, issue #247; upstream434ab81f5, an implementation limit rather than a draft one). - On 0.17.0, a subscription with
INCLUDE_PROPERTIES= 0 gets an empty track property list inSUBSCRIBE_OKand objects with no properties at all, Timestamp included (publisher.rs).
This is not an exhaustive registry compatibility test or a promise about future relay versions.
Compared with the 0.14.17 and 0.15.1 observations, the 0x0E in SUBSCRIBE_OK and the
invalid-value refusal are new.
For publisher-defined media timing, declare a track timescale and put timestamps in that unit on
objects. To sync a broadcast’s tracks, stamp them all from one clock. That convention, and the
player’s playout clock, are in
media-layer.md, Shared timeline and playout clock. Interpret received timestamps using the received track timescale. A timestamp’s presence
alone does not establish that the publisher supplied it. Do not treat a relay-generated timestamp
as the application’s event time or a synchronized wall clock. Put application metadata that must
arrive intact in the payload (or a separate metadata track’s payload). For the Video.js media
example, codec configuration is also carried in the catalog’s initDataList, so decoding does not
depend on Video Config object-property forwarding.
Evidence and protocol naming: issue #23 and, at moq-relay 0.15.8, the relay’s track property implementation, SUBSCRIBE_OK track properties and frame model.
Text and JSON helpers
Section titled “Text and JSON helpers”- Both product SDKs provide opt-in text and JSON writes on track and group writers and standalone readers for received objects. The protocol core remains payload-agnostic.
- Each helper encodes or decodes one complete UTF-8 object. Text readers preserve a leading U+FEFF
and reject malformed UTF-8. JSON readers additionally reject invalid JSON, including non-standard
NaNandInfinitytokens. Empty text is valid, but an empty payload is not JSON. - Writes delegate to the byte writer, retaining its options, metadata, grouping, demand, concurrency, cancellation, deadlines, and size limit (measured in encoded bytes). Serialization failures occur before starting a write and leave the writer usable.
- JSON uses the language’s standard serializer and parser, with no schema validation or automatic
content-type properties. Unsupported values fail locally. JavaScript also rejects top-level
values for which
JSON.stringifyreturns undefined. Runtime-specific number, Unicode, and serialization conversion rules are documented in each SDK’s README. This is not canonical JSON. - Readers do not mutate payloads or metadata. Decode failures propagate to the caller. Applications catch them inside object callbacks when they want to skip malformed objects.
Media helpers
Section titled “Media helpers”The optional @caprid/moq-media / moq-media packages implement pure format helpers. Raw Relay
writers keep their existing byte/group behavior. No automatic media packaging is added to them.
The media pins in protocol.json and exact bytes and parsed values in vectors/media.json apply
to both languages.
- LOC track properties declare a timescale (default 1,000,000). Object properties carry Timestamp (0x10), Timescale (0x08), and explicitly supplied Video Config (0x0D) / Audio Config (0x0F). Callers supply configs only on keyframes. Timestamps arrive in microseconds. Other timescales round to the nearest integer tick, ties upward. Inputs and the resulting tick count must be nonnegative safe integers (at most 2^53−1). The timescale must be positive.
- The LOC timestamp reader (
locTimestampUs/loc_timestamp_us, issue #290) returns an object’s Timestamp (0x10) in microseconds, or nothing when the object has no Timestamp that is a nonnegative safe integer. It scales by the first Timescale that is a positive safe integer, looking at the object’s Timescale (0x08), then the track properties’ (asSUBSCRIBE_OKcarries them), and skipping any other value. It never reads the catalog’stimescale: msf-01 §5.2.21 does not tie that field to the LOC Timestamp. The result isround(value × 1,000,000 / timescale), ties upward. With no Timescale it is the value itself, LOC’s wall-clock microseconds. A result past 2^53−1 reads as nothing. Pinned byvectors/media.jsonlocTimestamps. - The independent MSF catalog is compact UTF-8 JSON with
version: "draft-01"and explicit per-track namespace, LOC packaging, and live status. It omitsisComplete: msf-01 §5.1.3 forbids the field while false and forbids removing it once added. Absent optional fields are omitted, as is empty init data. Catalog strings must be valid Unicode, with no lone surrogates. Entries are orderedvideo,audio, themedialist in caller order, the media timeline, then data in caller order. Init-data entries have<track-name>-initIDs and inline base64 data. Numbers must be finite. Integral values must be safe integers and have no decimal suffix. Fractional values use ECMAScript JSON number spelling, including its exponent threshold. - The end-of-broadcast catalog (
buildMsfEndCatalog/build_msf_end_catalog) is msf-01 §11.3’s final document:version, the optionalgeneratedAt,isComplete: true, and an emptytracksarray, encoded by the same rules. A publisher sends it only when a broadcast ends on purpose, after every media track has ended (closing a track sends PUBLISH_DONE Track Ended), and never on a crash or a lost connection. avc1andavc3video entries are withheld without non-empty init data. With avcC, the codec suffix comes from the record’s profile/constraints/level. Theavc1/avc3prefix is retained. Header inspection rejects truncated/wrong-version headers. It does not decode SPS semantics.- Opus always declares 48,000 Hz and defaults to channel configuration
"2". Video and audio entries carryrenderGroup(msf-01 §5.2.11), 1 unless the caller names another, andaltGroup(§5.2.12) when the caller names one. Both must be non-negative safe integers, and the members of an alternate group must share a role and a render group (media-layer.md, issue #351). No target latency or buffer is set. Data entries have only name and optional role beyond the common fields, and reject the media rolesvideo,signlanguage,audio,audiodescription,caption, andsubtitle. Track names must be non-empty and unique, including tracks temporarily withheld pending init data. - Namespace inputs are sequences of fields, never bare strings. Catalog namespace fields must be
non-empty and cannot contain
/: the supported Video.js catalog dialect joins/splits namespace fields using that separator. Source URL components support arbitrary valid UTF-8 and escape every byte outside[A-Za-z0-9_]as lowercase.xx. Source composition accepts explicithttps://ormoqt://URLs without a fragment or credentials, preserves authority/path/query, supplies/for an absent path, defaults to thecatalogtrack, and percent-escapes an optionalc4mtoken using RFC 3986 unreserved characters. - Python H.264 helpers accept three- or four-byte Annex B start codes, omit Annex B padding zeros, extract SPS/PPS, construct a four-byte-length avcC, and convert access units to four-byte NAL lengths. SPS/PPS/AUD are dropped by default. Retaining them is an explicit option. avcC creation rejects missing/wrong-kind parameter sets, more than 31 SPS or 255 PPS, and NALs over 65,535 bytes. Construction supports Baseline, Main, and Extended profiles (66, 77, 88). Other profiles are rejected until their SPS-derived chroma/bit-depth extension fields are implemented. Encoder-generated avcC records of other profiles can still be passed to the catalog builder. The extension requirement is reflected in FFmpeg’s avcC writer.
- TypeScript consumes encoder/demuxer-provided avcC and length-prefixed packets, and exposes only
avcCodecStringfor header inspection. It does not expose Annex B parsing or construction APIs.@caprid/moq-mediamaps standard WebCodecs decoder configs for four-byte-length AVC and Opus, copying the exact description buffer/view range.chunkToLocFrameaccepts native encoded video or audio chunks, copies their payload bytes unchanged, preserves microsecond presentation timestamps, and attaches supplied configs only to key chunks. Callers preserve encoder output order. These helpers require no browser globals at import time. The optional@caprid/moq-mediabunnyfile-input adapter borrows public packet payloads and rejects metadata-only packets. Both chunk and packet helpers accept a shared integer timestamp offset to normalize negative timestamps. Input timestamps must be safe integers and the final timestamp follows the LOC nonnegative safe-integer contract. The adapter retains compatibility re-exports of the config mappers andLocFrametype from the media package. - The catalog reader (
parseMsfCatalog/parse_msf_catalog) takes a catalog object’s payload and the catalog track’s namespace, and returns the version, generation time, completeness, and track descriptions in document order. It reads the independent form only. It rejects a payload that is not a UTF-8 JSON object (no byte-order mark, noNaN/Infinity), a missing or non-stringversion, a version other thandraft-01or the deployed alias1(msf-01 §5.1.1: a subscriber MUST NOT parse a version it does not understand), a missingtracksarray, adeltaUpdate, and init data that is not base64 (decoded as WHATWGatobdoes). Everything else is ignored when not understood, as msf-01 requires: unknown fields, fields of the wrong JSON type, non-integral widths, heights, sample rates, bitrates, render and alternate groups, and generation times, track entries that are not objects or have no string name, and init entries whose type is notinline. A track’s slash-joinednamespaceis split back into fields, dropping empty ones (the builder’s dialect). A track without one inherits the catalog track’s namespace (§5.2.2). Packaging is returned as declared,locor otherwise, and payloads are not interpreted. Absent packaging reads as the empty string and absentisLiveas false.initRefresolves throughinitDataListto bytes. An unresolved reference leaves the track without init data. Every builder document must read back as its vector’sparsedvalue.groupMsfTracks/group_msf_tracksgroups parsed tracks into the sets a viewer picks one track from: tracks sharing a render group and an alternate group form one set, every other track is a set of its own, and sets are ordered by their first track. - One timeline, and the playout clock
(media-layer.md, issues #285 and #290): every
LOC media track one catalog lists is stamped in microseconds since the Unix epoch, its capture
time on the publisher’s system clock, with Timescale 1,000,000 declared on the track. The
broadcast clock (
createBroadcastClock/BroadcastClock) pins an integer-microsecond origin on its monotonic source (originUs) to an integer Unix-epoch time (originEpochUs). It maps times on its own source asoriginEpochUs + source − originUs, rounded to whole microseconds with ties upward. It maps a capture clock with another origin and rate (anAudioContext, Chromium’sMediaStreamTrackProcessor) through a source timeline:anchor + round(s − anchor s) − captureLatencyUs. A calibration pair(s, at)re-anchors when there is no anchor, or when the anchor (without the capture latency) is off by more thantoleranceUs(5 ms). Uncalibrated, the first timestamp anchors atnow(). The capture latency defaults to 0, must be a nonnegative safe integer, and can be changed between timestamps. No helper knows a device’s latency. It maps sample indexnasfirst + floor(n × 1,000,000 / rate). The playout clock (createPlayoutClock/PlayoutClock) is master-audio: its position is the latest audio report plus elapsed local time. It paces by wall clock while idle, afterstopAudio/stop_audio, and afteraudioTimeoutUs(500 ms) without a report arriving (measured from arrival, not from the report’satUs).dueanswerswait(early by at mostmaxVideoDelayUs, 1 s),show(late by at mostlateDropUs, 40 ms), ordrop. A frame further ahead is shown at once. Wall pacing re-anchors on a frame more thanmaxVideoDelayUsearly or more than the target latency late. The target latency (targetLatencyUs/target_latency_us) is the option when given, a positive number. Otherwise it is twice the longest of the last four keyframe intervals reported (reportKeyframeInterval/report_keyframe_interval, each positive), and 2 s before any is.reset()keeps the reported intervals and counts itself inresets. Both clocks read an injected monotonic microsecond source and need no DOM or transport. The decisions are pinned byvectors/clock.json. Implemented in both SDKs. - The Media Timeline track (msf-01 §7; media-layer.md,
issue #291). The broadcast clock’s
originEpochUs/origin_epoch_usis the origin as integer Unix-epoch microseconds: as given, or else read once at creation as the system clock (Date.now(),time.time_ns()) less the source time elapsed since the origin. An entry for a timeline timestampt, location(g, o)(object defaults to 0) and wall-clock timew(defaults tot, the epoch-anchored timeline, and 0 means unknown) is[floor(t / 1000), [g, o], floor(w / 1000)]. Every number must be a nonnegative safe integer. The document builder encodes entries in the order given as compact JSON. The reader rejects a payload that is not a UTF-8 JSON array (no byte-order mark, noNaN/Infinity). It reads a record whose first three items are a number, an array whose first two items are numbers, and a number, all nonnegative safe integers, and ignores extra items. It skips every other record. It does not decompress. The catalog builder’s optional timeline description emits one entry after video and audio and before data:packagingandrolemediatimeline,mimetypeapplication/json,isLive, anddepends.dependsdefaults to the video track’s name followed by the audio track’s, whichever exist. Each name must be a video or audio track of the same catalog, and the list must be non-empty with no duplicates. Names listed but withheld pending init data are left out, and the entry is withheld when none remains. The timeline’s name defaults totimelineand joins the uniqueness check. The catalog reader returnsdepends(string items only) andmimetypeas declared. The timeline accumulator (createMediaTimeline/MediaTimeline) holds one broadcast’s entries, each built as the entry helper builds it (wall-clock time defaulting to the timestamp), and keeps at mostmaxEntries/max_entries(a positive safe integer, default 512), dropping the oldest. Each add returns the independent document of the retained entries. Before the first it is[]. A rejected entry leaves the timeline unchanged. Retention is pinned byvectors/media.json. Cadence (one entry per video group that was sent) and the publishing rules are in media-layer.md. Implemented in both SDKs. - The Opus packet duration (
opusPacketDurationUs/opus_packet_duration_us) reads RFC 6716 §3.1’s TOC byte: the frame length from the configuration (SILK 10, 20, 40 or 60 ms, hybrid 10 or 20 ms, CELT 2.5, 5, 10 or 20 ms), times one frame for code 0, two for codes 1 and 2, and for code 3 the low six bits of the next byte. An empty packet, or a code 3 packet without that byte, reads as nothing. Pinned byvectors/media.jsonopusPacketDurations. - The interrupt track (
INTERRUPT_TRACK,interrupt; media-layer.md, issue #320) carries one interrupt per group as compact UTF-8 JSON:track(defaultaudio), the optionalfromUs, thenuntilUs, with nofromUskey when it is absent. The builder (buildInterrupt/build_interrupt) and the reader (parseInterrupt/parse_interrupt) refuse an empty or non-string track, a track that is not valid Unicode, a timestamp that is not a nonnegative safe integer, and afromUsthat is not beforeuntilUs. The reader refuses a payload that is not a UTF-8 JSON object, reads an integral number such as2e3as an integer, and ignores unknown fields. A listener applies an interrupt for an audio track it plays as a cut of that range. Pinned byvectors/media.jsoninterrupts,interruptErrors,interruptReadsandinterruptReadErrors. The track name byprotocol.json. - The transcript track (
TRANSCRIPT_TRACK,transcript; media-layer.md, issue #320) carries one version of one segment per group as compact UTF-8 JSON:seq,track(defaultaudio),fromUs, the optionaluntilUs,textandfinal, in that order, with nountilUskey when it is absent. The builder and the reader (buildTranscriptSegment/build_transcript_segment,parseTranscriptSegment/parse_transcript_segment) refuse aseqor timestamp that is not a nonnegative safe integer, an empty or non-string track, a track or text that is not valid Unicode, a non-string text, a non-booleanfinal, anuntilUsnot afterfromUs, and a final segment withoutuntilUs. The reader refuses a payload that is not a UTF-8 JSON object, reads an integral number as an integer, and ignores unknown fields. A cut starts at the later of the range’sfromUs(or 0) and the floored playout position, and changes nothing when that is not beforeuntilUs. Otherwise aTranscriptremembers it, drops the segments starting in it, and ends any segment running past its start there, final and marked cut.applykeeps a segment’s latest version, clipped by the remembered cuts in order of start: refused if it starts inside one (a final version refused this way also drops the line held for its segment), ended at the start of the first it runs into. It refuses any version of a segment the talker sent final, even one a cut dropped, and an interim version of a cut segment. A final version of a cut segment replaces it, clipped. It returns whether the held line changed. Lines are ordered byfromUs, thenseq. At a position, the lines are those withfromUsat or before it, and there are none while idle. Pinned byvectors/media.jsontranscriptSegments,transcriptSegmentErrors,transcriptSegmentReads,transcriptSegmentReadErrors,transcriptsandtranscriptCutErrors. The track name byprotocol.json. - Python’s Opus encoder and decoder (
moq_media.opus, theopusextra; issue #320, ADR 0006) are PyAV’s libopus. The encoder takes signed 16-bit PCM, or a PyAV frame, in runs: the first call of a run gives the timeline timestamp of its first sample and later calls refuse one, packet k of a run is stamped at that timestamp plus k × 20 ms, and ending the run pads its partial frame with silence and drains the encoder. Defaults are voice: mono, 32 kb/s, libopus’svoipapplication. The decoder returns s16 PCM at the caller’s rate and channel count, and resets on request, at a discontinuity. TypeScript exports no counterpart: applications encode with WebCodecs, and the audio player decodes with an internal libopus WebAssembly build. - Encoding, pacing, and time-based frame dropping remain application responsibilities. Catalog
serving, GOP grouping and recovery are the Media publishing policy below, and
reading frames in decode order within a latency budget is Media subscribing,
both implemented in both SDKs. A
register_mediapublishing handle is not implemented.
Media publishing
Section titled “Media publishing”The media publishing policy lives in the product package (@caprid/relay/media, caprid_relay.media)
over registered tracks: one tier above the write slot, one below the proposed register_media
handle. It knows no format. A frame carries its own object properties and a catalog is bytes.
Implemented in both SDKs.
- A catalog is a state-shaped track. The catalog service sends its document on the current relay demand and again on every later relay subscription, including a replacement subscription (the repeated “demanded” value), so a viewer joining after a relay reconnect receives it. A viewer joining while one relay subscription lasts triggers no re-send and is served the relay’s cached copy. Its document is fixed. Not adopted: MSF’s publish-on-change with a Joining FETCH from the relay cache. A plain SUBSCRIBE against the tested relay is live-edge only (#26), re-sending is what the Video.js player relies on, and MSF-01 §6’s one-object-per-stream sentence is treated as a draft defect because it contradicts LOC-04 §4.2 and MSF-01 §5.
- A catalog write that fails while the track stays open is retried after a configurable delay (default one second) for as long as demand remains, keeping only the current document. Losing demand ends the retries until the next relay subscription. The service runs until cancelled or until the track ends, and raises the failure that ended the track when a write exposed it.
- A document service serves a state-shaped track whose document the application replaces, such as the media timeline. It sends the latest document, each time as a new group, on every relay subscription as the catalog service does, and again on every update while the relay is subscribed. It sends nothing before the first document. An update without relay demand only replaces the document. An update made during a write is sent after it. Retries follow the catalog’s rule but always send the latest document. A retirement during a write ends the service with that failure. The track ending otherwise ends it quietly.
- A frame writer maps encoded frames onto one track’s groups. Under keyframe grouping, the LOC mapping for video, a keyframe FINs the open group and opens a new one, and deltas append to it. Under frame grouping, for audio and any other independently decodable frames, every frame is its own single-object group. Object properties are sent as the frame supplies them.
- Writes on one writer are served one at a time, in the order they were called, so an encoder whose
output callback cannot await may call
writewithout waiting for the previous one. Frames waiting behind the one being written are the writer’s backlog, bounded bymaxBacklog/max_backlog(a positive integer, default 8 under keyframe grouping and 25 under frame grouping). A caller that awaits every write never has a backlog. A waiting write that is cancelled (in Python only, because a TypeScript promise cannot be cancelled) leaves the backlog, and under keyframe grouping leaves a gap: the frame after it is treated as following a discontinuity, and a keyframe is needed. - A frame that arrives at a full backlog means the transport has fallen behind, and the writer drops rather than queues. Under keyframe grouping every waiting frame is dropped and the open group is reset at once. The frame being written into it, if any, is dropped too. The arriving frame then opens the next group if it is a keyframe, and is dropped otherwise, as is every delta after it until the next keyframe. Under frame grouping the oldest waiting frame is dropped and the arriving frame takes its place at the back.
- A frame the relay has no subscription for when its turn comes is skipped at once. The writer never waits on the write deadline, because an encoder loop cannot block. A relay demand change (a new or replacement subscription, or its end) ends the open group in the write slot at once. The writer finds it ended at the next frame, even one written before it has seen the change, and skips deltas until the next keyframe. The open group is not replayed.
- Under keyframe grouping a writer says when it needs a keyframe (
keyframeNeeded/keyframe_needed): from its start until a keyframe arrives, and again from each demand change, interruption, stale or discontinuous frame, or drop until the next keyframe arrives. A keyframe arrives whenwriteis called with it, whether or not it is later sent. The optionalonKeyframeNeeded/on_keyframe_neededcallback runs synchronously on every entry into that state after the start, so an encoder can force a keyframe instead of waiting out its interval. An exception from it is reported (toconsole.error, or to the asyncio loop’s exception handler) and does not touch the writer. The writer watches the track’s demand from its creation until it closes or the track ends. In Python it is therefore created with an event loop running. Under frame grouping no keyframe is ever needed. - A frame that starts a group (a keyframe, or under frame grouping every frame) opens it as the group’s first object, so a delivery timeout counts the wait for settling room and for the stream. When that open fails, no group came back, so the outcome names none.
- Lateness is the application’s. An optional
stalepredicate is consulted before a group is opened, again once a frame has opened its group (the frame went out with the open, and a stale one has its group reset), and before each later write, since time passes inside those steps. A stale frame is dropped, an open group that depended on it is reset, and deltas are dropped until the next keyframe. A frame flagged as following a discontinuity resets the open group the same way. - A failure is one of two kinds. It ends the writer (a retirement, see
CONTEXT.md) exactly when the error code isCLOSEDor the track reportsclosedafterwards, and likewise when the track is already closed as a frame’s turn comes. That frame and every later one are reported as ended, with the error, and nothing further touches the track. Any other failure (an interruption) resets the group, leaves the track open, and reports the frame as interrupted with its error. The next keyframe, or the next independent frame, opens a new group. The FIN of the previous group that a keyframe triggers is part of that keyframe’s write: a retirement during it ends the writer, while an interruption during it is absorbed, because the previous group’s delivered objects stand and the new group opens regardless. A group the writer resets for a drop is not a failure. Demand alone never decides: no demand is also what a track nobody subscribes to looks like. - A media track gets a default delivery timeout from
mediaTrackProperties(kind, options)/media_track_properties(kind, ...), whose track properties go toregisterTrack/register_trackbeside the format’s own (for LOC,locTrackProperties()): a stall then costs stale frames, not lasting latency. They hold one SUBGROUP_DELIVERY_TIMEOUT, in whole milliseconds rounded up from microseconds. Audio’s is 500 ms. Video’s is the target latency less the keyframe interval, and never less than the interval. The target latency defaults to twice the interval, so video’s default is one interval. An explicit subgroup timeout replaces either default. It is a track property because moq-relay reads an object’s0x06as a Timestamp, and because the relay ignores both sides’ values only the publisher’s own enforcement applies.- No object timeout is set. The frame writer opens each group with its first frame, so an object timeout would count the wait for settling room (#306), but once the group’s stream is open Chromium’s WebTransport takes each object at once even during a stall (every write resolved in under 1 ms through a 2 s blackout; #309). Whether the defaults should add one is #310.
- Audio’s value is measured, not derived from the target latency, which would let a whole stall’s audio through late. Through a 2 s uplink blackout to sjc, with the subscriber on a clean path, it brought the player’s audio lag after the stall from 2.44 s to 0.43 s (0.27 s before it), and under a 4 s 80 kbit/s uplink cap from 4.6 s to 1.6–1.7 s. Without it, audio’s 50 groups a second fill the 64-group settling limit during a stall, so both tracks’ groups wait up to about 1 s to open (#302). The timer starts when a frame’s FIN is handed over, so the audio from a stall’s last 500 ms survives and arrives late: after an uplink stall the player’s lag can rise by up to about the audio timeout. With the WebCodecs example’s defaults, three 2 s uplink blackouts raised it by 214, 168 and 511 ms (2170 ms without), so the live bar is a rise below the audio timeout plus 100 ms, not a fixed jitter allowance.
- A subgroup timeout resets a settling group the writer has moved past. It reports nothing to the writer and raises no keyframe-needed, since the open group decodes without it. Under keyframe grouping the open group has no timer until its FIN, at the next keyframe, so its objects can wait out a stall in the transport. Dropping late video is then the reader’s.
- A downlink stall is outside the publisher’s reach: the relay keeps a stalled viewer’s backlog whatever these values are.
- The same properties put audio ahead of video with a Default Publisher Priority (
0x0E), lower being more urgent (draft-22 §5.1): 16 for audio and 96 for video, the values Huddle set by hand, pinned inspec/protocol.jsonasmediaTrackProperties.publisherPriority. An explicit publisher priority replaces either default. Group headers leave the priority out, so it is the track’s value for every group.- The TypeScript publisher’s transport sends the more urgent track first when the uplink is congested, in Firefox, in Node and over WebSocket (send order). Chromium’s WebTransport and the Python publisher do not.
- On moq-relay 0.17 the default changes no delivery from the relay. The relay passes a publisher’s
0x0Eto viewers in SUBSCRIBE_OK, but orders what it sends a viewer by subscriber priority alone and ignores the publisher’s when subscriber priorities tie (#154, relay interop). A viewer that wants audio first under congestion asks for it with its own SUBSCRIBER_PRIORITY, whichmediaSubscribeParameters/media_subscribe_parametersgives it with the same values (media subscribing).
- Every frame gets exactly one outcome: sent, skipped, stale, dropped, interrupted or ended. The
writer returns it rather than raising, so a caller keeps counters without catching per frame, and
the group the frame was written to or that was reset on its behalf travels with it. Closing the
writer FINs the open group, reports every waiting frame as dropped, and refuses further frames
with
CLOSED. The registered track stays the application’s to close. - A replaceable track (
createReplaceableTrack/ReplaceableTrack) publishes a track the application can replace mid-stream, for interruptions (track replacement). It registers its first track under the given name and lists it in the catalog (catalog.service.update(catalog.build(name))/catalog_service.update(build_catalog(name))) at creation. Each replacement registers a new track,<name>-<n + 1>for replacementnfrom 1 unless the application names it, updates the catalog with that name, drops what the old track has not sent (frames still waiting resolvedropped, the old frame writer closes) and closes the old track. The new track takes every frame written from the call on. Registration or a catalog that fails leaves the current track in place, and a new track registered before the catalog failed is closed; the failed attempt uses its name up, and the next asks for the next. Closing retires the current track at once, so nothing waiting on it goes out later, and finishes once every old track still closing has closed. Frames written to a replacement while the relay has not subscribed to it wait, in order, until its first relay subscription or until the demand wait (default 2 s, 0 for none) runs out, then go to the frame writer one at a time, so a listener that subscribes once the new catalog reaches it misses none of the new reply. The first track never waits. Closing drops what has not been sent and closes the current track; writes and replacements are refused after it. Every frame still gets exactly one outcome.
Media subscribing
Section titled “Media subscribing”A track’s objects arrive in any order across group streams: after a stall, an older group’s stream
can finish after newer groups have started (issue #303).
The raw subscription delivers them as they arrive. A frame sequencer (createFrameSequencer /
FrameSequencer, in the format packages) turns one track’s objects into frames in decode order and
drops what is too late against the broadcast’s playout clock. A frame reader (createFrameReader /
FrameReader, in @caprid/relay/media and caprid_relay.media) is a subscription’s or a want’s handlers
that drive a sequencer and poll it when a wait ends. It knows no format. Implemented in both SDKs.
-
A viewer subscribes to a media track with the parameters from
mediaSubscribeParameters(kind, overrides)/media_subscribe_parameters(kind, overrides), in@caprid/relay/mediaandcaprid_relay.media(#337). They hold a SUBSCRIBER_PRIORITY that puts audio ahead of video, lower being more urgent: 16 for audio and 96 for video, the same values as the publisher priorities, pinned inspec/protocol.jsonasmediaSubscribeParameters.subscriberPriority. The overrides are copied onto a new result, and an explicit subscriber priority, an integer from 0 to 255, replaces the default. A kind other than'audio'or'video', or a priority outside that range, is refused like the media track timing arguments.-
This is the priority that decides delivery on moq-relay 0.17. The relay orders what it sends a viewer by subscriber priority alone and ignores the publisher’s
0x0Eeven when subscriber priorities tie (#154: 2.1 s against 26.9 s of lag with different subscriber priorities on a congested link, no difference with publisher priorities alone; relay interop). Without it, a viewer whose downlink is congested gets no audio preference, whatever the publisher declared. -
Measured on 2026-10-20 against ord (moq-relay 0.17.5) with
ts/scripts/probe-subscriber-priority.mjs: a Node publisher on a clean path sent 50 fps audio (160-byte frames, one group per frame) and about 1 Mbit/s of 30 fps video (a 40 kB keyframe each second), both registered withmediaTrackProperties, and a Node subscriber behind a downlink capped at 800 kbit/s (100 ms queue) timed the audio from capture to arrival, after a 5 s warm-up of a 25 s run. “Off” subscribes with no parameters, as before #337. “Inverted” sends audio 96 and video 16. The probe read each SUBSCRIBE off the subscriber’s request streams: SUBSCRIBER_PRIORITY 16 and 96 with the helper, none without. On a clean path every mode delivered audio in 1 to 2 ms.subscriber priority runs audio p50 p95 p99 max audio received after the warm-up on (audio 16, video 96) 3 43–45 ms 70–72 ms 74–76 ms 76–93 ms 995 of about 1,000 published off 3 176–181 ms 243–245 ms 253–259 ms 308–441 ms 996 of about 1,000 published inverted (audio 96, video 16) 2 11.9 s, 2 frames; none 2 and 0 of about 1,000 Video lagged 7.7 to 8.3 s at p50 either way: the cap is below its rate, and the relay keeps a stalled viewer’s backlog. The relay stamped each group stream header with the track’s
0x0Ein every mode, so a group header does not show what the viewer asked for. A track without0x0Egets 128 in its headers, and delivery still follows SUBSCRIBER_PRIORITY: the same runs withoutmediaTrackPropertiesgave the same latencies, and inverted audio starved again (#344, relay interop). -
It has to be chosen before SUBSCRIBE: on that relay any
update()ends the subscription (deviation 4), so a viewer that wants another priority switches or subscribes again. SUBSCRIBE_OK carries the publisher’s0x0Eonly after the choice is made. -
The relay serves each viewer separately, so the priority orders only that viewer’s own tracks against each other. A track subscribed without it gets the relay’s default.
-
-
Lateness is measured against the playout clock’s position, which advances in real time through a stall. While the clock is idle nothing is late. The budget is the clock’s target latency, one setting for every track of the broadcast. The sequencer reports the interval between the keyframes of consecutive groups to the clock, so the default follows the stream. Only a keyframe that can still be delivered reports one: an old duplicate does not move the budget.
-
A frame’s timestamp is its LOC Timestamp, scaled by the object’s Timescale, then the track properties from SUBSCRIBE_OK. An object without one is counted (
untimed) and ignored. An end-of-group status object ends its group. A repeated object is ignored. -
Keyframe grouping (video): frames go out in order, group by group and object by object, as soon as they are next. In-order delivery adds no delay. A frame more than the target latency behind the position is past its deadline: its group is abandoned, and so is any group whose keyframe is.
-
When the next frame is missing (a gap inside the group being delivered, a still-open group whose next frame has not arrived, or the next group’s object 0), later frames wait only while a newer group’s keyframe is waiting, and only until the position passes the last delivered frame’s timestamp plus the target latency (at once if nothing was delivered yet), and while the clock is idle until the group ends or is reset. The group is then passed over. A group whose stream ended with a hole, or was reset, is passed over at once. A group that ended after its last object was delivered is followed by the next group with no discontinuity. Any other group that ends or is reset without its keyframe is discarded, since it can never be delivered, and the end or reset of a group that has not been seen is kept only for the next group.
-
When the next group ID has not been seen, the lowest group holding its keyframe waits while that keyframe is not yet due (its timestamp ahead of the position), since the missing group may still arrive or may never have been sent (a failed group’s ID is not reused). Groups and objects behind what was delivered or passed over, or below the group being delivered, are dropped as late.
-
Frame grouping (audio): every frame stands alone and goes out at once in group and object order. A group may hold several frames, one per object, as a group opened with
openGroup()/open_group()writes them. The position is then a group and an object, so a group’s later objects are still ahead of its first. A frame whose group ID skips past the last delivered one, whose object ID skips within the last delivered group, or that opens a newer group past its object 0, carriesgap. A frame more than the clock’slateDropUsbehind the position is dropped, so a burst after a stall cannot build a lasting backlog in the player. One behind what was delivered (an earlier group, or an earlier object of the same group) is dropped too. Nothing waits unless a reorder window is set (below). -
Per-track delivery policy is TypeScript only. Python keeps the defaults. Three sequencer options tune one track without touching the clock its other tracks share. Each default leaves the behaviour above unchanged:
lateDropUs, frame grouping only: the late-drop limit for this track instead of the clock’s.Infinitydrops nothing as late, for a reader that wants every frame (speech-to-text).reorderWindowUs, frame grouping only (default 0): a frame whose group ID is more than one past the last group delivered, dropped or known to be over waits, with every newer one, for the missing group (a later object of the last group taken is not held), until the window has passed since that frame’s group arrived (on the clock’s local time). A missing group that arrives in time goes out in order with nogap. After the window the waiting frames go out withgap. A group that ended or was reset with nothing in it releases the wait at once, whenever its end arrived, before the first delivery or after a restart included. A frame that turns late while it waits is dropped as late. For a reader that wants every frame of a talker, the window should be at least the publisher’s delivery timeout (audio’s default is 500 ms): a group the publisher’s transport is still resending can arrive that long after the group that follows it, and moq-relay 0.17.5 delivers it however late. With a relay that honours a subscriber’s delivery timeout, the agent’s subscription sends none, or one at least as long as the window.catchUpAfterUs: when frames keep arriving late with none delivered, from the first one dropped as late to the latest one at least this long apart (on the clock’s local time), the next poll returnscatchUp: true, once, until a frame is delivered again. A restart does not re-arm it, so a replacement that is late from its first frame resyncs rather than catches up again. The TypeScript reader callsonCatchUp, whose owner replaces the subscription at the live edge (a want’s catch-up). Set it below the target latency, or a resync re-anchors on the late frames first.
-
A delivered frame whose group ID is more than one past the previous delivered frame’s carries
missingGroups(TypeScript only): the inclusive range of group IDs of which nothing was delivered, whether never received, reset, or dropped as late or behind. It is the gap report an application acts on. Nothing in the SDK fetches the missing groups. A trailing loss is reported only with the next delivered frame, and never across a restart, where group IDs start over. -
The first frame after anything was skipped carries the discontinuity
gap. A decoder resets there, as at a keyframe it can start from. No gap is marked before the first delivery. -
Resync: once frames have arrived but none within the budget for one whole target latency of the position, with their lateness steady, the sequencer delivers the newest late group that still has its keyframe (or the newest late frame), marks it
resync, and resets the clock, which re-anchors on the next frame shown. Lateness is steady while it stays within a quarter of the target latency of where it stood when the wait began, judged on every late object, held or arriving. A stall’s growing lateness and a draining backlog’s shrinking one restart the wait. That group keeps collecting its later objects and its end while it waits. The wait is measured on the clock’s local time (localNow()/local_now_us()). If another track resets or re-anchors the shared clock meanwhile (a reset, or a position that no longer moves with local time, by more than a quarter of the target latency), a candidate back within the budget (or facing an idle clock) is delivered normally instead, and otherwise the wait starts over on the new timeline. -
Resync forward: a keyframe that would open the next delivered group (or, under frame grouping, a frame) more than the target latency ahead of the position means the clock was anchored on a backlog, as after a resync during a long stall. The sequencer resets the clock and marks that frame
resync, so the backlog does not become lasting latency. It does so in a poll of its own: frames before it in the same poll are returned first, with a wait of 0. A lasting increase in delay therefore costs one target latency, not a black or silent player. -
Timestamp playout (TypeScript only, frame grouping only, off by default):
timestampPlayoutholds a frame that arrives ahead of its timestamp, for a talker that sends faster than real time (a text-to-speech agent). A frame, and every one after it, waits until its timestamp is within the lead (leadUs, default 100 ms; a function is read at every poll, and a value it returns that is not a finite number from 0 up counts as the default) of the position; the poll’s wait says when. While the clock has no position, the first frame considered anchors one of the sequencer’s own, that frame’s timestamp at the clock’s local time, until the clock has a position or is reset. Only a frame more thanhorizonUs(default 30 s) ahead of the position (or, before the clock has one, of the sequencer’s own) resyncs forward; one within it is held instead. Late drop, resync and catch-up are unchanged. The held bound grows to cover the horizon at 2.5 ms frames. A frame held is counted once (early). The audio player input’splayoutLeadUsis the lead that keeps its jitter buffer at its target. The sequencer keeps the least delay seen from the clock’s position to its local time since the clock was last reset, with the lead in force then; when the delay now exceeds it, less any growth of the lead since, by more than 40 ms (underruns left the clock behind), held frames are released early by the excess beyond those 40 ms, so the player’s jitter buffer holds it and trims it back. A lead that changes while the delay does not releases nothing early. -
A want’s Restart or Move restarts the sequencer: everything held is forgotten, group IDs start over, the clock is reset so the new timeline is not judged against the old anchor, and the next frame carries
restart, even when nothing was delivered before and even when that frame comes by a resync. A Move also brings the new subscription’s track properties. -
poll()returns the frames ready now, at mostmaxFrames/max_frames(a positive integer, default all), and how long until a wait can end: 0 when more is ready now, nothing when only a new object can change it. Frames not taken stay held and are judged again at the next poll. While the clock is idle and frames are held, it asks to be polled again after 50 ms, since the clock may anchor without any new object. At most about 1,024 frames are held: past that, as when nothing polls, everything held is dropped as late and the next frame carriesgap. The TypeScript reader polls after every event, the Python reader while a read is pending. Both poll again when that time comes, or after 50 ms if sooner: another track may reset or re-anchor a shared clock meanwhile, which no event of this track’s reports. The TypeScript reader hands each frame toonFrame. An event fed from withinonFrameis applied after the frame in hand, and a Restart or Move fed that way drops the rest of the batch, which belongs to the replaced subscription. The Python reader is iterated and takes one frame per read, so a slow reader gets fresh frames, not a backlog. Both treat each group as one subgroup, as the frame writer sends it. A want read through a reader keepsdrainMs/drainat 0, the default: a drained old subscription’s groups would arrive after the Move, indistinguishable from the new subscription’s. Counters: delivered, late, skipped groups, resyncs, untimed, and in TypeScript early. The decisions are pinned byvectors/frame-sequencer.json. -
A listener follows a track replacement by the catalog:
findReplacementTrack/find_replacement_trackreturns nothing while a newer catalog still lists the track playing (its namespace and name), and otherwise the first track listed with that track’s namespace, role and render group, or nothing when there is none (a removal, not a replacement).
Package boundaries
Section titled “Package boundaries”-
The optional TypeScript Mediabunny adapter depends on the media format package and declares Mediabunny as a peer. Neither the core, format package, nor product SDK depends on the adapter.
-
The generic media format package depends only on the core, imports no product or transport library, and owns no encoder outside a module behind an optional extra that its root does not import: Python’s
moq_media.opus, on PyAV (ADR 0006). The core imports nothing from the media package. -
The product package does not depend on the media format package either. Its media policy pairs with it structurally: a frame carries its own properties, and the frame reader drives a sequencer it is given.
-
Each language has a product package that owns the Relay API and the authentication convention, and a generic MOQT core package that is protocol-only.
-
The core has no runtime dependencies, no knowledge of Relay, and no player, UI, or media engine imports. It works over an abstract transport that the product package supplies.
-
Transport adapters are separate packages the application passes in, and the product package depends on none of them. The TypeScript WebSocket adapter is the only package with a third-party runtime dependency,
@moq/qmuxat an exact version (ADR 0005). -
Each language enforces this mechanically in its check task.
Tooling conventions
Section titled “Tooling conventions”Live tests and examples in every language read the same environment:
| Variable | Meaning |
|---|---|
RELAY_TOKEN |
Relay JWT. When absent, tooling prompts with input hidden. Never written to disk or printed. |
RELAY_URL |
Overrides the default endpoint in protocol.json. Must be https and must not carry the token. |
RELAY_SUB_URL |
Live tests only: the subscribers’ relay, for a cross-relay run. Defaults to RELAY_URL; same rules. |
RELAY_NAMESPACE_PREFIX |
Namespace prefix the token authorizes. Live tests append a random field so runs never share relay state. |
Live smoke test
Section titled “Live smoke test”Each SDK ships an opt-in test against a real relay, excluded from the default check. It publishes and subscribes on the same random namespace, then asserts:
- A publisher connection and a subscriber connection both open with the supplied token.
- Once the publisher’s
announcedsettles, subscriptions to two tracks on the namespace are accepted, retrying the fresh-namespace rejection while the announce propagates. A supervised subscriber’s wants make them and never resubscribe during the run. - Binary payloads, an empty payload, and a text payload all arrive intact. Each is written once,
after a warm-up marker the subscriber drops has arrived on that track: a
SUBSCRIBE_OKdoes not mean the path to the publisher is ready (a viewer on another relay). The report records the warm-up writes. - Closing one track writer does not affect delivery on another track.
- A late subscriber decodes
LARGEST_OBJECTfrom a populated track’sSUBSCRIBE_OKand receives the next live object. - The subscriber’s discovery on the namespace’s parent prefix reports the namespace
announcedonce the publisher’sannouncedsettles, thenunannouncedafter the publisher’s cleanclose(), and nothing else for that namespace. The report records how long the withdrawal took. - A want following a supervised publisher’s root receives the first
generation’s objects. When a second supervised publisher starts on the same root while the
first is still up, the want moves to the newer generation make-before-break. Both generations
beat one shared counter every 50 ms, and the want cuts over once a beat of the new one is within
a few of the latest delivered, with a 1 s drain. It reports one
onSwitchedand noonRestarted, and no beat number is missing across the move, on one relay and across two. The report records how long the move took. - A switchable subscription moves from one layer to another make-before-break. The layers carry the same frame numbers, one group per frame, and no frame number is missing across the cutover. The report records how long the cutover took.
- A room-rooted endpoint, whose path is the run’s namespace (built with
roomEndpoint/room_endpoint, so the namespace prefix must hold only room fields), takes namespaces relative to that path (subscribing). A publisher on each form and a subscriber on each form receive each other’s objects, discovery on each form names both publishers in its own form, and the relay refuses an absolute namespace on the rooted connection without delivering any of the room’s objects. The report records how long discovery took and the refusal. - All connections close cleanly once the test closes them.
Adding an assertion here means adding it to every SDK’s smoke test. The TypeScript test runs the
same scenario over each transport it ships: native QUIC in Node, WebTransport in Chromium, and QMux
over WebSocket in Node and Chromium, with WebTransport in Firefox, WebKit and Firefox over WebSocket,
and the WebTransport-to-WebSocket fallback on request (RELAY_RUNTIME).
See relay interoperability for a local moq-relay test.
Not yet specified
Section titled “Not yet specified”Datagram reception, discovery of tracks within a namespace (namespace discovery is specified), and player adapters are not implemented in any SDK and have no contract yet. Add them here when the first SDK gains them.
Draft-22 protocol boundaries
Section titled “Draft-22 protocol boundaries”MAX_REQUEST_IDandREQUESTS_BLOCKEDwere removed after draft-16 (draft-22 Appendix A.6). They are not draft-22 request-budget messages. Unknown control messages terminate the session, as required by Section 9.- An unrecognized wire Object Status terminates the session with
PROTOCOL_VIOLATION, following Section 11.1.1. TheUNKNOWN_OBJECT_STATUSstream-reset code has a different meaning: a FETCH publisher cannot determine whether an object exists (Section 12.5). Ordinary subgroup transport resets remain per-stream delivery events. - A publisher retains no objects, so it serves no fills
(Section 3.4).
A SUBSCRIBE or REQUEST_UPDATE with
FILL_PARAMETERSis answered as if the parameter were absent (SUBSCRIBE_OK, or REQUEST_OK for an update), and nothing about the subscription changes. Neither SDK publisher reports a Largest Object there (only in TRACK_STATUS_OK, below), so every fill range is empty and no fill fetch stream opens. The TypeScript core’s publish session can report one (getLargestObjecton a registered track, for embedders such as Video.js). It then sendsLARGEST_OBJECTin SUBSCRIBE_OK and REQUEST_OK, and fails a nonempty fill the way Section 3.4.1 requires: it opens a fill fetch stream, writes the FETCH_HEADER (committed where the transport hascommit()), resets it, and counts it in PUBLISH_DONE’s Stream Count. - A publisher connection serves SUBSCRIBE, SUBSCRIBE_NAMESPACE (the announce carrier) and
TRACK_STATUS. It answers PUBLISH with REQUEST_ERROR
UNINTERESTEDand every other request (FETCH, PUBLISH_NAMESPACE, SUBSCRIBE_TRACKS) withNOT_SUPPORTED: a non-relay endpoint may implement a subset and should answer the rest that way (Section 1.6). - TRACK_STATUS_OK from either SDK publisher carries LARGEST_OBJECT once the track has written an object, as
Section 9.20.17
requires (“If Objects have been published on this Track the Publisher MUST include this
parameter”). The publisher’s SUBSCRIBE_OK still does not, so the two answers differ there,
where Section 9.13
says they carry the same parameters. Adding it to SUBSCRIBE_OK changes what every relay’s
upstream subscription sees and turns a fill request into a failed fill (below), and has not been
tested against moq-relay (publisher history).
A TypeScript core track with
getLargestObjectanswers TRACK_STATUS with that Largest Object, the same one its SUBSCRIBE_OK carries.
Relay interop deviations
Section titled “Relay interop deviations”Four choices in the generic cores depend on how moq-relay treats the draft’s teardown messages,
track aliases and request updates. All four conform to draft-22 (and draft-20). Each is recorded because the
relay’s behavior, and for the first one its version, decides whether it works. The relay behavior,
the evidence and how to re-test are in relay
interoperability. Re-test against the relay before changing
one: on a relay older than 0.15.4 the first one aborts every viewer’s track. Both SDKs agree on all
four, and the core module headers (ts/packages/moq-core/src/protocol/publish-session.ts,
python/packages/moq-core/src/moq_core/publish_session.py) state the same constraints.
-
A subscription ends with PUBLISH_DONE, then FIN, which needs moq-relay 0.15.4 or later. Draft-22 Section 6.4.2.2: “the publisher of an Established subscription MUST send PUBLISH_DONE, before sending a FIN”, and Section 9.9: “A sender MUST NOT send PUBLISH_DONE until it has closed all streams it will ever open”. This conforms. Every end the publisher chooses resets the subscription’s data streams that are still open, writes PUBLISH_DONE with Stream Count set to the data streams opened for that subscription, and FINs the request stream:
end PUBLISH_DONE status reason closing a track writer, or the publisher TRACK_ENDED(0x2)track endeda replacement subscription supersedes an older one TRACK_ENDED(0x2)superseded by a newer subscriptionan unsupported REQUEST_UPDATE, after its REQUEST_ERROR (Section 9.5.1) UPDATE_FAILED(0x8)unsupported subscription updatethe subscriber FINs its direction while the subscription is live INTERNAL_ERROR(0x0)subscriber closed its request streamNo status names a replacement, and the relay finishes a track only on
TRACK_ENDED. A subscription the subscriber cancels (a reset) gets no PUBLISH_DONE, and its open data streams are reset (Section 3.1.2). Once a subscription is ending no data stream opens on its alias, and one that finishes opening afterwards is reset and not counted. moq-relay 0.15.4 and later read the PUBLISH_DONE, wait for Stream Count data streams or a grace period (1 s), finish the track, and only then FIN their side of the request stream: a viewer receives an end-of-track object and PUBLISH_DONE status 2track ended. A session that closes before the relay has done so aborts the track, which a viewer sees as status 0internal error, so closing the publisher waits for that FIN (the one Section 6.4.2.2 says a requester SHOULD send once the responder’s FIN completes the request) on every subscription it ended before closing the transport (bounded, see deviation 2). The SDK subscriber sends that FIN too, once the publisher’s FIN follows PUBLISH_DONE, so an SDK publisher facing an SDK subscriber directly closes without waiting out the bound. moq-relay 0.15.4 and later treat a FIN without PUBLISH_DONE as a failed request and abort the track. moq-relay 0.15.1 to 0.15.3 do the reverse: any byte after SUBSCRIBE_OK aborts the track, so every end these SDKs send reaches viewers as status 0internal error, where a bare FIN finished the track. No behavior ends cleanly on both, and the SDKs cannot tell the versions apart (the relay’s SETUP names onlymoq-lite-rs), so they require 0.15.4 or later. -
Neither connection sends GOAWAY. Draft-22 Section 9.2: “An endpoint sends a GOAWAY message on its control stream to inform the peer it intends to close the session soon”, and “A client MUST send a zero-length New Session URI in any GOAWAY”. GOAWAY is optional, so omitting it conforms. The SDK sends none because it has no drain behavior: a GOAWAY written as the transport closes gives the peer nothing to act on. How the relay treats a client GOAWAY depends on its version: moq-relay 0.14.17 closes the whole session, and 0.15.1 and later record it and keep the session. Closing the publisher ends every subscription with PUBLISH_DONE and a FIN (deviation 1). It then waits, for up to the largest write deadline among its tracks, for the relay to acknowledge the connection’s settling groups. Python also waits for the PUBLISH_DONEs and FINs to be acknowledged, so a short uplink outage at close does not send the session close while they are still in flight. After that it retracts the namespace with NAMESPACE_DONE, gives those writes a bounded window (250 ms), then waits up to 2 s for the relay to FIN its side of each ended subscription’s request stream, and ends the session. None of these bounds reports an error when it expires. The 2 s covers the relay’s 1 s grace plus a round trip. Against moq-relay the wait ends as soon as the relay has finished every track, typically within a round trip. Closing a subscriber connection closes the transport and writes nothing more on the control stream. The application observes nothing: the relay learns of the shutdown from the transport close, and from a publisher’s PUBLISH_DONEs and retraction before it. A GOAWAY the relay sends is still received and handled on both connections. Sending GOAWAY becomes useful once the SDK can drain on it, which means giving it time to arrive before the transport closes. On relays older than 0.15.1 it would still close the session.
-
A track alias is the peer’s own request ID. Draft-22 Section 3.1: “A publisher MAY assign the same or different Track Aliases to these subscriptions”, and Section 3.1.3: “The same Track Alias MUST NOT be used by a publisher to refer to two different Tracks simultaneously in the same session.” This conforms, and narrows a MAY: every SUBSCRIBE_OK carries the SUBSCRIBE’s request ID as the alias, which is unique for the session by construction, so an alias is never shared between subscriptions, of one track or of two. A request ID the peer reuses, or one with a client’s (even) parity, closes the session with
INVALID_REQUEST_IDbefore it can become an alias, as Section 6.4.2.1 requires. The subscriber connection applies the same check to every request the relay opens, including the ones it rejects, before answering. moq-relay allocates server request IDs odd, from 1, so the parity check never fires against it. moq-relay 0.15.1 and 0.15.8 close the session with PROTOCOL_VIOLATION when one alias names two different tracks, and cancel the newer subscription when two live subscriptions to one track share an alias. Unique aliases avoid both. The application never sees an alias on the Relay surface. Nothing needs to change unless the SDK serves concurrent subscriptions to one track, which ADR-0001 rules out today. -
update()sends REQUEST_UPDATE, which moq-relay does not implement. Draft-22 Section 9.5: “The receiver of a REQUEST_UPDATE MUST respond with exactly one REQUEST_UPDATE_OK or REQUEST_UPDATE_ERROR message indicating if the update was successful”, per-request names for REQUEST_OK and REQUEST_ERROR (Section 1.5). This conforms: eachupdate()on an accepted subscription writes one REQUEST_UPDATE, with its own Request ID, on that subscription’s request stream. A REQUEST_OK reachesonUpdateOk/on_update_ok, and a REQUEST_ERROR reachesonError/on_errorand ends the subscription. moq-relay answers neither, from 0.14.17 through 0.17.0: after SUBSCRIBE_OK it only polls the request stream for closure, any inbound byte counts as a close there, and it then takes its normal end path, dropping its upstream demand and writing PUBLISH_DONE statusTRACK_ENDED(0x2)track ended. A viewer that callsupdate()therefore getsonPublisherDone/on_publisher_donewithin one round trip and no further objects, whatever the update carried (a subscriber priority,forward0, or nothing), andonUpdateOk/on_update_oknever fires. As after any done, the subscription’sendedstays pending until it is cancelled or the connection closes. The session stays open, and a new subscribe on the same connection works. So against moq-relay a pause is a cancel followed by a new subscribe later, and a changed priority or filter is a new subscription. Subscribing paused is not an alternative: moq-relay 0.17.0 refuses a SUBSCRIBE withFORWARD= 0 with request error 3 (NOT_SUPPORTED)FORWARD=0 not supportedand keeps the session open, where 0.15.8 closed the session. The SDK sends the update anyway and does not warn, as decided in issue #94. REQUEST_UPDATE support would be an upstream relay feature.
Both cores pin the second choice for each role: after an orderly close, the control stream of a publish session and of a subscribe session carries SETUP and nothing else.