fix(quic-interop): handshake/chacha20 testcases must transfer the file

Two coupled bugs in our endpoint that the runner just surfaced:

1. The runner mounts \$CLIENT_DOWNLOADS to /downloads as a Docker volume
   (per quic-interop-runner's docker-compose.yml `client.volumes`).
   There is no DOWNLOADS env var. Our endpoint was reading \$DOWNLOADS,
   getting null, and (for handshake/chacha20) skipping any download path.
   Hard-code /downloads.

2. The handshake / chacha20 / handshakeloss testcases don't just verify
   the handshake completes — they also require the requested file at
   /downloads/<basename>. The runner's _check_files validator
   reported "Missing files: ['intense-tremendous-firefighter']" while
   our client side reported `handshake ok`. Route handshake-flavor
   testcases through runTransferTest so the full H3 GET pipeline runs.

   `chacha20` keeps the cipher-suite override (ChaCha20-only ClientHello);
   `handshakeloss` reuses the same H3 flow against a lossy sim.

Also drops the now-dead runHandshakeTest + parseFirstTarget helpers.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
Claude
2026-05-06 23:19:31 +00:00
parent 2667e966d8
commit 04bbb4e3e9
@@ -66,13 +66,16 @@ fun main() {
val testcase = System.getenv("TESTCASE")?.trim().orEmpty()
val requests = System.getenv("REQUESTS")?.trim().orEmpty()
val downloads = System.getenv("DOWNLOADS")?.takeIf { it.isNotBlank() }
// The runner mounts $CLIENT_DOWNLOADS to /downloads as a Docker volume
// (see quic-interop-runner's docker-compose.yml `client.volumes`). It
// does NOT export a DOWNLOADS env var. Hard-code the mount path.
val downloadsDir = File("/downloads")
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("downloads dir: ${downloadsDir.absolutePath} (exists=${downloadsDir.isDirectory})")
System.err.println("sslkeylogfile: ${keyLogPath ?: "(unset)"}")
val cipherSuites =
@@ -83,40 +86,30 @@ fun main() {
val code =
when (testcase) {
// Both `handshake` and `chacha20` only require the handshake to
// 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). `handshakeloss`
// is the same client behaviour against a lossy sim.
"handshake", "chacha20", "handshakeloss" -> {
runHandshakeTest(requests, cipherSuites, keyLogPath)
}
// `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). The remaining aliases run the same
// client logic against different sim configurations:
// transferloss — random packet loss
// transfercorruption — random bit-flips (recovery via AEAD AUTH FAIL → drop + retransmit)
// longrtt — emulated high-latency link
// goodput — throughput floor under default sim
// crosstraffic — competing UDP flows on the same link
// The runner's `handshake` / `chacha20` / `handshakeloss` testcases
// require BOTH a successful handshake AND the requested file
// transferred to /downloads — the validator checks file presence
// via _check_files() AND that exactly 1 handshake happened on the
// wire via _count_handshakes(). `chacha20` adds the constraint
// that we offered only ChaCha20-Poly1305 (verified by the runner
// via tshark on the sim's pcap decrypted using SSLKEYLOGFILE).
// `handshakeloss` is the same client behaviour against a lossy sim.
//
// `transfer` / `http3` / `multiplexing` and the network-condition
// aliases (transferloss / transfercorruption / longrtt / goodput
// / crosstraffic) run the same H3 GET pipeline; the runner just
// varies the sim configuration around them.
"handshake", "chacha20", "handshakeloss",
"transfer", "http3", "multiplexing",
"transferloss", "transfercorruption", "longrtt", "goodput", "crosstraffic",
-> {
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"),
)
}
runTransferTest(
requests = requests,
downloadsDir = downloadsDir,
cipherSuites = cipherSuites,
keyLogPath = keyLogPath,
parallel = (testcase == "multiplexing"),
)
}
else -> {
@@ -126,74 +119,10 @@ fun main() {
exitProcess(code)
}
private fun runHandshakeTest(
requests: String,
cipherSuites: IntArray?,
keyLogPath: String?,
): Int {
val target =
parseFirstTarget(requests) ?: run {
System.err.println("no parseable target in REQUESTS")
return EXIT_FAIL
}
val (host, port) = target
System.err.println("handshake target: $host:$port")
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(),
cipherSuites =
cipherSuites
?: intArrayOf(
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256,
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256,
),
extraSecretsListener = keyLogger?.listener,
)
val driver = QuicConnectionDriver(conn, socket, scope)
driver.start()
val handshake =
withTimeoutOrNull(HANDSHAKE_TIMEOUT_SEC * 1_000L) {
runCatching { conn.awaitHandshake() }
}
val result =
when {
handshake == null -> "timeout"
handshake.isSuccess -> "ok"
else -> "failed: ${handshake.exceptionOrNull()?.message ?: "?"}"
}
runCatching { driver.close() }
conn.tls.clientRandom?.let { keyLogger?.flush(it) }
delay(50)
result
}
scope.cancel()
return if (outcome == "ok") {
System.err.println("handshake ok")
EXIT_OK
} else {
System.err.println("handshake $outcome")
EXIT_FAIL
}
}
private fun runTransferTest(
requests: String,
downloadsDir: File,
cipherSuites: IntArray?,
keyLogPath: String?,
parallel: Boolean,
): Int {
@@ -229,6 +158,12 @@ private fun runTransferTest(
serverName = host,
config = QuicConnectionConfig(),
tlsCertificateValidator = PermissiveCertificateValidator(),
cipherSuites =
cipherSuites
?: intArrayOf(
TlsConstants.CIPHER_TLS_AES_128_GCM_SHA256,
TlsConstants.CIPHER_TLS_CHACHA20_POLY1305_SHA256,
),
extraSecretsListener = keyLogger?.listener,
)
val driver = QuicConnectionDriver(conn, socket, scope)
@@ -293,19 +228,6 @@ private fun runTransferTest(
}
}
private fun parseFirstTarget(requests: String): Pair<String, Int>? {
val first = requests.split(Regex("\\s+")).firstOrNull { it.isNotBlank() } ?: return null
val uri =
try {
URI(first)
} catch (_: Throwable) {
return null
}
val host = uri.host ?: return null
val port = uri.port.takeIf { it > 0 } ?: 443
return host to port
}
/**
* 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.