feat(quic-interop): multiconnect dispatch + multiplex stream-budget pacing

Two interop-runner gaps closed in one InteropClient pass plus a
QuicConnection snapshot helper:

1. multiconnect testcase. The runner's handshakeloss /
   handshakecorruption tests reuse TESTCASE_CLIENT=multiconnect — 50
   sequential connections, each fetching a 1KB file under 30% packet
   drop or bit-flip, with the runner verifying _count_handshakes()==50
   in the pcap. Pre-fix our InteropClient dispatch returned 127 (skip)
   for "multiconnect", so both tests showed as ?(L1, C1). Added
   runMulticonnectTest: loops fresh socket + conn + driver + GET +
   close per URL. Per-iteration qlog files at $QLOGDIR/client-N.sqlog
   so a stuck iteration leaves a focused trace.

2. multiplex pacing against quic-go. Pre-fix the parallel path
   chunked the URL list into fixed groups of MULTIPLEX_PARALLELISM=64.
   Worked against aioquic + picoquic (initial_max_streams_bidi=128)
   but blew up against quic-go (advertises 100, ramps slowly via
   MAX_STREAMS_BIDI bumps): second chunk pushed cumulative used past
   limit, threw QuicStreamLimitException. Now each iteration takes
   min(MULTIPLEX_PARALLELISM, peerMaxStreamsBidi - used). When budget
   hits 0, brief 50ms idle waits for the peer's bump.

   New QuicConnection.localBidiStreamsUsedSnapshot() exposes the
   consumed-side counter; combined with the existing
   peerMaxStreamsBidiSnapshot() the InteropClient computes the live
   available budget without holding streamsLock.

