fix(quic-interop): zerortt — match wire format to cached ALPN; requeue on TLS-rejection
Two coupled gaps surfaced when running the runner's zerortt testcase against aioquic (picoquic + quic-go already passed because they fault-tolerate harder). 1) Wire format. The 0-RTT pre-handshake batch was sending raw "GET /<path>\r\n" on bidi streams regardless of ALPN. aioquic's h3 server accepts 0-RTT at the TLS layer (early_data extension echoed in EE) but its h3 layer silently drops bidi streams whose payload isn't a valid HEADERS frame — server log shows N "Stream X created by peer" lines and zero responses. Switch the pre-handshake builder to fork on the cached ALPN: h3 → Http3GetClient (three uni control streams + HEADERS-framed bidi requests via prepareRequests); else → HqInteropGetClient (raw text). The post-handshake side then reuses the pre-handshake client and collects responses via awaitResponse(handle), so 1-RTT replay (after rejection) lands on the right parser. 2) TLS-layer rejection. When the server skips the early_data extension in EncryptedExtensions, the client must replay all in-flight 0-RTT app data through the 1-RTT keys (RFC 9001 §4.6.2). TlsClient now exposes earlyDataAccepted, set in the WAITING_ENCRYPTED_EXTENSIONS branch. QuicConnection's onApplicationKeysReady checks it: if 0-RTT was offered but EE didn't carry early_data, we requeueAllInflightStreamData() + cryptoSend.requeueAllInflight() + sentPackets.clear() BEFORE installing 1-RTT keys, so the next writer drain ships the identical stream/CRYPTO bytes under 1-RTT protection. Same stream handles, same response collection — invisible to the request layer. Result, ./quic/interop/run-matrix.sh -t zerortt: aioquic ✓(Z) picoquic ✓(Z) quic-go ✓(Z) Resumption sweep regression-clean across all three. 334 :quic unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+47
-36
@@ -34,7 +34,6 @@ import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import java.io.File
|
||||
@@ -876,23 +875,35 @@ private suspend fun runOneResumptionConnection(
|
||||
// get away without the rebuild — the server processes the
|
||||
// requests and replies in 1-RTT after handshake.
|
||||
val zeroRttPlanned = resumption != null && resumption.maxEarlyDataSize > 0 && fetchUrls.isNotEmpty()
|
||||
val pre0RttHandles =
|
||||
// ALPN isn't yet renegotiated for THIS connection (we're
|
||||
// pre-handshake), so use the cached ALPN from the prior
|
||||
// connection. RFC 9001 §4.6 requires the same ALPN when sending
|
||||
// 0-RTT data; the wire format MUST match it. aioquic's h3 server
|
||||
// accepts 0-RTT at the TLS layer but silently drops bidi streams
|
||||
// whose payload isn't a valid HEADERS frame, so we must
|
||||
// pre-instantiate the right GetClient (h3 → Http3GetClient with
|
||||
// its three uni control streams + HEADERS-framed bidi requests;
|
||||
// hq-interop → raw "GET /\r\n").
|
||||
val cachedAlpn = resumption?.negotiatedAlpn?.decodeToString().orEmpty()
|
||||
val pre0RttClient: GetClient? =
|
||||
if (zeroRttPlanned) {
|
||||
// ALPN isn't yet renegotiated for THIS connection (we're
|
||||
// pre-handshake), so use the cached ALPN from the prior
|
||||
// connection — RFC 9001 §4.6 requires the same ALPN when
|
||||
// sending 0-RTT data.
|
||||
val cachedAlpn = resumption!!.negotiatedAlpn?.decodeToString().orEmpty()
|
||||
when (cachedAlpn) {
|
||||
"h3" -> Http3GetClient(conn, driver).also { it.init(scope) }
|
||||
else -> HqInteropGetClient(conn, driver)
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val pre0RttHandles =
|
||||
if (pre0RttClient != null) {
|
||||
// Pre-handshake stream open works because QuicConnection.init
|
||||
// pre-loaded peerMaxStreamsBidi from the resumption state.
|
||||
val handles =
|
||||
conn.openBidiStreamsBatch(fetchUrls.map { "GET ${it.path}\r\n".encodeToByteArray() }) { stream, request ->
|
||||
stream.send.enqueue(request)
|
||||
stream.send.finish()
|
||||
stream
|
||||
}
|
||||
// prepareRequests opens N bidi streams under a single
|
||||
// streamsLock hold so the writer's first 0-RTT drain
|
||||
// coalesces them into one (or few) packets.
|
||||
val handles = pre0RttClient.prepareRequests(authority, fetchUrls.map { it.path })
|
||||
driver.wakeup()
|
||||
cachedAlpn to handles
|
||||
handles
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -912,8 +923,13 @@ private suspend fun runOneResumptionConnection(
|
||||
conn.tls.negotiatedAlpn
|
||||
?.decodeToString()
|
||||
.orEmpty()
|
||||
// Reuse the pre-handshake client when 0-RTT was attempted —
|
||||
// its uni control streams (h3 SETTINGS + qpack streams) are
|
||||
// already opened and either survived 0-RTT or got requeued
|
||||
// by QuicConnection.onApplicationKeysReady's rejection path
|
||||
// when the server skipped the early_data extension.
|
||||
val client: GetClient =
|
||||
when (negotiated) {
|
||||
pre0RttClient ?: when (negotiated) {
|
||||
"h3" -> {
|
||||
Http3GetClient(conn, driver).also { it.init(scope) }
|
||||
}
|
||||
@@ -930,34 +946,29 @@ private suspend fun runOneResumptionConnection(
|
||||
|
||||
var anyFailed = false
|
||||
if (pre0RttHandles != null) {
|
||||
// 0-RTT path: streams already opened and GETs already enqueued
|
||||
// pre-handshake. Just collect the responses on each stream.
|
||||
// Server may have accepted the 0-RTT data (responses come back)
|
||||
// or rejected it; rejection means data was dropped server-side
|
||||
// and we'd have to resend in 1-RTT, which is real work and not
|
||||
// wired here. For the runner's zerortt testcase against picoquic
|
||||
// (which accepts), this path is sufficient.
|
||||
val (_, handles) = pre0RttHandles
|
||||
for ((url, stream) in fetchUrls.zip(handles)) {
|
||||
val body =
|
||||
// 0-RTT path: streams already opened and requests already
|
||||
// enqueued pre-handshake. Just collect the responses via the
|
||||
// ALPN-matching client. If the server rejected 0-RTT at the
|
||||
// TLS layer, QuicConnection.onApplicationKeysReady has already
|
||||
// requeued the stream data through the 1-RTT keys — same
|
||||
// handles, same response collection.
|
||||
for ((url, handle) in fetchUrls.zip(pre0RttHandles)) {
|
||||
val resp =
|
||||
withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) {
|
||||
val chunks = stream.incoming.toList()
|
||||
val total = chunks.sumOf { it.size }
|
||||
val buf = ByteArray(total)
|
||||
var off = 0
|
||||
for (c in chunks) {
|
||||
c.copyInto(buf, off)
|
||||
off += c.size
|
||||
}
|
||||
buf
|
||||
client.awaitResponse(handle)
|
||||
}
|
||||
if (body == null || body.isEmpty()) {
|
||||
if (resp == null || resp.body.isEmpty()) {
|
||||
anyFailed = true
|
||||
System.err.println("[resumption:$iterIdx] 0RTT GET ${url.path} → empty/timeout")
|
||||
continue
|
||||
}
|
||||
if (resp.status != 200 && resp.status != 0) {
|
||||
System.err.println("[resumption:$iterIdx] 0RTT GET ${url.path} → status ${resp.status}")
|
||||
anyFailed = true
|
||||
continue
|
||||
}
|
||||
val name = url.path.substringAfterLast('/').ifBlank { "index" }
|
||||
File(downloadsDir, name).writeBytes(body)
|
||||
File(downloadsDir, name).writeBytes(resp.body)
|
||||
}
|
||||
} else {
|
||||
for (url in fetchUrls) {
|
||||
|
||||
@@ -540,6 +540,33 @@ class QuicConnection(
|
||||
clientSecret: ByteArray,
|
||||
serverSecret: ByteArray,
|
||||
) {
|
||||
// RFC 9001 §4.10 — 0-RTT rejection fallback. If we
|
||||
// offered 0-RTT but the server's EncryptedExtensions
|
||||
// didn't echo the early_data extension, any application
|
||||
// data we already shipped under early-data keys was
|
||||
// silently dropped server-side. Re-queue it so the
|
||||
// writer replays it under the about-to-be-installed
|
||||
// 1-RTT keys. Must run BEFORE we install the 1-RTT
|
||||
// sendProtection — once the writer sees 1-RTT keys
|
||||
// available it'll start drainOutbound under short
|
||||
// headers; we want any pending retransmits to flow
|
||||
// through that path with the original byte content.
|
||||
//
|
||||
// requeueAllInflightStreamData walks streamsList under
|
||||
// the assumption the caller holds streamsLock — which
|
||||
// we do here because the parser path that fired this
|
||||
// listener (handleServerFinished → onApplicationKeysReady)
|
||||
// runs inside streamsLock.withLock { feedDatagram(...) }
|
||||
// in the read loop.
|
||||
val rejected0Rtt =
|
||||
resumption != null &&
|
||||
resumption.maxEarlyDataSize > 0 &&
|
||||
!tls.earlyDataAccepted
|
||||
if (rejected0Rtt) {
|
||||
requeueAllInflightStreamData()
|
||||
application.cryptoSend.requeueAllInflight()
|
||||
application.sentPackets.clear()
|
||||
}
|
||||
application.sendProtection = packetProtectionFromSecret(cipherSuite, clientSecret)
|
||||
application.receiveProtection = packetProtectionFromSecret(cipherSuite, serverSecret)
|
||||
// Drop 0-RTT keys — the writer must use 1-RTT short
|
||||
|
||||
@@ -166,6 +166,23 @@ class TlsClient(
|
||||
*/
|
||||
private var pskAccepted: Boolean = false
|
||||
|
||||
/**
|
||||
* RFC 8446 §4.2.10 — true after EncryptedExtensions echoes the empty
|
||||
* `early_data` extension we sent in the resumption ClientHello.
|
||||
* False (the default) means the server rejected 0-RTT — any
|
||||
* application data we already sent under early-data keys was
|
||||
* silently dropped server-side and the QUIC layer must re-queue it
|
||||
* for retransmission once 1-RTT keys are available.
|
||||
*
|
||||
* Read by [com.vitorpamplona.quic.connection.QuicConnection]'s
|
||||
* onApplicationKeysReady callback to decide whether to invoke
|
||||
* [com.vitorpamplona.quic.connection.QuicConnection.requeueAllInflightStreamData].
|
||||
* Only meaningful on resumption + 0-RTT connections; non-0-RTT
|
||||
* connections leave it at false and never check it.
|
||||
*/
|
||||
var earlyDataAccepted: Boolean = false
|
||||
private set
|
||||
|
||||
/** The 32-byte ClientHello random, available after [start]. Exposed so
|
||||
* observers (e.g. SSLKEYLOGFILE writer) can correlate secrets with
|
||||
* this connection. */
|
||||
@@ -396,6 +413,12 @@ class TlsClient(
|
||||
}
|
||||
negotiatedAlpn = alpn
|
||||
peerTransportParameters = ee.quicTransportParameters
|
||||
// RFC 8446 §4.2.10 — server's `early_data` extension in
|
||||
// EE confirms 0-RTT acceptance. Absence means the server
|
||||
// ignored / dropped any app data we already sent under
|
||||
// early-data keys, and the QUIC layer must re-queue it
|
||||
// for 1-RTT replay.
|
||||
earlyDataAccepted = ee.extensions.any { it.type == TlsConstants.EXT_EARLY_DATA }
|
||||
transcript.append(msg)
|
||||
state = State.WAITING_CERTIFICATE_OR_FINISHED
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user