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:
@@ -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,
|
||||
|
||||
+58
-5
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+92
-15
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+67
-11
@@ -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" }
|
||||
}
|
||||
}
|
||||
|
||||
+28
-8
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+18
-1
@@ -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
|
||||
}
|
||||
|
||||
+15
@@ -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.
|
||||
|
||||
+172
-3
@@ -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.
|
||||
|
||||
+55
-2
@@ -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 {
|
||||
|
||||
+79
-39
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user