From da1c4d396842703ff7fbfc43249927693b0dd17d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 26 Apr 2026 20:40:19 +0000 Subject: [PATCH] fix(audio-rooms): advertise moq-lite-03 in WT CONNECT sub-protocols MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../transport/QuicWebTransportFactory.kt | 35 ++++++++++++++++++- .../nestsclient/interop/NostrNestsHarness.kt | 8 ++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt index 41a5e017f1..c50a8e6d58 100644 --- a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt @@ -79,6 +79,18 @@ class QuicWebTransportFactory( * the Extended CONNECT request stream before giving up with HandshakeFailed. */ 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 = listOf("moq-lite-03"), ) : WebTransportFactory { override suspend fun connect( authority: String, @@ -136,7 +148,13 @@ class QuicWebTransportFactory( // Open the Extended CONNECT request stream. 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)) driver.wakeup() @@ -214,6 +232,21 @@ class QuicWebTransportFactory( val status: Int, ) : 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 { + 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 { val idx = authority.lastIndexOf(':') if (idx <= 0) return authority to 443 diff --git a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt index 8957dd23ac..6cd356ce15 100644 --- a/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt +++ b/nestsClient/src/jvmTest/kotlin/com/vitorpamplona/nestsclient/interop/NostrNestsHarness.kt @@ -222,8 +222,14 @@ class NostrNestsHarness private constructor( private fun startExternal(): NostrNestsHarness { try { 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) + // 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) { throw IllegalStateException( "external moq-auth / moq-relay not reachable on 127.0.0.1:" +