refactor(core): remove out-of-scope HTTP/2 client/proxy, reorganize docs, refresh README

HttpProxy and Http2Client (719 LOC) shipped a reverse-proxy adapter and outbound HTTP/2
client from flash core with zero callers anywhere in the server itself — only each
other and their own tests. An HTTP/1.1+2 server framework has no business bundling an
outbound client; that capability belongs in its own flash-extensions/flash-ext-*
module if/when it's needed. Removed, along with the now-dead src/bench load driver
that depended on Http2Client (no replacement client written here — flagged as
follow-up work, not silently dropped).

docs/http2/ had accumulated core, cross-protocol documentation alongside genuine
HTTP/2-protocol internals: HTTP1-HARDENING, TRANSPORT, MESSAGE-MODEL,
TRAILERS-AND-STREAMING and BYTES all describe machinery HTTP/1.1 and HTTP/2 share, not
HTTP/2 specifically. Moved to a new docs/core/, leaving docs/http2/ to the protocol
layers, wire internals and operational docs that are actually HTTP/2-specific.
CLEARTEXT-AND-PROXY.md renamed to CLEARTEXT.md and its now-removed upstream-client
section cut, matching the source removal above.

README.md: removed the "HTTP/2 upstream proxy" section (documented the deleted
HttpProxy/Http2Client), the flash-bench module row and build command (not a module
that exists in this repo), and fixed every doc link to the new docs/core/ paths.
Added the new FlashConfiguration.maxConnections field to the configuration reference.

src/bench/ (a load-test harness distinct from the JMH suite, not wired into any Maven
profile or CI) is committed here for the first time.
This commit is contained in:
Zakaria El Orche
2026-08-14 18:13:03 +00:00
parent cf16be08c0
commit a0dda8e47a
28 changed files with 359 additions and 5656 deletions
+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-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
## Requirements
@@ -169,10 +168,11 @@ app.onException((ex, req, res) -> {
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) |
| `maxHeaderBufferSize` | `65536` | Max size of the header 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. |
| `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. |
| `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. |
| `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. |
@@ -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
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
(`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.
**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
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
```
@@ -433,7 +419,4 @@ mvn test
# Run a single test class
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
`FastPathRouterImpl.RouteScratch` (created once per connection via `AbstractRouter#newScratch`,
replacing the `ThreadLocal<MatchResult>`/`ThreadLocal<MethodPathByteView>` pair — see
`DECISIONS.md`, `DEC-19`, for why this is an opaque caller-owned object rather than an extension
of `ConnectionScratch`) also owns the reusable path-param arrays and a single long-lived
replacing the `ThreadLocal<MatchResult>`/`ThreadLocal<MethodPathByteView>` pair, as an opaque
caller-owned object rather than an extension of `ConnectionScratch`) also owns the reusable
path-param arrays and a single long-lived
`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
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
`EX-04` (the router's word-at-a-time path) and `EX-33` (the SWAR header-end scan) both carry an
explicit "measure, and keep only if it doesn't cost" instruction in the plan. Both are measured
together with the phase's overall zero-allocation contract in one JMH pass — see `DECISIONS.md`,
`DEC-20`, for the numbers and the keep/revert decision for each.
`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" requirement. Both were measured together
with the phase's overall zero-allocation contract in one JMH pass, and both were kept.
@@ -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`,
`valueEqualsIgnoreCase`, `contains`, `count`, `forEach`) and `Http1HeaderMap` (the existing
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)
owns and constructs it, and `http1`→root already exists via `Http1Connection`, so moving it to
`http1` would create a `models``http1` package cycle). `RequestLine.headers` is typed as the
`dev.relism.flash.http1`: `RequestParser` (root package) owns and constructs it, and
`http1`→root already exists via `Http1Connection`, so moving it to `http1` would create a
`models``http1` package cycle). `RequestLine.headers` is typed as the
interface; `Http2HeaderMap` is the HPACK-backed second implementation without requiring a
`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
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,
effectively 0. Full numbers in `DECISIONS.md`, `DEC-23`.
effectively 0.
## 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
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
exempts ("except for the user-facing `String`s the handler explicitly asks for"). See
`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.
exempts ("except for the user-facing `String`s the handler explicitly asks for").
+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:
@@ -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
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
`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
single-label wildcards are supported; h2c has no certificate identity and is unaffected.
## Trailer guarantee
The proxy copies request trailers only after the incoming body reaches EOF and emits upstream
trailers as a trailing HEADERS block. Response trailers follow the reverse path and remain
trailers on both HTTP/2 and HTTP/1.1 chunked downstream connections. The live relay tests cover
both downstream protocols.
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
framework has no business shipping an outbound client. That code has been removed; if a
reverse-proxy capability is needed later, it belongs in its own `flash-extensions/flash-ext-*`
module, not in core.
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
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
`Http2FrameReaderTest`, fixed, and given its own regression suite (`BufferedByteSourceTest`)
full writeup in the plan's registry, `EX-37`.
`Http2FrameReaderTest`, fixed, and given its own regression suite (`BufferedByteSourceTest`).
## 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 ----+
```
## 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.
- [Flow control](FLOW-CONTROL.md) — request backpressure and streamed responses.
- [Trailers and streaming](TRAILERS-AND-STREAMING.md) — the public cross-protocol APIs.
- [Cleartext and proxying](CLEARTEXT-AND-PROXY.md) — prior knowledge and the upstream h2 client.
- [Cleartext](CLEARTEXT.md) — prior knowledge and the 421 misdirected-request rule.
- [WebSockets](WEBSOCKET.md) — RFC 8441 extended CONNECT using the existing WebSocket API.
## 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.
- [Frames](FRAMES.md) — frame parsing, validation and error scope.
- [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.
- [Performance](PERFORMANCE.md) and [CI baselines](BASELINES.md) — measurements and regression
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
`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
live in `src/test/java`) compares four harnesses at `threads` ∈ {1, 2, 4, 8, 16, 64}:
registered only under the `jmh` Maven profile, not `src/test/java`) compares four harnesses at
`threads` ∈ {1, 2, 4, 8, 16, 64}:
- `trylock_mpsc` — the shipped `Http2FrameWriter` design.
- `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** |
**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
@@ -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();
}
}
}