Transport adapter contract
A Relay connection runs over a transport adapter (see CONTEXT.md): the browser’s native
WebTransport, the Python aioquic native QUIC client, the @caprid/relay-quic
native QUIC adapter for Node, the @caprid/relay-websocket QMux-over-WebSocket adapter for browsers and
Node, and the in-memory pairs the tests use. This document states what every adapter must do
for the SDK core to behave as behavior.md says.
Sources of truth
Section titled “Sources of truth”The protocols underneath are the authority. This contract adds no wire behaviour and overrides nothing in them. It only narrows what an adapter exposes at the seam the core depends on:
- draft-ietf-moq-transport-22 (MOQT, wire identifier
moqt-22, pinned inprotocol.json, withmoqt-20offered as a fallback) - RFC 9000 (QUIC streams, RESET_STREAM, STOP_SENDING, MAX_STREAMS, acknowledgement)
- RFC 7301 (ALPN)
- draft-ietf-webtrans-http3 and the W3C WebTransport API, for a WebTransport session
- draft-ietf-quic-qmux (QUIC streams over a reliable byte stream), draft-lcurley-qmux-websocket (its WebSocket binding, which moq-relay serves) and RFC 6455 (WebSocket), for QMux over WebSocket
Every rule cites the upstream section it narrows. If a rule seems to conflict with one of these specifications, the specification wins and the rule is a bug in this document. TA-27 is the one exception: on native QUIC it follows moq-relay’s stream error codes, which differ from draft-22.
QMux over WebSocket has QUIC’s stream semantics (STREAM, RESET_STREAM, STOP_SENDING, MAX_STREAMS and flow control, as QMux frames) but no QUIC underneath: every stream shares one TCP connection, so a lost packet holds up every stream behind it (head-of-line blocking), and nothing is acknowledged above TCP. Where this document names a QUIC frame, it means the QMux frame of the same name on a WebSocket.
Conventions
Section titled “Conventions”The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are to be interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.
Rules are numbered TA-n. The numbers are stable: a rule that is withdrawn keeps its number, marked
withdrawn, and new rules take new numbers. The conformance suite (#193) names each test after the
rule it checks. Each rule may be followed by:
- Why: the core behaviour that depends on it (informative)
- Evidence: what was observed in a real adapter (informative)
- Not yet implemented: the rule is ahead of the code, with the issue that closes the gap
The terms Transport adapter, Native QUIC, WebTransport session, Dialing end and Accepting end are
defined in CONTEXT.md. The SDKs ship only the dialing end. An accepting end exists in test helpers
only.
The rules are stated in terms of the protocols (send stream, receive stream, reset, FIN
acknowledgement) and are normative for the TypeScript SDK, whose binding is the next section. The
Python mapping records how moq_core.transport meets the same rules
and where its aioquic adapter differs today. It is informative.
Datagrams are outside the contract. MOQT requires the QUIC DATAGRAM extension (draft-22 §6.2), and the QUIC stack negotiates it, but the core never sends or reads datagrams.
TypeScript binding
Section titled “TypeScript binding”The seam is the factory a connection calls and the transport it returns.
| Member | Meaning |
|---|---|
createTransport(url, protocols) |
RelayTransportFactory. Called once per connection with the endpoint URL (the token already in its jwt query parameter) and the MOQT protocol identifiers to offer, in preference order. Returns { transport, ready, framing }. |
ready |
Resolves once the transport can open streams and a protocol was selected. A rejection fails the connect call. |
framing |
'webtransport', 'native-quic' or 'websocket': what the transport carries. Optional, default 'webtransport'. The core reads it once ready has resolved. |
transport.protocol |
The protocol identifier the peer selected (TA-8). A browser’s WebTransport has it. |
transport.incomingUnidirectionalStreams |
ReadableStream of the peer’s unidirectional streams, each a ReadableStream<Uint8Array>. |
transport.incomingBidirectionalStreams |
ReadableStream of the peer’s bidirectional streams, each { readable, writable }. |
transport.createUnidirectionalStream(options?) |
Opens a send stream: Promise<WritableStream<Uint8Array>>. options.sendOrder is its send order (TA-28). |
transport.createBidirectionalStream() |
Opens a bidirectional stream: Promise<{ readable, writable }>. |
transport.close({ closeCode, reason }) |
Closes the session. |
transport.closed |
Settles when the session has ended. |
On a send stream, “write” is writer.write(), “close” is writer.close() (FIN), “abort” is
writer.abort(reason) (RESET_STREAM), and writer.closed reports how the stream ended. On a
receive stream, “read” is reader.read() and “cancel” is reader.cancel(reason) (STOP_SENDING).
The same holds for a bidirectional stream’s two halves.
Dialing and framing
Section titled “Dialing and framing”TA-1. The adapter MUST declare its framing in the factory result: 'webtransport' when the
transport carries a WebTransport session, 'native-quic' when it carries MOQT directly on a QUIC
connection, 'websocket' when it carries MOQT on QMux over a WebSocket. An adapter that declares
nothing is a WebTransport session.
Why: the framing decides what CLIENT_SETUP contains (TA-5, TA-6).
TA-2. On a WebTransport session the adapter MUST send the extended CONNECT to the URL exactly
as it was given, path and query included, so the token reaches the relay in the jwt query
parameter (draft-22 §6.2.1, draft-ietf-webtrans-http3 §3). Over WebSocket it MUST send the opening
handshake (RFC 6455 §4.1) to the same URL with the scheme wss: in place of https:, path and query
unchanged: moq-relay reads the path and the token from the upgrade request, as it does from a CONNECT.
TA-3. On native QUIC the adapter MUST connect to the URL’s host and port (443 when the URL gives none), with the host as the TLS server name (draft-22 §6.1.2, §6.2.2). It MUST NOT send the URL’s path or query anywhere, and MUST NOT read, validate or log them: the path and the token travel only in the core’s SETUP (TA-6).
TA-4. The adapter MUST accept the https: URL the core hands it, in every framing. It MUST NOT
require a moqt: URL.
Why: callers pass the same URL in every runtime (#186). draft-22 §6.2.1 derives the https URI
from a moqt one by changing the scheme, so they name the same server.
Version negotiation
Section titled “Version negotiation”TA-5. The adapter MUST offer exactly the factory’s protocols, in the order given: as
WT-Available-Protocols on a WebTransport session (draft-ietf-webtrans-http3 §3.3), as the ALPN
list on native QUIC (RFC 7301 §3.1), and over WebSocket as Sec-WebSocket-Protocol, each protocol
prefixed with the QMux wire format that carries it, qmux-01.<protocol> (draft-lcurley-qmux-websocket,
“Subprotocol Negotiation”). On native QUIC it MUST NOT add h3 or any other ALPN, even
though draft-22 §6.1.2 lets a client offer h3 beside its MOQT ALPNs. Over WebSocket it MUST NOT add
a bare wire format (qmux-01, qmux-00, webtransport).
Why: the SDK speaks one MOQT version. A fallback to h3 would turn a native QUIC adapter into
a WebTransport session it cannot run, and a bare wire format lets moq-relay pick moq-lite in SETUP.
moq-relay pairs moqt-18 and newer only with qmux-01 (QMUX01_ONLY_ALPNS in
rs/moq-relay/src/websocket.rs), and fails the upgrade with 400 when nothing offered is its own.
TA-6. On native QUIC the core, not the adapter, sends the AUTHORITY setup option (the URL’s
authority) and the PATH setup option (the URL’s path-abempty, then ? and the query when present,
the resolved jwt included) in CLIENT_SETUP. On a WebTransport session, and over WebSocket, it MUST
NOT send either (draft-22 §9.1.1, §9.1.2): the request URL carries them.
The cases, from the URL and token to the exact option values, are in
vectors/setup-options.json.
TA-7. The adapter MUST NOT resolve ready until the peer has explicitly selected one of the
offered protocols: the negotiated ALPN on native QUIC, the WT-Protocol response header on a
WebTransport session, the Sec-WebSocket-Protocol response header over WebSocket. A peer that selects nothing, or selects an identifier that was not offered,
MUST make ready reject with a session error (TA-22). The adapter MUST NOT assume a default
protocol.
Why: the relay’s default when a client selects nothing is moq-lite, which the SDK does not
speak. A silent default fails late, as an obscure SETUP error, instead of at ready.
TA-8. Once ready resolves, transport.protocol MUST be the selected identifier: over WebSocket,
the MOQT protocol without its qmux-01. prefix.
Why: the core reads protocol to pick its codec, and the SDK’s connecting layer fails the
connection unless it is one of MOQT_PROTOCOL_IDS (#203). That check backs up TA-7. It does not
replace it. The TypeScript core’s MoqtTransport type marks protocol optional so an embedder can
hand the core a browser WebTransport whose type, or implementation, lacks it. The core then uses
moqt-22, so such a transport must offer only moqt-22. An adapter for these SDKs still MUST
report it.
Session lifetime
Section titled “Session lifetime”TA-9. closed MUST resolve, with { closeCode, reason }, when the session ends by an
application close from either end: a local close(), or the peer’s application CONNECTION_CLOSE
(native QUIC, frame type 0x1d, RFC 9000 §19.19; over WebSocket the QMux frame of that type) or
CLOSE_WEBTRANSPORT_SESSION capsule (draft-ietf-webtrans-http3 §6), whatever its code. It MUST
reject, with a session error (TA-22), when the session ends any other way: a QUIC-layer
CONNECTION_CLOSE (frame type 0x1c, a transport error such as FLOW_CONTROL_ERROR, RFC 9000 §20.1)
from either end, connection loss, idle timeout, a stateless reset, a WebSocket that closes or drops
without a QMux close, or a failed handshake or dial. closed MAY reject at the same time as ready.
Why: the core treats a resolution as a clean end and a rejection as a failure
(#handleClosed), and after SETUP it reports no error for a resolution. A transport failure that
resolved closed would read as an orderly shutdown. The core attaches its rejection handler as
soon as the factory returns (connect-session.ts), so a closed that rejects with ready is never
unhandled. Resolving for any application close code matches W3C WebTransport, where a
CLOSE_WEBTRANSPORT_SESSION capsule resolves closed with its code.
TA-10. close({ closeCode, reason }) MUST end the session with that code and reason, using the
framing’s own close (draft-22 §6.6). It MUST be idempotent, and MUST NOT throw, including after the
session has ended. A missing closeCode means 0 and a missing reason means empty.
Why: the core closes from teardown paths that cannot tell whether the session already ended.
TA-11. close() called before ready settles MUST cancel the dial: ready MUST reject,
closed MUST reject, and no session may be established afterwards, even if the handshake was about
to complete.
Why: a connect deadline or an aborted signal closes the transport mid-dial. A dial that went on
regardless would leave a session nobody owns.
TA-12. Once the session has ended, by either end:
createUnidirectionalStream()andcreateBidirectionalStream()MUST reject.- Every pending operation on every stream of the session MUST reject with a session error.
- The incoming stream readables MUST close after a clean end and error after any other.
Streams
Section titled “Streams”TA-13. Each stream the peer opens MUST appear on the matching incoming readable, in the order
the transport accepted them. Bytes on a stream MUST be delivered in order, and a FIN MUST end the
readable (done) only after every byte before it was delivered.
TA-14. A send stream’s write MUST resolve once the transport has accepted the bytes, not once the peer has received or acknowledged them, and MUST apply backpressure: a write stays pending while flow control holds the stream (RFC 9000 §4.1).
TA-15. A send stream’s close MUST send the FIN after every accepted write, and MUST resolve only
once the peer has acknowledged every byte and the FIN: the stream has reached the “Data Recvd”
state (RFC 9000 §3.1). It MUST reject if that becomes impossible: the stream is reset, the peer
stops it, or the session ends first.
Why: a group’s operation completes when its FIN is handed over, and it stays a settling group
until close resolves (settling-groups.ts, subgroup-writer.ts). publisher.close() drains the
settling groups before it closes the session. If close resolved at hand-over, that drain would
prove nothing and groups would be lost silently at shutdown.
Evidence: Chromium and the #187 spike both resolve close only on acknowledgement. In the spike,
with the peer frozen, close() stayed pending for the whole 1000 ms freeze and resolved 1 ms after
the peer resumed.
TA-16. A send stream’s abort MUST send RESET_STREAM immediately (RFC 9000 §19.4), in every state
short of “Data Recvd”: while a write is blocked on flow control, and while close is waiting for
acknowledgement after the FIN went out (RFC 9000 §3.1 allows Data Sent → Reset Sent). The pending
write, the pending close and writer.closed MUST reject.
Why: a write timeout or a changed subscription has to free the track’s write slot now, not after
the blocked write drains or the FIN is acknowledged.
TA-17. The code a reset or a stop carries out MUST be taken from the abort or cancel reason’s
streamErrorCode when that is an integer from 0 to 2³²−1, and MUST be 0 otherwise (absent, not a
number, not an integer, out of range). An adapter other than a runtime’s native WebTransport MUST
NOT depend on the reason’s class or name. A runtime’s native WebTransport reads the code only from
a native WebTransportError reason and resets with 0 for any other (see
Known deviations), so where that global exists the core MUST hand over a
WebTransportError (streamResetReason in stream-reset.ts does).
Why: the core sends MOQT stream reset codes (draft-22 §12.5) as streamErrorCode on the
reason it hands over (stream-reset.ts, write-slot.ts): a native WebTransportError in a browser,
the SDK’s own MoqtStreamError, which has the TA-22 shape, elsewhere. 2³²−1 is the largest code a
WebTransport session can carry, so the range is the same in both framings. A conformance test for this rule against a browser hands
the reason streamResetReason builds, not a plain object.
TA-18. A receive stream’s cancel MUST send STOP_SENDING (RFC 9000 §19.5) with the code TA-17 gives, and end the readable. Later reads MUST NOT deliver more bytes.
TA-19. A peer RESET_STREAM MUST error the receive stream with a stream error (TA-22) carrying the peer’s code. Bytes delivered before the reset stay delivered. Bytes that had not been delivered MAY be discarded (RFC 9000 §3.2).
TA-20. A peer STOP_SENDING MUST error the send stream with a stream error (TA-22) carrying the
peer’s code. writer.closed MUST reject with it even when no write or close is in flight, and
the next write or close MUST reject with it. The adapter’s QUIC stack answers the stop with a
RESET_STREAM as RFC 9000 §3.5 requires.
Why: the relay stops a subgroup stream when its group is no longer of interest (draft-22
§11.3.2). The write slot hears of it through writer.closed between writes (isStreamStop,
write-slot.ts) and reports SUBSCRIPTION_CHANGED. A stop that surfaced only on the next write
would be reported late, or never on an idle track.
TA-21. When the peer’s stream limit (MAX_STREAMS, RFC 9000 §4.6, §19.11) is
exhausted, createUnidirectionalStream() and createBidirectionalStream() MUST reject promptly. They
MUST NOT wait for the peer to raise the limit. Once credit is available again, they MUST succeed
again. A credit rejection’s shape is not part of the contract (TA-22 does not apply to it).
Why: the core bounds its own use of credit (settling groups, one open stream per track), so a
rejection means the peer is stingier than the core assumed, and the operation fails with
TRANSPORT_ERROR. A create that hung would hold the track’s write slot until its write timeout,
with no signal of why.
Evidence: Chromium rejects (settling-groups.ts). The #187 spike polls quinn’s open_uni()
once. With the peer at MAX_STREAMS 4, the fifth create rejected, and credit returned once a stream
drained.
TA-27. On native QUIC, stream codes MUST cross the wire in the HTTP/3 encoding
draft-ietf-webtrans-http3 gives WebTransport application error codes, as moq-relay uses them there.
A reset or a stop with code n (TA-17, TA-18) MUST carry 0x52E4A40FA8DB + n + floor(n / 0x1E).
A peer’s RESET_STREAM or STOP_SENDING code from 0x52E4A40FA8DB to 0x52E5AC983162 MUST be decoded
before TA-19, TA-20 and TA-22 report it: c − 0x52E4A40FA8DB = s gives s − floor(s / 0x1F),
and a GREASE slot (s mod 0x1F = 0x1E) carries no code and MUST be reported as null. A code
outside that range MUST be reported as it arrived. Session close codes (TA-9, TA-10, TA-24) MUST NOT
be mapped. This rule does not apply to a WebTransport session, whose runtime maps codes itself, nor
over WebSocket, where QMux carries stream codes as they are: moq-relay’s QMux sessions do not map
them (SendStream::reset in qmux 0.5.2, which moq-relay 0.17.5 uses), and a forwarded subgroup reset
reaches a WebSocket subscriber as code 1 (measured on a local 0.17.5, 2026-10-17).
Why: streamErrorCode must mean the same thing in both framings, and the relay must be able to
read the codes the core sends. moq-relay reaches raw QUIC through web-transport-moq, whose stream
wrappers map every reset and stop code through error_to_http3 and error_from_http3 (moq-relay
0.17.0 uses web-transport-moq 1.3.3, send.rs and recv.rs), while its session close stays raw.
draft-22 puts stream codes on native QUIC unmapped (§12.5, RFC 9000 §19.4, §19.5), so this follows
the relay rather than the draft (#248). Decoding is lenient: a peer that sends the draft’s raw codes
is still read correctly, since none of them reaches the range. The encoding on the way out is what
would have to change if web-transport-moq stopped mapping on raw QUIC.
Evidence: on 2026-10-05, production 0.17.0 reset forwarded subgroups with 0x52E4A40FA8DC
(code 1) in 3/3 runs per case, from Python and TS subscribers over native QUIC. A local
moqdev/moq-relay:0.17.0 logged a raw reset with code 7 from our publisher as
transport: invalid RESET_STREAM: 7, and the encoded one as code=7. Either way the relay forwards
the reset with code 1 rather than the publisher’s (#156), so subscribers read 1 today.
TA-28. createUnidirectionalStream(options) MUST accept { sendOrder }, an integer where a
higher value is more urgent and 0 is what every stream opened without one gets, and MUST open the
stream as it would without it. An adapter whose transport can schedule streams SHOULD hold a stream’s
queued bytes while a stream with a higher send order has bytes queued, as a WebTransport send order
does within its send group. An adapter that cannot MUST ignore the option.
Why: the publisher opens each data stream of a track that declares a Default Publisher Priority
with a send order below 0 (behavior.md, “Send order”), so that under a
congested uplink the publisher’s own transport sends audio before video, and both after the control
and request streams.
Evidence: a browser’s WebTransport takes sendOrder itself (W3C WebTransport,
WebTransportSendOptions): Firefox 155 schedules by it, and Chromium 153 ignores it (see
Known deviations). @moq/qmux 0.3.3 schedules stream data
highest sendOrder first, round-robin among equals (scheduler.d.ts). quinn 0.11’s
SendStream::set_priority takes an i32, higher sent first and 0 by default; @caprid/relay-quic
clamps the send order to that range.
Error shape
Section titled “Error shape”TA-22. Every error an adapter raises for a stream or session failure MUST be an object with a
source field:
source: 'stream': the failure belongs to one stream: the peer reset it (TA-19) or stopped it (TA-20). It MUST carrystreamErrorCode: the peer’s code as an integer, ornullwhen the adapter cannot know it.nullmeans unknown, never 0. A code above 2⁵³−1, possible only on native QUIC (RFC 9000 §20.2 allows 62 bits), MUST be reported asnull.source: 'session': the session has ended, and this operation failed because of it (TA-9, TA-12), or the dial failed (TA-7, TA-11).
The error’s class and name are not part of the contract. A browser’s WebTransportError already
has this shape, and other adapters SHOULD use an Error subclass so a stack is kept.
Why: the core classifies by shape so that an adapter without WebTransportError is understood
(#190): isStreamError and isSessionError in transport-error.ts read source alone, and
isStreamStop (errors.ts) and the publish session’s announce-loss check build on them.
TA-23. source: 'session' on a stream operation’s error MUST mean the session is over. An
adapter MUST NOT use it for a failure that leaves the session usable.
Why: the publish session reads a request stream failing with source: 'session' as the
session’s own end, not something the peer did to the stream (isSessionError). TA-21’s credit
rejection is exempt only because it is a create rejection, which the core never classifies.
TA-24. A session error that reports the peer’s close SHOULD carry the peer’s closeCode and
reason. A session error for a lost connection carries neither.
Why: a refusal for credentials is a peer close with MOQT’s UNAUTHORIZED code (draft-22 §12.2),
which the SDK reports as UNAUTHORIZED while connecting. Browsers did not expose it against relays
before moq-relay 0.17.0 (#14), so it is not a MUST.
Process lifetime
Section titled “Process lifetime”TA-25. In a runtime with a process event loop (Node), an adapter MUST keep the process alive from
the start of the dial until closed settles, and MUST NOT let an open session be idle-unreferenced.
Why: a publisher script with nothing else scheduled must not exit while it is connected.
Evidence: in the #187 spike, the SDK’s always-pending accept is a pending napi promise. It kept a
child process with an open session alive past 2.5 s.
TA-26. Once closed has settled, the adapter MUST hold nothing that keeps the process alive:
sockets, timers, threads or pending native calls. It is not required to release them within any
fixed time.
Evidence: in the #187 spike the process exited about 125 ms after close().
Browsers have no process to keep alive, so TA-25 and TA-26 do not apply to them.
What the core does in return
Section titled “What the core does in return”The contract has two sides. Against an adapter that follows the rules above, the core:
- attaches a handler to
closedas soon as the factory returns, so TA-9’s rejection alongsidereadyis never unhandled. - sends PATH and AUTHORITY only on native QUIC (TA-6), and fails the connection with
TRANSPORT_ERROR, closing the transport, whenframingis none of the values TA-1 allows. - checks
protocolafterreadyand, unless it is one it offered (moqt-22ormoqt-20), fails the connection withTRANSPORT_ERRORin phaseconnecting, naming the protocol received (or “none”), closes the transport and never sends CLIENT_SETUP (TA-7, TA-8). - classifies errors by
source, not by class or name, and hands overstreamErrorCodeon the reasons it resets and stops with (TA-17, TA-22,transport-error.ts). - never concludes the session has ended from a create rejection alone, only from
closedor asource: 'session'error on a stream. - fails the connection with
UNAUTHORIZEDwhen the peer closes with MOQT’s UNAUTHORIZED (0x2), MALFORMED_AUTH_TOKEN (0x16) or EXPIRED_AUTH_TOKEN (0x18) before its SETUP, read fromclosed’s resolution (TA-9) or a session error’scloseCode(TA-24). The same close after SETUP is not a refusal. - keeps reading the Chromium deviations below as their conforming equivalents.
Known deviations
Section titled “Known deviations”These are adapters, as they exist, that do not meet a rule. The core tolerates each one. New adapters MUST NOT copy them.
- Chromium STOP_SENDING as
NetworkError(TA-20, TA-22). Depending on which of its own notifications lands first, Chromium reports a peer STOP_SENDING either as aWebTransportErrorwithsource: 'stream'(“Received STOP_SENDING.”), or as aNetworkErrorDOMExceptionwith the message “The stream was aborted by the remote server”, which has nosourceand no code. The core MUST keep accepting the second form as a stop (isStreamStopinerrors.ts). - Browser reset reasons (TA-17). A browser’s native
WebTransporttakes a reset or stop code only from a nativeWebTransportErrorreason, and resets with code 0 for a plain object orErrorcarryingstreamErrorCode. The core works around it by building aWebTransportErrorwherever the global exists. - Chromium
WebTransportErrorconstructor (TA-17). Chromium still implements the earlier dictionary constructor,new WebTransportError({ message, streamErrorCode }).streamResetReasonfalls back to it, so the reason still carries the code. - Browser session close codes (TA-9, TA-24), fixed from moq-relay 0.17.0. Against moq-relay
0.15.8 and earlier, a browser did not expose the peer’s close code on a refused connection: the
relay tore down HTTP/3 in the same flight as its CLOSE_WEBTRANSPORT_SESSION capsule, so
closedrejected with “Connection lost.” and nocloseCode, and a credentials refusal reached the SDK asTRANSPORT_ERROR(#14). moq-relay 0.17.0 keeps the session alive until the capsule is delivered (upstreamb14a61f17, moq-dev/moq#4429), soclosedresolves with code 2 and reasonunauthorizedand the SDK reportsUNAUTHORIZEDin phaseconnecting. The core needs no change. Only a browser against an older relay still sees the deviation. Evidence: Chromium againstsjc.relay.mux.globalon 2026-10-02 (0.15.8): no token, an empty token, a tampered signature and a wrong path each gave “Connection lost.” in 3/3 runs. On 2026-10-05 (0.17.0): a missing, malformed, tampered, expired-looking or wrong-path token each gaveUNAUTHORIZEDin phaseconnectingin 3/3 runs. - Chromium send order (TA-28). Chromium 153 has no
WebTransportSendStreamand nocreateSendGroup(), and dropssendOrderfrom the options: its streams are scheduled alike. Firefox 155 has both and schedules by it. Evidence: on 2026-10-20, through an 800 kbit/s uplink cap (ts/scripts/probe-send-order.mjs), turning the media streams’ order around starved a Firefox publisher’s audio and left a Chromium publisher’s unchanged (behavior.md, “Send order”). - Browser stream error codes (TA-22). A browser’s
streamErrorCodeis nullable and isnullwhen the browser does not know the code, which TA-22 allows. - Chromium
readywithout a selected protocol (TA-7). When the peer’s response names no protocol, or one that was not offered, Chromium resolvesreadyanyway and setsprotocolto the empty string. The core’s check ofprotocolafterready(TA-8) turns that into a connect failure before SETUP. Evidence: the conformance suite, in Chromium 147.0.7727.15 against the Python accepting end. - Chromium 8-bit send codes (TA-17, TA-18). Chromium’s
WebTransportErrorconstructor clampsstreamErrorCodeto 255, so a reset or a stop it sends carries at most 255. Codes it receives are not limited: a peer reset with 2³²−1 arrives intact (TA-19). This is harmless today, since every stream reset code the core sends is 0x12 or below (draft-22 §12.5). Evidence: in Chromium 147.0.7727.15,new WebTransportError({ message, streamErrorCode: 256 }).streamErrorCodeis 255, and the conformance suite’s peer sees 255 for a reset or stop sent with 256, 65536 or 2³²−1.
QMux over WebSocket (@caprid/relay-websocket)
Section titled “QMux over WebSocket (@caprid/relay-websocket)”The adapter wraps @moq/qmux 0.3.3 (ADR 0005). It meets every other rule. The conformance suite
runs it in Node, Chromium, Firefox and WebKit (conformance/targets/websocket.js).
- No acknowledgement (TA-15, and TA-16 while close waits). QMux runs over TCP and acknowledges
nothing an application can see, so a send stream’s close resolves once its FIN is handed to the
WebSocket, not once the peer has it. A settling group’s wait therefore ends at hand-over, and
publisher.close()drains nothing a closing socket can still lose. This is the binding, not the library: no QMux-over-WebSocket adapter can meet TA-15. - A reset behind a blocked write (TA-16). The pending write and
writer.closedreject at once, so the write slot is free at once, but the RESET_STREAM goes out only once@moq/qmuxhas finished the write it already started, when the peer next grants credit. - A peer reset behind an unread chunk (TA-19).
@moq/qmuxreports a peer RESET_STREAM only to a read. The adapter keeps one read outstanding, so a reset lands without a read pending unless a chunk the application has not read is already waiting. In that case the next read delivers that chunk and the one after rejects. RFC 9000 §3.2 lets the chunk be discarded, but here it is delivered. - Long close reasons (TA-10). A reason is cut to 1024 UTF-8 bytes, on a character boundary:
@moq/qmuxrefuses to close at all when the close frame would not fit one QMux record.closedresolves with the reason as sent. The core’s own reasons are far shorter. - Stream credit (TA-21). A create waits for MAX_STREAMS credit instead of rejecting. moq-relay grants far more streams than the core uses at once, so the wait is not seen against the relay.
Test doubles
Section titled “Test doubles”The in-memory transport pair (ts/packages/moq-core/src/tests/helpers/transport-pair.ts) is a test
double, not an adapter the product ships. Its streams are in-process pipes with a receive buffer, and
createTransportPairFactory puts a factory in front of it with ready, framing and protocol. It
passes the conformance suite except where it has no QUIC to model, and the suite skips those cases
for it:
- no stream credit: creates never reject for credit (TA-21)
- no acknowledgement: close resolves once the FIN is in the peer’s buffer, and a peer cannot pause: drop what the dialer sends so that nothing is acknowledged (TA-15, and the cases of TA-14 and TA-16 that need a paused peer)
close() reaches both ends at once, with no time in flight. That is harmless: no rule depends on how
long a close takes to arrive.
A test that depends on credit or acknowledgement uses an adapter that has it: Chromium against the
conformance suite’s Python accepting end, the Python loopback server, or @caprid/relay-quic.
Python mapping (informative)
Section titled “Python mapping (informative)”moq_core.transport.MoqtTransport is the same seam spelled for asyncio. caprid_relay has one aioquic
adapter for it, caprid_relay.quic (connect_quic): native QUIC, and the default (ADR 0004). The package
ships no WebTransport session, so the rules only a WebTransport session has (TA-2, and TA-5’s
WT-Available-Protocols) have no Python row; a create_transport factory may still declare that framing
(TA-1). The test suite keeps a WebTransport session as the conformance peer’s accepting end, on the same
stream halves and QUIC plumbing (#232). How the rules map:
| Rule | Python |
|---|---|
| TA-1 | ConnectingTransport.framing, "webtransport" when a factory leaves it out. connect_quic, the default factory, declares "native-quic". "websocket" sends what "webtransport" does, though the package ships no WebSocket adapter. Any other value fails the connection with TRANSPORT_ERROR, closing the transport. |
| TA-3, TA-4 | Native QUIC: connect_quic takes the https: URL, dials its host and port (443 by default) with the host as the server name, and reads nothing else from it. |
| TA-5 | The ALPN list is exactly protocols, with no h3. |
| TA-6 | open_relay_session sends PATH and AUTHORITY through moq_core’s setup_options on native QUIC only (test_setup_options.py, against vectors/setup-options.json). |
| TA-7, TA-8 | protocol_id is the negotiated ALPN. A handshake that negotiates none, or one that was not offered, fails the dial. |
| TA-9 | wait_closed() returns after a clean end and raises the error otherwise. A peer’s application close (0x1d) is a clean end whatever its code, and close_info holds its (code, reason). A QUIC-layer close, an idle timeout or a failed dial raises. |
| TA-10 | close(close_code=0, reason="") is idempotent and ignored after the session ends. It waits, within a bound, for every send stream to be acknowledged first (#209), then ends the connection with an application CONNECTION_CLOSE carrying the code and reason. |
| TA-11 | close() before ready cancels the dial: ready and wait_closed() raise. |
| TA-12 | The incoming readables are async iterators. After a clean end they raise StopAsyncIteration; after any other, they raise the session’s error, the same source="session" error wait_closed() raises. Either way, streams that arrived before the end are yielded first. |
| TA-15 | Split in two: SendStream.close() returns once the FIN is handed over, and AcknowledgedSendStream.wait_acknowledged() returns on acknowledgement. Together they meet the rule. wait_acknowledged() also raises when the stream fails between writes, so it stands in for writer.closed (TA-12, TA-16, TA-20). A stream without wait_acknowledged counts as acknowledged at once, which only test doubles do. |
| TA-16 | abort() resets until the peer acknowledges the FIN, including after the FIN was transmitted, and wait_acknowledged() then raises the reason. Once the FIN is acknowledged it does nothing (the stream is in Data Recvd). A settling group’s delivery timeout depends on this (#167). |
| TA-17, TA-18 | abort(reason) and cancel(reason) take the code from a stream_error_code attribute (the write slot’s StreamReset, or a stream error), else from an integer reason, each within 0 to 2³²−1, else 0. The code goes on the wire as TA-27 encodes it. |
| TA-19, TA-20, TA-22 | Errors have the TA-22 shape, spelled source and stream_error_code: caprid_relay.quic raises a private QuicTransportError with them. A peer STOP_SENDING is a source="stream" error on the send half, with no subclass of its own. The core and the SDK classify by source alone (moq_core.transport.is_stream_error, is_session_error). An exhausted stream limit (TA-21), and a write to a stream this end already closed or reset, carry source=None. |
| TA-21 | A create rejects at once when the peer’s MAX_STREAMS is used up, and succeeds again once the peer raises it. |
| TA-24 | close_info carries the peer’s application close, and so does the session error an operation raises once it has ended the session (close_code and reason). The SDK reports UNAUTHORIZED from either, in any phase. |
| TA-25, TA-26 | asyncio holds no process open by itself: the application’s own asyncio.run decides. |
| TA-27 | QuicSession encodes and decodes stream codes in its _to_wire_code and _from_wire_code hooks (caprid_relay.quic._errors). The test suite’s native QUIC accepting ends map them the same way, as the relay does. |
| TA-28 | Not bound: create_unidirectional_stream() takes no options, since the Python publisher sets no send order and aioquic has no stream priorities. |
Conformance
Section titled “Conformance”The conformance suite (ts/conformance/, #193) is written against MoqtTransport once and run against
every target:
- the in-memory pair in vitest, as part of
pnpm check - Chromium’s native
WebTransport, inpnpm test:conformance, against a scriptable Python accepting end (python/packages/caprid-relay/tests/relay_helpers/conformance_peer.py) that can reset, stop, close at either layer, withhold acknowledgement and limit stream credit @caprid/relay-quicin Node, inpnpm test:conformance:node, against the same accepting end in its native QUIC mode (--framing native-quic)@caprid/relay-websocketin Node, inpnpm test:conformance:websocket, and in Chromium, Firefox and WebKit, inpnpm test:conformance:websocket:browsers, against a Node accepting end for QMux over WebSocket (ts/scripts/websocket-peer.js,@moq/qmuxoverws) that speaks the Python one’s protocol
A pytest port (python/packages/caprid-relay/tests/test_transport_conformance.py, #230) runs the same cases
against moq_core.transport.MoqtTransport, spelled as the Python mapping says,
in the Python SDK’s uv run pytest. Its target (tests/relay_helpers/conformance_targets.py) is the native QUIC
dialing end (connect_quic), against --framing native-quic; the cases only a WebTransport session has are
skipped because the package ships none (#232).
The Python mapping has no Differs row, so the native QUIC target has no known failure. A Differs row added later is a known failure there, naming the issue that closes it. TA-6, TA-25 and TA-26 are skipped, with the reasons the mapping gives.
Each MUST has at least one test named after its rule. TA-6 is the exception: it binds the core, not the
adapter, and the core’s SETUP tests cover it. For TA-26 the test is a Node script that has to exit by
itself after close().
A target declares what it cannot exercise, and which rules it is known to fail. A skip names its reason. A known failure names its Known deviation, or the issue that closes the gap, and its test goes red once the target starts to pass, so the expectation gets removed. The suite owns the results, and this document does not carry a per-adapter matrix.
A rule that a real adapter cannot meet is either fixed in the adapter or moved, with its evidence, to Known deviations. It is never removed silently.