feat(quic-interop): wire peer-uni-stream drainer + versionnegotiation

Two integrations from overnight agents:

1. agent C — peer-uni-stream drainer for the H3 multiplexing fix.
   Http3GetClient.init() now takes a CoroutineScope and calls
   conn.drainPeerInitiatedUniStreamsIntoBlackHole(scope) to consume +
   discard the server's three uni streams (control, qpack-encoder,
   qpack-decoder). RFC 9114 §6.2 mandates the client read these;
   pre-fix their bytes accumulated in 64-chunk per-stream channels
   until parser overflow tore down the connection with INTERNAL_ERROR
   ~4.5s into a multiplexing run.

2. agent A — versionnegotiation testcase wired into dispatch.
   Adds the testcase to the runTransferTest route and threads
   QuicVersion.FORCE_VERSION_NEGOTIATION as the initial version when
   testcase=='versionnegotiation'. The QuicConnection's RFC 9000 §6
   VN flow (applyVersionNegotiation) takes over from there, retrying
   with v1 once the server replies with its supported list.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
Claude
2026-05-07 00:23:48 +00:00
parent d0bc998cd2
commit 98bce58832
2 changed files with 28 additions and 3 deletions
@@ -22,6 +22,7 @@ package com.vitorpamplona.quic.interop.runner
import com.vitorpamplona.quic.QuicWriter
import com.vitorpamplona.quic.connection.QuicConnection
import com.vitorpamplona.quic.connection.drainPeerInitiatedUniStreamsIntoBlackHole
import com.vitorpamplona.quic.http3.Http3Frame
import com.vitorpamplona.quic.http3.Http3FrameReader
import com.vitorpamplona.quic.http3.Http3FrameType
@@ -29,6 +30,7 @@ import com.vitorpamplona.quic.http3.Http3Settings
import com.vitorpamplona.quic.http3.Http3StreamType
import com.vitorpamplona.quic.qpack.QpackDecoder
import com.vitorpamplona.quic.qpack.QpackEncoder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.collect
/** Common shape for the two interop GET clients (HTTP/3 and HQ-interop). */
@@ -59,7 +61,7 @@ data class GetResponse(
class Http3GetClient(
private val conn: QuicConnection,
) : GetClient {
suspend fun init() {
suspend fun init(scope: CoroutineScope) {
// Control stream: type-0x00 prefix followed by a SETTINGS frame
// (empty body is legal — RFC 9114 §7.2.4).
val control = conn.openUniStream()
@@ -81,6 +83,16 @@ class Http3GetClient(
val w3 = QuicWriter()
w3.writeVarint(Http3StreamType.QPACK_DECODER)
qpackDec.send.enqueue(w3.toByteArray())
// RFC 9114 §6.2: the server opens its own three uni streams
// (control, qpack encoder, qpack decoder). Their bytes accumulate
// in per-stream `incomingChannel`s (capacity 64); without an
// active consumer the channel saturates and `:quic` tears down
// the connection with INTERNAL_ERROR. We don't actually use the
// dynamic table or care about the server's settings, so drain
// and discard. Without this, multiplexing testcase fails after
// ~4.5s with "consumer overflowed" tear-down.
conn.drainPeerInitiatedUniStreamsIntoBlackHole(scope)
}
/**
@@ -89,6 +89,16 @@ fun main() {
else -> null
}
// For the versionnegotiation testcase the runner expects us to send
// an Initial advertising a version the server doesn't support, then
// honor its VN response by retrying with v1. agent A's
// QuicVersion.FORCE_VERSION_NEGOTIATION drives that flow.
val initialVersion =
when (testcase) {
"versionnegotiation" -> com.vitorpamplona.quic.packet.QuicVersion.FORCE_VERSION_NEGOTIATION
else -> com.vitorpamplona.quic.packet.QuicVersion.V1
}
// ALPN selection. Different servers configure different ALPNs PER
// testcase, with no consistent convention:
// - quic-go-qns — strictly hq-interop for non-http3 tests
@@ -131,13 +141,14 @@ fun main() {
"handshake", "chacha20", "handshakeloss",
"transfer", "http3", "multiplexing",
"transferloss", "transfercorruption", "longrtt", "goodput", "crosstraffic",
"retry", "ipv6",
"retry", "ipv6", "versionnegotiation",
-> {
runTransferTest(
requests = requests,
downloadsDir = downloadsDir,
cipherSuites = cipherSuites,
offeredAlpns = offeredAlpns,
initialVersion = initialVersion,
keyLogPath = keyLogPath,
parallel = (testcase == "multiplexing"),
)
@@ -168,6 +179,7 @@ private fun runTransferTest(
downloadsDir: File,
cipherSuites: IntArray?,
offeredAlpns: List<Alpn>,
initialVersion: Int,
keyLogPath: String?,
parallel: Boolean,
): Int {
@@ -204,6 +216,7 @@ private fun runTransferTest(
config = QuicConnectionConfig(),
tlsCertificateValidator = PermissiveCertificateValidator(),
alpnList = offeredAlpns.map { it.wireBytes },
initialVersion = initialVersion,
cipherSuites =
cipherSuites
?: intArrayOf(
@@ -235,7 +248,7 @@ private fun runTransferTest(
val client: GetClient =
when (negotiated) {
"h3" -> {
Http3GetClient(conn).also { it.init() }
Http3GetClient(conn).also { it.init(scope) }
}
"hq-interop" -> {