fix(quic): restore PTO CRYPTO retransmit lost in qlog merge

Direct evidence from aioquic interop qlog:
  packet_sent  PN=0  frames=[crypto]      (ClientHello)
  packet_sent  PN=1  frames=[ping]        (PTO probe — bare PING)
  packet_received    frames=[connection_close]
                     reason="Packet contains no CRYPTO frame"

aioquic strictly rejects pre-handshake Initials that contain no
CRYPTO frame. Our PTO probe was a bare PING, not a CRYPTO retransmit.

Agent 2's c9e036f72 implemented the right behavior: on PTO, the
driver requeues all inflight CRYPTO at the highest pre-application
level, so the next drain emits a fresh CRYPTO frame at the original
offset. The pendingPing flag stays as a fallback only used when no
CRYPTO is available to retransmit. That logic was wiped during the
qlog observer merge — driver's PTO branch only set pendingPing,
nothing requeued anything, probe was always bare PING.

Restored. Should fix the post-flush-fix regression where aioquic
went 1/7 (only transfer green) — once a packet was dropped or PTO
fired, the next probe was bare PING, aioquic closed with
"no CRYPTO frame", the test failed. Sequential tests on the same
matrix invocation likely intermittently passed/failed depending on
whether PTO fired before the response arrived.

The :quic helpers requeueAllInflightCrypto + highestPreApplicationLevel
already exist on the branch — just the driver call wasn't wired.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
Claude
2026-05-07 00:48:28 +00:00
parent 99a1a91de4
commit c0d7b6031a
@@ -145,14 +145,44 @@ class QuicConnectionDriver(
Unit
}
if (woke == null) {
// PTO fired. Set pendingPing so the writer emits a
// PING on the next drain (RFC 9002 §6.2.4 probe
// packet). The peer's ACK feeds loss detection +
// retransmit (steps 56).
// PTO fired. RFC 9002 §6.2.4: the probe packet MUST
// be ack-eliciting at the encryption level with
// unacknowledged data, and SHOULD retransmit lost
// data rather than just emit a PING.
//
// Pre-1-RTT we have a concrete thing to retransmit:
// the unacknowledged ClientHello (at Initial) or
// ClientFinished (at Handshake). We requeue ALL
// currently-inflight CRYPTO bytes for the highest
// active pre-application level so the next drain's
// takeChunk emits a fresh CRYPTO frame at the
// original offset. The pendingPing flag stays set
// as a fallback — `collectHandshakeLevelFrames`
// skips the PING when a CRYPTO retransmit lands in
// the same frame list, so we don't waste a frame.
//
// Why this matters in interop: aioquic strictly
// rejects pre-handshake Initials that contain no
// CRYPTO frame (CONNECTION_CLOSE 0x0 "Packet
// contains no CRYPTO frame"). A bare-PING probe
// hits that immediately. With CRYPTO retransmit on
// PTO, the probe carries a re-emitted ClientHello
// and the server processes it normally.
//
// Post-handshake (1-RTT installed) we retain the
// bare-PING behavior; STREAM retransmit is driven
// by packet-number-threshold loss detection from
// the ACK that the PING elicits.
val newPtoCount =
connection.lock
.withLock {
connection.pendingPing = true
if (connection.application.sendProtection == null) {
val level = highestPreApplicationLevel(connection)
if (level != null) {
connection.requeueAllInflightCrypto(level)
}
}
connection.consecutivePtoCount =
(connection.consecutivePtoCount + 1).coerceAtMost(6)
connection.consecutivePtoCount