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:
+18
-5
@@ -139,13 +139,26 @@ class QuicConnectionDriver(
|
|||||||
} ?: break
|
} ?: break
|
||||||
socket.send(out)
|
socket.send(out)
|
||||||
}
|
}
|
||||||
val ptoBaseMs =
|
// Use the loss-detection's PTO calculation in BOTH the pre- and
|
||||||
if (connection.lossDetection.hasFirstRttSample) {
|
// post-first-RTT-sample regimes. Pre-sample, smoothed_rtt =
|
||||||
val maxAckDelayMs = connection.peerTransportParameters?.maxAckDelay ?: 0L
|
// INITIAL_RTT_MS so ptoBaseMs returns
|
||||||
connection.lossDetection.ptoBaseMs(maxAckDelayMs).coerceAtLeast(1L)
|
// 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 {
|
} else {
|
||||||
1_000L
|
0L
|
||||||
}
|
}
|
||||||
|
val ptoBaseMs =
|
||||||
|
connection.lossDetection
|
||||||
|
.ptoBaseMs(maxAckDelayMs)
|
||||||
|
.coerceAtLeast(1L)
|
||||||
val backoff = (1L shl connection.consecutivePtoCount.coerceAtMost(6))
|
val backoff = (1L shl connection.consecutivePtoCount.coerceAtMost(6))
|
||||||
val ptoMillis = (ptoBaseMs * backoff).coerceAtMost(60_000L)
|
val ptoMillis = (ptoBaseMs * backoff).coerceAtMost(60_000L)
|
||||||
// Suspend until either: a wakeup arrives, or the PTO timer expires.
|
// Suspend until either: a wakeup arrives, or the PTO timer expires.
|
||||||
|
|||||||
+24
-2
@@ -180,8 +180,30 @@ class QuicLossDetection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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). */
|
/** RFC 9002 §6.1.1 packet-reordering threshold (number of PNs). */
|
||||||
const val PACKET_THRESHOLD: Long = 3L
|
const val PACKET_THRESHOLD: Long = 3L
|
||||||
|
|||||||
@@ -35,15 +35,19 @@ class PtoTest {
|
|||||||
@Test
|
@Test
|
||||||
fun ptoBeforeFirstRttSample_usesInitialDefault() {
|
fun ptoBeforeFirstRttSample_usesInitialDefault() {
|
||||||
val ld = QuicLossDetection()
|
val ld = QuicLossDetection()
|
||||||
// Before any sample: smoothed_rtt = 333, rttvar = 333/2 = 166.
|
// Before any sample: smoothed_rtt = INITIAL_RTT_MS, rttvar = INITIAL_RTT_MS/2.
|
||||||
// PTO = 333 + max(4*166, 1) + 0 = 333 + 664 = 997.
|
// PTO = smoothed_rtt + max(4*rttvar, 1) + 0
|
||||||
assertEquals(997L, ld.ptoBaseMs(maxAckDelayMs = 0L))
|
// = 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
|
@Test
|
||||||
fun ptoIncludesMaxAckDelay() {
|
fun ptoIncludesMaxAckDelay() {
|
||||||
val ld = QuicLossDetection()
|
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
|
@Test
|
||||||
|
|||||||
+4
-2
@@ -97,8 +97,10 @@ class QuicLossDetectionTest {
|
|||||||
@Test
|
@Test
|
||||||
fun lossDelay_floor() {
|
fun lossDelay_floor() {
|
||||||
val ld = QuicLossDetection()
|
val ld = QuicLossDetection()
|
||||||
// Initial: smoothed=333, latest=333. Loss delay = 333*9/8 = 374.
|
// Initial: smoothed=INITIAL_RTT_MS, latest=INITIAL_RTT_MS.
|
||||||
assertEquals(374L, ld.lossDelayMs())
|
// Loss delay = max_rtt * 9/8 = INITIAL_RTT_MS * 9 / 8.
|
||||||
|
val initRtt = QuicLossDetection.INITIAL_RTT_MS
|
||||||
|
assertEquals(initRtt * 9L / 8L, ld.lossDelayMs())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user