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:
@@ -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 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/<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
|
||||
}
|
||||
+132
-2
@@ -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 =
|
||||
|
||||
+59
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user