fix(quic-interop): hq-interop ALPN + per-testcase ALPN switch

quic-go-qns interop revealed two coupled gaps in our endpoint:

1. ALPN per testcase. The runner convention (followed strictly by
   quic-go-qns, lazily by aioquic / picoquic) is:
     - testcase 'http3' / 'multiplexing'  → ALPN 'h3'   (full HTTP/3)
     - everything else                    → ALPN 'hq-interop'
                                           (HTTP/0.9 over QUIC)
   We had hardcoded 'h3'. quic-go closed every non-http3 connection
   with CRYPTO_ERROR 0x178 (TLS no_application_protocol).

2. We had no HQ-interop client. HQ is dead simple: open bidi, send
   `GET /path\r\n` raw, FIN, read body verbatim until server FINs.
   No framing, no QPACK, no control stream.

This commit adds:
  - GetClient interface + GetResponse data class extracted from
    Http3GetClient so the runner code dispatches uniformly.
  - HqInteropGetClient — the 30-line HQ-interop GET implementation.
  - Alpn enum (H3, HQ_INTEROP) wired through main() → runTransferTest
    → QuicConnection.alpnList. Picked from the testcase name per the
    convention above.

Validated locally: :quic-interop:test green; :quic-interop:installDist
clean. Will need a real run against quic-go to confirm the fix.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
Claude
2026-05-06 23:49:16 +00:00
parent 2da5d42d70
commit e5bbf85096
3 changed files with 126 additions and 25 deletions
@@ -0,0 +1,61 @@
/*
* 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.connection.QuicConnection
import kotlinx.coroutines.flow.toList
/**
* HQ-interop (HTTP/0.9 over QUIC) GET client. quic-interop-runner convention
* for the non-`http3` testcases (handshake / chacha20 / transfer / loss
* variants): the client opens a bidi stream, sends `GET /path\r\n` (raw
* ASCII, no framing), FINs the send side, and reads the response body
* verbatim until the server FINs. There is no status code, no headers, no
* QPACK, no control stream — the body bytes ARE the response.
*
* Per the runner's testcase validator, an empty body means failure, a
* non-empty body that matches what the server staged at $WWW/<path> means
* success. We surface non-empty as status=200, empty as status=0, so the
* caller's `if (resp.status != 200) anyFailed = true` check keeps working.
*/
class HqInteropGetClient(
private val conn: QuicConnection,
) : GetClient {
override suspend fun get(
@Suppress("UNUSED_PARAMETER") authority: String,
path: String,
): GetResponse {
val stream = conn.openBidiStream()
val request = "GET $path\r\n".encodeToByteArray()
stream.send.enqueue(request)
stream.send.finish()
val chunks = stream.incoming.toList()
val total = chunks.sumOf { it.size }
val body = ByteArray(total)
var off = 0
for (c in chunks) {
c.copyInto(body, off)
off += c.size
}
return GetResponse(status = if (body.isEmpty()) 0 else 200, body = body)
}
}
@@ -31,9 +31,22 @@ import com.vitorpamplona.quic.qpack.QpackDecoder
import com.vitorpamplona.quic.qpack.QpackEncoder
import kotlinx.coroutines.flow.collect
/** Common shape for the two interop GET clients (HTTP/3 and HQ-interop). */
interface GetClient {
suspend fun get(
authority: String,
path: String,
): GetResponse
}
data class GetResponse(
val status: Int,
val body: ByteArray,
)
/**
* Minimal HTTP/3 GET client used by the interop endpoint to satisfy the
* `transfer`, `multiplexing`, and `http3` testcases.
* `http3` and `multiplexing` testcases.
*
* Opens the three required client-side unidirectional streams (control,
* QPACK encoder, QPACK decoder) per RFC 9114 §6.2.1. Encodes requests with
@@ -45,7 +58,7 @@ import kotlinx.coroutines.flow.collect
*/
class Http3GetClient(
private val conn: QuicConnection,
) {
) : GetClient {
suspend fun init() {
// Control stream: type-0x00 prefix followed by a SETTINGS frame
// (empty body is legal — RFC 9114 §7.2.4).
@@ -74,10 +87,10 @@ class Http3GetClient(
* Issue a GET on a fresh bidi stream and return the parsed response.
* Suspends until the server FINs the response stream.
*/
suspend fun get(
override suspend fun get(
authority: String,
path: String,
): Response {
): GetResponse {
val stream = conn.openBidiStream()
stream.send.enqueue(encodeRequest(authority, path))
stream.send.finish()
@@ -105,13 +118,8 @@ class Http3GetClient(
}
}
}
return Response(status = status, body = concat(body))
return GetResponse(status = status, body = concat(body))
}
data class Response(
val status: Int,
val body: ByteArray,
)
}
/**
@@ -89,21 +89,34 @@ fun main() {
else -> null
}
// ALPN per quic-interop-runner convention. Most testcases use
// `hq-interop` (HTTP/0.9 over QUIC, no H3/QPACK). Only the `http3` and
// `multiplexing` testcases use `h3`. quic-go-qns enforces this strictly
// (CRYPTO_ERROR 0x178 / TLS no_application_protocol on mismatch);
// aioquic and picoquic accept either. Match the per-test convention
// so we work everywhere.
val alpn =
when (testcase) {
"http3", "multiplexing" -> Alpn.H3
else -> Alpn.HQ_INTEROP
}
val code =
when (testcase) {
// 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.
// All these testcases require: successful handshake + file
// transferred to /downloads. Per-testcase notes:
// chacha20 — runner verifies the negotiated cipher
// was ChaCha20-Poly1305 via tshark on the
// pcap (decrypted with SSLKEYLOGFILE).
// handshakeloss/ — same client behaviour against the
// transferloss runner's sim with random packet loss.
// transfercorruption — random bit-flips (recovery via
// AEAD AUTH FAIL → drop + retransmit).
// longrtt — emulated high-latency link.
// goodput / crosstraffic — throughput-floor / competing-flow
// scenarios.
// multiplexing — H3 GETs issued in parallel; runner
// verifies overlap on the wire via tshark.
"handshake", "chacha20", "handshakeloss",
"transfer", "http3", "multiplexing",
"transferloss", "transfercorruption", "longrtt", "goodput", "crosstraffic",
@@ -112,6 +125,7 @@ fun main() {
requests = requests,
downloadsDir = downloadsDir,
cipherSuites = cipherSuites,
alpn = alpn,
keyLogPath = keyLogPath,
parallel = (testcase == "multiplexing"),
)
@@ -124,10 +138,24 @@ fun main() {
exitProcess(code)
}
internal enum class Alpn(
val wireBytes: ByteArray,
) {
/** RFC 9114 — full HTTP/3 + QPACK + H3 framing. */
H3("h3".encodeToByteArray()),
/** quic-interop-runner convention — HTTP/0.9 over QUIC. Just `GET /path\r\n`
* on a fresh bidi stream, server returns the body, FIN both sides. No
* control stream, no QPACK, no SETTINGS. Used for handshake / chacha20 /
* transfer / loss-variant testcases. */
HQ_INTEROP("hq-interop".encodeToByteArray()),
}
private fun runTransferTest(
requests: String,
downloadsDir: File,
cipherSuites: IntArray?,
alpn: Alpn,
keyLogPath: String?,
parallel: Boolean,
): Int {
@@ -163,6 +191,7 @@ private fun runTransferTest(
serverName = host,
config = QuicConnectionConfig(),
tlsCertificateValidator = PermissiveCertificateValidator(),
alpnList = listOf(alpn.wireBytes),
cipherSuites =
cipherSuites
?: intArrayOf(
@@ -184,8 +213,11 @@ private fun runTransferTest(
return@runBlocking "handshake_failed"
}
val client = Http3GetClient(conn)
client.init()
val client: GetClient =
when (alpn) {
Alpn.H3 -> Http3GetClient(conn).also { it.init() }
Alpn.HQ_INTEROP -> HqInteropGetClient(conn)
}
val authority = if (port == 443) host else "$host:$port"
val outcome =