diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/recovery/RecoveryToken.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/recovery/RecoveryToken.kt new file mode 100644 index 000000000..fae20eee6 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/recovery/RecoveryToken.kt @@ -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.recovery + +/** + * RFC 9002 §6 retransmit token. One token is recorded per ack-eliciting + * frame at send time; the [SentPacket] carrying it is held in + * [com.vitorpamplona.quic.connection.QuicConnection]'s sent-packet map + * keyed by packet number. + * + * On ACK, the carrying packet's tokens are dropped silently — the peer + * has confirmed receipt. On loss declaration, each token is dispatched + * to its `onLost` handler, which decides whether the frame still needs + * to be re-emitted (e.g. a `MAX_STREAMS_UNI` whose value has since + * been superseded by a higher extension is not re-sent). + * + * Mirrors Firefox neqo's `StreamRecoveryToken` enum at + * `neqo-transport/src/recovery/token.rs:21`. Scope (per + * `quic/plans/2026-05-04-control-frame-retransmit.md`) is the + * receive-side flow-control extensions that today silently wedge the + * connection on packet loss against moq-rs: + * + * - `MAX_STREAMS_UNI` / `MAX_STREAMS_BIDI` (RFC 9000 §19.11) + * - `MAX_DATA` (§19.9) + * - `MAX_STREAM_DATA` (§19.10) + * + * [Ack] is tracked but never retransmitted — ACK frames are not + * ack-eliciting per RFC 9000 §13.2.1, so their loss is recovered by + * the peer's own ACK retransmission of newer ranges. + * + * Out of scope (separate follow-ups, see plan): + * - STREAM data retransmit + * - CRYPTO data retransmit + * - RESET_STREAM, STOP_SENDING, NEW_CONNECTION_ID, etc. + */ +sealed class RecoveryToken { + /** + * Marks an ACK frame in the carrying packet. Recorded so the + * sent-packet map's invariant ("every retained packet has a + * non-empty `tokens` list") holds for ACK-only packets too, but + * never re-emitted on loss. + */ + data object Ack : RecoveryToken() + + /** + * `MAX_STREAMS_UNI` extension we sent. On loss, only re-emit if + * [maxStreams] still equals the connection's current + * `advertisedMaxStreamsUni` — otherwise a newer extension has + * already gone out and supersedes this one. + */ + data class MaxStreamsUni( + val maxStreams: Long, + ) : RecoveryToken() + + /** Bidi counterpart of [MaxStreamsUni]. */ + data class MaxStreamsBidi( + val maxStreams: Long, + ) : RecoveryToken() + + /** + * Connection-level `MAX_DATA` extension. On loss, only re-emit + * if [maxData] still equals the connection's current + * `advertisedMaxData` — same supersede semantics as + * [MaxStreamsUni]. + */ + data class MaxData( + val maxData: Long, + ) : RecoveryToken() + + /** + * Per-stream `MAX_STREAM_DATA` extension. On loss, the dispatcher + * looks up the stream by [streamId]; if the stream has since been + * closed or its advertised cap has moved past [maxData], the + * token is dropped without re-emit. + */ + data class MaxStreamData( + val streamId: Long, + val maxData: Long, + ) : RecoveryToken() +} diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/recovery/SentPacket.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/recovery/SentPacket.kt new file mode 100644 index 000000000..d7eb5a216 --- /dev/null +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/recovery/SentPacket.kt @@ -0,0 +1,76 @@ +/* + * 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.recovery + +/** + * Per-packet metadata retained from send time until the packet is + * either ACK'd or declared lost. Mirrors Firefox neqo's + * `recovery/sent.rs::Packet`. + * + * Held in a per-packet-number-space map on + * [com.vitorpamplona.quic.connection.QuicConnection], keyed by + * [packetNumber]. On ACK arrival the matching entry is removed and + * its tokens dropped silently. On loss-detection (RFC 9002 §6.1) + * the entry is removed and each token is dispatched to its `onLost` + * handler, which decides whether to re-emit. + * + * [ackEliciting] determines whether the packet's loss matters at all + * for retransmit — non-ack-eliciting packets (ACK-only, PADDING-only, + * CONNECTION_CLOSE) are never retransmitted per RFC 9000 §13.2.1. + * They're still tracked so the loss-detection algorithm has consistent + * timing data, but their tokens are limited to [RecoveryToken.Ack]. + * + * [sizeBytes] is the on-wire encrypted packet size; used by congestion + * control to release in-flight bytes when the packet is ACK'd or lost. + * Recorded here even though `:quic` has no congestion controller today + * — the field lets a future CC pass plug in without changing the + * sent-packet schema. + */ +data class SentPacket( + /** Packet number assigned at encrypt time. Unique within its space. */ + val packetNumber: Long, + /** + * Wall-clock epoch milliseconds at the moment the packet was + * handed to the UDP socket. Source is + * [com.vitorpamplona.quic.connection.QuicConnection.nowMillis], not + * `System.currentTimeMillis` — keeps unit tests deterministic. + */ + val sentAtMillis: Long, + /** + * RFC 9000 §13.2.1: whether the packet contains a frame that elicits + * an ACK from the peer. Loss detection only fires for ack-eliciting + * packets; non-ack-eliciting packets (ACK-only, PADDING-only, + * CONNECTION_CLOSE) are never retransmitted. + */ + val ackEliciting: Boolean, + /** + * Encrypted on-wire size in bytes, including AEAD tag and packet + * header. Used by congestion controllers to release in-flight + * bytes on ACK / loss. + */ + val sizeBytes: Int, + /** + * Frames in this packet that need to be tracked for retransmit. + * Empty list is legal for non-ack-eliciting packets that have + * nothing retransmittable (e.g. ACK-only). Otherwise non-empty. + */ + val tokens: List, +) diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/RecoveryTokenTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/RecoveryTokenTest.kt new file mode 100644 index 000000000..135fa485b --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/RecoveryTokenTest.kt @@ -0,0 +1,123 @@ +/* + * 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.recovery + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Type-level invariants for [RecoveryToken]: equality + hashCode + * correctness across data-class variants, the singleton [Ack] object, + * and the typed-token shape required by the dispatcher in step 6 of + * `quic/plans/2026-05-04-control-frame-retransmit.md`. + * + * No connection state is exercised here — these are unit tests on + * the type itself. + */ +class RecoveryTokenTest { + @Test + fun ack_isSingleton() { + val a: RecoveryToken = RecoveryToken.Ack + val b: RecoveryToken = RecoveryToken.Ack + // `data object Ack` ⇒ same reference, same hash. + assertTrue(a === b) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun maxStreamsUni_equalityByValue() { + val t1 = RecoveryToken.MaxStreamsUni(maxStreams = 150L) + val t2 = RecoveryToken.MaxStreamsUni(maxStreams = 150L) + val t3 = RecoveryToken.MaxStreamsUni(maxStreams = 200L) + + assertEquals(t1, t2) + assertEquals(t1.hashCode(), t2.hashCode()) + assertNotEquals(t1, t3) + } + + @Test + fun maxStreamsBidi_distinctFromUni() { + val uni: RecoveryToken = RecoveryToken.MaxStreamsUni(maxStreams = 100L) + val bidi: RecoveryToken = RecoveryToken.MaxStreamsBidi(maxStreams = 100L) + assertNotEquals(uni, bidi) + } + + @Test + fun maxData_equalityByValue() { + val t1 = RecoveryToken.MaxData(maxData = 1_000_000L) + val t2 = RecoveryToken.MaxData(maxData = 1_000_000L) + val t3 = RecoveryToken.MaxData(maxData = 2_000_000L) + + assertEquals(t1, t2) + assertNotEquals(t1, t3) + } + + @Test + fun maxStreamData_equalityByPair() { + val t1 = RecoveryToken.MaxStreamData(streamId = 0L, maxData = 1024L) + val t2 = RecoveryToken.MaxStreamData(streamId = 0L, maxData = 1024L) + val differentStream = RecoveryToken.MaxStreamData(streamId = 4L, maxData = 1024L) + val differentValue = RecoveryToken.MaxStreamData(streamId = 0L, maxData = 2048L) + + assertEquals(t1, t2) + assertNotEquals(t1, differentStream) + assertNotEquals(t1, differentValue) + } + + @Test + fun whenDispatch_isExhaustive() { + // Compile-time assertion that the sealed hierarchy is fixed and + // an exhaustive `when` actually compiles. If a new variant is + // added without updating the dispatcher, this test stops + // compiling — caught at build time, not at runtime. + val tokens: List = + listOf( + RecoveryToken.Ack, + RecoveryToken.MaxStreamsUni(150L), + RecoveryToken.MaxStreamsBidi(150L), + RecoveryToken.MaxData(1_000_000L), + RecoveryToken.MaxStreamData(streamId = 0L, maxData = 1024L), + ) + val labels = + tokens.map { + when (it) { + RecoveryToken.Ack -> "ack" + is RecoveryToken.MaxStreamsUni -> "msu:${it.maxStreams}" + is RecoveryToken.MaxStreamsBidi -> "msb:${it.maxStreams}" + is RecoveryToken.MaxData -> "md:${it.maxData}" + is RecoveryToken.MaxStreamData -> "msd:${it.streamId}:${it.maxData}" + } + } + assertEquals( + listOf( + "ack", + "msu:150", + "msb:150", + "md:1000000", + "msd:0:1024", + ), + labels, + ) + } +} diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/SentPacketTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/SentPacketTest.kt new file mode 100644 index 000000000..0f1a2ea91 --- /dev/null +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/SentPacketTest.kt @@ -0,0 +1,144 @@ +/* + * 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.recovery + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals +import kotlin.test.assertTrue + +/** + * Schema-level invariants for [SentPacket]: data-class equality + * mirrors [RecoveryTokenTest], plus the convention that ACK-only + * packets carry a non-empty `tokens` list with `RecoveryToken.Ack` + * (so the sent-packet map's invariant — every retained entry has at + * least one token to dispatch — holds uniformly across packet types). + */ +class SentPacketTest { + @Test + fun equality_byAllFields() { + val a = + SentPacket( + packetNumber = 7L, + sentAtMillis = 1_700_000_000_000L, + ackEliciting = true, + sizeBytes = 128, + tokens = listOf(RecoveryToken.MaxStreamsUni(150L)), + ) + val b = + SentPacket( + packetNumber = 7L, + sentAtMillis = 1_700_000_000_000L, + ackEliciting = true, + sizeBytes = 128, + tokens = listOf(RecoveryToken.MaxStreamsUni(150L)), + ) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun equality_byPacketNumber() { + val a = + SentPacket( + packetNumber = 7L, + sentAtMillis = 0L, + ackEliciting = false, + sizeBytes = 16, + tokens = listOf(RecoveryToken.Ack), + ) + val differentPn = a.copy(packetNumber = 8L) + assertNotEquals(a, differentPn) + } + + @Test + fun equality_byTokenList() { + val a = + SentPacket( + packetNumber = 1L, + sentAtMillis = 0L, + ackEliciting = true, + sizeBytes = 64, + tokens = listOf(RecoveryToken.MaxData(1_000L), RecoveryToken.MaxStreamsUni(150L)), + ) + val swapped = a.copy(tokens = listOf(RecoveryToken.MaxStreamsUni(150L), RecoveryToken.MaxData(1_000L))) + // List equality is order-sensitive — these are different. + assertNotEquals(a, swapped) + } + + @Test + fun ackOnlyPacket_carriesAckToken() { + // ACK-only packet: not ack-eliciting (RFC 9000 §13.2.1) but + // we still record a single Ack token so the sent-packet map's + // "every entry has a token" invariant holds. + val ackOnly = + SentPacket( + packetNumber = 42L, + sentAtMillis = 0L, + ackEliciting = false, + sizeBytes = 24, + tokens = listOf(RecoveryToken.Ack), + ) + assertTrue(ackOnly.tokens.isNotEmpty()) + assertEquals(RecoveryToken.Ack, ackOnly.tokens.single()) + } + + @Test + fun ackEliciting_packetCanCarryMultipleControlTokens() { + // A real outbound packet that bumps both flow-control caps + // simultaneously (e.g. publisher session with sustained + // stream churn) carries two tokens. Loss declaration walks + // both; ACK drops both. + val multi = + SentPacket( + packetNumber = 100L, + sentAtMillis = 0L, + ackEliciting = true, + sizeBytes = 80, + tokens = + listOf( + RecoveryToken.MaxStreamsUni(maxStreams = 1_500_000L), + RecoveryToken.MaxData(maxData = 64L * 1024L * 1024L), + ), + ) + assertEquals(2, multi.tokens.size) + assertEquals(RecoveryToken.MaxStreamsUni(1_500_000L), multi.tokens[0]) + assertEquals(RecoveryToken.MaxData(64L * 1024L * 1024L), multi.tokens[1]) + } + + @Test + fun copy_preservesUntouchedFields() { + val base = + SentPacket( + packetNumber = 5L, + sentAtMillis = 1L, + ackEliciting = true, + sizeBytes = 100, + tokens = listOf(RecoveryToken.MaxStreamsUni(150L)), + ) + val onlyTimeChanged = base.copy(sentAtMillis = 2L) + assertEquals(base.packetNumber, onlyTimeChanged.packetNumber) + assertEquals(base.ackEliciting, onlyTimeChanged.ackEliciting) + assertEquals(base.sizeBytes, onlyTimeChanged.sizeBytes) + assertEquals(base.tokens, onlyTimeChanged.tokens) + assertNotEquals(base.sentAtMillis, onlyTimeChanged.sentAtMillis) + } +}