feat(quic): Phase A — module foundations

Create the new :quic Gradle module (KMP, api(project(":quartz"))) and migrate
the QUIC varint codec out of :nestsClient where it was incidentally living.
Add the connection-ID, packet-number-space, and UDP socket primitives that
the rest of the QUIC client will build on.

Layer-by-layer plan in docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md.

- New :quic module wired into settings.gradle, with commonMain + jvmAndroid
  source sets mirroring :quartz's structure.
- Varint moves from com.vitorpamplona.nestsclient.moq to com.vitorpamplona.quic;
  MoqBuffer/MoqCodec updated to import the new path.
- ConnectionId enforces the 0..20 byte length range and ships a randomizer
  backed by Quartz's RandomInstance.
- PacketNumberSpaceState tracks per-space outbound allocation + largest-received
  tracking, and implements the RFC 9000 §A.3 truncated-PN decode formula plus
  the §17.1 minimum encode-length picker.
- UdpSocket is an expect class with a connected DatagramChannel actual on
  jvmAndroid using Dispatchers.IO (no Selector — one socket per connection).

All 12 tests pass on jvmTest. RFC 9000 §A.1 varint vectors and §A.3 truncated-PN
vector match bit-for-bit.

https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
This commit is contained in:
Claude
2026-04-25 17:19:02 +00:00
parent 41c1e131a0
commit 2d541c6fd4
13 changed files with 620 additions and 16 deletions
@@ -0,0 +1,122 @@
/*
* 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.quic
/**
* QUIC variable-length integer codec, per RFC 9000 §16.
*
* The two most significant bits of the first byte encode the total length:
* 00 → 1 byte, 6-bit value (0 .. 63)
* 01 → 2 bytes, 14-bit value (0 .. 16_383)
* 10 → 4 bytes, 30-bit value (0 .. 1_073_741_823)
* 11 → 8 bytes, 62-bit value (0 .. 4_611_686_018_427_387_903)
*
* Used by QUIC frame and packet framing, by HTTP/3, by QPACK, and by MoQ.
*/
object Varint {
const val MAX_VALUE: Long = (1L shl 62) - 1
/** Number of bytes required to encode [value]. */
fun size(value: Long): Int {
require(value >= 0) { "varint must be non-negative: $value" }
require(value <= MAX_VALUE) { "varint overflow: $value" }
return when {
value < (1L shl 6) -> 1
value < (1L shl 14) -> 2
value < (1L shl 30) -> 4
else -> 8
}
}
/** Encode [value] into a fresh ByteArray. */
fun encode(value: Long): ByteArray {
val buf = ByteArray(size(value))
writeTo(value, buf, 0)
return buf
}
/**
* Write [value] to [dst] starting at [offset]. Returns the number of bytes
* written. [dst] must have room for [size] bytes starting at [offset].
*/
fun writeTo(
value: Long,
dst: ByteArray,
offset: Int,
): Int {
val size = size(value)
when (size) {
1 -> {
dst[offset] = value.toByte()
}
2 -> {
dst[offset] = (0x40 or ((value ushr 8).toInt() and 0x3F)).toByte()
dst[offset + 1] = (value and 0xFF).toByte()
}
4 -> {
dst[offset] = (0x80 or ((value ushr 24).toInt() and 0x3F)).toByte()
dst[offset + 1] = ((value ushr 16) and 0xFF).toByte()
dst[offset + 2] = ((value ushr 8) and 0xFF).toByte()
dst[offset + 3] = (value and 0xFF).toByte()
}
8 -> {
dst[offset] = (0xC0 or ((value ushr 56).toInt() and 0x3F)).toByte()
dst[offset + 1] = ((value ushr 48) and 0xFF).toByte()
dst[offset + 2] = ((value ushr 40) and 0xFF).toByte()
dst[offset + 3] = ((value ushr 32) and 0xFF).toByte()
dst[offset + 4] = ((value ushr 24) and 0xFF).toByte()
dst[offset + 5] = ((value ushr 16) and 0xFF).toByte()
dst[offset + 6] = ((value ushr 8) and 0xFF).toByte()
dst[offset + 7] = (value and 0xFF).toByte()
}
}
return size
}
/**
* Decode a varint starting at [offset] in [src]. Returns [DecodeResult] with
* the value and the number of bytes consumed, or null if [src] doesn't
* contain enough bytes (caller should buffer more and retry).
*/
fun decode(
src: ByteArray,
offset: Int = 0,
): DecodeResult? {
if (offset >= src.size) return null
val first = src[offset].toInt() and 0xFF
val length = 1 shl ((first ushr 6) and 0x03)
if (offset + length > src.size) return null
var value = (first and 0x3F).toLong()
for (i in 1 until length) {
value = (value shl 8) or ((src[offset + i].toInt() and 0xFF).toLong())
}
return DecodeResult(value, length)
}
data class DecodeResult(
val value: Long,
val bytesConsumed: Int,
)
}
@@ -0,0 +1,59 @@
/*
* 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.quic.connection
import com.vitorpamplona.quartz.utils.RandomInstance
/**
* QUIC connection identifier per RFC 9000 §5.1.
*
* Length must be 0..20. Zero-length CIDs are legal but only the peer that
* issued them ever uses them; we always issue at least 8 bytes for our source
* IDs so the server can route packets back even after path migration (which
* we don't initiate, but they do happen on roaming clients).
*/
class ConnectionId(
val bytes: ByteArray,
) {
val length: Int get() = bytes.size
init {
require(bytes.size in 0..20) { "CID length out of range: ${bytes.size}" }
}
fun toHex(): String = bytes.joinToString("") { (it.toInt() and 0xFF).toString(16).padStart(2, '0') }
override fun equals(other: Any?): Boolean = other is ConnectionId && bytes.contentEquals(other.bytes)
override fun hashCode(): Int = bytes.contentHashCode()
override fun toString(): String = "ConnectionId(${toHex()})"
companion object {
/** Generate a random CID of the given length using [RandomInstance]. */
fun random(length: Int = 8): ConnectionId {
require(length in 0..20) { "CID length out of range: $length" }
return ConnectionId(RandomInstance.bytes(length))
}
val EMPTY = ConnectionId(ByteArray(0))
}
}
@@ -0,0 +1,124 @@
/*
* 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.quic.connection
/**
* The three QUIC packet-number spaces per RFC 9000 §12.3.
*
* Each space has independent monotonically-increasing packet numbers,
* independent ACK state, and is protected with a different set of keys.
*/
enum class PacketNumberSpace {
INITIAL,
HANDSHAKE,
APPLICATION,
}
/**
* Per-space outbound packet-number generator and inbound largest-received tracker.
*
* RFC 9000 §17.1: packet numbers in a space MUST start at 0 and increase by
* exactly 1 for each packet sent. Packet numbers MUST NOT exceed 2^62 - 1 in a
* single connection, but in practice we run out of bytes long before then.
*/
class PacketNumberSpaceState {
/** Next packet number to assign on send. RFC 9000 §17.1 — starts at 0. */
var nextPacketNumber: Long = 0L
private set
/** Largest packet number we've received that decrypted successfully. */
var largestReceived: Long = -1L
private set
/** Time (ms since epoch) the [largestReceived] packet was received. */
var largestReceivedTime: Long = 0L
private set
/** Allocate the next outbound packet number. */
fun allocateOutbound(): Long = nextPacketNumber++
/** Note that an inbound packet was successfully decrypted. */
fun observeInbound(
packetNumber: Long,
receivedAtMillis: Long,
) {
if (packetNumber > largestReceived) {
largestReceived = packetNumber
largestReceivedTime = receivedAtMillis
}
}
/**
* Decode a truncated wire packet number using RFC 9000 Appendix A.3.
*
* `expectedPn` is `largestReceived + 1` (or 0 when nothing has been
* received yet). `truncatedPn` is the value that was on the wire after
* removing header protection. `pnLen` is the wire length in bytes (1..4).
*/
fun decodePacketNumber(
truncatedPn: Long,
pnLen: Int,
): Long = decodePacketNumber(largestReceived, truncatedPn, pnLen)
companion object {
fun decodePacketNumber(
largestReceived: Long,
truncatedPn: Long,
pnLen: Int,
): Long {
val expected = largestReceived + 1
val pnNbits = pnLen * 8
val pnWin = 1L shl pnNbits
val pnHwin = pnWin / 2
val pnMask = pnWin - 1
val candidate = (expected and pnMask.inv()) or truncatedPn
return when {
candidate <= expected - pnHwin && candidate < (1L shl 62) - pnWin -> candidate + pnWin
candidate > expected + pnHwin && candidate >= pnWin -> candidate - pnWin
else -> candidate
}
}
/**
* Choose the minimum number of bytes needed to encode [packetNumber]
* given the largest acked packet — RFC 9000 §17.1 / §A.2.
*
* The encoded length must satisfy `2^(8*len - 1) >= num_unacked`,
* which gives the table:
* num_unacked ≤ 128 → 1 byte
* num_unacked ≤ 32768 → 2 bytes
* num_unacked ≤ 8388608 → 3 bytes
* otherwise → 4 bytes
*/
fun encodeLength(
packetNumber: Long,
largestAcked: Long,
): Int {
val numUnacked = if (largestAcked < 0) packetNumber + 1 else packetNumber - largestAcked
return when {
numUnacked <= 128L -> 1
numUnacked <= 32_768L -> 2
numUnacked <= 8_388_608L -> 3
else -> 4
}
}
}
}
@@ -0,0 +1,59 @@
/*
* 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.quic.transport
/**
* Connected UDP socket abstraction used by the QUIC connection.
*
* The socket is bound to an ephemeral local port and connected to one remote
* peer. We do not support unconnected sockets, multipath, or address migration
* — the QUIC connection uses exactly one 4-tuple for its lifetime.
*
* Implementations:
* - jvmAndroid: NIO `DatagramChannel` wrapped in suspend functions.
* - native (future): platform `socket()` / `recv()` / `send()` syscalls.
*/
expect class UdpSocket {
/** Send one datagram to the connected peer. Returns the number of bytes written. */
suspend fun send(payload: ByteArray): Int
/**
* Receive one datagram from the connected peer. Suspends until either a
* packet arrives or the socket is closed. Returns null on close.
*
* The returned ByteArray is freshly allocated for each call.
*/
suspend fun receive(): ByteArray?
/** Close the socket. After close, [receive] returns null and [send] throws. */
fun close()
/** Local port the OS assigned to the socket. */
val localPort: Int
companion object {
/** Open a UDP socket connected to [host]:[port]. Throws on resolution / bind / connect failure. */
suspend fun connect(
host: String,
port: Int,
): UdpSocket
}
}
@@ -0,0 +1,104 @@
/*
* 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.quic
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class VarintTest {
@Test
fun rfc9000_sample_151288809941952652() {
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)))
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)
}
}
@@ -0,0 +1,68 @@
/*
* 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.quic.connection
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNotEquals
class ConnectionIdTest {
@Test
fun random_default_length_is_8() {
assertEquals(8, ConnectionId.random().length)
}
@Test
fun random_returns_distinct_ids() {
val a = ConnectionId.random()
val b = ConnectionId.random()
assertNotEquals(a, b)
}
@Test
fun length_zero_is_legal() {
val id = ConnectionId(ByteArray(0))
assertEquals(0, id.length)
assertEquals("", id.toHex())
}
@Test
fun length_over_20_is_rejected() {
assertFailsWith<IllegalArgumentException> { ConnectionId(ByteArray(21)) }
}
@Test
fun equals_compares_bytes() {
val a = ConnectionId(byteArrayOf(0x01, 0x02, 0x03))
val b = ConnectionId(byteArrayOf(0x01, 0x02, 0x03))
val c = ConnectionId(byteArrayOf(0x01, 0x02, 0x04))
assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
assertNotEquals(a, c)
}
@Test
fun toHex_lowercase_padded() {
val id = ConnectionId(byteArrayOf(0x00, 0x0A, 0xFF.toByte()))
assertEquals("000aff", id.toHex())
}
}
@@ -0,0 +1,98 @@
/*
* 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.quic.connection
import kotlin.test.Test
import kotlin.test.assertEquals
class PacketNumberSpaceTest {
@Test
fun rfc9000_a3_decode_example() {
// RFC 9000 Appendix A.3: largest received = 0xa82f30ea, truncated wire = 0x9b32, len=2
// Expected decoded value: 0xa82f9b32
assertEquals(
0xa82f9b32L,
PacketNumberSpaceState.decodePacketNumber(
largestReceived = 0xa82f30eaL,
truncatedPn = 0x9b32L,
pnLen = 2,
),
)
}
@Test
fun decode_first_packet_starts_at_truncated() {
// No packets received → largestReceived = -1 → expected pn = 0
// Server sends pn=0, on the wire as 1-byte 0x00
assertEquals(
0L,
PacketNumberSpaceState.decodePacketNumber(
largestReceived = -1L,
truncatedPn = 0L,
pnLen = 1,
),
)
// Then pn=1
assertEquals(
1L,
PacketNumberSpaceState.decodePacketNumber(
largestReceived = 0L,
truncatedPn = 1L,
pnLen = 1,
),
)
}
@Test
fun outbound_allocation_is_monotonic() {
val s = PacketNumberSpaceState()
assertEquals(0L, s.allocateOutbound())
assertEquals(1L, s.allocateOutbound())
assertEquals(2L, s.allocateOutbound())
assertEquals(3L, s.nextPacketNumber)
}
@Test
fun inbound_observation_tracks_max() {
val s = PacketNumberSpaceState()
s.observeInbound(5L, 100L)
assertEquals(5L, s.largestReceived)
s.observeInbound(3L, 200L)
assertEquals(5L, s.largestReceived) // out of order, no update
assertEquals(100L, s.largestReceivedTime)
s.observeInbound(7L, 300L)
assertEquals(7L, s.largestReceived)
assertEquals(300L, s.largestReceivedTime)
}
@Test
fun encodeLength_picks_minimum() {
// First packet, no acks: needs at least 1 byte
assertEquals(1, PacketNumberSpaceState.encodeLength(0L, -1L))
assertEquals(1, PacketNumberSpaceState.encodeLength(127L, -1L))
// 2 bytes when 8-bit window not enough
assertEquals(2, PacketNumberSpaceState.encodeLength(256L, -1L))
// 3 bytes when 16-bit window not enough (needs 17+ bits for 2× margin)
assertEquals(3, PacketNumberSpaceState.encodeLength(0xFFFFL, 0L))
// 4 bytes for very large gaps
assertEquals(4, PacketNumberSpaceState.encodeLength(0x80_00_00_00L, -1L))
}
}
@@ -0,0 +1,105 @@
/*
* 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.quic.transport
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.InetAddress
import java.net.InetSocketAddress
import java.nio.ByteBuffer
import java.nio.channels.ClosedChannelException
import java.nio.channels.DatagramChannel
import java.util.concurrent.atomic.AtomicBoolean
/**
* JVM/Android UDP socket using blocking [DatagramChannel] dispatched onto
* [Dispatchers.IO]. We don't use NIO selectors because each QUIC connection
* has exactly one socket and one receive loop — Selector doesn't pay for
* itself at this scale.
*
* The receive buffer is sized to 64 KiB (max IPv4/IPv6 datagram); QUIC packets
* cap at MTU (~1500 in practice).
*/
actual class UdpSocket private constructor(
private val channel: DatagramChannel,
private val remote: InetSocketAddress,
) {
private val closed = AtomicBoolean(false)
private val readBuf = ByteBuffer.allocate(MAX_DATAGRAM_SIZE)
actual val localPort: Int
get() = (channel.localAddress as InetSocketAddress).port
actual suspend fun send(payload: ByteArray): Int =
withContext(Dispatchers.IO) {
if (closed.get()) throw ClosedChannelException()
val buf = ByteBuffer.wrap(payload)
channel.send(buf, remote)
}
actual suspend fun receive(): ByteArray? =
withContext(Dispatchers.IO) {
if (closed.get()) return@withContext null
try {
synchronized(readBuf) {
readBuf.clear()
channel.receive(readBuf) ?: return@withContext null
readBuf.flip()
val out = ByteArray(readBuf.remaining())
readBuf.get(out)
out
}
} catch (_: ClosedChannelException) {
null
}
}
actual fun close() {
if (closed.compareAndSet(false, true)) {
try {
channel.close()
} catch (_: Throwable) {
// already closed
}
}
}
actual companion object {
private const val MAX_DATAGRAM_SIZE: Int = 65535
actual suspend fun connect(
host: String,
port: Int,
): UdpSocket =
withContext(Dispatchers.IO) {
val address = InetAddress.getByName(host)
val remote = InetSocketAddress(address, port)
val channel = DatagramChannel.open()
channel.configureBlocking(true)
channel.bind(InetSocketAddress(0)) // ephemeral
// We use receive()/send(addr) instead of channel.connect() so that
// sendDatagram-style flows can still be implemented on the same
// socket if we ever need them. For the pure client use-case this
// is identical in latency.
UdpSocket(channel, remote)
}
}
}