feat(quic-interop): add transfer / multiplexing / http3 testcases via H3 GET client

Adds a minimal Http3GetClient (in :quic-interop, NOT :quic — interop-test
surface, not a production HTTP/3 client) that opens the three required
client uni streams (control + QPACK encoder + QPACK decoder per RFC 9114
§6.2.1), sends an empty SETTINGS, and per request opens a bidi stream,
encodes the four pseudo-headers via the existing literal-only QpackEncoder,
FINs, and reassembles HEADERS+DATA frames from the response.

Wires three testcases:
  - transfer: GET each REQUESTS URL sequentially, write body to
    \$DOWNLOADS/<basename>. status != 200 fails.
  - http3: identical to transfer.
  - multiplexing: same fetches but issued in parallel via
    coroutineScope { async { … } } so request streams genuinely
    overlap on the wire (what tshark verifies).

Out-of-scope (deliberate): GOAWAY, PUSH_PROMISE, dynamic QPACK table,
trailers, priority — none are required by these testcases.

Unit-tests round-trip the request encoding through the existing
Http3FrameReader + QpackDecoder so the wire format is verified without
needing a real peer.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
Claude
2026-05-06 21:42:56 +00:00
parent 7c41aa6dde
commit 68d0cdf2a2
4 changed files with 358 additions and 3 deletions
@@ -47,10 +47,26 @@ Inspect `./logs/<run>/client_qlog/*.qlog` in qvis when something breaks.
|---|---|---|---|
| 0 | Minimum harness | `handshake` | one test reproducible end-to-end ✅ |
| 1 | Triangulate handshake bugs | + `versionnegotiation`, `chacha20` | green vs aioquic + quiche + picoquic |
| 2 | Streams + loss + multiplexing | + `transfer`, `multiplexing`, `*loss`, `http3` | green vs aioquic + quiche; soak 500/500 |
| 2 | Streams + loss + multiplexing | + `transfer`, `multiplexing`, `*loss`, `http3` | `transfer` / `multiplexing` / `http3` ✅ landed; loss tests pending |
| 3 | Edge cases | `retry`, `resumption`, `zerortt`, `keyupdate`, `rebinding-*`, `blackhole`, `amplificationlimit` | every test green or unsupported-127 with a written reason |
| 4 | CI gate | nightly Phases 12; PR-blocking subset on every push | qlogs uploaded as artifacts on red |
## Phase 2 — landed 2026-05-06
- Minimal `Http3GetClient` (in `:quic-interop`, NOT `:quic` — interop-test
surface, not a production HTTP/3 client). Opens the three required
client uni streams (control + QPACK encoder + QPACK decoder), sends
empty SETTINGS, then per request opens a bidi stream, encodes a HEADERS
frame with the four pseudo-headers using the existing literal-only
`QpackEncoder`, FINs, and reassembles HEADERS+DATA frames from the
response. Out-of-scope: GOAWAY, PUSH_PROMISE, dynamic QPACK table,
trailers, priority.
- `transfer` + `http3` testcases: GET each URL in `REQUESTS` sequentially,
write each body to `$DOWNLOADS/<basename>`. Status != 200 fails.
- `multiplexing` testcase: same as `transfer` but issues each GET in a
parallel `coroutineScope { async { … } }` so the request streams
genuinely overlap on the wire (what tshark verifies).
## Phase 1a — landed 2026-05-06
- `SSLKEYLOGFILE` writer in `InteropClient` (NSS Key Log Format) so Wireshark
@@ -0,0 +1,150 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quic.interop.runner
import com.vitorpamplona.quic.QuicWriter
import com.vitorpamplona.quic.connection.QuicConnection
import com.vitorpamplona.quic.http3.Http3Frame
import com.vitorpamplona.quic.http3.Http3FrameReader
import com.vitorpamplona.quic.http3.Http3FrameType
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.flow.collect
/**
* Minimal HTTP/3 GET client used by the interop endpoint to satisfy the
* `transfer`, `multiplexing`, and `http3` testcases.
*
* Opens the three required client-side unidirectional streams (control,
* QPACK encoder, QPACK decoder) per RFC 9114 §6.2.1. Encodes requests with
* the literal-only [QpackEncoder] (no dynamic table — RFC 9204 Required
* Insert Count = 0) so we don't need to push QPACK encoder instructions.
*
* Not a production HTTP/3 client. Specifically: no GOAWAY handling, no
* priority, no push-promise, no trailers, no dynamic QPACK table.
*/
class Http3GetClient(
private val conn: QuicConnection,
) {
suspend fun init() {
// Control stream: type-0x00 prefix followed by a SETTINGS frame
// (empty body is legal — RFC 9114 §7.2.4).
val control = conn.openUniStream()
val w = QuicWriter()
w.writeVarint(Http3StreamType.CONTROL)
w.writeBytes(Http3Settings(emptyMap()).encodeFrame())
control.send.enqueue(w.toByteArray())
// Control stream stays open for the lifetime of the H3 connection;
// do NOT call finish() — peers treat that as H3_CLOSED_CRITICAL_STREAM.
// Required: open QPACK encoder + decoder streams, even though we
// never insert into the dynamic table. Just send the type prefix.
val qpackEnc = conn.openUniStream()
val w2 = QuicWriter()
w2.writeVarint(Http3StreamType.QPACK_ENCODER)
qpackEnc.send.enqueue(w2.toByteArray())
val qpackDec = conn.openUniStream()
val w3 = QuicWriter()
w3.writeVarint(Http3StreamType.QPACK_DECODER)
qpackDec.send.enqueue(w3.toByteArray())
}
/**
* Issue a GET on a fresh bidi stream and return the parsed response.
* Suspends until the server FINs the response stream.
*/
suspend fun get(
authority: String,
path: String,
): Response {
val stream = conn.openBidiStream()
stream.send.enqueue(encodeRequest(authority, path))
stream.send.finish()
val reader = Http3FrameReader()
var status = 0
val body = mutableListOf<ByteArray>()
stream.incoming.collect { chunk ->
reader.push(chunk)
while (true) {
val frame = reader.next() ?: break
when (frame) {
is Http3Frame.Headers -> {
val fields = QpackDecoder().decodeFieldSection(frame.qpackPayload)
status = fields.firstOrNull { it.first == ":status" }?.second?.toIntOrNull() ?: 0
}
is Http3Frame.Data -> {
body += frame.body
}
else -> {
Unit
}
}
}
}
return Response(status = status, body = concat(body))
}
data class Response(
val status: Int,
val body: ByteArray,
)
}
/**
* Serialize a GET request as a single HEADERS frame ready to be enqueued
* onto a fresh bidi stream. Exposed for unit-testing the wire format
* without spinning up a QUIC connection.
*/
internal fun encodeRequest(
authority: String,
path: String,
): ByteArray {
val headers =
listOf(
":method" to "GET",
":scheme" to "https",
":authority" to authority,
":path" to path,
)
val qpack = QpackEncoder().encodeFieldSection(headers)
val w = QuicWriter()
w.writeVarint(Http3FrameType.HEADERS)
w.writeVarint(qpack.size.toLong())
w.writeBytes(qpack)
return w.toByteArray()
}
private fun concat(parts: List<ByteArray>): ByteArray {
val total = parts.sumOf { it.size }
val out = ByteArray(total)
var off = 0
for (p in parts) {
p.copyInto(out, off)
off += p.size
}
return out
}
@@ -30,7 +30,9 @@ import com.vitorpamplona.quic.transport.UdpSocket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
@@ -53,6 +55,7 @@ private const val EXIT_FAIL = 1
private const val EXIT_UNSUPPORTED = 127
private const val HANDSHAKE_TIMEOUT_SEC = 10L
private const val TRANSFER_TIMEOUT_SEC = 30L
fun main() {
val role = System.getenv("ROLE") ?: "client"
@@ -63,11 +66,13 @@ fun main() {
val testcase = System.getenv("TESTCASE")?.trim().orEmpty()
val requests = System.getenv("REQUESTS")?.trim().orEmpty()
val downloads = System.getenv("DOWNLOADS")?.takeIf { it.isNotBlank() }
val keyLogPath = System.getenv("SSLKEYLOGFILE")?.takeIf { it.isNotBlank() }
System.err.println("== quic-interop client ==")
System.err.println("testcase: $testcase")
System.err.println("requests: $requests")
System.err.println("downloads: ${downloads ?: "(unset)"}")
System.err.println("sslkeylogfile: ${keyLogPath ?: "(unset)"}")
val cipherSuites =
@@ -82,9 +87,32 @@ fun main() {
// complete; `chacha20` adds the constraint that we offered only
// ChaCha20-Poly1305 (verified by the runner via tshark on the
// sim's pcap, decrypted using SSLKEYLOGFILE).
"handshake", "chacha20" -> runHandshakeTest(requests, cipherSuites, keyLogPath)
"handshake", "chacha20" -> {
runHandshakeTest(requests, cipherSuites, keyLogPath)
}
else -> EXIT_UNSUPPORTED
// `transfer` and `http3` both fetch every URL in REQUESTS and
// write each body to $DOWNLOADS/<basename>. `multiplexing`
// additionally requires the requests to be sent in parallel on
// separate streams (the runner verifies via tshark that the
// streams overlap in time).
"transfer", "http3", "multiplexing" -> {
if (downloads == null) {
System.err.println("DOWNLOADS env var required for $testcase")
EXIT_FAIL
} else {
runTransferTest(
requests = requests,
downloadsDir = File(downloads),
keyLogPath = keyLogPath,
parallel = (testcase == "multiplexing"),
)
}
}
else -> {
EXIT_UNSUPPORTED
}
}
exitProcess(code)
}
@@ -154,6 +182,108 @@ private fun runHandshakeTest(
}
}
private fun runTransferTest(
requests: String,
downloadsDir: File,
keyLogPath: String?,
parallel: Boolean,
): 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
}
val first = urls[0]
val host = first.host
val port = first.port.takeIf { it > 0 } ?: 443
downloadsDir.mkdirs()
val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val outcome =
runBlocking {
val socket =
try {
UdpSocket.connect(host, port)
} catch (t: Throwable) {
return@runBlocking "udp_failed: ${t.message ?: t::class.simpleName}"
}
val keyLogger = keyLogPath?.let { SslKeyLogger(File(it)) }
val conn =
QuicConnection(
serverName = host,
config = QuicConnectionConfig(),
tlsCertificateValidator = PermissiveCertificateValidator(),
extraSecretsListener = keyLogger?.listener,
)
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) }
return@runBlocking "handshake_failed"
}
val client = Http3GetClient(conn)
client.init()
val authority = if (port == 443) host else "$host:$port"
val outcome =
withTimeoutOrNull(TRANSFER_TIMEOUT_SEC * 1_000L) {
val responses =
if (parallel) {
// Open every request stream up-front so they
// genuinely overlap on the wire — what the
// multiplexing testcase verifies.
coroutineScope {
urls
.map { url -> async { url to client.get(authority, url.path) } }
.map { it.await() }
}
} else {
urls.map { url -> url to client.get(authority, url.path) }
}
var anyFailed = false
for ((url, resp) in responses) {
if (resp.status != 200) {
System.err.println("GET ${url.path} → status ${resp.status}")
anyFailed = true
continue
}
val name = url.path.substringAfterLast('/').ifBlank { "index" }
File(downloadsDir, name).writeBytes(resp.body)
System.err.println("GET ${url.path}${resp.body.size} bytes")
}
if (anyFailed) "request_failed" else "ok"
} ?: "transfer_timeout"
runCatching { driver.close() }
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
delay(50)
outcome
}
scope.cancel()
return if (outcome == "ok") {
System.err.println("transfer ok")
EXIT_OK
} else {
System.err.println("transfer $outcome")
EXIT_FAIL
}
}
private fun parseFirstTarget(requests: String): Pair<String, Int>? {
val first = requests.split(Regex("\\s+")).firstOrNull { it.isNotBlank() } ?: return null
val uri =
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quic.interop.runner
import com.vitorpamplona.quic.http3.Http3Frame
import com.vitorpamplona.quic.http3.Http3FrameReader
import com.vitorpamplona.quic.qpack.QpackDecoder
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class Http3GetClientTest {
@Test
fun `encodeRequest produces a single HEADERS frame with the four pseudo-headers`() {
val bytes = encodeRequest(authority = "example.com", path = "/file.bin")
val reader = Http3FrameReader().apply { push(bytes) }
val frame = reader.next()
assertTrue(frame is Http3Frame.Headers, "first frame must be HEADERS, got $frame")
assertEquals(null, reader.next(), "should be exactly one frame")
val fields = QpackDecoder().decodeFieldSection(frame.qpackPayload)
// QPACK emits headers in order; confirm pseudo-headers come first
// and carry the expected values.
val map = fields.associate { it.first to it.second }
assertEquals("GET", map[":method"])
assertEquals("https", map[":scheme"])
assertEquals("example.com", map[":authority"])
assertEquals("/file.bin", map[":path"])
}
@Test
fun `encodeRequest survives an authority with a non-default port`() {
val bytes = encodeRequest(authority = "example.com:8443", path = "/")
val frame = Http3FrameReader().apply { push(bytes) }.next()
require(frame is Http3Frame.Headers)
val map = QpackDecoder().decodeFieldSection(frame.qpackPayload).associate { it.first to it.second }
assertEquals("example.com:8443", map[":authority"])
assertEquals("/", map[":path"])
}
}