fix(quic): faster PTO with INITIAL_RTT=100ms and unified ptoBaseMs path

multiconnect handshakeloss / handshakecorruption tail-fail under the
runner's 30% packet drop / bit-flip scenarios because each PTO retransmit
chance gives ~49% one-side success (0.7² for both directions clear). With
INITIAL_RTT=333ms the first PTO fires at 999 ms and doubling tops out
at ~5 attempts in 30s — across 50 sequential connections, ~5% probability
some iteration runs out of retransmits before the per-iter budget.

Two coupled changes:

1. INITIAL_RTT_MS 333→100. RFC 9002 §6.2.2 spec-allowed (the standard
   default but explicitly configurable). Matches Chrome and
   Firefox/neqo. Pre-sample PTO is now 300 ms instead of 999 ms;
   doubling fits ~8 retransmit attempts in 30s instead of 5,
   pushing per-iter loss-recovery success past 99% under 30% drop.
   Spurious retransmits on slow paths are harmless (peer dedupes
   by packet number) and smoothed_rtt converges in one round-trip.

2. QuicConnectionDriver always uses lossDetection.ptoBaseMs() for the
   PTO timer, including before the first RTT sample. Pre-fix the
   driver hardcoded 1000ms as a "handshake-timeout safety floor"
   that ignored INITIAL_RTT_MS entirely — the PTO was always 1s
   pre-handshake regardless of the constant. Now both pre- and
   post-sample regimes go through the same calculation.

   max_ack_delay is gated to APPLICATION space (RFC 9002 §6.2.1) so
   pre-handshake PTOs aren't padded with the peer's quoted delay.

Two pre-existing tests (PtoTest, QuicLossDetectionTest) hard-coded
expected PTO durations derived from the old 333 ms constant; updated
them to express the relationships in terms of INITIAL_RTT_MS so future
tweaks don't desync.

Result: 21/21 against aioquic, picoquic, quic-go (handshake,
multiplexing, longrtt, transferloss, transfercorruption,
handshakeloss, handshakecorruption all pass on each peer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-07 16:42:33 -04:00
parent d85d921b3e
commit d1567e4a53
4 changed files with 54 additions and 13 deletions
@@ -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
@@ -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