feat(nestsClient): MoQ varint + SETUP handshake codec

Phase 3c-1 of the Clubhouse/nests integration. Lands a MoQ-transport
control-plane codec (draft-ietf-moq-transport) and a session wrapper
that runs the SETUP handshake over any WebTransportSession. Purely
commonMain + fully tested end-to-end against FakeWebTransport — no
network or Kwik dependency required.

commonMain (nestsclient.moq):
- `Varint` — QUIC variable-length integer codec per RFC 9000 §16.
  Encode/decode/size, with typed truncation handling (decode returns
  null so callers can buffer more and retry).
- `MoqWriter` / `MoqReader` — append-only byte writer + bounds-checked
  reader used by the message codec.
- `MoqCodecException` — typed error for malformed frames.
- `MoqMessage` sealed class + `MoqMessageType` enum + `SetupParameter`.
  Phase 3c-1 covers just `ClientSetup` / `ServerSetup` (message types
  0x40 / 0x41).
- `MoqCodec.encode/decode` — wraps payload with `type (varint) +
  length (varint)`. Rejects unknown types and trailing bytes inside a
  declared payload window.
- `MoqSession.client/server` — attaches to a WebTransportSession and
  runs the CLIENT_SETUP / SERVER_SETUP handshake with version
  negotiation + configurable timeout.
- `MoqVersion` constants for draft-11 and draft-17.

Tests:
- `VarintTest` — all four RFC 9000 §A.1 sample vectors (1/2/4/8 byte),
  boundary round-trips, negative/overflow rejection, short-buffer
  returns null, bytesConsumed accuracy.
- `MoqCodecTest` — CLIENT_SETUP / SERVER_SETUP round-trip (empty and
  multi-param), multi-version negotiation, exact wire layout for
  ServerSetup(1L), truncated-frame returns null, concatenated frames
  decode with offset, unknown type rejected, trailing bytes rejected,
  zero-length parameter values allowed.
- `MoqSessionTest` — full SETUP exchange over FakeWebTransport (both
  sides happy path, client falling back to older version, server
  rejecting when no version overlap + client timing out cleanly).

SUBSCRIBE / ANNOUNCE / OBJECT_STREAM / OBJECT_DATAGRAM land in
Phase 3c-2 on top of the same MoqSession.

