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:
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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 1–2; 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.
|
||||||
Executable
+25
@@ -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
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
|||||||
Reference in New Issue
Block a user