Result against quic-go: H, M, LR, L2, C2, C1 pass; was 0/7 at
session start (handshake itself failed pre-ALPN-fix), 4/6 after
key-update fix, now 6/7. Only L1 (handshakeloss) remains as
multiconnect-under-30%-drop flake (same flake picoquic shows).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vitor Pamplona
2026-05-07 15:54:48 -04:00
parent b622d0c936
commit 86a4727efb
2 changed files with 243 additions and 4 deletions
@@ -180,7 +180,7 @@ fun main() {
// ipv6 — same flow over an IPv6 socket;
// JDK DatagramChannel.connect handles
// the v6 address resolution natively.
"handshake", "chacha20", "handshakeloss",
"handshake", "chacha20",
"transfer", "http3", "multiplexing",
"transferloss", "transfercorruption", "longrtt", "goodput", "crosstraffic",
"retry", "ipv6",
@@ -216,6 +216,26 @@ fun main() {
)
}
// The runner reuses TESTCASE_CLIENT=multiconnect for the
// handshakeloss + handshakecorruption tests (see
// testcases_quic.py:746). Each URL must be fetched on a fresh
// QUIC connection — the testcase verifies via tshark that
// the pcap contains _num_runs (50) distinct handshakes. We
// could not satisfy this through runTransferTest (one
// connection, many GETs); fixing required a separate per-
// URL connection loop.
"multiconnect" -> {
runMulticonnectTest(
requests = requests,
downloadsDir = downloadsDir,
cipherSuites = cipherSuites,
offeredAlpns = offeredAlpns,
initialVersion = initialVersion,
keyLogPath = keyLogPath,
qlogDir = qlogDir,
)
}
else -> {
EXIT_UNSUPPORTED
}
@@ -416,11 +436,46 @@ private fun runTransferTest(
"expected_chunks=${(urls.size + MULTIPLEX_PARALLELISM - 1) / MULTIPLEX_PARALLELISM}",
)
}
urls.chunked(MULTIPLEX_PARALLELISM).forEachIndexed { chunkIdx, chunk ->
// Pace stream creation against peer's MAX_STREAMS_BIDI budget.
// Pre-fix this used a fixed [MULTIPLEX_PARALLELISM]=64
// chunk, which exceeded quic-go's tighter
// initial_max_streams_bidi=100 cap on the second chunk
// (cumulative used=128 > limit=108-ish). Throws
// QuicStreamLimitException, kills the test.
// aioquic + picoquic advertise 128 so we never noticed.
//
// Now: each iteration takes the smaller of MULTIPLEX_PARALLELISM
// and the live budget (peer cap minus what we've already
// consumed). When budget=0, poll briefly waiting for the
// peer's MAX_STREAMS_BIDI bump — the peer extends the
// limit as our completed streams retire from their
// bookkeeping.
val totalUrls = urls.size
var chunkIdx = 0
var remaining = urls
while (remaining.isNotEmpty()) {
val budget =
(conn.peerMaxStreamsBidiSnapshot() - conn.localBidiStreamsUsedSnapshot())
.toInt()
.coerceAtLeast(0)
if (budget == 0) {
// Brief idle while peer's MAX_STREAMS catches up. 50 ms is
// arbitrary but small enough that the matrix budget can
// absorb several rounds, large enough that we don't burn
// CPU spinning. If the test budget runs out before the peer
// bumps the cap, the outer withTimeoutOrNull surfaces it
// as transfer_timeout (clear signal vs a deadlock-looking
// hang).
delay(50)
continue
}
val take = minOf(MULTIPLEX_PARALLELISM, budget, remaining.size)
val chunk = remaining.subList(0, take)
remaining = remaining.subList(take, remaining.size)
val chunkStartMs = nowMs()
if (debug && chunkIdx < 3) {
System.err.println(
"[interop] chunk=$chunkIdx size=${chunk.size} starting prepareRequests",
"[interop] chunk=$chunkIdx size=${chunk.size} budget=$budget starting prepareRequests",
)
}
// Single lock-held batch open + enqueue.
@@ -450,9 +505,11 @@ private fun runTransferTest(
"[interop] chunk=$chunkIdx size=${chunk.size} " +
"enqueue=${enqueuedMs - chunkStartMs}ms " +
"responses=${doneMs - enqueuedMs}ms " +
"cumulative=${doneMs - transferStartMs}ms",
"cumulative=${doneMs - transferStartMs}ms " +
"completed=${totalUrls - remaining.size}/$totalUrls",
)
}
chunkIdx += 1
}
collected
} else {
@@ -487,6 +544,168 @@ private fun runTransferTest(
}
}
/**
* One QUIC connection per URL (handshakeloss / handshakecorruption).
*
* The runner's `multiconnect` testcase generates N small files (typ. 50 × 1 KB)
* and expects N independent handshakes in the pcap. Each iteration creates a
* fresh socket + connection + driver, performs a single HQ-interop GET,
* writes the body to /downloads, and closes. The whole loop runs serially —
* concurrency would only help under a much wider test budget than the runner
* gives us, and serial keeps the pcap easy to read.
*
* Per-iteration qlog files land at `$QLOGDIR/client-N.sqlog` so a failed run
* leaves a trace for the specific connection that failed; SSL-key-log lines
* accumulate in a single file (Wireshark de-dupes by client_random).
*/
private fun runMulticonnectTest(
requests: String,
downloadsDir: File,
cipherSuites: IntArray?,
offeredAlpns: List<Alpn>,
initialVersion: Int,
keyLogPath: String?,
qlogDir: File?,
): Int {
val urls =
requests
.split(Regex("\\s+"))
.filter { it.isNotBlank() }
.map { runCatching { URI(it) }.getOrNull() }
.filter { it != null && it.host != null }
.map { it!! }
if (urls.isEmpty()) {
System.err.println("no parseable URL in REQUESTS")
return EXIT_FAIL
}
downloadsDir.mkdirs()
val keyLogger = keyLogPath?.let { SslKeyLogger(File(it)) }
qlogDir?.mkdirs()
System.err.println("[boot] multiconnect: urls=${urls.size}")
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val outcome =
runBlocking {
for ((idx, url) in urls.withIndex()) {
val host = url.host
val port = url.port.takeIf { it > 0 } ?: 443
val authority = if (port == 443) host else "$host:$port"
val socket =
try {
UdpSocket.connect(host, port)
} catch (t: Throwable) {
return@runBlocking "udp_failed[$idx]: ${t.message ?: t::class.simpleName}"
}
val qlogWriter =
qlogDir?.let { dir ->
QlogWriter(file = File(dir, "client-$idx.sqlog"), odcidHex = "client$idx")
}
val conn =
QuicConnection(
serverName = host,
config =
QuicConnectionConfig(
// Same window sizing as runTransferTest;
// multiconnect's per-conn payload is tiny but
// matching keeps RTT-stall behavior identical
// across testcases for easier triangulation.
initialMaxData = 32L * 1024 * 1024,
initialMaxStreamDataBidiLocal = 32L * 1024 * 1024,
initialMaxStreamDataBidiRemote = 32L * 1024 * 1024,
initialMaxStreamDataUni = 32L * 1024 * 1024,
),
tlsCertificateValidator = PermissiveCertificateValidator(),
alpnList = offeredAlpns.map { it.wireBytes },
initialVersion = initialVersion,
cipherSuites =
cipherSuites
?: intArrayOf(
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256,
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256,
),
extraSecretsListener = keyLogger?.listener,
qlogObserver = qlogWriter ?: com.vitorpamplona.quic.observability.QlogObserver.NoOp,
)
val driver = QuicConnectionDriver(conn, socket, scope)
driver.start()
val handshake =
withTimeoutOrNull(HANDSHAKE_TIMEOUT_SEC * 1_000L) {
runCatching { conn.awaitHandshake() }
}
if (handshake == null || handshake.isFailure) {
runCatching { driver.close() }
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
runCatching { qlogWriter?.close() }
return@runBlocking "handshake_failed[$idx]"
}
val negotiated =
conn.tls.negotiatedAlpn
?.decodeToString()
.orEmpty()
val client: GetClient =
when (negotiated) {
"h3" -> {
Http3GetClient(conn, driver).also { it.init(scope) }
}
"hq-interop" -> {
HqInteropGetClient(conn, driver)
}
else -> {
System.err.println("[multiconnect:$idx] unrecognized ALPN '$negotiated'; defaulting to hq-interop")
HqInteropGetClient(conn, driver)
}
}
val resp =
withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) {
client.get(authority, url.path)
}
if (resp == null) {
runCatching { driver.close() }
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
runCatching { qlogWriter?.close() }
return@runBlocking "transfer_timeout[$idx]"
}
if (resp.status != 200) {
System.err.println("[multiconnect:$idx] GET ${url.path} → status ${resp.status}")
runCatching { driver.close() }
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
runCatching { qlogWriter?.close() }
return@runBlocking "request_failed[$idx]"
}
val name = url.path.substringAfterLast('/').ifBlank { "index" }
File(downloadsDir, name).writeBytes(resp.body)
runCatching { driver.close() }
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
runCatching { qlogWriter?.close() }
// Tiny breather so the just-closed driver / socket release
// their resources before the next iteration grabs a new
// ephemeral UDP port. Without this, the kernel occasionally
// hands the same port back before the OS ARP cache has
// settled and the sim drops the first Initial.
delay(50)
}
"ok"
}
scope.cancel()
return if (outcome == "ok") {
EXIT_OK
} else {
System.err.println("multiconnect $outcome")
EXIT_FAIL
}
}
/**
* Writes [NSS Key Log Format](https://firefox-source-docs.mozilla.org/security/nss/legacy/key_log_format/index.html)
* lines so Wireshark can decrypt the sim's captured pcap.