https://claude.ai/code/session_013nVLALALKaHVgHm9u5Cg8D
This commit is contained in:
Claude
2026-04-22 03:21:15 +00:00
parent 4ead4ccd5c
commit 37e24ce4f3
8 changed files with 1058 additions and 0 deletions
@@ -0,0 +1,133 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.moq
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNull
class MoqCodecTest {
@Test
fun client_setup_round_trip_no_parameters() {
val msg = ClientSetup(supportedVersions = listOf(MoqVersion.DRAFT_17))
val encoded = MoqCodec.encode(msg)
val decoded = MoqCodec.decode(encoded)!!
assertEquals(encoded.size, decoded.bytesConsumed)
assertEquals(msg, decoded.message)
}
@Test
fun client_setup_round_trip_multi_version_and_parameters() {
val msg =
ClientSetup(
supportedVersions = listOf(MoqVersion.DRAFT_11, MoqVersion.DRAFT_17),
parameters =
listOf(
SetupParameter(SetupParameter.KEY_ROLE, byteArrayOf(0x03)),
SetupParameter(SetupParameter.KEY_PATH, "/moq".encodeToByteArray()),
),
)
val encoded = MoqCodec.encode(msg)
val decoded = MoqCodec.decode(encoded)!!
val roundTripped = assertIs<ClientSetup>(decoded.message)
assertEquals(msg.supportedVersions, roundTripped.supportedVersions)
assertEquals(msg.parameters, roundTripped.parameters)
}
@Test
fun server_setup_round_trip() {
val msg =
ServerSetup(
selectedVersion = MoqVersion.DRAFT_17,
parameters = listOf(SetupParameter(SetupParameter.KEY_MAX_SUBSCRIBE_ID, byteArrayOf(0x40, 0x10))),
)
val encoded = MoqCodec.encode(msg)
val decoded = MoqCodec.decode(encoded)!!
assertEquals(msg, decoded.message)
}
@Test
fun encode_prefixes_with_type_and_length() {
val encoded = MoqCodec.encode(ServerSetup(selectedVersion = 1L))
// ServerSetup's type code is 0x41 (65). That's > 63 so it needs a
// 2-byte varint: 0x40 0x41. Length of the payload (2 bytes: one byte
// for varint(version=1), one byte for varint(0 params)) fits in a
// single-byte varint → 0x02. Payload: 0x01, 0x00. Total 5 bytes.
assertContentEquals(byteArrayOf(0x40, 0x41, 0x02, 0x01, 0x00), encoded)
}
@Test
fun decode_returns_null_when_frame_is_truncated() {
val full = MoqCodec.encode(ClientSetup(listOf(MoqVersion.DRAFT_17)))
// Lose the last byte → caller should buffer more and retry.
assertNull(MoqCodec.decode(full.copyOf(full.size - 1)))
}
@Test
fun decode_with_offset_reads_from_middle_of_buffer() {
val a = MoqCodec.encode(ClientSetup(listOf(MoqVersion.DRAFT_17)))
val b = MoqCodec.encode(ServerSetup(selectedVersion = MoqVersion.DRAFT_17))
val concatenated = a + b
val first = MoqCodec.decode(concatenated, offset = 0)!!
assertEquals(a.size, first.bytesConsumed)
assertIs<ClientSetup>(first.message)
val second = MoqCodec.decode(concatenated, offset = first.bytesConsumed)!!
assertEquals(b.size, second.bytesConsumed)
assertIs<ServerSetup>(second.message)
}
@Test
fun unknown_message_type_is_rejected() {
// Craft a frame with message type 0xFFFF (large varint), length 0, no payload.
val bogus = MoqWriter()
bogus.writeVarint(0xFFFFL)
bogus.writeVarint(0L)
assertFailsWith<MoqCodecException> { MoqCodec.decode(bogus.toByteArray()) }
}
@Test
fun trailing_bytes_in_payload_are_rejected() {
// Valid ServerSetup payload of (selectedVersion=1, 0 params) fits in 2 bytes
// (varint 0x01 + varint 0x00). Craft a frame that declares a 4-byte
// payload and pads with 2 junk bytes inside the declared window so the
// decoder sees extra data after the last field.
val w = MoqWriter()
w.writeVarint(MoqMessageType.ServerSetup.code)
w.writeVarint(4L)
w.writeVarint(1L) // selected version (1 byte)
w.writeVarint(0L) // 0 parameters (1 byte)
w.writeByte(0xAA) // trailing junk inside declared payload
w.writeByte(0xBB)
assertFailsWith<MoqCodecException> { MoqCodec.decode(w.toByteArray()) }
}
@Test
fun parameters_with_zero_length_value_are_allowed() {
val msg = ClientSetup(listOf(1L), parameters = listOf(SetupParameter(0x10L, ByteArray(0))))
val decoded = MoqCodec.decode(MoqCodec.encode(msg))!!
assertEquals(msg, decoded.message)
}
}
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.moq
import com.vitorpamplona.nestsclient.transport.FakeWebTransport
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
class MoqSessionTest {
@Test
fun client_and_server_complete_setup_handshake_over_fake_transport() =
runTest {
val (clientSide, serverSide) = FakeWebTransport.pair()
val clientJob =
async {
val session = MoqSession.client(clientSide)
session.setup(listOf(MoqVersion.DRAFT_17))
session.selectedVersion
}
val serverJob =
async {
val control = serverSide.peerOpenedBidiStreams().first()
val session = MoqSession.server(serverSide, control)
session.setup(
supportedVersions = listOf(MoqVersion.DRAFT_17, MoqVersion.DRAFT_11),
clientParameters =
listOf(
SetupParameter(
SetupParameter.KEY_MAX_SUBSCRIBE_ID,
byteArrayOf(0x10),
),
),
)
session.selectedVersion
}
assertEquals(MoqVersion.DRAFT_17, clientJob.await())
assertEquals(MoqVersion.DRAFT_17, serverJob.await())
}
@Test
fun server_picks_first_mutually_supported_version_from_its_own_list() =
runTest {
val (clientSide, serverSide) = FakeWebTransport.pair()
val clientJob =
async {
val session = MoqSession.client(clientSide)
// Client prefers 17, falls back to 11.
session.setup(listOf(MoqVersion.DRAFT_17, MoqVersion.DRAFT_11))
session.selectedVersion
}
val serverJob =
async {
val control = serverSide.peerOpenedBidiStreams().first()
val session = MoqSession.server(serverSide, control)
// Server only speaks 11 → overlap is 11.
session.setup(supportedVersions = listOf(MoqVersion.DRAFT_11))
session.selectedVersion
}
assertEquals(MoqVersion.DRAFT_11, clientJob.await())
assertEquals(MoqVersion.DRAFT_11, serverJob.await())
}
@Test
fun server_rejects_when_no_version_overlap() =
runTest {
val (clientSide, serverSide) = FakeWebTransport.pair()
val clientJob =
async {
val session = MoqSession.client(clientSide)
runCatching { session.setup(listOf(MoqVersion.DRAFT_17)) }
}
val serverJob =
async {
val control = serverSide.peerOpenedBidiStreams().first()
val session = MoqSession.server(serverSide, control)
assertFailsWith<MoqProtocolException> {
session.setup(supportedVersions = listOf(MoqVersion.DRAFT_11))
}
}
serverJob.await()
// Client's result: its control stream closes with no reply, which throws.
// We just verify that some throwable propagated.
val clientOutcome = clientJob.await()
assert(clientOutcome.isFailure) { "client setup should have failed, got: $clientOutcome" }
}
}
@@ -0,0 +1,114 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.nestsclient.moq
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class VarintTest {
/**
* RFC 9000 §A.1 sample encodings, which every QUIC varint implementation
* must reproduce bit-for-bit.
*/
@Test
fun rfc9000_sample_151288809941952652() {
// 62-bit: 151288809941952652 == 0x2136 0x0000 0000 8c4c (encoded)
val value = 151288809941952652L
val encoded = Varint.encode(value)
assertContentEquals(
byteArrayOf(0xC2.toByte(), 0x19, 0x7C, 0x5E, 0xFF.toByte(), 0x14, 0xE8.toByte(), 0x8C.toByte()),
encoded,
)
assertEquals(value, Varint.decode(encoded)!!.value)
}
@Test
fun rfc9000_sample_494878333() {
val value = 494878333L
val encoded = Varint.encode(value)
assertContentEquals(
byteArrayOf(0x9D.toByte(), 0x7F.toByte(), 0x3E, 0x7D.toByte()),
encoded,
)
assertEquals(value, Varint.decode(encoded)!!.value)
}
@Test
fun rfc9000_sample_15293() {
val value = 15293L
val encoded = Varint.encode(value)
assertContentEquals(byteArrayOf(0x7B.toByte(), 0xBD.toByte()), encoded)
assertEquals(value, Varint.decode(encoded)!!.value)
}
@Test
fun rfc9000_sample_37() {
val encoded = Varint.encode(37L)
assertContentEquals(byteArrayOf(0x25), encoded)
assertEquals(37L, Varint.decode(encoded)!!.value)
}
@Test
fun boundary_values_round_trip() {
for (v in listOf(0L, 63L, 64L, 16_383L, 16_384L, 1_073_741_823L, 1_073_741_824L, Varint.MAX_VALUE)) {
val encoded = Varint.encode(v)
assertEquals(
v,
Varint.decode(encoded)!!.value,
"round-trip for $v",
)
}
}
@Test
fun size_matches_encoding_length() {
for (v in listOf(0L, 63L, 64L, 16_383L, 16_384L, 1_073_741_823L, 1_073_741_824L, Varint.MAX_VALUE)) {
assertEquals(Varint.size(v), Varint.encode(v).size)
}
}
@Test
fun negative_value_is_rejected() {
assertFailsWith<IllegalArgumentException> { Varint.encode(-1L) }
}
@Test
fun overflow_value_is_rejected() {
assertFailsWith<IllegalArgumentException> { Varint.encode(Varint.MAX_VALUE + 1) }
}
@Test
fun short_buffer_returns_null_so_caller_can_buffer_more() {
assertNull(Varint.decode(ByteArray(0)))
// 4-byte varint with only 3 bytes available:
assertNull(Varint.decode(byteArrayOf(0x9D.toByte(), 0x7F.toByte(), 0x3E), 0))
}
@Test
fun bytesConsumed_reflects_actual_length() {
assertEquals(1, Varint.decode(byteArrayOf(0x25))!!.bytesConsumed)
assertEquals(2, Varint.decode(byteArrayOf(0x7B.toByte(), 0xBD.toByte()))!!.bytesConsumed)
assertEquals(4, Varint.decode(byteArrayOf(0x9D.toByte(), 0x7F.toByte(), 0x3E, 0x7D.toByte()))!!.bytesConsumed)
}
}