fix(audio-rooms): advertise moq-lite-03 in WT CONNECT sub-protocols

Without `wt-available-protocols`, moq-relay (`web-transport-quinn`) falls
back to the legacy in-band SETUP exchange (moq-lite-02) instead of
selecting the moq-lite-03 sub-protocol from the ALPN-style negotiation
header. Then the relay tries to decode our first post-CONNECT bytes as a
SETUP_CLIENT message, hits an unknown control type, and closes the QUIC
connection with `connection closed err=invalid value` — surfaced
client-side as a stuck SUBSCRIBE that ends with
`subscribe stream FIN before reply for id=0` (the bidi gets FIN'd because
the whole connection is being torn down).

Pass `wt-available-protocols: "moq-lite-03"` on the Extended CONNECT
request, encoded as an RFC 8941 Structured Field List of strings (the
header format mandated by draft-ietf-webtrans-http3-14 §3.3). With this,
moq-relay logs `negotiated version=moq-lite-03 transport="quic"` and the
SUBSCRIBE makes it into the relay's actual moq-lite session pump.

Mechanism: web-transport-proto's `ConnectRequest::encode` reads
`self.protocols` and writes them as a comma-separated list of bare
strings under `wt-available-protocols`. The server side (web-transport-
quinn) reads the same header into `request.protocols`, and moq-native's
`QuinnRequest::ok()` picks the first match against its supported ALPN
list (`moq-lite-04`, `moq-lite-03`, `moq-00`, `moqt-15`, etc.). On
match, version selection happens via the WT sub-protocol response and
the in-band SETUP is skipped — which is what moq-lite-03 expects.

Default the factory list to `["moq-lite-03"]`. Callers that want a
different version (or to disable sub-protocol negotiation entirely
to talk to a SETUP-based server) override the constructor parameter.

Bare-metal harness: NostrNestsHarness.startExternal() now skips the
TCP probe of the moq-relay port. moq-relay binds UDP only; the Docker
forwarder happens to also open TCP, but a directly-launched binary
doesn't, so the previous `Socket(host, 4443)` probe failed with
ConnectException. The QUIC handshake from the test surfaces a real
transport problem if any.
This commit is contained in:
Claude
2026-04-26 20:40:19 +00:00
parent d00e406587
commit da1c4d3968
2 changed files with 41 additions and 2 deletions
@@ -79,6 +79,18 @@ class QuicWebTransportFactory(
* the Extended CONNECT request stream before giving up with HandshakeFailed. * the Extended CONNECT request stream before giving up with HandshakeFailed.
*/ */
private val connectTimeoutMillis: Long = 10_000L, private val connectTimeoutMillis: Long = 10_000L,
/**
* WebTransport sub-protocols to advertise on the Extended CONNECT request,
* via the `wt-available-protocols` header (RFC 8941 Structured Field List
* of strings — see draft-ietf-webtrans-http3-14 §3.3).
*
* For nests, this MUST contain `moq-lite-03`; without it, moq-relay falls
* back to the legacy in-band SETUP exchange (moq-lite-02) and our first
* post-CONNECT message is decoded as SETUP_CLIENT, producing
* `connection closed err=invalid value` on the relay side and a stalled
* subscribe / `subscribe stream FIN before reply` on the client side.
*/
private val webTransportSubProtocols: List<String> = listOf("moq-lite-03"),
) : WebTransportFactory { ) : WebTransportFactory {
override suspend fun connect( override suspend fun connect(
authority: String, authority: String,
@@ -136,7 +148,13 @@ class QuicWebTransportFactory(
// Open the Extended CONNECT request stream. // Open the Extended CONNECT request stream.
val requestStream = conn.openBidiStream() val requestStream = conn.openBidiStream()
val headers = buildExtendedConnectHeaders(authority, path, bearerToken) val extraHeaders =
if (webTransportSubProtocols.isNotEmpty()) {
listOf("wt-available-protocols" to encodeSfStringList(webTransportSubProtocols))
} else {
emptyList()
}
val headers = buildExtendedConnectHeaders(authority, path, bearerToken, extraHeaders)
requestStream.send.enqueue(encodeHeadersFrame(headers)) requestStream.send.enqueue(encodeHeadersFrame(headers))
driver.wakeup() driver.wakeup()
@@ -214,6 +232,21 @@ class QuicWebTransportFactory(
val status: Int, val status: Int,
) : RuntimeException() ) : RuntimeException()
/**
* Encode [items] as an RFC 8941 Structured Field List of bare strings
* (the format `wt-available-protocols` requires per draft-ietf-webtrans-http3
* §3.3). Each entry becomes `"value"`; the items are comma-separated.
*
* The values we emit (e.g. `moq-lite-03`) are bare ASCII so we don't
* need to handle escaping — assert it instead of silently mis-emitting.
*/
private fun encodeSfStringList(items: List<String>): String {
require(items.all { p -> p.all { it in 0x20.toChar()..0x7e.toChar() && it != '"' && it != '\\' } }) {
"wt-available-protocols entry contains characters that need RFC 8941 escaping: $items"
}
return items.joinToString(", ") { "\"$it\"" }
}
private fun splitAuthority(authority: String): Pair<String, Int> { private fun splitAuthority(authority: String): Pair<String, Int> {
val idx = authority.lastIndexOf(':') val idx = authority.lastIndexOf(':')
if (idx <= 0) return authority to 443 if (idx <= 0) return authority to 443
@@ -222,8 +222,14 @@ class NostrNestsHarness private constructor(
private fun startExternal(): NostrNestsHarness { private fun startExternal(): NostrNestsHarness {
try { try {
waitForPort("127.0.0.1", AUTH_HOST_PORT, PORT_READY_TIMEOUT_MS) waitForPort("127.0.0.1", AUTH_HOST_PORT, PORT_READY_TIMEOUT_MS)
waitForPort("127.0.0.1", MOQ_HOST_PORT, PORT_READY_TIMEOUT_MS)
waitForHealth("http://127.0.0.1:$AUTH_HOST_PORT/health", PORT_READY_TIMEOUT_MS) waitForHealth("http://127.0.0.1:$AUTH_HOST_PORT/health", PORT_READY_TIMEOUT_MS)
// moq-relay is UDP only — the Docker compose forwarder
// happens to open TCP on 4443 too, which is what the
// Docker-mode probe relies on. Bare-metal moq-relay
// doesn't, so a TCP probe fails with ConnectException
// even though the UDP listener is healthy. Skip it
// here; the actual QUIC handshake from the test will
// surface a real connection problem if there is one.
} catch (t: Throwable) { } catch (t: Throwable) {
throw IllegalStateException( throw IllegalStateException(
"external moq-auth / moq-relay not reachable on 127.0.0.1:" + "external moq-auth / moq-relay not reachable on 127.0.0.1:" +