feat(quic-interop): scaffold quic-interop-runner endpoint module

Adds :quic-interop, a JVM-only module at quic/interop/ that implements
the quic-interop-runner Docker contract so we can drive matrix tests
against aioquic / quiche / picoquic / msquic / ngtcp2 / etc. as a
bug-finding harness.

Phase 0 supports only the `handshake` testcase; everything else returns
127 so the runner skips rather than fails. SSLKEYLOGFILE / QLOGDIR
plumbing is deferred to Phase 1.

https://claude.ai/code/session_01HcvfQq1ttPV9PkRoJb4nyT
This commit is contained in:
Claude
2026-05-06 21:08:02 +00:00
parent e144226eee
commit 1586c0cced
7 changed files with 333 additions and 0 deletions
+16
View File
@@ -0,0 +1,16 @@
# quic-interop-runner endpoint image for :quic.
#
# Build (from repo root):
# ./gradlew :quic-interop:installDist
# docker build -t amethyst-quic-interop -f quic/interop/Dockerfile .
#
# Or run `make build` inside quic/interop/.
FROM martenseemann/quic-network-simulator-endpoint:latest
RUN apt-get update \
&& apt-get install -y --no-install-recommends openjdk-21-jre-headless \
&& rm -rf /var/lib/apt/lists/*
COPY quic/interop/build/install/quic-interop /opt/quic-interop
COPY quic/interop/run_endpoint.sh /run_endpoint.sh
RUN chmod +x /run_endpoint.sh /opt/quic-interop/bin/quic-interop
+39
View File
@@ -0,0 +1,39 @@
# Local iteration loop for the quic-interop-runner endpoint image.
#
# make build # compile JVM dist + build Docker image
# make smoke # quick handshake test against a local picoquic
# make image-name # print the image tag (for use in implementations.json)
#
# These targets are deliberately thin wrappers — the real harness is the
# `quic-interop-runner` repo (clone separately and register `amethyst` with
# image=amethyst-quic-interop:latest, role=client).
IMAGE ?= amethyst-quic-interop:latest
REPO_ROOT := $(shell git rev-parse --show-toplevel)
DIST_DIR := build/install/quic-interop
PICOQUIC_PORT ?= 4433
.PHONY: build dist docker smoke image-name clean
build: docker
dist:
cd $(REPO_ROOT) && ./gradlew :quic-interop:installDist
docker: dist
cd $(REPO_ROOT) && docker build -t $(IMAGE) -f quic/interop/Dockerfile .
smoke: docker
@docker rm -f amethyst-quic-interop-smoke >/dev/null 2>&1 || true
docker run --rm --name amethyst-quic-interop-smoke \
--network host \
-e ROLE=client \
-e TESTCASE=handshake \
-e REQUESTS="https://127.0.0.1:$(PICOQUIC_PORT)/" \
$(IMAGE)
image-name:
@echo $(IMAGE)
clean:
cd $(REPO_ROOT) && ./gradlew :quic-interop:clean
docker image rm -f $(IMAGE) 2>/dev/null || true
+49
View File
@@ -0,0 +1,49 @@
/*
* 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.
*/
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.jetbrainsKotlinJvm)
application
}
kotlin {
jvmToolchain(21)
compilerOptions {
jvmTarget.set(JvmTarget.JVM_21)
}
}
sourceSets {
main {
kotlin.srcDir("src/main/kotlin")
}
}
dependencies {
implementation(project(":quic"))
implementation(libs.kotlinx.coroutines.core)
}
application {
mainClass.set("com.vitorpamplona.quic.interop.runner.InteropClientKt")
applicationName = "quic-interop"
}
@@ -0,0 +1,63 @@
# quic-interop-runner endpoint — Phase 0 scaffolding
Date: 2026-05-06
## Why
We want the [`quic-interop-runner`](https://github.com/quic-interop/quic-interop-runner)
matrix as a **bug-finding harness**, not a vanity scoreboard. Each peer impl
(quiche, aioquic, picoquic, ngtcp2, msquic, mvfst, lsquic, neqo, kwik) enforces
different parts of RFC 9000/9001/9002/9114 strictly, so failures triangulate
to specific bugs in `:quic`. The runner's ns-3 sim also exposes loss /
reorder / migration scenarios that are awkward to reproduce in unit tests.
## What's in Phase 0
- `:quic-interop` Gradle module (JVM-only application, registered at
`quic/interop/` via `settings.gradle`).
- `InteropClient.kt` reads the runner's env-var contract (`ROLE`, `TESTCASE`,
`REQUESTS`, …) and dispatches by testcase. Phase 0 implements only
`handshake`; everything else returns `127` (runner-skip).
- `Dockerfile` based on `martenseemann/quic-network-simulator-endpoint` +
OpenJDK 21 runtime, copies the `installDist` output.
- `run_endpoint.sh` sources the base image's `/setup.sh` then execs our JVM
binary.
- `Makefile` wrappers: `make build`, `make smoke`, `make clean`.
## Local iteration loop
```
# In our repo:
make -C quic/interop build
# In a sibling clone of quic-interop-runner, add to implementations.json:
"amethyst": {
"image": "amethyst-quic-interop:latest",
"url": "https://github.com/vitorpamplona/amethyst",
"role": "client"
}
python run.py -d -i amethyst -s aioquic -t handshake --log-dir ./logs
```
Inspect `./logs/<run>/client_qlog/*.qlog` in qvis when something breaks.
## Phase ladder (excerpt — full plan in conversation)
| Phase | Goal | Tests | Exit criterion |
|---|---|---|---|
| 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 |
| 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 12; PR-blocking subset on every push | qlogs uploaded as artifacts on red |
## Known follow-ups (NOT in Phase 0)
- `SSLKEYLOGFILE` and `QLOGDIR` are not yet wired through to `:quic` — the
runner will set them but our endpoint ignores them. Phase 1 must surface
TLS keys (so Wireshark can decrypt the sim's pcap captures) and qlog
output (so qvis is useful).
- Server role: we are client-first. Reassess after Phase 3.
- WebTransport is **not** part of the standard interop matrix; it needs a
separate harness against `moq-rs` / chrome-headless.
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# quic-interop-runner endpoint entry point.
#
# The base image (martenseemann/quic-network-simulator-endpoint) sets up
# routing in /setup.sh; we source it then dispatch to our JVM client based
# on the ROLE env var pushed in by the runner.
set -euo pipefail
# shellcheck disable=SC1091
source /setup.sh
case "${ROLE:-client}" in
client)
exec /opt/quic-interop/bin/quic-interop
;;
server)
# Phase 0: server role unimplemented. Exit 127 so the runner skips.
echo "server role not implemented" >&2
exit 127
;;
*)
echo "unknown ROLE=${ROLE}" >&2
exit 127
;;
esac
@@ -0,0 +1,139 @@
/*
* 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 com.vitorpamplona.quic.connection.QuicConnectionConfig
import com.vitorpamplona.quic.connection.QuicConnectionDriver
import com.vitorpamplona.quic.tls.PermissiveCertificateValidator
import com.vitorpamplona.quic.transport.UdpSocket
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeoutOrNull
import java.net.URI
import kotlin.system.exitProcess
/**
* Endpoint that implements the [quic-interop-runner](https://github.com/quic-interop/quic-interop-runner)
* Docker contract. Reads the env-var protocol the runner pushes into each
* client container and dispatches by [TESTCASE].
*
* Exit codes:
* - `0` — testcase passed
* - `1` — testcase failed (bug in our impl, OR partner)
* - `127` — testcase not implemented (runner skips, doesn't fail)
*/
private const val EXIT_OK = 0
private const val EXIT_FAIL = 1
private const val EXIT_UNSUPPORTED = 127
private const val HANDSHAKE_TIMEOUT_SEC = 10L
fun main() {
val role = System.getenv("ROLE") ?: "client"
if (role != "client") {
System.err.println("server role not implemented")
exitProcess(EXIT_UNSUPPORTED)
}
val testcase = System.getenv("TESTCASE")?.trim().orEmpty()
val requests = System.getenv("REQUESTS")?.trim().orEmpty()
System.err.println("== quic-interop client ==")
System.err.println("testcase: $testcase")
System.err.println("requests: $requests")
val code =
when (testcase) {
"handshake" -> runHandshakeTest(requests)
else -> EXIT_UNSUPPORTED
}
exitProcess(code)
}
private fun runHandshakeTest(requests: 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 conn =
QuicConnection(
serverName = host,
config = QuicConnectionConfig(),
tlsCertificateValidator = PermissiveCertificateValidator(),
)
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() }
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 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
}
+2
View File
@@ -40,3 +40,5 @@ include ':quic'
include ':nestsClient' include ':nestsClient'
include ':desktopApp' include ':desktopApp'
include ':cli' include ':cli'
include ':quic-interop'
project(':quic-interop').projectDir = file('quic/interop')