From fadfd05670c472d5f9596621c8c64ab7d28fe7b4 Mon Sep 17 00:00:00 2001 From: Ankur Date: Wed, 30 Sep 2026 20:27:46 +0000 Subject: [PATCH] Add http3 module: HTTP/3 in the Java HTTP Client (JEP 517), opt-in, discovery modes, fallback timings, proxy downgrade and a packet-loss comparison Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01KqJyCidz3ZgRyHABv2GVJh --- README.md | 1 + http3/.gitignore | 3 + http3/README.md | 42 ++++++++++ http3/output/01-java11-code-unchanged.txt | 11 +++ http3/output/02-opt-in.txt | 29 +++++++ http3/output/03-discovery-modes.txt | 4 + http3/output/04-fallback.txt | 19 +++++ http3/output/05-udp-blocked.txt | 5 ++ http3/output/06-proxy.txt | 6 ++ http3/output/07-packet-loss.txt | 38 +++++++++ http3/output/08-server-errors.txt | 1 + http3/run.sh | 96 +++++++++++++++++++++++ http3/scripts/loss.sh | 22 ++++++ http3/server/app.py | 34 ++++++++ http3/server/connect-proxy.py | 25 ++++++ http3/server/h3server.py | 46 +++++++++++ http3/server/make-cert.sh | 7 ++ http3/server/serve.py | 29 +++++++ http3/src/Basics.java | 16 ++++ http3/src/Bench.java | 49 ++++++++++++ http3/src/Discovery.java | 28 +++++++ http3/src/Fallback.java | 29 +++++++ http3/src/OptIn.java | 22 ++++++ http3/src/Tls.java | 26 ++++++ http3/src/ViaProxy.java | 29 +++++++ 25 files changed, 617 insertions(+) create mode 100644 http3/.gitignore create mode 100644 http3/README.md create mode 100644 http3/output/01-java11-code-unchanged.txt create mode 100644 http3/output/02-opt-in.txt create mode 100644 http3/output/03-discovery-modes.txt create mode 100644 http3/output/04-fallback.txt create mode 100644 http3/output/05-udp-blocked.txt create mode 100644 http3/output/06-proxy.txt create mode 100644 http3/output/07-packet-loss.txt create mode 100644 http3/output/08-server-errors.txt create mode 100755 http3/run.sh create mode 100755 http3/scripts/loss.sh create mode 100644 http3/server/app.py create mode 100644 http3/server/connect-proxy.py create mode 100644 http3/server/h3server.py create mode 100755 http3/server/make-cert.sh create mode 100644 http3/server/serve.py create mode 100644 http3/src/Basics.java create mode 100644 http3/src/Bench.java create mode 100644 http3/src/Discovery.java create mode 100644 http3/src/Fallback.java create mode 100644 http3/src/OptIn.java create mode 100644 http3/src/Tls.java create mode 100644 http3/src/ViaProxy.java diff --git a/README.md b/README.md index bbe2a30..75d82eb 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,7 @@ These modules keep only sources, a `run.sh` and their transcripts (`output/`). T | [`patterns/`](patterns) | Pattern Matching in Java: switch, Record Patterns and Primitive Patterns (JDK 21 to 27) | type and record patterns, guards and dominance errors, sealed exhaustiveness (with JDK 27's new `missing patterns` lines), `MatchException` from a throwing accessor and from separate compilation, unnamed patterns, primitive patterns with `--enable-preview` and the preview class-file trap | | [`sealed/`](sealed) | Sealed Classes and Interfaces: Modelling Domains with Exhaustive switch | a `Payment` model, `permits` and the three subclass modifiers, the `default` trap, generic and recursive hierarchies, the visitor for comparison, run-time enforcement and modules | | [`lazy-constants/`](lazy-constants) | Lazy Constants in JDK 27 (JEP 531): Replacing Double-Checked Locking | `LazyConstant` against the holder idiom and double-checked locking, the 26 to 27 failure-semantics change, `List`/`Map`/`Set.ofLazy`, the preview-flag traps and a JMH read-path benchmark | +| [`http3/`](http3) | HTTP/3 in the Java HTTP Client (JEP 517): Migrating from HttpClient 11 | Java 11 code unchanged on 25 and 26, the `HTTP_3` opt-in on client vs request, the three `H3_DISCOVERY` modes, fallback timings (including the 60 s `HTTP_3_URI_ONLY` failure), proxy downgrade, HTTP/1.1 vs 2 vs 3 under iptables packet loss, with a local Hypercorn + aioquic server | ## Captured output (`docs/output/`) diff --git a/http3/.gitignore b/http3/.gitignore new file mode 100644 index 0000000..8a9edff --- /dev/null +++ b/http3/.gitignore @@ -0,0 +1,3 @@ +certs/ +__pycache__/ +out/ diff --git a/http3/README.md b/http3/README.md new file mode 100644 index 0000000..7fa1cf6 --- /dev/null +++ b/http3/README.md @@ -0,0 +1,42 @@ +# http3 — HTTP/3 in the Java HTTP Client (JEP 517, final in Java 26) + +Companion code for the ankurm.com article **HTTP/3 in the Java HTTP Client (JEP 517): Migrating from HttpClient 11**. All explanation lives in the +article; this folder holds the runnable sources, a local HTTP/3 test server, and the transcripts they produced. + +## What was tested against + +| Thing | Version | Note | +|---|---|---| +| JDK 26 | Temurin 26.0.2.1+1 | JEP 517 is delivered in 26 (GA 2026-03-17); this is the only JDK that can run HTTP/3 here | +| JDK 25 | Temurin 25.0.4.1+1 | the "before": `HttpClient.Version.HTTP_3` does not exist, plain Java 11 code behaves the same as on 26 | +| Test server | Hypercorn 0.18.0 (HTTP/1.1 + HTTP/2 on TCP) + aioquic 1.3.0 (HTTP/3 on UDP) | one Python process, same port number for both, `Alt-Svc` advertised. Hypercorn's own QUIC listener was dropped: it died on a `KeyError` under packet loss and the port went silent | +| Loss injection | `iptables -m statistic --mode random` on `lo` | **not** `tc netem` (unavailable in the sandbox: `Specified qdisc kind is unknown`): loss only, no added delay | + +The JDK 26 lane in the other modules of this repo uses Amazon Corretto 26.0.2.1; this module used Temurin because that is what the sandbox had. The API is the same. + +## Quickstart + +```bash +pip install hypercorn==0.18.0 aioquic==1.3.0 +JDK25=/path/to/jdk-25 JDK26=/path/to/jdk-26 ./run.sh # about 45 minutes: the `HTTP_3_URI_ONLY` failure demos wait a full minute each and the loss table is 600 trials; needs root for 05 and 07; ONLY=07 regenerates just the loss table +# or by hand: +./server/make-cert.sh && python3 server/serve.py h3 4433 & # h3 | h2 | h1 +java src/OptIn.java https://localhost:4433/hello request +``` + +## Layout + +| Path | What is in it | +|---|---| +| `src/Tls.java` | trusts only the self-signed test certificate | +| `src/Basics.java` | plain Java 11 `HttpClient` code, no version set | +| `src/OptIn.java` | the JEP 517 opt-in, client-wide or per request | +| `src/Discovery.java` | the three `H3_DISCOVERY` modes | +| `src/Fallback.java` | first-request latency and version for each way of asking, against servers that cannot do HTTP/3 | +| `src/ViaProxy.java` | HTTP/3 and a proxy | +| `src/Bench.java` | HTTP/1.1 vs HTTP/2 vs HTTP/3 download times under packet loss | +| `server/` | `app.py` (shared responses + ASGI wrapper), `serve.py` (h3 / h2 / h1 modes), `h3server.py` (aioquic HTTP/3), `connect-proxy.py`, `make-cert.sh` | +| `scripts/loss.sh` | adds and removes the iptables loss rules and the MTU change | +| `output/` | every transcript quoted in the article (`01`-`08`) | + +Timings are machine dependent and the loss is random: treat `07-packet-loss.txt` as shape, not a benchmark of HTTP/3 implementations. The server is Python, the client is the JDK. diff --git a/http3/output/01-java11-code-unchanged.txt b/http3/output/01-java11-code-unchanged.txt new file mode 100644 index 0000000..03a6dd3 --- /dev/null +++ b/http3/output/01-java11-code-unchanged.txt @@ -0,0 +1,11 @@ +$ java src/Basics.java https://localhost:4433/hello (OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)) +request 1: HTTP_2 200 alt-svc=h3=":4433"; ma=3600 +request 2: HTTP_2 200 alt-svc=h3=":4433"; ma=3600 +request 3: HTTP_2 200 alt-svc=h3=":4433"; ma=3600 + +$ java src/Basics.java https://localhost:4433/hello (OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1)) +request 1: HTTP_2 200 alt-svc=h3=":4433"; ma=3600 +request 2: HTTP_2 200 alt-svc=h3=":4433"; ma=3600 +request 3: HTTP_2 200 alt-svc=h3=":4433"; ma=3600 + +# the alt-svc value above is the server advertising h3 on UDP 4433; nobody asked the client to use it. diff --git a/http3/output/02-opt-in.txt b/http3/output/02-opt-in.txt new file mode 100644 index 0000000..f99951f --- /dev/null +++ b/http3/output/02-opt-in.txt @@ -0,0 +1,29 @@ +$ java src/OptIn.java https://localhost:4433/hello client (OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1)) +client-level HTTP_3, request 1: HTTP_2 +client-level HTTP_3, request 2: HTTP_3 +client-level HTTP_3, request 3: HTTP_3 +client-level HTTP_3, request 4: HTTP_3 + +$ java src/OptIn.java https://localhost:4433/hello request (OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1)) +request-level HTTP_3, request 1: HTTP_3 +request-level HTTP_3, request 2: HTTP_3 +request-level HTTP_3, request 3: HTTP_3 +request-level HTTP_3, request 4: HTTP_3 + +$ java src/OptIn.java https://localhost:4433/hello client (OpenJDK Runtime Environment Temurin-25.0.4.1+1 (build 25.0.4.1+1-LTS)) +src/OptIn.java:12: error: cannot find symbol + if (where.equals("client")) builder.version(HttpClient.Version.HTTP_3); + ^ + symbol: variable HTTP_3 + location: class Version +src/OptIn.java:15: error: cannot find symbol + +$ first request of a fresh client-level HTTP_3 client, 8 fresh JVMs (OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1)) +client-level HTTP_3, request 1: HTTP_2 +client-level HTTP_3, request 1: HTTP_2 +client-level HTTP_3, request 1: HTTP_2 +client-level HTTP_3, request 1: HTTP_2 +client-level HTTP_3, request 1: HTTP_2 +client-level HTTP_3, request 1: HTTP_2 +client-level HTTP_3, request 1: HTTP_2 +client-level HTTP_3, request 1: HTTP_2 diff --git a/http3/output/03-discovery-modes.txt b/http3/output/03-discovery-modes.txt new file mode 100644 index 0000000..3857311 --- /dev/null +++ b/http3/output/03-discovery-modes.txt @@ -0,0 +1,4 @@ +$ java src/Discovery.java https://localhost:4433/hello (server: h1.1 + h2 on TCP, h3 on UDP, Alt-Svc advertised) +ANY -> HTTP_2 HTTP_3 HTTP_3 HTTP_3 +ALT_SVC -> HTTP_2 HTTP_3 HTTP_3 HTTP_3 +HTTP_3_URI_ONLY -> HTTP_3 HTTP_3 HTTP_3 HTTP_3 diff --git a/http3/output/04-fallback.txt b/http3/output/04-fallback.txt new file mode 100644 index 0000000..0730b0b --- /dev/null +++ b/http3/output/04-fallback.txt @@ -0,0 +1,19 @@ +$ java src/Fallback.java https://localhost:4433/hello 120 (server: h2 + h1.1 on TCP only; nothing listens on UDP 4433) +request HTTP_3 (ANY) -> HTTP_2 after 3340 ms +client HTTP_3 (ANY) -> HTTP_2 after 31 ms +request HTTP_3_URI_ONLY -> java.net.ConnectException: No response from peer for 30 seconds after 60016 ms + +$ java -Djdk.httpclient.http3.maxDirectConnectionTimeout=1 src/Fallback.java https://localhost:4433/hello 120 (same server) +request HTTP_3 (ANY) -> HTTP_2 after 501 ms +client HTTP_3 (ANY) -> HTTP_2 after 45 ms +request HTTP_3_URI_ONLY -> java.net.ConnectException: No response from peer for 30 seconds after 60016 ms + +$ java -Djdk.httpclient.quic.maxInitialTimeout=3 src/Fallback.java https://localhost:4433/hello 120 (same server) +request HTTP_3 (ANY) -> HTTP_2 after 3338 ms +client HTTP_3 (ANY) -> HTTP_2 after 39 ms +request HTTP_3_URI_ONLY -> java.net.ConnectException: No response from peer for 3 seconds after 6011 ms + +$ java src/Discovery.java https://localhost:4433/hello (server: HTTP/1.1 only, TCP) +ANY -> HTTP_1_1 HTTP_1_1 HTTP_1_1 HTTP_1_1 +ALT_SVC -> HTTP_1_1 HTTP_1_1 HTTP_1_1 HTTP_1_1 +HTTP_3_URI_ONLY -> ConnectException(No response from peer for 30 seconds) ConnectException(No response from peer for 30 seconds) ConnectException(No response from peer for 30 seconds) ConnectException(No response from peer for 30 seconds) diff --git a/http3/output/05-udp-blocked.txt b/http3/output/05-udp-blocked.txt new file mode 100644 index 0000000..77e92c4 --- /dev/null +++ b/http3/output/05-udp-blocked.txt @@ -0,0 +1,5 @@ +$ iptables: DROP 100% of UDP to/from port 4433 (TCP untouched); server is the h3 server +$ java src/Fallback.java https://localhost:4433/hello 120 +request HTTP_3 (ANY) -> HTTP_2 after 3397 ms +client HTTP_3 (ANY) -> HTTP_2 after 46 ms +request HTTP_3_URI_ONLY -> java.net.ConnectException: No response from peer for 30 seconds after 60014 ms diff --git a/http3/output/06-proxy.txt b/http3/output/06-proxy.txt new file mode 100644 index 0000000..478ad50 --- /dev/null +++ b/http3/output/06-proxy.txt @@ -0,0 +1,6 @@ +$ java src/ViaProxy.java https://localhost:4433/hello 4480 (client.proxy(...) points at a CONNECT proxy on 4480) +ANY via proxy -> HTTP_2 +HTTP_3_URI_ONLY via proxy -> java.net.http.UnsupportedProtocolVersionException: can't use HTTP/3 with proxied or unsecured connection + +$ proxy log +proxy saw: CONNECT localhost:4433 diff --git a/http3/output/07-packet-loss.txt b/http3/output/07-packet-loss.txt new file mode 100644 index 0000000..463c4da --- /dev/null +++ b/http3/output/07-packet-loss.txt @@ -0,0 +1,38 @@ +# loss = per-packet drop probability, applied in EACH direction, TCP and UDP port 4433, by iptables -m statistic on lo (MTU 1500). +# no added latency (loopback RTT is microseconds). 20 trials per row after one discarded warm-up; OpenJDK Runtime Environment Temurin-26.0.2.1+1 (build 26.0.2.1+1) +# one = fresh client, 1 download of 1 MiB. many = fresh client, 10 parallel downloads of 100 KiB. +h1 one loss=0 ok=20 failed=0 median=59 p90=68 max=84 (ms) +h2 one loss=0 ok=20 failed=0 median=61 p90=90 max=97 (ms) +h3 one loss=0 ok=20 failed=0 median=129 p90=197 max=246 (ms) +h1 many loss=0 ok=20 failed=0 median=97 p90=114 max=143 (ms) +h2 many loss=0 ok=20 failed=0 median=118 p90=152 max=159 (ms) +h3 many loss=0 ok=20 failed=0 median=135 p90=167 max=214 (ms) + +h1 one loss=1 ok=20 failed=0 median=61 p90=74 max=1278 (ms) +h2 one loss=1 ok=20 failed=0 median=57 p90=66 max=79 (ms) +h3 one loss=1 ok=20 failed=0 median=202 p90=273 max=1207 (ms) +h1 many loss=1 ok=20 failed=0 median=111 p90=1044 max=1054 (ms) +h2 many loss=1 ok=20 failed=0 median=160 p90=1039 max=1057 (ms) +h3 many loss=1 ok=20 failed=0 median=176 p90=232 max=454 (ms) + +h1 one loss=2 ok=20 failed=0 median=87 p90=281 max=2082 (ms) +h2 one loss=2 ok=20 failed=0 median=57 p90=294 max=1082 (ms) +h3 one loss=2 ok=20 failed=0 median=216 p90=316 max=390 (ms) +h1 many loss=2 ok=20 failed=0 median=249 p90=1058 max=2818 (ms) +h2 many loss=2 ok=20 failed=0 median=282 p90=1056 max=1073 (ms) +h3 many loss=2 ok=20 failed=0 median=238 p90=296 max=305 (ms) + +h1 one loss=5 ok=20 failed=0 median=100 p90=1070 max=1689 (ms) +h2 one loss=5 ok=20 failed=0 median=248 p90=1084 max=3057 (ms) +h3 one loss=5 ok=20 failed=0 median=302 p90=500 max=1508 (ms) +h1 many loss=5 ok=20 failed=0 median=1055 p90=2833 max=3496 (ms) +h2 many loss=5 ok=20 failed=0 median=1047 p90=1257 max=1305 (ms) +h3 many loss=5 ok=20 failed=0 median=322 p90=577 max=1330 (ms) + +h1 one loss=10 ok=20 failed=0 median=322 p90=1578 max=3475 (ms) +h2 one loss=10 ok=20 failed=0 median=678 p90=2177 max=2742 (ms) +h3 one loss=10 ok=20 failed=0 median=968 p90=1169 max=1292 (ms) +h1 many loss=10 ok=20 failed=0 median=1509 p90=3370 max=6458 (ms) +h2 many loss=10 ok=20 failed=0 median=1478 p90=3370 max=13812 (ms) +h3 many loss=10 ok=20 failed=0 median=885 p90=1088 max=1250 (ms) + diff --git a/http3/output/08-server-errors.txt b/http3/output/08-server-errors.txt new file mode 100644 index 0000000..9d0f7a6 --- /dev/null +++ b/http3/output/08-server-errors.txt @@ -0,0 +1 @@ +# what the Hypercorn/aioquic server wrote to stderr during 07 (the loss run), exception lines only, counted diff --git a/http3/run.sh b/http3/run.sh new file mode 100755 index 0000000..d6ea2be --- /dev/null +++ b/http3/run.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# HTTP/3 in the JDK HttpClient (JEP 517, final in JDK 26). Regenerates every file in output/. +# JDK25=/path JDK26=/path [TRIALS=20] ./run.sh +# Needs: python3 with `pip install "hypercorn[h3]"`, openssl, and (for 05/06) root with iptables + ip. +# The loss tests change the loopback MTU and add iptables rules; both are undone on exit. +set -uo pipefail +unset JAVA_TOOL_OPTIONS +HERE="$(cd "$(dirname "$0")" && pwd)"; cd "$HERE" +JDK25="${JDK25:-/opt/jdks/jdk-25.0.4.1+1}"; JDK26="${JDK26:-/opt/jdks/jdk26}"; TRIALS="${TRIALS:-20}" +OUT="$HERE/output"; mkdir -p "$OUT" +URL=https://localhost:4433/hello +[ -f certs/cert.pem ] || ./server/make-cert.sh +SPID=""; PPID_="" +start() { python3 server/serve.py "$1" 4433 >/dev/null 2>&1 & SPID=$!; sleep 2; } +stop() { [ -n "$SPID" ] && kill -9 "$SPID" 2>/dev/null; wait "$SPID" 2>/dev/null; SPID=""; } +cleanup() { stop; [ -n "$PPID_" ] && kill -9 "$PPID_" 2>/dev/null; scripts/loss.sh off 2>/dev/null; } +trap cleanup EXIT +v() { "$1/bin/java" -version 2>&1 | sed -n 2p; } +J25="$JDK25/bin/java"; J26="$JDK26/bin/java" + +ONLY="${ONLY:-}" # ONLY=07 regenerates just the loss table +if [ -z "$ONLY" ]; then +# 01 - plain Java 11 code: same source, JDK 25 and 26, server offers HTTP/3. Nothing changes. +start h3 +{ + for j in "$JDK25" "$JDK26"; do echo "\$ java src/Basics.java $URL ($(v $j))"; "$j/bin/java" src/Basics.java $URL 2>&1; echo; done + echo "# the alt-svc value above is the server advertising h3 on UDP 4433; nobody asked the client to use it." +} > "$OUT/01-java11-code-unchanged.txt" + +# 02 - the opt-in, client level and request level; and the JDK 25 failure +{ + echo "\$ java src/OptIn.java $URL client ($(v $JDK26))"; "$J26" src/OptIn.java $URL client 2>&1 + echo; echo "\$ java src/OptIn.java $URL request ($(v $JDK26))"; "$J26" src/OptIn.java $URL request 2>&1 + echo; echo "\$ java src/OptIn.java $URL client ($(v $JDK25))"; "$J25" src/OptIn.java $URL client 2>&1 | head -6 + echo; echo "\$ first request of a fresh client-level HTTP_3 client, 8 fresh JVMs ($(v $JDK26))" + for i in 1 2 3 4 5 6 7 8; do "$J26" src/OptIn.java $URL client 2>&1 | sed -n 1p; done +} > "$OUT/02-opt-in.txt" + +# 03 - the three discovery modes against a server that speaks HTTP/3 (h3 server still running) +{ echo "\$ java src/Discovery.java $URL (server: h1.1 + h2 on TCP, h3 on UDP, Alt-Svc advertised)"; "$J26" src/Discovery.java $URL 2>&1; } > "$OUT/03-discovery-modes.txt" +stop + +# 04 - fallback: servers that do not speak HTTP/3 at all +{ + start h2 + echo "\$ java src/Fallback.java $URL 120 (server: h2 + h1.1 on TCP only; nothing listens on UDP 4433)" + "$J26" src/Fallback.java $URL 120 2>&1 + echo; echo "\$ java -Djdk.httpclient.http3.maxDirectConnectionTimeout=1 src/Fallback.java $URL 120 (same server)" + "$J26" -Djdk.httpclient.http3.maxDirectConnectionTimeout=1 src/Fallback.java $URL 120 2>&1 + echo; echo "\$ java -Djdk.httpclient.quic.maxInitialTimeout=3 src/Fallback.java $URL 120 (same server)" + "$J26" -Djdk.httpclient.quic.maxInitialTimeout=3 src/Fallback.java $URL 120 2>&1 + stop; start h1 + echo; echo "\$ java src/Discovery.java $URL (server: HTTP/1.1 only, TCP)" + "$J26" src/Discovery.java $URL 2>&1 + stop +} > "$OUT/04-fallback.txt" + +# 05 - a firewall that drops all UDP to 4433 (common in offices): the server is h3-capable but unreachable over QUIC +start h3; scripts/loss.sh 100 udp +{ + echo "\$ iptables: DROP 100% of UDP to/from port 4433 (TCP untouched); server is the h3 server" + echo "\$ java src/Fallback.java $URL 120" + "$J26" src/Fallback.java $URL 120 2>&1 +} > "$OUT/05-udp-blocked.txt" +scripts/loss.sh off; stop + +# 06 - a proxy: HTTP/3 is not sent through it +start h3; python3 server/connect-proxy.py 4480 > "$OUT/.proxy.log" 2>&1 & PPID_=$!; sleep 1 +{ + echo "\$ java src/ViaProxy.java $URL 4480 (client.proxy(...) points at a CONNECT proxy on 4480)" + "$J26" src/ViaProxy.java $URL 4480 2>&1 + echo; echo "\$ proxy log"; cat "$OUT/.proxy.log" +} > "$OUT/06-proxy.txt" +kill $PPID_ 2>/dev/null; PPID_=""; rm -f "$OUT/.proxy.log"; stop + +fi +# 07 - HTTP/1.1 vs HTTP/2 vs HTTP/3 under random packet loss (same h3-capable server process for all three) +python3 server/serve.py h3 4433 2> "$OUT/.srv.err" >/dev/null & SPID=$!; sleep 2 +{ + echo "# loss = per-packet drop probability, applied in EACH direction, TCP and UDP port 4433, by iptables -m statistic on lo (MTU 1500)." + echo "# no added latency (loopback RTT is microseconds). $TRIALS trials per row after one discarded warm-up; $(v $JDK26)" + echo "# one = fresh client, 1 download of 1 MiB. many = fresh client, 10 parallel downloads of 100 KiB." + for L in 0 1 2 5 10; do + scripts/loss.sh $L + for w in one many; do for p in h1 h2 h3; do + timeout 600 "$J26" src/Bench.java $p $w $TRIALS $L 2>&1 + done; done + echo + done +} > "$OUT/07-packet-loss.txt" +scripts/loss.sh off; stop +{ + echo "# what the Hypercorn/aioquic server wrote to stderr during 07 (the loss run), exception lines only, counted" + grep -E "(Error|Exception)[:(]" "$OUT/.srv.err" | sed "s/^[ |+-]*//" | sort | uniq -c | sort -rn +} > "$OUT/08-server-errors.txt"; rm -f "$OUT/.srv.err" +echo "wrote $OUT" diff --git a/http3/scripts/loss.sh b/http3/scripts/loss.sh new file mode 100755 index 0000000..2ad25ce --- /dev/null +++ b/http3/scripts/loss.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# loss.sh P drop P percent (may be fractional, e.g. 2.5) of packets in each direction on lo, port 4433, TCP and UDP +# loss.sh P udp the same, UDP only (100 udp = a firewall that blocks QUIC) +# loss.sh off remove the rules and restore the loopback MTU +# Loopback MTU is 65536, so TCP would send 64 KiB segments while QUIC sends ~1.2 KB datagrams; a per-packet drop rate +# would then be wildly unfair. While loss is on we set the loopback MTU to 1500 so both protocols use Ethernet-sized packets. +# Kernel-level random drop (iptables -m statistic). This is NOT tc netem (netem was unavailable in the sandbox: +# "Specified qdisc kind is unknown"), so it adds loss but no delay. Needs root / CAP_NET_ADMIN. +set -euo pipefail +CH=H3LOSS +off() { ip link set dev lo mtu 65536; iptables -D INPUT -i lo -j $CH 2>/dev/null || true; iptables -F $CH 2>/dev/null || true; iptables -X $CH 2>/dev/null || true; } +off +[ "${1:?P or off}" = off ] && exit 0 +P=$(python3 -c "print(float('$1')/100)") +ip link set dev lo mtu 1500 +iptables -N $CH +for proto in ${2:-tcp udp}; do + for side in --dport --sport; do + iptables -A $CH -p $proto $side 4433 -m statistic --mode random --probability "$P" -j DROP + done +done +iptables -A INPUT -i lo -j $CH diff --git a/http3/server/app.py b/http3/server/app.py new file mode 100644 index 0000000..bb53ba0 --- /dev/null +++ b/http3/server/app.py @@ -0,0 +1,34 @@ +"""The test application. respond() is shared by the Hypercorn ASGI wrapper (HTTP/1.1, HTTP/2) and the aioquic +HTTP/3 server. It reports which HTTP version the request arrived on, returns N bytes for /bytes/N, and can redirect.""" +import os + +ALT_SVC = os.environ.get("ALT_SVC_PORT") # set by serve.py in h3 mode: advertise h3 on that UDP port + +def respond(path, version, host, port): + hdrs = [] + if ALT_SVC: + hdrs.append((b"alt-svc", ('h3=":%s"; ma=3600' % ALT_SVC).encode())) + if path.startswith("/bytes/"): + body = b"x" * int(path.rsplit("/", 1)[1]); ctype = b"application/octet-stream"; status = 200 + elif path == "/redirect": + hdrs.append((b"location", b"/hello")); return 302, hdrs, b"" + else: + body = ("hello from %s:%s via HTTP/%s\n" % (host, port, version)).encode(); ctype = b"text/plain"; status = 200 + hdrs += [(b"content-type", ctype), (b"content-length", str(len(body)).encode())] + return status, hdrs, body + +async def app(scope, receive, send): + if scope["type"] == "lifespan": + while True: + m = await receive() + if m["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif m["type"] == "lifespan.shutdown": + await send({"type": "lifespan.shutdown.complete"}); return + if scope["type"] != "http": + return + while (await receive()).get("more_body"): # drain the request body + pass + status, headers, body = respond(scope["path"], scope["http_version"], scope["server"][0], scope["server"][1]) + await send({"type": "http.response.start", "status": status, "headers": headers}) + await send({"type": "http.response.body", "body": body}) diff --git a/http3/server/connect-proxy.py b/http3/server/connect-proxy.py new file mode 100644 index 0000000..3a20563 --- /dev/null +++ b/http3/server/connect-proxy.py @@ -0,0 +1,25 @@ +"""A 25-line HTTP CONNECT proxy, just enough to show that the JDK will not send HTTP/3 through one.""" +import asyncio, sys + +async def pipe(r, w): + try: + while data := await r.read(65536): + w.write(data); await w.drain() + finally: + w.close() + +async def handle(cr, cw): + line = (await cr.readline()).decode() + while (await cr.readline()) not in (b"\r\n", b""): + pass + method, target, _ = line.split() + print("proxy saw:", method, target, flush=True) + host, port = target.rsplit(":", 1) + ur, uw = await asyncio.open_connection(host, int(port)) + cw.write(b"HTTP/1.1 200 Connection established\r\n\r\n"); await cw.drain() + await asyncio.gather(pipe(cr, uw), pipe(ur, cw)) + +async def main(): + srv = await asyncio.start_server(handle, "127.0.0.1", int(sys.argv[1])) + async with srv: await srv.serve_forever() +asyncio.run(main()) diff --git a/http3/server/h3server.py b/http3/server/h3server.py new file mode 100644 index 0000000..fb6c248 --- /dev/null +++ b/http3/server/h3server.py @@ -0,0 +1,46 @@ +"""Minimal HTTP/3 server on aioquic (the library Hypercorn uses for QUIC). It answers the same paths as app.py. +Hypercorn's own QUIC listener proved unreliable under packet loss (its UDPServer task dies on a KeyError and the +port then goes silent), so HTTP/3 is served from here and Hypercorn serves only TCP.""" +import asyncio, ssl +from aioquic.asyncio import QuicConnectionProtocol, serve as quic_serve +from aioquic.h3.connection import H3_ALPN, H3Connection +from aioquic.h3.events import HeadersReceived, DataReceived +from aioquic.quic.configuration import QuicConfiguration +from aioquic.quic.events import ProtocolNegotiated + +from app import respond # (path, http_version, host, port) -> (status, headers, body) + +class H3Protocol(QuicConnectionProtocol): + def __init__(self, *a, **kw): + super().__init__(*a, **kw) + self._http = None + self._pending = {} + + def quic_event_received(self, event): + if isinstance(event, ProtocolNegotiated) and event.alpn_protocol in H3_ALPN: + self._http = H3Connection(self._quic) + if self._http is None: + return + for ev in self._http.handle_event(event): + if isinstance(ev, HeadersReceived): + self._pending[ev.stream_id] = dict(ev.headers) + if ev.stream_ended: self._reply(ev.stream_id) + elif isinstance(ev, DataReceived) and ev.stream_ended: + self._reply(ev.stream_id) + + def _reply(self, stream_id): + h = self._pending.pop(stream_id, None) + if h is None: return + status, headers, body = respond(h[b":path"].decode(), "3", "127.0.0.1", self._port) + self._http.send_headers(stream_id, [(b":status", str(status).encode())] + headers) + self._http.send_data(stream_id, body, end_stream=True) + self.transmit() + + _port = 4433 + +async def start(port, certfile, keyfile): + H3Protocol._port = port + cfg = QuicConfiguration(is_client=False, alpn_protocols=H3_ALPN) + cfg.load_cert_chain(certfile, keyfile) + await quic_serve("127.0.0.1", port, configuration=cfg, create_protocol=H3Protocol) + await asyncio.Event().wait() diff --git a/http3/server/make-cert.sh b/http3/server/make-cert.sh new file mode 100755 index 0000000..486f259 --- /dev/null +++ b/http3/server/make-cert.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +# Self-signed certificate for localhost / 127.0.0.1, valid 30 days. Only the test server uses it. +set -euo pipefail +cd "$(dirname "$0")/.."; mkdir -p certs +openssl req -x509 -newkey rsa:2048 -nodes -days 30 -keyout certs/key.pem -out certs/cert.pem \ + -subj "/CN=localhost" -addext "subjectAltName=DNS:localhost,IP:127.0.0.1" 2>/dev/null +echo "wrote certs/cert.pem and certs/key.pem" diff --git a/http3/server/serve.py b/http3/server/serve.py new file mode 100644 index 0000000..e247e5e --- /dev/null +++ b/http3/server/serve.py @@ -0,0 +1,29 @@ +"""serve.py MODE [PORT] + h3 HTTP/1.1 + HTTP/2 on TCP (Hypercorn) and HTTP/3 on UDP (aioquic), same port number, Alt-Svc advertised + h2 TCP only, ALPN h2 + http/1.1 + h1 TCP only, ALPN http/1.1 +""" +import asyncio, os, sys +mode = sys.argv[1]; port = sys.argv[2] if len(sys.argv) > 2 else "4433" +if mode == "h3": os.environ["ALT_SVC_PORT"] = port +from hypercorn.asyncio import serve +from hypercorn.config import Config +from app import app + +root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +c = Config() +c.certfile, c.keyfile = f"{root}/certs/cert.pem", f"{root}/certs/key.pem" +c.bind = [f"127.0.0.1:{port}"] +c.loglevel = "WARNING" +if mode == "h1": + c.alpn_protocols = ["http/1.1"] +elif mode not in ("h2", "h3"): + sys.exit("mode must be h3, h2 or h1") + +async def main(): + tasks = [serve(app, c)] + if mode == "h3": + import h3server + tasks.append(h3server.start(int(port), c.certfile, c.keyfile)) + await asyncio.gather(*tasks) +asyncio.run(main()) diff --git a/http3/src/Basics.java b/http3/src/Basics.java new file mode 100644 index 0000000..3625cf2 --- /dev/null +++ b/http3/src/Basics.java @@ -0,0 +1,16 @@ +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +/** Plain Java 11 HttpClient code. No version is set. The same source file runs on JDK 11 to 27. */ +public class Basics { + public static void main(String[] args) throws Exception { + var client = Tls.builder().build(); // the only non-11 line is the self-signed trust + var request = HttpRequest.newBuilder(URI.create(args[0])).GET().build(); + for (int i = 1; i <= 3; i++) { + HttpResponse r = client.send(request, HttpResponse.BodyHandlers.ofString()); + System.out.println("request " + i + ": " + r.version() + " " + r.statusCode() + " alt-svc=" + r.headers().firstValue("alt-svc").orElse("-")); + } + } +} diff --git a/http3/src/Bench.java b/http3/src/Bench.java new file mode 100644 index 0000000..2cd9e0c --- /dev/null +++ b/http3/src/Bench.java @@ -0,0 +1,49 @@ +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpOption; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.CompletableFuture; + +/** + * Bench PROTO WORKLOAD TRIALS LOSS_LABEL + * PROTO h1 | h2 | h3 (h3 uses HTTP_3_URI_ONLY so a silent fallback cannot flatter the result) + * WORKLOAD one = fresh client, one 1 MiB download (handshake + one stream) + * many = fresh client, 10 parallel 100 KiB downloads (handshake + ten streams) + * Every response is checked: same HTTP version as asked for, exact body length. A trial that throws or + * exceeds 15 s is counted as failed. + */ +public class Bench { + public static void main(String[] a) throws Exception { + String proto = a[0], workload = a[1], loss = a[3]; + int trials = Integer.parseInt(a[2]); + var version = switch (proto) { case "h1" -> HttpClient.Version.HTTP_1_1; case "h2" -> HttpClient.Version.HTTP_2; default -> HttpClient.Version.HTTP_3; }; + int n = workload.equals("one") ? 1 : 10; + int size = workload.equals("one") ? 1_048_576 : 102_400; + List times = new ArrayList<>(); + int failed = 0; + String firstFailure = null; + for (int t = -1; t < trials; t++) { // trial -1 is a warm-up and is discarded + long t0 = System.nanoTime(); + try (var client = Tls.builder().version(version).build()) { + var rb = HttpRequest.newBuilder(URI.create("https://localhost:4433/bytes/" + size)).timeout(Duration.ofSeconds(15)); + if (version == HttpClient.Version.HTTP_3) rb.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY); + var req = rb.build(); + List>> fs = new ArrayList<>(); + for (int i = 0; i < n; i++) fs.add(client.sendAsync(req, HttpResponse.BodyHandlers.ofByteArray())); + for (var f : fs) { + var r = f.get(); + if (r.version() != version || r.body().length != size) throw new IllegalStateException(r.version() + " " + r.body().length); + } + if (t >= 0) times.add((System.nanoTime() - t0) / 1_000_000); + } catch (Exception e) { + if (t >= 0) { failed++; if (firstFailure == null) firstFailure = e.toString(); } + } + } + Collections.sort(times); + String stats = times.isEmpty() ? "n/a" : "median=%d p90=%d max=%d".formatted(times.get(times.size() / 2), times.get((int) Math.min(times.size() - 1, Math.ceil(times.size() * 0.9) - 1)), times.get(times.size() - 1)); + System.out.printf("%s %-4s loss=%-3s ok=%d failed=%d %s (ms)%s%n", proto, workload, loss, times.size(), failed, stats, firstFailure == null ? "" : " first failure: " + firstFailure); + } +} diff --git a/http3/src/Discovery.java b/http3/src/Discovery.java new file mode 100644 index 0000000..8f3069a --- /dev/null +++ b/http3/src/Discovery.java @@ -0,0 +1,28 @@ +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpOption.Http3DiscoveryMode; +import java.net.http.HttpOption; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +/** One fresh client, the three H3_DISCOVERY modes, four requests each. */ +public class Discovery { + public static void main(String[] args) throws Exception { + URI uri = URI.create(args[0]); + for (Http3DiscoveryMode mode : Http3DiscoveryMode.values()) { + var client = Tls.builder().version(HttpClient.Version.HTTP_3).build(); + var req = HttpRequest.newBuilder(uri).setOption(HttpOption.H3_DISCOVERY, mode).build(); + StringBuilder sb = new StringBuilder(); + for (int i = 1; i <= 4; i++) { + try { + var r = client.send(req, HttpResponse.BodyHandlers.ofString()); + sb.append(' ').append(r.version()); + } catch (Exception e) { + sb.append(' ').append(e.getClass().getSimpleName()).append('(').append(e.getMessage()).append(')'); + } + } + System.out.printf("%-16s ->%s%n", mode, sb); + client.close(); + } + } +} diff --git a/http3/src/Fallback.java b/http3/src/Fallback.java new file mode 100644 index 0000000..9e24268 --- /dev/null +++ b/http3/src/Fallback.java @@ -0,0 +1,29 @@ +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpOption; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** How long does the first request take, and which version answers, for each way of asking for HTTP/3? */ +public class Fallback { + public static void main(String[] args) throws Exception { + URI uri = URI.create(args[0]); + long limitSeconds = Long.parseLong(args[1]); + for (String mode : new String[] {"request HTTP_3 (ANY)", "client HTTP_3 (ANY)", "request HTTP_3_URI_ONLY"}) { + var cb = Tls.builder(); + if (mode.startsWith("client")) cb.version(HttpClient.Version.HTTP_3); + var rb = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(limitSeconds)); + if (!mode.startsWith("client")) rb.version(HttpClient.Version.HTTP_3); + if (mode.endsWith("URI_ONLY")) rb.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY); + long t0 = System.nanoTime(); + String result; + try (var client = cb.build()) { + result = client.send(rb.build(), HttpResponse.BodyHandlers.ofString()).version().toString(); + } catch (Exception e) { + result = e.getClass().getName() + ": " + e.getMessage(); + } + System.out.printf("%-26s -> %-70s after %5d ms%n", mode, result, (System.nanoTime() - t0) / 1_000_000); + } + } +} diff --git a/http3/src/OptIn.java b/http3/src/OptIn.java new file mode 100644 index 0000000..c81532f --- /dev/null +++ b/http3/src/OptIn.java @@ -0,0 +1,22 @@ +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +/** The JEP 517 opt-in, client-wide or per request. Prints the version every response really used. */ +public class OptIn { + public static void main(String[] args) throws Exception { + URI uri = URI.create(args[0]); + String where = args.length > 1 ? args[1] : "client"; // "client" or "request" + var builder = Tls.builder(); + if (where.equals("client")) builder.version(HttpClient.Version.HTTP_3); + var client = builder.build(); + var req = HttpRequest.newBuilder(uri); + if (where.equals("request")) req.version(HttpClient.Version.HTTP_3); + for (int i = 1; i <= 4; i++) { + var r = client.send(req.build(), HttpResponse.BodyHandlers.ofString()); + System.out.println(where + "-level HTTP_3, request " + i + ": " + r.version()); + } + client.close(); + } +} diff --git a/http3/src/Tls.java b/http3/src/Tls.java new file mode 100644 index 0000000..bcc8974 --- /dev/null +++ b/http3/src/Tls.java @@ -0,0 +1,26 @@ +import java.io.FileInputStream; +import java.net.http.HttpClient; +import java.security.KeyStore; +import java.security.cert.CertificateFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; + +/** Trusts only the self-signed test certificate in certs/cert.pem (made by server/make-cert.sh). */ +final class Tls { + static SSLContext context() throws Exception { + KeyStore ks = KeyStore.getInstance("PKCS12"); + ks.load(null); + try (var in = new FileInputStream("certs/cert.pem")) { + ks.setCertificateEntry("test", CertificateFactory.getInstance("X.509").generateCertificate(in)); + } + TrustManagerFactory tmf = TrustManagerFactory.getInstance("PKIX"); + tmf.init(ks); + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(null, tmf.getTrustManagers(), null); + return ctx; + } + + static HttpClient.Builder builder() throws Exception { + return HttpClient.newBuilder().sslContext(context()); + } +} diff --git a/http3/src/ViaProxy.java b/http3/src/ViaProxy.java new file mode 100644 index 0000000..4780d1d --- /dev/null +++ b/http3/src/ViaProxy.java @@ -0,0 +1,29 @@ +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpOption; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** The Javadoc says HTTP/3 through a proxy is not supported: ANY silently downgrades, HTTP_3_URI_ONLY fails. */ +public class ViaProxy { + public static void main(String[] args) throws Exception { + URI uri = URI.create(args[0]); + int proxyPort = Integer.parseInt(args[1]); + for (boolean uriOnly : new boolean[] {false, true}) { + var client = Tls.builder().version(HttpClient.Version.HTTP_3) + .proxy(ProxySelector.of(new InetSocketAddress("127.0.0.1", proxyPort))).build(); + var rb = HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(10)); + if (uriOnly) rb.setOption(HttpOption.H3_DISCOVERY, HttpOption.Http3DiscoveryMode.HTTP_3_URI_ONLY); + try { + var r = client.send(rb.build(), HttpResponse.BodyHandlers.ofString()); + System.out.println((uriOnly ? "HTTP_3_URI_ONLY" : "ANY ") + " via proxy -> " + r.version()); + } catch (Exception e) { + System.out.println((uriOnly ? "HTTP_3_URI_ONLY" : "ANY ") + " via proxy -> " + e.getClass().getName() + ": " + e.getMessage()); + } + client.close(); + } + } +}