diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt index 903cc00efa..4c8fab2ecb 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/QuicConnectionDriver.kt @@ -139,13 +139,26 @@ class QuicConnectionDriver( } ?: break socket.send(out) } - val ptoBaseMs = - if (connection.lossDetection.hasFirstRttSample) { - val maxAckDelayMs = connection.peerTransportParameters?.maxAckDelay ?: 0L - connection.lossDetection.ptoBaseMs(maxAckDelayMs).coerceAtLeast(1L) + // Use the loss-detection's PTO calculation in BOTH the pre- and + // post-first-RTT-sample regimes. Pre-sample, smoothed_rtt = + // INITIAL_RTT_MS so ptoBaseMs returns + // INITIAL_RTT_MS * 3 + max_ack_delay (~300 ms with the 100 ms + // initial). max_ack_delay only applies to APPLICATION space per + // RFC 9002 §6.2.1; pre-handshake we pass 0. Earlier shape + // hardcoded 1000 ms here as a "handshake-timeout safety floor" + // — the cost was four PTO retransmits in ~30 s of loss + // recovery instead of the eight that 300 ms initial gives, + // pinching multiconnect handshake-loss tests at the tail. + val maxAckDelayMs = + if (connection.application.sendProtection != null) { + connection.peerTransportParameters?.maxAckDelay ?: 0L } else { - 1_000L + 0L } + val ptoBaseMs = + connection.lossDetection + .ptoBaseMs(maxAckDelayMs) + .coerceAtLeast(1L) val backoff = (1L shl connection.consecutivePtoCount.coerceAtMost(6)) val ptoMillis = (ptoBaseMs * backoff).coerceAtMost(60_000L) // Suspend until either: a wakeup arrives, or the PTO timer expires. diff --git a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/recovery/QuicLossDetection.kt b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/recovery/QuicLossDetection.kt index d07643eca3..bbbc5fd7eb 100644 --- a/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/recovery/QuicLossDetection.kt +++ b/quic/src/commonMain/kotlin/com/vitorpamplona/quic/connection/recovery/QuicLossDetection.kt @@ -180,8 +180,30 @@ class QuicLossDetection { } companion object { - /** RFC 9002 §6.2.2 default initial RTT before a sample arrives. */ - const val INITIAL_RTT_MS: Long = 333L + /** + * Initial RTT before the first sample arrives. RFC 9002 §6.2.2 + * specifies a 333ms default but explicitly says "designs SHOULD + * allow it to be configurable". 100ms matches Chrome and + * Firefox/neqo's defaults — typical paths are 30–80ms, and the + * lower estimate gives faster PTO retransmits during the first + * round-trip when no real RTT sample exists yet. + * + * Why this matters for interop: PTO duration is + * `smoothed_rtt + max(4*rttvar, 1ms) + max_ack_delay`, doubled + * per consecutive PTO. With INITIAL_RTT=333 the very first + * retransmit is at +999ms, then 2s, 4s, 8s — only ~5 attempts + * fit in a 30s loss-recovery budget. With 100ms the first + * retransmit is at +300ms, then 600ms, 1.2s, 2.4s — twice as + * many attempts before the budget runs out. The handshakeloss / + * handshakecorruption multiconnect tests at 30% drop need every + * PTO opportunity to land 50 successful handshakes within the + * runner's 300s testcase budget. + * + * Spurious retransmits on slower paths are the trade-off, but + * they're harmless (peer dedupes via packet number) and the + * smoothed RTT updates within one round-trip on first ACK. + */ + const val INITIAL_RTT_MS: Long = 100L /** RFC 9002 §6.1.1 packet-reordering threshold (number of PNs). */ const val PACKET_THRESHOLD: Long = 3L diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/PtoTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/PtoTest.kt index 0290f97e80..8abd297134 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/PtoTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/PtoTest.kt @@ -35,15 +35,19 @@ class PtoTest { @Test fun ptoBeforeFirstRttSample_usesInitialDefault() { val ld = QuicLossDetection() - // Before any sample: smoothed_rtt = 333, rttvar = 333/2 = 166. - // PTO = 333 + max(4*166, 1) + 0 = 333 + 664 = 997. - assertEquals(997L, ld.ptoBaseMs(maxAckDelayMs = 0L)) + // Before any sample: smoothed_rtt = INITIAL_RTT_MS, rttvar = INITIAL_RTT_MS/2. + // PTO = smoothed_rtt + max(4*rttvar, 1) + 0 + // = INITIAL_RTT_MS + 4*(INITIAL_RTT_MS/2) + // = INITIAL_RTT_MS * 3. + val initRtt = QuicLossDetection.INITIAL_RTT_MS + assertEquals(initRtt * 3L, ld.ptoBaseMs(maxAckDelayMs = 0L)) } @Test fun ptoIncludesMaxAckDelay() { val ld = QuicLossDetection() - assertEquals(997L + 25L, ld.ptoBaseMs(maxAckDelayMs = 25L)) + val initRtt = QuicLossDetection.INITIAL_RTT_MS + assertEquals(initRtt * 3L + 25L, ld.ptoBaseMs(maxAckDelayMs = 25L)) } @Test diff --git a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/QuicLossDetectionTest.kt b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/QuicLossDetectionTest.kt index a5f8627bef..b80fb8c7f8 100644 --- a/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/QuicLossDetectionTest.kt +++ b/quic/src/commonTest/kotlin/com/vitorpamplona/quic/connection/recovery/QuicLossDetectionTest.kt @@ -97,8 +97,10 @@ class QuicLossDetectionTest { @Test fun lossDelay_floor() { val ld = QuicLossDetection() - // Initial: smoothed=333, latest=333. Loss delay = 333*9/8 = 374. - assertEquals(374L, ld.lossDelayMs()) + // Initial: smoothed=INITIAL_RTT_MS, latest=INITIAL_RTT_MS. + // Loss delay = max_rtt * 9/8 = INITIAL_RTT_MS * 9 / 8. + val initRtt = QuicLossDetection.INITIAL_RTT_MS + assertEquals(initRtt * 9L / 8L, ld.lossDelayMs()) } @Test