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:
+61
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+18
-10
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+47
-15
@@ -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 =
|
||||||
|
|||||||
Reference in New Issue
Block a user