From 68d0cdf2a2aa37cd8e88223ceea506771a6f38c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 21:42:56 +0000 Subject: [PATCH] feat(quic-interop): add transfer / multiplexing / http3 testcases via H3 GET client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/. 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 --- .../plans/2026-05-06-interop-runner.md | 18 ++- .../quic/interop/runner/Http3GetClient.kt | 150 ++++++++++++++++++ .../quic/interop/runner/InteropClient.kt | 134 +++++++++++++++- .../quic/interop/runner/Http3GetClientTest.kt | 59 +++++++ 4 files changed, 358 insertions(+), 3 deletions(-) create mode 100644 quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt create mode 100644 quic/interop/src/test/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClientTest.kt diff --git a/quic/interop/plans/2026-05-06-interop-runner.md b/quic/interop/plans/2026-05-06-interop-runner.md index d40e64068..b0af61aba 100644 --- a/quic/interop/plans/2026-05-06-interop-runner.md +++ b/quic/interop/plans/2026-05-06-interop-runner.md @@ -47,10 +47,26 @@ Inspect `./logs//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 1–2; 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/`. 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 diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt new file mode 100644 index 000000000..19aae6820 --- /dev/null +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClient.kt @@ -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() + 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 { + 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 +} diff --git a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt index 7fdffd580..0358a2a4f 100644 --- a/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt +++ b/quic/interop/src/main/kotlin/com/vitorpamplona/quic/interop/runner/InteropClient.kt @@ -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/. `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? { val first = requests.split(Regex("\\s+")).firstOrNull { it.isNotBlank() } ?: return null val uri = diff --git a/quic/interop/src/test/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClientTest.kt b/quic/interop/src/test/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClientTest.kt new file mode 100644 index 000000000..6a06e8fa2 --- /dev/null +++ b/quic/interop/src/test/kotlin/com/vitorpamplona/quic/interop/runner/Http3GetClientTest.kt @@ -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"]) + } +}