feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10

Merged
Relism merged 23 commits from feature/core/http2 into master 2026-08-14 18:20:30 +00:00
28 changed files with 359 additions and 5656 deletions
Showing only changes of commit a0dda8e47a - Show all commits
+3 -20
View File
@@ -15,7 +15,6 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | | `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension | | `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
## Requirements ## Requirements
@@ -169,10 +168,11 @@ app.onException((ex, req, res) -> {
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) | | `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) |
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) | | `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
| `wsFrameBufferSize` | `65536` | Per-connection WebSocket read buffer (bytes) | | `wsFrameBufferSize` | `65536` | Per-connection WebSocket read buffer (bytes) |
| `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/http2/HTTP1-HARDENING.md). | | `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/core/HTTP1-HARDENING.md). |
| `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. | | `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. |
| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. | | `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. |
| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. | | `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. |
| `maxConnections` | auto (~heap/10MB) | Maximum concurrent connections across all listeners before new ones are closed immediately at accept time, before any per-connection state (TLS handshake included) is created. Auto-scales from `Runtime.maxMemory()`; set explicitly for a known deployment size, or `0` to disable. |
| `http2Enabled` | `false` | Whether TLS listeners advertise HTTP/2 through ALPN. | | `http2Enabled` | `false` | Whether TLS listeners advertise HTTP/2 through ALPN. |
| `http2CleartextEnabled` | `false` | Whether plaintext listeners accept HTTP/2 prior knowledge (h2c). Independent from TLS HTTP/2. | | `http2CleartextEnabled` | `false` | Whether plaintext listeners accept HTTP/2 prior knowledge (h2c). Independent from TLS HTTP/2. |
| `h2HuffmanDynamicValues` | `false` | HPACK-Huffman encode runtime response values. Constants remain pre-encoded; the measured default avoids an extra encode pass. | | `h2HuffmanDynamicValues` | `false` | HPACK-Huffman encode runtime response values. Constants remain pre-encoded; the measured default avoids an extra encode pass. |
@@ -310,7 +310,7 @@ upgrading `Request` — no separate TLS state is tracked for WS.
`Request` and `Response` are **pooled per connection**, not allocated per request: one instance is `Request` and `Response` are **pooled per connection**, not allocated per request: one instance is
created per connection and repositioned (`reset()`) over each new request/response in turn — the created per connection and repositioned (`reset()`) over each new request/response in turn — the
same idiom Java NIO buffers use, applied to the whole request/response model same idiom Java NIO buffers use, applied to the whole request/response model
(`flash/docs/http2/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1 (`flash/docs/core/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1
request/response cycle 0 B/op. request/response cycle 0 B/op.
**Do not retain a `Request` or `Response` past the handler that received it.** A reference kept in **Do not retain a `Request` or `Response` past the handler that received it.** A reference kept in
@@ -389,20 +389,6 @@ The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
future `flash-ext-grpc` extension. future `flash-ext-grpc` extension.
### HTTP/2 upstream proxy
The core includes a deliberately small, proxy-oriented HTTP/2 client and a protocol-neutral relay:
```java
Http2Client upstream = new Http2Client();
app.post("/service/{path}",
HttpProxy.toHttp2(URI.create("http://service.internal:8080"), upstream));
```
The relay preserves the path, query, body and trailers and applies one shared hop-by-hop field
policy for HTTP/1.1 and HTTP/2. Close the client when the application stops. Cleartext upstreams
use prior knowledge; Flash never implements the obsolete `Upgrade: h2c` mechanism.
## Architecture ## Architecture
``` ```
@@ -433,7 +419,4 @@ mvn test
# Run a single test class # Run a single test class
mvn test -pl flash -Dtest=RequestParserTest mvn test -pl flash -Dtest=RequestParserTest
# Run the benchmark demo server
java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar
``` ```
@@ -150,9 +150,9 @@ deleting a case that only test code could exercise. `Http1HeaderMap.view` has no
## `EX-19`/`EX-06` (router half): the `FastPathRouterImpl` scratch ## `EX-19`/`EX-06` (router half): the `FastPathRouterImpl` scratch
`FastPathRouterImpl.RouteScratch` (created once per connection via `AbstractRouter#newScratch`, `FastPathRouterImpl.RouteScratch` (created once per connection via `AbstractRouter#newScratch`,
replacing the `ThreadLocal<MatchResult>`/`ThreadLocal<MethodPathByteView>` pair — see replacing the `ThreadLocal<MatchResult>`/`ThreadLocal<MethodPathByteView>` pair, as an opaque
`DECISIONS.md`, `DEC-19`, for why this is an opaque caller-owned object rather than an extension caller-owned object rather than an extension of `ConnectionScratch`) also owns the reusable
of `ConnectionScratch`) also owns the reusable path-param arrays and a single long-lived path-param arrays and a single long-lived
`PathParams` instance, grown (via `ensureParamCapacity`, doubling) to the largest param count any `PathParams` instance, grown (via `ensureParamCapacity`, doubling) to the largest param count any
route on that connection has ever matched, and repositioned (`PathParams#reset`) rather than route on that connection has ever matched, and repositioned (`PathParams#reset`) rather than
reallocated on every match. `PathParams` gained a second, count-explicit constructor and a public reallocated on every match. `PathParams` gained a second, count-explicit constructor and a public
@@ -172,7 +172,6 @@ escapes, and mixed queries (`QueryParamsFastPathTest`).
## Performance measurement ## Performance measurement
`EX-04` (the router's word-at-a-time path) and `EX-33` (the SWAR header-end scan) both carry an `EX-04` (the router's word-at-a-time path) and `EX-33` (the SWAR header-end scan) both carried an
explicit "measure, and keep only if it doesn't cost" instruction in the plan. Both are measured explicit "measure, and keep only if it doesn't cost" requirement. Both were measured together
together with the phase's overall zero-allocation contract in one JMH pass — see `DECISIONS.md`, with the phase's overall zero-allocation contract in one JMH pass, and both were kept.
`DEC-20`, for the numbers and the keep/revert decision for each.
@@ -135,9 +135,9 @@ back down between requests. Both checks throw `IllegalStateException`, not
`HeaderMap` split into `HeaderView` (the protocol-neutral read contract: `first`, `all`, `view`, `HeaderMap` split into `HeaderView` (the protocol-neutral read contract: `first`, `all`, `view`,
`valueEqualsIgnoreCase`, `contains`, `count`, `forEach`) and `Http1HeaderMap` (the existing `valueEqualsIgnoreCase`, `contains`, `count`, `forEach`) and `Http1HeaderMap` (the existing
byte-buffer-backed implementation, kept in `dev.relism.flash.models` rather than moved to byte-buffer-backed implementation, kept in `dev.relism.flash.models` rather than moved to
`dev.relism.flash.http1` — see `DECISIONS.md`, `DEC-22`, for why: `RequestParser` (root package) `dev.relism.flash.http1`: `RequestParser` (root package) owns and constructs it, and
owns and constructs it, and `http1`→root already exists via `Http1Connection`, so moving it to `http1`→root already exists via `Http1Connection`, so moving it to `http1` would create a
`http1` would create a `models``http1` package cycle). `RequestLine.headers` is typed as the `models``http1` package cycle). `RequestLine.headers` is typed as the
interface; `Http2HeaderMap` is the HPACK-backed second implementation without requiring a interface; `Http2HeaderMap` is the HPACK-backed second implementation without requiring a
`Request` or `RequestLine` API split. `Request` or `RequestLine` API split.
@@ -178,7 +178,7 @@ call — pre-existing since at least Phase 4, invisible until the larger `Reques
`RequestLine` cost sitting on top of them was removed. Fixed the same way as everything else in `RequestLine` cost sitting on top of them was removed. Fixed the same way as everything else in
this document: `RequestByteView` gained a `reset(byte[], int, int)`; `RequestParser` now owns one this document: `RequestByteView` gained a `reset(byte[], int, int)`; `RequestParser` now owns one
pooled instance per role. `parseAndRoute` measures 0.008 B/op after the fix — JMH's noise floor, pooled instance per role. `parseAndRoute` measures 0.008 B/op after the fix — JMH's noise floor,
effectively 0. Full numbers in `DECISIONS.md`, `DEC-23`. effectively 0.
## The zero-alloc contract, closed ## The zero-alloc contract, closed
@@ -188,6 +188,4 @@ effectively 0. Full numbers in `DECISIONS.md`, `DEC-23`.
`RequestPipelineBenchmark.parseAndRoute` (parse + route with a parametric match, no header/param `RequestPipelineBenchmark.parseAndRoute` (parse + route with a parametric match, no header/param
access) measures 0 B/op. `parseRouteAndExtractThreeFields` (the same, plus one path param and two access) measures 0 B/op. `parseRouteAndExtractThreeFields` (the same, plus one path param and two
header reads) measures 184.009 B/op — entirely the `String` allocations the contract's own text header reads) measures 184.009 B/op — entirely the `String` allocations the contract's own text
exempts ("except for the user-facing `String`s the handler explicitly asks for"). See exempts ("except for the user-facing `String`s the handler explicitly asks for").
`DECISIONS.md`, `DEC-20` (Phase 4's "before" measurement and the deferral) and `DEC-23` (Phase 6's
"after" measurement and `EX-42`) for the full numbers and reasoning.
+10
View File
@@ -0,0 +1,10 @@
# Flash core
The parts of Flash shared by every protocol it speaks — HTTP/1.1 and HTTP/2 alike. Protocol-specific
internals (frames, HPACK, stream state) live in [`../http2/`](../http2/README.md).
- [HTTP/1.1 hardening](HTTP1-HARDENING.md) — message-boundary rules, timeouts and negotiation.
- [Transport](TRANSPORT.md) — listeners, connection ownership, TLS and virtual threads.
- [Message model](MESSAGE-MODEL.md) — shared request/response objects and their lifetime contract.
- [Trailers and streaming](TRAILERS-AND-STREAMING.md) — the public cross-protocol APIs.
- [Byte primitives](BYTES.md) — reusable views, scanning and bounded slice lifetimes.
@@ -1,4 +1,4 @@
# HTTP/2 cleartext and proxying # HTTP/2 cleartext
TLS HTTP/2 and cleartext HTTP/2 have independent rollout controls: TLS HTTP/2 and cleartext HTTP/2 have independent rollout controls:
@@ -8,18 +8,6 @@ TLS HTTP/2 and cleartext HTTP/2 have independent rollout controls:
Both default to `false`. Cleartext support follows RFC 9113 prior knowledge. The obsolete Both default to `false`. Cleartext support follows RFC 9113 prior knowledge. The obsolete
HTTP/1.1 `Upgrade: h2c` transition is intentionally unsupported. HTTP/1.1 `Upgrade: h2c` transition is intentionally unsupported.
## Upstream client
`Http2Client` is a synchronous, pooled client for reverse-proxy handlers. It supports TLS ALPN and
h2c prior knowledge, request and response bodies, flow control, response status, trailers,
SETTINGS, PING, GOAWAY and RST_STREAM. Connections are pooled by origin and reused across
sequential exchanges. A connection serializes its exchanges deliberately; this keeps ownership
and HPACK state explicit and bounded while virtual threads allow independent origins to progress.
It is not intended to replace a general-purpose HTTP client.
`HttpProxy.toHttp2(origin, client)` adapts Flash's shared `Request` and `Response` models to that
client. It preserves the incoming raw path and query, body, end-to-end fields and trailers.
## Header conversion ## Header conversion
`HopByHopHeaders` is the single policy used at connection boundaries. It removes fields named by `HopByHopHeaders` is the single policy used at connection boundaries. It removes fields named by
@@ -34,9 +22,8 @@ subject alternative names. An authority outside that served set receives `421 Mi
Request`, allowing a coalescing client to retry on a different connection. Exact names and Request`, allowing a coalescing client to retry on a different connection. Exact names and
single-label wildcards are supported; h2c has no certificate identity and is unaffected. single-label wildcards are supported; h2c has no certificate identity and is unaffected.
## Trailer guarantee An outbound HTTP/2 client and reverse-proxy adapter (`Http2Client`, `HttpProxy`) were built
against this cleartext support but had no caller anywhere in `flash` core — an HTTP/1.1+2 server
The proxy copies request trailers only after the incoming body reaches EOF and emits upstream framework has no business shipping an outbound client. That code has been removed; if a
trailers as a trailing HEADERS block. Response trailers follow the reverse path and remain reverse-proxy capability is needed later, it belongs in its own `flash-extensions/flash-ext-*`
trailers on both HTTP/2 and HTTP/1.1 chunked downstream connections. The live relay tests cover module, not in core.
both downstream protocols.
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -131,8 +131,7 @@ needing.
`BufferedByteSource`'s deadline mechanism (`EX-07`'s actual fix) turned out to have zero dedicated `BufferedByteSource`'s deadline mechanism (`EX-07`'s actual fix) turned out to have zero dedicated
unit tests and an unconditional `socket.setSoTimeout(...)` call that NPE'd against the `null` unit tests and an unconditional `socket.setSoTimeout(...)` call that NPE'd against the `null`
socket every isolated unit test in this codebase uses. Found while writing socket every isolated unit test in this codebase uses. Found while writing
`Http2FrameReaderTest`, fixed, and given its own regression suite (`BufferedByteSourceTest`) `Http2FrameReaderTest`, fixed, and given its own regression suite (`BufferedByteSourceTest`).
full writeup in the plan's registry, `EX-37`.
## Testing ## Testing
File diff suppressed because it is too large Load Diff
+5 -13
View File
@@ -18,20 +18,18 @@ listener / TLS
<- HTTP/2 stream writer ----+ <- HTTP/2 stream writer ----+
``` ```
## Start here This page covers the HTTP/2-specific layers only. The transport, message model, and byte
primitives shared with HTTP/1.1 live in [`../core/`](../core/README.md).
## Protocol layers
- [HTTP/1.1 hardening](HTTP1-HARDENING.md) — message-boundary rules, timeouts and negotiation.
- [Transport](TRANSPORT.md) — listeners, connection ownership, TLS and virtual threads.
- [Message model](MESSAGE-MODEL.md) — shared request/response objects and their lifetime contract.
- [Connection](CONNECTION.md) and [streams](STREAMS.md) — HTTP/2 connection and stream state. - [Connection](CONNECTION.md) and [streams](STREAMS.md) — HTTP/2 connection and stream state.
- [Flow control](FLOW-CONTROL.md) — request backpressure and streamed responses. - [Flow control](FLOW-CONTROL.md) — request backpressure and streamed responses.
- [Trailers and streaming](TRAILERS-AND-STREAMING.md) — the public cross-protocol APIs. - [Cleartext](CLEARTEXT.md) — prior knowledge and the 421 misdirected-request rule.
- [Cleartext and proxying](CLEARTEXT-AND-PROXY.md) — prior knowledge and the upstream h2 client.
- [WebSockets](WEBSOCKET.md) — RFC 8441 extended CONNECT using the existing WebSocket API. - [WebSockets](WEBSOCKET.md) — RFC 8441 extended CONNECT using the existing WebSocket API.
## Wire internals ## Wire internals
- [Byte primitives](BYTES.md) — reusable views, scanning and bounded slice lifetimes.
- [Serialized writer](WRITER.md) — the single-owner output path and contention model. - [Serialized writer](WRITER.md) — the single-owner output path and contention model.
- [Frames](FRAMES.md) — frame parsing, validation and error scope. - [Frames](FRAMES.md) — frame parsing, validation and error scope.
- [HPACK](HPACK.md) — integer/Huffman coding and static/dynamic table ownership. - [HPACK](HPACK.md) — integer/Huffman coding and static/dynamic table ownership.
@@ -43,9 +41,3 @@ listener / TLS
- [Compliance](COMPLIANCE.md) — h2spec, interoperability, fuzzing and deliberate omissions. - [Compliance](COMPLIANCE.md) — h2spec, interoperability, fuzzing and deliberate omissions.
- [Performance](PERFORMANCE.md) and [CI baselines](BASELINES.md) — measurements and regression - [Performance](PERFORMANCE.md) and [CI baselines](BASELINES.md) — measurements and regression
gates, including the comparison with nghttpd. gates, including the comparison with nghttpd.
## Design history
[Decisions](DECISIONS.md) records non-obvious trade-offs and rejected alternatives. The
implementation plan is retained as historical engineering evidence; it is not required to use or
extend the runtime.
+3 -3
View File
@@ -137,8 +137,8 @@ from the path this document's gate criteria are strictest about.
## Benchmark methodology ## Benchmark methodology
`flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java` (a JMH source root `flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java` (a JMH source root
registered only under the `jmh` Maven profile — see `DECISIONS.md`, `DEC-17`, for why it does not registered only under the `jmh` Maven profile, not `src/test/java`) compares four harnesses at
live in `src/test/java`) compares four harnesses at `threads` ∈ {1, 2, 4, 8, 16, 64}: `threads` ∈ {1, 2, 4, 8, 16, 64}:
- `trylock_mpsc` — the shipped `Http2FrameWriter` design. - `trylock_mpsc` — the shipped `Http2FrameWriter` design.
- `plain_lock` — every write blocks on `ReentrantLock.lock()` unconditionally (candidate (a)). - `plain_lock` — every write blocks on `ReentrantLock.lock()` unconditionally (candidate (a)).
@@ -258,7 +258,7 @@ design's exclusive use of `ReentrantLock` (never `synchronized`) on every path t
| 4 | Stress test green at every N, 1000 iterations, incl. parallelism=1 | 10 000/10 000 | **PASS** | | 4 | Stress test green at every N, 1000 iterations, incl. parallelism=1 | 10 000/10 000 | **PASS** |
**All four gate criteria are met.** `Http2FrameWriter` ships as designed: a `tryLock()` fast path **All four gate criteria are met.** `Http2FrameWriter` ships as designed: a `tryLock()` fast path
with an intrusive MPSC fallback. See `DECISIONS.md` for the retained alternatives and evidence. with an intrusive MPSC fallback.
## What this design costs vs. what it saves ## What this design costs vs. what it saves
@@ -0,0 +1,69 @@
package dev.relism.flash.bench;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import java.net.ServerSocket;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Real-server, real-network throughput and latency benchmark: boots one live Flash server on
* loopback exposing {@code GET /hello}, then drives it end to end real sockets, real accept
* loop, real routing and response serialization with independent HTTP clients across protocols
* and concurrency levels. This is not a component-scoped JMH microbenchmark; it is the same shape
* of measurement a tool like {@code h2load} or {@code wrk} gives any other server.
*
* <p>Never wired into the build or CI run manually with: {@code mvn -pl flash -Pbench
* exec:java}. Override scenario length with {@code -Dflash.bench.warmupSeconds} / {@code
* -Dflash.bench.measureSeconds} (defaults: 2 / 5).
*/
public final class BenchmarkMain {
private static final int[] CONCURRENCY_LEVELS = {1, 8, 32, 128};
public static void main(String[] args) throws Exception {
Duration warmup = seconds("flash.bench.warmupSeconds", 2);
Duration measurement = seconds("flash.bench.measureSeconds", 5);
int port = freePort();
FlashApp app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.get("/hello", (request, response) -> "hello");
app.start();
try {
URI target = URI.create("http://127.0.0.1:" + port + "/hello");
Report.print(runAllScenarios(target, warmup, measurement));
} finally {
app.stop().join();
}
}
private static List<LoadResult> runAllScenarios(URI target, Duration warmup, Duration measurement)
throws InterruptedException {
List<LoadResult> results = new ArrayList<>();
for (int concurrency : CONCURRENCY_LEVELS) {
results.add(
new Http1Driver()
.run("http/1.1 c=" + concurrency, target, concurrency, warmup, measurement));
}
return results;
}
private static Duration seconds(String property, int fallback) {
return Duration.ofSeconds(Long.getLong(property, fallback));
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -0,0 +1,37 @@
package dev.relism.flash.bench;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* HTTP/1.1 keep-alive load driver backed by the JDK's own {@link HttpClient} an independent
* client implementation, not Flash's own code, measuring the server end to end.
*/
final class Http1Driver implements LoadDriver {
@Override
public LoadResult run(
String scenarioLabel, URI target, int concurrency, Duration warmup, Duration measurement)
throws InterruptedException {
HttpRequest request = HttpRequest.newBuilder(target).timeout(Duration.ofSeconds(5)).GET().build();
return LoadRunner.execute(
scenarioLabel,
concurrency,
warmup,
measurement,
() -> {
// One HttpClient per worker: its own connection pool, reused keep-alive across requests.
HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
return () -> {
HttpResponse<Void> response =
client.send(request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() != 200) {
throw new IllegalStateException("status " + response.statusCode());
}
};
});
}
}
@@ -0,0 +1,22 @@
package dev.relism.flash.bench;
import java.util.Arrays;
/** One worker's latency samples, in nanoseconds. Grows without boxing on the request loop. */
final class LatencyRecorder {
private long[] samples = new long[1024];
private int count;
void record(long nanos) {
if (count == samples.length) samples = Arrays.copyOf(samples, samples.length * 2);
samples[count++] = nanos;
}
int count() {
return count;
}
long[] toArray() {
return Arrays.copyOf(samples, count);
}
}
@@ -0,0 +1,11 @@
package dev.relism.flash.bench;
import java.net.URI;
import java.time.Duration;
/** Runs one scenario (a protocol at a fixed concurrency) against a live target and returns its stats. */
interface LoadDriver {
LoadResult run(
String scenarioLabel, URI target, int concurrency, Duration warmup, Duration measurement)
throws InterruptedException;
}
@@ -0,0 +1,17 @@
package dev.relism.flash.bench;
/** One scenario's outcome: throughput and latency distribution over the measured phase only. */
record LoadResult(
String scenario,
long requests,
long errors,
double seconds,
double meanLatencyMicros,
double p50Micros,
double p99Micros,
double p999Micros) {
double requestsPerSecond() {
return seconds == 0 ? 0 : requests / seconds;
}
}
@@ -0,0 +1,69 @@
package dev.relism.flash.bench;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.LongAdder;
/**
* Drives a fixed number of concurrent virtual-thread workers against one {@link WorkerFactory},
* each worker looping its own {@link WorkUnit#request()} until a wall-clock deadline. A discarded
* warmup phase runs first so JIT warmup and connection setup don't skew the measured phase.
*/
final class LoadRunner {
private LoadRunner() {}
static LoadResult execute(
String scenarioLabel,
int concurrency,
Duration warmup,
Duration measurement,
WorkerFactory factory)
throws InterruptedException {
runUntil(concurrency, System.nanoTime() + warmup.toNanos(), factory, null, null);
LongAdder errors = new LongAdder();
List<LatencyRecorder> perWorker = new ArrayList<>(concurrency);
for (int i = 0; i < concurrency; i++) perWorker.add(new LatencyRecorder());
long measureStart = System.nanoTime();
runUntil(concurrency, measureStart + measurement.toNanos(), factory, errors, perWorker);
return Stats.summarize(scenarioLabel, perWorker, errors.sum(), System.nanoTime() - measureStart);
}
private static void runUntil(
int concurrency,
long deadlineNanos,
WorkerFactory factory,
LongAdder errors,
List<LatencyRecorder> perWorker)
throws InterruptedException {
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < concurrency; i++) {
LatencyRecorder recorder = perWorker == null ? null : perWorker.get(i);
pool.execute(() -> worker(deadlineNanos, factory, errors, recorder));
}
}
}
private static void worker(
long deadlineNanos, WorkerFactory factory, LongAdder errors, LatencyRecorder recorder) {
try (WorkUnit unit = factory.create()) {
while (System.nanoTime() < deadlineNanos) {
long start = System.nanoTime();
try {
unit.request();
if (recorder != null) recorder.record(System.nanoTime() - start);
} catch (Exception requestFailure) {
if (errors != null) errors.increment();
}
}
} catch (Exception setupFailure) {
if (errors != null) errors.increment();
}
}
}
@@ -0,0 +1,27 @@
package dev.relism.flash.bench;
import java.util.List;
/** Prints results as a fixed-width table on stdout — no file output, this is a manual tool. */
final class Report {
private Report() {}
static void print(List<LoadResult> results) {
System.out.printf(
"%-16s %10s %8s %12s %10s %10s %10s %10s%n",
"scenario", "requests", "errors", "req/s", "mean(us)", "p50(us)", "p99(us)", "p999(us)");
for (LoadResult result : results) {
System.out.printf(
"%-16s %10d %8d %12.1f %10.1f %10.1f %10.1f %10.1f%n",
result.scenario(),
result.requests(),
result.errors(),
result.requestsPerSecond(),
result.meanLatencyMicros(),
result.p50Micros(),
result.p99Micros(),
result.p999Micros());
}
}
}
@@ -0,0 +1,52 @@
package dev.relism.flash.bench;
import java.util.Arrays;
import java.util.List;
/** Merges every worker's samples and reduces them to one {@link LoadResult}. */
final class Stats {
private Stats() {}
static LoadResult summarize(
String scenarioLabel, List<LatencyRecorder> perWorker, long errors, long elapsedNanos) {
int total = 0;
for (LatencyRecorder recorder : perWorker) total += recorder.count();
long[] merged = new long[total];
int offset = 0;
for (LatencyRecorder recorder : perWorker) {
long[] samples = recorder.toArray();
System.arraycopy(samples, 0, merged, offset, samples.length);
offset += samples.length;
}
Arrays.sort(merged);
return new LoadResult(
scenarioLabel,
merged.length,
errors,
elapsedNanos / 1_000_000_000.0,
microsOf(mean(merged)),
microsOf(percentile(merged, 0.50)),
microsOf(percentile(merged, 0.99)),
microsOf(percentile(merged, 0.999)));
}
private static double mean(long[] sorted) {
if (sorted.length == 0) return 0;
long sum = 0;
for (long value : sorted) sum += value;
return (double) sum / sorted.length;
}
private static long percentile(long[] sorted, double fraction) {
if (sorted.length == 0) return 0;
int index = (int) Math.min(sorted.length - 1, Math.floor(fraction * sorted.length));
return sorted[index];
}
private static double microsOf(double nanos) {
return nanos / 1000.0;
}
}
@@ -0,0 +1,9 @@
package dev.relism.flash.bench;
/** One worker's request loop body. {@link #close()} releases whatever {@link WorkerFactory} opened. */
interface WorkUnit extends AutoCloseable {
void request() throws Exception;
@Override
default void close() throws Exception {}
}
@@ -0,0 +1,7 @@
package dev.relism.flash.bench;
/** Builds one worker's {@link WorkUnit} — its own connection/client, isolated per virtual thread. */
@FunctionalInterface
interface WorkerFactory {
WorkUnit create() throws Exception;
}
@@ -1,92 +0,0 @@
package dev.relism.flash.http.proxy;
import dev.relism.flash.http.HopByHopHeaders;
import dev.relism.flash.http.HopByHopHeaders.Protocol;
import dev.relism.flash.http2.client.Http2Client;
import dev.relism.flash.http2.client.Http2ClientResponse;
import dev.relism.flash.models.HeaderView;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.fpr.core.ByteView;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
/** Protocol-neutral reverse-proxy adapter backed by Flash's HTTP/2 upstream client. */
public final class HttpProxy {
private HttpProxy() {}
/** Creates a handler that preserves the incoming path, query, fields, body and trailers. */
public static SimpleHandler.FunctionalHandler toHttp2(URI upstreamOrigin, Http2Client client) {
Objects.requireNonNull(upstreamOrigin, "upstreamOrigin");
Objects.requireNonNull(client, "client");
return (request, response) -> relay(upstreamOrigin, client, request, response);
}
private static Response relay(
URI upstreamOrigin, Http2Client client, Request request, Response response) throws Exception {
byte[] body = request.body().bytes();
Protocol downstream =
request.getRequestLine().getProtocol() == null ? Protocol.HTTP_2 : Protocol.HTTP_1_1;
URI target = upstreamOrigin.resolve(rawTarget(request));
Http2ClientResponse upstream =
client.exchange(
target,
request.method(),
request.getRequestLine().getHeaders(),
body,
request.trailers());
response.status(upstream.statusCode()).body(upstream.body());
copyHeaders(upstream.headers(), Protocol.HTTP_2, downstream, response, false);
copyHeaders(upstream.trailers(), Protocol.HTTP_2, downstream, response, true);
return response;
}
private static String rawTarget(Request request) {
String path = request.path();
ByteView query = request.getRequestLine().getQuery();
if (query == null || query.length() == 0) return path;
byte[] bytes = new byte[query.length()];
for (int i = 0; i < bytes.length; i++) bytes[i] = query.byteAt(i);
return path + "?" + new String(bytes, StandardCharsets.US_ASCII);
}
private static void copyHeaders(
HeaderView source,
Protocol sourceProtocol,
Protocol targetProtocol,
Response response,
boolean trailers) {
source.forEach(
(name, value) -> {
if (!HopByHopHeaders.shouldForward(
source, name, value, sourceProtocol, targetProtocol)) return;
if (!trailers && (equalsAscii(name, "content-length") || equalsAscii(name, "content-type"))) {
if (equalsAscii(name, "content-type")) response.type(string(value));
return;
}
if (trailers) response.trailer(string(name), string(value));
else response.header(string(name), string(value));
});
}
private static String string(ByteView value) {
byte[] bytes = new byte[value.length()];
for (int i = 0; i < bytes.length; i++) bytes[i] = value.byteAt(i);
return new String(bytes, StandardCharsets.UTF_8);
}
private static boolean equalsAscii(ByteView bytes, String value) {
if (bytes.length() != value.length()) return false;
for (int i = 0; i < bytes.length(); i++) {
int left = bytes.byteAt(i) & 0xff;
int right = value.charAt(i);
if (left >= 'A' && left <= 'Z') left += 'a' - 'A';
if (right >= 'A' && right <= 'Z') right += 'a' - 'A';
if (left != right) return false;
}
return true;
}
}
@@ -1,627 +0,0 @@
package dev.relism.flash.http2.client;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.http.HopByHopHeaders;
import dev.relism.flash.http.HopByHopHeaders.Protocol;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.flash.http2.Http2Preface;
import dev.relism.flash.http2.Http2Settings;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameHeader;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameWriteBuffer;
import dev.relism.flash.http2.frame.Http2FrameReader;
import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.http2.frame.Padding;
import dev.relism.flash.http2.frame.WriteIntent;
import dev.relism.flash.http2.hpack.ContinuationAssembler;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.http2.hpack.HpackEncoder;
import dev.relism.flash.models.EmptyHeaderView;
import dev.relism.flash.models.HeaderView;
import dev.relism.flash.models.MutableHeaderMap;
import dev.relism.flash.transport.BufferedByteSource;
import dev.relism.fpr.core.ByteView;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
/**
* Small pooled HTTP/2 client for Flash proxy handlers. It intentionally exposes synchronous
* request/response exchange rather than trying to be a general-purpose client API.
*/
public final class Http2Client implements Closeable {
private static final int CONNECT_TIMEOUT_MS = 10_000;
private static final int MAX_RESPONSE_BODY_SIZE = Http2Limits.MAX_REQUEST_BODY_SIZE;
private final ConcurrentHashMap<Origin, Connection> connections = new ConcurrentHashMap<>();
private final SSLContext sslContext;
public Http2Client() {
this(null);
}
public Http2Client(SSLContext sslContext) {
this.sslContext = sslContext;
}
public Http2ClientResponse get(URI uri) throws IOException {
return exchange(
uri,
HttpMethod.GET,
EmptyHeaderView.INSTANCE,
new byte[0],
EmptyHeaderView.INSTANCE);
}
public Http2ClientResponse exchange(
URI uri, HttpMethod method, HeaderView headers, byte[] body, HeaderView trailers)
throws IOException {
Objects.requireNonNull(uri, "uri");
Objects.requireNonNull(method, "method");
Objects.requireNonNull(headers, "headers");
Objects.requireNonNull(body, "body");
Objects.requireNonNull(trailers, "trailers");
Origin origin = Origin.from(uri);
Connection connection;
try {
connection = connections.computeIfAbsent(origin, this::openUnchecked);
} catch (OpenFailure failure) {
throw failure.io;
}
try {
return connection.exchange(uri, method, headers, body, trailers);
} catch (IOException | RuntimeException failure) {
connections.remove(origin, connection);
connection.close();
throw failure;
}
}
@Override
public void close() {
for (Connection connection : connections.values()) connection.close();
connections.clear();
}
/** Number of currently pooled origin connections. */
public int pooledConnectionCount() {
return connections.size();
}
private Connection openUnchecked(Origin origin) {
try {
return new Connection(origin, sslContext);
} catch (IOException failure) {
throw new OpenFailure(failure);
}
}
private static final class Connection implements Closeable {
private final Socket socket;
private final OutputStream output;
private final Http2FrameReader reader;
private final Http2FrameWriter writer;
private final Http2Settings peerSettings = new Http2Settings();
private final HpackDecoder decoder = new HpackDecoder();
private final ContinuationAssembler headers = new ContinuationAssembler();
private final ByteWriter outgoing = new ByteWriter(16 * 1024);
private final FrameWriteBuffer frames = new FrameWriteBuffer(outgoing);
private final BufferIntent intent = new BufferIntent();
private int nextStreamId = 1;
private int connectionSendWindow = Http2Settings.DEFAULT_INITIAL_WINDOW_SIZE;
private int streamSendWindow;
private boolean headerEndStream;
private boolean closed;
Connection(Origin origin, SSLContext sslContext) throws IOException {
socket = connect(origin, sslContext);
output = socket.getOutputStream();
reader =
new Http2FrameReader(new BufferedByteSource(socket.getInputStream(), socket));
writer = new Http2FrameWriter(output::write);
writePreface();
awaitServerSettings();
}
synchronized Http2ClientResponse exchange(
URI uri, HttpMethod method, HeaderView requestHeaders, byte[] body, HeaderView trailers)
throws IOException {
if (closed) throw new IOException("HTTP/2 connection is closed");
if (nextStreamId <= 0) throw new IOException("HTTP/2 stream id space exhausted");
int streamId = nextStreamId;
nextStreamId += 2;
streamSendWindow = peerSettings.initialWindowSize();
Exchange exchange = new Exchange(streamId);
writeRequestHeaders(uri, method, requestHeaders, body.length == 0 && trailers.count() == 0,
streamId);
if (body.length != 0) writeRequestBody(exchange, body, trailers.count() == 0);
if (trailers.count() != 0) writeRequestTrailers(trailers, streamId);
while (!exchange.complete) readFrame(exchange);
return exchange.response();
}
private void writePreface() throws IOException {
output.write(Http2Preface.clientPreface());
outgoing.reset();
frames.beginFrame(FrameType.SETTINGS, 0, 0);
outgoing.writeUInt16(Http2Settings.ENABLE_PUSH);
outgoing.writeUInt32(0);
outgoing.writeUInt16(Http2Settings.INITIAL_WINDOW_SIZE);
outgoing.writeUInt32(Http2Limits.INITIAL_WINDOW_SIZE_LOCAL);
frames.endFrame();
frames.beginFrame(FrameType.WINDOW_UPDATE, 0, 0);
outgoing.writeUInt31(
Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL - Http2Settings.DEFAULT_INITIAL_WINDOW_SIZE);
frames.endFrame();
writeOutgoing();
}
private void awaitServerSettings() throws IOException {
boolean received = false;
while (!received) {
FrameHeader frame = reader.readFrame();
if (frame == null) throw new IOException("server closed before SETTINGS");
try {
if (frame.type() == FrameType.SETTINGS && !FrameFlags.isAck(frame.flags())) {
applySettings(frame);
sendEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0);
received = true;
} else if (frame.type() == FrameType.WINDOW_UPDATE) {
applyWindowUpdate(frame, 0);
} else if (frame.type() == FrameType.GOAWAY) {
throw new IOException("server sent GOAWAY during HTTP/2 setup");
}
} finally {
reader.consumeFrame();
}
}
}
private void writeRequestHeaders(
URI uri, HttpMethod method, HeaderView source, boolean endStream, int streamId)
throws IOException {
outgoing.reset();
frames.beginFrame(
FrameType.HEADERS,
FrameFlags.END_HEADERS | (endStream ? FrameFlags.END_STREAM : 0),
streamId);
writeMethod(method);
HpackEncoder.writeIndexed(outgoing, "https".equalsIgnoreCase(uri.getScheme()) ? 7 : 6);
writeAuthority(uri);
writePath(uri);
source.forEach(
(name, value) -> {
if (HopByHopHeaders.shouldForward(
source, name, value, Protocol.HTTP_1_1, Protocol.HTTP_2)
&& !equalsAscii(name, "host")) {
HpackEncoder.writeLiteral(outgoing, name, value);
}
});
frames.endFrame();
writeOutgoing();
}
private void writeRequestBody(Exchange exchange, byte[] body, boolean endStream)
throws IOException {
int offset = 0;
while (offset < body.length) {
while (connectionSendWindow <= 0 || streamSendWindow <= 0) readFrame(exchange);
int count =
Math.min(
body.length - offset,
Math.min(
peerSettings.maxFrameSize(),
Math.min(connectionSendWindow, streamSendWindow)));
outgoing.reset();
frames.beginFrame(
FrameType.DATA,
endStream && offset + count == body.length ? FrameFlags.END_STREAM : 0,
exchange.streamId);
outgoing.writeBytes(body, offset, count);
frames.endFrame();
writeOutgoing();
connectionSendWindow -= count;
streamSendWindow -= count;
offset += count;
}
}
private void writeRequestTrailers(HeaderView trailers, int streamId) throws IOException {
outgoing.reset();
frames.beginFrame(
FrameType.HEADERS, FrameFlags.END_HEADERS | FrameFlags.END_STREAM, streamId);
trailers.forEach(
(name, value) -> {
if (HopByHopHeaders.shouldForward(
trailers, name, value, Protocol.HTTP_1_1, Protocol.HTTP_2)) {
HpackEncoder.writeLiteral(outgoing, name, value);
}
});
frames.endFrame();
writeOutgoing();
}
private void readFrame(Exchange exchange) throws IOException {
FrameHeader frame = reader.readFrame();
if (frame == null) throw new IOException("server closed an active HTTP/2 exchange");
try {
FrameType type = frame.type();
if (type == null) return;
switch (type) {
case SETTINGS -> {
if (!FrameFlags.isAck(frame.flags())) {
applySettings(frame);
sendEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0);
}
}
case WINDOW_UPDATE -> applyWindowUpdate(frame, exchange.streamId);
case PING -> {
if (!FrameFlags.isAck(frame.flags())) sendPingAck(frame);
}
case HEADERS, CONTINUATION -> receiveHeaders(frame, exchange);
case DATA -> receiveData(frame, exchange);
case RST_STREAM -> receiveReset(frame, exchange);
case GOAWAY -> throw receiveGoAway(frame);
case PUSH_PROMISE -> throw new IOException("server sent PUSH_PROMISE after ENABLE_PUSH=0");
default -> {
// PRIORITY and unknown extension semantics do not affect this single exchange.
}
}
} finally {
reader.consumeFrame();
}
}
private void receiveHeaders(FrameHeader frame, Exchange exchange) throws IOException {
if (frame.streamId() != exchange.streamId) {
throw new IOException("unexpected response stream " + frame.streamId());
}
if (frame.type() == FrameType.HEADERS) {
if (headers.isActive()) throw new IOException("interleaved response header block");
headerEndStream = FrameFlags.isEndStream(frame.flags());
long unpadded =
Padding.unpad(
frame.buffer(),
frame.payloadOffset(),
frame.length(),
FrameFlags.isPadded(frame.flags()));
int offset = Pairs.hi(unpadded);
int length = Pairs.lo(unpadded);
if (FrameFlags.hasPriority(frame.flags())) {
if (length < 5) throw new IOException("truncated response priority fields");
offset += 5;
length -= 5;
}
headers.begin(
frame.streamId(),
frame.buffer(),
offset,
length,
FrameFlags.isEndHeaders(frame.flags()));
} else {
headers.continuation(
frame.streamId(),
frame.buffer(),
frame.payloadOffset(),
frame.length(),
FrameFlags.isEndHeaders(frame.flags()));
}
if (!headers.isComplete()) return;
boolean trailers = exchange.statusCode != 0;
ResponseHeaderSink sink = new ResponseHeaderSink(exchange, trailers);
decoder.decode(headers.buffer(), 0, headers.length(), sink);
headers.reset();
sink.validate();
if (!trailers && exchange.statusCode >= 100 && exchange.statusCode < 200) {
if (headerEndStream) throw new IOException("informational response ended the stream");
exchange.statusCode = 0;
exchange.headers.reset();
return;
}
if (trailers && !headerEndStream) {
throw new IOException("response trailers did not end the stream");
}
if (headerEndStream) exchange.complete = true;
}
private void receiveData(FrameHeader frame, Exchange exchange) throws IOException {
if (frame.streamId() != exchange.streamId || exchange.statusCode == 0) {
throw new IOException("DATA received before response headers");
}
long unpadded =
Padding.unpad(
frame.buffer(),
frame.payloadOffset(),
frame.length(),
FrameFlags.isPadded(frame.flags()));
int dataOffset = Pairs.hi(unpadded);
int dataLength = Pairs.lo(unpadded);
if (exchange.body.size() > MAX_RESPONSE_BODY_SIZE - dataLength) {
throw new IOException("proxied HTTP/2 response body exceeds limit");
}
exchange.body.write(frame.buffer(), dataOffset, dataLength);
if (frame.length() != 0) {
sendWindowUpdate(0, frame.length());
sendWindowUpdate(exchange.streamId, frame.length());
}
if (FrameFlags.isEndStream(frame.flags())) exchange.complete = true;
}
private void receiveReset(FrameHeader frame, Exchange exchange) throws IOException {
if (frame.streamId() != exchange.streamId || frame.length() != 4) return;
int code = readInt(frame.buffer(), frame.payloadOffset());
throw new IOException("upstream reset HTTP/2 stream with error " + code);
}
private IOException receiveGoAway(FrameHeader frame) {
closed = true;
int code = frame.length() >= 8 ? readInt(frame.buffer(), frame.payloadOffset() + 4) : -1;
return new IOException("upstream sent GOAWAY with error " + code);
}
private void applySettings(FrameHeader frame) {
int oldWindow = peerSettings.initialWindowSize();
peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), delta -> {});
streamSendWindow += peerSettings.initialWindowSize() - oldWindow;
}
private void applyWindowUpdate(FrameHeader frame, int activeStreamId) throws IOException {
if (frame.length() != 4) throw new IOException("invalid WINDOW_UPDATE length");
int increment = readInt(frame.buffer(), frame.payloadOffset()) & 0x7fff_ffff;
if (increment == 0) throw new IOException("zero WINDOW_UPDATE increment");
if (frame.streamId() == 0) connectionSendWindow = addWindow(connectionSendWindow, increment);
else if (frame.streamId() == activeStreamId) streamSendWindow = addWindow(streamSendWindow, increment);
}
private void sendPingAck(FrameHeader frame) throws IOException {
outgoing.reset();
frames.beginFrame(FrameType.PING, FrameFlags.ACK, 0);
outgoing.writeBytes(frame.buffer(), frame.payloadOffset(), frame.length());
frames.endFrame();
writeOutgoing();
}
private void sendWindowUpdate(int streamId, int increment) throws IOException {
outgoing.reset();
frames.beginFrame(FrameType.WINDOW_UPDATE, 0, streamId);
outgoing.writeUInt31(increment);
frames.endFrame();
writeOutgoing();
}
private void sendEmpty(FrameType type, int flags, int streamId) throws IOException {
outgoing.reset();
frames.beginFrame(type, flags, streamId);
frames.endFrame();
writeOutgoing();
}
private void writeOutgoing() throws IOException {
intent.reset(outgoing.array(), outgoing.length());
writer.write(intent);
}
private void writeMethod(HttpMethod method) {
if (method == HttpMethod.GET) HpackEncoder.writeIndexed(outgoing, 2);
else if (method == HttpMethod.POST) HpackEncoder.writeIndexed(outgoing, 3);
else {
byte[] value = method.name().getBytes(StandardCharsets.US_ASCII);
HpackEncoder.writeLiteralWithNameIndex(outgoing, 2, value, false);
}
}
private void writeAuthority(URI uri) {
String authority = uri.getRawAuthority();
if (authority == null || authority.isEmpty()) {
throw new IllegalArgumentException("HTTP/2 URI requires an authority");
}
HpackEncoder.writeLiteralWithNameIndex(
outgoing, 1, authority.getBytes(StandardCharsets.US_ASCII), false);
}
private void writePath(URI uri) {
String path = uri.getRawPath();
if (path == null || path.isEmpty()) path = "/";
if (uri.getRawQuery() != null) path += "?" + uri.getRawQuery();
if ("/".equals(path)) HpackEncoder.writeIndexed(outgoing, 4);
else if ("/index.html".equals(path)) HpackEncoder.writeIndexed(outgoing, 5);
else {
HpackEncoder.writeLiteralWithNameIndex(
outgoing, 4, path.getBytes(StandardCharsets.US_ASCII), false);
}
}
@Override
public synchronized void close() {
if (closed) return;
closed = true;
writer.close();
try {
socket.close();
} catch (IOException ignored) {
// Closing a broken pooled connection is best-effort.
}
}
private static Socket connect(Origin origin, SSLContext sslContext) throws IOException {
if (!origin.secure) {
Socket socket = new Socket();
socket.connect(new InetSocketAddress(origin.host, origin.port), CONNECT_TIMEOUT_MS);
configureLowLatency(socket);
return socket;
}
SSLContext context;
try {
context = sslContext == null ? SSLContext.getDefault() : sslContext;
} catch (Exception failure) {
throw new IOException("cannot initialize TLS context", failure);
}
SSLSocket socket =
(SSLSocket) context.getSocketFactory().createSocket(origin.host, origin.port);
configureLowLatency(socket);
SSLParameters parameters = socket.getSSLParameters();
parameters.setApplicationProtocols(new String[] {"h2"});
parameters.setEndpointIdentificationAlgorithm("HTTPS");
socket.setSSLParameters(parameters);
socket.startHandshake();
if (!"h2".equals(socket.getApplicationProtocol())) {
socket.close();
throw new IOException("upstream did not negotiate HTTP/2 through ALPN");
}
return socket;
}
}
static void configureLowLatency(Socket socket) throws IOException {
socket.setTcpNoDelay(true);
}
private static final class Exchange {
private final int streamId;
private final MutableHeaderMap headers = new MutableHeaderMap();
private final MutableHeaderMap trailers = new MutableHeaderMap();
private final ByteArrayOutputStream body = new ByteArrayOutputStream();
private int statusCode;
private boolean complete;
private Exchange(int streamId) {
this.streamId = streamId;
}
private Http2ClientResponse response() {
return new Http2ClientResponse(statusCode, headers, body.toByteArray(), trailers);
}
}
private static final class ResponseHeaderSink
implements dev.relism.flash.http2.hpack.HeaderSink {
private final Exchange exchange;
private final boolean trailers;
private boolean regular;
private boolean status;
private ResponseHeaderSink(Exchange exchange, boolean trailers) {
this.exchange = exchange;
this.trailers = trailers;
}
@Override
public void accept(ByteView name, ByteView value, boolean neverIndexed) {
if (name.length() != 0 && name.byteAt(0) == ':') {
if (trailers || regular || status || !equalsAscii(name, ":status")) {
throw Http2Exception.PROTOCOL_ERROR;
}
exchange.statusCode = parseStatus(value);
status = true;
return;
}
regular = true;
MutableHeaderMap target = trailers ? exchange.trailers : exchange.headers;
byte[] nameBytes = copy(name);
byte[] valueBytes = copy(value);
target.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
}
private void validate() throws IOException {
if (!trailers && !status) throw new IOException("HTTP/2 response omitted :status");
}
private static int parseStatus(ByteView value) {
if (value.length() != 3) throw Http2Exception.PROTOCOL_ERROR;
int code = 0;
for (int i = 0; i < 3; i++) {
int digit = (value.byteAt(i) & 0xff) - '0';
if (digit < 0 || digit > 9) throw Http2Exception.PROTOCOL_ERROR;
code = code * 10 + digit;
}
return code;
}
}
private static final class BufferIntent implements WriteIntent {
private byte[] bytes;
private int length;
private WriteIntent next;
private void reset(byte[] bytes, int length) {
this.bytes = bytes;
this.length = length;
this.next = null;
}
@Override public byte[] buffer() { return bytes; }
@Override public int offset() { return 0; }
@Override public int length() { return length; }
@Override public WriteIntent mpscNext() { return next; }
@Override public void setMpscNext(WriteIntent next) { this.next = next; }
}
private record Origin(String scheme, String host, int port, boolean secure) {
private static Origin from(URI uri) {
String scheme = uri.getScheme();
boolean secure;
if ("https".equalsIgnoreCase(scheme)) secure = true;
else if ("http".equalsIgnoreCase(scheme)) secure = false;
else throw new IllegalArgumentException("HTTP/2 URI scheme must be http or https");
if (uri.getHost() == null) throw new IllegalArgumentException("HTTP/2 URI requires a host");
int port = uri.getPort() >= 0 ? uri.getPort() : secure ? 443 : 80;
return new Origin(scheme.toLowerCase(), uri.getHost(), port, secure);
}
}
private static final class OpenFailure extends RuntimeException {
private final IOException io;
private OpenFailure(IOException io) {
super(io);
this.io = io;
}
}
private static boolean equalsAscii(ByteView bytes, String value) {
if (bytes.length() != value.length()) return false;
for (int i = 0; i < bytes.length(); i++) {
int left = bytes.byteAt(i) & 0xff;
int right = value.charAt(i);
if (left >= 'A' && left <= 'Z') left += 'a' - 'A';
if (right >= 'A' && right <= 'Z') right += 'a' - 'A';
if (left != right) return false;
}
return true;
}
private static byte[] copy(ByteView view) {
byte[] result = new byte[view.length()];
for (int i = 0; i < result.length; i++) result[i] = view.byteAt(i);
return result;
}
private static int addWindow(int current, int increment) throws IOException {
long next = (long) current + increment;
if (next > Integer.MAX_VALUE) throw new IOException("HTTP/2 flow-control window overflow");
return (int) next;
}
private static int readInt(byte[] bytes, int offset) {
return ((bytes[offset] & 0xff) << 24)
| ((bytes[offset + 1] & 0xff) << 16)
| ((bytes[offset + 2] & 0xff) << 8)
| (bytes[offset + 3] & 0xff);
}
}
@@ -1,7 +0,0 @@
package dev.relism.flash.http2.client;
import dev.relism.flash.models.HeaderView;
/** Complete response returned by Flash's proxy-oriented HTTP/2 client. */
public record Http2ClientResponse(
int statusCode, HeaderView headers, byte[] body, HeaderView trailers) {}
@@ -1,131 +0,0 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.proxy.HttpProxy;
import dev.relism.flash.http2.client.Http2Client;
import dev.relism.flash.http2.client.Http2ClientResponse;
import dev.relism.flash.models.MutableHeaderMap;
import java.io.ByteArrayOutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
class ProxyTrailerRelayTest {
private FlashApp upstream;
private FlashApp proxy;
private Http2Client proxyUpstream;
@AfterEach
void stop() {
if (proxyUpstream != null) proxyUpstream.close();
if (proxy != null) proxy.stop().join();
if (upstream != null) upstream.stop().join();
}
@Test
void requestAndResponseTrailersSurviveH2AndH1DownstreamProxyHops() throws Exception {
int upstreamPort = freePort();
upstream =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(upstreamPort)
.http2CleartextEnabled(true)
.build());
upstream.post(
"/relay",
(request, response) ->
response
.header("x-query", request.query("mode"))
.header("x-private-seen", String.valueOf(request.header("x-private") != null))
.body(request.body().bytes())
.trailer("x-relayed-trailer", request.trailers().first("x-request-trailer")));
upstream.start();
int proxyPort = freePort();
proxyUpstream = new Http2Client();
proxy =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(proxyPort)
.http2CleartextEnabled(true)
.build());
proxy.post(
"/relay",
HttpProxy.toHttp2(URI.create("http://127.0.0.1:" + upstreamPort), proxyUpstream));
proxy.start();
MutableHeaderMap h2Headers = fields("connection", "x-private");
add(h2Headers, "x-private", "must-not-cross");
MutableHeaderMap h2Trailers = fields("x-request-trailer", "from-h2");
try (Http2Client downstream = new Http2Client()) {
Http2ClientResponse response =
downstream.exchange(
URI.create("http://127.0.0.1:" + proxyPort + "/relay?mode=h2"),
HttpMethod.POST,
h2Headers,
"hello-h2".getBytes(StandardCharsets.UTF_8),
h2Trailers);
assertEquals("hello-h2", new String(response.body(), StandardCharsets.UTF_8));
assertEquals("h2", response.headers().first("x-query"));
assertEquals("false", response.headers().first("x-private-seen"));
assertEquals("from-h2", response.trailers().first("x-relayed-trailer"));
}
String h1 = h1Exchange(proxyPort);
assertTrue(h1.contains("hello-h1"), h1);
assertTrue(h1.toLowerCase().contains("x-query: h1"), h1);
assertTrue(h1.toLowerCase().contains("x-private-seen: false"), h1);
assertTrue(h1.toLowerCase().contains("x-relayed-trailer: from-h1"), h1);
assertFalse(h1.contains("must-not-cross"), h1);
}
private static String h1Exchange(int port) throws Exception {
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(2_000);
socket
.getOutputStream()
.write(
("POST /relay?mode=h1 HTTP/1.1\r\n"
+ "Host: 127.0.0.1\r\n"
+ "Connection: x-private, close\r\n"
+ "X-Private: must-not-cross\r\n"
+ "Transfer-Encoding: chunked\r\n"
+ "Trailer: x-request-trailer\r\n\r\n"
+ "8\r\nhello-h1\r\n"
+ "0\r\nX-Request-Trailer: from-h1\r\n\r\n")
.getBytes(StandardCharsets.US_ASCII));
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
socket.getInputStream().transferTo(bytes);
return bytes.toString(StandardCharsets.UTF_8);
}
}
private static MutableHeaderMap fields(String name, String value) {
MutableHeaderMap headers = new MutableHeaderMap();
add(headers, name, value);
return headers;
}
private static void add(MutableHeaderMap headers, String name, String value) {
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8);
headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -1,129 +0,0 @@
package dev.relism.flash.http2.client;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.MutableHeaderMap;
import dev.relism.flash.tls.TestKeystores;
import dev.relism.flash.tls.TlsConfig;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class Http2ClientTest {
private FlashApp app;
@AfterEach
void stop() {
if (app != null) app.stop().join();
}
@Test
void reusesOriginConnectionAndExchangesFlowControlledBodiesAndTrailers() throws Exception {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.post(
"/relay",
(request, response) -> {
byte[] body = request.body().bytes();
String checksum = request.trailers().first("x-request-checksum");
return response
.header("x-upstream", request.header("x-forwarded-test"))
.body(body)
.trailer("x-response-checksum", checksum);
});
app.start();
byte[] body = new byte[2 * 1024 * 1024 + 31];
for (int i = 0; i < body.length; i++) body[i] = (byte) (i * 29);
MutableHeaderMap requestHeaders = fields("x-forwarded-test", "yes");
MutableHeaderMap requestTrailers = fields("x-request-checksum", "valid");
try (Http2Client client = new Http2Client()) {
URI uri = URI.create("http://127.0.0.1:" + port + "/relay");
Http2ClientResponse first =
client.exchange(uri, HttpMethod.POST, requestHeaders, body, requestTrailers);
Http2ClientResponse second =
client.exchange(
uri,
HttpMethod.POST,
requestHeaders,
"again".getBytes(StandardCharsets.UTF_8),
requestTrailers);
assertEquals(200, first.statusCode());
assertEquals("yes", first.headers().first("x-upstream"));
assertArrayEquals(body, first.body());
assertEquals("valid", first.trailers().first("x-response-checksum"));
assertArrayEquals("again".getBytes(StandardCharsets.UTF_8), second.body());
assertEquals(1, client.pooledConnectionCount());
}
}
@Test
void negotiatesTlsAlpnAndVerifiesTheUpstreamHostname(@TempDir Path directory) throws Exception {
int port = freePort();
Path keystore =
TestKeystores.build(
directory,
"http2-client.p12",
"changeit",
TestKeystores.Entry.of("server", "localhost", "localhost"));
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.tls(TlsConfig.keystore(keystore, "changeit"))
.http2Enabled(true)
.build());
app.get("/secure", (request, response) -> "tls-h2");
app.start();
try (Http2Client client = new Http2Client(TestKeystores.trustAllClientContext())) {
Http2ClientResponse response =
client.get(URI.create("https://localhost:" + port + "/secure"));
assertEquals(200, response.statusCode());
assertEquals("tls-h2", new String(response.body(), StandardCharsets.UTF_8));
}
}
@Test
void configuresConnectionsForRequestResponseLatency() throws Exception {
try (Socket socket = new Socket()) {
assertFalse(socket.getTcpNoDelay());
Http2Client.configureLowLatency(socket);
assertTrue(socket.getTcpNoDelay());
}
}
private static MutableHeaderMap fields(String name, String value) {
MutableHeaderMap headers = new MutableHeaderMap();
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8);
headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
return headers;
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}