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 com.vitorpamplona.quic.qpack.QpackEncoder
import kotlinx.coroutines.flow.collect 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 * 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, * Opens the three required client-side unidirectional streams (control,
* QPACK encoder, QPACK decoder) per RFC 9114 §6.2.1. Encodes requests with * QPACK encoder, QPACK decoder) per RFC 9114 §6.2.1. Encodes requests with
@@ -45,7 +58,7 @@ import kotlinx.coroutines.flow.collect
*/ */
class Http3GetClient( class Http3GetClient(
private val conn: QuicConnection, private val conn: QuicConnection,
) { ) : GetClient {
suspend fun init() { suspend fun init() {
// Control stream: type-0x00 prefix followed by a SETTINGS frame // Control stream: type-0x00 prefix followed by a SETTINGS frame
// (empty body is legal — RFC 9114 §7.2.4). // (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. * Issue a GET on a fresh bidi stream and return the parsed response.
* Suspends until the server FINs the response stream. * Suspends until the server FINs the response stream.
*/ */
suspend fun get( override suspend fun get(
authority: String, authority: String,
path: String, path: String,
): Response { ): GetResponse {
val stream = conn.openBidiStream() val stream = conn.openBidiStream()
stream.send.enqueue(encodeRequest(authority, path)) stream.send.enqueue(encodeRequest(authority, path))
stream.send.finish() 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 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 = val code =
when (testcase) { when (testcase) {
// The runner's `handshake` / `chacha20` / `handshakeloss` testcases // All these testcases require: successful handshake + file
// require BOTH a successful handshake AND the requested file // transferred to /downloads. Per-testcase notes:
// transferred to /downloads — the validator checks file presence // chacha20 — runner verifies the negotiated cipher
// via _check_files() AND that exactly 1 handshake happened on the // was ChaCha20-Poly1305 via tshark on the
// wire via _count_handshakes(). `chacha20` adds the constraint // pcap (decrypted with SSLKEYLOGFILE).
// that we offered only ChaCha20-Poly1305 (verified by the runner // handshakeloss/ — same client behaviour against the
// via tshark on the sim's pcap decrypted using SSLKEYLOGFILE). // transferloss runner's sim with random packet loss.
// `handshakeloss` is the same client behaviour against a lossy sim. // transfercorruption — random bit-flips (recovery via
// // AEAD AUTH FAIL → drop + retransmit).
// `transfer` / `http3` / `multiplexing` and the network-condition // longrtt — emulated high-latency link.
// aliases (transferloss / transfercorruption / longrtt / goodput // goodput / crosstraffic — throughput-floor / competing-flow
// / crosstraffic) run the same H3 GET pipeline; the runner just // scenarios.
// varies the sim configuration around them. // multiplexing — H3 GETs issued in parallel; runner
// verifies overlap on the wire via tshark.
"handshake", "chacha20", "handshakeloss", "handshake", "chacha20", "handshakeloss",
"transfer", "http3", "multiplexing", "transfer", "http3", "multiplexing",
"transferloss", "transfercorruption", "longrtt", "goodput", "crosstraffic", "transferloss", "transfercorruption", "longrtt", "goodput", "crosstraffic",
@@ -112,6 +125,7 @@ fun main() {
requests = requests, requests = requests,
downloadsDir = downloadsDir, downloadsDir = downloadsDir,
cipherSuites = cipherSuites, cipherSuites = cipherSuites,
alpn = alpn,
keyLogPath = keyLogPath, keyLogPath = keyLogPath,
parallel = (testcase == "multiplexing"), parallel = (testcase == "multiplexing"),
) )
@@ -124,10 +138,24 @@ fun main() {
exitProcess(code) 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( private fun runTransferTest(
requests: String, requests: String,
downloadsDir: File, downloadsDir: File,
cipherSuites: IntArray?, cipherSuites: IntArray?,
alpn: Alpn,
keyLogPath: String?, keyLogPath: String?,
parallel: Boolean, parallel: Boolean,
): Int { ): Int {
@@ -163,6 +191,7 @@ private fun runTransferTest(
serverName = host, serverName = host,
config = QuicConnectionConfig(), config = QuicConnectionConfig(),
tlsCertificateValidator = PermissiveCertificateValidator(), tlsCertificateValidator = PermissiveCertificateValidator(),
alpnList = listOf(alpn.wireBytes),
cipherSuites = cipherSuites =
cipherSuites cipherSuites
?: intArrayOf( ?: intArrayOf(
@@ -184,8 +213,11 @@ private fun runTransferTest(
return@runBlocking "handshake_failed" return@runBlocking "handshake_failed"
} }
val client = Http3GetClient(conn) val client: GetClient =
client.init() when (alpn) {
Alpn.H3 -> Http3GetClient(conn).also { it.init() }
Alpn.HQ_INTEROP -> HqInteropGetClient(conn)
}
val authority = if (port == 443) host else "$host:$port" val authority = if (port == 443) host else "$host:$port"
val outcome = val outcome =