diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt index bc5f3888c..3cf1cf575 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/NestsConnect.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.nestsclient.audio.AudioCapture import com.vitorpamplona.nestsclient.audio.OpusEncoder import com.vitorpamplona.nestsclient.moq.SubscribeHandle import com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession +import com.vitorpamplona.nestsclient.moq.lite.MoqLiteVersion import com.vitorpamplona.nestsclient.transport.WebTransportException import com.vitorpamplona.nestsclient.transport.WebTransportFactory import com.vitorpamplona.nestsclient.transport.WebTransportSession @@ -106,19 +107,22 @@ suspend fun connectNestsListener( state.value = NestsListenerState.Connecting(NestsListenerState.Connecting.ConnectStep.MoqHandshake) - // moq-lite Lite-03 has NO setup message — the WebTransport handshake - // itself is the handshake, version is selected by the ALPN - // `moq-lite-03`. + // moq-lite has NO setup message — the WebTransport handshake itself + // is the handshake. Version (Lite-03 vs Lite-04) is selected by the + // ALPN exchange (`wt-available-protocols` / `wt-protocol`); fall + // back to Lite-03 if the server didn't echo `wt-protocol` (older + // single-protocol deployments). + val moqVersion = resolveMoqLiteVersion(webTransport.negotiatedSubProtocol) val moq = try { - MoqLiteSession.client(webTransport, scope) + MoqLiteSession.client(webTransport, scope, moqVersion) } catch (t: Throwable) { runCatching { webTransport.close(0, "moq-lite session init failed") } state.value = NestsListenerState.Failed("moq-lite session init failed: ${t.message}", t) return failedListener(state) } - state.value = NestsListenerState.Connected(room, MOQ_LITE_03_VERSION) + state.value = NestsListenerState.Connected(room, versionCode(moqVersion)) return MoqLiteNestsListener( session = moq, mutableState = state, @@ -134,6 +138,35 @@ suspend fun connectNestsListener( */ const val MOQ_LITE_03_VERSION: Long = 0x6D71_6C03L +/** + * Synthetic version code for moq-lite Lite-04 sessions — same shape + * as [MOQ_LITE_03_VERSION] with the ALPN suffix `-04` in the low + * bytes. + */ +const val MOQ_LITE_04_VERSION: Long = 0x6D71_6C04L + +/** + * Resolve the [MoqLiteVersion] to use for a session given the ALPN + * the WebTransport server selected (or `null` if the server didn't + * echo `wt-protocol`, e.g. older single-protocol deployments). + * + * Falls back to [MoqLiteVersion.LITE_03] when the negotiated value + * is unknown or absent — every nostrnests deployment supports + * Lite-03, so this stays safe under any of: + * - server is older than draft-13 (no `wt-protocol` echo) + * - server picked something we don't recognise (e.g. a future + * `moq-lite-05`) + * - client offered only one protocol + */ +internal fun resolveMoqLiteVersion(negotiatedSubProtocol: String?): MoqLiteVersion = MoqLiteVersion.fromAlpn(negotiatedSubProtocol) ?: MoqLiteVersion.LITE_03 + +/** Map a [MoqLiteVersion] to its synthetic [MOQ_LITE_03_VERSION]-family code. */ +internal fun versionCode(version: MoqLiteVersion): Long = + when (version) { + MoqLiteVersion.LITE_03 -> MOQ_LITE_03_VERSION + MoqLiteVersion.LITE_04 -> MOQ_LITE_04_VERSION + } + /** * Build a no-op [NestsListener] in a Failed state for callers that want a * uniform return type even on early-failure paths. @@ -239,18 +272,19 @@ suspend fun connectNestsSpeaker( state.value = NestsSpeakerState.Connecting(NestsSpeakerState.Connecting.ConnectStep.MoqHandshake) - // moq-lite Lite-03 has NO setup message. Same logic as the listener - // path — version is selected by the `moq-lite-03` ALPN. + // moq-lite has NO setup message. Same logic as the listener path — + // version is selected by the ALPN exchange. + val moqVersion = resolveMoqLiteVersion(webTransport.negotiatedSubProtocol) val moq = try { - MoqLiteSession.client(webTransport, scope) + MoqLiteSession.client(webTransport, scope, moqVersion) } catch (t: Throwable) { runCatching { webTransport.close(0, "moq-lite session init failed") } state.value = NestsSpeakerState.Failed("moq-lite session init failed: ${t.message}", t) return failedSpeaker(state) } - state.value = NestsSpeakerState.Connected(room, MOQ_LITE_03_VERSION) + state.value = NestsSpeakerState.Connected(room, versionCode(moqVersion)) return MoqLiteNestsSpeaker( session = moq, speakerPubkeyHex = speakerPubkeyHex, diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteAlpn.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteAlpn.kt index 2c3186ef6..e37725e2e 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteAlpn.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteAlpn.kt @@ -47,12 +47,65 @@ object MoqLiteAlpn { const val LITE_03: String = "moq-lite-03" /** - * `moq-lite-04` ALPN string. Wire-incompatible with [MoqLiteCodec] - * today — see the object kdoc for the codec diff. Defined here so - * a future patch that lands version-aware Announce / Probe codecs - * can drop it into the [QuicWebTransportFactory] sub-protocol list - * without re-deriving the constant. + * `moq-lite-04` ALPN string. Wire-compatible with [MoqLiteCodec] + * when the codec is invoked with [MoqLiteVersion.LITE_04] — see + * [MoqLiteVersion] for the version-conditional codec branches. + * Lite-04 reshapes `Announce.hops` (varint count → `OriginList`), + * adds `AnnounceInterest.exclude_hop`, and adds `Probe.rtt`. + * Subscribe / SubscribeOk / Drop / Group / SubscribeResponse are + * unchanged from Lite-03. The factory advertises Lite-04 ahead + * of Lite-03 to match kixelated's preference order. */ const val LITE_04: String = "moq-lite-04" const val LEGACY: String = "moql" } + +/** + * Version discriminator for the [MoqLiteCodec] / [MoqLiteSession] + * version-aware code paths. Selected at WebTransport CONNECT time + * via the `wt-available-protocols` / `wt-protocol` ALPN exchange + * (see `QuicWebTransportFactory` and + * [com.vitorpamplona.nestsclient.transport.WebTransportSession.negotiatedSubProtocol]). + * + * The wire delta between [LITE_03] and [LITE_04] is exactly three + * fields: + * - `Announce.hops`: Lite-03 = single varint count; Lite-04 = + * `varint(count) + count × varint(originId)` (the `OriginList`). + * - `AnnounceInterest.exclude_hop` (= our `AnnouncePlease.excludeHop`): + * Lite-03 = absent; Lite-04 = a single varint after `prefix` + * (sentinel `0` = no exclusion). + * - `Probe.rtt`: Lite-03 = absent; Lite-04 = a single varint after + * `bitrate` (sentinel `0` = unknown; outgoing `Some(0)` is + * clamped to `Some(1)` to avoid colliding with the sentinel). + * + * Everything else (`Subscribe`, `SubscribeOk`, `SubscribeDrop`, + * `SubscribeResponse`, `GroupHeader`, `ControlType`, `DataType`, + * `AnnounceStatus`) is byte-for-byte identical between the two + * versions. + * + * Source: `kixelated/moq` `rs/moq-lite/src/lite/{announce,probe, + * subscribe}.rs` Lite-04 branches (verified against `main` @ + * 2026-05-09). + */ +enum class MoqLiteVersion( + val alpn: String, +) { + LITE_03(MoqLiteAlpn.LITE_03), + LITE_04(MoqLiteAlpn.LITE_04), + ; + + companion object { + /** + * Resolve a negotiated `wt-protocol` value back to the + * version enum, or `null` if the string isn't a recognised + * moq-lite version. Caller decides whether to fall back to + * a default ([LITE_03]) or fail loudly. + */ + fun fromAlpn(alpn: String?): MoqLiteVersion? = + when (alpn) { + MoqLiteAlpn.LITE_03 -> LITE_03 + MoqLiteAlpn.LITE_04 -> LITE_04 + else -> null + } + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteCodec.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteCodec.kt index 239a41d55..7a6f24868 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteCodec.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteCodec.kt @@ -49,37 +49,86 @@ import com.vitorpamplona.nestsclient.moq.MoqWriter object MoqLiteCodec { // ---------------- AnnouncePlease ---------------- - fun encodeAnnouncePlease(msg: MoqLiteAnnouncePlease): ByteArray { + fun encodeAnnouncePlease( + msg: MoqLiteAnnouncePlease, + version: MoqLiteVersion = MoqLiteVersion.LITE_03, + ): ByteArray { val body = MoqWriter() body.writeLengthPrefixedString(MoqLitePath.normalize(msg.prefix)) + // Lite-04 appends `excludeHop` as a single varint after + // prefix. Sentinel `0` = no exclusion. Lite-03 omits the + // field entirely; ignore [msg.excludeHop] in that case. + if (version == MoqLiteVersion.LITE_04) { + body.writeVarint(msg.excludeHop) + } return wrapSizePrefixed(body) } - fun decodeAnnouncePlease(payload: ByteArray): MoqLiteAnnouncePlease { + fun decodeAnnouncePlease( + payload: ByteArray, + version: MoqLiteVersion = MoqLiteVersion.LITE_03, + ): MoqLiteAnnouncePlease { val r = MoqReader(payload) val prefix = MoqLitePath.normalize(r.readLengthPrefixedString()) + val excludeHop = + if (version == MoqLiteVersion.LITE_04) r.readVarint() else 0L ensureFullyConsumed(r, "AnnouncePlease") - return MoqLiteAnnouncePlease(prefix = prefix) + return MoqLiteAnnouncePlease(prefix = prefix, excludeHop = excludeHop) } // ---------------- Announce ---------------- - fun encodeAnnounce(msg: MoqLiteAnnounce): ByteArray { + fun encodeAnnounce( + msg: MoqLiteAnnounce, + version: MoqLiteVersion = MoqLiteVersion.LITE_03, + ): ByteArray { val body = MoqWriter() body.writeByte(msg.status.code) body.writeLengthPrefixedString(MoqLitePath.normalize(msg.suffix)) - body.writeVarint(msg.hops) + // Lite-03: just the count. Lite-04: count + each origin id. + // Mirrors kixelated's `encode_hops` switch. + body.writeVarint(msg.hops.size.toLong()) + if (version == MoqLiteVersion.LITE_04) { + for (originId in msg.hops) { + body.writeVarint(originId) + } + } return wrapSizePrefixed(body) } - fun decodeAnnounce(payload: ByteArray): MoqLiteAnnounce { + fun decodeAnnounce( + payload: ByteArray, + version: MoqLiteVersion = MoqLiteVersion.LITE_03, + ): MoqLiteAnnounce { val r = MoqReader(payload) val statusByte = r.readByte() val status = MoqLiteAnnounceStatus.fromCode(statusByte) ?: throw MoqCodecException("unknown moq-lite Announce status byte: $statusByte") val suffix = MoqLitePath.normalize(r.readLengthPrefixedString()) - val hops = r.readVarint() + val count = r.readVarint() + if (count < 0L || count > MoqLiteAnnounce.MAX_HOPS) { + throw MoqCodecException("Announce hops count out of range [0, ${MoqLiteAnnounce.MAX_HOPS}]: $count") + } + val hops: List = + when (version) { + MoqLiteVersion.LITE_03 -> { + // Wire only carries a count; fill with UNKNOWN + // placeholders (origin id 0). Mirrors kixelated's + // `Origin::UNKNOWN` fill in announce.rs. + List(count.toInt()) { 0L } + } + + MoqLiteVersion.LITE_04 -> { + val list = ArrayList(count.toInt()) + repeat(count.toInt()) { + val id = r.readVarint() + require(id >= 0) { "decoded origin id must be non-negative" } + list.add(id) + } + list + } + } ensureFullyConsumed(r, "Announce") return MoqLiteAnnounce(status = status, suffix = suffix, hops = hops) } @@ -244,24 +293,52 @@ object MoqLiteCodec { // ---------------- Probe ---------------- - fun decodeProbe(payload: ByteArray): MoqLiteProbe { + fun decodeProbe( + payload: ByteArray, + version: MoqLiteVersion = MoqLiteVersion.LITE_03, + ): MoqLiteProbe { val r = MoqReader(payload) val bitrate = r.readVarint() + // Lite-04 appends `rtt` as a single varint after bitrate. + // Sentinel `0` = unknown (decoded as null). + val rtt: Long? = + if (version == MoqLiteVersion.LITE_04) { + val raw = r.readVarint() + if (raw == 0L) null else raw + } else { + null + } ensureFullyConsumed(r, "Probe") - return MoqLiteProbe(bitrate = bitrate) + return MoqLiteProbe(bitrate = bitrate, rtt = rtt) } /** - * Encode a single Lite-03 Probe message body - * (`lite/probe.rs` — `bitrate: u62` only; `rtt` is Lite-04+). - * The publisher writes these size-prefixed onto a Probe bidi the + * Encode a Probe message body. Lite-03 writes only `bitrate`; + * Lite-04 appends `rtt` (sentinel `0` = unknown; outgoing + * `Some(0)` is clamped to `Some(1)` to avoid colliding with the + * sentinel — matches kixelated's `encode_msg` clamp). The + * publisher writes these size-prefixed onto a Probe bidi the * subscriber opened, advertising the publisher's expected - * bandwidth. Wrapping (size prefix) is the caller's responsibility, - * matching [encodeAnnouncePlease] / [encodeAnnounce]. + * bandwidth. Wrapping (size prefix) is the caller's + * responsibility, matching [encodeAnnouncePlease] / [encodeAnnounce]. */ - fun encodeProbe(probe: MoqLiteProbe): ByteArray { + fun encodeProbe( + probe: MoqLiteProbe, + version: MoqLiteVersion = MoqLiteVersion.LITE_03, + ): ByteArray { val body = MoqWriter() body.writeVarint(probe.bitrate) + if (version == MoqLiteVersion.LITE_04) { + // null → 0 (unknown sentinel). Some(0) → 1 (clamp). + // Otherwise pass through. + val wire = + when (val r = probe.rtt) { + null -> 0L + 0L -> 1L + else -> r + } + body.writeVarint(wire) + } return wrapSizePrefixed(body) } diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteMessages.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteMessages.kt index 8a5991f0b..e149cf514 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteMessages.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteMessages.kt @@ -30,16 +30,32 @@ package com.vitorpamplona.nestsclient.moq.lite /** * "I'm interested in broadcasts under this prefix" — the first message - * the subscriber writes on an Announce bidi. + * the subscriber writes on an Announce bidi. Mirrors kixelated's + * `AnnounceInterest` struct (`rs/moq-lite/src/lite/announce.rs`). * * Wire layout (size-prefixed): - * prefix string (varint length + UTF-8) + * prefix string (varint length + UTF-8) + * excludeHop u62 varint (Lite-04 ONLY; sentinel `0` = no exclusion) * - * Empty prefix means "everything". + * Empty prefix means "everything". `excludeHop != 0` (Lite-04 only) + * asks the publisher to skip announces whose hop ID list contains + * this value — used in clustered moq-rs deployments to break + * forwarding loops. */ data class MoqLiteAnnouncePlease( val prefix: String, -) + /** + * Lite-04 only. `0` (the default) means "no exclusion." A + * non-zero value asks the publisher to skip announces whose + * `hops` list contains this origin ID. Encoded as a single + * varint after [prefix]; absent on the wire under Lite-03. + */ + val excludeHop: Long = 0L, +) { + init { + require(excludeHop >= 0) { "excludeHop must be non-negative: $excludeHop" } + } +} /** * Per-broadcast announce update streamed by the publisher (or relay) @@ -52,13 +68,37 @@ data class MoqLiteAnnouncePlease( * suffix string (broadcast path with the requested `prefix` * stripped — `MoqLitePath.join(prefix, suffix)` * reconstitutes the absolute path) - * hops u62 (relay routing depth, Lite-03 only) + * hops OriginList (Lite-03: a single varint count; + * Lite-04: `varint(count) + count × varint(id)`) + * + * [hops] is modeled as `List` — origin IDs the announce has + * traversed. Empty list = no hops. Origin id `0` is reserved as + * `Origin::UNKNOWN` (used by Lite-03 decode to fill the count of + * placeholder entries when the wire only carries a count). Per + * kixelated, [hops] is bounded to 32 entries (`MAX_HOPS`). */ data class MoqLiteAnnounce( val status: MoqLiteAnnounceStatus, val suffix: String, - val hops: Long, -) + val hops: List, +) { + init { + require(hops.size <= MAX_HOPS) { "hops list must not exceed MAX_HOPS=$MAX_HOPS, got ${hops.size}" } + // Origin id is a u62 varint; reject negatives so encoding + // can't silently produce a malformed varint. Per kixelated, + // id == 0 means UNKNOWN; otherwise must fit in 62 bits. + require(hops.all { it >= 0 }) { "hops origin ids must be non-negative" } + } + + companion object { + /** + * Maximum number of origin entries in [hops]. Mirrors + * `kixelated/moq`'s `Origin::MAX_HOPS = 32`; the decoder + * rejects oversize lists with a codec exception. + */ + const val MAX_HOPS: Int = 32 + } +} /** * "Subscribe me to (broadcast, track)" — the first message the @@ -210,12 +250,28 @@ data class MoqLiteGroupHeader( /** * Probe message written by the *publisher* on a subscriber-initiated * Probe bidi (ControlType=4). `bitrate` is encoded as a u62 varint in - * a size-prefixed body. Decode-only for now — the listener path - * exposes the most recent reading, and we don't initiate probes from - * the speaker side. + * a size-prefixed body. + * + * Wire layout (size-prefixed): + * bitrate u62 varint + * rtt u62 varint (Lite-04 ONLY; sentinel `0` = unknown. + * Outgoing `Some(0)` is clamped to `Some(1)` + * to avoid colliding with the sentinel.) * * Source: `rs/moq-lite/src/lite/probe.rs`. */ data class MoqLiteProbe( val bitrate: Long, -) + /** + * Round-trip time hint, in the unit the application and peer + * have agreed to (kixelated's Rust type is plain `Option` + * — no unit attached at the type level). Lite-04 only; absent + * on the wire under Lite-03. `null` = unknown. + */ + val rtt: Long? = null, +) { + init { + require(bitrate >= 0) { "bitrate must be non-negative: $bitrate" } + if (rtt != null) require(rtt >= 0) { "rtt must be non-negative when present: $rtt" } + } +} diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt index 40282f6bf..d6ba0d80d 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSession.kt @@ -77,6 +77,23 @@ class MoqLiteSession internal constructor( */ internal val transport: WebTransportSession, private val scope: CoroutineScope, + /** + * Wire-format version this session speaks. Selected at + * WebTransport CONNECT time via the + * `wt-available-protocols` / `wt-protocol` exchange (see + * [com.vitorpamplona.nestsclient.transport.WebTransportSession.negotiatedSubProtocol]). + * Defaults to [MoqLiteVersion.LITE_03] for backward compat with + * call sites that don't pass an explicit version (most tests + + * the legacy single-version factory). Production + * `connectNestsListener` / `connectNestsSpeaker` pass the + * resolved version from the negotiated ALPN. + * + * The version selects between the version-conditional codec + * branches in [MoqLiteCodec] for `Announce.hops`, + * `AnnouncePlease.excludeHop`, and `Probe.rtt`. All other + * messages are wire-identical between the two versions. + */ + val version: MoqLiteVersion = MoqLiteVersion.LITE_03, ) { private val state = Mutex() private val subscriptionsBySubscribeId: MutableMap = HashMap() @@ -132,7 +149,7 @@ class MoqLiteSession internal constructor( ensureOpen() val bidi = transport.openBidiStream() bidi.write(Varint.encode(MoqLiteControlType.Announce.code)) - bidi.write(MoqLiteCodec.encodeAnnouncePlease(MoqLiteAnnouncePlease(prefix))) + bidi.write(MoqLiteCodec.encodeAnnouncePlease(MoqLiteAnnouncePlease(prefix), version)) // replay=64, DROP_OLDEST: announces emitted before the // caller's `collect` attaches MUST NOT be dropped — that's @@ -164,7 +181,7 @@ class MoqLiteSession internal constructor( buffer.push(chunk) while (true) { val payload = buffer.readSizePrefixed() ?: break - val decoded = MoqLiteCodec.decodeAnnounce(payload) + val decoded = MoqLiteCodec.decodeAnnounce(payload, version) emitCount += 1 Log.d("NestRx") { "session.announce(prefix='$prefix') bidi pump emit #$emitCount " + @@ -733,7 +750,7 @@ class MoqLiteSession internal constructor( buffer.push(chunk) while (true) { val payload = buffer.readSizePrefixed() ?: break - updates.emit(MoqLiteCodec.decodeProbe(payload)) + updates.emit(MoqLiteCodec.decodeProbe(payload, version)) } } } catch (ce: CancellationException) { @@ -949,7 +966,7 @@ class MoqLiteSession internal constructor( when (controlType) { MoqLiteControlType.Announce -> { val pleasePayload = buffer.readSizePrefixed() ?: return@collect - val please = MoqLiteCodec.decodeAnnouncePlease(pleasePayload) + val please = MoqLiteCodec.decodeAnnouncePlease(pleasePayload, version) // Per moq-lite Lite-03 (`rs/moq-lite/src/lite/announce.rs`), // a publisher MUST only emit Active for broadcasts whose // path starts with the requested prefix. If our suffix @@ -976,8 +993,9 @@ class MoqLiteSession internal constructor( MoqLiteAnnounce( status = MoqLiteAnnounceStatus.Active, suffix = emittedSuffix, - hops = 0L, + hops = emptyList(), ), + version, ), ) announcePublisher.registerAnnounceBidi(bidi, emittedSuffix) @@ -1134,7 +1152,7 @@ class MoqLiteSession internal constructor( // which left the subscriber's ABR // estimator with no signal. runCatching { - bidi.write(MoqLiteCodec.encodeProbe(MoqLiteProbe(bitrate = NESTS_AUDIO_BITRATE_HINT_BPS))) + bidi.write(MoqLiteCodec.encodeProbe(MoqLiteProbe(bitrate = NESTS_AUDIO_BITRATE_HINT_BPS), version)) bidi.finish() } dispatched = true @@ -1511,8 +1529,9 @@ class MoqLiteSession internal constructor( MoqLiteAnnounce( status = MoqLiteAnnounceStatus.Ended, suffix = entry.emittedSuffix, - hops = 0L, + hops = emptyList(), ), + version, ), ) } @@ -1607,6 +1626,7 @@ class MoqLiteSession internal constructor( fun client( transport: WebTransportSession, pumpScope: CoroutineScope, - ): MoqLiteSession = MoqLiteSession(transport, pumpScope) + version: MoqLiteVersion = MoqLiteVersion.LITE_03, + ): MoqLiteSession = MoqLiteSession(transport, pumpScope, version) } } diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt index 28d952c04..35dd7a83f 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/FakeWebTransport.kt @@ -47,6 +47,15 @@ class FakeWebTransport private constructor( private val inboundBidiStreams: Channel, private val inboundUniStreams: Channel, private val outboundUniStreams: Channel, + /** + * Test-controllable negotiated sub-protocol. Defaults to `null` + * — production fakes that don't care about ALPN negotiation + * keep the same behavior they had before this field existed. + * Tests that exercise the version-aware [MoqLiteSession] can + * pass a non-null value via [pair] to simulate a relay that + * picked Lite-04 (or any other ALPN string). + */ + override val negotiatedSubProtocol: String? = null, ) : WebTransportSession { private val stateLock = Mutex() private var open = true @@ -170,8 +179,14 @@ class FakeWebTransport private constructor( * Create two linked fakes that act as "client" and "server" endpoints of * the same virtual WebTransport session. Datagrams and streams opened on * one side appear on the other. + * + * @param negotiatedSubProtocol surfaced by both sides via + * [WebTransportSession.negotiatedSubProtocol]. Defaults to + * `null`. Tests that drive a version-aware + * [com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession] + * pass e.g. `"moq-lite-04"` here. */ - fun pair(): Pair { + fun pair(negotiatedSubProtocol: String? = null): Pair { val aToBDatagrams = Channel(Channel.BUFFERED) val bToADatagrams = Channel(Channel.BUFFERED) val aToBBidi = Channel(Channel.BUFFERED) @@ -187,6 +202,7 @@ class FakeWebTransport private constructor( inboundBidiStreams = bToABidi, inboundUniStreams = bToAUni, outboundUniStreams = aToBUni, + negotiatedSubProtocol = negotiatedSubProtocol, ) val b = FakeWebTransport( @@ -196,6 +212,7 @@ class FakeWebTransport private constructor( inboundBidiStreams = aToBBidi, inboundUniStreams = aToBUni, outboundUniStreams = bToAUni, + negotiatedSubProtocol = negotiatedSubProtocol, ) return a to b } diff --git a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt index 467578427..e9c7a735f 100644 --- a/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt +++ b/nestsClient/src/commonMain/kotlin/com/vitorpamplona/nestsclient/transport/WebTransportSession.kt @@ -42,6 +42,21 @@ interface WebTransportSession { /** True once the Extended CONNECT exchange has returned 2xx and before [close] is called. */ val isOpen: Boolean + /** + * The sub-protocol the server selected via the + * `wt-protocol` response header (RFC 8941 SF-string; + * draft-ietf-webtrans-http3 §3.3). `null` if the server didn't + * echo a sub-protocol — typically because the client offered + * none, or because the server is older than draft-13 and + * supports only a single protocol implicitly. + * + * Used by moq-lite negotiation: a client that advertised both + * `moq-lite-04` and `moq-lite-03` reads this to select the + * matching [com.vitorpamplona.nestsclient.moq.lite.MoqLiteVersion] + * for its [com.vitorpamplona.nestsclient.moq.lite.MoqLiteSession]. + */ + val negotiatedSubProtocol: String? + /** * Open a new bidirectional WebTransport stream. The returned [WebTransportBidiStream] * is writable + readable and closes when either peer half-closes. diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteCodecTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteCodecTest.kt index bbde1d16d..2a61a6e93 100644 --- a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteCodecTest.kt +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteCodecTest.kt @@ -61,17 +61,20 @@ class MoqLiteCodecTest { @Test fun announce_active_round_trips() { - val msg = MoqLiteAnnounce(status = MoqLiteAnnounceStatus.Active, suffix = "speakerPubkey", hops = 0L) + val msg = MoqLiteAnnounce(status = MoqLiteAnnounceStatus.Active, suffix = "speakerPubkey", hops = emptyList()) val payload = peelSizePrefix(MoqLiteCodec.encodeAnnounce(msg)) // Byte 0 is the status byte (literal 1), then a 13-byte string, - // then a varint hops. + // then a varint hops count. assertEquals(1, payload[0].toInt()) assertEquals(msg, MoqLiteCodec.decodeAnnounce(payload)) } @Test fun announce_ended_with_relay_hops_round_trips() { - val msg = MoqLiteAnnounce(status = MoqLiteAnnounceStatus.Ended, suffix = "fff", hops = 7L) + // Lite-03 wire only carries a count; decoded list is filled + // with UNKNOWN placeholders (origin id 0). Round-trip thus + // requires the input also be a list of zeros. + val msg = MoqLiteAnnounce(status = MoqLiteAnnounceStatus.Ended, suffix = "fff", hops = List(7) { 0L }) val payload = peelSizePrefix(MoqLiteCodec.encodeAnnounce(msg)) assertEquals(0, payload[0].toInt()) assertEquals(msg, MoqLiteCodec.decodeAnnounce(payload)) @@ -202,6 +205,172 @@ class MoqLiteCodecTest { assertEquals(msg, MoqLiteCodec.decodeGroupHeader(payload)) } + // ---------------- Lite-04 wire diffs (audit L1) ---------------- + + @Test + fun announcePlease_lite04_round_trips_excludeHop() { + val msg = MoqLiteAnnouncePlease(prefix = "rooms/A", excludeHop = 7L) + val payload = + peelSizePrefix( + MoqLiteCodec.encodeAnnouncePlease(msg, MoqLiteVersion.LITE_04), + ) + // Wire after prefix: a single varint excludeHop. + val r = MoqReader(payload) + assertEquals("rooms/A", r.readLengthPrefixedString()) + assertEquals(7L, r.readVarint(), "Lite-04 excludeHop is a single varint after prefix") + assertTrue(!r.hasMore()) + // Round-trip via decoder. + assertEquals( + msg, + MoqLiteCodec.decodeAnnouncePlease(payload, MoqLiteVersion.LITE_04), + ) + } + + @Test + fun announcePlease_lite03_omits_excludeHop_on_wire() { + // Lite-03 encode MUST NOT write excludeHop, regardless of the + // value carried in the data class. Confirms a Lite-04-aware + // app accidentally talking Lite-03 doesn't leak the field + // and desync the peer. + val msg = MoqLiteAnnouncePlease(prefix = "rooms/A", excludeHop = 99L) + val payload = + peelSizePrefix( + MoqLiteCodec.encodeAnnouncePlease(msg, MoqLiteVersion.LITE_03), + ) + val r = MoqReader(payload) + assertEquals("rooms/A", r.readLengthPrefixedString()) + assertTrue(!r.hasMore(), "Lite-03 wire MUST end after prefix — no excludeHop") + // Decoded msg drops back to excludeHop=0 (Lite-03 default). + assertEquals( + MoqLiteAnnouncePlease(prefix = "rooms/A", excludeHop = 0L), + MoqLiteCodec.decodeAnnouncePlease(payload, MoqLiteVersion.LITE_03), + ) + } + + @Test + fun announce_lite04_round_trips_full_origin_list() { + val msg = + MoqLiteAnnounce( + status = MoqLiteAnnounceStatus.Active, + suffix = "spk", + hops = listOf(1L, 5L, 12L), + ) + val payload = + peelSizePrefix( + MoqLiteCodec.encodeAnnounce(msg, MoqLiteVersion.LITE_04), + ) + // Wire: status u8, suffix string, count varint, then count + // origin id varints. + val r = MoqReader(payload) + assertEquals(1, r.readByte()) // Active + assertEquals("spk", r.readLengthPrefixedString()) + assertEquals(3L, r.readVarint(), "count") + assertEquals(1L, r.readVarint(), "origin[0]") + assertEquals(5L, r.readVarint(), "origin[1]") + assertEquals(12L, r.readVarint(), "origin[2]") + assertTrue(!r.hasMore()) + assertEquals(msg, MoqLiteCodec.decodeAnnounce(payload, MoqLiteVersion.LITE_04)) + } + + @Test + fun announce_lite03_drops_origin_ids_keeps_count() { + // Lite-03 wire only carries the count; the IDs are discarded + // on encode and reconstructed as UNKNOWN (0) placeholders on + // decode. Confirms cross-version round-trip is lossy in this + // direction (matches kixelated's Lite-03 hop-count semantic). + val sent = + MoqLiteAnnounce( + status = MoqLiteAnnounceStatus.Active, + suffix = "spk", + hops = listOf(1L, 5L, 12L), + ) + val payload = peelSizePrefix(MoqLiteCodec.encodeAnnounce(sent, MoqLiteVersion.LITE_03)) + val decoded = MoqLiteCodec.decodeAnnounce(payload, MoqLiteVersion.LITE_03) + // Same count, IDs replaced with UNKNOWN. + assertEquals(3, decoded.hops.size) + assertEquals(listOf(0L, 0L, 0L), decoded.hops) + } + + @Test + fun announce_decoder_rejects_oversize_hop_count() { + // MAX_HOPS = 32 per kixelated's Origin::MAX_HOPS. A wire + // payload with count = 33 must be rejected. + val out = + com.vitorpamplona.nestsclient.moq + .MoqWriter() + out.writeByte(1) // Active + out.writeLengthPrefixedString("spk") + out.writeVarint(33L) + // Lite-03 doesn't write IDs so the body ends here. + val payload = out.toByteArray() + assertFailsWith { + MoqLiteCodec.decodeAnnounce(payload, MoqLiteVersion.LITE_03) + } + } + + @Test + fun probe_lite04_round_trips_rtt() { + val msg = MoqLiteProbe(bitrate = 32_000L, rtt = 25L) + val payload = + peelSizePrefix( + MoqLiteCodec.encodeProbe(msg, MoqLiteVersion.LITE_04), + ) + val r = MoqReader(payload) + assertEquals(32_000L, r.readVarint()) + assertEquals(25L, r.readVarint()) + assertTrue(!r.hasMore()) + assertEquals(msg, MoqLiteCodec.decodeProbe(payload, MoqLiteVersion.LITE_04)) + } + + @Test + fun probe_lite04_clamps_some_zero_to_one_to_avoid_unknown_sentinel() { + // Per kixelated `encode_msg`, an outgoing rtt of `Some(0)` is + // clamped to `Some(1)` because 0 is the unknown-sentinel. + // Mirroring that exactly avoids a peer round-trip from + // Some(0) to None. + val msg = MoqLiteProbe(bitrate = 100_000L, rtt = 0L) + val payload = + peelSizePrefix( + MoqLiteCodec.encodeProbe(msg, MoqLiteVersion.LITE_04), + ) + val r = MoqReader(payload) + r.readVarint() // bitrate + assertEquals(1L, r.readVarint(), "rtt=0 must be clamped to 1 on the wire") + } + + @Test + fun probe_lite04_decodes_zero_rtt_as_null() { + // Symmetric: an incoming `0` MUST decode as null (None). + val out = + com.vitorpamplona.nestsclient.moq + .MoqWriter() + out.writeVarint(64_000L) + out.writeVarint(0L) // rtt = unknown + val payload = out.toByteArray() + val decoded = MoqLiteCodec.decodeProbe(payload, MoqLiteVersion.LITE_04) + assertEquals(64_000L, decoded.bitrate) + assertEquals(null, decoded.rtt, "rtt=0 decodes as null (unknown sentinel)") + } + + @Test + fun probe_lite03_wire_omits_rtt() { + // Lite-03 encode MUST NOT write rtt regardless of the data + // class value. + val msg = MoqLiteProbe(bitrate = 32_000L, rtt = 99L) + val payload = + peelSizePrefix( + MoqLiteCodec.encodeProbe(msg, MoqLiteVersion.LITE_03), + ) + val r = MoqReader(payload) + assertEquals(32_000L, r.readVarint()) + assertTrue(!r.hasMore(), "Lite-03 probe must end after bitrate — no rtt") + // Decoded msg drops rtt to null (Lite-03 default). + assertEquals( + MoqLiteProbe(bitrate = 32_000L, rtt = null), + MoqLiteCodec.decodeProbe(payload, MoqLiteVersion.LITE_03), + ) + } + @Test fun decoders_reject_trailing_garbage() { // Build a valid AnnouncePlease payload then append a stray byte. diff --git a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSessionTest.kt b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSessionTest.kt index 3e419fee4..566be7e86 100644 --- a/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSessionTest.kt +++ b/nestsClient/src/commonTest/kotlin/com/vitorpamplona/nestsclient/moq/lite/MoqLiteSessionTest.kt @@ -169,7 +169,7 @@ class MoqLiteSessionTest { MoqLiteAnnounce( MoqLiteAnnounceStatus.Active, "speakerOne", - hops = 1L, + hops = listOf(0L), ), ), ) @@ -178,7 +178,7 @@ class MoqLiteSessionTest { MoqLiteAnnounce( MoqLiteAnnounceStatus.Active, "speakerTwo", - hops = 1L, + hops = listOf(0L), ), ), ) @@ -342,6 +342,59 @@ class MoqLiteSessionTest { session.close() } + @Test + fun lite04_announce_round_trips_full_origin_list_through_session() = + runBlocking { + // Lite-03 audit L1: a session running [MoqLiteVersion.LITE_04] + // routes its codec calls through the Lite-04 branches — + // verified end-to-end by encoding an Announce on the + // server side with two origin IDs and confirming the + // client-side announce-watch flow surfaces them + // unchanged. Pre-fix the codec was Lite-03-only and + // would have read the count and discarded the IDs. + val (clientSide, serverSide) = FakeWebTransport.pair() + val session = MoqLiteSession.client(clientSide, pumpScope, MoqLiteVersion.LITE_04) + assertEquals(MoqLiteVersion.LITE_04, session.version) + + val peer = + async { + val bidi = serverSide.peerOpenedBidiStreams().first() + // Drain control byte + AnnouncePlease. Lite-04 + // shape: prefix string then excludeHop varint. + val chunks = bidi.incoming().take(2).toList() + assertEquals(MoqLiteControlType.Announce.code, MoqLiteFrameBuffer().apply { push(chunks[0]) }.readVarint()) + val pleasePayload = + MoqLiteFrameBuffer().apply { push(chunks[1]) }.readSizePrefixed() + ?: error("AnnouncePlease body missing") + val please = MoqLiteCodec.decodeAnnouncePlease(pleasePayload, MoqLiteVersion.LITE_04) + assertEquals("rooms/A", please.prefix) + assertEquals(0L, please.excludeHop, "default excludeHop is 0 (no exclusion)") + + // Push an Announce with full origin list (Lite-04 + // shape). + bidi.write( + MoqLiteCodec.encodeAnnounce( + MoqLiteAnnounce( + status = MoqLiteAnnounceStatus.Active, + suffix = "speakerA", + hops = listOf(2L, 7L), + ), + MoqLiteVersion.LITE_04, + ), + ) + } + + val handle = session.announce("rooms/A") + val announce = withTimeout(2_000) { handle.updates.first() } + assertEquals(MoqLiteAnnounceStatus.Active, announce.status) + assertEquals("speakerA", announce.suffix) + assertEquals(listOf(2L, 7L), announce.hops, "Lite-04 session preserves origin id list") + + peer.await() + handle.close() + session.close() + } + @Test fun publisher_subscribeOk_narrows_startGroup_to_next_sequence() = runBlocking { 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 f93fa5fe0..4402239a1 100644 --- a/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt +++ b/nestsClient/src/jvmAndroid/kotlin/com/vitorpamplona/nestsclient/transport/QuicWebTransportFactory.kt @@ -85,32 +85,26 @@ class QuicWebTransportFactory( * 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. + * For nests, this MUST contain at least `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 on the client side. * - * **Lite-04 is intentionally NOT advertised** even though the kixelated - * browser client now ships it. Lite-04 reshapes the on-the-wire - * Announce.hops field from a single varint count into a full - * `OriginList` (`kixelated/moq` commit 45db108, "moq-lite/moq-relay: - * hop-based clustering"), adds an `exclude_hop` field to - * AnnounceInterest, and adds an `rtt` field to Probe — Subscribe and - * Group framing are unchanged but Announce framing diverges. Our codec - * speaks pure Lite-03; if a Lite-04-preferring relay picks - * `moq-lite-04` from our advertised list, the very first Announce - * exchange would desync (we'd encode/read a bare hops varint where - * the peer expects `len + len × u62`) and the connection would abort. - * Until [com.vitorpamplona.nestsclient.moq.lite.MoqLiteCodec] gains - * version-aware Announce / AnnounceInterest / Probe paths and - * [com.vitorpamplona.nestsclient.moq.lite.MoqLiteAnnounce.hops] - * becomes a list, advertising Lite-04 is a footgun. See - * [com.vitorpamplona.nestsclient.moq.lite.MoqLiteAlpn] for the - * known-version constants. + * Both `moq-lite-04` and `moq-lite-03` are advertised by default + * — the codec is now version-aware (audit L1), so the relay's + * choice (echoed via `wt-protocol`) is honoured at runtime via + * [WebTransportSession.negotiatedSubProtocol] → + * [com.vitorpamplona.nestsclient.moq.lite.MoqLiteVersion.fromAlpn]. + * Lite-04 sits ahead of Lite-03 in the list to match kixelated's + * preference order; servers that don't yet support Lite-04 fall + * back to Lite-03 cleanly. See + * [com.vitorpamplona.nestsclient.moq.lite.MoqLiteAlpn] / + * [com.vitorpamplona.nestsclient.moq.lite.MoqLiteVersion] for the + * version constants and codec behavior. */ private val webTransportSubProtocols: List = - listOf(MoqLiteAlpn.LITE_03), + listOf(MoqLiteAlpn.LITE_04, MoqLiteAlpn.LITE_03), ) : WebTransportFactory { override suspend fun connect( authority: String, @@ -181,27 +175,27 @@ class QuicWebTransportFactory( // Wait for the response HEADERS and verify :status is 2xx before // declaring the WebTransport session open. Per RFC 9220 a non-2xx // status means the server rejected the upgrade. - val responseStatus = + val response = kotlinx.coroutines.withTimeoutOrNull(connectTimeoutMillis) { - readResponseStatus(requestStream) - } ?: -1 - if (responseStatus < 0) { + readConnectResponse(requestStream) + } + if (response == null) { driver.close() throw WebTransportException( kind = WebTransportException.Kind.HandshakeFailed, message = "WebTransport CONNECT response timed out after ${connectTimeoutMillis}ms", ) } - if (responseStatus !in 200..299) { + if (response.status !in 200..299) { driver.close() throw WebTransportException( kind = WebTransportException.Kind.ConnectRejected, - message = "WebTransport CONNECT returned :status=$responseStatus", + message = "WebTransport CONNECT returned :status=${response.status}", ) } val state = QuicWebTransportSessionState(conn, driver, requestStream.streamId) - return QuicWebTransportSession(state) + return QuicWebTransportSession(state, response.subProtocol) } catch (we: WebTransportException) { throw we } catch (ce: kotlinx.coroutines.CancellationException) { @@ -220,14 +214,20 @@ class QuicWebTransportFactory( } /** - * Drain bytes from [requestStream] through an [Http3FrameReader] until a - * HEADERS frame arrives, then decode the QPACK field section and pull the - * `:status` pseudo-header. + * Drain bytes from [requestStream] through an [Http3FrameReader] + * until a HEADERS frame arrives, then decode the QPACK field + * section and pull out (`:status`, `wt-protocol`). * - * Returns 0 if the stream closes without a HEADERS frame (the caller treats - * that as a connect rejection). + * `wt-protocol` (draft-ietf-webtrans-http3 §3.3, RFC 8941 + * SF-string) is the server's selection from the client-offered + * `wt-available-protocols` list. Returned as a bare string with + * surrounding quotes stripped; `null` when the server didn't + * echo the header (e.g. older servers, or when the client + * offered only one protocol). The header value MAY contain + * commas if the server were to echo a list; we only expose the + * first item — moq-lite servers always echo a single value. */ - private suspend fun readResponseStatus(requestStream: QuicStream): Int { + private suspend fun readConnectResponse(requestStream: QuicStream): ConnectResponse { val reader = Http3FrameReader() val incoming = requestStream.incoming try { @@ -238,20 +238,52 @@ class QuicWebTransportFactory( if (frame is Http3Frame.Headers) { val pairs = QpackDecoder().decodeFieldSection(frame.qpackPayload) val status = pairs.firstOrNull { it.first == ":status" }?.second?.toIntOrNull() ?: 0 - throw HeadersReceived(status) + val subProtocol = + pairs + .firstOrNull { it.first.equals("wt-protocol", ignoreCase = true) } + ?.second + ?.let { parseFirstSfString(it) } + throw HeadersReceived(status, subProtocol) } } } } catch (e: HeadersReceived) { - return e.status + return ConnectResponse(e.status, e.subProtocol) } - return 0 + return ConnectResponse(0, null) } + private data class ConnectResponse( + val status: Int, + val subProtocol: String?, + ) + private class HeadersReceived( val status: Int, + val subProtocol: String?, ) : RuntimeException() + /** + * Parse the first item out of an RFC 8941 SF-list-of-strings + * value (the format `wt-protocol` carries per + * draft-ietf-webtrans-http3 §3.3). For "moq-lite-04" servers + * echo a single quoted string; for completeness we tolerate a + * comma-separated list and take the first. Returns `null` if + * the value can't be parsed as an SF-string. + * + * This is a deliberately minimal parser — full RFC 8941 + * parameter handling isn't needed for the moq-lite use case + * (no parameters are ever attached). Tolerates surrounding + * whitespace per §3.1. + */ + private fun parseFirstSfString(raw: String): String? { + val firstItem = raw.substringBefore(',').trim() + if (firstItem.length < 2 || firstItem.first() != '"' || firstItem.last() != '"') return null + // SF-string only supports `\\` and `\"` escapes per §3.3.3. + // moq-lite values are bare ASCII; just strip the quotes. + return firstItem.substring(1, firstItem.length - 1) + } + /** * Encode [items] as an RFC 8941 Structured Field List of bare strings * (the format `wt-available-protocols` requires per draft-ietf-webtrans-http3 @@ -279,6 +311,14 @@ class QuicWebTransportFactory( /** Adapter that wraps the :quic [QuicWebTransportSessionState] in the nestsClient interface. */ class QuicWebTransportSession( private val state: QuicWebTransportSessionState, + /** + * The sub-protocol the server selected via `wt-protocol` + * during Extended CONNECT. Captured by + * [QuicWebTransportFactory.connect] from the parsed response + * headers and passed through to this constructor. `null` when + * the server didn't echo the header. + */ + override val negotiatedSubProtocol: String? = null, ) : WebTransportSession { override val isOpen: Boolean get() = state.isOpen