moq-relay interoperability
Both SDKs offer moqt-22, then moqt-20, and speak whichever the relay selects; moq-relay selects
moqt-22 from 0.15.0 (draft-22 LOCATION_FILTER framing).
Their LARGEST_OBJECT message parameter is two bare
varints (Group, Object), matching draft-22 §9.20 and moq-relay from 0.14.17 through
0.17.0 (SubscribeOk in rs/moq-net/src/ietf/subscribe.rs at tag moq-relay-v0.17.0).
Production moved to 0.17.0 on 2026-10-05. As of 2026-10-28, every Mux relay is in the OCI fleet
(phx, ord, iad, and fra), which runs 0.17.7. The Fly relays, sjc among them, retired on
2026-10-23.
Upstream fix.
Older moq-relay releases used a length prefix for this parameter. That encoding cannot be safely auto-detected: coordinate adoption of these SDK builds with the relay upgrade. Old SDK builds fail on subscriptions to populated tracks after the relay upgrade; these SDK builds misdecode or reject the old relay’s populated-track responses.
INCLUDE_PROPERTIES (0x35) is one raw byte with no length, matching draft-22 §9.20.21 and
moq-relay 0.17.0 (Subscribe in rs/moq-net/src/ietf/subscribe.rs at tag moq-relay-v0.17.0).
0.15.8 and earlier length-prefixed it. Neither relay version sends it upstream, so only an
application that sets include_properties / includeProperties on a SUBSCRIBE sees the change.
An SDK build from before PR #246 that sets it
against 0.17.0 gets its whole session closed with PROTOCOL_VIOLATION (0x3) wrong frame size
(measured on production on 2026-10-05, 3 of 3); a current build against a relay older than 0.17.0
is misread the other way.
Draft-22 LOCATION_FILTER framing
Section titled “Draft-22 LOCATION_FILTER framing”Draft-22’s one wire change from draft-20 is the LOCATION_FILTER parameter (0x21): it opens with a
Location Filter Type that says which fields follow and has no length
(draft-22 §9.20.9).
Draft-20 length-prefixed up to four fields and read the meaning from how many were present
(docs/research/moqt-22-delta.md). The SDKs encode the filter
for the negotiated draft. moq-relay negotiates moqt-22 from 0.15.0, but through 0.17.2 it frames
LOCATION_FILTER the draft-20 way on every draft from 20 on (Filter::is_draft20 and
Param for Filter in rs/moq-net/src/ietf/filter.rs at tag moq-relay-v0.17.0). moq-relay
0.17.3 fixed this: it frames the filter by the draft-22 rules on moqt-22
(moq-dev/moq#5080, for
moq-dev/moq#4847). Every Mux relay runs 0.17.7
and has the fix. The Fly r1 fleet ran 0.17.0 without it until it retired on 2026-10-23
(issue #275). On 0.17.0 the two spellings are
the same bytes when the field count equals the type and every field is under 128
(spec/vectors/location-filter.json), so most filters work; the ones that do not are:
| filter | draft-22 bytes the SDK sends | what moq-relay 0.17.0 does on moqt-22 |
|---|---|---|
Next Object (next-object / NextObjectFilter) |
21 05 |
reads 05 as a length and closes the session with PROTOCOL_VIOLATION (0x3) short buffer |
open-ended absolute {0,0} |
21 02 00 00 |
reads it as Next Object, the draft-20 meaning of those bytes |
| any start group, start object, end delta or end object of 128 or more | type, then multi-byte fields | misreads the length and the fields that follow |
Measured on production (sjc, 0.17.0) on 2026-10-06: a subscription with no filter, the explicit
no-filter type 0x00, relative-group 1 and an absolute start at group 3 were all accepted and
delivered on moqt-22; Next Object closed the session as above. The relay’s own upstream SUBSCRIBE
to an SDK publisher carries 21 02 00 00 (Next Object, in the draft-20 framing) and a fill of
21 01 01, which read under draft-22 as an absolute start at {0,0} and a relative start of 1. No
SDK publisher acts on a subscription’s filter, so that changes nothing. On moqt-20 every shape
works, Next Object included. Every Mux relay now runs 0.17.3 or later. Against an older
relay, a live-edge subscriber uses a relative start of 0 (the next group) rather than Next Object, as the live smoke tests do, or leaves
the filter out (issue #275).
Publishers still retain no history and advertise no LARGEST_OBJECT in SUBSCRIBE_OK (their
TRACK_STATUS_OK does carry it, below). The relay’s
Next Object + current-group FILL_PARAMETERS pull (since 0.14.17, unchanged through 0.17.0, whose
upstream SUBSCRIBE was observed live on 2026-10-05) therefore has no cached publisher
content to fill; the publisher serves subsequent writes. Adding publisher retention and
backfill is a separate feature. The test fixtures send the pull parameters of 0.14.17 to 0.17.0.
An embedder of the TypeScript core that reports a Largest Object makes that fill range nonempty
once the track has content; the core then fails the fill with a reset fill fetch stream and keeps
serving the subscription (behavior).
Publisher restarts and resubscription
Section titled “Publisher restarts and resubscription”Measured against production moq-relay 0.17.0 on 2026-10-05 with the Python SDK, three runs per case and six for a crash (killed or stopped with SIGSTOP); on 0.15.8 on 2026-09-28 the resubscription rows were measured with TypeScript too (issue #34, issue #21). A replacement publisher announced the same namespace about 1 s after the first one ended.
| how the publisher ends | existing subscriptions | new subscribes |
|---|---|---|
close() on the publisher |
done(2) track ended within 0.1 s (3 of 3; on 0.15.8 some runs gave 0 internal error) |
request error 16 unroutable until the replacement announces, then served by it at once |
| its connection closes without MoQ cleanup | done(0) within 0.1 s | the same |
| the process is killed or stopped | silent, then done(0) internal error after about 11 s (10.9-11.35 s, 6 of 6), the relay’s QUIC idle timeout; 30-35 s on 0.15.8 |
accepted, but served only the dead publisher’s cached last object until the replacement announces; after that, served by the replacement at once in 3 of 6 runs, and in the other 3 still the dead publisher’s cached object until the idle timeout |
In every case the existing subscriptions are never moved to the replacement: they end with the old
publisher, and only a new subscribe reaches the new one. Until the idle timeout the relay still
holds the crashed publisher’s announcement next to the replacement’s, and from 0.17.0 it does not
prefer the newer of two (see duplicate announce below), so a new subscribe
can land on the dead one. A crashed peer is noticed only at the QUIC idle timeout, 10 s from 0.17.0
(DEFAULT_IDLE_TIMEOUT in rs/moq-tokio/src/quic.rs:90 at tag moq-relay-v0.17.0, upstream
a834dfdea; 30 s at 0.15.8).
Re-subscribing to a finished track gets one of two answers, depending on whether its publisher is still there, and neither delivers new content:
-
The publisher has gone. Every re-subscribe gets request error 16
unroutable, because the relay answersUnroutablefor an ended broadcast (rs/moq-net/src/model/broadcast.rs:813-830at tagmoq-relay-v0.17.0). A loop that re-subscribes from the done callback runs only at the application’s own retry cadence: over five publisher generations each subscription got exactly one done and every object arrived once (75 / 75), with and without cancelling, at 10 ms and 300 ms retry delays (0.15.8). Against 0.14.17 a closing broadcast still served its tracks, so the same loop replayed cached objects and done at round-trip cadence; that was the storm reported in issue #21. -
The publisher stays connected and announced after the track ends cleanly (its track writer closed). The relay keeps a finished track in a live broadcast cached and serves it again by design (“Publish new content under a new name”,
broadcast.rs:836-850): every re-subscribe gets SUBSCRIBE_OK, the cached last group again, an end-of-track object and PUBLISH_DONE status 2, about one round trip (50 ms) after the OK. Re-subscribing from the done callback, with the publisher held for 3 s over three track generations (Python rows identical on 0.15.8 and on 0.17.0, 3 of 3 runs each; TypeScript rows measured on 0.15.8):SDK retry delay dones objects delivered / published Python 10 ms 57 99 / 45 Python 300 ms 21 63 / 45 TypeScript 10 ms 51 93 / 45 TypeScript 300 ms 21 63 / 45 A loop that keeps re-subscribing keeps the track cached: with the publisher held for 40 s at a 300 ms retry delay, it replayed the track 89 times. From 0.17.0 a finished track that nobody reads for about 30 s (
TRACK_IDLE_LINGER, upstream166f9e87b) is forgotten, and a subscribe then gets request error 16not foundwhile the publisher stays announced, andunroutableonce it has gone (measured on 2026-10-05, 3 of 3: a re-subscribe 1 s after the end was replayed, 36 s after it wasnot found).
A done with status 2 does not say on its own why the subscription ended: moq-relay also sends it
when this viewer calls update() on a live track
(deviation 4). Tell the two apart by what the application
knows:
- The done followed this subscription’s own
update(). The track is still live; re-subscribe with the new parameters. That is the documented way to change them against moq-relay. - The publisher ended the track (the application knows from the publisher side, its catalog or its own discovery). Do not re-subscribe to it: wait for a signal that new content exists (a new track name in the catalog, or a new namespace or announce seen through discovery), and back off any re-subscribe you do make. Publish restarted content under a new track name rather than reusing the finished one.
Each subscribe() creates an independent request, including its starting location.
It does not resume the preceding subscription. The SDK does not retry automatically
or suppress objects received by new subscriptions.
Applications that follow a restarting publisher should apply these policies:
- Keep at most one pending retry per logical track. Schedule retries for done notifications as well as retryable request errors; a delay used only for the fresh-namespace rejection does not bound an accepted-subscription/done loop. Cancel pending retries when the application stops, changes source, or closes the connection.
- Use capped exponential backoff with jitter and an application retry budget. For
example, start around 500 ms and grow to a 5 s cap. Do not reset backoff solely on
SUBSCRIBE_OKor a cached object: neither proves that a new publisher is live. Reset only on application-defined progress, such as advancing sequence numbers from the expected publisher generation. Backoff limits request load; it does not eliminate replay or guarantee recovery. - Treat done as a control notification, not a data-drained signal. Trailing objects may arrive afterward. Define how long to drain a retired subscription before cancelling it, or explicitly accept losing trailing objects when cancelling immediately. Bound the number and lifetime of retired subscriptions.
- If continuity or duplicate suppression matters, put a publisher generation and sequence number in the payload and define the application’s replay policy. Group/object IDs alone are insufficient across restarts because a new publisher can restart its counters. Do not discard all lower group IDs as stale.
- Choose subscription location filters to match the application’s history needs. A new subscription’s filter is not an exactly-once resume token across publisher generations, and opening another connection is not a cache invalidation guarantee.
Record done status and reason for diagnosis. Around a publisher shutdown both track ended (2)
and internal error (0) occur, and a crash ends with 0; neither identifies a new publisher
generation. Preserve actual errors rather than interpreting all status-0 responses as harmless
shutdowns.
Duplicate announce
Section titled “Duplicate announce”When two publishers announce the same namespace, moq-relay 0.17.0 routes new subscribes to one of
the two connections, effectively at random: route_order (rs/moq-net/src/model/origin.rs:633-649
at tag moq-relay-v0.17.0) ranks equal routes by a hash of the namespace and the route’s hop chain,
and each publisher connection’s announcements carry a random hop id (stamp: Hop::random() in
rs/moq-net/src/ietf/subscriber.rs), before it looks at which announcement is newer. Measured on
production on 2026-10-05 with the Python SDK
(issue #37), the second publisher won 6 of 12
runs. The choice was stable while both stayed connected, and the losing publisher got no demand.
When the serving publisher closed, its viewers got done(2) and did not move, and new subscribes
went to the other one at once. When it crashed, new subscribes kept getting its cached last object
until the idle timeout, and its viewers then got done(0). A local moq-relay 0.15.8 gave every new
subscribe to the newest announcer (6 of 6).
Announce propagation
Section titled “Announce propagation”The relay acknowledges a NAMESPACE entry with nothing, and it routes subscribes to the namespace
only once it has processed the entry. Measured against sjc.relay.mux.global on 2026-09-23 with the
Python SDK: a fresh namespace per trial, the subscriber connection already open, three interleaved
runs of twelve trials per mode. The relay was moq-relay 0.15.1, which answered the race with 404.
| subscribe issued | rejected 404 |
|---|---|
at once after register_track, before the announce left |
13 / 36 |
after announced settled (within 1 ms of register_track) |
4 / 36 |
after announced + 100 ms |
0 / 12 |
after announced + 250 ms |
0 / 12 |
after announced + 500 ms |
0 / 12 |
The relay answered every subscribe in about 50 ms, one round trip from that vantage point. So the
window after announced is about one round trip, no publisher-side fact can close it, and the
retry belongs to the application (behavior.md). A subscriber in another
process can observe the announce only through discovery, which closes the
race on 0.17.0 (below). Earlier text said “about a second”; that was the retry
interval of the original reproduction, not the window.
moq-relay 0.15.8 (production from 2026-09-28) still raced, but answered request error 16 (0x10,
DOES_NOT_EXIST) with reason unroutable instead of 404
(issue #136). It sends the same code with reason
not found for an unknown track in an announced namespace, so only the reason tells the race apart
from a request no wait can satisfy. Measured on 2026-09-28 with the Python SDK through the examples’
retry, ten trials per mode: at once after register_track, 6 / 10 first attempts were rejected
unroutable; after announced, 3 / 10. Every rejected trial was accepted on the second attempt,
100 ms later, and a subscribe to an unknown track in each namespace came back not found once,
unretried.
moq-relay 0.17.0 (production from 2026-10-05) races less often but answers the same way, so the
retry is still needed. Measured on 2026-10-05 through the examples’ retry: with the Python SDK, 2 / 30
first attempts at once after register_track and 1 / 30 after announced were rejected
unroutable; with the TypeScript SDK, 4 / 10 and 0 / 10. Every rejected Python trial was accepted
on the second attempt, and an unknown track still came back not found in both SDKs.
A subscribe sent on the subscriber’s own connection once its discovery
reports the namespace announced was never rejected
(issue #269). Measured on 2026-10-06 against
moq-relay 0.17.0, publisher on sjc.relay.mux.global, subscriber on sjc or on fra.relay.mux.global:
a fresh namespace and publisher connection per trial, the subscriber connections already open, one
subscribe at once after the trigger, then retries every 100 ms. Both SDKs (TypeScript in Node over
native QUIC), four interleaved runs of ten trial pairs per SDK and relay, the two modes alternating
within each pair:
| subscribe issued | subscriber on sjc | subscriber on fra |
|---|---|---|
at once after the publisher’s announced settles (baseline, Python) |
6 / 40 rejected | 40 / 40 rejected |
at once after the publisher’s announced settles (baseline, TypeScript) |
14 / 40 rejected | 40 / 40 rejected |
at once after discovery reports announced (Python) |
0 / 40 rejected | 0 / 40 rejected |
at once after discovery reports announced (TypeScript) |
0 / 40 rejected | 0 / 40 rejected |
Every rejection was request error 16 unroutable, and every rejected trial was accepted on the
second attempt, 100 ms later. Discovery reported the namespace a median 50 ms after the publisher’s
register_track / registerTrack on sjc and 147 to 151 ms (at most 283 ms) on fra. The window on
another relay is correspondingly wider: a subscriber there that subscribed as soon as the
publisher’s announced settled lost the race every time. The relay builds NAMESPACE entries from the same origin it
routes subscribes on (run_subscribe_namespace_stream → origin.announced() in
rs/moq-net/src/ietf/publisher.rs at moq-relay-v0.17.0), including a route learned from a cluster
peer, which matches what was measured. That is an observation of 0.17.0, not a guarantee the
protocol makes.
Namespace discovery
Section titled “Namespace discovery”moq-relay 0.17.0 serves SUBSCRIBE_NAMESPACE from client sessions
(run_subscribe_namespace_stream in rs/moq-net/src/ietf/publisher.rs at tag
moq-relay-v0.17.0). On draft-20 and draft-22 it answers REQUEST_OK without a request id, then writes NAMESPACE
and NAMESPACE_DONE entries named relative to the prefix. Its source is the relay’s origin, which
includes routes learned from cluster peers.
- Which of two paths carries the advertisements depends on the peer’s SETUP
(
rs/moq-net/src/ietf/solicit.rs). A peer that sends no SOLICIT option gets an unsolicited PUBLISH_NAMESPACE for everything in scope right after SETUP, and its SUBSCRIBE_NAMESPACE streams stay open but carry nothing. A peer that sends SOLICIT=1 gets only the solicited path. The relay itself always sends SOLICIT=1. A peer that writes the option at all, even as 0, declares it implements the extension, and the relay then holds it to never advertising unasked. The subscriber connection sends SOLICIT=1 (behavior.md). Before this, both SDKs refused the pushed PUBLISH_NAMESPACE with NOT_SUPPORTED and retry interval 0, which the relay reads as “never offer this again” (refusal()inpublisher.rs) for the rest of the session. - A prefix outside the session’s grant gets REQUEST_OK and no entries, not an error.
- It does not answer an overlapping SUBSCRIBE_NAMESPACE with PREFIX_OVERLAP, as draft-22 §9.15
requires: a probe on 2026-10-06 held
[a, b, c]and[a, b, c, pre]open on one session, and both received the namespace they share. The SDKs refuse an overlapping prefix before sending it, so applications see the same rule against any relay. - The relay writes NAMESPACE again for a namespace it already advertised when that namespace’s route changes, so entries are not edge-triggered on the wire. The SDKs drop those repeats.
- Measured on 2026-10-06 against sjc, and from fra for a publisher on sjc (TypeScript SDK, Node):
the snapshot arrived one round trip after the request (~50 ms, ~110 ms). A new announce arrived
~50 ms (~140 ms) after the publisher’s
announced, and a clean close was withdrawn within ~400 ms. A SIGKILLed publisher was withdrawn after ~12.2 s (~12.3 s), at the QUIC idle timeout. - A subscribe on the same connection at once after
announcedwas never rejectedunroutable: 0 of 160 trials on 2026-10-06, in both SDKs, on the publisher’s relay and on another one, against 6 to 14 of 40 (same relay) and 40 of 40 (other relay) for a subscribe right after the publisher’sannounced(announce propagation).
Endpoint paths
Section titled “Endpoint paths”moq-relay takes the endpoint URL’s path, split on /, as the namespace prefix of a connection
(room-rooted endpoints). It does not percent-decode the
segments. Measured on 2026-10-20 on ord.relay.mux.global (moq-relay 0.17.5) and
sjc.relay.mux.global (0.17.0), for issue #348.
A Python publisher (native QUIC) connected to <host>/<root>/<run>/<segment> with the namespace
['t'], and bare-host discovery named the namespace the relay derived. The TypeScript smoke test,
with a room field a b, failed in the same way on Node (native QUIC) and Chromium (WebTransport).
Its room publisher at .../a%20b/... never appeared under the bare a b namespace.
| Path segment | Field the relay derived |
|---|---|
a%20b, a%25b, a%2Fb, a%2fb, a%2Eb, %2E%2E, %C3%A9 |
the same text, % sequences included: never a b, a%b, a/b, a.b, .. or é |
raw é (UTF-8 bytes in PATH, native QUIC only; a browser URL encodes it) |
é |
a~b, a!b, a$b, a&b, a'b, a(b), a+b, a,b, a;b, a=b, a:b, a@b (ord only) |
the same text |
a*b (ord only) |
none: the connection fails with UNAUTHORIZED |
An encoded / is a literal part of a field, not a separator and not a refusal. A browser cannot
send a raw space or non-ASCII character in a path, and the WHATWG URL parser folds ., .. and
their encoded forms into the path before the request leaves. roomEndpoint / room_endpoint
therefore refuses those fields instead of encoding them. The live smoke test passed on ord with a
room field holding every character the helper allows
(RELAY_NAMESPACE_PREFIX="<root>/a-._~!$&'()+,;=:@Z9"), in Python and on all four TypeScript
runtimes, QMux over WebSocket included. Encoded segments were not measured over WebSocket.
Supervised generations
Section titled “Supervised generations”Measured on 2026-10-06 against sjc.relay.mux.global (moq-relay 0.17.0) with the TypeScript SDK
(behavior.md, issue #260):
- A newer generation while the old one is still up. A want following a root moved to a second supervised publisher’s generation, and received its objects, about 200 ms after that publisher connected. That was one smoke run in Node and one in Chromium. The relay still held the first generation’s announce, so this is the duplicate announce case resolved by the generation order rather than the relay’s hash ranking.
- In-process reconnect. The supervised publisher’s native QUIC transport was closed from
underneath it while it wrote one object every 50 ms. Reconnection backoff started at 200 ms. In
5 of 5 runs the next generation was up 515 to 595 ms after the close, and the subscriber’s want
received its objects 656 to 742 ms after it. Writes in between failed at once with
RECONNECTING(10 or 11 per run), and nothing was replayed. The want restarted with reasongenerationwhen the old generation was withdrawn, orpublisher-donewhen the done arrived first. - The same with the Python SDK (native QUIC on aioquic, same day and relay). The generation
switch took 202 to 203 ms in 4 of 4 smoke runs. In the in-process reconnect, 5 of 5 runs had the
next generation up 624 to 741 ms after the close and the want receiving its objects 764 to 881 ms
after it, with 6 to 8 writes failing at once with
RECONNECTING. The write in flight at the close failed withTRANSPORT_ERRORinstead: closing the transport locally makes aioquic drain for about 300 ms before the session ends, longer than the supervisor waits for the end to explain a failure. A relay’s close or an idle timeout ends the session in the same turn its streams fail.
A transport closed this way still sends a QUIC close, so the relay withdraws the old generation within a round trip. A crash leaves it announced until the idle timeout; the newer generation still wins, as in the first case.
Token expiry
Section titled “Token expiry”A session lasts until its credential expires, and a live session cannot take a new one (behavior.md, issue #261). From moq-relay 0.17.0’s source:
- The session holds a lease on its grant. At the grant’s deadline,
Lease::ended()returnsExpiredand the connection callssession.abort(Unauthorized)(rs/moq-relay/src/auth.rs,Lease::ended;connection.rs, thelease.ended()arm). The deadline is the JWT’sexpwith the reference auth server (moq auth serve), and Switchboard caps every grant at the token’sexptoo. - AUTHORIZATION_TOKEN on a later request is ignored: the session’s grant authorizes every request
(
rs/moq-net/src/ietf/subscribe.rs). Revalidation can move the deadline, but it re-checks the connect request the session was admitted with, not a new credential.
Measured on 2026-10-06 against a local moq-relay 0.17.0 (--auth-url pointing at a test auth
server that granted until the token’s exp), with the TypeScript SDK over native QUIC:
- Expiry. A publisher and a subscriber connected with a token 6 s from its
exp. Both sessions closed 81 to 83 ms after it, with session termination code 0x2 and reasonunauthorized, which the SDK reports asUNAUTHORIZEDin phaseconnection. The subscriber’s transport reported the close throughclosed, the publisher’s as a session error on a stream. The auth server receivedendwith reasonexpired. The Python SDK measured the same, 82 ms afterexp. In headless Chromium the subscriber reportedUNAUTHORIZEDtoo, within a few milliseconds ofexp, but the publisher’s control stream failed (NetworkError: The stream was aborted by the remote server) before WebTransport’sclosedcarried code 0x2, so it reportedTRANSPORT_ERROR(issue #279). - Rotation. A supervised publisher wrote one object per group every 40 ms for 40 s, and a
supervised subscriber followed its root. Both took 12 s tokens (the subscriber’s 15 s) and rotated
5 s before expiry. The publisher rotated 5 times and the subscriber 4 times. All 989 objects
arrived once each, in order, with no gap longer than the 42 ms between writes, no failed write,
and the status
livethroughout. Each publisher rotation restarted the want with reasongeneration, each subscriber rotation withrotation. Before the replacement subscription skipped what the old one had delivered, each subscriber rotation repeated one object. The Python SDK, run the same way, delivered 964 of 964 objects with no gap longer than 43 ms. So did headless Chromium over WebTransport for 30 s: 4 publisher and 3 subscriber rotations, 737 of 737 objects, no gap longer than 43 ms.
To reproduce, run moq-relay with --auth-url pointing at moq auth serve --key ..., and sign
short-lived tokens with moq auth sign --expires.
Relay demand
Section titled “Relay demand”A publisher’s demand is the relay’s one upstream subscription for a track, not a count of
downstream viewers. Measured against sjc.relay.mux.global (moq-relay 0.15.8) on 2026-09-28 with
the Python SDK (issue #137); on 0.17.0 on
2026-10-05 demand still ended about 50 ms after the last subscriber left:
| scenario | result |
|---|---|
| the only subscriber cancels, connection kept open | demand ends 50-51 ms later (3 of 3) |
| the only subscriber closes its connection | demand ends 50-51 ms later (3 of 3) |
| a second subscriber joins while the first is present | no new demanded value; the second receives the catalog from the relay’s cache |
| the first of two subscribers leaves | demand stays; it ends 50 ms after the second leaves |
| a subscriber joins 2-35 s after the last one left | a new demanded value, so the catalog is re-sent (12 of 12) |
| the same connection resubscribes within about 30 s of cancelling | it receives the previous group from the relay’s cache, then the re-sent one (6 of 6 at gaps of 2-20 s; 0 of 2 at 35 s; 0 of 4 from a new connection) |
| 200 subscribe/receive-one/cancel cycles in 19.3 s on one connection | 185 demanded values and 185 releases; the last release 51 ms after the last cancel |
So the relay coalesces concurrent viewers into one subscription and cancels it as soon as the last
one leaves, about one round trip later. moq-net 0.15.0 removed the hold: the relay’s front drops
the source track when its last reader leaves, and its 30-second TRACK_IDLE_LINGER now keeps only
a cached copy of delivered groups, holding no upstream subscription (rs/moq-net/src/model/origin.rs
at moq-relay-v0.15.8, still 30 s at moq-relay-v0.17.0). has_demand / hasDemand is not a
liveness signal and not a reason to
keep or stop encoding, and a catalog re-sent on each demanded value is what the relay caches for
every later viewer: write only state that is safe to serve from cache, and expect a viewer to see
a document more than once.
A viewer on another relay
Section titled “A viewer on another relay”When the first viewer of a track is on a different relay from the publisher, or is a moq-lite
viewer on any relay, the publisher sees two relay subscriptions, not one
(issue #243,
issue #150). Relays mesh over moq-lite, and a
lite-05 subscriber fetches a track’s TRACK_INFO before it subscribes (TrackInfoFetch in
rs/moq-net/src/lite/subscriber.rs at moq-relay-v0.17.0). The publisher’s relay can learn the
info of an IETF publisher’s track only from a SUBSCRIBE_OK, so it subscribes to answer the query
(TrackInfoServe in rs/moq-net/src/lite/publisher.rs, run_subscribe in
rs/moq-net/src/ietf/subscriber.rs). Nothing reads that track, so the relay cancels the
subscription as unused about 50 ms later, and the downstream relay’s real SUBSCRIBE starts a new one
after the info has made the trip back. Measured on the r1 fleet on 0.15.8 (sjc publisher, two-hop
path to fra):
59.642 sjc ietf subscriber: subscribe started track=events (answers the TRACK_INFO query)59.692 sjc ietf subscriber: subscribe cancelled track=events60.072 sjc lite publisher: subscribed started (the mesh SUBSCRIBE from iad)60.072 sjc ietf subscriber: subscribe started track=eventsObjects written to the first subscription reach no viewer. A write still in flight when it is
cancelled fails with SUBSCRIPTION_CHANGED; a write that completed before the cancel was accepted by
the transport and is dropped by the relay without an error. Against local relays the first object
of a continuous publisher was lost in every run, and on the fleet the remote viewer’s first object
was seq 1 or 2 on three of four paths. An IETF viewer (either SDK) on the publisher’s own relay
sends no TRACK_INFO query and gets one subscription (3 of 3 on 0.17.0 on 2026-10-05). A moq-lite
viewer (the moq CLI, @moq/watch) is a lite subscriber itself, so it causes the same subscribe,
cancel, subscribe on the publisher’s own relay: 11 of 11 runs on 0.17.0 had two upstream
SUBSCRIBEs, the first cancelled about 50 ms after it started
(issue #150). The SDKs cannot tell the two
subscriptions apart: each is a demanded value, the first followed by false (ADR 0001).
Because publishers retain no history, the second subscription’s current-group fill has nothing to
recover either. A live publisher therefore cannot count on its first objects after demand begins
reaching a viewer: write state-shaped tracks on every demanded value (the catalog service does), and
start media at a keyframe after any change (the frame writer does). The live smoke tests repeat a
warm-up marker on each track until the subscriber receives one before writing what they assert, and
RELAY_SUB_URL puts their subscribers on another relay
(issue #244).
TRACK_STATUS on moq-relay main
Section titled “TRACK_STATUS on moq-relay main”moq-relay main from moq-dev/moq#5991 (916cd4543,
2026-10-20; in no release through 0.17.7) learns the track another relay asks about with
TRACK_STATUS instead of a SUBSCRIBE: when the publisher’s relay has no subscription for the track,
its IETF subscriber sends the publisher TRACK_STATUS (track_status in
rs/moq-net/src/ietf/subscriber.rs at 8810d7365), answers the downstream relay’s TRACK_INFO from
the TRACK_STATUS_OK, and subscribes only once the downstream relay does. A refusal fails the
downstream request, and #5991 removed the fallback to SUBSCRIBE that #5409 had added, so the
downstream relay retries forever. Draft-22 lets a relay do either
(Section 9.13:
relays “MAY forward TRACK_STATUS … or MAY initiate a subscription”), and lets a non-relay endpoint
answer it NOT_SUPPORTED (Section 1.6),
which is what both SDKs’ publishers did until they answered it
(behavior).
Measured on 2026-10-26 on two local relays built from main at 8810d7365 (the voice benchmark’s
relay configuration with its [cluster.api] keys renamed by hand, its Switchboard and its CA;
publisher on one relay, subscriber on the other, over moqt-22), with the live smoke tests:
| publisher | one hop | two hops | the publisher’s relay logs, per track asked about |
|---|---|---|---|
refuses TRACK_STATUS (NOT_SUPPORTED) |
pass | fail in every runtime: Node over native QUIC, Chromium WebTransport, Node and Chromium WebSocket (TypeScript), and aioquic (Python) | track status refused err=unsupported, then track info error ... err=unsupported to the other relay, which logs track info failed, once per retried SUBSCRIBE |
| answers TRACK_STATUS | pass | pass, in the same five runtimes | track status known, then one upstream subscribe started under a millisecond later |
On moq-relay 0.17.7 (e2c5b50) the relay never sends TRACK_STATUS, and both publishers pass one
and two hops the same way; the publisher’s relay there still subscribes twice per track asked
about from the other relay (subscribe, cancel, subscribe, as above), where main subscribes once
(32 of 32 tracks each). The early-object loss above is therefore a 0.17 behavior; on main with a
publisher that answers TRACK_STATUS it was not seen, though the smoke tests’ warm-up markers would
hide a single lost object.
Relays before 0.15 held the upstream subscription for about 30 seconds after the last viewer left (measured against the same endpoint on 2026-09-15, issue #24); that is no longer the case.
Late subscribers and location filters
Section titled “Late subscribers and location filters”A plain SUBSCRIBE, with no LOCATION_FILTER or an explicit zero-field one, starts at the beginning
of the relay’s latest group: a subscriber that joins an ongoing track receives that group’s cached
objects and everything published after, and nothing older. NextObjectFilter is stricter and starts
after the relay’s largest object, so it skips the rest of the current group. A relative-group or
absolute filter reaches further back into the relay’s cache. moq-relay 0.17.0 resolves the filters
this way in filter_range (rs/moq-net/src/ietf/publisher.rs at tag moq-relay-v0.17.0).
Measured against production on 2026-09-28, on 0.15.8
(issue #138), and again on 0.17.0 on 2026-10-05
(3 of 3 runs): a publisher wrote groups up to 19, and every late subscriber joined between groups 9
and 10.
| subscriber | filter | groups received on 0.17.0 | on 0.15.8 |
|---|---|---|---|
| late | none (no LOCATION_FILTER sent), or explicit NoLocationFilter() |
[9..19] |
the same |
| late | NextObjectFilter |
[10..19] |
the same |
| late | RelativeGroupFilter(groups_before_next=3) |
[7..19] |
the same |
| late | AbsoluteLocationFilter(start=group 2) |
[2..19] |
[4..19] |
On 0.17.0 an absolute start at group 0 received [1..19]: the relay serves from the requested group
as far back as it still caches. On 2026-09-15, before production moved to 0.15.8
(issue #26), NextObjectFilter behaved the same
as no filter; from 0.15.8 it does not.
The draft defines an unfiltered subscription as passing all Objects published or received via
upstream subscriptions (behavior.md); it does not require a relay to
replay what it cached before the subscription, so starting at the latest group is a legitimate
reading and not an SDK encoding problem. A late joiner that needs a decodable start or recent
history subscribes with RelativeGroupFilter / { type: 'relative-group' } (groups_before_next /
groupsBeforeNext of 1 starts at the current group) or with an absolute start. How far back
either reaches depends on what the relay still holds; on 0.15.8 the absolute case above started
later than it asked for.
The table holds only for tracks published over moqt, as both SDKs publish. When the track’s
publisher speaks moq-lite (the moq CLI, moq-rs, @moq/net), the relay treats the first
moqt subscriber differently: its SUBSCRIBE_OK carries no LARGEST_OBJECT, and within about 100 ms it
receives every group the publisher still retains, in one burst and out of order, whatever filter it
sent (none, NextObjectFilter or RelativeGroupFilter, on moqt-20; groups 0-8 in 3 of 3 runs
on 0.17.0, against 2 of 2 correct with the same producer publishing over moqt). Later subscribers are filtered
as the table says. A subscriber to a third-party moq-lite broadcast that wants the live edge drops
any group older than the newest it has seen. The fix belongs upstream in moq-relay.
Issue #153 records the measurements.
Subscription parameters
Section titled “Subscription parameters”MessageParameters exposes every draft-22 SUBSCRIBE parameter, and both SDKs send whatever the
application sets. moq-relay decides which it acts on. Read from the source at tag
moq-relay-v0.17.0 (Subscribe in rs/moq-net/src/ietf/subscribe.rs, the SUBSCRIBE serve path in
rs/moq-net/src/ietf/publisher.rs) and measured on production 0.17.0 on 2026-10-06
(issue #265):
| parameter | moq-relay 0.17.0 | effect for an application |
|---|---|---|
SUBSCRIBER_PRIORITY (0x20) |
honored on SUBSCRIBE: it sets the priority the relay serves this viewer with. It is not passed upstream (the relay’s own SUBSCRIBE carried 255 when the viewer asked for 7) | set it on the SUBSCRIBE; changing it later with update() ends the subscription (deviation 4) |
publisher priority (track property 0x0E) |
carried in group headers and SUBSCRIBE_OK, but not used to order delivery; under congestion only subscriber priority does. Every group stream header carries the track’s 0x0E, or 128 when the track has none, whatever SUBSCRIBER_PRIORITY the viewer sent, and so does the SUBSCRIBE_OK a viewer gets unless it asked for no properties (INCLUDE_PROPERTIES = 0 empties the list): with and without 0x0E, each subscribed with the media priorities, without SUBSCRIBER_PRIORITY and inverted, 3 of 3 per case on 0.17.0 (sjc) and 0.17.5 (ord) on 2026-10-20 (#344). On ord’s capped downlink, delivery followed SUBSCRIBER_PRIORITY in both cases: inverted, audio starved even when every header said 128 (source) |
an app protects audio by setting its subscriber priority. The publisher’s is ignored even when subscriber priorities tie (3 of 3 on 0.17.0, #154), so the media defaults (audio 16, video 96) do not change it (behavior.md); a viewer sets the same values as its subscriber priority with mediaSubscribeParameters (behavior.md) |
LOCATION_FILTER (0x21) |
honored, with draft-20 framing on every draft (fixed in 0.17.3) | see late subscribers and LOCATION_FILTER framing |
GROUP_ORDER (0x22) |
decoded and ignored: SUBSCRIBE_OK states Descending whatever the viewer asked for and whatever the publisher declared (DEFAULT_PUBLISHER_GROUP_ORDER), and the relay’s upstream SUBSCRIBE always asks for Descending | do not rely on group order either way. A late subscriber that asked for Ascending got its cached groups in no consistent order in 9 of 10 runs (for example [2..10, 1, 11, 12]), and ascending once. Upstream main applies it (moq-dev/moq#5385), in no release through 0.17.7 |
NEW_GROUP_REQUEST (0x32) |
decoded and ignored, on SUBSCRIBE and REQUEST_UPDATE: never forwarded upstream, neither in the SUBSCRIBE for a track without a subscription nor as a REQUEST_UPDATE for one already served, which draft-22 §9.20.19 requires of a relay | there is no protocol key-frame request through this relay. An application that needs one (a decoder error, a layer switch) carries the request on its own track back to the publisher |
OBJECT_DELIVERY_TIMEOUT (0x02), SUBGROUP_DELIVERY_TIMEOUT (0x06) |
decoded and ignored; not passed upstream | only the publisher’s own values apply (delivery timeouts) |
AUTHORIZATION_TOKEN (0x03) on a request |
ignored: the session’s grant authorizes every request | authorize at connect time |
FORWARD (0x10) = 0 |
refused per request: REQUEST_ERROR 3 FORWARD=0 not supported, session stays open |
see deviation 4 |
range filters (0x25 to 0x28) |
refused per request: REQUEST_ERROR 3 range filters not supported |
— |
INCLUDE_PROPERTIES (0x35) |
honored | one raw byte from 0.17.0; see the top of this page |
Neither SDK’s publisher declares DYNAMIC_GROUPS (0x30) or acts on a NEW_GROUP_REQUEST, so a relay
that forwarded it would not change the outcome for an SDK publisher yet. The relay’s upstream
SUBSCRIBE to an SDK publisher carries FORWARD 1, its own priority, GROUP_ORDER Descending, a
LOCATION_FILTER and a FILL_PARAMETERS, and nothing the viewer asked for beyond the track.
To re-check after a relay upgrade, run the probe below. It subscribes with each ignored parameter set, records the relay’s upstream SUBSCRIBE and any REQUEST_UPDATE as the publisher receives them, and the group order SUBSCRIBE_OK states, and it exits non-zero, with the differing rows, when any of those no longer match this table. It also prints the order a late subscriber’s cached groups arrive in, for reading only: QUIC streams have no cross-stream order, so that is not compared. It is a relay fact, not an SDK contract, so only the Python SDK runs it.
RELAY_URL="$RELAY_URL" RELAY_NAMESPACE_PREFIX="$RELAY_NAMESPACE" mise run python:probe:relay-parametersGroup stream priority in source
Section titled “Group stream priority in source”The header priority and the send priority come from different values. Read at tag
moq-relay-v0.17.7 (the same code at moq-relay-v0.17.5 and moq-relay-v0.17.0), in
rs/moq-net/src/ietf/:
- The relay keeps the publisher’s
0x0Efrom the SUBSCRIBE_OK it gets upstream (subscriber.rs:2184,priority: msg.properties.priority), and uses wire 128 when there is none (subscriber.rs:1787,priority.unwrap_or(128)). - It stamps that value on every group stream header it sends a viewer (
publisher.rs:2499,publisher_priority: ...to_wire(self.track.info().priority)). Its SUBSCRIBE_OK to the viewer carries the same value as0x0E(publisher.rs:695), unless the viewer set INCLUDE_PROPERTIES to 0, which empties the SUBSCRIBE_OK properties; the headers carry it either way. - It opens each group stream at the viewer’s SUBSCRIBER_PRIORITY (
publisher.rs:2711,stream.set_priority(self.applied), from the subscription’s priority atpublisher.rs:2362). That is the value the QUIC send scheduler orders by.
To re-measure the header priority, run the TypeScript probe with and without the media track
properties. It prints each track’s SUBSCRIBE_OK 0x0E and the header priorities that arrived
(headerPriorities). Run it with SUBSCRIBER_PRIORITY set to on, off and invert, from ts/
after mise run ts:build and mise run ts:build:native:
RELAY_URL="$RELAY_URL" RELAY_TOKEN="$RELAY_TOKEN" RELAY_NAMESPACE_PREFIX="$RELAY_NAMESPACE" \ PRIORITY_TRACK_PROPERTIES=off SUBSCRIBER_PRIORITY=invert node scripts/probe-subscriber-priority.mjsSubgroup resets
Section titled “Subgroup resets”moq-relay 0.15.8 forwards a subgroup reset once it has begun forwarding that subgroup, but not at once. Measured against production on 2026-09-28 (issue #138): a publisher wrote objects to a group and aborted it after the relay had forwarded some of them. All 10 of 10 such aborts reached the subscriber as a subgroup reset, and each arrived only when the publisher’s next group began: about 0.5 s after the abort at the publisher’s normal cadence, and 3 s after it when the publisher paused 3 s before its next group. A subscriber that must react to an abandoned group sooner than the next group cannot rely on the reset’s timing. On 0.17.0 on 2026-10-05, every partly forwarded group aborted in the reset-code runs below reached the subscriber as a reset, about 0.55 s after the abort; the timing relative to the next group was not re-measured.
A reset for a subgroup the relay never began forwarding is unverified on 0.15.8. On 2026-09-15, before production moved to 0.15.8 (issue #38), a publisher wrote two small objects to a group and aborted it: one subscriber received the reset, and in another run a subscriber received nothing for that group, neither objects nor a reset, and its first group was the next one. In the close path of issue #30, where 8 MiB objects had been partly forwarded, the relay did propagate the reset. The exact condition (bytes forwarded, time since the first object, or cache policy) was not isolated.
A subscriber therefore cannot rely on a reset to learn that a group was abandoned. A missing group ID is the only trace, and it is indistinguishable from a group the publisher never sent. An application that must detect loss carries its own sequence numbers in payloads.
The reset code does not carry the publisher’s reason. moq-relay re-codes every subgroup reset it
forwards as 1, whatever code the publisher sent, and a PUBLISH_DONE status other than 2
track ended reaches viewers as 0 internal error
(issue #156). Over native QUIC (the Python SDK,
and the TypeScript SDK in Node) the code also arrives in its WebTransport (HTTP/3) encoding, because
the relay’s raw-QUIC sessions still map codes as for WebTransport: code 1 arrives as
0x52E4A40FA8DC (91141958510812) in stream_error_code / streamErrorCode
(issue #248). Measured on production 0.17.0 on
2026-10-05 (3 of 3 per case, both SDKs) and the same on a local 0.15.8. Treat a subgroup reset as
“this group was abandoned” and nothing more.
Voice delivery on 0.17.5: deadlines, FETCH and group order
Section titled “Voice delivery on 0.17.5: deadlines, FETCH and group order”What a voice application can tune on the relay’s side, measured on a local moq-relay 0.17.5 (tag
moq-relay-v0.17.5, the OCI fleet’s image at the time, with no cache settings, as the fleet runs it) on
2026-10-17 with the Python probe below, and read in its source (rs/moq-net/src/ietf/subscribe.rs,
publisher.rs). The publisher wrote one-object groups every 20 ms, with track Timescale 1,000,000
and a LOC Timestamp on every object. For the slow-link rows the subscriber’s link went through
moq-shaper --rate 300000 --queue 1s with 1000-byte objects, so the relay built a backlog.
| question | moq-relay 0.17.5 | effect for an application |
|---|---|---|
| Does a subscriber’s OBJECT_ / SUBGROUP_DELIVERY_TIMEOUT bound delivery? | no: decoded and dropped. With 100 ms set, and with neither set, the slow link delivered 600 of 600 groups, lateness p50 about 2.2 s and max about 11.5 s; a 24 s run delivered every group, the last about 24 s late. The track’s 5 s default retention did not cut it short either | the relay never drops for a late subscriber. A listener bounds its own delay: the sequencer’s late drop and catch-up (behavior.md) |
| How does a group the relay drops show? | a group never opened leaves only a missing group ID; a stream it stops mid-group is reset with code 1 (CANCELLED), never 0x2 (DELIVERY_TIMEOUT). None occurred in the runs above | a missing group ID is the trace; the sequencer reports it as missingGroups |
GROUP_ORDER (0x22) on SUBSCRIBE |
ignored by this release, as by 0.17.0: SUBSCRIBE_OK states 2 (descending), and each group stream is sent at the subscriber priority alone, with nothing for its group’s place in the order (its header carries the track’s 0x0E, or 128, above). On the slow link, Descending and Ascending gave the same arrival order, oldest backlog first |
there is no newest-group-first delivery to ask for on this relay |
| FETCH of a recent group | served, one group per FETCH: a two-group range is refused with REQUEST_ERROR 3 FETCH spanning several groups not supported. Single groups 40 ms to 29 s old (the oldest tried) came back in 1-2 ms on a clean link; with no cache capacity or duration set, as on the fleet, the 5 s retention does not age groups out of the cache |
a gap can be filled from the relay group by group, but on a slow link the FETCH queues behind the live backlog (2-5 s in the runs above) |
| Properties on FETCH objects | none: a standalone FETCH answers without a Timescale, so the relay writes no properties and the LOC Timestamp is lost | a fetched frame cannot be placed on the timeline from its own properties |
These rows are facts about the releases named, not about moq-relay in general. 0.17.7, read in
source and not measured, has the same code on each path: the delivery timeouts are decoded into
unused bindings, a standalone FETCH answers unstamped, and group streams are sent at the subscriber
priority alone, with the track’s 0x0E in their headers (group stream priority in source). Upstream main differs, read in source and not measured: it applies GROUP_ORDER on
the IETF publisher, and FETCH keeps object timestamps
(moq-dev/moq#5385,
#5494, in neither 0.17.6 nor 0.17.7). Its delivery
timeouts are still advisory by design (doc/concept/standard.md at 7a13b8f8b: they “do not drop
objects or reset subgroups”), and nothing maps an IETF subscriber’s budget onto the relay’s
presentation-time max_delay: asked for in
moq-dev/moq#5881, and a bench relay that honours the
timeout is being built for the voice benchmark. Main since then maps the MoQ Timestamp extension’s
MAX_AGE onto it instead, per subscriber, and still ignores the timeouts
(subscriber max age). The SDK’s delivery options
(behavior.md) are meant to work with and without such a relay: the
subscriber’s timeout goes on the SUBSCRIBE either way, and the late drop, reorder window and
catch-up act on whatever the relay sends.
To re-measure, against a local relay as in Reproduce against a local relay
or a deployed one, and through an impairer for the slow-link rows (RELAY_SUB_URL is the
subscriber’s endpoint, VOICE_* set the payload, timing, delivery timeouts and group order; the
script’s docstring lists them):
RELAY_URL="$RELAY_URL" mise run python:probe:voice-deliverySubscriber max age (MAX_AGE)
Section titled “Subscriber max age (MAX_AGE)”moq-dev main enforces one per-subscriber latency budget on SUBSCRIBE: MAX_AGE (0x915C6), the subscriber
max age of the MoQ Timestamp extension (drafts/draft-lcurley-moq-timestamp.md in moq-dev’s tree, “Subscriber
Max Age”; not in the published -00 or -01). The draft-22 delivery timeouts are not that budget on any relay
named here. Read at moq-dev commit 6298cbcdf (2026-10-26), in rs/moq-net/src/:
- Encoding. An even type, so the value is a bare varint of milliseconds; at most 2^53-1, a larger value
fails to decode (
ietf/max_age.rs:6-7,:17-22;ietf/parameters.rs:334-343). On draft-16 and later it sits in the delta-typed parameter list like any other (ietf/parameters.rs:504-560); its type is the highest, so it is last. - Negotiation. The Setup Option MAX_AGE (
0x915C4, value reserved, sent as 1) declares support (ietf/max_age.rs:5,:9-15). The relay always sends it (ietf/session.rs:845). The extension says an endpoint MUST NOT send the parameter to a peer that did not declare the option; the relay itself does not check that a subscriber declared it before reading the parameter. - Versions. Decoded on SUBSCRIBE for every draft the relay speaks (
ietf/subscribe.rs:89for draft 14,:131for the rest, with no version gate) and on REQUEST_UPDATE (ietf/request_stream.rs:414,:455). Main speaks moq-00 (draft 14) and moqt-15 to moqt-18 and moqt-22, not moqt-20 (ietf/version.rs:6-13,version.rs:45-50), so both SDKs negotiate moqt-22 with it. Not decoded on FETCH or TRACK_STATUS. - What the relay does with it. The SUBSCRIBE’s value becomes that downstream subscription’s
max_delay(ietf/publisher.rs:2029-2031); a REQUEST_UPDATE replaces it (:2316-2320). The budget is per subscription: a relay asks upstream for its most tolerant subscriber’s budget, and only from a peer that declared the option (ietf/subscriber.rs:3225-3229), so one subscriber’s short budget never tightens another’s. A group goes stale for that subscriber once a newer group supersedes it and either its successor arrived at this relay at leastmax_delayago or, on a timed track (TIMESCALE, as both SDKs’ media tracks are), its media time trails the live edge by that much; the newest group never goes stale (model/subscription.rs:56-75). The budget is capped by the publisher’s MAX_CACHE_DURATION and the relay’s cache duration (model/track.rs:1138-1145,:3644-3649). A stale group the relay has not begun is skipped (no stream: a missing group ID), and a group stream still being written or not yet acknowledged when it goes stale is reset (ietf/publisher.rs:5468-5476,:5862-5868); the reset arrives as code 1. - The delivery timeouts. OBJECT_DELIVERY_TIMEOUT (
0x02) and SUBGROUP_DELIVERY_TIMEOUT (0x06) are decoded into unused bindings (ietf/subscribe.rs:113,:118), as on moq-relay 0.17.9 (tag commit30fd9d450). moq-relay 0.17.x has no MAX_AGE: it does not declare the option, and on moqt-20 and moqt-22 a SUBSCRIBE carrying the parameter fails to decode (an unlisted type;ietf/subscribe.rs:86-101at30fd9d450). The SDKs therefore sendmaxAge/max_ageonly when the relay declared the option (behavior.md).
Measured on a Sprite on 2026-10-29 in moq-netbench’s local two-namespace rig (relay-dev, relay-3cnp.416):
moq-relay built from 6298cbcdf, one TypeScript talker (90 s, one 80-byte object per 20 ms group, LOC
timestamps) beside the relay, and two TypeScript subscribers over native QUIC behind one shaped access link,
one asking 500 ms and one 5 s, each its own connection. The relay logged both values as decoded
(max_age: Some(500), Some(5000)). “Missing” frames are ones the talker sent that never reached that
subscriber; lateness is arrival minus the frame’s capture time (one host clock). The control asks
OBJECT_DELIVERY_TIMEOUT and SUBGROUP_DELIVERY_TIMEOUT 500 ms and 5 s instead.
| link (runs) | asked | frames delivered | missing | late p50 | late max | over 500 ms | over 1 s |
|---|---|---|---|---|---|---|---|
| narrow (2/1 Mbps, 30 ms added RTT, 500 ms FIFO) with one CUBIC download, 419-428 ms of queueing (3) | max age 500 ms | 13,487 of 13,500 | 13 | 405-427 ms | 526-538 ms | 1,212 | 0 |
| max age 5 s | 13,500 of 13,500 | 0 | 405-427 ms | 1,009-1,578 ms | 1,213 | 8 | |
| delivery timeouts 500 ms | 13,500 of 13,500 | 0 | 394-427 ms | 513-1,100 ms | 1,115 | 8 | |
| delivery timeouts 5 s | 13,500 of 13,500 | 0 | 393-427 ms | 513-1,087 ms | 1,120 | 5 | |
| loss-burst-10 (100% loss 2% of the time, in bursts of 200 ms mean) (5) | max age 500 ms | 22,444 of 22,500 | 56 | 27 ms | 339-538 ms | 15 | 0 |
| max age 5 s | 22,500 of 22,500 | 0 | 27 ms | 339-918 ms | 76 | 0 | |
| delivery timeouts 500 ms | 22,500 of 22,500 | 0 | 27 ms | 477-817 ms | 37 | 0 | |
| delivery timeouts 5 s | 22,500 of 22,500 | 0 | 27 ms | 477-817 ms | 37 | 0 | |
| narrow with a download (2) | max age 200 ms | 8,992 of 9,000 | 8 | 428-429 ms | 534-552 ms | 800 | 0 |
| max age 5 s | 9,000 of 9,000 | 0 | 427-429 ms | 1,052-1,065 ms | 796 | 3 |
- Per subscriber, at the relay. Every frame the 500 ms subscriber lacked, the 5 s subscriber on the same link received, 557 to 918 ms after capture under loss bursts and 504 to 1,065 ms on the narrow link. The relay’s log shows it began serving every one of those groups to the 500 ms subscriber: it gave up resending them when they went stale (lost on that connection, then reset), and skipped none outright. The 5 s subscriber got every frame. Loss bursts that resend within about 500 ms drop nothing (2 of the 5 loss runs).
- Only what the relay still holds. On the narrow link the delay is the downstream FIFO’s: the relay sends each frame at once and it waits in the queue. A 200 ms or 500 ms budget leaves that delay as it is (p50 about 428 ms against 427 ms for 5 s); it cuts only resends, so it caps the worst case (about 550 ms against 1 to 1.6 s) at the price of a frame in about a thousand. A budget below the path’s own queueing delay does not make audio fresher.
- Resets are not a reliable trace. The subscribers saw 10 subgroup resets on the narrow link, where 21 frames were lost this way, and none under loss bursts, where 56 were: a reset for a stream whose first packet never arrived reaches no handler. A missing group ID is the trace, as for any subgroup reset.
- The control. Delivery timeouts changed nothing: nothing was dropped, the two control subscribers’
lateness matched (p50 to 0.1 ms, the worst frame within 16 ms) in every run, and the relay logged
max_age: Nonefor them.
Draft-22 deviations
Section titled “Draft-22 deviations”The relay behavior behind the relay interop deviations.
The constraints came into this repository with the Video.js core extraction
(UPSTREAM.json) as findings against moq-lite-rs, without
a recorded relay version. On 2026-09-28 they were checked by reading the moq-relay source (crate
rs/moq-net) at tags moq-relay-v0.14.17 (commit b2f26b92), moq-relay-v0.15.1 and
moq-relay-v0.15.8. The PUBLISH_DONE row was also measured live the same day, below. Close codes
for 0.14.17 are moq-net’s own Error::to_code values; from 0.15.1 a session close carries the
draft-22 Section 12.2 code.
| SDK choice | relay behavior by version | where | effect |
|---|---|---|---|
| PUBLISH_DONE, then FIN | 0.14.17 to 0.15.3: after SUBSCRIBE_OK the relay waits for the request stream to close, and Reader::closed returns a decode error if any byte arrives first. 0.15.4 and later (upstream a342004d9, #4083): read_publish_done expects PUBLISH_DONE, Error::ProtocolViolation on a FIN without one, then waits for Stream Count data streams or a grace period; only status TRACK_ENDED is a clean end |
ietf/subscriber.rs run_subscribe, read_publish_done; coding/reader.rs closed; ietf/publish.rs PublishDone::end |
up to 0.15.3: a bare FIN finishes the relay’s track, and a PUBLISH_DONE, or any other byte, aborts it for every downstream viewer. From 0.15.4: the reverse |
| no GOAWAY | 0.14.17: a GOAWAY on the client’s control stream is decoded and answered with Error::Unsupported. 0.15.1 and 0.15.8: it is decoded, logged and recorded, and the relay keeps reading the stream; a second GOAWAY is Error::ProtocolViolation |
ietf/session.rs run_goaway; from 0.15.1 also goaway.rs record |
0.14.17: the session closes with code 17. 0.15.1 and later: the session stays open |
| alias = request ID | the same at 0.14.17, 0.15.1 and 0.15.8: an alias already active for another request ID is Error::Duplicate when the two name different tracks, and Error::Unsupported for the new subscription when they name the same track |
ietf/subscriber.rs insert_track_alias, register_alias |
different tracks: the session closes (code 12 on 0.14.17; PROTOCOL_VIOLATION, 0x3, from 0.15.1); same track: that subscription is cancelled |
update() sends REQUEST_UPDATE |
the same at 0.14.17, 0.15.1 and 0.15.8: after SUBSCRIBE_OK the relay only polls the request stream for closure, and Reader::poll_closed counts any inbound byte as a close; it never sends REQUEST_OK or REQUEST_ERROR, and the poll ending takes the normal end path with TRACK_ENDED |
ietf/publisher.rs subscribe serve loop (poll_closed, then PublishDone); coding/reader.rs poll_closed |
any update ends the subscription: the viewer gets PUBLISH_DONE status 2 track ended and no further objects, and the relay drops its upstream demand; the session stays open |
Live check of the REQUEST_UPDATE row against production 0.15.8 on 2026-09-28 (Python, 4 of 4
runs): with subscriber_priority=10, forward=0 or an empty update, the relay sent PUBLISH_DONE
status 2 track ended, stream count 2, within 50 ms and stopped delivering; the publisher’s later
writes timed out waiting for demand; the session stayed open and a new subscribe on the same
connection worked. The SDK’s ended settled only when the connection closed, as after any done
(issue #94).
Re-check on production 0.17.0 on 2026-10-05, live: a track writer’s close still reached viewers as
PUBLISH_DONE status 2 (Python 24 of 24, TypeScript 20 of 20); a client GOAWAY still left the session
open, and a second one still closed it with PROTOCOL_VIOLATION (0x3); and any REQUEST_UPDATE still
ended the subscription with PUBLISH_DONE status 2 within about 50 ms (19 of 19). The alias row was
not re-checked. New on 0.17.0: a SUBSCRIBE with FORWARD = 0 is refused per request with
REQUEST_ERROR 3 (NOT_SUPPORTED) FORWARD=0 not supported, and the session stays open; 0.15.8
closed the session (upstream 9e2b77d08, “decode every legal request and refuse per request”).
The alias row refines what the core headers said before 0.15, that any alias bound to two live request IDs closes the session. That holds only for two different tracks, which draft-22 Section 3.1.3 makes fatal anyway; for one track the relay cancels the newer subscription, because it cannot demultiplex subscriptions with identical filters. Unique aliases avoid both.
Live check of the PUBLISH_DONE row on 2026-09-28: a publisher wrote three objects, then closed the track writer or the publisher, and a subscriber on the same relay recorded what reached it. Both SDKs gave the same results (Python over aioquic, TypeScript in Chromium 147). “Bare FIN” is the previous behavior, reproduced by making the core’s PUBLISH_DONE encode to nothing.
| relay | publisher sends | the viewer receives |
|---|---|---|
local moqdev/moq-relay:0.15.8 |
PUBLISH_DONE, then FIN | an end-of-track object, then PUBLISH_DONE status 2 track ended, stream count 4 |
local moqdev/moq-relay:0.15.8 |
bare FIN | PUBLISH_DONE status 0 internal error |
local moqdev/moq-relay:0.15.1 |
PUBLISH_DONE, then FIN | PUBLISH_DONE status 0 internal error |
local moqdev/moq-relay:0.15.1 |
bare FIN | PUBLISH_DONE status 2 track ended |
sjc.relay.mux.global (0.15.1) |
PUBLISH_DONE, then FIN | PUBLISH_DONE status 0 internal error |
sjc.relay.mux.global (0.15.1) |
bare FIN | status 2 after a writer close; status 0 after a publisher close |
The production publisher-close row matches issue #25: with a bare FIN the relay wrote status 2 on some runs and 0 on others. From the subscriber’s side, what may follow SUBSCRIBE_OK on a request stream is the REQUEST_UPDATE row: against this relay, nothing that should keep the subscription (issue #94). Both cores’ publish-session tests pin the three choices against in-process raw peers: every end path writes PUBLISH_DONE with its status and stream count after the data streams close, then FINs, an orderly close puts SETUP and nothing else on the control stream, and SUBSCRIBE_OK carries the request ID as the alias. Both cores’ session tests pin the same control stream contents for a subscriber connection’s close.
To re-test after a relay upgrade, run the local relay below at the new version and, for each choice, make the one-line change in a scratch build of either core:
- for PUBLISH_DONE, close a track writer and check that a subscriber receives every object, then an end-of-track object and a done notification with status 2, rather than status 0 or a reset;
- GOAWAY is not sent because the SDK has no drain behavior, not because of the relay alone, so dropping this deviation means adding a drain. Against 0.14.17 any client GOAWAY closes the session; from 0.15.1, write GOAWAY (timeout 0, empty URI) on the publisher’s control stream before NAMESPACE_DONE, and on a subscriber connection’s control stream before its transport close, then check that each connection’s closure carries no error;
- REQUEST_UPDATE needs no core change: subscribe, call
update()withforward1 once objects arrive, and check for an update acknowledgement and continued delivery rather than a done notification; only then can the READMEs recommendupdate()against moq-relay; - the alias choice needs no re-test while the SDK serves one subscription per track (ADR-0001); if that changes, check whether the relay accepts one alias shared by two subscriptions to the same track.
A change that passes can drop its deviation from behavior.md in the same pull request that changes both cores.
Browser QUIC limits
Section titled “Browser QUIC limits”These are limits of the browser’s QUIC stack, not of moq-relay, recorded here with the other transport findings. The TypeScript guide’s browser transport limits gives the application-facing rules. Observed in Chromium 147 (2026-09); they may change between versions, and other browsers are untested.
- Handshake idle timeout, about 4 s. The effective connect deadline is the smaller of
connectTimeoutMsand about 4 s of silence, so a longerconnectTimeoutMsnever fires on a silent path (#157). - Blackhole detection, about 5 s. A connection with data outstanding that hears nothing for
about 5 s is closed even if the path has come back. Chromium logs
Network blackhole detected(QUIC error 85); the page’sWebTransportErrorcarries no QUIC code (#148). - 1250-byte Initial datagrams. Chromium’s QUIC Initial packets are 1250 bytes, so a path that carries only the 1200-byte minimum from RFC 9000 section 14 never connects. A cap applied after the connection is up does not end it.
The Python SDK’s aioquic transport behaves differently: it survives outages until the relay’s idle timeout (10 s from moq-relay 0.17.0, 30 s before), waits out the full connect deadline, and sends 1200-byte Initials.
QMux over WebSocket
Section titled “QMux over WebSocket”Both fleets serve MOQT over QMux over WebSocket on TCP 443, beside QUIC on UDP 443, through
moq-relay’s [web] listener (--web-https-listen, with --web-ws on by default;
rs/moq-relay/src/websocket.rs). The TypeScript SDK reaches it through @caprid/relay-websocket
(behavior). What differs from QUIC, measured on 2026-10-17 against a
local moq-relay 0.17.5 (the OCI fleet’s version at the time) built from tag moq-relay-v0.17.5, with QUIC and the
WebSocket listener on the same port, unless a row says it is read from the source:
| Behaviour | QUIC (WebTransport, native QUIC) | QMux over WebSocket |
|---|---|---|
| Version negotiation | ALPN / WT-Available-Protocols: moqt-22, moqt-20 |
Sec-WebSocket-Protocol: qmux-01.moqt-22, qmux-01.moqt-20. The relay pairs moqt-18 and newer only with qmux-01 (QMUX01_ONLY_ALPNS); an offer with none of its own, a bare moqt-22 included, fails the upgrade with 400. A bare qmux-01 or webtransport would let it pick moq-lite in SETUP, so the SDK never offers one. |
| Path and token | WebTransport: the CONNECT URL. Native QUIC: PATH in SETUP | The upgrade request’s path and query, as on WebTransport (the relay’s Binding::Uri); no PATH or AUTHORITY in SETUP |
| A refused token at connect | the session is closed with code 0x2 unauthorized, which the SDK reports as UNAUTHORIZED in phase connecting |
the relay asks its auth source before it upgrades, and answers a refusal with HTTP 401 (serve_ws). A WebSocket client sees only a failed handshake, so the SDK reports TRANSPORT_ERROR in phase connecting: a token that is bad and a relay that is unreachable look the same. Measured with an --auth-url server refusing everything but one token: a bad and an empty token each failed in about 0.1 s with TRANSPORT_ERROR, and the auth server logged the refusal with "transport":"websocket" |
| A grant’s expiry on a live session | closed with 0x2 unauthorized: UNAUTHORIZED in phase connection |
the same: a QMux application close with 0x2, UNAUTHORIZED in phase connection, about 3 s after a grant that expired 3 s on (publisher and subscriber, Node) |
| A publisher’s process is killed | silent until the relay’s QUIC idle timeout: viewers get done(0) internal error 13.0–13.1 s later (6 of 6) |
the kernel closes the TCP connection, and viewers get done(0) internal error 10–17 ms later (3 of 3) |
| A publisher’s process is stopped (SIGSTOP) | the same 13 s (3 of 3) | the kernel still acknowledges TCP, so only the relay’s WebSocket keep-alive notices (a ping every 5 s, 30 s without a frame): done(0) 29.96 s later (3 of 3) |
| Subgroup reset codes | native QUIC: in the HTTP/3 encoding (subgroup resets, TA-27) | as they are: a forwarded reset arrives as code 1, whichever transport the publisher used (3 of 3, publisher over WebSocket and over native QUIC); QMux does not map codes (qmux 0.5.2) |
| Stream multiplexing | independent QUIC streams | one TCP connection: a lost packet holds up every stream behind it (head-of-line blocking); a send stream’s close resolves at hand-over to the socket (TA-15) |
| Datagrams | negotiated by QUIC | unavailable to the SDK: QMux can carry them, but the adapter offers none |
The crash rows had the subscriber on WebSocket in every run; only the publisher’s transport changed.
A dial that falls back to WebSocket costs the time the WebTransport attempt took: in Chromium 147
against the local relay, a connect took 8–14 ms over WebTransport, 48–60 ms straight over WebSocket,
64–72 ms through withWebSocketFallback when UDP to the port was rejected (ICMP port unreachable),
and 4.05–4.08 s when it was silently dropped, the then-default 4 s handshake timeout plus the
WebSocket connect (3 of 3 each, UDP filtered with nftables on the relay host). Since #392 the
WebSocket dial races WebTransport after a 250 ms head start, so a silently dropped UDP path should
cost about 250 ms plus the WebSocket connect; that has not been measured yet.
The live scenario (behavior) passed over WebSocket in Node 24, Chromium 147, Firefox 148 and Playwright’s WebKit (Safari 26.4’s engine, on Linux), and through the fallback in Chromium, against the same relay. That says nothing about Safari on macOS or iOS, and the fleets themselves could not be reached from where this was measured.
Reproduce against a local relay
Section titled “Reproduce against a local relay”Requires Docker, OpenSSL, the repository’s installed dependencies, and Playwright Chromium
(cd ts && pnpm exec playwright install chromium). Run from the repository root. These commands
create an anonymous moqdev/moq-relay:0.17.0 test relay, the version the retired Fly fleet ran, on loopback and
a short-lived ECDSA certificate trusted explicitly by the test clients. The certificate is copied
into the container rather than bind-mounted, so the recipe also works when the Docker daemon cannot
see the local filesystem.
interop_dir=$(mktemp -d)openssl req -x509 -newkey ec -pkeyopt ec_paramgen_curve:P-256 -nodes \ -keyout "$interop_dir/key.pem" -out "$interop_dir/cert.pem" -days 7 \ -subj /CN=localhost -addext subjectAltName=DNS:localhost,IP:127.0.0.1
docker create --name relay-sdk-interop -p 127.0.0.1:4443:4443/udp moqdev/moq-relay:0.17.0 \ --listen '[::]:4443' --listen-tls-cert /tls/cert.pem --listen-tls-key /tls/key.pem \ --auth-public '**'docker cp "$interop_dir/." relay-sdk-interop:/tlsdocker start relay-sdk-interop
export RELAY_ANONYMOUS=1 RELAY_URL=https://127.0.0.1:4443unset RELAY_TOKENexport RELAY_CA_FILE="$interop_dir/cert.pem"export RELAY_CERTIFICATE_HASH=$(openssl x509 -in "$interop_dir/cert.pem" -outform DER | openssl dgst -sha256 -r | cut -d ' ' -f 1)mise run python:test:relaymise run ts:test:relay
docker rm -f relay-sdk-interoprm -r "$interop_dir"unset RELAY_ANONYMOUS RELAY_URL RELAY_CA_FILE RELAY_CERTIFICATE_HASHThe relay URL is an IPv4 literal because the port is published on 127.0.0.1 only. Where
localhost resolves to ::1 first, nothing listens there: the Node run falls back to the next
address and passes, but Chromium and aioquic do not, so the TypeScript summary reads DIVERGED. The
certificate carries IP:127.0.0.1 for Python’s RELAY_CA_FILE hostname check, and keeps
DNS:localhost for hosts where localhost is IPv4.
If the Docker daemon runs in another network namespace, such as a sidecar or a VM whose published
ports do not reach this machine’s loopback, replace -p 127.0.0.1:4443:4443/udp with
--network host; the relay listens on [::], so 127.0.0.1 still reaches it. On 2026-10-01 the
recipe passed for Python, Node and Chromium both as written and with --network host, on a host
where localhost resolves to ::1 first; on 2026-10-05 it passed for all three as written with
the 0.17.0 image.
Both tests publish binary, text, and empty payloads, then connect a second subscriber
after the relay has received content. They require SUBSCRIBE_OK to contain
LARGEST_OBJECT and verify that the late subscriber receives the next live object.
This exercises the relay’s encoder independently of the SDK codecs and in-process hubs.
Successful output includes lateSubscriberLargestObject.
Current SDK builds need moq-relay 0.15.4 or later to end a track cleanly (see the PUBLISH_DONE row
above), so the image tag should not go below that. The flags are those of 0.15.x and 0.17.0:
moq-relay 0.14.17 took --server-bind, --tls-cert, --tls-key and --web-http-listen instead,
and --auth-public '' for an anonymous relay, which 0.15.8 rejects. The PUBLISH_DONE table above
was measured against local 0.15.x images; to reproduce it, use those tags.
RELAY_ANONYMOUS=1 skips the token prompt; if RELAY_TOKEN is set, it is still used.
RELAY_CA_FILE adds the local certificate as a Python trust root.
RELAY_CERTIFICATE_HASH pins its SHA-256 DER hash for browser WebTransport and for the TypeScript
smoke test’s Node run over native QUIC. That Node run uses RELAY_CA_FILE as its ca only when no
hash is set, and only with a CA-signed leaf: the self-signed certificate above is not accepted as its
own trust anchor. These are smoke-test options; normal SDK TLS verification remains unchanged.
Over WebSocket
Section titled “Over WebSocket”The recipe above publishes only the relay’s UDP port, so it reaches QUIC alone. To run the WebSocket
runtimes as well, add the relay’s WebSocket listener on the same port and publish its TCP side:
-p 127.0.0.1:4443:4443/tcp beside the UDP mapping, and
--web-https-listen '[::]:4443' --web-https-cert /tls/cert.pem --web-https-key /tls/key.pem beside
the other flags (those of 0.17.x). Then trust the certificate in Node, which reads extra roots only
as it starts, and pick the runtimes:
export NODE_EXTRA_CA_CERTS="$interop_dir/cert.pem"RELAY_RUNTIME=node-websocket,chromium-websocket,webkit-websocket,firefox-websocket,chromium-fallback \ mise run ts:test:relayunset NODE_EXTRA_CA_CERTSThe browsers ignore certificate errors on their WebSockets whenever RELAY_CERTIFICATE_HASH or
RELAY_CA_FILE is set, since a page cannot pin a WebSocket’s certificate. chromium-fallback dials
WebTransport without the hash, so against this relay its WebTransport handshake fails and it falls
back; each fallback is reported as a fallback phase. WebKit and Firefox need Playwright’s builds
(pnpm exec playwright install webkit firefox). The table in
QMux over WebSocket was measured this way with a moq-relay 0.17.5 binary
built from source rather than the image.