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 ✅ | | 0 | Minimum harness | `handshake` | one test reproducible end-to-end ✅ |
| 1 | Triangulate handshake bugs | + `versionnegotiation`, `chacha20` | green vs aioquic + quiche + picoquic | | 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 | | 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 | | 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 ## Phase 1a — landed 2026-05-06
- `SSLKEYLOGFILE` writer in `InteropClient` (NSS Key Log Format) so Wireshark - `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.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
@@ -53,6 +55,7 @@ private const val EXIT_FAIL = 1
private const val EXIT_UNSUPPORTED = 127 private const val EXIT_UNSUPPORTED = 127
private const val HANDSHAKE_TIMEOUT_SEC = 10L private const val HANDSHAKE_TIMEOUT_SEC = 10L
private const val TRANSFER_TIMEOUT_SEC = 30L
fun main() { fun main() {
val role = System.getenv("ROLE") ?: "client" val role = System.getenv("ROLE") ?: "client"
@@ -63,11 +66,13 @@ fun main() {
val testcase = System.getenv("TESTCASE")?.trim().orEmpty() val testcase = System.getenv("TESTCASE")?.trim().orEmpty()
val requests = System.getenv("REQUESTS")?.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() } val keyLogPath = System.getenv("SSLKEYLOGFILE")?.takeIf { it.isNotBlank() }
System.err.println("== quic-interop client ==") System.err.println("== quic-interop client ==")
System.err.println("testcase: $testcase") System.err.println("testcase: $testcase")
System.err.println("requests: $requests") System.err.println("requests: $requests")
System.err.println("downloads: ${downloads ?: "(unset)"}")
System.err.println("sslkeylogfile: ${keyLogPath ?: "(unset)"}") System.err.println("sslkeylogfile: ${keyLogPath ?: "(unset)"}")
val cipherSuites = val cipherSuites =
@@ -82,9 +87,32 @@ fun main() {
// complete; `chacha20` adds the constraint that we offered only // complete; `chacha20` adds the constraint that we offered only
// ChaCha20-Poly1305 (verified by the runner via tshark on the // ChaCha20-Poly1305 (verified by the runner via tshark on the
// sim's pcap, decrypted using SSLKEYLOGFILE). // 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) 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>? { private fun parseFirstTarget(requests: String): Pair<String, Int>? {
val first = requests.split(Regex("\\s+")).firstOrNull { it.isNotBlank() } ?: return null val first = requests.split(Regex("\\s+")).firstOrNull { it.isNotBlank() } ?: return null
val uri = 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"])
}
}