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:
@@ -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.
|
|
||||||
@@ -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
@@ -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
@@ -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.
|
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user