feat(nestsclient): version-aware Lite-03/04 codec + ALPN negotiation — moq-lite Lite-04

Lite-03 audit L1: completes the moq-lite version surface so the
session speaks the wire-format version the server picks via
WebTransport ALPN negotiation. Pre-fix the codec hardcoded Lite-03;
advertising `moq-lite-04` was a footgun because a Lite-04-preferring
relay would desync on the very first Announce exchange.

Verified diff between Lite-03 and Lite-04 by WebFetching kixelated's
Rust reference (`rs/moq-lite/src/lite/{announce,subscribe,probe}.rs`,
`rs/moq-lite/src/version.rs`, `rs/moq-lite/src/model/origin.rs`):
exactly three fields differ; everything else is byte-identical.

  - `Announce.hops`: Lite-03 wire = single varint count (the spec
    explicitly fills with `Origin::UNKNOWN` placeholders on decode);
    Lite-04 wire = `varint(count) + count × varint(originId)` (the
    `OriginList`). MAX_HOPS = 32.
  - `AnnouncePlease.excludeHop`: Lite-03 absent; Lite-04 single
    varint after `prefix`. Sentinel `0` = no exclusion.
  - `Probe.rtt`: Lite-03 absent; Lite-04 single varint after
    `bitrate`. Sentinel `0` = unknown (decoded as null). Outgoing
    `Some(0)` is clamped to `Some(1)` to avoid colliding with the
    sentinel — mirrors kixelated's `encode_msg` clamp.

New surface:
  - `MoqLiteVersion` enum (LITE_03, LITE_04) with `fromAlpn` lookup.
  - `MoqLiteCodec.{encode,decode}{AnnouncePlease,Announce,Probe}`
    take `version: MoqLiteVersion = LITE_03` parameter, branch on
    it. Default LITE_03 keeps existing call sites compiling.
  - `MoqLiteSession.client(transport, scope, version)` carries the
    version through every codec invocation.
  - `MoqLiteAnnouncePlease.excludeHop: Long = 0L` (default).
  - `MoqLiteAnnounce.hops: List<Long>` (was `Long`) — list of
    origin IDs, bounded to MAX_HOPS=32. Existing call sites
    migrate `hops = 0L` → `hops = emptyList()`, `hops = 7L` →
    `hops = List(7) { 0L }`.
  - `MoqLiteProbe.rtt: Long? = null` (default).

Negotiation:
  - `WebTransportSession.negotiatedSubProtocol: String?` exposes
    the server's `wt-protocol` selection. `QuicWebTransportFactory`
    parses the response HEADERS, extracts the SF-string from
    `wt-protocol`, and threads it into `QuicWebTransportSession`.
    `FakeWebTransport.pair(negotiatedSubProtocol = …)` lets tests
    drive the same path.
  - Default advertised list now `[moq-lite-04, moq-lite-03]` (was
    `[moq-lite-03]`). Lite-04 sits first to match kixelated's
    preference; servers that don't support it fall back to
    Lite-03 cleanly.
  - `connectNestsListener` / `connectNestsSpeaker` resolve the
    version via `resolveMoqLiteVersion(webTransport.negotiatedSubProtocol)`
    and pass it to `MoqLiteSession.client(...)`. Falls back to
    Lite-03 when the server doesn't echo `wt-protocol` (older
    deployments) or echoes something unrecognised (forward-compat).
  - New `MOQ_LITE_04_VERSION = 0x6D71_6C04L` synthetic version
    code; `versionCode(version)` mapping function.

Regression tests (all green):
  - `MoqLiteCodecTest.announcePlease_lite04_round_trips_excludeHop`
  - `MoqLiteCodecTest.announcePlease_lite03_omits_excludeHop_on_wire`
  - `MoqLiteCodecTest.announce_lite04_round_trips_full_origin_list`
  - `MoqLiteCodecTest.announce_lite03_drops_origin_ids_keeps_count`
  - `MoqLiteCodecTest.announce_decoder_rejects_oversize_hop_count`
    (MAX_HOPS bounds check)
  - `MoqLiteCodecTest.probe_lite04_round_trips_rtt`
  - `MoqLiteCodecTest.probe_lite04_clamps_some_zero_to_one_to_avoid_unknown_sentinel`
  - `MoqLiteCodecTest.probe_lite04_decodes_zero_rtt_as_null`
  - `MoqLiteCodecTest.probe_lite03_wire_omits_rtt`
  - `MoqLiteSessionTest.lite04_announce_round_trips_full_origin_list_through_session`
    (end-to-end Lite-04 session)

Audit doc: nestsClient/plans/2026-05-09-moq-lite-rfc-compliance.md (L1).

https://claude.ai/code/session_012TGfo99Ugz7fcCPv85a8Tq
This commit is contained in:
Claude
2026-05-09 15:05:54 +00:00
parent d24953d98c
commit 335a337283
10 changed files with 627 additions and 93 deletions
@@ -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,
@@ -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
}
}
}
@@ -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<Long> =
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<Long>(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)
}
@@ -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<Long>` — 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<Long>,
) {
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<u64>`
* — 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" }
}
}
@@ -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<Long, ListenerSubscription> = 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)
}
}
@@ -47,6 +47,15 @@ class FakeWebTransport private constructor(
private val inboundBidiStreams: Channel<FakeBidiStream>,
private val inboundUniStreams: Channel<WebTransportReadStream>,
private val outboundUniStreams: Channel<WebTransportReadStream>,
/**
* 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<FakeWebTransport, FakeWebTransport> {
fun pair(negotiatedSubProtocol: String? = null): Pair<FakeWebTransport, FakeWebTransport> {
val aToBDatagrams = Channel<ByteArray>(Channel.BUFFERED)
val bToADatagrams = Channel<ByteArray>(Channel.BUFFERED)
val aToBBidi = Channel<FakeBidiStream>(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
}
@@ -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.
@@ -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<MoqCodecException> {
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.
@@ -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 {
@@ -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<String> =
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