Compare commits

10 Commits
Author SHA1 Message Date
Zakaria El Orche a0dda8e47a 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.
2026-08-14 18:13:03 +00:00
Zakaria El Orche cf16be08c0 fix(core): fix HTTP/2 rate-limiter false positives, connection-flood OOM, and a streaming-body leak
Targeted stress testing under this branch's HTTP/2 work surfaced four independent
production bugs, each verified with a before/after load test and a regression test:

- Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL (400/10s) rejected legitimate
  high-concurrency HTTP/2 clients as if they were CVE-2023-44487 rapid-reset abuse —
  h2load's default pattern alone triggered 40-92% request failure. Raised to 100,000,
  matching MAX_STREAMS_PER_CONNECTION's existing lifetime budget; the RST_STREAM-rate
  counter remains the precise defence against the actual attack signature.

- Flash had no connection-admission control anywhere: AcceptLoop accepted every TCP
  connection unconditionally, so a connection flood (h2load -c 400) ran the JVM out of
  heap and crashed with OutOfMemoryError, killing even unrelated daemon threads.
  TransportLimits.defaultMaxConnections() auto-scales a cap from Runtime.maxMemory();
  ConnectionRunner.accept() enforces it before any per-connection state (TLS handshake
  included) is created. Verified surviving 42x the admission limit under both cleartext
  and TLS load with bounded RSS.

- Http1ResponseWriter never closed a handler's streaming response body on a write
  failure (e.g. the client disconnecting mid-transfer) — only on a clean EOF. A handler
  whose stream releases a held resource (a pooled backend connection, for a reverse
  proxy) from close() leaks it under any real amount of client disconnects. Now closed
  on every exit path, matching InputStream#close()'s own idempotency contract.

- Http2StreamState.transition() called the enum's values() every state transition;
  values() clones a fresh array on every call. Cached once, removing ~10.76% of
  allocations measured live under load.

695 -> 698 tests (three new regression tests), all passing.
2026-08-14 18:12:46 +00:00
Zakaria El Orche 825bdfc942 docs(core): document HTTP/2 operation and architecture 2026-08-13 21:39:37 +00:00
Zakaria El Orche 3679eed74a feat(core): add HTTP/2 performance gates 2026-08-13 21:29:21 +00:00
Zakaria El Orche 6386264a1e test(core): add HTTP/2 compliance suite 2026-08-13 20:51:35 +00:00
Zakaria El Orche f3011ffdf6 feat(core): add WebSocket over HTTP/2 2026-08-13 20:18:05 +00:00
Zakaria El Orche 3c1eb0d0df feat(core): add HTTP/2 cleartext proxy support 2026-08-13 20:00:59 +00:00
Zakaria El Orche 5755ef77fe feat(core): harden HTTP/2 abuse resistance 2026-08-13 19:40:52 +00:00
Zakaria El Orche ee90ac44ff feat(core): add HTTP trailers and push streaming 2026-08-13 19:23:26 +00:00
Zakaria El Orche 8d5340a0b4 feat(core): add HTTP/2 flow-controlled bodies 2026-08-13 19:00:19 +00:00
133 changed files with 8085 additions and 4665 deletions
+33 -1
View File
@@ -32,8 +32,40 @@ jobs:
server-username: MAVEN_USERNAME
server-password: MAVEN_PASSWORD
- name: Install h2spec 2.6.0
run: |
curl --fail --location --silent --show-error \
--output /tmp/h2spec.tar.gz \
https://github.com/summerwind/h2spec/releases/download/v2.6.0/h2spec_linux_amd64.tar.gz
echo "157ee0de702e01ad40e752dbf074b366027e550c8e7504f9450da2809e279318 /tmp/h2spec.tar.gz" \
| sha256sum --check
tar --extract --gzip --file /tmp/h2spec.tar.gz --directory /tmp
- name: Install nghttp client
run: |
sudo apt-get update
sudo apt-get install --yes nghttp2-client
- name: Install grpcurl 1.9.3
run: |
curl --fail --location --silent --show-error \
--output /tmp/grpcurl.tgz \
https://github.com/fullstorydev/grpcurl/releases/download/v1.9.3/grpcurl_1.9.3_linux_x86_64.tar.gz
echo "a926b62a85787ccf73ef8736b3ae554f1242e39d92bb8767a79d6dd23b11d1d5 /tmp/grpcurl.tgz" \
| sha256sum --check
tar --extract --gzip --file /tmp/grpcurl.tgz --directory /tmp grpcurl
- name: Build and test
run: mvn -B --settings .github/settings.xml clean verify
run: >-
mvn -B --settings .github/settings.xml
-Dh2spec.executable=/tmp/h2spec
-Dcurl.executable=/usr/bin/curl
-Dnghttp.executable=/usr/bin/nghttp
-Dgrpcurl.executable=/tmp/grpcurl
-Djdk.tracePinnedThreads=full
-Pjmh
-Dflash.performance.gates=true
clean verify
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
+88 -22
View File
@@ -1,12 +1,13 @@
# Flash
A high-performance HTTP/1.1 server library for Java 21, built around virtual threads and a zero-allocation FSM router.
A high-performance HTTP/1.1 and HTTP/2 server library for Java 21, built around virtual threads,
a zero-allocation FSM router, bounded protocol state, and one shared request/response API.
## Modules
| Module | Description |
|---|---|
| `flash` | Core server library — router, request parser, HTTP I/O transport |
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
@@ -14,7 +15,6 @@ A high-performance HTTP/1.1 server library for Java 21, built around virtual thr
| `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
@@ -58,7 +58,7 @@ app.post("/echo", (req, res) -> {
});
app.get("/users/{id}", (req, res) -> {
String id = req.pathParam("id");
String id = req.param("id");
return "user:" + id;
});
```
@@ -168,18 +168,63 @@ 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. |
| `http2Enabled` | `false` | Whether this server will ever negotiate HTTP/2. Off by default until the HTTP/2 connection state machine lands (see `flash/docs/http2/IMPLEMENTATION-PLAN.md`). |
| `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. |
| `h2MaxResetStreamsPerInterval` | `200` | Rapid Reset budget per rolling interval. |
| `h2MaxStreamsCreatedPerInterval` | `400` | New-stream budget per rolling interval. |
| `h2AbuseRateIntervalMs` | `10000` | Rolling interval for the two operator-tunable rate limits above. |
| `h2MaxStreamsPerConnection` | `100000` | Total stream budget; `0` disables it. |
| `h2MaxBytesPerConnection` | `0` | Optional total wire-byte budget; `0` disables it. |
| `h2MaxConnectionLifetimeMs` | `0` | Optional connection lifetime; `0` disables it. |
| `h2StreamIdleTimeoutMs` | `60000` | Inactive open-stream deadline. |
| `sendDate` | `true` | Add an RFC 9110 `Date` field to responses; disable when an upstream proxy supplies it. |
## Protocols
Routes, middleware, `Request`, `Response`, bodies, trailers, streaming and WebSockets use the same
API on HTTP/1.1 and HTTP/2. Protocol selection happens once per connection:
- On TLS listeners, enable `http2Enabled`; Flash advertises `h2` and `http/1.1` through ALPN and
uses the protocol selected by the client. Existing HTTP/1.1 clients continue to work.
- On plaintext listeners, enable `http2CleartextEnabled` to accept the HTTP/2 prior-knowledge
preface on the same port as HTTP/1.1. Clients that do not send that exact preface are parsed as
HTTP/1.1.
- With both switches left at their default `false`, Flash behaves as an HTTP/1.1 server.
After enabling the appropriate switch, application routes need no protocol-specific code. TLS
still requires the normal certificate configuration shown below.
Flash deliberately does not implement HTTP/2 server push, RFC 7540 dependency-tree priority
scheduling, or the obsolete HTTP/1.1 `Upgrade: h2c` transition. Server push has no application API,
RFC 9113 deprecated the old priority scheme, and cleartext HTTP/2 uses prior knowledge instead.
See the [HTTP/2 compliance record](flash/docs/http2/COMPLIANCE.md) for exact coverage.
## WebSockets over HTTP/2
The same `ws(path, handler)` route serves WebSockets over HTTP/1.1 and HTTP/2. When HTTP/2 is
enabled, Flash advertises RFC 8441 extended CONNECT support and carries WebSocket frames inside
flow-controlled DATA frames. No alternate handler, route, or session API is required:
```java
app.ws("/live", handler);
```
HTTP/1.1 clients use the ordinary `101 Switching Protocols` upgrade. HTTP/2 clients use an
extended CONNECT and receive status `200`; Flash applies the same RFC 6455 framing, masking,
fragmentation, close, and callback behavior on both transports. Client support for negotiating
WebSockets over HTTP/2 varies, so clients without RFC 8441 support continue to use HTTP/1.1.
## TLS
HTTPS and WSS are a transport-layer concern only: once a listener is bound, the accepted
`Socket` is either plain or an `SSLSocket` indistinguishably from `HttpServer`'s point of view
onward — the request parser, router, and WebSocket upgrade never branch on it. WSS is therefore
not a separate feature; it's a WebSocket upgrade running over whatever transport it was handed.
HTTPS and WSS are a transport-layer concern only. Once a listener is bound, the accepted socket
is plain or TLS; the selected HTTP connection implementation then performs either the HTTP/1.1
upgrade or the HTTP/2 extended CONNECT. WSS does not require a separate route or handler API.
### Quick start
@@ -265,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
@@ -316,6 +361,34 @@ app.get("/health", (req, res) -> res.header(NO_STORE).body("ok"));
HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared
application and middleware code.
### Trailers and push streaming
Request trailers become available after the body reaches EOF:
```java
byte[] payload = req.body().bytes();
String status = req.trailers().first("grpc-status");
```
For a producer-driven response, `Response.streaming` provides a blocking `ResponseStream`. Its
bounded buffer and HTTP/2 flow-control windows apply backpressure directly to the producer's
virtual thread:
```java
return res.streaming(stream -> {
try {
stream.write(payload, 0, payload.length);
stream.trailer("result", "complete");
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
});
```
The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HEADERS on
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
future `flash-ext-grpc` extension.
## Architecture
```
@@ -323,13 +396,9 @@ TransportFactory.create() # binds every listener, wires the connection
→ AcceptLoop # one per listener × accept thread; hands sockets off
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
Http1Connection.run() # the ConnectionProtocol seam; HTTP/2 plugs in here later
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
→ RequestHandler.handle() # user handler; return value sets body
→ Request.drain() # consume unread body for keep-alive
→ Http1ResponseWriter.write() # status line, headers, then fixed or chunked body
→ loop or close socket # based on Connection header, or ServerLifecycle draining
├─ Http1Connection.run() # request parser, router, handler, h1 response writer
└─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
→ RequestHandler.handle() # the same protocol-neutral request/response API
```
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
@@ -337,7 +406,7 @@ TransportFactory.create() # binds every listener, wires the connection
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
- **`ConnectionProtocol` seam** — h1 and h2 (in progress, see `flash/docs/http2/`) are peers behind this interface, decided once per connection by `ProtocolNegotiator`, never by an `if` inside shared code. See `flash/docs/http2/TRANSPORT.md` for the full component breakdown.
- **`ConnectionProtocol` seam** — HTTP/1.1 and HTTP/2 are peers behind this interface, selected once per connection by `ProtocolNegotiator`; routing and application models are shared.
## Build & test
@@ -350,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
```
@@ -1,4 +1,4 @@
# The Byte Layer (Phase 4)
# The byte layer
Audience: contributors. This is the design record for `dev.relism.flash.bytes` — the
protocol-neutral byte primitives both HTTP/1.1 and HTTP/2 build on — and for the Phase 4
@@ -38,7 +38,7 @@ ByteView (fpr-core)
│ ├── FastPathViews.StringByteView a String's UTF-8 bytes
│ └── PooledSlice the EX-05 reusable, pool-issued slice
└── (bare ByteView, not array-backed)
├── SegmentedByteView K discontiguous segments (future HPACK CONTINUATION)
├── SegmentedByteView K discontiguous segments (general-purpose; HPACK stays contiguous)
└── FastPathViews.MethodPathByteView method bytes + another ByteView, composed
```
@@ -101,26 +101,25 @@ and every match position (including unaligned starts and matches at the very las
and `ByteScanFuzzTest` throws 20 000 fully-random trials at each, per the plan's task 1. All
green — see the class's own Javadoc for the full technique writeup.
## `EX-09`: `HeaderMap`'s index
## `Http1HeaderMap`'s index
Before this phase, every `HeaderMap` lookup (`first`, `all`, `view`, `valueEqualsIgnoreCase`)
Originally, every `Http1HeaderMap` lookup (`first`, `all`, `view`, `valueEqualsIgnoreCase`)
rescanned the entire header section from scratch — O(n·m) for a realistic middleware chain
performing 610 lookups per request. `HeaderMap.reset()` now scans the section exactly once,
recording per-header `(nameOffset, nameLength, valueOffset, valueLength)` and a case-insensitive
performing 610 lookups per request. `RequestParser` now populates the index while it validates
each header line; direct `Http1HeaderMap.reset()` callers scan the section exactly once. It records
per-header `(nameOffset, nameLength, valueOffset, valueLength)` and a case-insensitive
32-bit FNV-1a hash of the name (`ByteScan.hashNameIgnoreCaseAscii`) into `int[]` arrays grown
(never shrunk) to the connection's high-water mark, capped by `Http1Limits.MAX_HEADER_COUNT`
(asserted, not silently truncated — Phase 1 already rejects any request that would exceed it).
(asserted, not silently truncated — the parser rejects a request that would exceed it).
Every lookup then compares the caller's own hash (`ByteScan.hashNameIgnoreCaseAscii(String)`,
computed once) against the index's hashes before ever falling back to a full case-insensitive
name comparison. Net effect: the header section is scanned once per request (at `reset()`) plus
once at parse time (`RequestParser`'s own validation pass) — two scans total, replacing "one scan
at parse time plus one rescan per lookup" — strictly less work even for a single lookup, and much
less for the realistic multi-lookup case. `forEach` was unified onto the same index rather than
keeping its own independent scan, removing a second, easily-diverging scanning implementation.
name comparison. Production therefore performs one combined validation/index pass rather than
one parse-time pass plus one rescan per lookup. `forEach` uses the same index rather than keeping
an independent scanner.
## `EX-05`: pooled slices
`HeaderMap.view`, `QueryParams.view`, and `PathParams.view` used to allocate a fresh anonymous
`Http1HeaderMap.view`, `QueryParams.view`, and `PathParams.view` used to allocate a fresh anonymous
`ByteView` (plus its capturing instance) on every call. Each now draws from a small
(`VIEW_POOL_SIZE = 4`) `SlicePool` of reusable `PooledSlice` instances instead. The lifetime
contract, restated on each method: **a returned view stays valid until either the request ends,
@@ -128,15 +127,15 @@ or the same `view()` method is called `VIEW_POOL_SIZE` more times on the same in
whichever comes first** — at which point the ring silently repositions the same object over
different bytes. This is a real, demonstrated hazard, not a hypothetical one:
`SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice` and the analogous tests in
`HeaderMapIndexTest`, `QueryParamsFastPathTest`, and `PathParamsTest` all show a 5th call
`Http1HeaderMapIndexTest`, `QueryParamsFastPathTest`, and `PathParamsTest` all show a 5th call
returning the exact same object instance the 1st call did, now aliased to different content.
`QueryParams` and `PathParams`'s pools are created **lazily**, on the first actual `view()` call
— not eagerly in the constructor — because both classes are otherwise-cheap objects created per
request (or, for `PathParams`'s `FastPathRouterImpl`-owned reusable instance, once per
connection) regardless of whether `view()` is ever invoked; an eager pool would add
`VIEW_POOL_SIZE` allocations to every such object whether or not it needed them; `HeaderMap`'s
pool, by contrast, is unconditionally useful (every request's `HeaderMap` handles headers) and is
`VIEW_POOL_SIZE` allocations to every such object whether or not it needed them; `Http1HeaderMap`'s
pool, by contrast, is unconditionally useful (every request's map handles headers) and is
constructed eagerly for simplicity.
**Two documented, deliberately-kept exceptions to "no `new ByteView()` remains"**: `QueryParams.view`
@@ -145,15 +144,15 @@ backing source is *not* `ArrayBackedByteView` — structurally unreachable on th
today (`RequestParser` only ever constructs array-backed views), kept because both constructors
are `public` and could in principle be called with an arbitrary `ByteView`. A silent, correct,
allocating fallback was judged preferable to either crashing on a technically-valid input or
deleting a case that only test/future code could exercise. `HeaderMap.view` has no such fallback
deleting a case that only test code could exercise. `Http1HeaderMap.view` has no such fallback
— it is always buffer-backed by construction.
## `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
@@ -173,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.
@@ -1,4 +1,4 @@
# HTTP/1.1 Hardening (Phase 1)
# HTTP/1.1 hardening
Audience: operators. This is the document to read when a `400`/`413`/`414`/`431`/`501` shows up
in the logs and it isn't obvious why. Every rejection rule Flash's HTTP/1.1 parser enforces is
@@ -62,9 +62,8 @@ as `1`) instead of rejecting them — this is the fix.
| `MAX_TRAILER_COUNT` | 50 | `431` |
| A chunk's data not followed by `\r\n`, or a malformed chunk-size/trailer terminator | — | `400` |
Trailers are consumed (safely, within the bounds above) but discarded, not exposed to the
handler, on HTTP/1.1 today — exposing them via `Request.trailers()` on both protocols is Phase
12 scope.
Trailers are consumed within the bounds above and exposed through `Request.trailers()` on both
HTTP/1.1 and HTTP/2.
## Timeouts (`FlashConfiguration`)
@@ -73,7 +72,7 @@ handler, on HTTP/1.1 today — exposing them via `Request.trailers()` on both pr
| `idleKeepAliveTimeoutMs` | 60 000 | How long a keep-alive connection may sit idle waiting for its next request. |
| `headerReadTimeoutMs` | 10 000 | Once the first byte of a request arrives, how long the full header block may take. |
| `bodyReadTimeoutMs` | 30 000 | How long reading the body (by the handler, or the automatic post-response drain) may take. |
| `shutdownDrainTimeoutMs` | 15 000 | How long graceful shutdown waits for in-flight requests before force-closing (wired up starting Phase 2). |
| `shutdownDrainTimeoutMs` | 15 000 | How long graceful shutdown waits for in-flight requests before force-closing. |
These are enforced by an **absolute deadline**, not merely `Socket.setSoTimeout`. A per-read
socket timeout alone never trips against a peer that sends one byte just often enough to keep
@@ -89,6 +88,5 @@ implemented on top of the JDK's per-read-only timeout API.
suite list is filtered against the RFC 9113 Appendix A blocklist
(`TlsConfig.TLS12_H2_BLOCKED_CIPHERS`, ~280 entries). TLS 1.3 is never affected — none of its
cipher suites are on that list.
- HTTP/2 itself is not yet served in this phase (lands in Phase 8): a connection that negotiates
`h2` via ALPN, or that opens with the h2c prior-knowledge preface while
`FlashConfiguration.http2Enabled` is set, is currently closed cleanly rather than served.
- `FlashConfiguration.http2Enabled` advertises `h2` on TLS listeners. The independent
`http2CleartextEnabled` switch accepts the h2c prior-knowledge preface on plaintext listeners.
@@ -1,10 +1,8 @@
# The Message Model (Phase 6)
# The message model
Audience: contributors. This is the design record for `dev.relism.flash.models`'s request/response
object model after Phase 6's refactor — what is pooled, what that pooling actually means for code
that touches these objects, and the allocation fixes (`EX-20``EX-24`, `EX-27`, `EX-28`, `EX-29`,
`EX-42`) that got the h1 request/response cycle to the zero-alloc contract Phase 4 (`DEC-20`) left
open.
Audience: contributors. This is the design record for `dev.relism.flash.models`'s shared
request/response model: what is pooled, what that pooling means for callers, and how HTTP/1.1 and
HTTP/2 retain the same public contract.
## Why this exists
@@ -103,15 +101,14 @@ sequence (`headerTags`/`headerRefs`) interleaves the two stores back into declar
serialized, so mixing `header(String,String)` and `header(byte[])` calls on the same response still
produces headers in the order they were added.
`PreEncodedHeader` precomputes a header's name+value ASCII bytes once (e.g. for a constant response
header set at boot) — deliberately does **not** yet expose HPACK-encoded bytes, since HPACK does
not exist until Phase 9; that scope boundary is recorded in the class's own Javadoc rather than
building untested, speculative API surface now.
`PreEncodedHeader` precomputes a header's name and value ASCII bytes once (for example, a constant
response header set at boot). Preserving the boundary lets HTTP/1.1 render a field line and HTTP/2
encode the same pair through HPACK without a second public header type.
`ResponseSerializer.forEachField(Response, FieldConsumer)` is the **one source of truth for what
headers a response has** — it enumerates `Content-Type` (if set) plus every structured custom
header, in order, and is the only place that knowledge lives. `Http1ResponseWriter` renders that
sequence as `Name: Value\r\n` lines; the Phase 9 h2 encoder will render the same sequence as HPACK.
sequence as `Name: Value\r\n` lines; `Http2ResponseWriter` renders the same sequence as HPACK.
Deliberately excluded: `Content-Length`/`Connection`/`Date` (connection framing, not response
object properties — and HTTP/2 has no `Connection` header at all, RFC 9113 §8.2.2) and raw
`header(byte[])` entries (no recoverable name/value structure to hand the h2 encoder).
@@ -138,11 +135,11 @@ 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
interface, so a future `Http2HeaderMap` (Phase 10, HPACK-backed) is a drop-in second
implementation, not a `Request`/`RequestLine` API change.
`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.
## `ByteTemplate` (`EX-28`)
@@ -181,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
@@ -191,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.
+36
View File
@@ -0,0 +1,36 @@
# Trailers and streaming
Flash exposes the same request and response model on HTTP/1.1 and HTTP/2. Request trailers are
available through `Request.trailers()` after the body has reached EOF. Calling it earlier throws
`IllegalStateException`; this prevents handlers from observing an incomplete trailer section.
HTTP/1.1 reads trailers from the final chunk, while HTTP/2 decodes the trailing HEADERS block in
the connection's existing HPACK context.
Response trailers are added with `Response.trailer(name, value)` or a `PreEncodedHeader`. HTTP/1.1
uses chunked framing and writes the fields after the zero chunk. HTTP/2 writes a trailing HEADERS
block with `END_STREAM`; the final DATA frame deliberately does not carry `END_STREAM`.
`Response.streaming(producer)` is the push alternative to `stream(InputStream, length)` and
`chunked(InputStream)`. Its `ResponseStream` is a bounded blocking bridge. A producer runs on a
virtual thread and blocks when the protocol writer or the HTTP/2 flow-control windows cannot make
progress. This keeps backpressure explicit without callbacks or reactive types:
```java
return response.type("application/grpc").streaming(stream -> {
try {
for (byte[] message : messages) stream.write(message, 0, message.length);
stream.trailer("grpc-status", "0");
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
});
```
The transport supports the primitives required by gRPC, but the core does not provide protobuf
codecs, generated stubs, service descriptors, or a gRPC service API. Those belong in a future
`flash-ext-grpc` module. `GrpcInteropTest` verifies the boundary with the external `grpcurl` client
and a hand-written wire-format handler.
CONNECT requests follow RFC 9113 request pseudo-header rules: `:authority` is required and
`:scheme`/`:path` are forbidden. Their DATA remains subject to the ordinary request limits,
timeouts and two-level flow control.
@@ -1,19 +1,19 @@
# Transport Architecture (Phase 2)
# Transport architecture
Audience: contributors. This is the document Phase 3 onward extends as HTTP/2 grows a real
connection state machine behind the seam described here.
Audience: contributors. This document describes the shared listener and connection layer behind
the HTTP/1.1 and HTTP/2 implementations.
## Why this exists
Before Phase 2, `HttpServer` (563 lines) did bind, accept, virtual-thread dispatch, WebSocket
The original `HttpServer` (563 lines) did bind, accept, virtual-thread dispatch, WebSocket
upgrade detection, WebSocket handshake, the WebSocket session loop, keep-alive detection, HTTP
response serialization, chunked encoding, hex encoding, and decimal encoding — eleven reasons to
change in one class (R6). It also held three `ThreadLocal`s that meant "one per connection" under
virtual threads, not "one per core" (`EX-06`), and used `synchronized` around blocking socket
writes in `WebSocketSession`, which pins a virtual thread's carrier on Java 21 (`EX-01`).
Phase 2 replaces it with named, single-responsibility components and the `ConnectionProtocol`
seam HTTP/2 will plug into starting Phase 8.
The current design replaces it with named, single-responsibility components and a
`ConnectionProtocol` seam implemented by both wire protocols.
## Package layout
@@ -51,7 +51,7 @@ dev.relism.flash.websocket (existing package, extended)
```
TransportFactory.create(configuration, router, wsRouter)
binds every configured listener (ListenerBinder)
builds one ConnectionRunner (shared virtual-thread executor, ScratchPool, Http1Connection)
builds one ConnectionRunner (shared virtual-thread executor, ScratchPool, both protocols)
returns a ServerLifecycle (implements ServerHandle)
ServerLifecycle.start()
@@ -71,8 +71,8 @@ ConnectionRunner.handle(socket, stopped)
if SSLSocket: force startHandshake() under headerReadTimeoutMs (EX-30)
wrap streams: BufferedByteSource in, buffered OutputStream out, raw OutputStream rawOut
negotiated = negotiateProtocol(socket, in) # ALPN or h2c preface
if negotiated == H2: return # no Http2Connection yet (Phase 8) -- close cleanly
build ConnectionContext, dispatch to http1Protocol.run(ctx)
build ConnectionContext
dispatch to http1Protocol.run(ctx) or http2Protocol.run(ctx)
finally:
activeSockets.remove(socket); scratchPool.release(scratch)
```
@@ -96,12 +96,8 @@ leak-free arena: above its bound (`min(availableProcessors * 64, 4096)` by defau
scratch is simply dropped for the garbage collector rather than queued, so an unusually large
burst of connections cannot grow it without limit.
The router's own `ThreadLocal`s (`FastPathRouterImpl`, `FastPathWsRouterImpl`) are **not**
removed in this phase — `EX-06`'s registry entry explicitly phases that part of the fix to
Phase 4, where the router also gains the API surface change (a scratch parameter, or reading
from the request's context) needed to remove them correctly. See `DECISIONS.md` (`DEC-15`) for
why Phase 2's Definition of Done was corrected to say so explicitly rather than silently drift
from the registry.
The routers use an explicit per-connection scratch passed through `AbstractRouter.route`; neither
`FastPathRouterImpl` nor `FastPathWsRouterImpl` retains connection state in a `ThreadLocal`.
## The `ConnectionProtocol` seam (R1 / `DEC-02`)
@@ -112,10 +108,9 @@ public interface ConnectionProtocol {
```
`ConnectionRunner` decides h1 vs h2 exactly once, immediately after ALPN/preface detection, and
dispatches. Today only `Http1Connection` exists; an `H2` negotiation result is closed cleanly
(there is no `Http2Connection` to hand off to until Phase 8). Neither implementation is aware
the other exists — `dev.relism.flash.http1` and `dev.relism.flash.http2` do not import each other,
enforced by `PackageBoundaryTest`.
dispatches to `Http1Connection` or `Http2Connection`. Neither implementation is aware the other
exists — `dev.relism.flash.http1` and `dev.relism.flash.http2` do not import each other, enforced
by `PackageBoundaryTest`.
## Graceful shutdown (`EX-32`)
@@ -130,7 +125,8 @@ Two stages, driven by `ServerLifecycle.stop()`:
(the common case). `ServerLifecycle.stop()` polls `activeSockets` for up to
`shutdownDrainTimeoutMs`, then force-closes whatever remains and shuts down the executor.
HTTP/2's half of this fix (a `GOAWAY` frame, RFC 9113 §6.8) lands in Phase 8.
HTTP/2 shutdown sends the two-stage `GOAWAY` sequence from RFC 9113 §6.8 before the lifecycle's
drain deadline force-closes remaining sockets.
## What changed for WebSocket (`EX-01`, `EX-11`, `EX-12`, `EX-13`)
+44
View File
@@ -0,0 +1,44 @@
# HTTP performance baselines
These numbers are regression controls, not cross-machine promises. They were measured on
2026-08-13 under Linux 6.12/KVM, six exposed cores of an AMD Ryzen 7 1700X, Temurin 21.0.11 and
JMH 1.37. CI uses short independent forks for allocation and sample latency so the sampling
harness does not contaminate `gc.alloc.rate.norm`.
## Gated hot paths
| Benchmark | B/op | p50 ns | p99 ns | p999 ns | CI p99 ceiling ns |
|---|---:|---:|---:|---:|---:|
| h1 parse and route | 0.022 | 540 | 33,472 | 60,822 | 45,000 |
| h2 pooled stream lifecycle | 0.010 | 530 | 2,138 | 37,724 | 2,900 |
| h2 response encoding | 0.004 | 210 | 993 | 14,626 | 1,350 |
| HPACK browser-request decode | 0.015 | 730 | 5,245 | 27,577 | 7,100 |
| HPACK typical-response encode | 0.003 | 180 | 620 | 12,025 | 850 |
| frame read/validate/discard | 0.006 | 70 | 1,999 | 90,508 | 2,700 |
The sub-byte allocation values occur with no collection and are JMH/GC-profiler rate
normalization noise. The CI allocation ceiling is 0.05 B/op. A benchmark exceeding it fails; a
baseline or ceiling change requires an explicit edit and justification here.
The table records the higher percentile observed across three consecutive controlled runs; this is
important because short sample-mode runs on the shared KVM host showed visible scheduler noise.
The p999 values expose those tails but are recorded rather than gated. The p99 ceilings are the
worst observed p99 plus about 35% headroom.
## HTTP/1 historical comparison
The plan required a pre-Phase-1 number, but no benchmark was committed at that point. Phase 17
reconstructed the current `RequestPipelineBenchmark.parseAndRoute` fixture against Phase 0 commit
`db6e4a4` in a detached worktree and ran both revisions on the same host and JVM:
| Revision | ns/op | B/op |
|---|---:|---:|
| Phase 0 (`db6e4a4`) | 976.195 ± 45.924 | 224.007 |
| Phase 17 | 1,024.602 ± 50.744 | 0.007 |
The hardened parser's mean is 5.0% higher and removes effectively all 224 B/op. The 99.9%
confidence intervals overlap (`930.2711,022.120` ns for Phase 0 and `973.8581,075.345` ns for
Phase 17), so this run does not establish a statistically significant latency regression. This is
an honest reconstruction, not a claim that an absent historical run existed. Phase 17 recovered
about 4.5% by having `RequestParser` populate `Http1HeaderMap`'s zero-copy index during the same
validated header pass instead of rescanning every line; all security checks remain in that path.
+29
View File
@@ -0,0 +1,29 @@
# HTTP/2 cleartext
TLS HTTP/2 and cleartext HTTP/2 have independent rollout controls:
- `http2Enabled` advertises `h2` through TLS ALPN.
- `http2CleartextEnabled` accepts the HTTP/2 prior-knowledge preface on plaintext listeners.
Both default to `false`. Cleartext support follows RFC 9113 prior knowledge. The obsolete
HTTP/1.1 `Upgrade: h2c` transition is intentionally unsupported.
## Header conversion
`HopByHopHeaders` is the single policy used at connection boundaries. It removes fields named by
`Connection`, the standard hop-by-hop set, HTTP/2-forbidden fields and pseudo-fields. `TE` is
forwarded only as `trailers` when the target is HTTP/2. Tests execute the same policy for all four
HTTP/1.1 and HTTP/2 source/target combinations.
## Authority and 421
On TLS HTTP/2 connections, Flash checks `:authority` against the selected certificate's DNS/IP
subject alternative names. An authority outside that served set receives `421 Misdirected
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.
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.
+84
View File
@@ -0,0 +1,84 @@
# HTTP/2 compliance
This document records the repeatable protocol gate for Flash's HTTP/2 server. The automated
matrix runs from Maven; external tools are selected through system properties so local builds
without them skip only the corresponding interoperability adapter. CI installs and enables every
command-line client listed below.
## h2spec
Validated on 2026-08-13 with h2spec 2.6.0.
| Listener | Cases | Failures | Skips |
|---|---:|---:|---:|
| TLS with ALPN `h2` | 146 | 0 | 0 |
| Cleartext prior knowledge on the mixed HTTP/1.1 + HTTP/2 port | 145 | 0 | 0 |
`H2SpecComplianceTest` parses h2spec's JUnit XML and fails on a failure, error, or skipped case.
The cleartext selection omits only `http2/3.5/2`, which sends a complete invalid HTTP/2 preface.
That case assumes a dedicated HTTP/2 endpoint. Flash deliberately has one cleartext port that
selects HTTP/2 only when the 24-byte prior-knowledge preface matches; any other initial bytes are
HTTP/1.1 input. RFC 9113 section 3.3 defines the exact preface as the cleartext protocol selector,
while section 3.4's `PROTOCOL_ERROR` applies after an endpoint is operating as HTTP/2. The HTTP/2
state machine itself does return `GOAWAY(PROTOCOL_ERROR)` for a complete invalid preface, covered
byte-for-byte by `invalid-preface.hex`. Excluding the mixed-port negotiation case therefore does
not waive an HTTP/2 state-machine requirement.
## Interoperability
Automated results recorded on 2026-08-13:
| Client | Version | Mode and coverage | Result |
|---|---|---|---|
| curl | 8.5.0, libnghttp2 1.59.0 | TLS and h2c; GET, POST, 2 MiB upload/download | pass |
| Java `HttpClient` | Temurin 21.0.11+10 | TLS; GET, POST, large bodies and multiplexing | pass |
| nghttp | nghttp2 1.59.0 | TLS and h2c; verbose SETTINGS/HEADERS/DATA trace, POST and 2 MiB download | pass |
| grpcurl | 1.9.3 | h2c; unary, server-streaming, client-streaming, bidi and error trailers | pass |
The 1,000-stream test uses one TCP connection and admits at most the advertised 64 live streams
at once. This tests 1,000 multiplexed stream lifecycles without contradicting
`SETTINGS_MAX_CONCURRENT_STREAMS` or weakening the production memory bound.
Chrome and Firefox are a release smoke test rather than a CI dependency. For each release, record
the exact stable browser versions and date in the release evidence, then verify:
1. Load a TLS route and confirm `h2` in the browser network protocol column.
2. Exercise GET, POST, a large upload and a large streamed download.
3. Open the same registered WebSocket route over HTTP/1.1 and RFC 8441, exchange a fragmented
message larger than one flow-control window, and close from each side once.
4. Confirm no certificate, console, failed-request, or retry-to-HTTP/1.1 warnings.
This manual row is intentionally not represented as an automated pass: browser release testing
must record the browsers actually shipped at release time rather than a stale development image.
## Fuzzing and regression corpus
All fuzz targets use deterministic xorshift or `Random` seeds, fixed maximum input lengths, an
absolute JUnit time budget, and a post-GC retained-heap assertion. Untyped runtime failures fail
the test immediately. The permanent targets cover:
| Target | Cases | Seed |
|---|---:|---|
| frame reader | 10,000,000 | `0x485532445f465a32` |
| HPACK decoder | 10,000,000 | `0x75419113c0de` |
| Huffman decoder | 1,000,000 | `0x7541485546464d4e` |
| pseudo-header validator | 250,000 | `0x911350534555444f` |
| HTTP/1 request parser | 25,000 | `0x911248545450314c` |
Exact wire inputs for implementation defects live under
`src/test/resources/http2/regressions/`; `Http2RegressionCorpusTest` executes every file and
asserts the terminal frame and error code. The nightly `Http2SoakTest` defaults to ten minutes of
GET, POST, streaming DATA, reset and PING traffic, with retained-heap assertions. A short run can
be requested with `-Dflash.http2.soak=true -Dflash.http2.soak.seconds=10`.
## Deliberately absent features
- HTTP/2 server push is not exposed. A client cannot send `PUSH_PROMISE` to a server (RFC 9113
section 6.6); receiving one is a connection error. Flash does not originate push.
- RFC 7540 dependency-tree priority scheduling is not implemented. RFC 9113 section 5.3.2
deprecates the scheme; PRIORITY frames are validated and ignored as required.
- `Upgrade: h2c` is not implemented. RFC 9113 section 3.1 removed the HTTP/1.1 upgrade mechanism;
cleartext support uses section 3.3 prior knowledge.
These omissions do not create alternate request/response APIs: HTTP/1.1 and HTTP/2 remain peers
behind the transport protocol boundary.
-953
View File
@@ -1,953 +0,0 @@
# Flash HTTP/2 — Decision Log
This is the living record of every non-obvious choice made while implementing
`flash/docs/http2/IMPLEMENTATION-PLAN.md`. It is not a changelog of what was built — the git
history is that — it is a record of *why*, for choices that were not forced by the RFC and that
a future reader would otherwise have to re-derive or, worse, silently re-litigate.
Every entry: **Context / Options / Decision / Consequence / Revisit when**.
Seeded at Phase 0 with `DEC-01``DEC-10` (the decisions already implied by the plan itself, per
Appendix A). Every subsequent non-obvious choice appends a new entry with the next free number.
Numbers are never reused, even if a decision is later reversed — the reversal gets its own entry
that supersedes the earlier one and says so explicitly.
---
## DEC-01 — HTTP/2 lives in `flash` core, package `dev.relism.flash.http2`, not an extension
**Context.** Flash has an extension mechanism (`flash-ext-*` modules) for optional
functionality. HTTP/2 could in principle be shipped as `flash-ext-h2`.
**Options.**
1. Ship as an extension, loaded optionally.
2. Ship in `flash` core, alongside HTTP/1.1.
**Decision.** Core (option 2).
**Consequence.** The protocol decision (h1 vs h2) is made once, immediately after
ALPN/preface detection, inside the transport layer. `HttpServer` (and its Phase 2 replacement)
is package-private to `flash` core; an extension cannot hook into ALPN negotiation or the
accept loop without core exposing seams it does not otherwise need. HTTP/2 is a transport
concern in the same sense HTTP/1.1 is — it cannot be optional in the way, say, an OpenAPI
generator is.
**Revisit when.** Never, absent a restructuring of the extension mechanism itself to support
transport-level extensions (not currently planned).
---
## DEC-02 — h1 and h2 are peers behind a `ConnectionProtocol` seam, never flags in shared code
**Context.** The obvious shortcut is `if (isHttp2) { ... } else { ... }` scattered through the
existing HTTP/1.1 code paths.
**Options.**
1. Flag-branch inside shared code.
2. A `ConnectionProtocol` interface with two implementations (`Http1Connection`,
`Http2Connection`), selected once per connection.
**Decision.** Option 2 (R1).
**Consequence.** Shared code (byte scanning, the writer discipline, `Request`/`Response`) is
extracted upward into protocol-neutral components (`dev.relism.flash.bytes`,
`ResponseSerializer`), never pushed sideways with a protocol flag. This is enforced by an
architecture test (Phase 2) asserting `dev.relism.flash.http1` never references
`dev.relism.flash.http2` and vice versa. The cost is more up-front extraction work in Phase 2 and
Phase 6; the benefit is that h1 throughput cannot regress from an `if` that the JIT fails to
eliminate, and that either implementation can be read in isolation.
**Revisit when.** Never — this is a structural invariant, not a tunable.
---
## DEC-03 — `ReentrantLock` everywhere, never `synchronized` around blocking I/O
**Context.** Java 21 (this project's baseline) has virtual threads (JEP 444) but not JEP 491
(which removes `synchronized` carrier-pinning); JEP 491 lands in JDK 24. A virtual thread that
blocks inside a `synchronized` block pins its carrier platform thread for the duration of the
block, including any blocking I/O inside it.
**Options.**
1. Keep `synchronized` where it already exists (`WebSocketSession`, `EX-01`) and accept the
pinning risk.
2. Replace every `synchronized` block that can block on I/O with `java.util.concurrent.locks
.ReentrantLock`, which unmounts a blocked virtual thread instead of pinning its carrier.
**Decision.** Option 2, applied retroactively to the existing WebSocket code (Phase 2) and as a
standing rule for every future connection-writer path, most importantly `Http2FrameWriter`
(Phase 3).
**Consequence.** One virtual thread blocking on a slow write no longer starves the carrier pool
for every other connection scheduled onto that carrier. The cost is that `ReentrantLock` is
slightly more expensive than an uncontended `synchronized` monitor in the*platform-thread* case
— irrelevant here, since every request-serving thread in this codebase is virtual.
**Revisit when.** The project's Java baseline moves to JDK 24+ and JEP 491 is confirmed to
remove pinning for `synchronized`. Even then, `ReentrantLock`'s explicit `tryLock()` — which
`synchronized` cannot offer — is load-bearing for Phase 3's writer design, so this decision
would only partially reverse.
---
## DEC-04 — The HPACK **encoder** uses the static table only; no dynamic table
**Context.** RFC 7541's dynamic table is optional for an encoder (a decoder must always
support the peer using one; nothing requires the encoder to use one itself). Using it on the
encode side would save bytes on repeated headers (e.g. a constant `server` value) but requires
mutable, connection-shared state: an insertion changes indices for every subsequent encode on
that connection.
**Options.**
1. Encoder uses the dynamic table, saving bytes on repeated custom headers.
2. Encoder emits only Indexed (static) and Literal-Without-Indexing representations; no dynamic
table, no mutable encoder state.
**Decision.** Option 2.
**Consequence.** The write path — already the project's largest architectural risk (Phase 3) —
needs no shared-table lock and no invalidation protocol across concurrently-writing streams.
The cost is a few extra bytes per response for headers that do not already have a static-table
entry (i.e. everything except the ~30 header names RFC 7541 Appendix A knows about). The
encoder still honours the peer's `SETTINGS_HEADER_TABLE_SIZE` by sending a Dynamic Table Size
Update of 0 at the start of the first header block, declaring "I will never use this table" —
a correctness detail, not optional politeness (Phase 9 task 1).
**Revisit when.** Benchmark evidence (Phase 17) shows the extra wire bytes materially hurt
throughput or latency on a realistic workload — not before. A shared dynamic table is a
non-trivial correctness surface (see `DEC-06`'s discussion of the analogous decode-side hazard)
and should only be taken on with a measured reason.
---
## DEC-05 — Huffman-encode constants at boot; emit runtime values as raw literals
**Context.** HPACK lets the encoder Huffman-code any string at its option. Constants (status
lines, `content-type` values) are a closed, known set and can be Huffman-encoded once, at class
initialization, for free at runtime. Runtime-generated values (a dynamic `ETag`, a user-set
custom header) would need to be Huffman-encoded on every response.
**Options.**
1. Huffman-encode everything, including runtime values, on every write.
2. Huffman-encode only boot-time constants; emit runtime values as raw (uncompressed) literals.
**Decision.** Option 2, with `FlashConfiguration.h2HuffmanDynamicValues` (default `false`) so
option 1's cost/benefit can actually be measured on real traffic rather than argued about in
the abstract.
**Consequence.** The response write path's critical section has no per-byte Huffman encode
loop for the common case. The cost is a few extra bytes on the wire for runtime header values,
which HPACK's other mechanisms (indexing on the receive side, if the receiver chooses to use
its dynamic table) can still partially recover.
**Revisit when.** Phase 17 benchmarks the flag both ways on a representative response shape.
---
## DEC-06 — Decoded headers are copied into a **per-stream** arena, not referenced in the dynamic table
**Context.** A `ByteView` into the HPACK dynamic table's arena is valid only while its entry is
still live. Under HTTP/1.1 this is trivially safe (one thread, one request at a time). Under
HTTP/2, the demux thread can decode a second stream's HEADERS — evicting and overwriting
dynamic-table arena bytes — while a handler on a different virtual thread is still reading a
view produced by an earlier decode. This is a genuine, silent data race: it does not manifest
in any test that decodes one block at a time, only under real multiplexed load.
**Options.**
1. Reference dynamic-table entries directly from decoded `ByteView`s, and protect them with an
epoch or reference-count scheme so an entry cannot be evicted while still referenced.
2. Copy every decoded header (name and value) into an arena owned by the stream being
assembled, at decode time. One `~30`-byte-average `memcpy` per header; correctness by
construction, no cross-thread coordination.
**Decision.** Option 2.
**Consequence.** Header decode is not zero-copy relative to the dynamic table (R3's "honest
naming" clause applies: HTTP/2 copies each novel header once per connection and references it
by index thereafter — the per-stream arena copy is that one copy). In exchange, no handler can
ever observe a torn or evicted header value, and the demux thread never needs to coordinate
with a handler thread to decode the next block. Per-stream arenas are pooled (returned on
stream close) so this is zero allocation at steady state despite the copy.
**Revisit when.** Profiling (Phase 17) shows the per-header copy is a measurable cost on a
realistic HPACK-heavy workload. Even then, option 1's concurrent bookkeeping is a large
correctness surface to take on to avoid a small `memcpy`, and should not be revisited casually.
---
## DEC-07 — `:authority` is exposed to user code as both `:authority` and `host`
**Context.** HTTP/2 requests carry authority information in the `:authority` pseudo-header
(RFC 9113 §8.3.1), not a `Host` header — `host` may optionally also be present and, if so, must
match `:authority`, but is not required. Existing Flash middleware (and most middleware in the
wild) reads `Host` by convention, inherited from HTTP/1.1.
**Options.**
1. Expose only `:authority`, under whatever name the h2 header map uses for pseudo-headers.
Middleware written against `Host` silently breaks on h2.
2. Expose `:authority`'s value under both keys: the literal `:authority` and `host`.
**Decision.** Option 2.
**Consequence.** A single small duplication (one extra index entry into the same per-stream
arena bytes — no extra copy) buys behavioural parity for existing and future middleware that
reads `Host`, without requiring every middleware author to special-case h2. Documented in
`flash/docs/http2/STREAMS.md`.
**Revisit when.** Not planned to be revisited; this is a compatibility shim with negligible
cost, not a design compromise under pressure.
---
## DEC-08 — Flash ships HTTP/2, not a gRPC codec
**Context.** gRPC is one of the strongest motivations for HTTP/2 support (Pathway's upstream
use case), and it is tempting to let that motivation expand scope into shipping gRPC framing,
proto codecs, or a service-definition layer.
**Options.**
1. Ship a gRPC codec/framework alongside HTTP/2 transport support.
2. Ship HTTP/2 transport only; validate gRPC compatibility with an interop test, not a feature.
**Decision.** Option 2.
**Consequence.** Phase 12's `GrpcInteropTest` proves that the protocol features gRPC actually
needs — trailers, `content-type: application/grpc`, `te: trailers`, half-close, streaming — are
present and correct, using a real gRPC client against a hand-written Flash handler that speaks
the wire format directly. Flash does not gain a dependency on any gRPC/protobuf library, and
users who want a gRPC service framework build it on top of Flash rather than being handed one.
**Revisit when.** Not planned to be revisited; this is a scope boundary, not a temporary
limitation.
---
## DEC-09 — The chosen `Http2FrameWriter` design, with its benchmark numbers
**Context.** Phase 3 is a GO/NO-GO gate: build and benchmark the connection-level serialized
frame writer, the one genuinely novel architectural risk in this codebase's HTTP/2 work (see
Part I's "one thread owns the socket" framing). Three candidate designs were built and compared
against the plan's numeric gate criteria: (a) `plain_lock` — unconditional
`ReentrantLock.lock()` per frame; (b) `trylock_mpsc` — `tryLock()` fast path with an intrusive
Vyukov-style MPSC queue fallback; (c) `dedicated_thread` — every write handed off via the same
MPSC queue to one dedicated, parked/unparked writer thread. A fourth harness,
`raw_unsynchronized` (no coordination at all — unsafe, not a candidate), establishes the N=1
baseline the 50 ns budget is measured against.
**Options.** (a), (b), (c) as above — full description, JMH methodology, and raw numbers in
`flash/docs/http2/WRITER.md`.
**Decision.** (b), `trylock_mpsc` — matching the plan's own proposed design. Measured against
every gate criterion (JDK 21.0.11, JMH 1.37; see `WRITER.md` for the complete methodology
including its two stated caveats — an in-memory counting sink rather than a real loopback
socket, and one JMH "op" being a 4 000-write burst rather than a single write):
| Criterion | Result | Verdict |
|---|---|---|
| N=1: 0 B/op | 0.0015 B/write differential vs. `raw_unsynchronized`, within measurement noise | PASS |
| N=1: ≤50 ns overhead vs. raw unsynchronized | 42.6 ns point estimate, ≤47.9 ns at the 99.9% CI's worst case | PASS |
| N=64: throughput ≥60% of N=1 per-thread rate | 65.5% | PASS |
| N=64: p999 <1 ms | 11.814.2 µs | PASS |
| No carrier pinning (`-Djdk.tracePinnedThreads=full`) | none observed | PASS |
| Stress test green at every N ∈ {1,2,8,64,256}, 1000 iterations, incl. parallelism=1 | 10 000/10 000 | PASS |
`plain_lock` was also measured for comparison (not merely asserted inferior): it retains only
58.1% of its own N=1 throughput at N=64 (below the 60% bar `trylock_mpsc` clears) and its p999
latency blows up to 1.62.0 ms under load — unfair blocking causing tail pile-up, exactly the
failure mode a naive per-frame lock predicts. `dedicated_thread` has the best tail latency of the
three (1.56.7 µs at N=64) but pays a ~3.3× throughput penalty at N=1, because every write —
even a genuinely uncontended one — pays a full park/unpark handoff; there is no fast path for
the dominant "one active writer" case. Neither alternative is a better shipped default than
`trylock_mpsc`.
**Consequence.** `Http2FrameWriter` ships exactly as designed in the plan: `tryLock()` fast path
(one uncontended CAS on the overwhelmingly common single-writer case), intrusive MPSC fallback
under genuine contention (the `WriteIntent` itself is the queue node — zero allocation to
enqueue), `ReentrantLock` throughout (never `synchronized` — `EX-01`'s carrier-pinning fix
generalized to the connection writer), and a scan-based write-timeout reaper
(`Http2Limits.WRITE_TIMEOUT_MS`, 30 s) rather than a per-write `System.nanoTime()` deadline — an
earlier revision recorded a per-write deadline and this phase's own benchmark is what caught it
costing enough to threaten the 50 ns budget, which is itself part of why the reaper's
consecutive-scan design (documented on `Http2FrameWriter.WriteTimeoutReaper`) exists. Phase 4 may
proceed.
**Revisit when.** Not expected to be revisited — the three-candidate comparison is unlikely to
change qualitatively unless the JDK's virtual-thread scheduler or `ReentrantLock` implementation
changes materially. If a future JDK's `synchronized` stops pinning carriers (JEP 491, JDK 24+),
revisit whether `synchronized`'s simpler semantics become preferable now that its only drawback
here is removed — but `ReentrantLock` still uniquely offers `tryLock()`, which this design's fast
path depends on, so the revisit is not expected to change the outcome.
---
## DEC-10 — `Upgrade: h2c` is deliberately **not** implemented
**Context.** RFC 7540 §3.2 (the original HTTP/2 RFC) defined an `Upgrade: h2c` mechanism to
move a plaintext HTTP/1.1 connection to HTTP/2 mid-connection. RFC 9113 (which obsoletes
RFC 7540) §3.1 removes this mechanism entirely from the current specification.
**Options.**
1. Implement `Upgrade: h2c` for compatibility with any client that still relies on it.
2. Do not implement it; support cleartext HTTP/2 only via prior knowledge (RFC 9113 §3.4).
**Decision.** Option 2.
**Consequence.** Every h2c client that matters for Flash's use case (gRPC, and every modern h2c
implementation) uses prior knowledge, not the upgrade dance, so nothing is lost in practice.
Recorded explicitly so a future contributor who notices `Upgrade: h2c` is unhandled does not
assume it was an oversight and add it back.
**Revisit when.** A concrete client that requires `Upgrade: h2c` and cannot be changed is
identified. Not anticipated.
---
## DEC-11 — Commit scope stays `core`; `h2` is not added to `AGENTS.md`'s allowed-scope list
**Context.** `AGENTS.md` (§Commit Messages) enumerates the allowed Conventional Commits scopes.
`h2` is not among them. R9 leaves the choice open: either add `h2` as a new scope via a
`docs:` commit, or use `core` and record the decision here.
**Options.**
1. Add `h2` as a new allowed scope, so h2-specific commits are distinguishable in history from
other core work at a glance.
2. Use the existing `core` scope for all HTTP/2 work.
**Decision.** Option 2.
**Consequence.** All HTTP/2 commits use `feat(core): ...` / `fix(core): ...` /
`refactor(core): ...`, consistent with the branch name (`feature/core/http2`) and with `DEC-01`
(HTTP/2 is core, not a separate concern). A reader can still find every h2-related commit via
the file paths touched (`dev.relism.flash.http2/**`, `flash/docs/http2/**`) or via the commit body,
which is no worse than a scope label and avoids growing the scope list for what is, by `DEC-01`,
not actually a separate module.
**Revisit when.** The `h2` package's commit volume makes `core` too coarse to navigate in
`git log` — not expected before Phase 10 at the earliest, if ever.
---
## DEC-12 — Phase 1 plan corrections: two missing files, one corrected limit check
**Context.** While implementing Phase 1, two problems in the plan document itself surfaced
(distinct from problems in the *code*, which is what the `EX-nn` registry tracks).
1. Phase 1 task 8 requires "a `BufferedByteSource` owned by the connection that wraps the read
buffer plus the socket and exposes `readByte()`, `readFully(...)`, `skip(...)` and `peek()`",
and task 12 depends on it for h2c preface detection — but the Phase 1 **Files** list never
named the file. Likewise, the typed rejection `EX-02`/`EX-03`/`EX-08`/`EX-18` all need (a
specific HTTP status to respond with, as distinct from `HttpException`'s handler-routed
semantics — see `DEC-14`) was never named as a file either.
2. Task 4's exact wording — "Enforce `MAX_REQUEST_LINE_LENGTH` against `headerEndIdx - base` for
the request line specifically" — describes checking the length of the *entire header block*
(`headerEndIdx` is where the whole header section ends), not the request line. The request
line's own end is `protocolEnd` (or `sectionStart`), not `headerEndIdx`.
**Decision.**
1. Added `flash/src/main/java/dev/relism/flash/transport/BufferedByteSource.java` and
`flash/src/main/java/dev/relism/flash/exceptions/MalformedRequestException.java` to Phase 1's
Files list (see the phase section itself, now corrected in place).
2. Implemented the check as `protocolEnd - base > MAX_REQUEST_LINE_LENGTH` — the request line's
actual span — rather than the literal (and, read literally, incorrect) `headerEndIdx - base`.
**Consequence.** None beyond the plan text now matching what was actually built and why —
these are wording/omission fixes, not design trade-offs. Recorded per the plan's own rule that
corrections to the plan must be explicit and tracked, never silent.
**Revisit when.** N/A — already resolved.
---
## DEC-13 — `BufferedByteSource`'s deadline is enforced by computing the exact remaining `SO_TIMEOUT` per underlying read, not by a fixed poll-and-retry loop
**Context.** `EX-07` requires an *absolute* deadline across a sequence of socket reads (a
per-read `SO_TIMEOUT` alone never trips against a peer that keeps each individual read within
the window while never completing the whole message — the canonical slowloris shape). Two ways
to implement that on top of the blocking `Socket`/`SSLSocket` API, which only offers a per-read
timeout:
**Options.**
1. Set `SO_TIMEOUT` to a fixed, short polling interval (e.g. 1 s); on each
`SocketTimeoutException`, re-check whether the absolute deadline has actually passed, and if
not, retry. Deadline precision is bounded by the poll interval (up to ~1 s of slop).
2. Before every underlying read, compute the exact remaining budget
(`deadlineNanos - System.nanoTime()`) and hand that exact value to `setSoTimeout`. A
`SocketTimeoutException` from that read then unambiguously means the deadline — not merely
one poll cycle — has elapsed, with no retry loop needed.
**Decision.** Option 2.
**Consequence.** Deadline precision is exact (modulo OS timer granularity) rather than
poll-interval-bounded, and the implementation is simpler — no retry loop, no distinction between
"timed out this poll" and "timed out for real". The cost is one `setSoTimeout` syscall per
underlying fill (not per byte, not per `read()` call served from the buffer) — negligible, since
fills already happen at buffer granularity (up to 8 KiB at a time), not per byte.
**Revisit when.** Not expected to be revisited; this is strictly better than option 1 on both
precision and simplicity.
---
## DEC-14 — `MalformedRequestException extends HttpException`; caught separately from the per-request handler try/catch, never routed through the user's exception handler
**Context.** `EX-02`/`EX-03`/`EX-08`/`EX-18` all need to reject a request with a specific HTTP
status before any handler or middleware runs. `HttpException` already exists in this codebase
for "carry a status code, get turned into a response" — but it is caught by
`router.getExceptionHandler()` inside the per-request try/catch, which is user-configurable
(e.g. `flash-ext-jackson` installs a JSON-formatting handler).
**Options.**
1. Reuse `HttpException` directly, letting a malformed request flow through the same
user-configurable exception handler as an application-level failure.
2. A new type, `MalformedRequestException extends HttpException`, caught at a separate site —
around `parser.parse(in)` itself, before routing — with a fixed, minimal, non-customizable
response, always followed by closing the connection.
**Decision.** Option 2.
**Consequence.** A malformed or hostile request never reaches user code at all — not the
handler, not middleware, not a custom exception handler that might (reasonably, for its actual
purpose) try to look up a route, log structured JSON, or otherwise do work that assumes a
well-formed `Request`. The connection is always closed afterwards, never kept alive, which is
exactly the property `EX-02`'s smuggling defense depends on. Subclassing `HttpException` (rather
than an unrelated new hierarchy) keeps `status()`/message` access idiomatic with the rest of the
codebase's error-status convention, while the distinct type is what lets `HttpServer` catch it
at the parse site specifically.
**Revisit when.** Not expected to be revisited.
---
## DEC-15 — Phase 2 plan correction: the "no `ThreadLocal` anywhere" DoD line was inconsistent with `EX-06`'s own phasing
**Context.** Phase 2's DoD stated flatly: "No `ThreadLocal` remains anywhere in `flash` core."
`EX-06`'s registry entry — the fix this DoD line is checking — explicitly phases itself:
"**Phase**: 2 (introduce), 3 (h2 consumes it), 4 (router consumes it)." `FastPathRouterImpl` and
`FastPathWsRouterImpl`'s `ThreadLocal`s (`MatchResult`, `MethodPathByteView`) are the "router
consumes it" part, assigned to Phase 4 — where the router also gains the scratch-parameter (or
request-context) API surface change needed to remove them correctly, per `EX-06`'s own fix
description ("the router now takes the scratch as a parameter or reads it from the request's
context"). Taken literally, Phase 2's DoD line would have required either doing Phase 4's router
work two phases early (undermining the reason `EX-06` was split across phases in the first
place — the router-facing API change is more invasive and deserves its own phase) or leaving the
DoD unresolvable.
**Options.**
1. Do the full router `ThreadLocal` removal now, in Phase 2, to satisfy the DoD line literally.
2. Correct the DoD line to match `EX-06`'s already-considered phasing, and record why.
**Decision.** Option 2.
**Consequence.** Phase 2 removes every `ThreadLocal` `HttpServer` itself owned (`SHA1`,
`LONG_BUF`, `STREAM_RELAY_BUFFER` — all now fields on `ConnectionScratch`). The router's two
`ThreadLocal`s are explicitly left for Phase 4, tracked there, not silently dropped — this is
still R10-compliant (the defect is registered and scheduled, not ignored) and keeps Phase 2
scoped to what it already set out to do (kill the `HttpServer` god class), rather than absorbing
an unrelated API-surface change under deadline pressure.
**Revisit when.** N/A — resolved; Phase 4 closes the remaining `EX-06` scope.
---
## DEC-16 — No separate `WebSocketFrameCodec` class; the `EX-11`/`EX-12` fixes stay inside `WebSocketSession`
**Context.** Phase 2's file list named `dev.relism.flash.websocket.WebSocketFrameCodec.java`,
extracted from `WebSocketSession`, as a Phase 2 deliverable — motivated by R6 (no god classes)
and by a forward reference in Phase 15 ("this requires abstracting its InputStream/OutputStream
pair behind a small interface — which the Phase 2 WebSocketFrameCodec extraction should already
have made possible").
**Options.**
1. Extract a `WebSocketFrameCodec` operating on byte arrays/scratch buffers, with
`WebSocketSession` calling into it for encode/decode and owning only the actual stream I/O.
2. Keep frame encode/decode inside `WebSocketSession`, where it already lived.
**Decision.** Option 2, for this phase.
**Consequence.** `WebSocketSession` after the `EX-01`/`EX-11`/`EX-12` fixes is ~360 lines — over
R6's soft ~250-line guidance, but R6 itself carves out exactly this case: "a 300-line class that
is one cohesive state machine ... is fine; a 150-line class doing two things is not." Frame
header decode, continuation reassembly, and masking are one state machine (RFC 6455 §5's frame
grammar), not two unrelated responsibilities glued together, so the soft guidance's exception
applies. Splitting it now, before any concrete second caller exists, risks the "artificial
split that doesn't reduce complexity" R6 also warns against implicitly — there is no code today
that would consume a standalone codec except `WebSocketSession` itself. Phase 15's forward
reference is noted and re-evaluated then: if RFC 8441 (WebSocket over h2) genuinely needs frame
encode/decode decoupled from a socket-backed `InputStream`/`OutputStream` pair (an h2 stream is
not one), the extraction happens at that point, with a real second shape driving the interface
instead of a speculative one.
**Revisit when.** Phase 15, when RFC 8441's transport requirements are concrete.
---
## DEC-17 — `FrameWriterBenchmark` lives in `src/jmh/java`, a source root registered only inside the `jmh` profile, not in `src/test/java`
**Context.** The Phase 3 JMH benchmark (`FrameWriterBenchmark`) was first placed directly in
`src/test/java/dev/relism/flash/http2/frame/`, on the theory recorded in `flash/pom.xml`'s comment
at the time: since the class carries only `@Benchmark`/JMH annotations and no JUnit annotations,
Surefire's JUnit-Jupiter engine would simply not select it as a test, so a plain `mvn test` (no
`-Pjmh`) would harmlessly ignore it. Verifying this assumption (`mvn -pl flash -am clean
test-compile`, no profile) showed it is false: Surefire's `junit-jupiter` engine performs test
*discovery* by loading every class under `target/test-classes`, regardless of whether it
ultimately selects it as a test — and `FrameWriterBenchmark` cannot even compile without
`jmh-core` on the classpath (it imports `org.openjdk.jmh.annotations.*` unconditionally), so with
the `jmh` profile inactive the module's test-compile step failed outright: "package
org.openjdk.jmh.annotations does not exist". A plain `mvn test` on `flash` — the command every
other phase's DoD, and CI itself, uses to verify "still green" — was broken for the entire
module, not merely silently skipping the benchmark as intended. This was caught only because
this phase's resume step re-ran `mvn test` (via the maven-wrapper distribution under
`~/.m2/wrapper/dists`, not a bare `mvn` on `PATH`) without `-Pjmh`, rather than re-running the
`-Pjmh`-scoped command the prior session had been using — the same class of gap R10 exists to
catch, just in the build graph rather than the source graph.
**Options.**
1. Keep the benchmark in `src/test/java`, and instead exclude it from the default Surefire test
set via `<excludes>` in the `maven-surefire-plugin` configuration, re-including it only when
`-Pjmh` is active. This still leaves it on the default `test-compile` classpath, so the
compile failure would remain — excludes only affect which already-compiled tests Surefire
*runs*, not what the compiler plugin *compiles*. Rejected: does not fix the actual failure.
2. Move it to its own source root, `src/jmh/java`, and register that root as a test-source
directory (`build-helper-maven-plugin`'s `add-test-source` goal) only inside the `jmh`
profile's `<build>`. With the profile inactive, the file is not handed to the compiler at
all, under any goal — not `test-compile`, not IDE indexing driven by the effective POM.
This is also what the plan itself already suggested (Phase 3's Files list: `flash/src/jmh/
java/dev/relism/flash/http2/FrameWriterBenchmark.java (or a flash-bench submodule...)`) — the
prior session's placement in `src/test/java` was itself a deviation from the plan's own
suggested layout, not a considered alternative.
3. A separate `flash-bench` submodule, depending on `flash` and always pulling in JMH. The
plan's own text offers this as the other option, rejected for the same reason a `jmh` profile
was chosen over it in the first place: a whole extra module (its own `pom.xml`, its own
`groupId:artifactId`, its own place in the reactor) for one benchmark class is disproportionate
machinery, and it does not obviously fix the underlying problem either — `mvn test` from the
repo root still touches every reactor module and would still need the module's own default
build to not require JMH.
**Decision.** Option 2 — matching the plan's original suggestion, which is exactly what should
have been done the first time.
**Consequence.** `mvn -pl flash -am test` (no profile) compiles and runs the ordinary unit/stress
tests only, exactly as every other phase's DoD assumes, and never touches JMH. `mvn -Pjmh -pl
flash test-compile` (or any goal at `generate-test-sources` or later, with the profile active)
additionally compiles `src/jmh/java` into `target/test-classes`, exactly where
`FrameWriterBenchmark`'s own Javadoc's run instructions already expected it, so that Javadoc
needed no change. `build-helper-maven-plugin` (`${build.helper.plugin.version}`, `3.6.0`) is a
new build-time-only dependency of the `flash` module, added to the root `pom.xml`'s
`<properties>` alongside `jmh.version`, consistent with how every other plugin version in this
reactor is centralized. No production code changed; this is a build-graph correction only.
**Revisit when.** Not expected to be revisited.
---
## DEC-18 — Phase 17 gains a second, explicitly non-gating category of benchmark: application-level, real-`HttpServer`, showcase/literature-only
**Context.** Raised while wrapping up Phase 3, after reviewing `FrameWriterBenchmark`'s results
with the project owner. Phase 3's benchmark is deliberately narrow — it exercises only
`Http2FrameWriter` against an in-memory `CountingSink`, isolating the writer's own lock/queue
cost from network variance (see `WRITER.md`'s stated caveats). That narrowness is correct for a
GO/NO-GO *component* gate, but it means nothing in the plan yet produces end-to-end, real-
`HttpServer` numbers — realistic traffic shapes, or deliberately extreme ones (thousands of
streams on one connection, pathological header blocks, slow/bursty clients, mixed h1+h2 on one
listener) — of the kind that make a project's performance claims concrete rather than asserted.
The project owner wants exactly this: **benchmark-driven development** as an ongoing practice,
not only a one-time gate, with results available for showcase and literature purposes
(illustrating real behavior under real and extreme conditions) independent of whether they pass
or fail anything.
**Options.**
1. Fold this into Phase 17's existing JMH suite (task 1) and its allocation/latency gates (tasks
23), i.e. make these new benchmarks part of the same pass/fail pipeline as the rest of
Phase 17.
2. Add it as a distinct, explicitly non-gating task within Phase 17 — same `src/jmh` source root
as the Phase 3 writer benchmark, same JMH tooling, but no threshold, no CI wiring, output
meant to be read by a human (or quoted in a doc/blog post), not consumed by a pass/fail check.
**Decision.** Option 2, recorded now as a scoped goal for Phase 17 (Phase 17's own Tasks list,
new task 8) — **not implemented as part of Phase 3 or this decision**. Phase 4 begins immediately
after this entry with a clean, unrelated scope.
**Consequence.** Phase 17, when it lands, produces two categories of benchmark under `src/jmh`,
and both must stay distinguishable at a glance (by class name, by package, or by a doc-comment
banner — decided when Phase 17 is actually implemented): (a) the gating suite — allocation-rate
and latency-regression checks that fail CI, matching this phase's existing tasks 13, run against
narrow, isolated scenarios exactly like `FrameWriterBenchmark`; and (b) the showcase suite —
real, end-to-end `HttpServer`/h2-connection scenarios, including deliberately extreme ones, that
only print results and never gate anything. Keeping (b) non-gating is deliberate: an "extreme
case" benchmark (e.g. 10 000 streams on one connection) is valuable precisely because it shows
*how* the system behaves under stress, including graceful degradation — turning that into a
pass/fail threshold would either be meaningless (no natural "correct" number for a pathological
case) or would quietly narrow what counts as an "extreme case" down to whatever currently passes.
**Revisit when.** Phase 17 is actually started — at that point this entry's task 8 becomes
concrete work with its own scenario list, harness design, and output format, rather than a
recorded intention.
---
## DEC-19 — `EX-06`'s router half is fixed with an opaque, caller-owned per-connection scratch object, not by extending `ConnectionScratch`
**Context.** `EX-06`'s registry entry phases itself: "Phase 2 (introduce), Phase 3 (h2 consumes
it), Phase 4 (router consumes it)" — Phase 4 is where `FastPathRouterImpl`'s and
`FastPathWsRouterImpl`'s `ThreadLocal<MatchResult>`/`ThreadLocal<MethodPathByteView>` (unbounded
under virtual threads, one per connection with no upper bound and no pooling — exactly the
failure mode `ConnectionScratch` exists to avoid for every other per-connection buffer) get
removed. `ConnectionScratch`'s own class Javadoc (written in Phase 2, in anticipation) already
commits to a specific mechanism: "Extended in Phase 4 with the router's reusable
{@code MatchResult}/path-view fields."
Attempting that literally surfaced a real problem: `ConnectionScratch` lives in
`dev.relism.flash.transport`; the router lives in `dev.relism.flash.routing` (and
`dev.relism.flash.routing.routers.fastpathrouter`). Today `transport` depends on `routing`
(`ConnectionContext` holds `AbstractRouter`/`AbstractWsRouter`) but **`routing` has zero imports
of `transport`** anywhere in this codebase (verified by grep, not assumed) — a clean one-way
dependency. Adding the router's scratch fields to `ConnectionScratch` and passing it into
`route()` would require `routing`'s classes to import `transport.ConnectionScratch`, creating the
first reverse edge and a genuine package cycle where none exists today.
**Options.**
1. Extend `ConnectionScratch` as its own Javadoc already describes, accepting the new
`routing → transport` edge (and the resulting cycle with the existing `transport → routing`
edge).
2. `AbstractRouter`/`AbstractWsRouter` gain a `newScratch()` method (default `null`) that each
router implementation overrides to return an opaque, implementation-specific object (kept as a
package-private nested class — `FastPathRouterImpl.RouteScratch`,
`FastPathWsRouterImpl.RouteScratch` — never a new public type). The connection driver
(`Http1Connection.run`) calls `newScratch()` **once per connection**, exactly the same
"created once, held by the loop, reused across every request" shape already used there for
`RequestParser`, and passes the opaque result into every `route(request, scratch)` call for
that connection's lifetime. No package outside `routing`/`routing.routers.fastpathrouter` ever
sees the concrete scratch type.
**Decision.** Option 2.
**Consequence.** Practically identical outcome to option 1 — one object per connection, created
once, reused across every request on that connection, replacing the `ThreadLocal`s — but without
introducing `routing`'s only dependency on `transport`. `ConnectionScratch`'s own Javadoc (which
predated this decision) is corrected in the same change to describe what was actually built
rather than the mechanism it originally assumed; `AbstractRouter.route`'s and
`AbstractWsRouter.route`'s signatures gain an `Object scratch` parameter, which is the one
API-surface cost of this approach (every router implementation, and every direct caller —
`Http1Connection` and the handful of tests that call `route()` directly — must now pass one).
`EX-19` (reusable `PathParams`/path-param arrays) piggybacks on the same `RouteScratch` object
for `FastPathRouterImpl`, since it needed an identical "created once per connection, grown to the
connection's high-water mark" lifetime — implemented together with `EX-06`'s router half rather
than as a separate pass over the same class.
**Revisit when.** Not expected to be revisited — the untyped `Object scratch` parameter is a
minor wart, but the alternative (a generic `AbstractRouter<S>` type parameter propagated through
`ConnectionContext`, `ServerHandle`, and every public router-registration API) is a far larger
API-surface change for one internal implementation detail, and is not justified unless a second
router implementation actually needs a differently-shaped scratch object — none exists today.
---
## DEC-20 — Phase 4 performance measurements: `EX-04`, `EX-33`, the router's own allocation profile, and the h1 zero-alloc contract's actual current number
**Context.** Phase 4's plan carries two explicit "measure, keep only if it earns its keep"
instructions (`EX-04`: revert if the win is negative or noise; `EX-33`: keep scalar if the SWAR
win is under 3%), plus a zero-alloc contract ("an h1 `GET /users/{id}` request that reads three
headers and one path param must be 0 B/op end to end except for the user-facing `String`s the
handler explicitly asks for. Add this as a JMH allocation test now"). All three measured together
(JDK 21.0.11, JMH 1.37, `avgt` mode, `-prof gc`, `flash/src/jmh/java`) rather than as separate
passes, since they share the same request/route fixtures.
**Measurements.**
*`EX-33` — SWAR vs. scalar `\r\n\r\n` scan, realistic ~330-byte request (`ByteScanBenchmark`):*
| | ns/op |
|---|---|
| `headerEndScan_scalar` | 134.921 ± 5.558 |
| `headerEndScan_swar` | 87.116 ± 1.411 |
SWAR is **35.4 % faster** (47.8 ns absolute) — far above the 3 % keep-threshold. **Kept.**
*`EX-04` — the `longAt`/`ByteCompare` mechanism in isolation, and the real router
(`FastPathRouterBenchmark`):*
| | ns/op | B/op |
|---|---|---|
| `byteCompare_byteAtATime` (useLong=false) | 22.281 ± 1.021 | ≈0 |
| `byteCompare_longPath` (useLong=true) | 15.146 ± 1.090 | ≈0 |
| `router_staticRoute` (real `FastPathRouterImpl.route`) | 143.409 ± 14.992 | 0.001 |
| `router_parametricRoute` (real `FastPathRouterImpl.route`, 1 param extracted) | 284.433 ± 31.510 | 0.002 |
The long path is **32.1 % faster** (7.1 ns) than the byte-at-a-time comparison it replaces, at
the mechanism level — a clear, real win, confirming `EX-04` is worth keeping. **Honest caveat**,
not a failure of the measurement but a finding in its own right: `router_staticRoute`/
`router_parametricRoute` do **not** exercise this win today, because the actual value
`FastPathRouterImpl.route` passes to `router.match()` is always a
`FastPathViews.MethodPathByteView` — a deliberate composite of method bytes + path view, which
(per `EX-04`'s own registry text) correctly keeps `supportsLong() == false`, since a word-at-a-
time read across two independent sources is unsound, not merely unoptimized. `EX-04`'s win will
apply once a future phase (`HPACK` static-table matching, frame validation — Phase 5+) compares
two genuinely-contiguous array-backed ranges directly, which is exactly the shape
`byteCompare_longPath` measures. **Kept** — implemented correctly, verified correct
(`FastPathViewsLongAtTest`), and measured worthwhile for its actual future consumers; it was
never going to show up in today's router-benchmark numbers, and the plan's own text already
predicted this by excluding `MethodPathByteView` from the fix.
Separately: both router benchmarks show **≈0 B/op** — confirms `EX-06`/`EX-19`'s scratch reuse
(the `RouteScratch` object, its reused `MatchResult`, `MethodPathByteView`, and path-param
arrays/`PathParams` instance) is genuinely zero-allocation in practice, including on a
parametric route that extracts a param.
*The h1 zero-alloc contract, end to end (`RequestPipelineBenchmark`):*
| | ns/op | B/op |
|---|---|---|
| `parseAndRoute` (parse + route only, no header/param access) | 1135.125 ± 68.888 | 120.008 |
| `parseRouteAndExtractThreeFields` (+ 1 path param, 2 headers read) | 1335.965 ± 57.378 | 304.009 |
**Not literally 0 B/op** — and this is expected, not a Phase 4 regression: the 120.008 B/op in
`parseAndRoute` (which touches no header or path-param API at all) is entirely attributable to
`Request`/`RequestBody`/`RequestLine` construction, still allocated fresh per request. That is
`EX-21`/`EX-22`'s scope, explicitly assigned to **Phase 6** ("Request/Response model refactor"),
not Phase 4's. The delta to `parseRouteAndExtractThreeFields` — 304.009 120.008 = **184.001
B/op for exactly three explicit `String` reads** (one path param, two headers) — is precisely the
"user-facing `String`s the handler explicitly asks for" the contract's own text carves out as
acceptable, and confirms that *reading* those three fields (the header index lookup, the pooled
slice, the path-param array read) itself adds no allocation beyond the unavoidable `String`
objects themselves.
**Decision.** `EX-33`: keep the SWAR scan. `EX-04`: keep the `longAt`/`supportsLong`
implementation as built — correct, tested, and measured worthwhile for the array-backed
comparisons it was designed for, independent of whether today's single call site
(`MethodPathByteView`) happens to use it. The h1 zero-alloc DoD item is recorded as: **Phase 4's
own scope (`EX-05`/`EX-09`/`EX-19`/`EX-25`/`EX-26`/`EX-33`) is verified zero-allocation**
(`router_staticRoute`/`router_parametricRoute`'s ≈0 B/op, `HeaderMapIndexTest`'s identity-based
allocation check); the remaining 120.008 B/op is `Request`/`RequestBody`/`RequestLine`
construction, out of scope until Phase 6, and is not silently hidden — this benchmark now exists
specifically so Phase 6 has a "before" number to compare against and a regression gate once
Phase 17 wires `-prof gc` into CI.
**Consequence.** No code changes from this entry — it is a measurement record. Three new
benchmark classes ship under `src/jmh/java`: `ByteScan`Benchmark, `FastPathRouterBenchmark`,
`RequestPipelineBenchmark` — all component-level and gate-relevant (unlike the `DEC-18` showcase
category, these exist to answer the plan's own explicit measurement instructions, not for
literature/demo purposes).
**Revisit when.** `RequestPipelineBenchmark`'s `parseAndRoute` number should drop close to 0 B/op
once Phase 6 lands `Request`/`RequestBody` pooling — re-run this exact benchmark then and update
this entry (or add a new one) with the "after" number, closing the loop Phase 4 opened.
---
## DEC-21 — Phase 5's zero-alloc contract, measured
**Context.** Phase 5's plan states: "Reading, validating and discarding a frame: 0 B/op ...
Writing a frame header: 0 B/op." Measured with JMH `-prof gc` (JDK 21.0.11, JMH 1.37,
`FrameLayerBenchmark`, `src/jmh/java`) rather than left as an unverified assertion, per this
project's own standing practice of measuring every stated performance/allocation claim
(`DEC-09`, `DEC-20`).
**Measurement.** `readValidateAndDiscard` (`Http2FrameReader.readFrame` +
`FrameValidator.validate` + one byte read from the payload + `consumeFrame`, against a warm,
already-grown buffer, matching real keep-alive-connection steady state): 299.846 ± 19.722 ns/op,
**0.002 B/op** — indistinguishable from zero (compare `DEC-20`'s harness-floor discussion: even
this near-zero figure is most plausibly measurement noise around the true 0, not a real
allocation, since nothing in the read/validate/consume path can be shown by inspection to
allocate on the warm path). `writeFrame` (`FrameWriteBuffer.beginFrame` + one `writeBytes` call +
`endFrame`, against an already-grown `ByteWriter`): 14.262 ± 1.084 ns/op, **≈10⁻⁴ B/op** —
likewise indistinguishable from zero.
**Decision.** Contract verified as stated; no design change required. Both numbers are recorded
here as the baseline Phase 17's eventual CI allocation gate should hold this component to.
**Consequence.** None beyond the recorded numbers — this entry exists so a future regression
(e.g. a later phase accidentally introducing an allocation on this path while adding HPACK or
stream-state integration) has a concrete "was 0, now isn't" baseline to diff against, per this
project's standing insistence that every non-obvious performance claim trace to an actual number.
**Revisit when.** Not expected to be revisited; re-measure if `FrameHeader`, `Http2FrameReader`,
or `FrameWriteBuffer` are ever modified in a way that could plausibly affect their allocation
profile.
---
## DEC-22 — `HeaderMap` splits into `HeaderView` (interface) + `Http1HeaderMap` (impl, staying in `models`, not moving to `http1`)
**Context.** Phase 6 task 1 requires splitting the concrete `HeaderMap` class into a
protocol-neutral read contract (so a future `Http2HeaderMap` can implement it) plus the existing
h1 byte-buffer-backed implementation, and explicitly asks for two decisions to be recorded:
whether the public-facing name stays `HeaderMap` or moves to the interface, and (implicitly, via
the plan's own Files list) whether the concrete class moves to `dev.relism.flash.http1`.
**Decision 1 — naming.** Checked whether `HeaderMap` is actually part of `Request`'s public
surface first, since the task's hard constraint is "the public API of `Request` must not
change": `Request`'s own methods (`header`, `headers`, `param`, `query`) return `String`/
`List<String>`, never a `HeaderMap`/`HeaderView` — the only exposure is the transitive,
Javadoc'd-as-"Internal" `Request.getRequestLine().getHeaders()` path. Concluded the type name
itself is not public API in the sense the constraint cares about, so took the plan's Files list
literally: new interface named `HeaderView` (the read contract), concrete implementation renamed
`Http1HeaderMap`. `RequestLine.headers` (and its Lombok-generated `getHeaders()`) is now typed
`HeaderView`.
**Decision 2 — package placement.** The plan's Files list suggests `http1/Http1HeaderMap.java`.
Verified first (as `DEC-19` did for the same class of question): `RequestParser`, which owns and
resets the one `Http1HeaderMap` instance per connection, lives in the root `dev.relism.flash`
package, not `http1`. `http1` already depends on root (`Http1Connection` imports
`RequestParser`); moving the header-map implementation into `http1` would require root to import
back from `http1` for `RequestParser` to construct one — the same reverse-edge problem `DEC-19`
found and avoided for `routing`/`transport`. Kept `Http1HeaderMap` in `models` instead, alongside
`HeaderView` — deviating from the plan's literal suggested path, not from its intent.
**Consequence.** `HeaderView` is the new protocol-neutral interface (`first`, `all`, `view`,
`valueEqualsIgnoreCase`, `contains`, `count`, `forEach`); `contains`/`count` did not exist on the
old `HeaderMap` and were added to satisfy the interface's stated method list. `Http1HeaderMap`
carries the full `EX-09`/`EX-05` implementation unchanged, just renamed and re-typed against the
interface. Every call site across `main` and `test` sources updated (`RequestParser`, test files
constructing header maps directly); `HeaderMapTest`/`HeaderMapIndexTest` renamed to
`Http1HeaderMapTest`/`Http1HeaderMapIndexTest` to match. 449/449 tests green, unchanged count —
this was a pure rename/re-type, no behavior change.
**Revisit when.** Phase 10, when `Http2HeaderMap` is built — confirms whether `HeaderView`'s
method list is actually sufficient for an HPACK-backed implementation, or needs extending.
---
## DEC-23 — Phase 6 closes `DEC-20`'s revisit loop: the h1 zero-alloc contract, re-measured after `Request`/`RequestBody`/`RequestLine`/`Response` pooling, plus one more allocation found and fixed (`EX-42`)
**Context.** `DEC-20` (Phase 4) measured `RequestPipelineBenchmark.parseAndRoute` at 120.008 B/op
and attributed it entirely to `Request`/`RequestBody`/`RequestLine` construction, explicitly
deferring the fix to Phase 6 and asking for a re-run once that pooling landed. Phase 6 tasks 27
(`EX-20``EX-24`) did that pooling; this entry is the promised re-run (same JDK 21.0.11, JMH 1.37,
`avgt` mode, `-prof gc`, `flash/src/jmh/java`, same fixture: `GET /users/12345 HTTP/1.1` with
`Host`/`Accept`/`Authorization`).
**First re-run, after `EX-20``EX-24` alone:**
| | ns/op | B/op |
|---|---|---|
| `parseAndRoute` | 1194.105 ± 944.469 | 48.008 |
| `parseRouteAndExtractThreeFields` | 1324.679 ± 296.883 | 232.009 |
Down from 120.008 to 48.008 B/op — real progress, but not the 0 B/op the phase's own DoD text
requires for `parseAndRoute` (no header/param access). Investigated rather than accepted: reading
`RequestParser.parse` line by line turned up three `new FastPathViews.RequestByteView(...)`
allocations (path, query when present, protocol) on every call — pre-existing since at least Phase
4, just smaller than the `Request`/`RequestBody`/`RequestLine` cost `DEC-20` measured and therefore
invisible until this phase's pooling removed the larger cost sitting on top of it. Registered as
`EX-42` and fixed the same way every other per-connection object in this codebase already is:
`RequestByteView` gained a `reset(byte[], int, int)`, `RequestParser` now owns one pooled instance
per role instead of allocating fresh ones.
**Second re-run, after `EX-42`:**
| | ns/op | B/op |
|---|---|---|
| `parseAndRoute` | 1111.260 ± 104.692 | 0.008 |
| `parseRouteAndExtractThreeFields` | 1301.840 ± 228.068 | 184.009 |
`parseAndRoute` — 0.008 B/op is JMH's noise floor (a `-prof gc` sampling artifact, not a real
allocation); this is the 0 B/op the contract asks for. `parseRouteAndExtractThreeFields` dropped
from 232.009 to 184.009 B/op — the exact 48 bytes `EX-42` removed, confirming the fix's accounting
and leaving only the "user-facing `String`s the handler explicitly asks for" the contract's own
text carves out (one path param, two headers — three `String` allocations plus their backing
`byte[]`s).
**Decision.** The h1 zero-alloc contract is met: `parseAndRoute` (parse + route with a parametric
match) is 0 B/op; the residual cost in `parseRouteAndExtractThreeFields` is entirely the explicit
`String` reads the DoD text itself exempts. `DEC-20`'s revisit item is closed.
**Consequence.** `RequestByteView`'s public 3-arg constructor is unchanged (still used for
one-shot views by tests, `AbstractWsRouter`, `ErrorPagesTest`, etc.) — only `RequestParser`'s three
call sites moved to the pooled `reset()` path. `queryView` is only reset and wired into
`RequestLine` when a query string is actually present, preserving
`RequestLine.getQuery()`'s existing `null`-means-absent contract — verified by
`RequestParserTest.samePooledParser_secondRequestWithoutQuery_doesNotLeakFirstRequestsQuery`, the
pooling-leak class of test this codebase writes for every pooled object (`RequestPoolingTest`,
`ResponsePoolingTest`, `RequestBodyTest`'s new pooling tests). 500/500 tests green.
**Revisit when.** Never expected to — this closes the loop `DEC-20` opened. If a future phase adds
a fourth per-request view (e.g. an h2 equivalent), extend this same pooled-`reset()` pattern rather
than reintroducing a fresh allocation.
---
## DEC-24 — Compact the HPACK arena and copy decoded headers into stream-owned storage
**Context.** Dynamic-table entries must be contiguous for cheap indexed lookup, but FIFO eviction
leaves holes at the front of a bounded arena. Views into that arena also cannot outlive later
decodes on a multiplexed connection.
**Decision.** Compact live dynamic entries when the free tail cannot hold an insertion. Do not use
`SegmentedByteView` for wrapped entries or CONTINUATION fragments. At the decoder boundary,
`HpackHeaderBlock` copies fields into a reusable arena owned by the stream.
**Consequence.** Compaction is occasionally O(table size), bounded by the advertised table size,
while all ordinary lookups and consumer copies remain contiguous. Stream handlers never observe
dynamic-table eviction or compaction. The JMH decode benchmark remains at the allocation noise
floor (0.001 B/op).
**Revisit when.** Profiling shows compaction is material under realistic dynamic-table churn.
---
## DEC-25 — Keep response-dependent h2spec gates with the phases that own the response path
**Context.** The Phase 8 checklist names whole h2spec sections 4 and 6.9, but several tests in
those sections require a successful response HEADERS/DATA sequence or per-stream flow-control
state. Those mechanisms are explicitly introduced in Phases 911. Making the whole sections green
now would require a temporary response/stream implementation in the connection state machine and
then deleting it immediately.
**Decision.** Phase 8 closes on every connection-owned h2spec case plus the complete unit,
integration, curl and allocation gates. Response- and stream-dependent cases remain visibly
unchecked and move with their owning Phase 911 gates. No placeholder response path is added.
**Consequence.** The connection layer stays cohesive: it validates frames and HPACK composition but
does not acquire a second, short-lived implementation of response or stream semantics. The ledger
records the partial external gate rather than claiming whole-section conformance prematurely.
**Revisit when.** Close the remaining h2spec section 4 and 6.9 cases as Phases 911 land, then rerun
the combined selection without skips.
---
## DEC-26 — Keep one protocol-neutral `PreEncodedHeader` model
**Context.** The original work plan proposed a second HTTP/2-specific `PreEncodedHeader` carrying
complete HTTP/1 and HPACK renderings. The existing public model already preserves immutable name
and value bytes, which is the common information both writers need. Adding another type would
split one application concept across protocol packages and force callers or `Response` to retain
protocol-specific state.
**Decision.** Keep `models.PreEncodedHeader` as the only public type. HTTP/1 renders its bytes as a
field line; HTTP/2 feeds the same byte ranges to the stateless encoder. Closed framework constants
(status, content type and Date) retain their specialized precompiled HPACK forms because those are
owned internally and measurably avoid work on every response.
**Consequence.** Application and middleware code builds one reusable header constant that works on
both protocols. Custom constants still traverse the HPACK literal encoder, but the measured write
path remains allocation-free and avoids duplicating the response model.
**Revisit when.** Only if profiling shows custom constant encoding is material; optimize the
existing model internally without introducing a second public header abstraction.
---
## DEC-27 — Drain already-buffered frames before dispatching completed streams
**Context.** A client can write a burst of complete requests before the server schedules their
handlers. Dispatching after every individual HEADERS frame lets a very fast handler close and
release streams while the same inbound burst is still being decoded, making the advertised
concurrency limit dependent on virtual-thread scheduling. Waiting a fixed interval would make the
limit deterministic but would add latency to every ordinary request.
**Decision.** Completed bodyless streams enter a fixed queue bounded by
`MAX_CONCURRENT_STREAMS`. The demultiplexer continues only while its own frame reader already has
bytes buffered; as soon as consuming the next frame would require network input, it drains the
queue to the shared virtual-thread executor. The configured concurrent-stream limit is 64 and the
primitive stream table has exactly the same bound.
**Consequence.** One socket read's request burst is admitted and bounded as a unit, excess streams
receive `REFUSED_STREAM`, and a single request is dispatched immediately without a timer. The demux
still never executes application code or waits for a worker. h2spec's concurrency case passes and
the lifecycle benchmark remains at the allocation noise floor.
**Revisit when.** If production traces show a materially different batching pattern, tune the
advertised limit or reader size from measurements; do not add a sleep-based dispatch delay.
---
+48
View File
@@ -0,0 +1,48 @@
# HTTP/2 bodies and flow control
HTTP/2 applies flow control independently to the connection and to every stream. Flash advertises
a 1 MiB receive window at both levels and sends WINDOW_UPDATE only after the application has
consumed at least half a window. A DATA frame decrements both windows by its complete payload
length, including the pad-length byte and padding; only its unpadded data reaches the handler.
## Request bodies
Known bodies up to 64 KiB remain in one reusable contiguous stream buffer. Their handler is
dispatched at END_STREAM, and `RequestBody.bytes()` performs the only allocation: the byte array
returned to application code. For a 1,024-byte body JMH reports exactly 1,040 B/op, the array plus
its object header, with no framework allocation around it.
Larger or unknown-length bodies dispatch after request headers. DATA is copied out of the frame
reader into a connection-owned pool of 64 reusable 16 KiB buffers. Small adjacent frames coalesce
inside a buffer, so the pool is bounded by bytes rather than frame count. The existing
`RequestBody.stream()` blocks only the handler's virtual thread when data is absent. Buffers return
to the pool as reads consume them, and that consumption reopens both receive windows. If a handler
does not read its body, the normal post-handler drain performs the same bounded consumption.
The connection window and pool both cover exactly 1 MiB, so the peer can never hold more credit
than the server can store before backpressure takes effect. Per-stream accepted body bytes remain
bounded by `MAX_REQUEST_BODY_SIZE`. Declared content length is parsed without a String and checked
against the unpadded DATA total at END_STREAM.
## Responses
Fixed byte arrays, known-length streams and unknown-length streams all use one resumable
`Http2ResponseWriter`. It emits DATA frames no larger than the peer's frame limit, the available
connection window, the available stream window and the reusable 16 KiB relay buffer. A
WINDOW_UPDATE schedules the stream on the shared virtual-thread executor; application streams are
never read by the demultiplexer.
`Response.chunked(InputStream)` means unknown-length streaming at the application API. HTTP/2 has
no chunked transfer coding, so Flash emits ordinary DATA followed by END_STREAM and never sends a
`transfer-encoding` field. `Response.stream(InputStream, length)` emits `content-length` and fails
the stream if the source ends before that length.
## Verification
- A real Java HTTP/2 client uploads and downloads 100 MiB over TLS; both directions are validated
byte-for-byte without materializing the test payload.
- A synthetic 100 MiB response proves serialized scratch storage stays below 64 KiB.
- h2spec sections 5, 6.1, 6.9 and 8: 50 passed, one h2spec-skipped case, zero failures.
- Clean Maven build with JMH sources: 633 tests, no failures.
- JMH request streaming: 159.408 ns/op, 0.001 B/op, no GC.
- JMH response streaming frame: 219.090 ns/op, 0.002 B/op, no GC.
+11 -12
View File
@@ -1,4 +1,4 @@
# The Frame Layer (Phase 5)
# The frame layer
Audience: contributors. This is the design record for `dev.relism.flash.http2.frame`'s frame
reading, validation, and writing — the 9-byte header and payload boundary, with no connection
@@ -40,9 +40,9 @@ dev.relism.flash.http2.frame
├── FrameValidator table-driven per-type RFC validation, specific error code per rule
├── Padding RFC 9113 §6.1/§6.2 pad-length byte + trailing padding, DATA/HEADERS
├── FrameWriteBuffer beginFrame()/endFrame() length back-patching over a ByteWriter
├── Http2FrameWriter (Phase 3) the connection's single serialized writer — unchanged here
├── WriteIntent (Phase 3) unchanged
└── IntrusiveMpscQueue (Phase 3) unchanged
├── Http2FrameWriter the connection's single serialized writer
├── WriteIntent caller-owned serialized frame batch
└── IntrusiveMpscQueue allocation-free contended-write queue
```
## The validation table
@@ -55,7 +55,7 @@ min/max length bounds, the `MAX_FRAME_SIZE_LOCAL` ceiling, the stream-id rule, t
| Type | Code | Length | Stream id | Notes / RFC |
|---|---|---|---|---|
| DATA | 0x0 | 0..MAX_FRAME_SIZE | required (≠0) | §6.1. Padding via `Padding.unpad`. |
| HEADERS | 0x1 | 0..MAX_FRAME_SIZE | required (≠0) | §6.2. Padding + PRIORITY fields (Phase 7+ parses the latter). |
| HEADERS | 0x1 | 0..MAX_FRAME_SIZE | required (≠0) | §6.2. Padding and optional PRIORITY fields are parsed before HPACK. |
| PRIORITY | 0x2 | exactly 5 | required (≠0) | §6.3. Deprecated (§5.3.2) — parsed, discarded, never acted on. |
| RST_STREAM | 0x3 | exactly 4 | required (≠0) | §6.4. The 4 bytes are the error code. |
| SETTINGS | 0x4 | multiple of 6 | forbidden (=0) | §6.5. Modulus checked before the generic bounds. |
@@ -82,7 +82,7 @@ The one exception (§6.10): if an unrecognised-type frame arrives **between** a
PUSH_PROMISE frame that lacked `END_HEADERS` and the CONTINUATION that eventually sets it, the
HPACK decoder's state has nowhere to put that frame's bytes without desynchronizing — so this one
case *is* a `PROTOCOL_ERROR`, tracked by `FrameValidator.validate`'s `insideHeaderBlock`
parameter (owned and threaded through by the Phase 8 connection loop, which is the only caller
parameter (owned and threaded through by the connection loop, which is the only caller
that knows whether a header block is currently open).
`PRIORITY` frames are a different kind of "ignore": they are a recognised, well-formed type that
@@ -112,8 +112,8 @@ pad-length, then data, then that many padding bytes (whose contents carry no mea
only to obscure payload size from network observers). A pad length greater than or equal to the
whole payload length is `PROTOCOL_ERROR` (RFC 9113 §6.1), checked before any arithmetic that
could otherwise underflow. Flow-control accounting for padded DATA frames (RFC 9113 §6.9.1: the
*whole* payload counts against the window, not just the data) is Phase 11 scope — `Padding` only
locates the data range, it performs no window bookkeeping itself.
*whole* payload counts against the window, not just the data) is applied by
`Http2FlowController`; `Padding` only locates the data range.
## Writing: `FrameWriteBuffer`'s back-patching
@@ -121,18 +121,17 @@ A frame's length is rarely known before its payload is serialized (an HPACK-enco
in particular, has no cheap way to be measured in advance). `FrameWriteBuffer.beginFrame` writes
a 9-byte header with a placeholder length; the caller writes the payload directly through the
same `ByteWriter`; `endFrame` computes the actual length from how far the writer has advanced and
rewrites the three length bytes in place. This is *why* `Http2FrameWriter` (Phase 3) serializes a
rewrites the three length bytes in place. This is *why* `Http2FrameWriter` serializes a
complete buffer before ever taking the connection lock, rather than streaming bytes as they are
produced — streaming would need the length upfront, which back-patching deliberately avoids
needing.
## `EX-37`, found while building this phase's tests
## Buffered-source deadline regression
`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
+99
View File
@@ -0,0 +1,99 @@
# HTTP/2 performance
## Method
Measurements were taken on 2026-08-13 under Linux 6.12/KVM with six exposed AMD Ryzen 7 1700X
cores, Temurin 21.0.11, JMH 1.37 and nghttp2 1.59.0. JMH component benchmarks use prepared,
reusable protocol state and forked JVMs. `h2load` exercises the real cleartext server on loopback;
Flash and nghttpd run on the same host in alternating order. Results are snapshots, not promises
for different hardware.
No "unmatched throughput" claim is supported. nghttpd is normally faster in this matrix; Flash's
numbers include framework routing, request-model assembly and handler dispatch that the static
reference server does not.
## Component results
The CI-controlled allocation and percentile numbers are in `BASELINES.md`. Additional average
time measurements from the same run were:
| Scenario | Result |
|---|---:|
| h2 responses across 1 live stream | 74.405 ns |
| h2 responses across 8 live streams | 705.368 ns |
| h2 responses across 64 live streams | 5,591.368 ns |
| h2 responses across 256 live streams | 28,961.681 ns |
| h2 POST lifecycle with 1 KiB DATA | 741.031 ns, 0.005 B/op |
| 1 MiB streaming response | 78,681.815 ns |
The multiplexing benchmark reports one complete response-encoding pass across all live streams,
not per-stream time. `Http2BodyBenchmark` separately covers the 1 KiB request-body shape and the
1 MiB response shape. `FrameWriterBenchmark` retains the Phase 3 contention matrix and its
per-write latency distribution.
## End-to-end h2load comparison
Each row uses at least 1,000 requests. Requested stream concurrency is capped first by Flash's
advertised 64-stream setting and then to 4,096 aggregate active streams so the 1,000-connection
rows remain bounded. Both requested and effective values are shown.
| Connections | Requested/effective streams | Flash req/s | nghttpd req/s |
|---:|---:|---:|---:|
| 1 | 1 / 1 | 2,136.18 | 12,786.42 |
| 1 | 10 / 10 | 18,396.56 | 83,521.26 |
| 1 | 100 / 64 | 17,039.55 | 66,746.76 |
| 10 | 1 / 1 | 11,247.08 | 37,838.66 |
| 10 | 10 / 10 | 25,055.12 | 104,964.84 |
| 10 | 100 / 64 | 3,878.28 | 67,303.81 |
| 100 | 1 / 1 | 5,517.94 | 42,319.09 |
| 100 | 10 / 10 | 1,818.52 | 26,732.25 |
| 100 | 100 / 40 | 7,042.85 | 136,585.90 |
| 1,000 | 1 / 1 | 1,393.17 | 3,877.62 |
| 1,000 | 10 / 4 | 10,374.83 | 40,976.05 |
| 1,000 | 100 / 4 | 49,622.10 | 85,344.40 |
The matrix found a correctness issue before it produced these final numbers: closed streams still
occupied live admission slots while their final write callback was pending. The bounded detach
fix is recorded as EX-57 and covered by regression tests.
## Tuning decisions
| Knob | Measurement | Decision |
|---|---|---|
| 16 KiB / 64 KiB / 1 MiB response frame | 1 MiB stream: 104,071 / 97,996 / 98,769 ns in the non-Huffman sweep | Keep 16 KiB. The roughly 6% gain at 64 KiB does not justify 4x per-connection buffer exposure on this noisy host. |
| 1 MiB initial receive window | 100 MiB Phase 11 transfer and the load matrix complete without flow stalls | Keep; it matches bounded receive capacity and changing it independently would not isolate a throughput claim. |
| half-window WINDOW_UPDATE hysteresis | 1 MiB streaming and 100 MiB transfer complete with steady pooled reads | Keep; no per-frame update traffic and no demonstrated reason to weaken backpressure. |
| 64 KiB inline body | 1 KiB inline materialization is one 1,040 B allocation; streaming steady state is ≈0 B/op | Keep the explicit one-array small-body tradeoff and stream larger bodies. |
| 64 × 16 KiB DATA buffers | 1 MiB streaming is 78,682 ns with ≈0 B/op; h2load stays bounded | Keep; larger chunks did not produce a clear win beyond the frame-size sweep. |
| `ScratchPool` bound | 64 objects per exposed CPU, capped at 4,096; full 1,000-connection matrix completes | Keep the capacity bound; it affects retained burst memory, not steady-state request instructions. |
| word-at-a-time route compare | 14.289 ns versus 22.313 ns bytewise, 36.0% faster | Keep. |
| SWAR header-end scan | 89.919 ns versus 128.460 ns scalar, 30.0% faster | Keep. |
| `SlicePool` size 4 | Header/path/query view benchmarks remain allocation-free | Keep; size changes lifetime capacity, not lookup work, and four simultaneous borrowed views match the documented contract. |
| runtime-value Huffman | representative response headers: 375.761 ns versus 180.129 ns at 16 KiB | Keep disabled by default; this header set is 109% slower to encode. |
The frame-size/Huffman factorial produced counterintuitive variation in the body-only rows, so it
was not used to claim a Huffman body effect: Huffman only prepares headers. This is treated as
host noise rather than reverse-engineered into a preferred result.
## Profiling
async-profiler 4.4 was run against the representative browser HPACK decode. The top CPU leaves
were `Huffman.decode` (72.67%), `HpackHeaderBlock.accept` (7.00%), `HpackDecoder.decode` (5.00%),
JVM byte-array copy (4.00%), `HpackHeaderBlock.copy` (4.00%), `HpackStaticTable.name` (2.00%),
`PooledSlice.reset` (1.00%), `PooledSlice.array` (0.67%), JVM byte-arraycopy (0.67%), and
`HpackDecoder.decodeString` (0.67%). Each belongs to decoding, bounded arena ownership, or the
copy that makes header lifetime independent of dynamic-table eviction; none is incidental
locking or logging.
The allocation profile produced no samples on the gated decode path. The realistic eight-writer
lock profile produced no sampled contended locks; the writer benchmark measured 185.605 bursts/s,
p50 1.2 µs, p99 5.3 µs and p999 41.6 µs. CPU, allocation and lock artifacts were generated under
`/tmp/phase17-*` and are intentionally not committed.
CI runs the complete suite with `-Djdk.tracePinnedThreads=full`. The forked allocation and p99
gates run only under the Maven `jmh` profile; the h2load comparison remains informational and
conditional because cross-runner throughput is not a stable correctness gate.
The reconstructed Phase-0 HTTP/1 comparison is documented in `BASELINES.md`. Its confidence
interval overlaps the Phase-17 result, while normalized allocation falls from 224.007 B/op to
0.007 B/op.
+43
View File
@@ -0,0 +1,43 @@
# HTTP/2 in Flash
Flash treats HTTP/1.1 and HTTP/2 as peer transports behind one connection boundary. TLS ALPN or
the cleartext prior-knowledge preface selects a protocol once; both paths then feed the same
router, `Request`, `Response`, handler, trailer, streaming and WebSocket APIs. HTTP/2 adds a
bounded frame decoder, HPACK codec, stream state machine, two-level flow control and one serialized
writer per connection. Application code does not branch on the wire protocol.
The implementation is deliberately layered:
```text
listener / TLS
-> protocol negotiation
-> HTTP/1.1 parser ---------+
-> HTTP/2 frames + HPACK ----+-> shared request model -> router -> handler
shared response model
<- HTTP/1.1 serializer -----+
<- HTTP/2 stream writer ----+
```
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
- [Connection](CONNECTION.md) and [streams](STREAMS.md) — HTTP/2 connection and stream state.
- [Flow control](FLOW-CONTROL.md) — request backpressure and streamed responses.
- [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
- [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.
## Operate and verify
- [Security](SECURITY.md) — every HTTP/2 limit, default and abuse control.
- [Troubleshooting](TROUBLESHOOTING.md) — GOAWAY/RST_STREAM diagnosis and protocol tracing.
- [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.
+42
View File
@@ -0,0 +1,42 @@
# HTTP/2 security controls
HTTP/2 multiplexing lets one connection create disproportionate parser, stream and response work.
Flash therefore combines structural bounds, flow-control bounds and rate bounds. Rate counters use
two fixed half-window buckets, allocate nothing per frame and need no timer thread.
JMH on JDK 21 measures one rate-counter increment at 38.083 ns/op and approximately
`10^-4 B/op` (allocation noise floor, no GC).
| Limit | Default | Defence / tuning guidance |
|---|---:|---|
| `MAX_CONCURRENT_STREAMS` | 64 | Bounds simultaneously retained stream state. |
| `MAX_STREAMS_CREATED_PER_INTERVAL` | 400 / 10 s | Companion to Rapid Reset; tune with `h2MaxStreamsCreatedPerInterval`. |
| `MAX_RESET_STREAMS_PER_INTERVAL` | 200 / 10 s | CVE-2023-44487 Rapid Reset; tune with `h2MaxResetStreamsPerInterval`. |
| `MAX_CONTINUATION_FRAMES_PER_BLOCK` | 8 | CVE-2024-27316 CONTINUATION flood. |
| `MAX_HEADER_LIST_SIZE` | 32 KiB | Stops HPACK expansion before fields reach stream storage. |
| `MAX_HPACK_STRING_LENGTH` | 8 KiB | Bounds one decoded literal, including Huffman expansion. |
| `MAX_SETTINGS_PER_INTERVAL` | 100 / 10 s | Bounds mandatory SETTINGS acknowledgements. |
| `MAX_PINGS_PER_INTERVAL` | 200 / 10 s | Bounds mandatory PING acknowledgements. |
| `MAX_USELESS_FRAMES_PER_INTERVAL` | 10,000 / 10 s | Aggregate CPU bound for PRIORITY, WINDOW_UPDATE, empty DATA and unknown frames. |
| `MAX_SETTINGS_ACK_QUEUE_DEPTH` | 64 | Bounds queued SETTINGS control writes. |
| `MAX_PING_QUEUE_DEPTH` | 64 | Bounds queued PING control writes. |
| `MAX_EMPTY_DATA_FRAMES_PER_STREAM` | 1,000 | Stops DATA work that spends no flow-control credit. |
| `INITIAL_WINDOW_SIZE_LOCAL` | 1 MiB | Matches the bounded DATA pool; consumption, not receipt, returns credit. |
| `MAX_REQUEST_BODY_SIZE` | 100 MiB | Hard per-stream request body bound. |
| `HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS` | 10 s | Absolute HEADERS-to-END_HEADERS deadline. |
| `STREAM_IDLE_TIMEOUT_MS` | 60 s | Cancels retained inactive streams; tune with `h2StreamIdleTimeoutMs`. |
| `FRAME_READ_TIMEOUT_MS` | 20 s | Absolute partial-frame deadline. |
| `WRITE_TIMEOUT_MS` | 30 s | Interrupts a socket writer blocked by a peer that stopped reading. |
| `MAX_STREAMS_PER_CONNECTION` | 100,000 | Optional connection churn budget; zero disables, tune with `h2MaxStreamsPerConnection`. |
| `MAX_BYTES_PER_CONNECTION` | disabled | Optional wire-byte budget; tune with `h2MaxBytesPerConnection`. |
| `MAX_CONNECTION_LIFETIME_MS` | disabled | Optional lifetime rotation; tune with `h2MaxConnectionLifetimeMs`. |
`h2AbuseRateIntervalMs` changes the rolling interval for reset and stream-creation operator
limits. Breaching a connection-wide rate or budget produces GOAWAY `ENHANCE_YOUR_CALM`; malformed
stream-local messages use the RFC-defined stream error. The ordinary write queue is bounded by the
64 live streams and their single-in-flight response intent; control writes use the fixed scratch
slots above, so a slow reader cannot create an unbounded application queue.
The security suite covers Rapid Reset, stream churn, SETTINGS/PING/non-progress floods, 100,000
CONTINUATION frames, HPACK expansion, malformed names/pseudo-fields, configurable resource budgets,
header assembly deadlines and idle-stream cancellation. HTTP/1 request/header/body security tests
remain in the full suite and the shared public message model uses the same bounds on both paths.
+4 -5
View File
@@ -40,10 +40,9 @@ while dispatch is pending.
## Verification
- Clean Maven build with JMH sources: 618 tests, no failures.
- h2spec sections 5 and 8: 37/39. The two remaining cases require request DATA byte accounting and
are completed with body flow control.
- The complete clean Maven/JMH suite is recorded in `COMPLIANCE.md` and `PERFORMANCE.md`.
- h2spec sections 5 and 8 pass after request DATA byte accounting landed.
- Java `HttpClient` negotiates HTTP/2 over TLS and runs an existing parameterized route unchanged.
- curl prior-knowledge h2c receives a valid `200` response and body.
- JMH pooled lifecycle (HPACK decode, request assembly, response write and release):
458.499 ns/op, 0.003 B/op, no GC.
- The pooled lifecycle (HPACK decode, request assembly, response write and release) remains a
zero-GC CI gate; current percentile and allocation baselines live in `BASELINES.md`.
+73
View File
@@ -0,0 +1,73 @@
# HTTP/2 troubleshooting
## Confirm which protocol was selected
For TLS, the client and server must both offer `h2` through ALPN. Enable
`FlashConfiguration.http2Enabled`, use a certificate valid for the requested hostname, then check
with `curl --http2 -v https://host/path` or `nghttp -nv https://host/path`. The trace must report
ALPN `h2`; a successful HTTP/1.1 response usually means HTTP/2 was not enabled or the client did
not offer it.
For plaintext, enable `http2CleartextEnabled` and use prior knowledge:
```bash
curl --http2-prior-knowledge -v http://host:port/path
nghttp -nv http://host:port/path
```
Flash does not support `Upgrade: h2c`. A client configured for Upgrade rather than prior knowledge
will remain on HTTP/1.1.
## Read GOAWAY and RST_STREAM
GOAWAY terminates or drains a connection; `last_stream_id` identifies the highest client stream
the server may have processed. A client may retry a stream above that id only when its own request
semantics make retry safe. RST_STREAM affects one stream and leaves the connection usable.
| Error | What it usually means | What to check |
|---|---|---|
| `NO_ERROR` | Graceful shutdown or connection rotation. | Server lifecycle and configured connection lifetime. |
| `PROTOCOL_ERROR` | Invalid preface, pseudo-header ordering, stream state or frame semantics. | A verbose frame trace and the first rejected stream. |
| `INTERNAL_ERROR` | Handler, response production or I/O failed unexpectedly. | The server exception immediately preceding stream cancellation. |
| `FLOW_CONTROL_ERROR` | A window overflow or DATA exceeded available credit. | Client flow-control implementation and SETTINGS deltas. |
| `SETTINGS_TIMEOUT` | The peer did not complete required SETTINGS progress. | Network stalls or a non-compliant peer. |
| `STREAM_CLOSED` | A frame targeted a stream whose remote side or whole lifecycle was closed. | Late DATA/HEADERS and duplicate terminal frames. |
| `FRAME_SIZE_ERROR` | A frame length violated its type or the negotiated maximum. | The nine-byte frame header and peer frame-size configuration. |
| `REFUSED_STREAM` | Live or pending-output capacity was temporarily exhausted. | Client concurrency versus the advertised maximum; retry only when safe. |
| `CANCEL` | The request, handler or streamed response was cancelled. | Client cancellation and application producer logs. |
| `COMPRESSION_ERROR` | HPACK integer, Huffman, index or table update was invalid. | Header-block bytes and whether an intermediary rewrote them. |
| `CONNECT_ERROR` | A CONNECT tunnel failed. | Upstream tunnel or extended-CONNECT negotiation. |
| `ENHANCE_YOUR_CALM` | A configured abuse, rate, header, body or queue bound was exceeded. | [Security controls](SECURITY.md) and traffic rate before increasing a limit. |
| `INADEQUATE_SECURITY` | TLS does not meet HTTP/2 requirements. | TLS version, cipher suite and ALPN configuration. |
| `HTTP_1_1_REQUIRED` | The peer should retry using HTTP/1.1. | Protocol policy and intermediary compatibility. |
Flash caps GOAWAY debug data, and clients must not depend on it being present. The numeric error
code and last stream id are the reliable diagnostic fields.
## Capture a frame trace
Flash does not log every frame in production: frame logs leak header and traffic metadata and add
work to the hottest connection loop. Reproduce against a verbose client instead:
```bash
nghttp -nv https://host/path
curl --http2 -v https://host/path
```
`nghttp -nv` prints SETTINGS, HEADERS, DATA, WINDOW_UPDATE, RST_STREAM and GOAWAY in wire order. For
a server-side-only failure, capture the connection with an approved packet tool; TLS traffic must
be decrypted in a controlled environment. Never attach production header blocks or payloads to a
ticket without redacting credentials and personal data.
## Common misconfiguration patterns
1. **HTTP/2 switch disabled.** `http2Enabled` controls TLS ALPN and
`http2CleartextEnabled` controls prior knowledge independently.
2. **Wrong cleartext mode.** The client sends `Upgrade: h2c`; Flash expects the RFC 9113 prior-
knowledge preface on the shared plaintext listener.
3. **ALPN or certificate mismatch.** A custom `TlsConfig.ofContext` omits `h2`, or hostname
verification rejects the certificate before HTTP/2 starts. Inspect the TLS handshake first.
If a connection closes under load rather than at startup, compare the observed rate and retained
stream count with [the security defaults](SECURITY.md), especially reset/stream creation budgets,
the 64 concurrent-stream setting, header assembly time and stream idle time.
+52
View File
@@ -0,0 +1,52 @@
# WebSockets over HTTP/2
Flash implements RFC 8441 extended CONNECT alongside the existing HTTP/1.1 WebSocket upgrade.
Both transports resolve the same `ws(path, handler)` registration through `AbstractWsRouter` and
run the same `WebSocketSession`, frame parser, handler callbacks, and close lifecycle.
## Protocol negotiation
Every HTTP/2 server connection advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL` (`0x8`) with value
`1`. A WebSocket request uses this pseudo-header shape:
```text
:method CONNECT
:protocol websocket
:scheme https # or http
:authority example.com
:path /live
```
The normal HTTP/1.1 upgrade fields (`Connection`, `Upgrade`, `Sec-WebSocket-Key`, and
`Sec-WebSocket-Accept`) are neither required nor permitted on this path. A matched route receives
status `200`; a missing route receives `404`.
## Shared application behavior
At the router boundary, an extended CONNECT for `websocket` is represented as a GET so the
existing WebSocket router can be reused without a second registration table or protocol-specific
handler API. The wire validator retains the original CONNECT semantics and rejects malformed
pseudo-header combinations before dispatch.
Request DATA is exposed through the existing streaming `RequestBody`. WebSocket output passes
through the common push-style `ResponseStream`, so HTTP/2 stream and connection flow-control
windows apply without changing the WebSocket codec. Messages may cross any number of DATA-frame
boundaries; those boundaries are invisible to RFC 6455 framing. Client-to-server masking remains
mandatory and is validated by the same frame parser used for HTTP/1.1.
## Lifecycle and backpressure
Response HEADERS are sent before the push producer is allowed to wait for request DATA. This is
required for a full-duplex protocol: waiting for the first WebSocket frame before publishing the
successful CONNECT response would deadlock compliant clients. Subsequent response batches block
behind the bounded response bridge and resume when HTTP/2 flow-control credit becomes available.
Handler failures from `onOpen` or `onMessage` are reported through `onError`; `onClose` is invoked
once and the transport is released even if the close callback itself fails.
## Verification
`WebSocketOverH2Test` exercises the extended CONNECT exchange, fragmented text, masking, graceful
close, and a binary message larger than the initial one-mebibyte stream window.
`WebSocketParityTest` sends the same message through one route and handler over HTTP/1.1 and
HTTP/2 and compares the result byte for byte.
+9 -13
View File
@@ -1,13 +1,10 @@
# The Serialized Frame Writer (Phase 3 — GO/NO-GO gate)
# The serialized frame writer
Audience: contributors. This is the design record and benchmark evidence for
`dev.relism.flash.http2.frame.Http2FrameWriter`, the one component every HTTP/2 write in this
codebase passes through. Phase 3 of `IMPLEMENTATION-PLAN.md` treats this component as the
single genuinely novel architectural risk in the whole project — everything downstream (frames,
HPACK, flow control) is table-driven work with known cost, but nothing in Flash today
coordinates concurrent writers onto one socket. If this component could not deliver, the plan
says to stop here having spent one phase, not ten. It delivered: **GO**, see the gate table at
the end of this document.
codebase passes through. It was the central architectural risk: frames, HPACK and flow control are
table-driven, but multiplexed streams require concurrent producers to share one socket without
interleaving bytes or pinning carrier threads. The measured gate is recorded below.
## The problem, precisely
@@ -27,7 +24,7 @@ has already built its complete frame — header, HPACK block, payload — into a
writer never serializes anything; it holds the lock only for the duration of one bulk
`sink.write(buffer, offset, length)` call, never for a sequence of small writes. This is why
`EX-27` (collapsing `HttpServer.writeResponse`'s ~10 small writes into one) is a prerequisite for
h1 too, landing in Phase 6.
HTTP/1.1 too; `Http1ResponseWriter` now follows the same bulk-write discipline.
**Layer 2 — `ReentrantLock`, never `synchronized`.** On Java 21, a virtual thread that blocks
inside a `synchronized` block pins its carrier platform thread (JEP 491, which removes this,
@@ -140,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)).
@@ -260,9 +257,8 @@ design's exclusive use of `ReentrantLock` (never `synchronized`) on every path t
| 3 | No carrier pinning under `-Djdk.tracePinnedThreads=full` | none observed | **PASS** |
| 4 | Stress test green at every N, 1000 iterations, incl. parallelism=1 | 10 000/10 000 | **PASS** |
**All four gate criteria are met. Verdict: GO.** `Http2FrameWriter` ships as designed
`tryLock()` fast path, intrusive MPSC fallback — and Phase 4 may proceed. See `DECISIONS.md`,
`DEC-09`, for the decision-log entry recording this outcome alongside the plan's other decisions.
**All four gate criteria are met.** `Http2FrameWriter` ships as designed: a `tryLock()` fast path
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;
}
@@ -0,0 +1,95 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Collection;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.profile.GCProfiler;
import org.openjdk.jmh.results.Result;
import org.openjdk.jmh.results.RunResult;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
import org.openjdk.jmh.runner.options.TimeValue;
/** Short, forked JMH gates used by CI; full publication runs retain each benchmark's annotations. */
@EnabledIfSystemProperty(named = "flash.performance.gates", matches = "true")
class PerformanceGateTest {
private static final double ALLOCATION_NOISE_FLOOR = 0.05;
private static final String INCLUDE =
"(RequestPipelineBenchmark.parseAndRoute"
+ "|Http2StreamBenchmark.lifecycle"
+ "|Http2ResponseWriterBenchmark.encodeResponse"
+ "|HpackDecoderBenchmark.decodeTypicalBrowserRequest"
+ "|HpackEncoderBenchmark.encodeTypicalResponse"
+ "|FrameLayerBenchmark.readValidateAndDiscard)";
@Test
void allocationAndLatencyBaselinesHold() throws Exception {
Collection<RunResult> allocationResults = new Runner(allocationOptions()).run();
assertFalse(allocationResults.isEmpty(), "JMH did not discover the allocation gates");
for (RunResult run : allocationResults) {
String benchmark = shortName(run.getParams().getBenchmark());
Result<?> allocation = run.getSecondaryResults().get("gc.alloc.rate.norm");
assertTrue(allocation != null, "missing allocation measurement for " + benchmark);
assertTrue(
allocation.getScore() <= ALLOCATION_NOISE_FLOOR,
() -> benchmark + " allocated " + allocation.getScore() + " B/op");
}
Collection<RunResult> latencyResults = new Runner(latencyOptions()).run();
assertFalse(latencyResults.isEmpty(), "JMH did not discover the latency gates");
for (RunResult run : latencyResults) {
String benchmark = shortName(run.getParams().getBenchmark());
Double maximumNanos = MAXIMUM_P99_NANOS.get(benchmark);
assertTrue(maximumNanos != null, "missing latency baseline for " + benchmark);
Result<?> p99 = run.getSecondaryResults().get("p0.99");
assertTrue(p99 != null, "missing p99 measurement for " + benchmark);
double score = p99.getScore();
assertTrue(
score <= maximumNanos,
() -> benchmark + " p99 regressed to " + score + " ns/op; gate is " + maximumNanos);
}
}
private static Options allocationOptions() {
return commonOptions()
.mode(Mode.AverageTime)
.addProfiler(GCProfiler.class)
.build();
}
private static Options latencyOptions() {
return commonOptions().mode(Mode.SampleTime).build();
}
private static ChainedOptionsBuilder commonOptions() {
return new OptionsBuilder()
.include(INCLUDE)
.warmupIterations(2)
.warmupTime(TimeValue.milliseconds(250))
.measurementIterations(3)
.measurementTime(TimeValue.milliseconds(350))
.forks(1)
.shouldFailOnError(true);
}
private static String shortName(String benchmark) {
return benchmark.substring(benchmark.lastIndexOf('.') + 1);
}
// Filled from the controlled baseline run documented in BASELINES.md, with 35% CI headroom.
private static final Map<String, Double> MAXIMUM_P99_NANOS =
Map.of(
"parseAndRoute", 45_000.0,
"lifecycle", 2_900.0,
"encodeResponse", 1_350.0,
"decodeTypicalBrowserRequest", 7_100.0,
"encodeTypicalResponse", 850.0,
"readValidateAndDiscard", 2_700.0);
}
@@ -0,0 +1,27 @@
package dev.relism.flash.http2;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
public class RollingWindowCounterBenchmark {
private final RollingWindowCounter counter = new RollingWindowCounter(10_000);
@Benchmark
public boolean increment() {
return counter.incrementExceeded(Integer.MAX_VALUE);
}
}
@@ -1,6 +1,10 @@
package dev.relism.flash.http2.hpack;
import java.util.concurrent.TimeUnit;
import dev.relism.flash.bytes.ByteWriter;
import java.nio.charset.StandardCharsets;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
@@ -22,6 +26,25 @@ public class HpackDecoderBenchmark {
private final HpackDecoder decoder = new HpackDecoder();
private final HpackHeaderBlock headers = new HpackHeaderBlock();
private final byte[] block = {(byte) 0x82, (byte) 0x87, (byte) 0x84, (byte) 0x88};
private byte[] browserBlock;
@Setup(Level.Trial)
public void setupTypicalBlock() {
ByteWriter encoded = new ByteWriter(256);
HpackEncoder.writeIndexed(encoded, 2);
HpackEncoder.writeIndexed(encoded, 7);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 4, "/products?category=books".getBytes(StandardCharsets.US_ASCII), true);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 1, "shop.example.com".getBytes(StandardCharsets.US_ASCII), true);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 19, "text/html,application/xhtml+xml".getBytes(StandardCharsets.US_ASCII), true);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 16, "gzip, deflate".getBytes(StandardCharsets.US_ASCII), true);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 55, "Mozilla/5.0 benchmark".getBytes(StandardCharsets.US_ASCII), true);
browserBlock = java.util.Arrays.copyOf(encoded.array(), encoded.length());
}
@Benchmark
public int decodeStaticRequest() {
@@ -29,4 +52,11 @@ public class HpackDecoderBenchmark {
decoder.decode(block, 0, block.length, headers);
return headers.count();
}
@Benchmark
public int decodeTypicalBrowserRequest() {
headers.reset();
decoder.decode(browserBlock, 0, browserBlock.length, headers);
return headers.count();
}
}
@@ -0,0 +1,43 @@
package dev.relism.flash.http2.hpack;
import dev.relism.flash.bytes.ByteWriter;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/** Measures a representative stateless response header block. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class HpackEncoderBenchmark {
private static final byte[] CONTENT_LENGTH = "1024".getBytes(StandardCharsets.US_ASCII);
private static final byte[] CONTENT_TYPE = "application/json".getBytes(StandardCharsets.US_ASCII);
private static final byte[] CACHE_CONTROL = "no-cache".getBytes(StandardCharsets.US_ASCII);
private static final byte[] ETAG_NAME = "etag".getBytes(StandardCharsets.US_ASCII);
private static final byte[] ETAG = "\"abc123\"".getBytes(StandardCharsets.US_ASCII);
private static final byte[] SERVER = "Flash".getBytes(StandardCharsets.US_ASCII);
private final ByteWriter output = new ByteWriter(128);
@Benchmark
public int encodeTypicalResponse() {
output.reset();
HpackEncoder.writeIndexed(output, 8);
HpackEncoder.writeLiteralWithNameIndex(output, 31, CONTENT_TYPE, true);
HpackEncoder.writeLiteralWithNameIndex(output, 28, CONTENT_LENGTH, false);
HpackEncoder.writeLiteralWithNameIndex(output, 24, CACHE_CONTROL, true);
HpackEncoder.writeLiteralWithNameIndex(output, 51, SERVER, true);
HpackEncoder.writeLiteral(output, ETAG_NAME, ETAG);
return output.length();
}
}
@@ -0,0 +1,122 @@
package dev.relism.flash.http2.message;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.RequestBody;
import dev.relism.flash.models.Response;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
@Fork(2)
@State(Scope.Thread)
public class Http2BodyBenchmark {
private static final Http2RequestBody.ConsumptionListener NOOP = bytes -> {};
private final byte[] payload = new byte[1024];
private final byte[] streamingPayload = new byte[1024 * 1024];
private final byte[] target = new byte[1024];
private DataBufferPool pool;
private Http2RequestBody source;
private RequestBody body;
private Response response;
private Http2ResponseWriter responseWriter;
private ResettableInputStream responseSource;
private ResettableInputStream largeResponseSource;
@Setup(Level.Trial)
public void setup() throws IOException {
pool = new DataBufferPool(16_384, 1);
source = new Http2RequestBody(pool);
body = new RequestBody();
response = new Response(200, ContentType.BINARY);
responseWriter = new Http2ResponseWriter();
responseSource = new ResettableInputStream(payload);
largeResponseSource = new ResettableInputStream(streamingPayload);
source.begin(-1, false, NOOP);
source.offer(1, payload, 0, payload.length, payload.length);
source.finish(1);
source.read(target);
}
@Benchmark
public byte[] inlineBytes() {
source.begin(payload.length, true, NOOP);
source.offer(1, payload, 0, payload.length, payload.length);
source.finish(1);
body.reset(source, payload.length, null, 0, 0);
return body.bytes();
}
@Benchmark
public int streamingRead() throws IOException {
source.begin(-1, false, NOOP);
source.offer(1, payload, 0, payload.length, payload.length);
source.finish(1);
return source.read(target, 0, target.length);
}
@Benchmark
public int streamingResponseFrame() throws IOException {
responseSource.rewind();
response.reset(200, ContentType.BINARY).stream(responseSource, payload.length);
responseWriter.startFlowControlled(
response, 1, false, false, true, false, false, 16_384, 32_768, 16_384);
return responseWriter.length();
}
@Benchmark
public int streamingResponseOneMiB() throws IOException {
largeResponseSource.rewind();
response.reset(200, ContentType.BINARY).stream(largeResponseSource, streamingPayload.length);
responseWriter.startFlowControlled(
response, 1, false, false, true, false, false, 16_384, 32_768, 16_384);
int wireBytes = responseWriter.length();
while (!responseWriter.finished()) {
responseWriter.resume(16_384, 16_384);
wireBytes += responseWriter.length();
}
return wireBytes;
}
private static final class ResettableInputStream extends InputStream {
private final byte[] source;
private int position;
ResettableInputStream(byte[] source) {
this.source = source;
}
void rewind() {
position = 0;
}
@Override
public int read() {
return position == source.length ? -1 : source[position++] & 0xff;
}
@Override
public int read(byte[] target, int offset, int length) {
if (position == source.length) return -1;
int count = Math.min(length, source.length - position);
System.arraycopy(source, position, target, offset, count);
position += count;
return count;
}
}
}
@@ -0,0 +1,117 @@
package dev.relism.flash.http2.message;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.PreEncodedHeader;
import dev.relism.flash.models.Response;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/** Factorial measurements for the response knobs considered during tuning. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class Http2TuningBenchmark {
@Param({"false", "true"})
public boolean huffmanDynamicValues;
@Param({"16384", "65536", "1048576"})
public int maxFrameSize;
private final byte[] largeBody = new byte[1024 * 1024];
private Http2ResponseWriter writer;
private Response response;
private Response streamingResponse;
private ResettableInputStream source;
@Setup(Level.Trial)
public void setup() {
writer = new Http2ResponseWriter();
response =
new Response(200, "hello", ContentType.JSON)
.header(new PreEncodedHeader("cache-control", "private, max-age=60"))
.header(new PreEncodedHeader("x-request-id", "d7bca219-6dd4-4ef0-a881-f21931e249c7"));
source = new ResettableInputStream(largeBody);
streamingResponse = new Response(200, ContentType.BINARY).stream(source, largeBody.length);
}
@Benchmark
public int encodeResponseHeaders() {
writer.prepare(
response,
1,
false,
true,
true,
huffmanDynamicValues,
false,
maxFrameSize,
32_768,
65_535);
return writer.length();
}
@Benchmark
public int streamOneMiB() throws IOException {
source.rewind();
writer.startFlowControlled(
streamingResponse,
1,
false,
false,
true,
huffmanDynamicValues,
false,
maxFrameSize,
32_768,
maxFrameSize);
int wireBytes = writer.length();
while (!writer.finished()) {
writer.resume(maxFrameSize, maxFrameSize);
wireBytes += writer.length();
}
return wireBytes;
}
private static final class ResettableInputStream extends InputStream {
private final byte[] bytes;
private int position;
private ResettableInputStream(byte[] bytes) {
this.bytes = bytes;
}
private void rewind() {
position = 0;
}
@Override
public int read() {
return position == bytes.length ? -1 : bytes[position++] & 0xff;
}
@Override
public int read(byte[] target, int offset, int length) {
if (position == bytes.length) return -1;
int count = Math.min(length, bytes.length - position);
System.arraycopy(bytes, position, target, offset, count);
position += count;
return count;
}
}
}
@@ -0,0 +1,77 @@
package dev.relism.flash.http2.stream;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.http2.hpack.HpackEncoder;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Param;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/** Measures one response pass across a connection with N simultaneously live request streams. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class Http2MultiplexingBenchmark {
private static final byte[] BODY = "ok".getBytes(StandardCharsets.US_ASCII);
@Param({"1", "8", "64", "256"})
public int liveStreams;
private Http2Stream[] streams;
@Setup(Level.Trial)
public void setup() {
Http2StreamTable table = new Http2StreamTable(liveStreams);
streams = new Http2Stream[liveStreams];
ByteWriter encoded = new ByteWriter(64);
HpackEncoder.writeIndexed(encoded, 2);
HpackEncoder.writeIndexed(encoded, 7);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 4, "/get".getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeLiteralWithNameIndex(
encoded, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
HpackDecoder decoder = new HpackDecoder();
for (int i = 0; i < streams.length; i++) {
Http2Stream stream = table.acquire(i * 2 + 1);
decoder.decode(encoded.array(), 0, encoded.length(), stream.headerBlock());
stream.assembleRequest(null, null);
streams[i] = stream;
}
}
@Benchmark
public int encodeAllLiveStreamResponses() {
int wireBytes = 0;
for (Http2Stream stream : streams) {
stream
.responseWriter()
.prepare(
stream.resetResponse().body(BODY),
stream.id(),
false,
false,
true,
false,
false,
16_384,
32_768,
65_535);
wireBytes += stream.responseWriter().length();
}
return wireBytes;
}
}
@@ -26,11 +26,14 @@ import org.openjdk.jmh.annotations.Warmup;
@Measurement(iterations = 5, time = 1)
public class Http2StreamBenchmark {
private static final byte[] BODY = "pong".getBytes(StandardCharsets.US_ASCII);
private static final byte[] POST_BODY = new byte[1024];
private Http2StreamTable streams;
private HpackDecoder decoder;
private byte[] requestBlock;
private int requestLength;
private byte[] postBlock;
private int postLength;
@Setup
public void setup() {
@@ -45,7 +48,19 @@ public class Http2StreamBenchmark {
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
requestBlock = block.array();
requestLength = block.length();
ByteWriter post = new ByteWriter(96);
HpackEncoder.writeIndexed(post, 3);
HpackEncoder.writeIndexed(post, 7);
HpackEncoder.writeLiteralWithNameIndex(
post, 4, "/ping".getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeLiteralWithNameIndex(
post, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeLiteralWithNameIndex(
post, 28, "1024".getBytes(StandardCharsets.US_ASCII), false);
postBlock = post.array();
postLength = post.length();
lifecycle();
postOneKiB();
}
@Benchmark
@@ -62,4 +77,32 @@ public class Http2StreamBenchmark {
streams.release(stream);
return bytes;
}
/** Unary request shape: HPACK decode, one 1 KiB DATA payload, assembly and fixed response. */
@Benchmark
public int postOneKiB() {
Http2Stream stream = streams.acquire(1);
decoder.decode(postBlock, 0, postLength, stream.headerBlock());
stream.prepareRequestBody(null, false);
stream.receiveData(POST_BODY, 0, POST_BODY.length, POST_BODY.length);
stream.finishRequestBody();
stream.assembleRequest(null, null);
stream
.responseWriter()
.prepare(
stream.resetResponse().body(BODY),
1,
false,
false,
true,
false,
false,
16_384,
32_768,
65_535);
int bytes = stream.responseWriter().length();
streams.remove(1);
streams.release(stream);
return bytes;
}
}
@@ -2,6 +2,8 @@ package dev.relism.flash;
import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.models.BodyCompletion;
import dev.relism.flash.models.MutableHeaderMap;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.IOException;
@@ -20,15 +22,28 @@ import java.io.InputStream;
* {@link BufferedByteSource#prependOnce}, replacing the {@code SequenceInputStream}/
* {@code ByteArrayInputStream} pair the previous implementation allocated per chunked request.
*/
final class ChunkedInputStream extends InputStream {
final class ChunkedInputStream extends InputStream implements BodyCompletion {
private final BufferedByteSource src;
private int chunkRemaining = 0;
private boolean done = false;
private int chunksSeen = 0;
private final MutableHeaderMap trailers;
private final byte[] trailerLine = new byte[Http1Limits.MAX_HEADER_VALUE_LENGTH];
ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen,
MutableHeaderMap trailers) {
this.src = src;
this.trailers = trailers;
if (preBufLen > 0) src.prependOnce(preBuf, preBufOff, preBufLen);
}
ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen) {
this.src = src;
if (preBufLen > 0) src.prependOnce(preBuf, preBufOff, preBufLen);
this(src, preBuf, preBufOff, preBufLen, new MutableHeaderMap());
}
@Override
public boolean fullyRead() {
return done;
}
@Override
@@ -129,7 +144,7 @@ final class ChunkedInputStream extends InputStream {
int trailerCount = 0;
while (true) {
int b = src.read();
if (b == -1) return; // EOF mid-trailers — nothing left to bound.
if (b == -1) throw new MalformedRequestException(400, "Truncated trailer section");
if (b == '\r') {
if (src.read() != '\n') {
throw new MalformedRequestException(400, "Malformed trailer section terminator");
@@ -139,12 +154,68 @@ final class ChunkedInputStream extends InputStream {
if (++trailerCount > Http1Limits.MAX_TRAILER_COUNT) {
throw new MalformedRequestException(431, "Too many trailers");
}
int lineLen = 1;
int lineLen = 0;
trailerLine[lineLen++] = (byte) b;
while ((b = src.read()) != -1 && b != '\n') {
if (++lineLen > Http1Limits.MAX_HEADER_VALUE_LENGTH) {
if (lineLen == trailerLine.length) {
throw new MalformedRequestException(431, "Trailer line too long");
}
trailerLine[lineLen++] = (byte) b;
}
if (b != '\n' || lineLen == 0 || trailerLine[lineLen - 1] != '\r') {
throw new MalformedRequestException(400, "Malformed trailer line");
}
addTrailer(lineLen - 1);
}
}
private void addTrailer(int lineLength) throws MalformedRequestException {
int colon = -1;
for (int i = 0; i < lineLength; i++) {
if (trailerLine[i] == ':') { colon = i; break; }
}
if (colon <= 0) throw new MalformedRequestException(400, "Malformed trailer field");
for (int i = 0; i < colon; i++) {
int c = trailerLine[i] & 0xff;
if (!isToken(c)) {
throw new MalformedRequestException(400, "Invalid trailer field name");
}
}
int valueStart = colon + 1;
while (valueStart < lineLength
&& (trailerLine[valueStart] == ' ' || trailerLine[valueStart] == '\t')) valueStart++;
int valueEnd = lineLength;
while (valueEnd > valueStart
&& (trailerLine[valueEnd - 1] == ' ' || trailerLine[valueEnd - 1] == '\t')) valueEnd--;
if (forbidden(trailerLine, colon)) {
throw new MalformedRequestException(400, "Forbidden trailer field");
}
trailers.add(trailerLine, 0, colon, trailerLine, valueStart, valueEnd - valueStart);
}
private static boolean isToken(int c) {
return (c >= '0' && c <= '9')
|| (c >= 'A' && c <= 'Z')
|| (c >= 'a' && c <= 'z')
|| c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\''
|| c == '*' || c == '+' || c == '-' || c == '.' || c == '^' || c == '_'
|| c == '`' || c == '|' || c == '~';
}
private static boolean forbidden(byte[] name, int length) {
return asciiEquals(name, length, "content-length")
|| asciiEquals(name, length, "transfer-encoding")
|| asciiEquals(name, length, "host")
|| asciiEquals(name, length, "trailer");
}
private static boolean asciiEquals(byte[] bytes, int length, String expected) {
if (length != expected.length()) return false;
for (int i = 0; i < length; i++) {
int c = bytes[i] & 0xff;
if (c >= 'A' && c <= 'Z') c += 32;
if (c != expected.charAt(i)) return false;
}
return true;
}
}
@@ -5,6 +5,7 @@ import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.MutableHeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestBody;
import dev.relism.flash.models.RequestLine;
@@ -57,6 +58,7 @@ public class RequestParser {
private final InetSocketAddress remoteAddress;
private final SSLSocket sslSocket;
private final Http1HeaderMap headerMap = new Http1HeaderMap();
private final MutableHeaderMap trailerMap = new MutableHeaderMap();
// request — same idiom as headerMap above.
private final RequestLine requestLine = new RequestLine();
private final Request request = new Request();
@@ -115,6 +117,7 @@ public class RequestParser {
* @throws IOException on genuine I/O failure (socket reset, timeout).
*/
public Request parse(BufferedByteSource in) throws IOException {
trailerMap.reset();
// Snapshot leftover bytes from the previous request, then reset immediately.
// Any exception thrown below leaves bufBase/bufLen at 0 — safe state.
int base = bufBase;
@@ -187,6 +190,7 @@ public class RequestParser {
boolean transferEncodingSeen = false;
boolean transferEncodingChunked = false;
int headerCount = 0;
headerMap.beginParsed(buffer, sectionStart, headerEndIdx);
while (current < headerEndIdx) {
// deprecates line folding and treating a folded continuation as part of the
@@ -229,6 +233,7 @@ public class RequestParser {
if (lineEnd - valueStart > Http1Limits.MAX_HEADER_VALUE_LENGTH) {
throw new MalformedRequestException(431, "Header value exceeds " + Http1Limits.MAX_HEADER_VALUE_LENGTH + " bytes");
}
headerMap.addParsed(current, colon - current, valueStart, lineEnd - valueStart);
if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "content-length")) {
// parseLong, which silently accepted "5abc" as 5 and "-1" as 1.
@@ -267,8 +272,6 @@ public class RequestParser {
if (!contentLengthSeen) contentLength = 0;
}
headerMap.reset(buffer, sectionStart, headerEndIdx);
// ── Body / pipelining accounting ─────────────────────────────────────
int bodyStart = headerEndIdx + 4;
@@ -293,11 +296,15 @@ public class RequestParser {
// is handled by the same call: preBufLen is already forced to 0 for it above) or the
// chunked case, never reallocated.
if (isChunked) {
requestBody.reset(new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0);
requestBody.reset(
new ChunkedInputStream(in, buffer, bodyStart, preBufLen, trailerMap),
-1L, null, 0, 0);
} else {
requestBody.reset(in, contentLength, buffer, bodyStart, preBufLen);
}
return Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
Request parsed = Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
parsed.setTrailers(trailerMap);
return parsed;
}
/**
@@ -24,7 +24,7 @@ import dev.relism.fpr.core.ByteView;
* to be copied.</li>
* </ul>
* Code that only has a bare {@link ByteView} (e.g. because it received one across the
* {@link SegmentedByteView} boundary, from a future HPACK CONTINUATION-spanning block) keeps the
* {@link SegmentedByteView} boundary) keeps the
* byte-at-a-time fallback — this interface is an opportunistic fast path, never a requirement.
*/
public interface ArrayBackedByteView extends ByteView {
@@ -7,7 +7,7 @@ import java.util.List;
/**
* Inspects a handler class at registration time and returns zero or more
* {@link Middleware middlewares} to inject automatically.
* {@link MiddlewareNode middleware nodes} to inject automatically.
*
* <p>Processors are called once per register call, before
* the handler is compiled into the router. Returning an empty list is always
@@ -89,18 +89,59 @@ public class FlashConfiguration {
@Builder.Default int shutdownDrainTimeoutMs = 15_000;
/**
* Whether this server negotiates HTTP/2. When enabled, plaintext listeners recognize h2c prior
* knowledge and TLS listeners advertise {@code h2} followed by HTTP/1.1 through ALPN. Disabled by
* default until the HTTP/2 request/response path is complete.
* Maximum connections admitted across all listeners before new connections are closed
* immediately at accept time, before any per-connection state (TLS handshake, protocol
* negotiation, HPACK tables, buffers) is set up. Defaults to an auto-scaled budget based on the
* JVM's max heap ({@link dev.relism.flash.transport.TransportLimits#defaultMaxConnections()}),
* so a connection flood cannot exhaust the heap out of the box. Set explicitly if you know your
* deployment's real capacity, or to {@code 0} to disable the check entirely (unlimited).
*/
@Builder.Default int maxConnections =
dev.relism.flash.transport.TransportLimits.defaultMaxConnections();
/** Whether TLS listeners advertise HTTP/2 through ALPN. */
@Builder.Default boolean http2Enabled = false;
/**
* Whether plaintext listeners accept the HTTP/2 prior-knowledge preface. This is independent
* from TLS HTTP/2 and deliberately disabled by default.
*/
@Builder.Default boolean http2CleartextEnabled = false;
/**
* Whether runtime-generated HTTP/2 header values use HPACK Huffman coding. Constants are always
* compressed once at startup; leaving this disabled avoids a per-byte encode pass on responses.
*/
@Builder.Default boolean h2HuffmanDynamicValues = false;
/** Maximum peer RST_STREAM frames per rolling interval. */
@Builder.Default int h2MaxResetStreamsPerInterval =
dev.relism.flash.http2.Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL;
/** Maximum peer-created streams per rolling interval. */
@Builder.Default int h2MaxStreamsCreatedPerInterval =
dev.relism.flash.http2.Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL;
/** Rolling interval used by HTTP/2 abuse-rate counters. */
@Builder.Default long h2AbuseRateIntervalMs =
dev.relism.flash.http2.Http2Limits.RESET_RATE_INTERVAL_MS;
/** Maximum total streams served by one HTTP/2 connection; zero disables the budget. */
@Builder.Default long h2MaxStreamsPerConnection =
dev.relism.flash.http2.Http2Limits.MAX_STREAMS_PER_CONNECTION;
/** Maximum wire bytes read by one HTTP/2 connection; zero disables the budget. */
@Builder.Default long h2MaxBytesPerConnection =
dev.relism.flash.http2.Http2Limits.MAX_BYTES_PER_CONNECTION;
/** Maximum HTTP/2 connection lifetime in milliseconds; zero disables the budget. */
@Builder.Default long h2MaxConnectionLifetimeMs =
dev.relism.flash.http2.Http2Limits.MAX_CONNECTION_LIFETIME_MS;
/** Maximum inactivity time for an open HTTP/2 stream. */
@Builder.Default long h2StreamIdleTimeoutMs =
dev.relism.flash.http2.Http2Limits.STREAM_IDLE_TIMEOUT_MS;
/**
* Whether every response includes a {@code Date} header (RFC 9110 §6.6.1). Default {@code true};
* set {@code false} if Flash sits behind a reverse proxy that already adds one, to skip the
@@ -2,8 +2,9 @@ package dev.relism.flash.extension;
import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.routing.Ws;
import dev.relism.flash.routing.Route;
import dev.relism.flash.routing.Routes;
import dev.relism.flash.routing.Ws;
import dev.relism.flash.websocket.WebSocketEndpoint;
import java.io.File;
@@ -7,7 +7,7 @@ import java.util.Arrays;
import lombok.Getter;
/**
* Pre-compiled byte representations of common HTTP {@code Content-Type} values. {@link #getBytes()}
* Pre-compiled byte representations of common HTTP {@code Content-Type} values. {@code getBytes()}
* returns the pre-computed array directly, never allocates.
*/
@Getter
@@ -0,0 +1,87 @@
package dev.relism.flash.http;
import dev.relism.flash.models.HeaderView;
import dev.relism.fpr.core.ByteView;
/** Shared proxy policy for fields that must not cross an HTTP connection boundary. */
public final class HopByHopHeaders {
public enum Protocol {
HTTP_1_1,
HTTP_2
}
private HopByHopHeaders() {}
/** Returns whether a field may be copied to a new downstream connection. */
public static boolean shouldForward(
HeaderView source,
ByteView name,
ByteView value,
Protocol sourceProtocol,
Protocol targetProtocol) {
if (name.length() == 0 || name.byteAt(0) == ':') return false;
if (is(name, "connection")
|| is(name, "keep-alive")
|| is(name, "proxy-connection")
|| is(name, "proxy-authenticate")
|| is(name, "proxy-authorization")
|| is(name, "trailer")
|| is(name, "transfer-encoding")
|| is(name, "upgrade")) {
return false;
}
if (isConnectionListed(source, name)) return false;
if (is(name, "te")) {
return targetProtocol == Protocol.HTTP_2 && isTrimmed(value, "trailers");
}
return true;
}
private static boolean isConnectionListed(HeaderView source, ByteView fieldName) {
for (String value : source.all("connection")) {
int start = 0;
while (start < value.length()) {
int comma = value.indexOf(',', start);
int end = comma < 0 ? value.length() : comma;
while (start < end && isWhitespace(value.charAt(start))) start++;
while (end > start && isWhitespace(value.charAt(end - 1))) end--;
if (equalsAsciiIgnoreCase(fieldName, value, start, end)) return true;
start = comma < 0 ? value.length() : comma + 1;
}
}
return false;
}
private static boolean is(ByteView bytes, String expected) {
return equalsAsciiIgnoreCase(bytes, expected, 0, expected.length());
}
private static boolean isTrimmed(ByteView bytes, String expected) {
int start = 0;
int end = bytes.length();
while (start < end && isWhitespace((char) bytes.byteAt(start))) start++;
while (end > start && isWhitespace((char) bytes.byteAt(end - 1))) end--;
if (end - start != expected.length()) return false;
for (int i = 0; i < expected.length(); i++) {
if (lower(bytes.byteAt(start + i) & 0xff) != lower(expected.charAt(i))) return false;
}
return true;
}
private static boolean equalsAsciiIgnoreCase(
ByteView bytes, String expected, int expectedStart, int expectedEnd) {
if (bytes.length() != expectedEnd - expectedStart) return false;
for (int i = 0; i < bytes.length(); i++) {
if (lower(bytes.byteAt(i) & 0xff) != lower(expected.charAt(expectedStart + i))) return false;
}
return true;
}
private static int lower(int value) {
return value >= 'A' && value <= 'Z' ? value + ('a' - 'A') : value;
}
private static boolean isWhitespace(char value) {
return value == ' ' || value == '\t';
}
}
@@ -82,7 +82,9 @@ public final class Http1ResponseWriter {
response.writeHeadersInto(head);
if (response.isStreaming()) {
if (response.hasTrailers()) {
writeTrailerBody(out, head, response, keepAlive, suppressBody, scratch);
} else if (response.isStreaming()) {
writeStreamingBody(out, head, response, keepAlive, noContentAllowed, suppressBody, scratch);
} else {
byte[] body = response.getBody();
@@ -109,6 +111,28 @@ public final class Http1ResponseWriter {
out.flush();
}
private static void writeTrailerBody(OutputStream out, ByteWriter head, Response response,
boolean keepAlive, boolean suppressBody,
ConnectionScratch scratch) throws IOException {
head.writeBytes(TRANSFER_CHUNKED);
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
head.writeBytes(CRLF);
out.write(head.array(), 0, head.length());
if (suppressBody) return;
if (response.isStreaming()) {
writeChunkedAndClose(out, response, scratch);
} else {
byte[] body = response.getBody();
if (body != null && body.length != 0) {
writeHex(out, body.length);
out.write(CRLF);
out.write(body);
out.write(CRLF);
}
writeFinalChunk(out, response);
}
}
private static void writeStreamingBody(OutputStream out, ByteWriter head, Response response, boolean keepAlive,
boolean noContentAllowed, boolean suppressBody,
ConnectionScratch scratch) throws IOException {
@@ -121,7 +145,7 @@ public final class Http1ResponseWriter {
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
head.writeBytes(CRLF);
out.write(head.array(), 0, head.length());
if (!suppressBody) relay(response.getStream(), out, scratch);
if (!suppressBody) relayAndClose(response.getStream(), out, scratch);
} else {
head.writeBytes(TRANSFER_CHUNKED);
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
@@ -130,7 +154,34 @@ public final class Http1ResponseWriter {
// A HEAD response still declares the Transfer-Encoding GET would have used (RFC
// 9110 §9.3.2) but writes zero body bytes — not even the final-chunk marker, since
// there is no chunk framing at all for a message with no body.
if (!suppressBody) writeChunked(out, response.getStream(), scratch);
if (!suppressBody) writeChunkedAndClose(out, response, scratch);
}
}
/**
* Closes the handler's stream on every exit — clean EOF or a write failure partway through
* (e.g. the client disconnected mid-transfer). Without this, a handler whose stream only
* releases a held resource (a pooled backend connection, say) from {@code close()} — not from
* observing EOF on a {@code read()} that a downstream write failure means it never reaches —
* leaks that resource for as long as the JVM takes to finalize it. A well-behaved stream's
* {@code close()} must already be idempotent (Java's own contract for {@link InputStream}), so
* this costs nothing extra on the ordinary clean-EOF path.
*/
private static void relayAndClose(InputStream in, OutputStream out, ConnectionScratch scratch)
throws IOException {
try {
relay(in, out, scratch);
} finally {
in.close();
}
}
private static void writeChunkedAndClose(OutputStream out, Response response, ConnectionScratch scratch)
throws IOException {
try {
writeChunked(out, response.getStream(), response, scratch);
} finally {
response.getStream().close();
}
}
@@ -150,7 +201,8 @@ public final class Http1ResponseWriter {
else { head.writeDecimal(statusCode); head.writeBytes(UNKNOWN_STATUS_SUFFIX); }
}
private static void writeChunked(OutputStream out, InputStream stream, ConnectionScratch scratch) throws IOException {
private static void writeChunked(OutputStream out, InputStream stream, Response response,
ConnectionScratch scratch) throws IOException {
byte[] buf = scratch.relayBuffer;
int n;
while ((n = stream.read(buf)) > 0) {
@@ -159,7 +211,15 @@ public final class Http1ResponseWriter {
out.write(buf, 0, n);
out.write(CRLF);
}
out.write(FINAL_CHUNK);
if (response.hasTrailers()) writeFinalChunk(out, response);
else out.write(FINAL_CHUNK);
}
private static void writeFinalChunk(OutputStream out, Response response) throws IOException {
out.write('0');
out.write(CRLF);
response.writeTrailers(out);
out.write(CRLF);
}
private static void writeHex(OutputStream out, int value) throws IOException {
@@ -0,0 +1,114 @@
package dev.relism.flash.http2;
import dev.relism.flash.extension.FlashConfiguration;
/** Enforces per-connection HTTP/2 rate limits and lifetime budgets. */
final class Http2AbuseGuard {
private RollingWindowCounter resetRate;
private RollingWindowCounter streamCreationRate;
private RollingWindowCounter settingsRate;
private RollingWindowCounter pingRate;
private RollingWindowCounter uselessFrameRate;
private int maxResetRate;
private int maxStreamCreationRate;
private long maxStreams;
private long maxBytes;
private long maxLifetimeNanos;
private long startedNanos;
private long wireBytes;
private long streams;
Http2AbuseGuard() {
configure(FlashConfiguration.builder().build());
}
void configure(FlashConfiguration configuration) {
long interval = configuration.getH2AbuseRateIntervalMs();
if (interval < 2) throw new IllegalArgumentException("h2AbuseRateIntervalMs must be at least 2");
resetRate = new RollingWindowCounter(interval);
streamCreationRate = new RollingWindowCounter(interval);
settingsRate = new RollingWindowCounter(interval);
pingRate = new RollingWindowCounter(interval);
uselessFrameRate = new RollingWindowCounter(interval);
maxResetRate =
positive(
configuration.getH2MaxResetStreamsPerInterval(),
"h2MaxResetStreamsPerInterval");
maxStreamCreationRate =
positive(
configuration.getH2MaxStreamsCreatedPerInterval(),
"h2MaxStreamsCreatedPerInterval");
maxStreams =
nonNegative(configuration.getH2MaxStreamsPerConnection(), "h2MaxStreamsPerConnection");
maxBytes =
nonNegative(configuration.getH2MaxBytesPerConnection(), "h2MaxBytesPerConnection");
long lifetime =
nonNegative(
configuration.getH2MaxConnectionLifetimeMs(), "h2MaxConnectionLifetimeMs");
maxLifetimeNanos = toNanos(lifetime);
}
void start() {
startedNanos = System.nanoTime();
}
void receivedFrame(int payloadLength) {
wireBytes += 9L + payloadLength;
checkBudgets();
}
void streamCreated() {
if (streamCreationRate.incrementExceeded(maxStreamCreationRate)) {
calm("stream creation rate");
}
streams++;
if (maxStreams > 0 && streams > maxStreams) calm("connection stream budget");
}
void resetReceived() {
if (resetRate.incrementExceeded(maxResetRate)) calm("RST_STREAM rate");
}
void settingsReceived() {
if (settingsRate.incrementExceeded(Http2Limits.MAX_SETTINGS_PER_INTERVAL)) {
calm("SETTINGS rate");
}
}
void pingReceived() {
if (pingRate.incrementExceeded(Http2Limits.MAX_PINGS_PER_INTERVAL)) calm("PING rate");
}
void uselessFrameReceived() {
if (uselessFrameRate.incrementExceeded(Http2Limits.MAX_USELESS_FRAMES_PER_INTERVAL)) {
calm("non-progress frame rate");
}
}
void checkBudgets() {
if (maxBytes > 0 && wireBytes > maxBytes) calm("connection byte budget");
if (maxLifetimeNanos > 0 && System.nanoTime() - startedNanos > maxLifetimeNanos) {
calm("connection lifetime budget");
}
}
private static int positive(int value, String name) {
if (value <= 0) throw new IllegalArgumentException(name + " must be positive");
return value;
}
private static long nonNegative(long value, String name) {
if (value < 0) throw new IllegalArgumentException(name + " must not be negative");
return value;
}
private static long toNanos(long milliseconds) {
return milliseconds > Long.MAX_VALUE / 1_000_000L
? Long.MAX_VALUE
: milliseconds * 1_000_000L;
}
private static void calm(String reason) {
throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, reason + " exceeded");
}
}
@@ -0,0 +1,54 @@
package dev.relism.flash.http2;
import java.security.cert.Certificate;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import javax.net.ssl.SSLSession;
/** Validates a coalesced request authority against the certificate selected for its connection. */
final class Http2Authority {
private Http2Authority() {}
static boolean isServed(String authority, SSLSession session) {
if (session == null || authority == null) return true;
String host = host(authority);
try {
Certificate[] certificates = session.getLocalCertificates();
if (certificates == null || certificates.length == 0
|| !(certificates[0] instanceof X509Certificate certificate)) {
return true;
}
Collection<List<?>> names = certificate.getSubjectAlternativeNames();
if (names == null) return true;
for (List<?> name : names) {
int type = (Integer) name.get(0);
if ((type == 2 || type == 7) && matches(host, name.get(1).toString())) return true;
}
return false;
} catch (CertificateParsingException failure) {
return true;
}
}
static boolean matches(String authority, String certificateName) {
String host = host(authority).toLowerCase(Locale.ROOT);
String name = certificateName.toLowerCase(Locale.ROOT);
if (!name.startsWith("*.")) return host.equals(name);
String suffix = name.substring(1);
if (!host.endsWith(suffix)) return false;
int prefixLength = host.length() - suffix.length();
return prefixLength > 0 && host.indexOf('.') == prefixLength;
}
private static String host(String authority) {
if (authority.startsWith("[")) {
int closing = authority.indexOf(']');
return closing < 0 ? authority : authority.substring(1, closing);
}
int colon = authority.lastIndexOf(':');
return colon > 0 && authority.indexOf(':') == colon ? authority.substring(0, colon) : authority;
}
}
@@ -1,5 +1,7 @@
package dev.relism.flash.http2;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http2.Http2ConnectionScratch.ControlIntent;
import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind;
import dev.relism.flash.http2.frame.FrameFlags;
@@ -8,7 +10,10 @@ import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameValidator;
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.hpack.HeaderSink;
import dev.relism.flash.http2.message.DataBufferPool;
import dev.relism.flash.http2.stream.Http2FlowController;
import dev.relism.flash.http2.stream.Http2Stream;
import dev.relism.flash.http2.stream.Http2StreamState;
import dev.relism.flash.http2.stream.Http2StreamTable;
@@ -37,10 +42,13 @@ public final class Http2Connection implements ConnectionProtocol {
private final Http2Settings.StreamWindowUpdater streamWindows;
private final long settingsAckTimeoutMs;
private final Http2HeaderBlockDecoder headerBlocks = new Http2HeaderBlockDecoder();
private final Http2StreamTable streams = new Http2StreamTable(Http2Limits.MAX_CONCURRENT_STREAMS);
private final DataBufferPool dataBuffers =
new DataBufferPool(Http2Limits.MAX_FRAME_SIZE_LOCAL, Http2Limits.DATA_BUFFER_POOL_SIZE);
private final Http2StreamTable streams =
new Http2StreamTable(Http2Limits.MAX_CONCURRENT_STREAMS, dataBuffers);
private static final HeaderSink DISCARD_HEADERS = (name, value, never) -> {};
private long connectionSendWindow = 65_535;
private Http2FlowController flowController;
private int outstandingLocalSettings;
private long oldestSettingsSentNanos;
private int lastProcessedStreamId;
@@ -52,9 +60,13 @@ public final class Http2Connection implements ConnectionProtocol {
private int highestClientStreamId;
private Http2Stream pendingHeaderStream;
private boolean refusingHeaderStream;
private boolean pendingTrailers;
private Http2StreamDispatcher streamDispatcher;
private final Http2Stream[] dispatchQueue = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
private int dispatchCount;
private final Http2AbuseGuard abuse = new Http2AbuseGuard();
private long streamIdleTimeoutNanos = Http2Limits.STREAM_IDLE_TIMEOUT_MS * 1_000_000L;
private final Http2Stream[] idleSweep = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
public Http2Connection() {
this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
@@ -68,7 +80,8 @@ public final class Http2Connection implements ConnectionProtocol {
this.streamWindows =
delta -> {
try {
streams.adjustAllSendWindows(delta);
if (flowController == null) streams.adjustAllSendWindows(delta);
else flowController.applyInitialWindowDelta(streams, delta);
} catch (IllegalStateException overflow) {
throw Http2Exception.FLOW_CONTROL_ERROR;
}
@@ -79,13 +92,18 @@ public final class Http2Connection implements ConnectionProtocol {
@Override
public void run(ConnectionContext ctx) throws IOException {
configure(ctx.configuration());
Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write);
flowController =
new Http2FlowController(
(streamId, increment) -> sendWindowUpdate(writer, streamId, increment));
streamDispatcher =
new Http2StreamDispatcher(
ctx,
writer,
peerSettings,
streams,
flowController,
(streamId, error) -> sendRstStream(writer, streamId, error));
try {
run(ctx.in(), writer, ctx.stopped());
@@ -96,6 +114,11 @@ public final class Http2Connection implements ConnectionProtocol {
void run(BufferedByteSource input, Http2FrameWriter writer, BooleanSupplier stopped)
throws IOException {
if (flowController == null) {
flowController =
new Http2FlowController(
(streamId, increment) -> sendWindowUpdate(writer, streamId, increment));
}
Http2FrameReader reader = new Http2FrameReader(input);
runPrepared(input, reader, writer, stopped);
}
@@ -107,7 +130,14 @@ public final class Http2Connection implements ConnectionProtocol {
Http2FrameWriter writer,
BooleanSupplier stopped)
throws IOException {
if (!verifyPreface(input)) return;
PrefaceResult preface = verifyPreface(input);
if (preface == PrefaceResult.TRUNCATED) return;
if (preface == PrefaceResult.INVALID) {
sendGoAway(writer, 0, Http2ErrorCode.PROTOCOL_ERROR, "invalid client preface");
writer.drain();
return;
}
abuse.start();
sendConstant(writer, Http2Preface.serverSettings());
sendConstant(writer, Http2Preface.initialConnectionWindow());
@@ -117,12 +147,15 @@ public final class Http2Connection implements ConnectionProtocol {
boolean firstFrame = true;
try {
while (!gracefulFinished && !peerGoAway) {
abuse.checkBudgets();
closeIdleStreams(writer);
if (stopped.getAsBoolean() && !gracefulStarted) startGracefulShutdown(writer);
FrameHeader frame;
try {
frame = reader.readFrame(Math.min(100, nextReadTimeoutMs()));
} catch (SocketTimeoutException timeout) {
checkSettingsTimeout();
headerBlocks.checkTimeout();
if (stopped.getAsBoolean() && !gracefulStarted) {
startGracefulShutdown(writer);
continue;
@@ -131,7 +164,9 @@ public final class Http2Connection implements ConnectionProtocol {
continue;
}
if (frame == null) break;
abuse.receivedFrame(frame.length());
try {
headerBlocks.checkTimeout();
FrameValidator.validate(frame, headerBlocks.insideHeaderBlock());
if (headerBlocks.insideHeaderBlock() && frame.type() != FrameType.CONTINUATION) {
throw Http2Exception.PROTOCOL_ERROR;
@@ -165,25 +200,36 @@ public final class Http2Connection implements ConnectionProtocol {
}
}
private boolean verifyPreface(BufferedByteSource input) throws IOException {
private PrefaceResult verifyPreface(BufferedByteSource input) throws IOException {
byte[] preface = scratch.prefaceBuffer();
int read = 0;
input.setDeadline(System.nanoTime() + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L);
try {
while (read < preface.length) {
int n = input.read(preface, read, preface.length - read);
if (n < 0) return false;
if (n < 0) return PrefaceResult.TRUNCATED;
read += n;
}
return Http2Preface.matchesClientPreface(preface);
return Http2Preface.matchesClientPreface(preface)
? PrefaceResult.MATCHED
: PrefaceResult.INVALID;
} finally {
input.clearDeadline();
}
}
private enum PrefaceResult {
MATCHED,
INVALID,
TRUNCATED
}
private void dispatch(FrameHeader frame, Http2FrameWriter writer) throws IOException {
FrameType type = frame.type();
if (type == null) return;
if (type == null) {
abuse.uselessFrameReceived();
return;
}
switch (type) {
case SETTINGS -> receiveSettings(frame, writer);
case PING -> receivePing(frame, writer);
@@ -201,12 +247,50 @@ public final class Http2Connection implements ConnectionProtocol {
private void receiveHeaders(FrameHeader frame, Http2FrameWriter writer) throws IOException {
int streamId = frame.streamId();
if ((streamId & 1) == 0) throw Http2Exception.PROTOCOL_ERROR;
if (streamId <= highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
Http2Stream existing = streams.get(streamId);
if (existing != null) {
existing.touch();
if (existing.state() == Http2StreamState.HALF_CLOSED_REMOTE) {
throw new Http2StreamException(
streamId, Http2ErrorCode.STREAM_CLOSED, "stream is half-closed remotely");
}
if (!FrameFlags.isEndStream(frame.flags())) {
throw new Http2StreamException(
streamId, Http2ErrorCode.PROTOCOL_ERROR, "trailers require END_STREAM");
}
pendingHeaderStream = existing;
pendingTrailers = true;
existing.trailerBlock().reset();
if (headerBlocks.accept(frame, existing.trailerBlock())) completeHeaders(writer, streamId);
return;
}
if (streamId <= highestClientStreamId) {
int closedKind = streams.closedKind(streamId);
if (closedKind == Http2StreamTable.CLOSED_NORMALLY) {
throw Http2Exception.of(Http2ErrorCode.STREAM_CLOSED, "frame on a closed stream");
}
if (closedKind == Http2StreamTable.CLOSED_BY_RESET) {
throw new Http2StreamException(
streamId, Http2ErrorCode.STREAM_CLOSED, "stream was reset");
}
throw Http2Exception.PROTOCOL_ERROR;
}
abuse.streamCreated();
highestClientStreamId = streamId;
pendingTrailers = false;
pendingHeaderStream = streams.acquire(streamId);
refusingHeaderStream = pendingHeaderStream == null;
HeaderSink sink = refusingHeaderStream ? DISCARD_HEADERS : pendingHeaderStream.headerBlock();
if (pendingHeaderStream != null) {
flowController.initializeStreamSendWindow(
pendingHeaderStream, peerSettings.initialWindowSize());
}
HeaderSink sink =
refusingHeaderStream
? DISCARD_HEADERS
: (pendingTrailers
? pendingHeaderStream.trailerBlock()
: pendingHeaderStream.headerBlock());
if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId());
}
@@ -214,34 +298,55 @@ public final class Http2Connection implements ConnectionProtocol {
if (pendingHeaderStream == null && !refusingHeaderStream) {
throw Http2Exception.PROTOCOL_ERROR;
}
HeaderSink sink = refusingHeaderStream ? DISCARD_HEADERS : pendingHeaderStream.headerBlock();
HeaderSink sink =
refusingHeaderStream
? DISCARD_HEADERS
: (pendingTrailers
? pendingHeaderStream.trailerBlock()
: pendingHeaderStream.headerBlock());
if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId());
}
private void completeHeaders(Http2FrameWriter writer, int streamId) throws IOException {
if (refusingHeaderStream) {
sendRstStream(writer, streamId, Http2ErrorCode.REFUSED_STREAM);
} else {
Http2Stream stream = pendingHeaderStream;
if (streamDispatcher != null) stream.validateHeaders();
stream.transition(
headerBlocks.endStream()
? Http2StreamState.Event.RECV_HEADERS_ES
: Http2StreamState.Event.RECV_HEADERS);
lastProcessedStreamId = Math.max(lastProcessedStreamId, streamId);
if (streamDispatcher == null) {
streams.remove(streamId);
streams.release(stream);
if (!gracefulStarted) startGracefulShutdown(writer);
} else if (headerBlocks.endStream()) {
enqueueDispatch(stream);
try {
if (refusingHeaderStream) {
sendRstStream(writer, streamId, Http2ErrorCode.REFUSED_STREAM);
} else {
Http2Stream stream = pendingHeaderStream;
boolean dispatch;
if (pendingTrailers) {
stream.validateTrailers();
stream.finishRequestBody();
stream.transition(Http2StreamState.Event.RECV_HEADERS_ES);
dispatch = !stream.dispatched();
} else {
if (streamDispatcher != null) stream.validateHeaders();
dispatch = stream.prepareRequestBody(flowController, headerBlocks.endStream());
stream.transition(
headerBlocks.endStream()
? Http2StreamState.Event.RECV_HEADERS_ES
: Http2StreamState.Event.RECV_HEADERS);
lastProcessedStreamId = Math.max(lastProcessedStreamId, streamId);
}
if (streamDispatcher == null && !pendingTrailers) {
streams.retire(stream, streamId);
if (!gracefulStarted) startGracefulShutdown(writer);
} else if (dispatch) {
enqueueDispatch(stream);
} else if (stream.responseStarted() && stream.responseWriter().finished()
&& !stream.responseInFlight() && stream.state() == Http2StreamState.CLOSED) {
streams.retire(stream, streamId);
}
}
} finally {
pendingHeaderStream = null;
refusingHeaderStream = false;
pendingTrailers = false;
}
pendingHeaderStream = null;
refusingHeaderStream = false;
}
private void receivePriority(FrameHeader frame) {
abuse.uselessFrameReceived();
int dependency = readUInt31(frame.buffer(), frame.payloadOffset());
if (dependency == frame.streamId()) {
throw new Http2StreamException(
@@ -250,21 +355,59 @@ public final class Http2Connection implements ConnectionProtocol {
}
private void receiveData(FrameHeader frame) {
Http2Stream stream = streamForFrame(frame.streamId());
stream.transition(
FrameFlags.isEndStream(frame.flags())
? Http2StreamState.Event.RECV_DATA_ES
: Http2StreamState.Event.RECV_DATA);
if (frame.length() != 0) {
if (frame.length() == 0) abuse.uselessFrameReceived();
flowController.receiveConnectionBytes(frame.length());
Http2Stream stream = streams.get(frame.streamId());
if (stream == null) {
discardConnectionBytes(frame.length());
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
throw new Http2StreamException(
frame.streamId(), Http2ErrorCode.INTERNAL_ERROR, "request DATA support is not active");
frame.streamId(), Http2ErrorCode.STREAM_CLOSED, "stream is closed");
}
if (FrameFlags.isEndStream(frame.flags()) && streamDispatcher != null) {
enqueueDispatch(stream);
boolean bodyAccepted = false;
try {
stream.touch();
stream.transition(
FrameFlags.isEndStream(frame.flags())
? Http2StreamState.Event.RECV_DATA_ES
: Http2StreamState.Event.RECV_DATA);
flowController.receiveStreamBytes(stream, frame.length());
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 (frame.length() == 0) {
if (stream.incrementEmptyDataFrames()
> Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM) {
throw new Http2StreamException(
frame.streamId(), Http2ErrorCode.ENHANCE_YOUR_CALM, "empty DATA frame limit exceeded");
}
} else {
stream.resetEmptyDataFrames();
}
stream.receiveData(frame.buffer(), dataOffset, dataLength, frame.length());
bodyAccepted = true;
if (FrameFlags.isEndStream(frame.flags())) {
stream.finishRequestBody();
if (streamDispatcher != null && !stream.dispatched()) enqueueDispatch(stream);
else if (stream.responseStarted() && stream.responseWriter().finished()
&& !stream.responseInFlight()
&& stream.state() == Http2StreamState.CLOSED) {
streams.retire(stream, frame.streamId());
}
}
} catch (RuntimeException failure) {
if (!bodyAccepted) discardConnectionBytes(frame.length());
throw failure;
}
}
private void receiveRstStream(FrameHeader frame) {
abuse.resetReceived();
Http2Stream stream = streams.get(frame.streamId());
if (stream == null) {
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
@@ -273,9 +416,11 @@ public final class Http2Connection implements ConnectionProtocol {
boolean releaseDeferred =
stream.dispatched() || stream.state() == Http2StreamState.HALF_CLOSED_REMOTE;
stream.transition(Http2StreamState.Event.RECV_RST);
streams.remove(stream.id());
if (!streams.removeIfSame(stream, frame.streamId())) return;
streams.rememberReset(frame.streamId());
if (releaseDeferred) {
stream.cancel();
if (stream.responseStarted() && !stream.responseInFlight()) streams.release(stream);
} else {
streams.release(stream);
}
@@ -286,6 +431,7 @@ public final class Http2Connection implements ConnectionProtocol {
throw new Http2StreamException(
stream.id(), Http2ErrorCode.REFUSED_STREAM, "dispatch queue is full");
}
stream.markDispatched();
dispatchQueue[dispatchCount++] = stream;
}
@@ -299,13 +445,6 @@ public final class Http2Connection implements ConnectionProtocol {
}
}
private Http2Stream streamForFrame(int streamId) {
Http2Stream stream = streams.get(streamId);
if (stream != null) return stream;
if (streamId > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
throw new Http2StreamException(streamId, Http2ErrorCode.STREAM_CLOSED, "stream is closed");
}
private void receiveSettings(FrameHeader frame, Http2FrameWriter writer) throws IOException {
boolean ack = FrameFlags.isAck(frame.flags());
if (ack) {
@@ -315,6 +454,7 @@ public final class Http2Connection implements ConnectionProtocol {
if (outstandingLocalSettings == 0) oldestSettingsSentNanos = 0;
return;
}
abuse.settingsReceived();
peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), streamWindows);
sendConstant(writer, Http2Preface.settingsAck());
}
@@ -327,12 +467,14 @@ public final class Http2Connection implements ConnectionProtocol {
}
return;
}
abuse.pingReceived();
ControlIntent pong = scratch.acquire(ControlKind.PING);
pong.frame(FrameType.PING, FrameFlags.ACK, 0, frame.buffer(), frame.payloadOffset(), 8);
writer.writePriority(pong);
}
private void receiveWindowUpdate(FrameHeader frame) {
abuse.uselessFrameReceived();
int increment = readUInt31(frame.buffer(), frame.payloadOffset());
if (increment == 0) {
if (frame.streamId() == 0) throw Http2Exception.PROTOCOL_ERROR;
@@ -346,16 +488,17 @@ public final class Http2Connection implements ConnectionProtocol {
return;
}
try {
stream.adjustSendWindow(increment);
stream.touch();
flowController.increaseStreamSendWindow(stream, increment);
} catch (IllegalStateException overflow) {
throw new Http2StreamException(
frame.streamId(), Http2ErrorCode.FLOW_CONTROL_ERROR, "stream window overflow");
}
if (streamDispatcher != null) streamDispatcher.streamWindowUpdated(stream);
return;
}
long next = connectionSendWindow + increment;
if (next > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR;
connectionSendWindow = next;
flowController.increaseConnectionSendWindow(increment);
if (streamDispatcher != null) streamDispatcher.connectionWindowUpdated();
}
private void receiveGoAway(FrameHeader frame) {
@@ -364,6 +507,30 @@ public final class Http2Connection implements ConnectionProtocol {
peerGoAway = true;
}
void configure(FlashConfiguration configuration) {
abuse.configure(configuration);
long idle = configuration.getH2StreamIdleTimeoutMs();
if (idle <= 0) throw new IllegalArgumentException("h2StreamIdleTimeoutMs must be positive");
streamIdleTimeoutNanos = idle > Long.MAX_VALUE / 1_000_000L
? Long.MAX_VALUE : idle * 1_000_000L;
}
private void closeIdleStreams(Http2FrameWriter writer) throws IOException {
int count = streams.copyValues(idleSweep);
long now = System.nanoTime();
for (int i = 0; i < count; i++) {
Http2Stream stream = idleSweep[i];
idleSweep[i] = null;
if (!stream.idleExpired(now, streamIdleTimeoutNanos)) continue;
int streamId = stream.id();
if (!streams.removeIfSame(stream, streamId)) continue;
streams.rememberReset(streamId);
sendRstStream(writer, streamId, Http2ErrorCode.CANCEL);
if (stream.dispatched()) stream.cancel();
else streams.release(stream);
}
}
private void startGracefulShutdown(Http2FrameWriter writer) throws IOException {
gracefulStarted = true;
sendGoAway(writer, Integer.MAX_VALUE, Http2ErrorCode.NO_ERROR, "server shutting down");
@@ -386,9 +553,26 @@ public final class Http2Connection implements ConnectionProtocol {
writer.writePriority(rst);
}
private void sendWindowUpdate(Http2FrameWriter writer, int streamId, int increment)
throws IOException {
ControlIntent update = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
update.windowUpdate(streamId, increment);
writer.writePriority(update);
}
private void discardConnectionBytes(int bytes) {
try {
flowController.discarded(bytes);
} catch (IOException failure) {
throw new IllegalStateException("failed to restore connection flow-control window", failure);
}
}
private void closeStreamAfterError(int streamId) {
Http2Stream stream = streams.remove(streamId);
Http2Stream stream = streams.get(streamId);
if (stream == null) return;
if (!streams.removeIfSame(stream, streamId)) return;
streams.rememberReset(streamId);
if (stream.dispatched()) stream.cancel();
else streams.release(stream);
if (pendingHeaderStream == stream) pendingHeaderStream = null;
@@ -446,7 +630,7 @@ public final class Http2Connection implements ConnectionProtocol {
}
public long connectionSendWindow() {
return connectionSendWindow;
return flowController == null ? 65_535 : flowController.connectionSendWindow();
}
public int peerLastStreamId() {
@@ -459,7 +643,7 @@ public final class Http2Connection implements ConnectionProtocol {
void reset() {
peerSettings.reset();
connectionSendWindow = 65_535;
flowController = null;
outstandingLocalSettings = 0;
oldestSettingsSentNanos = 0;
lastProcessedStreamId = 0;
@@ -119,6 +119,17 @@ final class Http2ConnectionScratch {
length = 17 + debugLength;
}
void windowUpdate(int streamId, int increment) {
buffer[0] = 0;
buffer[1] = 0;
buffer[2] = 4;
buffer[3] = (byte) FrameType.WINDOW_UPDATE.code();
buffer[4] = 0;
writeUInt31(buffer, 5, streamId);
writeUInt31(buffer, 9, increment);
length = 13;
}
private static void writeUInt31(byte[] target, int off, int value) {
writeUInt32(target, off, value & 0x7FFF_FFFF);
}
@@ -16,7 +16,18 @@ final class Http2HeaderBlockDecoder {
private final ContinuationAssembler assembler = new ContinuationAssembler();
private final HpackDecoder decoder = new HpackDecoder();
private final long assemblyTimeoutNanos;
private boolean endStream;
private long assemblyStartedNanos;
Http2HeaderBlockDecoder() {
this(Http2Limits.HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS);
}
Http2HeaderBlockDecoder(long assemblyTimeoutMillis) {
if (assemblyTimeoutMillis <= 0) throw new IllegalArgumentException("non-positive timeout");
assemblyTimeoutNanos = assemblyTimeoutMillis * 1_000_000L;
}
boolean insideHeaderBlock() {
return assembler.isActive();
@@ -24,6 +35,7 @@ final class Http2HeaderBlockDecoder {
/** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */
boolean accept(FrameHeader frame, HeaderSink sink) {
checkTimeout();
if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) {
throw Http2Exception.PROTOCOL_ERROR;
}
@@ -50,14 +62,26 @@ final class Http2HeaderBlockDecoder {
streamId, Http2ErrorCode.ENHANCE_YOUR_CALM, tooLarge.getMessage());
}
assembler.reset();
assemblyStartedNanos = 0;
return true;
}
void checkTimeout() {
if (assembler.isActive()
&& System.nanoTime() - assemblyStartedNanos >= assemblyTimeoutNanos) {
assembler.reset();
assemblyStartedNanos = 0;
throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM,
"header block assembly timeout");
}
}
boolean endStream() {
return endStream;
}
private void begin(FrameHeader frame) {
assemblyStartedNanos = System.nanoTime();
endStream = FrameFlags.isEndStream(frame.flags());
long unpadded =
Padding.unpad(
@@ -70,8 +70,40 @@ public final class Http2Limits {
* companion bound to {@link #MAX_RESET_STREAMS_PER_INTERVAL}: Rapid Reset defences that only
* count resets can still be bypassed by a peer that creates streams fast enough that the reset
* counter never saturates within any single window boundary.
*
* <p>Matches {@link #MAX_STREAMS_PER_CONNECTION}'s lifetime budget by design: a connection may
* not create more streams in one rolling burst window than it is ever allowed to create in its
* whole lifetime. An earlier value of 400 (40/s) measured the RST_STREAM flood attack this bound
* exists for, but also rejected ordinary high-concurrency multiplexed clients well below the
* throughput a hardened server is expected to sustain — h2load's default light-load pattern (10
* connections, 10 concurrent streams each) alone drives multiple thousands of legitimate stream
* creations per connection per second on a fast peer, which 400/10s cannot distinguish from
* abuse. The RST_STREAM-rate counter above measures the actual CVE-2023-44487 signature (resets,
* not creates); this bound only needs to catch a peer creating streams fast enough to dodge that
* counter, which a much higher ceiling still does.
*/
public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 400;
public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 100_000;
/** Maximum SETTINGS frames accepted within one abuse-rate interval. */
public static final int MAX_SETTINGS_PER_INTERVAL = 100;
/** Maximum non-acknowledgement PING frames accepted within one abuse-rate interval. */
public static final int MAX_PINGS_PER_INTERVAL = 200;
/**
* Aggregate bound for frames that consume parsing work without carrying application data:
* PRIORITY, WINDOW_UPDATE, empty DATA and unknown extension frames.
*/
public static final int MAX_USELESS_FRAMES_PER_INTERVAL = 10_000;
/** Default total-stream budget for one connection; zero disables the budget. */
public static final long MAX_STREAMS_PER_CONNECTION = 100_000;
/** Default wire-byte budget for one connection; zero disables the budget. */
public static final long MAX_BYTES_PER_CONNECTION = 0;
/** Default connection lifetime budget in milliseconds; zero disables the budget. */
public static final long MAX_CONNECTION_LIFETIME_MS = 0;
/**
* Maximum number of SETTINGS parameter entries accepted in a single SETTINGS frame. A SETTINGS
@@ -111,6 +143,15 @@ public final class Http2Limits {
*/
public static final int MAX_EMPTY_DATA_FRAMES_PER_STREAM = 1_000;
/** Largest request body retained contiguously before dispatching its handler. */
public static final int INLINE_BODY_THRESHOLD = 64 * 1024;
/** Hard limit for request body bytes accepted on one stream. */
public static final int MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024;
/** Number of frame-sized buffers available to streaming request bodies on one connection. */
public static final int DATA_BUFFER_POOL_SIZE = 64;
/**
* The value of {@code SETTINGS_INITIAL_WINDOW_SIZE} Flash advertises for every new stream:
* deliberately large (1 MiB, versus the RFC default of 65535) so that a normal-sized
@@ -125,7 +166,7 @@ public final class Http2Limits {
* windows, and sizing for that worst case would commit 100 MiB of receive window to every
* connection regardless of load.
*/
public static final int CONNECTION_WINDOW_SIZE_LOCAL = 16 * 1_048_576;
public static final int CONNECTION_WINDOW_SIZE_LOCAL = 1_048_576;
/**
* The HPACK dynamic table size Flash's decoder honours, in bytes of RFC 7541 §4.1 accounting. RFC
@@ -170,7 +211,7 @@ public final class Http2Limits {
/**
* Maximum time, in milliseconds, {@code Http2FrameReader} may wait for a single frame's
* header and payload to fully arrive. Bounds the same slowloris-shaped hazard {@code
* header and payload to fully arrive. Bounds the same slowloris-shaped hazard:
* without it, a peer that sends 9 header bytes and then never sends the declared payload
* would hold this connection's frame reader waiting forever.
*/
@@ -7,7 +7,7 @@ import dev.relism.flash.http2.frame.FrameWriteBuffer;
import java.nio.charset.StandardCharsets;
/** Byte-exact client preface and immutable server startup frames, compiled once at class load. */
final class Http2Preface {
public final class Http2Preface {
private static final byte[] CLIENT_PREFACE =
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
private static final byte[] SERVER_SETTINGS = buildServerSettings();
@@ -16,6 +16,11 @@ final class Http2Preface {
private Http2Preface() {}
/** Immutable client connection preface bytes. Callers must not modify the returned array. */
public static byte[] clientPreface() {
return CLIENT_PREFACE;
}
static int clientPrefaceLength() {
return CLIENT_PREFACE.length;
}
@@ -50,6 +55,7 @@ final class Http2Preface {
setting(bytes, Http2Settings.MAX_CONCURRENT_STREAMS, Http2Limits.MAX_CONCURRENT_STREAMS);
setting(bytes, Http2Settings.INITIAL_WINDOW_SIZE, Http2Limits.INITIAL_WINDOW_SIZE_LOCAL);
setting(bytes, Http2Settings.MAX_HEADER_LIST_SIZE, Http2Limits.MAX_HEADER_LIST_SIZE);
setting(bytes, Http2Settings.ENABLE_CONNECT_PROTOCOL, 1);
frame.endFrame();
return copy(bytes);
}
@@ -11,6 +11,7 @@ public final class Http2Settings {
public static final int INITIAL_WINDOW_SIZE = 0x4;
public static final int MAX_FRAME_SIZE = 0x5;
public static final int MAX_HEADER_LIST_SIZE = 0x6;
public static final int ENABLE_CONNECT_PROTOCOL = 0x8;
public static final int DEFAULT_HEADER_TABLE_SIZE = 4_096;
public static final int DEFAULT_INITIAL_WINDOW_SIZE = 65_535;
@@ -78,7 +79,7 @@ public final class Http2Settings {
private static void validate(int id, long value) {
switch (id) {
case ENABLE_PUSH -> {
case ENABLE_PUSH, ENABLE_CONNECT_PROTOCOL -> {
if (value > 1) throw Http2Exception.PROTOCOL_ERROR;
}
case INITIAL_WINDOW_SIZE -> {
@@ -1,22 +1,29 @@
package dev.relism.flash.http2;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.http2.message.Http2ResponseWriter;
import dev.relism.flash.http2.stream.Http2FlowController;
import dev.relism.flash.http2.stream.Http2Stream;
import dev.relism.flash.http2.stream.Http2StreamState;
import dev.relism.flash.http2.stream.Http2StreamTable;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.ResponseStreamOutputStream;
import dev.relism.flash.transport.ConnectionContext;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketLoop;
import dev.relism.flash.websocket.WebSocketSession;
import java.io.IOException;
import java.util.concurrent.RejectedExecutionException;
import lombok.extern.slf4j.Slf4j;
/** Dispatches completed request streams without blocking the connection demultiplexer. */
@Slf4j
final class Http2StreamDispatcher {
final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
@FunctionalInterface
interface FailureSink {
void fail(int streamId, Http2ErrorCode errorCode) throws IOException;
@@ -26,7 +33,10 @@ final class Http2StreamDispatcher {
private final Http2FrameWriter frameWriter;
private final Http2Settings peerSettings;
private final Http2StreamTable streams;
private final Http2FlowController flowController;
private final FailureSink failures;
private final Http2Stream[] resumeScratch =
new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
private volatile boolean firstResponse = true;
Http2StreamDispatcher(
@@ -34,46 +44,109 @@ final class Http2StreamDispatcher {
Http2FrameWriter frameWriter,
Http2Settings peerSettings,
Http2StreamTable streams,
Http2FlowController flowController,
FailureSink failures) {
this.context = context;
this.frameWriter = frameWriter;
this.peerSettings = peerSettings;
this.streams = streams;
this.flowController = flowController;
this.failures = failures;
}
void streamWindowUpdated(Http2Stream stream) {
scheduleResume(stream);
}
void connectionWindowUpdated() {
int count = streams.copyValues(resumeScratch);
for (int i = 0; i < count; i++) {
Http2Stream stream = resumeScratch[i];
resumeScratch[i] = null;
scheduleResume(stream);
}
}
private void scheduleResume(Http2Stream stream) {
if (!stream.responseStarted() || stream.cancelled()) return;
if (!stream.beginResponseBatch()) return;
stream.markResumeTask();
try {
context.executor().execute(stream);
} catch (RejectedExecutionException rejected) {
stream.endResponseBatch();
failAndRelease(stream, Http2ErrorCode.REFUSED_STREAM, rejected);
}
}
void dispatch(Http2Stream stream) {
if (stream.cancelled()) {
streams.release(stream);
return;
}
stream.markDispatched();
stream.responseSink(this);
try {
context.executor().execute(() -> handle(stream));
context.executor().execute(stream);
} catch (RejectedExecutionException rejected) {
failAndRelease(stream, Http2ErrorCode.REFUSED_STREAM, rejected);
}
}
@Override
public void handleRequest(Http2Stream stream) {
handle(stream);
}
private void handle(Http2Stream stream) {
stream.touch();
if (stream.cancelled()) {
streams.release(stream);
return;
}
try {
Request request = stream.assembleRequest(context.remoteAddress(), context.sslSocket());
Response pooled = stream.resetResponse();
Response response = pooled;
Object routeScratch = stream.routeScratch(context.router());
RequestHandler handler = context.router().route(request, routeScratch);
if (handler == null) handler = context.router().getNotFoundHandler();
try {
Object result = handler.handle(request, response);
if (result instanceof Response returned) response = returned;
else if (result != null) response.setBody(result);
} catch (Exception handlerFailure) {
Object result =
context.router().getExceptionHandler().handle(handlerFailure, request, response);
if (result instanceof Response returned) response = returned;
else if (result != null) response.setBody(result);
if (!Http2Authority.isServed(request.header("host"), request.sslSession())) {
response.status(HttpStatus.MISDIRECTED_REQUEST);
if (stream.websocketConnect()) response.type(ContentType.NONE).streaming(output -> {});
} else if (stream.websocketConnect()) {
WebSocketHandler handler =
context.wsRouter().route(request, stream.wsRouteScratch(context.wsRouter()));
response.type(ContentType.NONE);
if (handler == null) {
response.status(HttpStatus.NOT_FOUND).streaming(output -> {});
} else {
response.streaming(
output ->
WebSocketLoop.run(
new WebSocketSession(
request.body().stream(),
new ResponseStreamOutputStream(output),
context.configuration().getWsFrameBufferSize(),
request,
false),
handler));
}
} else {
Object routeScratch = stream.routeScratch(context.router());
RequestHandler handler = context.router().route(request, routeScratch);
if (handler == null) handler = context.router().getNotFoundHandler();
try {
Object result = handler.handle(request, response);
if (result instanceof Response returned) response = returned;
else if (result != null) response.setBody(result);
} catch (Exception handlerFailure) {
Object result =
context.router().getExceptionHandler().handle(handlerFailure, request, response);
if (result instanceof Response returned) response = returned;
else if (result != null) response.setBody(result);
}
}
boolean pushStreaming = response.isPushStreaming();
if (!pushStreaming) request.drain();
Http2ResponseWriter responseWriter = stream.responseWriter();
if (stream.cancelled()) {
request.recycle();
@@ -81,48 +154,150 @@ final class Http2StreamDispatcher {
streams.release(stream);
return;
}
boolean prepared;
boolean headRequest = request.method() == HttpMethod.HEAD;
int reserved;
int used;
synchronized (this) {
boolean tableUpdate = firstResponse;
prepared =
responseWriter.prepare(
response,
stream.id(),
request.method() == HttpMethod.HEAD,
context.configuration().isSendDate(),
true,
context.configuration().isH2HuffmanDynamicValues(),
tableUpdate,
peerSettings.maxFrameSize(),
peerSettings.maxHeaderListSize(),
(int) Math.min(stream.sendWindow(), Integer.MAX_VALUE));
if (prepared) {
firstResponse = false;
stream.transition(Http2StreamState.Event.SEND_HEADERS_ES);
request.recycle();
if (response == pooled) pooled.recycle();
streams.remove(stream.id());
frameWriter.write(responseWriter);
reserved = flowController.reserveSend(stream, peerSettings.maxFrameSize());
used = 0;
try {
used =
responseWriter.startFlowControlled(
response,
stream.id(),
headRequest,
context.configuration().isSendDate(),
true,
context.configuration().isH2HuffmanDynamicValues(),
tableUpdate,
peerSettings.maxFrameSize(),
peerSettings.maxHeaderListSize(),
reserved);
} finally {
flowController.refundSend(stream, reserved - used);
}
firstResponse = false;
}
if (!prepared) {
request.recycle();
if (response == pooled) pooled.recycle();
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, null);
if (!pushStreaming) request.recycle();
stream.markResponseStarted();
applyBatchTransition(stream, responseWriter);
if (!stream.beginResponseBatch()) {
throw new IllegalStateException("response batch already in flight");
}
detachFinalBatch(stream, responseWriter);
frameWriter.write(responseWriter);
} catch (Exception failure) {
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure);
}
}
private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) {
if (stream.id() == 0) return;
if (cause != null) log.error("HTTP/2 stream {} failed", stream.id(), cause);
streams.remove(stream.id());
private void tryResumeResponse(Http2Stream stream) {
stream.touch();
if (stream.cancelled()) {
stream.endResponseBatch();
streams.release(stream);
return;
}
Http2ResponseWriter responseWriter = stream.responseWriter();
int streamId = stream.id();
if (responseWriter.finished()) {
stream.endResponseBatch();
if (stream.state() == Http2StreamState.CLOSED) {
streams.retire(stream, streamId);
}
return;
}
int reserved = flowController.reserveSend(stream, peerSettings.maxFrameSize());
if (reserved == 0) {
stream.endResponseBatch();
return;
}
try {
failures.fail(stream.id(), error);
int used = 0;
try {
used = responseWriter.resume(peerSettings.maxFrameSize(), reserved);
} finally {
flowController.refundSend(stream, reserved - used);
}
applyBatchTransition(stream, responseWriter);
detachFinalBatch(stream, responseWriter);
frameWriter.write(responseWriter);
} catch (Exception failure) {
stream.endResponseBatch();
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure);
}
}
@Override
public void resumeResponse(Http2Stream stream) {
tryResumeResponse(stream);
}
private static void applyBatchTransition(
Http2Stream stream, Http2ResponseWriter responseWriter) {
if (responseWriter.headersInBatch()) {
if (responseWriter.finished() && responseWriter.dataBytesInBatch() == 0
&& !responseWriter.trailerHeadersInBatch()) {
stream.transition(Http2StreamState.Event.SEND_HEADERS_ES);
return;
}
stream.transition(Http2StreamState.Event.SEND_HEADERS);
}
if (responseWriter.dataBytesInBatch() != 0 || responseWriter.endStreamInBatch()) {
if (responseWriter.dataBytesInBatch() != 0) {
stream.transition(
responseWriter.endStreamInBatch() && !responseWriter.trailerHeadersInBatch()
? Http2StreamState.Event.SEND_DATA_ES
: Http2StreamState.Event.SEND_DATA);
}
if (responseWriter.trailerHeadersInBatch()) {
stream.transition(Http2StreamState.Event.SEND_HEADERS_ES);
} else if (responseWriter.dataBytesInBatch() == 0 && responseWriter.endStreamInBatch()) {
stream.transition(Http2StreamState.Event.SEND_DATA_ES);
}
}
}
@Override
public void responseBatchCompleted(Http2Stream stream) {
stream.touch();
stream.endResponseBatch();
int streamId = stream.id();
if (streamId == 0) return;
if (stream.cancelled()) {
streams.remove(stream.id());
streams.release(stream);
return;
}
if (stream.responseWriter().finished()) {
if (stream.state() == Http2StreamState.CLOSED) {
if (!streams.retire(stream, streamId)) streams.release(stream);
}
return;
}
scheduleResume(stream);
}
private void detachFinalBatch(Http2Stream stream, Http2ResponseWriter writer) {
if (writer.finished() && stream.state() == Http2StreamState.CLOSED) {
streams.detach(stream, stream.id());
}
}
private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) {
int streamId = stream.id();
if (!streams.removeIfSame(stream, streamId) && stream.id() != streamId) return;
if (cause != null) log.error("HTTP/2 stream {} failed", streamId, cause);
try {
stream.cancel();
} catch (RuntimeException cancellationFailure) {
log.debug("Failed to cancel HTTP/2 stream {} cleanly", streamId, cancellationFailure);
}
try {
failures.fail(streamId, error);
} catch (IOException writeFailure) {
log.debug("Failed to write RST_STREAM for {}", stream.id(), writeFailure);
log.debug("Failed to write RST_STREAM for {}", streamId, writeFailure);
} finally {
streams.release(stream);
}
@@ -0,0 +1,31 @@
package dev.relism.flash.http2;
/** Allocation-free two-bucket rolling rate counter owned by one connection thread. */
final class RollingWindowCounter {
private final long bucketNanos;
private long currentBucket;
private int currentCount;
private int previousCount;
RollingWindowCounter(long intervalMillis) {
if (intervalMillis < 2) throw new IllegalArgumentException("interval must be at least 2 ms");
bucketNanos = intervalMillis * 1_000_000L / 2;
}
boolean incrementExceeded(int limit) {
return incrementExceeded(limit, System.nanoTime());
}
boolean incrementExceeded(int limit, long nowNanos) {
long bucket = nowNanos / bucketNanos;
if (currentBucket == 0) {
currentBucket = bucket;
} else if (bucket != currentBucket) {
previousCount = bucket == currentBucket + 1 ? currentCount : 0;
currentCount = 0;
currentBucket = bucket;
}
currentCount++;
return currentCount + previousCount > limit;
}
}
@@ -1,6 +1,7 @@
package dev.relism.flash.http2.hpack;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.fpr.core.ByteView;
/**
* Stateless HPACK encoder for response header blocks. It uses the RFC 7541 static table and literal
@@ -25,6 +26,19 @@ public final class HpackEncoder {
writeLiteral(out, name, 0, name.length, value, 0, value.length, false);
}
/** Writes a non-indexed literal directly from protocol-neutral byte views. */
public static void writeLiteral(ByteWriter out, ByteView name, ByteView value) {
HpackIntegers.encode(out, 0, 4, 0);
HpackIntegers.encode(out, 0, 7, name.length());
for (int i = 0; i < name.length(); i++) {
int octet = name.byteAt(i) & 0xff;
if (octet >= 'A' && octet <= 'Z') octet += 'a' - 'A';
out.writeByte((byte) octet);
}
HpackIntegers.encode(out, 0, 7, value.length());
for (int i = 0; i < value.length(); i++) out.writeByte(value.byteAt(i));
}
public static void writeLiteral(
ByteWriter out,
byte[] name,
@@ -0,0 +1,69 @@
package dev.relism.flash.http2.message;
/** Bounded connection-owned free list of frame-sized request-body buffers. */
public final class DataBufferPool {
static final class DataBuffer {
final byte[] bytes;
DataBuffer next;
int position;
int length;
int flowControlledBytes;
DataBuffer(int size) {
bytes = new byte[size];
}
void reset() {
next = null;
position = 0;
length = 0;
flowControlledBytes = 0;
}
}
private final int bufferSize;
private final int maxBuffers;
private DataBuffer free;
private int created;
private int available;
public DataBufferPool(int bufferSize, int maxBuffers) {
if (bufferSize < 1 || maxBuffers < 1) {
throw new IllegalArgumentException("bufferSize and maxBuffers must be positive");
}
this.bufferSize = bufferSize;
this.maxBuffers = maxBuffers;
}
synchronized DataBuffer acquire() {
DataBuffer buffer = free;
if (buffer != null) {
free = buffer.next;
available--;
buffer.reset();
return buffer;
}
if (created == maxBuffers) return null;
created++;
return new DataBuffer(bufferSize);
}
synchronized void release(DataBuffer buffer) {
buffer.reset();
buffer.next = free;
free = buffer;
available++;
}
public synchronized int createdCount() {
return created;
}
public synchronized int availableCount() {
return available;
}
public int capacity() {
return maxBuffers;
}
}
@@ -18,7 +18,7 @@ public final class Http2HeaderMap implements HeaderView {
private HpackHeaderBlock block;
private PseudoHeaders pseudoHeaders;
private int viewCursor;
private int regularCount;
private int regularCount = -1;
public Http2HeaderMap() {
for (int i = 0; i < views.length; i++) views[i] = new PooledSlice();
@@ -28,11 +28,11 @@ public final class Http2HeaderMap implements HeaderView {
this.block = block;
this.pseudoHeaders = pseudoHeaders;
viewCursor = 0;
regularCount = 0;
for (int i = 0; i < block.count(); i++) {
block.get(i, scanName, scanValue);
if (scanName.byteAt(0) != ':') regularCount++;
}
regularCount = -1;
}
public void reset(HpackHeaderBlock block) {
reset(block, null);
}
@Override
@@ -54,7 +54,8 @@ public final class Http2HeaderMap implements HeaderView {
new String(
scanValue.array(), scanValue.offset(), scanValue.length(), StandardCharsets.UTF_8));
}
if (result == null && isAuthorityAlias(name) && pseudoHeaders.authority().array() != null) {
if (result == null && pseudoHeaders != null
&& isAuthorityAlias(name) && pseudoHeaders.authority().array() != null) {
return List.of(string(pseudoHeaders.authority()));
}
return result == null ? List.of() : result;
@@ -62,11 +63,16 @@ public final class Http2HeaderMap implements HeaderView {
@Override
public List<String> all() {
List<String> result = new ArrayList<>(regularCount);
List<String> result = new ArrayList<>(regularCount < 0 ? block.count() : regularCount);
int found = 0;
for (int i = 0; i < block.count(); i++) {
block.get(i, scanName, scanValue);
if (scanName.byteAt(0) != ':') result.add(string(scanValue));
if (scanName.byteAt(0) != ':') {
result.add(string(scanValue));
found++;
}
}
regularCount = found;
return result;
}
@@ -89,6 +95,14 @@ public final class Http2HeaderMap implements HeaderView {
@Override
public int count() {
if (regularCount < 0) {
int found = 0;
for (int i = 0; i < block.count(); i++) {
block.get(i, scanName, scanValue);
if (scanName.byteAt(0) != ':') found++;
}
regularCount = found;
}
return regularCount;
}
@@ -108,7 +122,8 @@ public final class Http2HeaderMap implements HeaderView {
return target;
}
}
if (isAuthorityAlias(requested) && pseudoHeaders.authority().array() != null) {
if (pseudoHeaders != null
&& isAuthorityAlias(requested) && pseudoHeaders.authority().array() != null) {
PooledSlice authority = pseudoHeaders.authority();
target.reset(authority.array(), authority.offset(), authority.length());
return target;
@@ -0,0 +1,246 @@
package dev.relism.flash.http2.message;
import dev.relism.flash.http2.Http2ErrorCode;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.flash.http2.Http2StreamException;
import dev.relism.flash.http2.message.DataBufferPool.DataBuffer;
import java.io.IOException;
import java.io.InputStream;
import dev.relism.flash.models.BodyCompletion;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/** Reusable request-body source fed by the connection demultiplexer. */
public final class Http2RequestBody extends InputStream implements BodyCompletion {
@FunctionalInterface
public interface ConsumptionListener {
void consumed(int flowControlledBytes) throws IOException;
}
private final DataBufferPool pool;
private final ReentrantLock lock = new ReentrantLock();
private final Condition dataAvailable = lock.newCondition();
private final byte[] oneByte = new byte[1];
private byte[] inline;
private DataBuffer head;
private DataBuffer tail;
private ConsumptionListener listener;
private long declaredLength;
private long received;
private int inlinePosition;
private int inlineFlowControlledBytes;
private boolean inlineMode;
private boolean finished;
private boolean fullyRead;
public Http2RequestBody(DataBufferPool pool) {
this.pool = pool;
}
public void begin(long declaredLength, boolean inlineMode, ConsumptionListener listener) {
releaseQueued();
this.declaredLength = declaredLength;
this.inlineMode = inlineMode;
this.listener = listener;
received = 0;
inlinePosition = 0;
inlineFlowControlledBytes = 0;
finished = false;
fullyRead = false;
if (inlineMode && inline == null) inline = new byte[Http2Limits.INLINE_BODY_THRESHOLD];
}
public void offer(
int streamId, byte[] source, int offset, int length, int flowControlledBytes) {
long next = received + length;
if (next > Http2Limits.MAX_REQUEST_BODY_SIZE) {
throw new Http2StreamException(
streamId, Http2ErrorCode.PROTOCOL_ERROR, "request body exceeds configured limit");
}
if (declaredLength >= 0 && next > declaredLength) {
throw new Http2StreamException(
streamId, Http2ErrorCode.PROTOCOL_ERROR, "request body exceeds content-length");
}
if (length == 0) {
notifyConsumed(flowControlledBytes);
return;
}
if (inlineMode) {
if (next > inline.length) {
throw new Http2StreamException(
streamId, Http2ErrorCode.PROTOCOL_ERROR, "inline request body exceeded its bound");
}
System.arraycopy(source, offset, inline, (int) received, length);
received = next;
inlineFlowControlledBytes += flowControlledBytes;
return;
}
lock.lock();
try {
int remaining = length;
int sourcePosition = offset;
while (remaining > 0) {
if (tail == null || tail.length == tail.bytes.length) {
DataBuffer buffer = pool.acquire();
if (buffer == null) {
throw new Http2StreamException(
streamId,
Http2ErrorCode.ENHANCE_YOUR_CALM,
"request body buffer pool exhausted");
}
if (tail == null) head = buffer;
else tail.next = buffer;
tail = buffer;
}
int copied = Math.min(remaining, tail.bytes.length - tail.length);
System.arraycopy(source, sourcePosition, tail.bytes, tail.length, copied);
tail.length += copied;
sourcePosition += copied;
remaining -= copied;
}
tail.flowControlledBytes += flowControlledBytes;
received = next;
dataAvailable.signal();
} finally {
lock.unlock();
}
}
public void finish(int streamId) {
if (declaredLength >= 0 && received != declaredLength) {
throw new Http2StreamException(
streamId,
Http2ErrorCode.PROTOCOL_ERROR,
"content-length does not match received DATA bytes");
}
lock.lock();
try {
finished = true;
dataAvailable.signalAll();
} finally {
lock.unlock();
}
}
public int cancel() {
int discarded;
lock.lock();
try {
finished = true;
discarded = inlineFlowControlledBytes + releaseQueuedLocked();
inlineFlowControlledBytes = 0;
dataAvailable.signalAll();
} finally {
lock.unlock();
}
return discarded;
}
public long declaredLength() {
return declaredLength;
}
@Override
public int read() throws IOException {
int count = read(oneByte, 0, 1);
return count < 0 ? -1 : oneByte[0] & 0xff;
}
@Override
public int read(byte[] target, int offset, int length) throws IOException {
if (length == 0) return 0;
if (inlineMode) return readInline(target, offset, length);
DataBuffer consumed = null;
int copied;
int flowControlled = 0;
lock.lock();
try {
while (head == null && !finished) {
try {
dataAvailable.await();
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IOException("interrupted while waiting for request DATA", interrupted);
}
}
if (head == null) {
fullyRead = true;
return -1;
}
DataBuffer buffer = head;
copied = Math.min(length, buffer.length - buffer.position);
System.arraycopy(buffer.bytes, buffer.position, target, offset, copied);
buffer.position += copied;
if (buffer.position == buffer.length) {
head = buffer.next;
if (head == null) tail = null;
flowControlled = buffer.flowControlledBytes;
consumed = buffer;
}
} finally {
lock.unlock();
}
if (consumed != null) {
pool.release(consumed);
notifyConsumed(flowControlled);
}
return copied;
}
private int readInline(byte[] target, int offset, int length) throws IOException {
if (!finished) {
throw new IOException("inline request body is not complete");
}
if (inlinePosition == received) {
fullyRead = true;
return -1;
}
int copied = (int) Math.min(length, received - inlinePosition);
System.arraycopy(inline, inlinePosition, target, offset, copied);
inlinePosition += copied;
if (inlinePosition == received && inlineFlowControlledBytes != 0) {
int flowControlled = inlineFlowControlledBytes;
inlineFlowControlledBytes = 0;
notifyConsumed(flowControlled);
}
return copied;
}
@Override
public boolean fullyRead() {
return fullyRead || (finished && received == 0);
}
private void notifyConsumed(int bytes) {
if (bytes == 0 || listener == null) return;
try {
listener.consumed(bytes);
} catch (IOException failure) {
cancel();
throw new IllegalStateException("failed to update request flow-control window", failure);
}
}
private void releaseQueued() {
lock.lock();
try {
releaseQueuedLocked();
} finally {
lock.unlock();
}
}
private int releaseQueuedLocked() {
int flowControlled = 0;
while (head != null) {
DataBuffer released = head;
head = released.next;
flowControlled += released.flowControlledBytes;
pool.release(released);
}
tail = null;
return flowControlled;
}
}
@@ -5,6 +5,7 @@ import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.DateHeader;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.http2.Http2ErrorCode;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.flash.http2.Http2StreamException;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
@@ -13,6 +14,8 @@ import dev.relism.flash.http2.frame.WriteIntent;
import dev.relism.flash.http2.hpack.HpackEncoder;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.ResponseSerializer;
import java.io.IOException;
import java.io.InputStream;
/**
* Reusable per-stream HTTP/2 response serializer. It prepares a complete small response outside the
@@ -30,13 +33,26 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
private final ByteWriter headerBlock;
private final ByteWriter output;
private final FrameWriteBuffer frames;
private final byte[] decimalScratch = new byte[10];
private final byte[] decimalScratch = new byte[20];
private final byte[] relay = new byte[Http2Limits.MAX_FRAME_SIZE_LOCAL];
private WriteIntent next;
private boolean huffmanDynamicValues;
private int streamId;
private long headerListSize;
private long maxHeaderListSize;
private Completion completion;
private byte[] fixedBody;
private InputStream streamBody;
private long bodyRemaining;
private int fixedPosition;
private boolean unknownLength;
private boolean pushBody;
private boolean finished;
private boolean headersInBatch;
private boolean endStreamInBatch;
private int dataBytesInBatch;
private boolean trailerHeadersInBatch;
private Response response;
public Http2ResponseWriter() {
this(1024, 2048);
@@ -91,6 +107,7 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
this.streamId = streamId;
this.huffmanDynamicValues = huffmanDynamicValues;
this.maxHeaderListSize = maxHeaderListSize;
this.response = response;
headerListSize = 0;
next = null;
@@ -108,15 +125,208 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
}
ResponseSerializer.forEachCustomField(response, this);
writeHeaderFrames(maxFrameSize, suppressBody || bodyLength == 0);
boolean hasTrailers = response.hasTrailers() && !suppressBody;
writeHeaderFrames(maxFrameSize, suppressBody || (bodyLength == 0 && !hasTrailers));
if (!suppressBody && bodyLength > 0) {
frames.beginFrame(FrameType.DATA, FrameFlags.END_STREAM, streamId);
frames.beginFrame(FrameType.DATA, hasTrailers ? 0 : FrameFlags.END_STREAM, streamId);
output.writeBytes(body);
frames.endFrame();
}
if (hasTrailers) appendTrailers(maxFrameSize);
return true;
}
/** Starts a response whose DATA may span multiple flow-control windows. */
public int startFlowControlled(
Response response,
int streamId,
boolean headRequest,
boolean sendDate,
boolean sendContentLength,
boolean huffmanDynamicValues,
boolean emitTableSizeUpdate,
int maxFrameSize,
long maxHeaderListSize,
int availableFlowWindow)
throws IOException {
if (streamId <= 0) throw new IllegalArgumentException("streamId must be positive");
if (maxFrameSize <= 0 || availableFlowWindow < 0) {
throw new IllegalArgumentException(
"frame size must be positive and flow window non-negative");
}
headerBlock.reset();
output.reset();
this.streamId = streamId;
this.huffmanDynamicValues = huffmanDynamicValues;
this.maxHeaderListSize = maxHeaderListSize;
headerListSize = 0;
next = null;
headersInBatch = true;
endStreamInBatch = false;
dataBytesInBatch = 0;
trailerHeadersInBatch = false;
this.response = response;
fixedPosition = 0;
fixedBody = response.isStreaming() ? null : response.getBody();
streamBody = response.isStreaming() ? response.getStream() : null;
unknownLength = response.isStreaming() && response.isChunked();
pushBody = response.isPushStreaming();
if (response.isStreaming() && !unknownLength && response.getStreamLength() < 0) {
throw new IllegalArgumentException("known response stream length must not be negative");
}
bodyRemaining =
response.isStreaming()
? (unknownLength ? -1 : response.getStreamLength())
: (fixedBody == null ? 0 : fixedBody.length);
long representationLength = bodyRemaining;
boolean representationUnknownLength = unknownLength;
int statusCode = response.getStatusCode();
boolean bodyForbidden =
statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200);
if (headRequest || bodyForbidden) {
fixedBody = null;
streamBody = null;
unknownLength = false;
bodyRemaining = 0;
}
if (emitTableSizeUpdate) HpackEncoder.writeDynamicTableSizeUpdateZero(headerBlock);
writeStatus(statusCode);
writeContentType(response.getContentType());
if (sendDate) {
addHeaderListSize(4, 29);
headerBlock.writeBytes(DateHeader.hpackBytes());
}
if (sendContentLength && !bodyForbidden && !representationUnknownLength) {
addHeaderListSize(CONTENT_LENGTH_NAME_LENGTH, decimalLength(representationLength));
writeDecimalLiteral(28, representationLength);
}
ResponseSerializer.forEachCustomField(response, this);
boolean hasBody = unknownLength || bodyRemaining > 0;
boolean hasTrailers = !headRequest && !bodyForbidden && response.hasTrailers();
writeHeaderFrames(maxFrameSize, !hasBody && !hasTrailers);
finished = !hasBody && !hasTrailers;
if (!hasBody && hasTrailers) {
appendTrailers(maxFrameSize);
finished = true;
endStreamInBatch = true;
}
if (hasBody && availableFlowWindow > 0 && !pushBody) {
appendData(maxFrameSize, availableFlowWindow);
}
return dataBytesInBatch;
}
/** Serializes the next DATA batch after a WINDOW_UPDATE or previous write completion. */
public int resume(int maxFrameSize, int availableFlowWindow) throws IOException {
if (finished || availableFlowWindow <= 0) return 0;
output.reset();
next = null;
headersInBatch = false;
endStreamInBatch = false;
dataBytesInBatch = 0;
trailerHeadersInBatch = false;
appendData(maxFrameSize, availableFlowWindow);
return dataBytesInBatch;
}
private void appendData(int maxFrameSize, int availableFlowWindow) throws IOException {
int target = Math.min(relay.length, Math.min(maxFrameSize, availableFlowWindow));
int count;
boolean end;
if (fixedBody != null) {
count = (int) Math.min(target, bodyRemaining);
boolean finalData = count == bodyRemaining;
boolean trailersFollow = finalData && response.hasTrailers();
frames.beginFrame(
FrameType.DATA, finalData && !trailersFollow ? FrameFlags.END_STREAM : 0, streamId);
output.writeBytes(fixedBody, fixedPosition, count);
frames.endFrame();
fixedPosition += count;
bodyRemaining -= count;
end = bodyRemaining == 0;
} else {
int limit = unknownLength ? target : (int) Math.min(target, bodyRemaining);
count = 0;
boolean eof = false;
if (pushBody) {
int read = streamBody.read(relay, 0, limit);
if (read < 0) eof = true;
else count = read;
} else {
while (count < limit) {
int read = streamBody.read(relay, count, limit - count);
if (read < 0) {
eof = true;
break;
}
if (read == 0) {
int one = streamBody.read();
if (one < 0) {
eof = true;
break;
}
relay[count++] = (byte) one;
} else {
count += read;
}
}
}
if (!unknownLength) {
bodyRemaining -= count;
if (eof && bodyRemaining != 0) {
throw new IOException("streaming response ended before its declared length");
}
}
end = unknownLength ? eof : bodyRemaining == 0;
boolean trailersFollow = end && response.hasTrailers();
if (count != 0 || !trailersFollow) {
frames.beginFrame(
FrameType.DATA, end && !trailersFollow ? FrameFlags.END_STREAM : 0, streamId);
output.writeBytes(relay, 0, count);
frames.endFrame();
}
}
dataBytesInBatch = count;
if (end && response.hasTrailers()) {
appendTrailers(maxFrameSize);
endStreamInBatch = true;
} else {
endStreamInBatch = end;
}
finished = end;
}
private void appendTrailers(int maxFrameSize) {
headerBlock.reset();
headerListSize = 0;
ResponseSerializer.forEachTrailerField(response, this);
writeHeaderFrames(maxFrameSize, true);
trailerHeadersInBatch = true;
}
public boolean finished() {
return finished;
}
public boolean headersInBatch() {
return headersInBatch;
}
public boolean endStreamInBatch() {
return endStreamInBatch;
}
public int dataBytesInBatch() {
return dataBytesInBatch;
}
public boolean trailerHeadersInBatch() {
return trailerHeadersInBatch;
}
@Override
public void accept(
byte[] name, int nameOff, int nameLen, byte[] value, int valueOff, int valueLen) {
@@ -151,10 +361,10 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
}
}
private void writeDecimalLiteral(int nameIndex, int value) {
private void writeDecimalLiteral(int nameIndex, long value) {
int length = decimalLength(value);
int offset = decimalScratch.length - length;
int current = value;
long current = value;
for (int i = decimalScratch.length - 1; i >= offset; i--) {
decimalScratch[i] = (byte) ('0' + current % 10);
current /= 10;
@@ -209,17 +419,13 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
return true;
}
private static int decimalLength(int value) {
if (value < 10) return 1;
if (value < 100) return 2;
if (value < 1000) return 3;
if (value < 10000) return 4;
if (value < 100000) return 5;
if (value < 1000000) return 6;
if (value < 10000000) return 7;
if (value < 100000000) return 8;
if (value < 1000000000) return 9;
return 10;
private static int decimalLength(long value) {
int length = 1;
while (value >= 10) {
value /= 10;
length++;
}
return length;
}
@Override
@@ -12,6 +12,7 @@ public final class PseudoHeaders {
private static final int SCHEME = 2;
private static final int PATH = 4;
private static final int AUTHORITY = 8;
private static final int PROTOCOL = 16;
private final PooledSlice name = new PooledSlice();
private final PooledSlice value = new PooledSlice();
@@ -19,6 +20,7 @@ public final class PseudoHeaders {
private final PooledSlice scheme = new PooledSlice();
private final PooledSlice path = new PooledSlice();
private final PooledSlice authority = new PooledSlice();
private final PooledSlice protocol = new PooledSlice();
private final PooledSlice host = new PooledSlice();
private int present;
@@ -28,6 +30,7 @@ public final class PseudoHeaders {
scheme.reset(null, 0, 0);
path.reset(null, 0, 0);
authority.reset(null, 0, 0);
protocol.reset(null, 0, 0);
host.reset(null, 0, 0);
boolean regularSeen = false;
@@ -51,7 +54,15 @@ public final class PseudoHeaders {
if ((present & METHOD) == 0) fail(streamId, "missing :method");
boolean connect = equals(method, "CONNECT");
if (connect) {
boolean extendedConnect = (present & PROTOCOL) != 0;
if (extendedConnect) {
if (!connect) fail(streamId, ":protocol requires CONNECT");
int required = METHOD | SCHEME | PATH | AUTHORITY | PROTOCOL;
if ((present & required) != required) {
fail(streamId, "extended CONNECT missing pseudo-header");
}
if (path.length() == 0) fail(streamId, "empty :path");
} else if (connect) {
if ((present & AUTHORITY) == 0) fail(streamId, "CONNECT requires :authority");
if ((present & (SCHEME | PATH)) != 0) fail(streamId, "CONNECT forbids :scheme and :path");
} else {
@@ -64,6 +75,20 @@ public final class PseudoHeaders {
}
}
/** Validates a trailing field section, where pseudo-fields are never permitted. */
public static void validateTrailers(HpackHeaderBlock block, int streamId) {
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
for (int i = 0; i < block.count(); i++) {
block.get(i, name, value);
if (name.length() == 0 || name.byteAt(0) == ':') fail(streamId, "pseudo-header in trailers");
validateRegular(name, value, streamId);
if (equals(name, "content-length") || equals(name, "host") || equals(name, "te")) {
fail(streamId, "field is not permitted in trailers");
}
}
}
public PooledSlice method() {
return method;
}
@@ -80,11 +105,16 @@ public final class PseudoHeaders {
return authority;
}
public boolean websocket() {
return protocol.array() != null && equals(protocol, "websocket");
}
private void copySlice(int bit, PooledSlice source) {
if (bit == METHOD) copy(source, method);
else if (bit == SCHEME) copy(source, scheme);
else if (bit == PATH) copy(source, path);
else copy(source, authority);
else if (bit == AUTHORITY) copy(source, authority);
else copy(source, protocol);
}
private static void copy(PooledSlice source, PooledSlice target) {
@@ -96,6 +126,7 @@ public final class PseudoHeaders {
if (equals(name, ":scheme")) return SCHEME;
if (equals(name, ":path")) return PATH;
if (equals(name, ":authority")) return AUTHORITY;
if (equals(name, ":protocol")) return PROTOCOL;
return 0;
}
@@ -0,0 +1,108 @@
package dev.relism.flash.http2.stream;
import dev.relism.flash.http2.Http2ErrorCode;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.flash.http2.Http2StreamException;
import java.io.IOException;
/** Connection-level half of HTTP/2's two-level flow-control accounting. */
public final class Http2FlowController {
@FunctionalInterface
public interface WindowUpdateSink {
void update(int streamId, int increment) throws IOException;
}
private final WindowUpdateSink updates;
private int receiveWindow = Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL;
private int consumedSinceUpdate;
private long sendWindow = 65_535;
public Http2FlowController(WindowUpdateSink updates) {
this.updates = updates;
}
public synchronized void receiveConnectionBytes(int bytes) {
if (bytes < 0) throw new IllegalArgumentException("bytes must not be negative");
if (bytes > receiveWindow) throw Http2Exception.FLOW_CONTROL_ERROR;
receiveWindow -= bytes;
}
public void consumed(Http2Stream stream, int bytes) throws IOException {
int connectionIncrement = 0;
synchronized (this) {
consumedSinceUpdate += bytes;
if (consumedSinceUpdate >= Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL / 2) {
connectionIncrement = consumedSinceUpdate;
receiveWindow += connectionIncrement;
consumedSinceUpdate = 0;
}
}
int streamIncrement = stream.consumedReceiveBytes(bytes);
if (streamIncrement != 0) updates.update(stream.id(), streamIncrement);
if (connectionIncrement != 0) updates.update(0, connectionIncrement);
}
public void discarded(int bytes) throws IOException {
int increment = 0;
synchronized (this) {
consumedSinceUpdate += bytes;
if (consumedSinceUpdate >= Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL / 2) {
increment = consumedSinceUpdate;
receiveWindow += increment;
consumedSinceUpdate = 0;
}
}
if (increment != 0) updates.update(0, increment);
}
public synchronized int reserveSend(Http2Stream stream, int requested) {
int streamWindow = stream.sendWindow();
if (requested <= 0 || sendWindow <= 0 || streamWindow <= 0) return 0;
int granted =
(int)
Math.min(requested, Math.min(sendWindow, Math.min(streamWindow, Integer.MAX_VALUE)));
sendWindow -= granted;
stream.adjustSendWindow(-granted);
return granted;
}
public synchronized void refundSend(Http2Stream stream, int bytes) {
if (bytes == 0) return;
sendWindow += bytes;
stream.adjustSendWindow(bytes);
}
public synchronized void increaseConnectionSendWindow(int increment) {
long next = sendWindow + increment;
if (next > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR;
sendWindow = next;
}
public synchronized void increaseStreamSendWindow(Http2Stream stream, int increment) {
stream.adjustSendWindow(increment);
}
public synchronized void initializeStreamSendWindow(Http2Stream stream, int initialWindow) {
stream.adjustSendWindow(initialWindow - 65_535);
}
public synchronized void applyInitialWindowDelta(Http2StreamTable streams, int delta) {
streams.adjustAllSendWindows(delta);
}
public void receiveStreamBytes(Http2Stream stream, int bytes) {
if (!stream.receiveBytes(bytes)) {
throw new Http2StreamException(
stream.id(), Http2ErrorCode.FLOW_CONTROL_ERROR, "stream receive window exceeded");
}
}
public synchronized int connectionReceiveWindow() {
return receiveWindow;
}
public synchronized long connectionSendWindow() {
return sendWindow;
}
}
@@ -4,9 +4,12 @@ import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http2.Http2ErrorCode;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.flash.http2.Http2StreamException;
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
import dev.relism.flash.http2.message.DataBufferPool;
import dev.relism.flash.http2.message.Http2HeaderMap;
import dev.relism.flash.http2.message.Http2RequestBody;
import dev.relism.flash.http2.message.Http2ResponseWriter;
import dev.relism.flash.http2.message.PseudoHeaders;
import dev.relism.flash.models.Request;
@@ -14,36 +17,65 @@ import dev.relism.flash.models.RequestBody;
import dev.relism.flash.models.RequestLine;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.fpr.core.ByteView;
import java.io.IOException;
import java.net.InetSocketAddress;
import javax.net.ssl.SSLSocket;
/** Per-stream request, response, decoded-header and write state. */
public final class Http2Stream implements Http2ResponseWriter.Completion {
public final class Http2Stream
implements Http2ResponseWriter.Completion, Http2RequestBody.ConsumptionListener, Runnable {
public interface ResponseSink {
void handleRequest(Http2Stream stream);
void responseBatchCompleted(Http2Stream stream);
void resumeResponse(Http2Stream stream);
}
private static final byte[] HTTP_2 = {'H', 'T', 'T', 'P', '/', '2'};
private final HpackHeaderBlock headerBlock = new HpackHeaderBlock();
private final HpackHeaderBlock trailerBlock = new HpackHeaderBlock();
private final PseudoHeaders pseudoHeaders = new PseudoHeaders();
private final Http2HeaderMap headers = new Http2HeaderMap();
private final Http2HeaderMap trailers = new Http2HeaderMap();
private final RequestLine requestLine = new RequestLine();
private final RequestBody requestBody = new RequestBody();
private final Http2RequestBody http2Body;
private final Request request = new Request();
private final Response response = new Response(200, ContentType.TEXT_PLAIN);
private final Http2ResponseWriter responseWriter = new Http2ResponseWriter();
private final PooledSlice path = new PooledSlice();
private final PooledSlice query = new PooledSlice();
private final PooledSlice protocol = new PooledSlice();
private final PooledSlice scanName = new PooledSlice();
private final PooledSlice scanValue = new PooledSlice();
private int id;
private Http2StreamState state = Http2StreamState.IDLE;
private int sendWindow = 65_535;
private int receiveWindow = Http2Limits.INITIAL_WINDOW_SIZE_LOCAL;
private int consumedReceiveBytes;
private int emptyDataFrames;
private Http2StreamTable owner;
private Object routeScratch;
private Object wsRouteScratch;
private volatile boolean dispatched;
private volatile boolean cancelled;
private boolean headersValidated;
private Http2FlowController flowController;
private ResponseSink responseSink;
private volatile boolean responseInFlight;
private volatile boolean responseStarted;
private boolean releaseClaimed;
private volatile boolean resumeTask;
private volatile long lastActivityNanos;
Http2Stream poolNext;
Http2Stream() {
Http2Stream(DataBufferPool dataBuffers) {
http2Body = new Http2RequestBody(dataBuffers);
responseWriter.completion(this);
protocol.reset(HTTP_2, 0, HTTP_2.length);
}
@@ -53,10 +85,21 @@ public final class Http2Stream implements Http2ResponseWriter.Completion {
this.owner = owner;
state = Http2StreamState.IDLE;
sendWindow = 65_535;
receiveWindow = Http2Limits.INITIAL_WINDOW_SIZE_LOCAL;
consumedReceiveBytes = 0;
emptyDataFrames = 0;
dispatched = false;
cancelled = false;
headersValidated = false;
responseInFlight = false;
responseStarted = false;
releaseClaimed = false;
resumeTask = false;
responseSink = null;
headerBlock.reset();
trailerBlock.reset();
trailers.reset(trailerBlock);
touch();
}
void clear() {
@@ -94,9 +137,13 @@ public final class Http2Stream implements Http2ResponseWriter.Completion {
throw new Http2StreamException(
id, Http2ErrorCode.PROTOCOL_ERROR, "unsupported request method");
}
if (pseudoHeaders.websocket()) method = HttpMethod.GET;
requestLine.reset(method, path, question < 0 ? null : query, protocol, headers);
requestBody.reset(null, 0, null, 0, 0);
return Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
requestBody.reset(http2Body, http2Body.declaredLength(), null, 0, 0);
Request assembled =
Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
assembled.setTrailers(trailers);
return assembled;
}
public void validateHeaders() {
@@ -105,6 +152,68 @@ public final class Http2Stream implements Http2ResponseWriter.Completion {
headersValidated = true;
}
public boolean prepareRequestBody(Http2FlowController flowController, boolean endStream) {
this.flowController = flowController;
long contentLength = parseContentLength();
if (contentLength < 0 && endStream) contentLength = 0;
boolean inline = contentLength >= 0 && contentLength <= Http2Limits.INLINE_BODY_THRESHOLD;
http2Body.begin(contentLength, inline, this);
if (endStream) http2Body.finish(id);
return endStream || !inline;
}
public void receiveData(byte[] source, int offset, int length, int flowControlledBytes) {
http2Body.offer(id, source, offset, length, flowControlledBytes);
}
public void finishRequestBody() {
http2Body.finish(id);
}
public void validateTrailers() {
PseudoHeaders.validateTrailers(trailerBlock, id);
trailers.reset(trailerBlock);
}
private long parseContentLength() {
long parsed = -1;
for (int i = 0; i < headerBlock.count(); i++) {
headerBlock.get(i, scanName, scanValue);
if (!equals(scanName, "content-length")) continue;
long value = parseDecimal(scanValue);
if (parsed >= 0 && parsed != value) {
throw new Http2StreamException(
id, Http2ErrorCode.PROTOCOL_ERROR, "conflicting content-length fields");
}
parsed = value;
}
return parsed;
}
private long parseDecimal(ByteView value) {
if (value.length() == 0) {
throw new Http2StreamException(id, Http2ErrorCode.PROTOCOL_ERROR, "empty content-length");
}
long parsed = 0;
for (int i = 0; i < value.length(); i++) {
int digit = (value.byteAt(i) & 0xff) - '0';
if (digit < 0 || digit > 9 || parsed > (Http2Limits.MAX_REQUEST_BODY_SIZE - digit) / 10L) {
throw new Http2StreamException(
id, Http2ErrorCode.PROTOCOL_ERROR, "invalid or oversized content-length");
}
parsed = parsed * 10 + digit;
}
return parsed;
}
private static boolean equals(ByteView value, String expected) {
if (value.length() != expected.length()) return false;
for (int i = 0; i < value.length(); i++) {
if ((value.byteAt(i) & 0xff) != expected.charAt(i)) return false;
}
return true;
}
public Response resetResponse() {
return response.reset(200, ContentType.TEXT_PLAIN);
}
@@ -125,15 +234,64 @@ public final class Http2Stream implements Http2ResponseWriter.Completion {
return headerBlock;
}
public HpackHeaderBlock trailerBlock() {
return trailerBlock;
}
public Http2ResponseWriter responseWriter() {
return responseWriter;
}
public void responseSink(ResponseSink responseSink) {
this.responseSink = responseSink;
}
public void markResponseStarted() {
responseStarted = true;
}
public boolean responseStarted() {
return responseStarted;
}
public void markResumeTask() {
resumeTask = true;
}
public synchronized boolean beginResponseBatch() {
if (responseInFlight) return false;
responseInFlight = true;
return true;
}
public synchronized void endResponseBatch() {
responseInFlight = false;
}
public synchronized boolean responseInFlight() {
return responseInFlight;
}
synchronized boolean claimRelease() {
if (releaseClaimed) return false;
releaseClaimed = true;
return true;
}
public Object routeScratch(AbstractRouter router) {
if (routeScratch == null) routeScratch = router.newScratch();
return routeScratch;
}
public Object wsRouteScratch(AbstractWsRouter router) {
if (wsRouteScratch == null) wsRouteScratch = router.newScratch();
return wsRouteScratch;
}
public boolean websocketConnect() {
return pseudoHeaders.websocket();
}
public void markDispatched() {
dispatched = true;
}
@@ -144,25 +302,79 @@ public final class Http2Stream implements Http2ResponseWriter.Completion {
public void cancel() {
cancelled = true;
int discarded = http2Body.cancel();
if (discarded != 0 && flowController != null) {
try {
flowController.discarded(discarded);
} catch (IOException failure) {
throw new IllegalStateException("failed to restore discarded flow-control bytes", failure);
}
}
}
public boolean cancelled() {
return cancelled;
}
public int sendWindow() {
public void touch() {
lastActivityNanos = System.nanoTime();
}
public boolean idleExpired(long nowNanos, long timeoutNanos) {
return timeoutNanos > 0 && nowNanos - lastActivityNanos >= timeoutNanos;
}
public synchronized int sendWindow() {
return sendWindow;
}
public void adjustSendWindow(int delta) {
public synchronized void adjustSendWindow(int delta) {
long adjusted = (long) sendWindow + delta;
if (adjusted > Integer.MAX_VALUE) throw new IllegalStateException("stream window overflow");
sendWindow = (int) adjusted;
}
public synchronized boolean receiveBytes(int bytes) {
if (bytes > receiveWindow) return false;
receiveWindow -= bytes;
return true;
}
public synchronized int consumedReceiveBytes(int bytes) {
consumedReceiveBytes += bytes;
if (consumedReceiveBytes < Http2Limits.INITIAL_WINDOW_SIZE_LOCAL / 2) {
return 0;
}
int increment = consumedReceiveBytes;
receiveWindow += increment;
consumedReceiveBytes = 0;
return increment;
}
public int incrementEmptyDataFrames() {
return ++emptyDataFrames;
}
public void resetEmptyDataFrames() {
emptyDataFrames = 0;
}
@Override
public void consumed(int flowControlledBytes) throws IOException {
flowController.consumed(this, flowControlledBytes);
}
@Override
public void responseWriteCompleted() {
Http2StreamTable table = owner;
if (table != null) table.release(this);
ResponseSink sink = responseSink;
if (sink != null) sink.responseBatchCompleted(this);
}
@Override
public void run() {
ResponseSink sink = responseSink;
if (sink == null) return;
if (resumeTask) sink.resumeResponse(this);
else sink.handleRequest(this);
}
}
@@ -26,6 +26,8 @@ public enum Http2StreamState {
private static final byte ERROR = -1;
private static final byte[][] TRANSITIONS = buildTransitions();
// enum values() clones its backing array on every call; this is read-only and shared safely.
private static final Http2StreamState[] VALUES = values();
public Http2StreamState transition(int streamId, Event event) {
int next = TRANSITIONS[ordinal()][event.ordinal()];
@@ -33,7 +35,7 @@ public enum Http2StreamState {
throw new Http2StreamException(
streamId, errorFor(event), "invalid stream transition " + this + " + " + event);
}
return values()[next];
return VALUES[next];
}
private Http2ErrorCode errorFor(Event event) {
@@ -1,5 +1,7 @@
package dev.relism.flash.http2.stream;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.flash.http2.message.DataBufferPool;
import java.util.Arrays;
/** Fixed-capacity primitive stream-id table using linear-probed open addressing. */
@@ -14,11 +16,26 @@ public final class Http2StreamTable {
private final Http2Stream[] values;
private final int mask;
private final int maxEntries;
private final int maxObjects;
private final int[] closedIds;
private final byte[] closedKinds;
private int size;
private Http2Stream free;
private int created;
private final DataBufferPool dataBuffers;
private int closedCursor;
public static final int CLOSED_UNKNOWN = 0;
public static final int CLOSED_NORMALLY = 1;
public static final int CLOSED_BY_RESET = 2;
public Http2StreamTable(int maxEntries) {
this(
maxEntries,
new DataBufferPool(Http2Limits.MAX_FRAME_SIZE_LOCAL, Http2Limits.DATA_BUFFER_POOL_SIZE));
}
public Http2StreamTable(int maxEntries, DataBufferPool dataBuffers) {
if (maxEntries < 1) throw new IllegalArgumentException("maxEntries must be positive");
int capacity = 1;
while (capacity < maxEntries * 2) capacity <<= 1;
@@ -26,6 +43,10 @@ public final class Http2StreamTable {
values = new Http2Stream[capacity];
mask = capacity - 1;
this.maxEntries = maxEntries;
maxObjects = maxEntries * 2;
this.dataBuffers = dataBuffers;
closedIds = new int[maxEntries * 2];
closedKinds = new byte[closedIds.length];
}
public synchronized Http2Stream get(int streamId) {
@@ -50,8 +71,8 @@ public final class Http2StreamTable {
free = stream.poolNext;
stream.poolNext = null;
} else {
if (created == maxEntries) return null;
stream = new Http2Stream();
if (created == maxObjects) return null;
stream = new Http2Stream(dataBuffers);
created++;
}
stream.reset(streamId, this);
@@ -60,6 +81,7 @@ public final class Http2StreamTable {
}
public synchronized void release(Http2Stream stream) {
if (!stream.claimRelease()) return;
stream.clear();
stream.poolNext = free;
free = stream;
@@ -86,12 +108,55 @@ public final class Http2StreamTable {
return removed;
}
/** Removes a stream only when both its id and pooled-object identity still match. */
public synchronized boolean removeIfSame(Http2Stream stream, int streamId) {
if (streamId <= 0) return false;
int slot = find(streamId);
if (keys[slot] != streamId || values[slot] != stream) return false;
remove(streamId);
return true;
}
/** Atomically removes and recycles the matching generation of a pooled stream. */
public synchronized boolean retire(Http2Stream stream, int streamId) {
if (!removeIfSame(stream, streamId)) return false;
rememberClosed(streamId, CLOSED_NORMALLY);
release(stream);
return true;
}
/** Removes a wire-closed stream from live concurrency while retaining its in-flight buffer. */
public synchronized boolean detach(Http2Stream stream, int streamId) {
if (!removeIfSame(stream, streamId)) return false;
rememberClosed(streamId, CLOSED_NORMALLY);
return true;
}
public synchronized void rememberReset(int streamId) {
rememberClosed(streamId, CLOSED_BY_RESET);
}
public synchronized int closedKind(int streamId) {
for (int i = 0; i < closedIds.length; i++) {
if (closedIds[i] == streamId) return closedKinds[i];
}
return CLOSED_UNKNOWN;
}
public synchronized void forEach(StreamConsumer consumer) {
for (int i = 0; i < keys.length; i++) {
if (keys[i] != 0) consumer.accept(values[i]);
}
}
public synchronized int copyValues(Http2Stream[] target) {
int count = 0;
for (int i = 0; i < keys.length && count < target.length; i++) {
if (keys[i] != 0) target[count++] = values[i];
}
return count;
}
public synchronized void adjustAllSendWindows(int delta) {
for (int i = 0; i < keys.length; i++) {
if (keys[i] == 0) continue;
@@ -126,7 +191,16 @@ public final class Http2StreamTable {
public synchronized void clear() {
Arrays.fill(keys, 0);
Arrays.fill(values, null);
Arrays.fill(closedIds, 0);
Arrays.fill(closedKinds, (byte) 0);
size = 0;
closedCursor = 0;
}
private void rememberClosed(int streamId, int kind) {
closedIds[closedCursor] = streamId;
closedKinds[closedCursor] = (byte) kind;
closedCursor = (closedCursor + 1) % closedIds.length;
}
private int find(int streamId) {
@@ -0,0 +1,6 @@
package dev.relism.flash.models;
/** Internal completion signal used to enforce request-trailer ordering. */
public interface BodyCompletion {
boolean fullyRead();
}
@@ -0,0 +1,18 @@
package dev.relism.flash.models;
import dev.relism.fpr.core.ByteView;
import java.util.List;
/** Immutable empty header collection shared by requests without trailers. */
public enum EmptyHeaderView implements HeaderView {
INSTANCE;
@Override public String first(String name) { return null; }
@Override public List<String> all(String name) { return List.of(); }
@Override public List<String> all() { return List.of(); }
@Override public ByteView view(String name) { return null; }
@Override public boolean valueEqualsIgnoreCase(String name, String value) { return false; }
@Override public boolean contains(String name) { return false; }
@Override public int count() { return 0; }
@Override public void forEach(HeaderConsumer consumer) {}
}
@@ -77,12 +77,33 @@ public class Http1HeaderMap implements HeaderView {
private Slice valueSlice;
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
this.buffer = buffer;
this.sectionStart = sectionStart;
this.sectionEnd = sectionEnd;
beginParsed(buffer, sectionStart, sectionEnd);
buildIndex();
}
/**
* Starts an index populated by the request parser while it validates the same header lines.
* This avoids rescanning a validated section solely to recover offsets already known there.
*/
public void beginParsed(byte[] buffer, int sectionStart, int sectionEnd) {
this.buffer = buffer;
this.sectionStart = sectionStart;
this.sectionEnd = sectionEnd;
headerCount = 0;
}
/** Adds one already-validated header to the current zero-copy index. */
public void addParsed(int nameOffset, int nameLength, int valueOffset, int valueLength) {
ensureIndexCapacity(headerCount + 1);
nameOffsets[headerCount] = nameOffset;
nameLengths[headerCount] = nameLength;
valueOffsets[headerCount] = valueOffset;
valueLengths[headerCount] = valueLength;
nameHashes[headerCount] =
ByteScan.hashNameIgnoreCaseAscii(buffer, nameOffset, nameLength);
headerCount++;
}
private void buildIndex() {
headerCount = 0;
if (buffer == null) return;
@@ -92,13 +113,7 @@ public class Http1HeaderMap implements HeaderView {
int colon = findColon(i, lineEnd);
if (colon != -1) {
int vs = skipSpaces(colon + 1, lineEnd);
ensureIndexCapacity(headerCount + 1);
nameOffsets[headerCount] = i;
nameLengths[headerCount] = colon - i;
valueOffsets[headerCount] = vs;
valueLengths[headerCount] = lineEnd - vs;
nameHashes[headerCount] = ByteScan.hashNameIgnoreCaseAscii(buffer, i, colon - i);
headerCount++;
addParsed(i, colon - i, vs, lineEnd - vs);
}
i = lineEnd + 2;
}
@@ -0,0 +1,138 @@
package dev.relism.flash.models;
import dev.relism.flash.bytes.ByteScan;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.io.IOException;
import java.io.OutputStream;
/** Reusable owned-byte header collection for sections parsed outside the request head buffer. */
public final class MutableHeaderMap implements HeaderView {
private final ByteWriter bytes = new ByteWriter(128);
private final PooledSlice view = new PooledSlice();
private final PooledSlice scanName = new PooledSlice();
private final PooledSlice scanValue = new PooledSlice();
private int[] fields = new int[16];
private int count;
public void reset() {
bytes.reset();
count = 0;
}
public void add(byte[] name, int nameOffset, int nameLength,
byte[] value, int valueOffset, int valueLength) {
ensure(count + 1);
int base = count * 4;
fields[base] = bytes.length();
fields[base + 1] = nameLength;
bytes.writeBytes(name, nameOffset, nameLength);
fields[base + 2] = bytes.length();
fields[base + 3] = valueLength;
bytes.writeBytes(value, valueOffset, valueLength);
count++;
}
public void writeLines(OutputStream output) throws IOException {
for (int i = 0; i < count; i++) {
int base = i * 4;
output.write(bytes.array(), fields[base], fields[base + 1]);
output.write(':');
output.write(' ');
output.write(bytes.array(), fields[base + 2], fields[base + 3]);
output.write('\r');
output.write('\n');
}
}
void forEachStructured(ResponseSerializer.FieldConsumer consumer) {
for (int i = 0; i < count; i++) {
int base = i * 4;
consumer.accept(bytes.array(), fields[base], fields[base + 1],
bytes.array(), fields[base + 2], fields[base + 3]);
}
}
@Override
public String first(String name) {
int index = indexOf(name, 0);
return index < 0 ? null : value(index);
}
@Override
public List<String> all(String name) {
List<String> result = null;
int from = 0;
int index;
while ((index = indexOf(name, from)) >= 0) {
if (result == null) result = new ArrayList<>();
result.add(value(index));
from = index + 1;
}
return result == null ? List.of() : result;
}
@Override
public List<String> all() {
if (count == 0) return List.of();
List<String> result = new ArrayList<>(count);
for (int i = 0; i < count; i++) result.add(value(i));
return result;
}
@Override
public ByteView view(String name) {
int index = indexOf(name, 0);
if (index < 0) return null;
int base = index * 4;
view.reset(bytes.array(), fields[base + 2], fields[base + 3]);
return view;
}
@Override
public boolean valueEqualsIgnoreCase(String name, String value) {
int index = indexOf(name, 0);
if (index < 0) return false;
int base = index * 4;
return ByteScan.equalsIgnoreCaseAscii(
bytes.array(), fields[base + 2], fields[base + 2] + fields[base + 3], value);
}
@Override public boolean contains(String name) { return indexOf(name, 0) >= 0; }
@Override public int count() { return count; }
@Override
public void forEach(HeaderConsumer consumer) {
for (int i = 0; i < count; i++) {
int base = i * 4;
scanName.reset(bytes.array(), fields[base], fields[base + 1]);
scanValue.reset(bytes.array(), fields[base + 2], fields[base + 3]);
consumer.accept(scanName, scanValue);
}
}
private int indexOf(String name, int from) {
for (int i = from; i < count; i++) {
int base = i * 4;
if (ByteScan.equalsIgnoreCaseAscii(
bytes.array(), fields[base], fields[base] + fields[base + 1], name)) return i;
}
return -1;
}
private String value(int index) {
int base = index * 4;
return new String(bytes.array(), fields[base + 2], fields[base + 3], StandardCharsets.UTF_8);
}
private void ensure(int needed) {
int ints = needed * 4;
if (ints <= fields.length) return;
fields = Arrays.copyOf(fields, Math.max(ints, fields.length * 2));
}
}
@@ -0,0 +1,103 @@
package dev.relism.flash.models;
import java.io.IOException;
import java.io.InputStream;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.util.function.Consumer;
/** Bounded bridge from a push producer to the protocol writers' common pull path. */
final class ProducerInputStream extends InputStream {
private final PipedInputStream input;
private final ProducerOutput output;
private final Consumer<ResponseStream> producer;
private volatile Throwable failure;
private boolean started;
ProducerInputStream(Consumer<ResponseStream> producer, Response response) {
try {
input = new PipedInputStream(16 * 1024);
output = new ProducerOutput(new PipedOutputStream(input), response);
} catch (IOException impossible) {
throw new IllegalStateException(impossible);
}
this.producer = producer;
}
@Override
public int read() throws IOException {
start();
int value = input.read();
checkFailure(value < 0);
return value;
}
@Override
public int read(byte[] bytes, int offset, int length) throws IOException {
start();
int count = input.read(bytes, offset, length);
checkFailure(count < 0);
return count;
}
@Override
public void close() throws IOException {
input.close();
}
private synchronized void start() {
if (started) return;
started = true;
Thread.startVirtualThread(() -> {
try (output) {
producer.accept(output);
} catch (Throwable thrown) {
failure = thrown;
try {
output.close();
} catch (IOException ignored) {
}
}
});
}
private void checkFailure(boolean eof) throws IOException {
if (eof && failure != null) throw new IOException("response stream producer failed", failure);
}
private static final class ProducerOutput implements ResponseStream {
private final PipedOutputStream output;
private final Response response;
private boolean closed;
ProducerOutput(PipedOutputStream output, Response response) {
this.output = output;
this.response = response;
}
@Override
public synchronized void write(byte[] data, int offset, int length) throws IOException {
if (closed) throw new IOException("response stream is closed");
output.write(data, offset, length);
}
@Override
public synchronized void flush() throws IOException {
if (closed) throw new IOException("response stream is closed");
output.flush();
}
@Override
public synchronized void trailer(String name, String value) {
if (closed) throw new IllegalStateException("response stream is closed");
response.trailer(name, value);
}
@Override
public synchronized void close() throws IOException {
if (closed) return;
closed = true;
output.close();
}
}
}
@@ -52,6 +52,7 @@ import java.util.List;
public class Request {
private RequestBody body;
private HeaderView trailers = EmptyHeaderView.INSTANCE;
/** Internal: the parsed request line (method, path, query, protocol, headers). */
private RequestLine requestLine;
@@ -95,6 +96,7 @@ public class Request {
void reset(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
this.requestLine = requestLine;
this.body = body;
this.trailers = EmptyHeaderView.INSTANCE;
this.pathParams = null;
this.queryParams = null;
this.cachedPath = null;
@@ -143,6 +145,11 @@ public class Request {
return pooled;
}
/** Internal protocol hook that supplies the request's trailer collection. */
public void setTrailers(HeaderView trailers) {
this.trailers = trailers == null ? EmptyHeaderView.INSTANCE : trailers;
}
// Request line
/** HTTP method ({@code GET}, {@code POST}, …). */
@@ -253,6 +260,19 @@ public class Request {
*/
public RequestBody body() { checkActive(); return body; }
/**
* Returns request trailers after the body has been consumed completely.
*
* @throws IllegalStateException when called before the body reaches EOF
*/
public HeaderView trailers() {
checkActive();
if (!body.fullyRead()) {
throw new IllegalStateException("request trailers are available only after the body is fully read");
}
return trailers;
}
/** Discards unread body bytes; called by the server after each request on keep-alive connections. */
public void drain() { body.drain(); }
@@ -11,7 +11,7 @@ import java.io.*;
* bodies larger than 2 GB.</li>
* <li>{@link #stream()} returns a bounded {@link InputStream} without upfront allocation.
* into the already-buffered header bytes stitched to the socket; for chunked bodies it is
* the raw {@link dev.relism.ChunkedInputStream} that de-chunks on the fly.</li>
* the raw {@link dev.relism.flash.ChunkedInputStream} that de-chunks on the fly.</li>
* </ul>
*
* <p><b>Mutual exclusivity:</b> calling both {@code bytes()} and {@code stream()} on the same
@@ -87,6 +87,15 @@ public class RequestBody {
*/
public long contentLength() { return contentLength; }
/** Whether the complete body has been consumed by the application. */
public boolean fullyRead() {
if (resolved != null || contentLength == 0) return true;
if (socket instanceof BodyCompletion completion) return completion.fullyRead();
if (contentLength < 0) return false;
if (boundedStream != null) return boundedStream.complete();
return preBufLen >= contentLength;
}
/**
* Materialises and caches the full body. Suitable for JSON, small form data, and any payload
* that must be inspected in full. The result is cached repeated calls return the same array.
@@ -125,7 +134,7 @@ public class RequestBody {
* <p>For fixed-length bodies: a reused {@link BoundedBufferedInputStream} (see the class
* view of the socket stream zero allocation on a warm connection.
*
* <p>For chunked bodies: the raw {@link dev.relism.ChunkedInputStream} that de-chunks on
* <p>For chunked bodies: the raw {@link dev.relism.flash.ChunkedInputStream} that de-chunks on
* the fly; EOF signals the end of the logical body and leaves the socket positioned for
* the next keep-alive request.
*
@@ -178,6 +187,10 @@ public class RequestBody {
this.socketRemaining = socketRemaining;
}
boolean complete() {
return preBufRemaining == 0 && socketRemaining == 0;
}
@Override
public int read() throws IOException {
if (preBufRemaining > 0) {
@@ -12,6 +12,8 @@ import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.function.Consumer;
/**
* HTTP response. All mutating methods return {@code this} for fluent chaining.
@@ -43,7 +45,9 @@ public class Response {
private InputStream stream;
private long streamLength; // meaningful only when isStreaming() && !chunked
private boolean chunked;
private boolean pushStreaming;
private byte[] contentType;
private final MutableHeaderMap trailers = new MutableHeaderMap();
// of a List<byte[]> of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder +
// char[] + String + getBytes() chain per header(String,String) call). Two backing stores,
@@ -112,9 +116,11 @@ public class Response {
this.stream = null;
this.streamLength = 0;
this.chunked = false;
this.pushStreaming = false;
this.contentType = contentType.getBytes();
this.headerQuadCount = 0;
this.headerCount = 0;
this.trailers.reset();
if (rawHeaderLines != null) rawHeaderLines.clear();
this.active = true;
return this;
@@ -175,6 +181,7 @@ public class Response {
checkActive();
this.body = bytes;
this.stream = null;
this.pushStreaming = false;
return this;
}
@@ -189,6 +196,7 @@ public class Response {
this.streamLength = length;
this.chunked = false;
this.body = null;
this.pushStreaming = false;
return this;
}
@@ -198,9 +206,62 @@ public class Response {
this.stream = is;
this.chunked = true;
this.body = null;
this.pushStreaming = false;
return this;
}
/** Push-style streaming response with bounded blocking backpressure. */
public Response streaming(Consumer<ResponseStream> producer) {
checkActive();
this.stream = new ProducerInputStream(Objects.requireNonNull(producer), this);
this.streamLength = -1;
this.chunked = true;
this.pushStreaming = true;
this.body = null;
return this;
}
/** Adds a trailer rendered after the response body on both HTTP versions. */
public Response trailer(String name, String value) {
checkActive();
validateTrailer(name, value);
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8);
trailers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
return this;
}
/** Adds a pre-encoded structured trailer. */
public Response trailer(PreEncodedHeader trailer) {
checkActive();
byte[] name = trailer.nameBytes();
byte[] value = trailer.valueBytes();
validateTrailer(
new String(name, StandardCharsets.US_ASCII),
new String(value, StandardCharsets.US_ASCII));
trailers.add(name, 0, name.length, value, 0, value.length);
return this;
}
private static void validateTrailer(String name, String value) {
if (name.isEmpty() || name.charAt(0) == ':' || containsLineBreak(name)
|| containsLineBreak(value)) {
throw new IllegalArgumentException("invalid response trailer");
}
if (name.equalsIgnoreCase("content-length")
|| name.equalsIgnoreCase("transfer-encoding")
|| name.equalsIgnoreCase("connection")
|| name.equalsIgnoreCase("host")
|| name.equalsIgnoreCase("te")
|| name.equalsIgnoreCase("trailer")) {
throw new IllegalArgumentException("field is not permitted in response trailers: " + name);
}
}
private static boolean containsLineBreak(String value) {
return value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0;
}
/**
* 302 Found redirect. Clears the body, sets status and {@code Location} header. Encoded once at
* call time; zero-alloc on the write path.
@@ -367,6 +428,12 @@ public class Response {
return chunked;
}
/** Internal distinction between producer-driven and InputStream-driven response bodies. */
public boolean isPushStreaming() {
checkActive();
return pushStreaming;
}
public int getStatusCode() {
checkActive();
return statusCode;
@@ -397,6 +464,20 @@ public class Response {
return streamLength;
}
public boolean hasTrailers() {
checkActive();
return trailers.count() != 0;
}
public void writeTrailers(OutputStream output) throws IOException {
checkActive();
trailers.writeLines(output);
}
void forEachTrailerField(ResponseSerializer.FieldConsumer consumer) {
trailers.forEachStructured(consumer);
}
// -------------------------------------------------------------------------
// Internal setters used by HttpServer for handler return values
// -------------------------------------------------------------------------
@@ -54,4 +54,9 @@ public final class ResponseSerializer {
public static void forEachCustomField(Response response, FieldConsumer consumer) {
response.forEachStructuredField(consumer);
}
/** Enumerates response trailers in declaration order. */
public static void forEachTrailerField(Response response, FieldConsumer consumer) {
response.forEachTrailerField(consumer);
}
}
@@ -0,0 +1,11 @@
package dev.relism.flash.models;
import java.io.IOException;
/** Blocking, flow-controlled response body used by push-style streaming producers. */
public interface ResponseStream extends AutoCloseable {
void write(byte[] data, int offset, int length) throws IOException;
void flush() throws IOException;
void trailer(String name, String value);
@Override void close() throws IOException;
}
@@ -0,0 +1,35 @@
package dev.relism.flash.models;
import java.io.IOException;
import java.io.OutputStream;
/** Adapts a flow-controlled response stream to APIs that write to an {@link OutputStream}. */
public final class ResponseStreamOutputStream extends OutputStream {
private final ResponseStream stream;
private final byte[] single = new byte[1];
public ResponseStreamOutputStream(ResponseStream stream) {
this.stream = stream;
}
@Override
public void write(int value) throws IOException {
single[0] = (byte) value;
stream.write(single, 0, 1);
}
@Override
public void write(byte[] bytes, int offset, int length) throws IOException {
stream.write(bytes, offset, length);
}
@Override
public void flush() throws IOException {
stream.flush();
}
@Override
public void close() throws IOException {
stream.close();
}
}
@@ -84,7 +84,10 @@ public abstract class AbstractRouter {
*/
public AbstractRouter doRegister(HttpMethod method, String path,
RequestHandler handler, Middleware[] middlewares) {
return addRoute(method, PathUtils.sanitize(path), compile(handler, middlewares));
String target = method == HttpMethod.CONNECT
? PathUtils.sanitizeAuthority(path)
: PathUtils.sanitize(path);
return addRoute(method, target, compile(handler, middlewares));
}
/**
@@ -23,6 +23,14 @@ public class PathUtils {
return sanitized;
}
/** Normalizes an authority-form CONNECT target without turning it into an origin-form path. */
public static String sanitizeAuthority(String authority) {
if (authority == null) return "";
String sanitized = authority.trim();
while (sanitized.startsWith("/")) sanitized = sanitized.substring(1);
return sanitized;
}
/**
* Joins two path segments and ensures the result is sanitized.
* Prevents "double namespace" if the path already starts with the base.
@@ -61,21 +61,38 @@ public final class ConnectionRunner {
* Submits {@code socket} to the virtual-thread executor for full connection handling. {@code
* stopped} is threaded through to the eventual {@link ConnectionContext} so the protocol
* implementation can observe an in-progress graceful shutdown.
*
* <p>Rejects before any per-connection state exists no TLS handshake, no protocol
* negotiation, no HPACK tables once {@code activeSockets} reaches {@link
* FlashConfiguration#getMaxConnections()}. This is an approximate check (accept runs on up to
* {@link TransportTuning#ACCEPT_THREADS} concurrent threads, so a burst can briefly land a few
* connections past the limit), not an atomic guarantee; it only needs to bound worst-case
* growth, not enforce an exact count.
*/
public void accept(Socket socket, BooleanSupplier stopped) {
int max = configuration.getMaxConnections();
if (max > 0 && activeSockets.size() >= max) {
closeQuietly(socket);
return;
}
activeSockets.add(socket);
try {
executorService.submit(() -> handle(socket, stopped));
} catch (RejectedExecutionException ignored) {
try {
socket.close();
} catch (IOException e) {
log.debug("Error closing socket on shutdown", e);
}
activeSockets.remove(socket);
closeQuietly(socket);
}
}
private static void closeQuietly(Socket socket) {
try {
socket.close();
} catch (IOException e) {
log.debug("Error closing socket on shutdown", e);
}
}
private void handle(Socket socket, BooleanSupplier stopped) {
activeSockets.add(socket);
ConnectionScratch scratch = scratchPool.acquire();
try (socket;
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
@@ -134,17 +151,13 @@ public final class ConnectionRunner {
}
}
/**
* Decides h1 vs h2 for this connection, applying {@link FlashConfiguration#isHttp2Enabled()} to
* the plaintext (h2c) path see {@link ProtocolNegotiator}'s Javadoc for why the flag is applied
* here rather than inside the negotiator itself.
*/
/** Decides h1 vs h2 while keeping the TLS and cleartext rollout gates independent. */
private NegotiatedProtocol negotiateProtocol(Socket socket, BufferedByteSource in)
throws IOException {
if (socket instanceof SSLSocket) {
return ProtocolNegotiator.negotiate(socket, in); // ALPN already resolved, no I/O
}
if (!configuration.isHttp2Enabled()) {
if (!configuration.isHttp2CleartextEnabled()) {
return NegotiatedProtocol.HTTP_1_1; // skip the h2c peek entirely when disabled
}
in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L);
@@ -1,63 +1,35 @@
package dev.relism.flash.transport;
import javax.net.ssl.SSLSocket;
import java.io.IOException;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import javax.net.ssl.SSLSocket;
/**
* Decides, once per connection and before any request is parsed, whether the connection speaks
* immediately after ALPN/preface detection").
*
* <p>Two independent signals, in order:
* <ol>
* <li><b>ALPN</b> (TLS connections). If the socket is an {@link SSLSocket} and the TLS
* handshake already resolved {@code "h2"} as the application protocol, this connection is
* {@link NegotiatedProtocol#HTTP_2}. Anything else negotiated {@code "http/1.1"}, no
* protocol at all (a peer that doesn't speak ALPN), or an empty string is
* {@link NegotiatedProtocol#HTTP_1_1}. This costs nothing beyond a field read: ALPN is
* resolved during the handshake, which must already have completed (see
* {@code TlsConfig}'s Javadoc on why {@code startHandshake()} must be called explicitly
* <li><b>h2c prior knowledge</b> (plaintext connections, RFC 9113 §3.4). The first 24 bytes of
* the connection are compared, without being consumed, against the client connection
* preface {@code "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"}. A match is
* {@link NegotiatedProtocol#HTTP_2}; anything else including a partial match followed by
* EOF, or a preface look-alike that diverges partway through is
* {@link NegotiatedProtocol#HTTP_1_1}. This is why {@link BufferedByteSource#peek} exists:
* the bytes must remain available for {@code RequestParser} if they turn out not to be an
* h2 preface after all.</li>
* </ol>
*
* <p>This method reports the protocol accurately and unconditionally it does not consult
* {@code FlashConfiguration.http2Enabled}. Gating whether an {@link NegotiatedProtocol#HTTP_2}
* {@code Http2Connection} yet) and whether the h2c peek is even attempted for plaintext
* connections are both the caller's responsibility, so that this class stays a pure,
* directly-testable detector (see {@code ProtocolNegotiatorTest}).
*/
/** Detects HTTP/1.1 or HTTP/2 once, before the connection parser is selected. */
public final class ProtocolNegotiator {
private static final byte[] H2C_PREFACE =
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
/**
* reconstructed per connection.
*/
private static final byte[] H2C_PREFACE =
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
private ProtocolNegotiator() {}
private ProtocolNegotiator() {
/**
* Uses the completed TLS ALPN result for secure sockets and a non-consuming prior-knowledge
* preface probe for plaintext sockets. Configuration gates remain the caller's responsibility,
* which keeps detection deterministic and independently testable.
*/
public static NegotiatedProtocol negotiate(Socket socket, BufferedByteSource source)
throws IOException {
if (socket instanceof SSLSocket ssl) {
return "h2".equals(ssl.getApplicationProtocol())
? NegotiatedProtocol.HTTP_2
: NegotiatedProtocol.HTTP_1_1;
}
public static NegotiatedProtocol negotiate(Socket socket, BufferedByteSource source) throws IOException {
if (socket instanceof SSLSocket ssl) {
String applicationProtocol = ssl.getApplicationProtocol();
return "h2".equals(applicationProtocol) ? NegotiatedProtocol.HTTP_2 : NegotiatedProtocol.HTTP_1_1;
}
byte[] probe = new byte[H2C_PREFACE.length];
int n = source.peek(probe, 0, probe.length);
if (n == H2C_PREFACE.length && Arrays.equals(probe, H2C_PREFACE)) {
return NegotiatedProtocol.HTTP_2;
}
return NegotiatedProtocol.HTTP_1_1;
}
byte[] probe = new byte[H2C_PREFACE.length];
int read = source.peek(probe, 0, probe.length);
return read == H2C_PREFACE.length && Arrays.equals(probe, H2C_PREFACE)
? NegotiatedProtocol.HTTP_2
: NegotiatedProtocol.HTTP_1_1;
}
}
@@ -0,0 +1,50 @@
package dev.relism.flash.transport;
/**
* Transport-level bound on concurrent connections, enforced by {@link ConnectionRunner} before
* any per-connection state (TLS handshake, protocol negotiation, HPACK tables, buffers) is set
* up. Unlike {@link dev.relism.flash.http2.Http2Limits} (bounds on what one already-admitted
* connection may do), this bounds how many connections are admitted at all the guard a stress
* test found completely absent: {@code AcceptLoop} accepted unconditionally, so a connection
* flood ran the JVM out of heap rather than being turned away.
*/
public final class TransportLimits {
private TransportLimits() {}
/**
* Deliberately conservative estimate of one connection's worst-case retained heap (HPACK
* tables, stream table, in-flight response batches, up to {@code
* Http2Limits#MAX_CONCURRENT_STREAMS} concurrent streams), used only to size {@link
* #defaultMaxConnections()}'s auto-scaled budget not an enforced per-connection cap.
*
* <p>Not a precise per-byte accounting. A stress test on this codebase (h2load, 20 concurrent
* HTTP/2 streams per connection) observed {@code OutOfMemoryError} somewhere between 200 and
* 400 concurrent connections on a 1.5 GiB heap. This constant is chosen so {@link
* #defaultMaxConnections()} lands comfortably below that observed floor (~150 connections at
* 1.5 GiB) rather than hugging it. A heap-dump-derived precise figure is a natural follow-up;
* until then this trades some throughput headroom for a real safety margin.
*/
static final long ASSUMED_WORST_CASE_BYTES_PER_CONNECTION = 5L * 1024 * 1024;
/**
* Fraction of the JVM's max heap set aside for connection-admission accounting; the rest is
* left for GC headroom, response buffers, and everything else the server needs.
*/
static final double HEAP_FRACTION_FOR_CONNECTIONS = 0.5;
/** Floor so a tiny heap (dev/test containers) still gets a usable, non-degenerate limit. */
static final int MIN_MAX_CONNECTIONS = 64;
/**
* Auto-scaled default for {@code FlashConfiguration#getMaxConnections()}. Computed from {@link
* Runtime#maxMemory()} so the same default protects a 256 MiB container and an 8 GiB one
* without operator input; set {@code maxConnections} explicitly to override it, or to {@code 0}
* to disable the check (unlimited the behavior every version before this had unconditionally).
*/
public static int defaultMaxConnections() {
long heapBudget = (long) (Runtime.getRuntime().maxMemory() * HEAP_FRACTION_FOR_CONNECTIONS);
long computed = heapBudget / ASSUMED_WORST_CASE_BYTES_PER_CONNECTION;
return (int) Math.max(MIN_MAX_CONNECTIONS, Math.min(Integer.MAX_VALUE, computed));
}
}
@@ -3,39 +3,44 @@ package dev.relism.flash.websocket;
import java.io.IOException;
/**
* Drives one {@link WebSocketSession}'s read loop until the session closes, dispatching frames
* responsibility is this loop; the handshake and upgrade detection live in
* {@link WebSocketUpgrade}.
* Drives one {@link WebSocketSession}'s read loop until the session closes. The handshake and
* upgrade detection live in {@link WebSocketUpgrade}.
*/
public final class WebSocketLoop {
private WebSocketLoop() {
}
private WebSocketLoop() {}
public static void run(WebSocketSession session, WebSocketHandler handler) {
handler.onOpen(session);
WebSocketFrame frame = new WebSocketFrame();
try {
while (session.isOpen()) {
if (!session.readFrame(frame)) break;
switch (frame.opcode()) {
case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY
-> handler.onMessage(session, frame);
case WebSocketFrame.OP_CLOSE
-> session.closeFromPeer(frame);
case WebSocketFrame.OP_PING
-> session.sendPong(frame);
case WebSocketFrame.OP_PONG -> { /* heartbeat ack, no-op */ }
}
}
} catch (WebSocketProtocolException e) {
try { session.close(e.closeCode()); } catch (IOException ignored) { }
handler.onError(session, e);
} catch (IOException e) {
handler.onError(session, e);
} finally {
handler.onClose(session, session.closeCode());
session.forceClose();
public static void run(WebSocketSession session, WebSocketHandler handler) {
WebSocketFrame frame = new WebSocketFrame();
try {
handler.onOpen(session);
while (session.isOpen()) {
if (!session.readFrame(frame)) break;
switch (frame.opcode()) {
case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY ->
handler.onMessage(session, frame);
case WebSocketFrame.OP_CLOSE -> session.closeFromPeer(frame);
case WebSocketFrame.OP_PING -> session.sendPong(frame);
case WebSocketFrame.OP_PONG -> {
// Heartbeat acknowledgement; no action is required.
}
}
}
} catch (WebSocketProtocolException failure) {
try {
session.close(failure.closeCode());
} catch (IOException ignored) {
// The peer may already have closed the transport.
}
handler.onError(session, failure);
} catch (IOException | RuntimeException failure) {
handler.onError(session, failure);
} finally {
try {
handler.onClose(session, session.closeCode());
} finally {
session.forceClose();
}
}
}
}
@@ -68,7 +68,7 @@ class ChunkedInputStreamTest {
@Test
void trailers_consumed() throws IOException {
// trailing headers after 0-chunk must be consumed
assertEquals("hi", readAll(wrap("2\r\nhi\r\n0\r\nTrailer: value\r\n\r\n")));
assertEquals("hi", readAll(wrap("2\r\nhi\r\n0\r\nX-Trailer: value\r\n\r\n")));
}
// --- byte-by-byte read ---
@@ -0,0 +1,68 @@
package dev.relism.flash;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http1.Http1ResponseWriter;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.Response;
import dev.relism.flash.transport.BufferedByteSource;
import dev.relism.flash.transport.ConnectionScratch;
import dev.relism.flash.transport.ScratchPool;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class Http1TrailersTest {
@Test
void requestTrailersBecomeVisibleOnlyAfterBodyEof() throws Exception {
byte[] wire = ("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n"
+ "3\r\nabc\r\n0\r\nGrpc-Status: 0\r\nX-Trace: done\r\n\r\n")
.getBytes(StandardCharsets.US_ASCII);
Request request = new RequestParser().parse(
new BufferedByteSource(new ByteArrayInputStream(wire), null));
assertThrows(IllegalStateException.class, request::trailers);
assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII));
assertEquals("0", request.trailers().first("grpc-status"));
assertEquals("done", request.trailers().first("x-trace"));
}
@Test
void responseTrailersUseChunkedRendering() throws Exception {
Response response = new Response(200, "hello", ContentType.TEXT_PLAIN)
.trailer("grpc-status", "0");
ByteArrayOutputStream output = new ByteArrayOutputStream();
Http1ResponseWriter.writeResponse(
output, response, HttpMethod.GET, true, false, new ScratchPool().acquire());
String wire = output.toString(StandardCharsets.US_ASCII);
assertEquals(true, wire.contains("Transfer-Encoding: chunked\r\n"));
assertEquals(true, wire.endsWith("5\r\nhello\r\n0\r\ngrpc-status: 0\r\n\r\n"));
}
@Test
void pushStreamingAndTrailersShareTheSameHttp1Writer() throws Exception {
Response response = new Response(200, ContentType.BINARY).streaming(stream -> {
try {
stream.write("one".getBytes(StandardCharsets.US_ASCII), 0, 3);
stream.write("two".getBytes(StandardCharsets.US_ASCII), 0, 3);
stream.trailer("grpc-status", "0");
} catch (Exception failure) {
throw new RuntimeException(failure);
}
});
ByteArrayOutputStream output = new ByteArrayOutputStream();
Http1ResponseWriter.writeResponse(
output, response, HttpMethod.GET, true, false, new ScratchPool().acquire());
String wire = output.toString(StandardCharsets.US_ASCII);
assertEquals(true, wire.contains("one"));
assertEquals(true, wire.contains("two"));
assertEquals(true, wire.endsWith("0\r\ngrpc-status: 0\r\n\r\n"));
}
}
@@ -0,0 +1,54 @@
package dev.relism.flash;
import static org.junit.jupiter.api.Assertions.assertTimeout;
import static org.junit.jupiter.api.Assertions.fail;
import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.transport.BufferedByteSource;
import dev.relism.flash.testing.FuzzMemory;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class RequestParserFuzzTest {
private static final int CASES = 25_000;
@Test
void arbitraryWireBytesHaveBoundedTypedOutcomes() {
assertTimeout(
Duration.ofSeconds(20),
() -> {
byte[] input = new byte[256];
long state = 0x9112_4854_5450_314CL;
long baseline = FuzzMemory.snapshot();
for (int iteration = 0; iteration < CASES; iteration++) {
state = next(state);
int length = (int) (state & 255);
for (int i = 0; i < length; i++) {
state = next(state);
input[i] = (byte) state;
}
try {
new RequestParser(512)
.parse(
new BufferedByteSource(
new ByteArrayInputStream(input, 0, length), null, 256));
} catch (MalformedRequestException expected) {
// Hostile HTTP/1 syntax is rejected with an explicit response status.
} catch (IOException unexpected) {
fail("in-memory input produced I/O failure at case " + iteration, unexpected);
} catch (Throwable unexpected) {
fail("unexpected failure at case " + iteration + ", length " + length, unexpected);
}
}
FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024);
});
}
private static long next(long value) {
value ^= value << 13;
value ^= value >>> 7;
return value ^ (value << 17);
}
}
@@ -0,0 +1,55 @@
package dev.relism.flash.http;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http.HopByHopHeaders.Protocol;
import dev.relism.flash.models.MutableHeaderMap;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class HopByHopHeaderTest {
@Test
void sharedPolicyCoversAllFourProtocolConversions() {
for (Protocol sourceProtocol : Protocol.values()) {
for (Protocol targetProtocol : Protocol.values()) {
MutableHeaderMap source = new MutableHeaderMap();
add(source, "connection", "x-private, keep-alive");
add(source, "x-private", "secret");
add(source, "upgrade", "websocket");
add(source, "te", "trailers");
add(source, "x-end-to-end", "yes");
assertFalse(forward(source, "connection", "x-private", sourceProtocol, targetProtocol));
assertFalse(forward(source, "x-private", "secret", sourceProtocol, targetProtocol));
assertFalse(forward(source, "upgrade", "websocket", sourceProtocol, targetProtocol));
assertTrue(forward(source, "x-end-to-end", "yes", sourceProtocol, targetProtocol));
assertTrue(forward(source, "te", "trailers", sourceProtocol, Protocol.HTTP_2));
assertFalse(forward(source, "te", "trailers", sourceProtocol, Protocol.HTTP_1_1));
}
}
}
private static boolean forward(
MutableHeaderMap source,
String name,
String value,
Protocol sourceProtocol,
Protocol targetProtocol) {
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII);
PooledSlice nameView = new PooledSlice();
PooledSlice valueView = new PooledSlice();
nameView.reset(nameBytes, 0, nameBytes.length);
valueView.reset(valueBytes, 0, valueBytes.length);
return HopByHopHeaders.shouldForward(
source, nameView, valueView, sourceProtocol, targetProtocol);
}
private static void add(MutableHeaderMap headers, String name, String value) {
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII);
headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
}
}
@@ -7,8 +7,11 @@ import dev.relism.flash.transport.ConnectionScratch;
import dev.relism.flash.transport.ScratchPool;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
@@ -177,4 +180,74 @@ class Http1ResponseWriterTest {
assertEquals(1, out.arrayWriteCalls);
assertFalse(out.sink.toString(StandardCharsets.UTF_8).contains("hello world"));
}
// --- Streaming body close-on-every-exit -----------------------------------------
/** Tracks whether {@code close()} was called, regardless of how the stream was read. */
private static final class TrackingInputStream extends ByteArrayInputStream {
boolean closed;
TrackingInputStream(byte[] buf) {
super(buf);
}
@Override
public void close() throws IOException {
closed = true;
super.close();
}
}
/** Simulates a client disconnecting mid-transfer: the Nth write() call throws. */
private static final class FailingOutputStream extends OutputStream {
private final int failAfterCalls;
private int calls;
FailingOutputStream(int failAfterCalls) {
this.failAfterCalls = failAfterCalls;
}
@Override public void write(int b) {}
@Override
public void write(byte[] b, int off, int len) throws IOException {
calls++;
if (calls > failAfterCalls) throw new IOException("simulated client disconnect");
}
}
@Test
void chunkedStreamingBody_isClosed_onCleanCompletion() throws IOException {
TrackingInputStream stream = new TrackingInputStream("hello world".getBytes(StandardCharsets.UTF_8));
Response response = new Response(200, ContentType.TEXT_PLAIN).chunked(stream);
Http1ResponseWriter.writeResponse(new ByteArrayOutputStream(), response, HttpMethod.GET, true, false, scratch());
assertTrue(stream.closed, "a fully-relayed streaming body must be closed");
}
@Test
void fixedLengthStreamingBody_isClosed_evenWhenTheClientDisconnectsMidTransfer() {
// Regression test: a handler's streaming body (e.g. a reverse proxy relaying a pooled
// upstream connection's response) must have close() called even when the downstream
// write fails partway through otherwise a resource that's only released from close(),
// not from observing EOF on a read() the failed write means it never reaches, leaks.
TrackingInputStream stream = new TrackingInputStream("hello world".getBytes(StandardCharsets.UTF_8));
Response response = new Response(200, ContentType.TEXT_PLAIN).stream(stream, 11);
FailingOutputStream out = new FailingOutputStream(1); // 1st call writes the head, 2nd (body) fails
assertThrows(IOException.class, () ->
Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch()));
assertTrue(stream.closed, "the streaming body must be closed even when the write to the client fails");
}
@Test
void chunkedStreamingBody_isClosed_evenWhenTheClientDisconnectsMidTransfer() {
TrackingInputStream stream = new TrackingInputStream("hello world".getBytes(StandardCharsets.UTF_8));
Response response = new Response(200, ContentType.TEXT_PLAIN).chunked(stream);
FailingOutputStream out = new FailingOutputStream(1); // 1st call writes the head, 2nd (body) fails
assertThrows(IOException.class, () ->
Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch()));
assertTrue(stream.closed, "the streaming body must be closed even when the write to the client fails");
}
}
@@ -0,0 +1,120 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.tls.TestKeystores;
import dev.relism.flash.tls.TlsConfig;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.io.TempDir;
@EnabledIfSystemProperty(named = "curl.executable", matches = ".+")
class CurlInteropTest {
private FlashApp app;
@AfterEach
void stop() {
if (app != null) app.stop().join();
}
@Test
void tlsGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception {
int port = freePort();
Path keystore =
TestKeystores.build(
directory,
"curl.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());
exercise(directory, "https://localhost:" + port, "--http2", "--insecure");
}
@Test
void cleartextGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
exercise(directory, "http://127.0.0.1:" + port, "--http2-prior-knowledge");
}
private void exercise(Path directory, String origin, String... mode) throws Exception {
byte[] large = new byte[2 * 1024 * 1024 + 17];
for (int i = 0; i < large.length; i++) large[i] = (byte) (i * 31);
app.get("/get", (request, response) -> "curl-get");
app.post("/post", (request, response) -> request.body().bytes());
app.get("/large", (request, response) -> response.body(large));
app.start();
Path upload = directory.resolve("upload.bin");
Path output = directory.resolve("output.bin");
Files.write(upload, large);
assertArrayEquals(
"curl-get".getBytes(StandardCharsets.US_ASCII),
runCurl(output, origin + "/get", mode));
assertArrayEquals(
"small-post".getBytes(StandardCharsets.US_ASCII),
runCurl(output, origin + "/post", append(mode, "--data-binary", "small-post")));
assertArrayEquals(
large,
runCurl(output, origin + "/post", append(mode, "--data-binary", "@" + upload)));
assertArrayEquals(large, runCurl(output, origin + "/large", mode));
}
private static byte[] runCurl(Path output, String url, String... options) throws Exception {
String executable = System.getProperty("curl.executable");
List<String> command = new ArrayList<>();
command.add(executable);
command.add("--silent");
command.add("--show-error");
command.add("--fail");
command.addAll(List.of(options));
command.add("--output");
command.add(output.toString());
command.add(url);
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
assertTrue(process.waitFor(Duration.ofSeconds(30).toMillis(), TimeUnit.MILLISECONDS));
String diagnostics =
new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertEquals(0, process.exitValue(), diagnostics);
return Files.readAllBytes(output);
}
private static String[] append(String[] values, String... suffix) {
String[] result = new String[values.length + suffix.length];
System.arraycopy(values, 0, result, 0, values.length);
System.arraycopy(suffix, 0, result, values.length, suffix.length);
return result;
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -0,0 +1,166 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.io.TempDir;
@Tag("interop")
@EnabledIfSystemProperty(named = "grpcurl.executable", matches = ".+")
class GrpcInteropTest {
private FlashApp app;
@AfterEach
void stop() {
if (app != null) app.stop().join();
}
@Test
void grpcurlCompletesUnaryStreamingAndErrorCalls(@TempDir Path directory) throws Exception {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.post("/flash.test.Echo/Unary", (request, response) ->
response.type("application/grpc")
.body(request.body().bytes())
.trailer("grpc-status", "0"));
app.post("/flash.test.Echo/Stream", (request, response) -> {
byte[] message = request.body().bytes();
return response.type("application/grpc").streaming(stream -> {
try {
for (int i = 0; i < 3; i++) stream.write(message, 0, message.length);
stream.trailer("grpc-status", "0");
} catch (Exception failure) {
throw new RuntimeException(failure);
}
});
});
app.post("/flash.test.Echo/Fail", (request, response) ->
response.type("application/grpc")
.trailer("grpc-status", "3")
.trailer("grpc-message", "invalid request"));
app.post("/flash.test.Echo/ClientStream", (request, response) ->
response.type("application/grpc")
.body(firstGrpcMessage(request.body().bytes()))
.trailer("grpc-status", "0"));
app.post("/flash.test.Echo/Bidi", (request, response) ->
response.type("application/grpc")
.body(request.body().bytes())
.trailer("grpc-status", "0"));
app.start();
Path proto = directory.resolve("echo.proto");
Files.writeString(proto, """
syntax = "proto3";
package flash.test;
service Echo {
rpc Unary (Message) returns (Message);
rpc Stream (Message) returns (stream Message);
rpc ClientStream (stream Message) returns (Message);
rpc Bidi (stream Message) returns (stream Message);
rpc Fail (Message) returns (Message);
}
message Message { string value = 1; }
""");
Result unary = call(directory, port, "Unary");
assertEquals(0, unary.exitCode);
assertTrue(unary.output.contains("hello"), unary.output);
Result streaming = call(directory, port, "Stream");
assertEquals(0, streaming.exitCode);
assertEquals(3, occurrences(streaming.output, "hello"), streaming.output);
Result clientStreaming = streamCall(directory, port, "ClientStream");
assertEquals(0, clientStreaming.exitCode, clientStreaming.output);
assertEquals(1, occurrences(clientStreaming.output, "hello"), clientStreaming.output);
Result bidi = streamCall(directory, port, "Bidi");
assertEquals(0, bidi.exitCode, bidi.output);
assertEquals(2, occurrences(bidi.output, "hello"), bidi.output);
Result error = call(directory, port, "Fail");
assertTrue(error.exitCode != 0);
assertTrue(error.output.contains("InvalidArgument"), error.output);
assertTrue(error.output.contains("invalid request"), error.output);
}
private static Result call(Path directory, int port, String method) throws Exception {
return call(directory, port, method, "{\"value\":\"hello\"}", false);
}
private static Result streamCall(Path directory, int port, String method) throws Exception {
return call(
directory,
port,
method,
"{\"value\":\"hello\"}\n{\"value\":\"hello\"}\n",
true);
}
private static Result call(
Path directory, int port, String method, String input, boolean stdin) throws Exception {
Process process = new ProcessBuilder(
System.getProperty("grpcurl.executable"),
"-plaintext",
"-import-path", directory.toString(),
"-proto", "echo.proto",
"-d", stdin ? "@" : input,
"127.0.0.1:" + port,
"flash.test.Echo/" + method)
.redirectErrorStream(true)
.start();
if (stdin) {
process.getOutputStream().write(input.getBytes(StandardCharsets.UTF_8));
}
process.getOutputStream().close();
assertTrue(process.waitFor(10, TimeUnit.SECONDS), "grpcurl timed out");
return new Result(process.exitValue(),
new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
}
private static byte[] firstGrpcMessage(byte[] body) {
if (body.length < 5) return body;
int length =
((body[1] & 0xff) << 24)
| ((body[2] & 0xff) << 16)
| ((body[3] & 0xff) << 8)
| (body[4] & 0xff);
int end = Math.min(body.length, 5 + length);
return java.util.Arrays.copyOf(body, end);
}
private static int occurrences(String text, String needle) {
int count = 0;
int position = 0;
while ((position = text.indexOf(needle, position)) >= 0) {
count++;
position += needle.length();
}
return count;
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
private record Result(int exitCode, String output) {}
}
@@ -0,0 +1,141 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import java.net.ServerSocket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.io.TempDir;
@Tag("benchmark")
@EnabledIfSystemProperty(named = "h2load.executable", matches = ".+")
@EnabledIfSystemProperty(named = "nghttpd.executable", matches = ".+")
class H2LoadMeasurementTest {
private static final int[] CONNECTIONS = {1, 10, 100, 1_000};
private static final int[] STREAMS = {1, 10, 100};
private static final Pattern RATE = Pattern.compile("([0-9.]+) req/s");
private static final Pattern REQUESTS =
Pattern.compile("requests: (\\d+) total, .*? (\\d+) succeeded, (\\d+) failed");
private FlashApp app;
private Process reference;
@AfterEach
void stop() {
if (app != null) app.stop().join();
if (reference != null) reference.destroyForcibly();
}
@Test
void measureFlashAndNghttpdMatrix(@TempDir Path directory) throws Exception {
int flashPort = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(flashPort)
.http2CleartextEnabled(true)
.h2MaxStreamsCreatedPerInterval(Integer.MAX_VALUE)
.h2MaxStreamsPerConnection(0)
.build());
app.get("/index.html", (request, response) -> "flash-load");
app.start();
int referencePort = freePort();
Files.writeString(directory.resolve("index.html"), "flash-load");
ProcessBuilder server =
new ProcessBuilder(
System.getProperty("nghttpd.executable"),
"--no-tls",
"--max-concurrent-streams=128",
"-d",
directory.toString(),
Integer.toString(referencePort));
applyLibraryPath(server);
reference = server.redirectErrorStream(true).start();
Thread.sleep(200);
System.out.println(
"implementation,connections,requested_streams,effective_streams,requests,requests_per_second");
for (int connections : CONNECTIONS) {
for (int streams : STREAMS) {
int requests = Math.max(1_000, connections * streams);
int effectiveStreams =
Math.max(
1,
Math.min(
Math.min(streams, Http2Limits.MAX_CONCURRENT_STREAMS),
4_096 / connections));
measure("flash", flashPort, connections, streams, effectiveStreams, requests);
measure("nghttpd", referencePort, connections, streams, effectiveStreams, requests);
}
}
}
private static void measure(
String implementation,
int port,
int connections,
int requestedStreams,
int effectiveStreams,
int requests)
throws Exception {
List<String> command = new ArrayList<>();
command.add(System.getProperty("h2load.executable"));
command.add("-n");
command.add(Integer.toString(requests));
command.add("-c");
command.add(Integer.toString(connections));
command.add("-m");
command.add(Integer.toString(effectiveStreams));
command.add("-t");
command.add(Integer.toString(Math.min(8, connections)));
command.add("http://127.0.0.1:" + port + "/index.html");
ProcessBuilder builder = new ProcessBuilder(command).redirectErrorStream(true);
applyLibraryPath(builder);
Process process = builder.start();
assertTrue(process.waitFor(Duration.ofMinutes(2).toMillis(), TimeUnit.MILLISECONDS));
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
assertEquals(0, process.exitValue(), output);
Matcher requestsResult = REQUESTS.matcher(output);
assertTrue(requestsResult.find(), output);
assertEquals(requests, Integer.parseInt(requestsResult.group(1)), output);
assertEquals(requests, Integer.parseInt(requestsResult.group(2)), output);
assertEquals(0, Integer.parseInt(requestsResult.group(3)), output);
Matcher rate = RATE.matcher(output);
assertTrue(rate.find(), output);
System.out.printf(
"%s,%d,%d,%d,%d,%s%n",
implementation,
connections,
requestedStreams,
effectiveStreams,
requests,
rate.group(1));
}
private static void applyLibraryPath(ProcessBuilder builder) {
String path = System.getProperty("nghttp.library.path");
if (path != null) builder.environment().put("LD_LIBRARY_PATH", path);
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -0,0 +1,155 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.tls.TestKeystores;
import dev.relism.flash.tls.TlsConfig;
import java.nio.charset.StandardCharsets;
import java.net.ServerSocket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.LockSupport;
import javax.xml.parsers.DocumentBuilderFactory;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
import org.junit.jupiter.api.io.TempDir;
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
@EnabledIfSystemProperty(named = "h2spec.executable", matches = ".+")
class H2SpecComplianceTest {
private static final String VERSION = "2.6.0";
private FlashApp app;
@AfterEach
void stop() {
if (app != null) app.stop().join();
}
@Test
void cleartextSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
registerProbeRoutes();
app.start();
runH2Spec(port, false, directory.resolve("h2spec-h2c.xml"));
}
@Test
void tlsSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception {
int port = freePort();
Path keystore =
TestKeystores.build(
directory,
"h2spec.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());
registerProbeRoutes();
app.start();
runH2Spec(port, true, directory.resolve("h2spec-tls.xml"));
}
private void registerProbeRoutes() {
app.get("/", (request, response) -> probeResponse());
app.post("/", (request, response) -> probeResponse());
}
private static String probeResponse() {
// h2spec deliberately writes illegal follow-up frames immediately after END_STREAM. Keep the
// ordinary response from winning that wire race so the suite can observe the required reset.
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(20));
return "flash-compliance";
}
private static void runH2Spec(int port, boolean tls, Path report) throws Exception {
String executable = System.getProperty("h2spec.executable");
ProcessResult version = run(List.of(executable, "--version"), Duration.ofSeconds(5));
assertEquals(0, version.exitCode, version.output);
assertTrue(version.output.contains(VERSION), "unexpected h2spec version: " + version.output);
List<String> command = new ArrayList<>();
command.add(executable);
command.add("--host");
command.add(tls ? "localhost" : "127.0.0.1");
command.add("--port");
command.add(Integer.toString(port));
command.add("--timeout");
command.add("5");
command.add("--junit-report");
command.add(report.toString());
if (tls) {
command.add("--tls");
command.add("--insecure");
} else {
command.add("generic");
command.add("hpack");
command.add("http2/3.5/1");
command.add("http2/4");
command.add("http2/5");
command.add("http2/6");
command.add("http2/7");
command.add("http2/8");
}
ProcessResult result = run(command, Duration.ofMinutes(3));
assertEquals(0, result.exitCode, result.output);
assertReportHasNoFailuresOrSkips(report, result.output);
}
private static ProcessResult run(List<String> command, Duration timeout) throws Exception {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
boolean completed = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
if (!completed) {
process.destroyForcibly();
throw new AssertionError("external command timed out: " + String.join(" ", command));
}
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
return new ProcessResult(process.exitValue(), output);
}
private static void assertReportHasNoFailuresOrSkips(Path report, String output)
throws Exception {
assertTrue(Files.isRegularFile(report), "h2spec did not create its JUnit report\n" + output);
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
Document document = factory.newDocumentBuilder().parse(report.toFile());
NodeList failures = document.getElementsByTagName("failure");
NodeList errors = document.getElementsByTagName("error");
NodeList skipped = document.getElementsByTagName("skipped");
assertEquals(0, failures.getLength(), output);
assertEquals(0, errors.getLength(), output);
assertEquals(0, skipped.getLength(), output);
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
private record ProcessResult(int exitCode, String output) {}
}
@@ -0,0 +1,292 @@
package dev.relism.flash.http2;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameWriteBuffer;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.http2.hpack.HpackEncoder;
import dev.relism.flash.websocket.WebSocketFrame;
import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.EOFException;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
/** Minimal RFC 8441 peer used only by the live WebSocket-over-h2 tests. */
final class H2WebSocketTestClient implements Closeable {
private static final int WINDOW = 2 * 1024 * 1024;
private final Socket socket;
private final InputStream input;
private final OutputStream output;
private final ByteArrayOutputStream responseData = new ByteArrayOutputStream();
private int connectionWindow = 65_535;
private int streamWindow = 65_535;
private int peerMaxFrame = 16_384;
private boolean connectProtocolAdvertised;
private boolean responseEnded;
H2WebSocketTestClient(String host, int port, String path) throws Exception {
socket = new Socket(host, port);
socket.setSoTimeout(5_000);
input = socket.getInputStream();
output = socket.getOutputStream();
writePreface();
awaitSettings();
writeConnect(host + ":" + port, path);
int status = awaitStatus();
if (status != 200) throw new IOException("extended CONNECT returned " + status);
}
boolean connectProtocolAdvertised() {
return connectProtocolAdvertised;
}
void sendText(String value) throws Exception {
sendWebSocketFrame(true, WebSocketFrame.OP_TEXT, value.getBytes(StandardCharsets.UTF_8), false);
}
void sendFragmentedText(String first, String second) throws Exception {
sendWebSocketFrame(
false, WebSocketFrame.OP_TEXT, first.getBytes(StandardCharsets.UTF_8), false);
sendWebSocketFrame(
true, WebSocketFrame.OP_CONTINUATION, second.getBytes(StandardCharsets.UTF_8), false);
}
void sendBinary(byte[] value) throws Exception {
sendWebSocketFrame(true, WebSocketFrame.OP_BINARY, value, false);
}
byte[] readMessage(byte expectedOpcode) throws Exception {
responseData.reset();
while (true) {
readAndHandleFrame();
byte[] bytes = responseData.toByteArray();
if (bytes.length < 2) continue;
int opcode = bytes[0] & 0x0f;
int marker = bytes[1] & 0x7f;
int headerLength;
long payloadLength;
if (marker < 126) {
headerLength = 2;
payloadLength = marker;
} else if (marker == 126) {
if (bytes.length < 4) continue;
headerLength = 4;
payloadLength = ((bytes[2] & 0xff) << 8) | (bytes[3] & 0xff);
} else {
if (bytes.length < 10) continue;
headerLength = 10;
payloadLength = 0;
for (int i = 2; i < 10; i++) payloadLength = (payloadLength << 8) | (bytes[i] & 0xffL);
}
if (payloadLength > Integer.MAX_VALUE || bytes.length < headerLength + payloadLength) {
continue;
}
if (opcode != expectedOpcode) throw new IOException("unexpected WebSocket opcode " + opcode);
byte[] payload = new byte[(int) payloadLength];
System.arraycopy(bytes, headerLength, payload, 0, payload.length);
return payload;
}
}
void closeGracefully() throws Exception {
sendWebSocketFrame(true, WebSocketFrame.OP_CLOSE, new byte[] {3, (byte) 232}, true);
while (!responseEnded) readAndHandleFrame();
}
@Override
public void close() throws IOException {
socket.close();
}
private void writePreface() throws IOException {
output.write(Http2Preface.clientPreface());
ByteWriter bytes = new ByteWriter(64);
FrameWriteBuffer frames = new FrameWriteBuffer(bytes);
frames.beginFrame(FrameType.SETTINGS, 0, 0);
bytes.writeUInt16(Http2Settings.ENABLE_PUSH);
bytes.writeUInt32(0);
bytes.writeUInt16(Http2Settings.INITIAL_WINDOW_SIZE);
bytes.writeUInt32(WINDOW);
frames.endFrame();
frames.beginFrame(FrameType.WINDOW_UPDATE, 0, 0);
bytes.writeUInt31(WINDOW - 65_535);
frames.endFrame();
output.write(bytes.array(), 0, bytes.length());
}
private void awaitSettings() throws Exception {
while (!connectProtocolAdvertised) {
WireFrame frame = readFrame();
if (frame.type == FrameType.SETTINGS.code() && (frame.flags & FrameFlags.ACK) == 0) {
for (int offset = 0; offset < frame.payload.length; offset += 6) {
int id = ((frame.payload[offset] & 0xff) << 8) | (frame.payload[offset + 1] & 0xff);
int value = readInt(frame.payload, offset + 2);
if (id == Http2Settings.ENABLE_CONNECT_PROTOCOL && value == 1) {
connectProtocolAdvertised = true;
} else if (id == Http2Settings.INITIAL_WINDOW_SIZE) {
streamWindow = value;
} else if (id == Http2Settings.MAX_FRAME_SIZE) {
peerMaxFrame = value;
}
}
writeEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0);
} else {
handle(frame);
}
}
}
private void writeConnect(String authority, String path) throws IOException {
ByteWriter bytes = new ByteWriter(256);
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
frame.beginFrame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1);
HpackEncoder.writeLiteralWithNameIndex(
bytes, 2, "CONNECT".getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeIndexed(bytes, 6);
HpackEncoder.writeLiteralWithNameIndex(
bytes, 1, authority.getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeLiteralWithNameIndex(
bytes, 4, path.getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeLiteral(
bytes,
":protocol".getBytes(StandardCharsets.US_ASCII),
"websocket".getBytes(StandardCharsets.US_ASCII));
frame.endFrame();
output.write(bytes.array(), 0, bytes.length());
}
private int awaitStatus() throws Exception {
HpackDecoder decoder = new HpackDecoder();
while (true) {
WireFrame frame = readFrame();
if (frame.type != FrameType.HEADERS.code() || frame.streamId != 1) {
handle(frame);
continue;
}
int[] status = {0};
decoder.decode(
frame.payload,
0,
frame.payload.length,
(name, value, never) -> {
if (name.length() == 7 && name.byteAt(0) == ':') {
status[0] =
(value.byteAt(0) - '0') * 100
+ (value.byteAt(1) - '0') * 10
+ value.byteAt(2)
- '0';
}
});
return status[0];
}
}
private void sendWebSocketFrame(boolean fin, byte opcode, byte[] payload, boolean endStream)
throws Exception {
byte[] encoded = maskedFrame(fin, opcode, payload);
int offset = 0;
while (offset < encoded.length) {
while (connectionWindow <= 0 || streamWindow <= 0) readAndHandleFrame();
int count =
Math.min(
encoded.length - offset,
Math.min(peerMaxFrame, Math.min(connectionWindow, streamWindow)));
writeData(encoded, offset, count, endStream && offset + count == encoded.length);
offset += count;
connectionWindow -= count;
streamWindow -= count;
}
}
private void readAndHandleFrame() throws Exception {
handle(readFrame());
}
private void handle(WireFrame frame) throws IOException {
if (frame.type == FrameType.WINDOW_UPDATE.code()) {
int increment = readInt(frame.payload, 0) & 0x7fff_ffff;
if (frame.streamId == 0) connectionWindow += increment;
else if (frame.streamId == 1) streamWindow += increment;
} else if (frame.type == FrameType.DATA.code() && frame.streamId == 1) {
responseData.write(frame.payload);
responseEnded = (frame.flags & FrameFlags.END_STREAM) != 0;
} else if (frame.type == FrameType.SETTINGS.code() && (frame.flags & FrameFlags.ACK) == 0) {
writeEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0);
} else if (frame.type == FrameType.RST_STREAM.code() && frame.streamId == 1) {
throw new IOException("WebSocket stream reset with " + readInt(frame.payload, 0));
} else if (frame.type == FrameType.GOAWAY.code()) {
throw new IOException("HTTP/2 connection closed with " + readInt(frame.payload, 4));
}
}
private void writeData(byte[] payload, int offset, int length, boolean endStream)
throws IOException {
ByteWriter bytes = new ByteWriter(length + 9);
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
frame.beginFrame(FrameType.DATA, endStream ? FrameFlags.END_STREAM : 0, 1);
bytes.writeBytes(payload, offset, length);
frame.endFrame();
output.write(bytes.array(), 0, bytes.length());
}
private void writeEmpty(FrameType type, int flags, int streamId) throws IOException {
byte[] frame = {0, 0, 0, (byte) type.code(), (byte) flags, 0, 0, 0, (byte) streamId};
output.write(frame);
}
private WireFrame readFrame() throws IOException {
byte[] header = input.readNBytes(9);
if (header.length != 9) throw new EOFException("HTTP/2 connection closed between frames");
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
int streamId =
((header[5] & 0x7f) << 24)
| ((header[6] & 0xff) << 16)
| ((header[7] & 0xff) << 8)
| (header[8] & 0xff);
byte[] payload = input.readNBytes(length);
if (payload.length != length) throw new EOFException("HTTP/2 frame truncated");
return new WireFrame(header[3] & 0xff, header[4] & 0xff, streamId, payload);
}
private static byte[] maskedFrame(boolean fin, byte opcode, byte[] payload) {
int lengthBytes = payload.length <= 125 ? 0 : payload.length <= 0xffff ? 2 : 8;
byte[] frame = new byte[2 + lengthBytes + 4 + payload.length];
int position = 0;
frame[position++] = (byte) ((fin ? 0x80 : 0) | opcode);
if (lengthBytes == 0) {
frame[position++] = (byte) (0x80 | payload.length);
} else if (lengthBytes == 2) {
frame[position++] = (byte) (0x80 | 126);
frame[position++] = (byte) (payload.length >>> 8);
frame[position++] = (byte) payload.length;
} else {
frame[position++] = (byte) (0x80 | 127);
long payloadLength = payload.length;
for (int shift = 56; shift >= 0; shift -= 8) {
frame[position++] = (byte) (payloadLength >>> shift);
}
}
byte[] mask = {1, 2, 3, 4};
System.arraycopy(mask, 0, frame, position, mask.length);
position += mask.length;
for (int i = 0; i < payload.length; i++) {
frame[position + i] = (byte) (payload[i] ^ mask[i & 3]);
}
return frame;
}
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);
}
private record WireFrame(int type, int flags, int streamId, byte[] payload) {}
}
@@ -0,0 +1,133 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.http2.hpack.HpackEncoder;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
class H2cPriorKnowledgeTest {
private FlashApp app;
@AfterEach
void stop() {
if (app != null) app.stop().join();
}
@Test
void priorKnowledgeRequiresItsIndependentOptIn() throws Exception {
int disabledPort = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(disabledPort)
.http2Enabled(true)
.build());
app.get("/", (request, response) -> "wrong protocol");
app.start();
try (Socket socket = new Socket("127.0.0.1", disabledPort)) {
socket.setSoTimeout(2_000);
socket.getOutputStream().write(Http2Preface.clientPreface());
byte[] prefix = socket.getInputStream().readNBytes(5);
assertArrayEquals("HTTP/".getBytes(StandardCharsets.US_ASCII), prefix);
}
app.stop().join();
int enabledPort = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(enabledPort)
.http2CleartextEnabled(true)
.build());
app.get("/", (request, response) -> "h2c");
app.start();
try (Socket socket = new Socket("127.0.0.1", enabledPort)) {
socket.setSoTimeout(5_000);
ByteWriter block = new ByteWriter(32);
HpackEncoder.writeIndexed(block, 2); // :method GET
HpackEncoder.writeIndexed(block, 6); // :scheme http
HpackEncoder.writeIndexed(block, 4); // :path /
HpackEncoder.writeLiteralWithNameIndex(
block, 1, ("127.0.0.1:" + enabledPort).getBytes(StandardCharsets.US_ASCII), false);
socket
.getOutputStream()
.write(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(
FrameType.HEADERS,
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
1,
Arrays.copyOf(block.array(), block.length()))));
socket.getOutputStream().flush();
assertEquals(200, readStatus(socket.getInputStream()));
assertEquals("h2c", new String(readData(socket.getInputStream()), StandardCharsets.UTF_8));
}
}
private static int readStatus(InputStream input) throws Exception {
HpackDecoder decoder = new HpackDecoder();
while (true) {
Http2TestFrames.WireFrame frame = readFrame(input);
if (frame.type() != FrameType.HEADERS.code() || frame.streamId() != 1) continue;
int[] status = {0};
decoder.decode(
frame.payload(),
0,
frame.payload().length,
(name, value, never) -> {
if (name.length() == 7 && name.byteAt(0) == ':') {
status[0] =
(value.byteAt(0) - '0') * 100
+ (value.byteAt(1) - '0') * 10
+ value.byteAt(2)
- '0';
}
});
return status[0];
}
}
private static byte[] readData(InputStream input) throws Exception {
for (int i = 0; i < 12; i++) {
Http2TestFrames.WireFrame frame = readFrame(input);
if (frame.streamId() == 1 && frame.type() == FrameType.DATA.code()
&& frame.payload().length != 0) return frame.payload();
}
throw new AssertionError("missing h2c response DATA");
}
private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception {
byte[] header = input.readNBytes(9);
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
byte[] payload = input.readNBytes(length);
return new Http2TestFrames.WireFrame(
header[3] & 0xff, header[4] & 0xff, Http2TestFrames.readInt(header, 5) & 0x7fff_ffff,
payload);
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -0,0 +1,315 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.hpack.HeaderListSizeException;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.http2.hpack.HpackEncoder;
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
import dev.relism.flash.http2.message.PseudoHeaders;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.ByteArrayInputStream;
import java.io.EOFException;
import java.io.InputStream;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.io.ByteArrayOutputStream;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class Http2AbuseTest {
@Test
void rapidResetClosesConnectionWithEnhanceYourCalm() throws Exception {
List<byte[]> frames = new ArrayList<>();
frames.add(Http2TestFrames.PREFACE);
frames.add(Http2TestFrames.settings());
for (int i = 0; i <= Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL; i++) {
int streamId = i * 2 + 1;
frames.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, streamId,
new byte[0]));
frames.add(Http2TestFrames.frame(FrameType.RST_STREAM, 0, streamId, new byte[4]));
}
assertCalm(run(frames));
}
@Test
void streamCreationFloodIsBoundedIndependentlyOfResets() throws Exception {
List<byte[]> frames = new ArrayList<>();
frames.add(Http2TestFrames.PREFACE);
frames.add(Http2TestFrames.settings());
for (int i = 0; i <= Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL; i++) {
frames.add(Http2TestFrames.frame(
FrameType.HEADERS, FrameFlags.END_HEADERS, i * 2 + 1, new byte[0]));
}
assertCalm(run(frames));
}
@Test
void settingsAndPingFloodsAreRateLimited() throws Exception {
List<byte[]> settings = base();
for (int i = 0; i <= Http2Limits.MAX_SETTINGS_PER_INTERVAL; i++) {
settings.add(Http2TestFrames.settings());
}
assertCalm(run(settings));
List<byte[]> pings = base();
for (int i = 0; i <= Http2Limits.MAX_PINGS_PER_INTERVAL; i++) {
pings.add(Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]));
}
assertCalm(run(pings));
}
@Test
void aggregateNonProgressFrameFloodIsRateLimited() throws Exception {
List<byte[]> frames = base();
byte[] priority = new byte[5];
for (int i = 0; i <= Http2Limits.MAX_USELESS_FRAMES_PER_INTERVAL; i++) {
frames.add(Http2TestFrames.frame(FrameType.PRIORITY, 0, 1, priority));
}
assertCalm(run(frames));
}
@Test
void operatorConnectionStreamAndByteBudgetsAreEnforced() throws Exception {
FlashConfiguration oneStream = FlashConfiguration.builder()
.h2MaxStreamsPerConnection(1).build();
List<byte[]> streams = base();
streams.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0]));
streams.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 3, new byte[0]));
assertCalm(runConfigured(streams, oneStream));
FlashConfiguration nineBytes = FlashConfiguration.builder()
.h2MaxBytesPerConnection(9).build();
List<byte[]> bytes = base();
bytes.add(Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]));
assertCalm(runConfigured(bytes, nineBytes));
}
@Test
void optionalConnectionLifetimeBudgetRotatesTheConnection() throws Exception {
byte[] initial = Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings());
ByteArrayInputStream delegate = new ByteArrayInputStream(initial);
InputStream stalled = new InputStream() {
@Override
public int read(byte[] target, int offset, int length) throws IOException {
if (delegate.available() > 0) return delegate.read(target, offset, length);
try {
Thread.sleep(5);
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt();
throw new IOException(interrupted);
}
throw new SocketTimeoutException("idle");
}
@Override
public int read() throws IOException {
byte[] one = new byte[1];
int count = read(one, 0, 1);
return count < 0 ? -1 : one[0] & 0xff;
}
};
Http2Connection connection = new Http2Connection();
connection.configure(FlashConfiguration.builder().h2MaxConnectionLifetimeMs(1).build());
ByteArrayOutputStream output = new ByteArrayOutputStream();
Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000);
try {
connection.run(new BufferedByteSource(stalled, null), writer, () -> false);
} finally {
writer.close();
}
assertCalm(new Run(Http2TestFrames.parse(output.toByteArray())));
}
@Test
void continuationFloodDiesBeforeMaterializingAttack() throws Exception {
List<byte[]> frames = base();
frames.add(Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82}));
byte[] continuation = Http2TestFrames.frame(FrameType.CONTINUATION, 0, 1, new byte[0]);
for (int i = 0; i < 100_000; i++) frames.add(continuation);
byte[] input = Http2TestFrames.concat(frames.toArray(byte[][]::new));
long before = usedHeap();
Run result = org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(
Duration.ofSeconds(2), () -> run(input));
assertEquals(Http2ErrorCode.PROTOCOL_ERROR.code(), result.lastGoAwayError());
assertTrue(usedHeap() - before < 8L * 1024 * 1024, "attack processing retained too much heap");
}
@Test
void hpackBombStopsPublishingFieldsAtTheConfiguredBound() {
ByteWriter block = new ByteWriter(4096);
byte[] name = "x".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
byte[] value = new byte[1024];
for (int i = 0; i < 100; i++) HpackEncoder.writeLiteral(block, name, value);
int[] published = {0};
assertThrows(
HeaderListSizeException.class,
() -> new HpackDecoder(4096, 4096).decode(
block.array(), 0, block.length(), (n, v, sensitive) -> published[0]++));
assertTrue(published[0] <= 3, "fields beyond the list bound reached stream storage");
}
@Test
void incompleteHeaderBlockHasAnAbsoluteAssemblyDeadline() throws Exception {
byte[] wire = Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82});
dev.relism.flash.http2.frame.Http2FrameReader reader =
new dev.relism.flash.http2.frame.Http2FrameReader(
new BufferedByteSource(new ByteArrayInputStream(wire), null));
Http2HeaderBlockDecoder decoder = new Http2HeaderBlockDecoder(1);
decoder.accept(reader.readFrame(), (name, value, sensitive) -> {});
Thread.sleep(5);
Http2Exception failure = assertThrows(Http2Exception.class, decoder::checkTimeout);
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, failure.errorCode());
}
@Test
void idleOpenStreamIsCancelledWithinConfiguredDeadline() throws Exception {
int port = freePort();
FlashApp app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.h2StreamIdleTimeoutMs(20)
.build());
app.post("/idle", (request, response) -> request.body().bytes());
app.start();
ByteWriter headers = new ByteWriter(64);
HpackEncoder.writeIndexed(headers, 3);
HpackEncoder.writeIndexed(headers, 6);
HpackEncoder.writeLiteralWithNameIndex(headers, 4, ascii("/idle"), false);
HpackEncoder.writeLiteralWithNameIndex(headers, 1, ascii("localhost"), false);
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(2_000);
socket.getOutputStream().write(Http2TestFrames.concat(
Http2TestFrames.PREFACE, Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1,
java.util.Arrays.copyOf(headers.array(), headers.length()))));
socket.getOutputStream().flush();
Http2TestFrames.WireFrame rst = readUntil(socket.getInputStream(), FrameType.RST_STREAM);
assertEquals(Http2ErrorCode.CANCEL.code(), Http2TestFrames.readInt(rst.payload(), 0));
} finally {
app.stop().join();
}
}
@Test
void zeroNameDuplicatePseudoAndOversizedFieldAreRejected() {
HpackHeaderBlock emptyName = new HpackHeaderBlock();
new HpackDecoder().decode(new byte[] {0, 0, 0}, 0, 3, emptyName);
assertThrows(Http2StreamException.class,
() -> new PseudoHeaders().validate(emptyName, 1));
HpackHeaderBlock duplicate = new HpackHeaderBlock();
new HpackDecoder().decode(new byte[] {(byte) 0x82, (byte) 0x82}, 0, 2, duplicate);
assertThrows(Http2StreamException.class,
() -> new PseudoHeaders().validate(duplicate, 1));
assertThrows(Http2Exception.class,
() -> new HpackDecoder().decode(new byte[] {0, 0x7f, (byte) 0x81, 0x3f}, 0, 4,
(n, v, s) -> {}));
}
private static List<byte[]> base() {
List<byte[]> frames = new ArrayList<>();
frames.add(Http2TestFrames.PREFACE);
frames.add(Http2TestFrames.settings());
return frames;
}
private static Run run(List<byte[]> frames) throws Exception {
return run(Http2TestFrames.concat(frames.toArray(byte[][]::new)));
}
private static Run run(byte[] input) throws Exception {
Http2ConnectionHandshakeTest.RunResult result = Http2ConnectionHandshakeTest.run(input);
return new Run(Http2TestFrames.parse(result.output()));
}
private static Run runConfigured(List<byte[]> frames, FlashConfiguration configuration)
throws Exception {
Http2Connection connection = new Http2Connection();
connection.configure(configuration);
ByteArrayOutputStream output = new ByteArrayOutputStream();
Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000);
try {
connection.run(
new BufferedByteSource(
new ByteArrayInputStream(Http2TestFrames.concat(frames.toArray(byte[][]::new))), null),
writer,
() -> false);
writer.drain();
} finally {
writer.close();
}
return new Run(Http2TestFrames.parse(output.toByteArray()));
}
private static void assertCalm(Run result) {
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM.code(), result.lastGoAwayError());
}
private static long usedHeap() {
Runtime runtime = Runtime.getRuntime();
return runtime.totalMemory() - runtime.freeMemory();
}
private static byte[] ascii(String text) {
return text.getBytes(StandardCharsets.US_ASCII);
}
private static Http2TestFrames.WireFrame readUntil(InputStream input, FrameType expected)
throws Exception {
for (int i = 0; i < 12; i++) {
byte[] header = input.readNBytes(9);
if (header.length != 9) throw new EOFException();
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
byte[] payload = input.readNBytes(length);
Http2TestFrames.WireFrame frame = new Http2TestFrames.WireFrame(
header[3] & 0xff, header[4] & 0xff,
Http2TestFrames.readInt(header, 5) & 0x7fff_ffff, payload);
if (frame.type() == expected.code()) return frame;
}
throw new AssertionError("missing " + expected);
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
private record Run(List<Http2TestFrames.WireFrame> frames) {
int lastGoAwayError() {
for (int i = frames.size() - 1; i >= 0; i--) {
Http2TestFrames.WireFrame frame = frames.get(i);
if (frame.type() == FrameType.GOAWAY.code()) {
return Http2TestFrames.readInt(frame.payload(), 4);
}
}
throw new AssertionError("missing GOAWAY");
}
}
}
@@ -0,0 +1,18 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class Http2AuthorityTest {
@Test
void matchesExactIpPortAndSingleLabelWildcardAuthorities() {
assertTrue(Http2Authority.matches("api.example.com:443", "api.example.com"));
assertTrue(Http2Authority.matches("127.0.0.1:8443", "127.0.0.1"));
assertTrue(Http2Authority.matches("one.example.com", "*.example.com"));
assertFalse(Http2Authority.matches("example.com", "*.example.com"));
assertFalse(Http2Authority.matches("two.one.example.com", "*.example.com"));
assertFalse(Http2Authority.matches("other.example.net", "*.example.com"));
}
}
@@ -0,0 +1,37 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.http2.message.DataBufferPool;
import dev.relism.flash.http2.message.Http2RequestBody;
import dev.relism.flash.http2.stream.Http2FlowController;
import dev.relism.flash.http2.stream.Http2Stream;
import dev.relism.flash.http2.stream.Http2StreamTable;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class Http2BackpressureTest {
@Test
void windowUpdatesAreWithheldUntilTheHandlerConsumesQueuedData() throws Exception {
AtomicInteger updates = new AtomicInteger();
Http2FlowController flow =
new Http2FlowController((streamId, increment) -> updates.addAndGet(increment));
Http2Stream stream = new Http2StreamTable(1).acquire(1);
DataBufferPool pool = new DataBufferPool(Http2Limits.MAX_FRAME_SIZE_LOCAL, 32);
Http2RequestBody body = new Http2RequestBody(pool);
body.begin(-1, false, bytes -> flow.consumed(stream, bytes));
byte[] frame = new byte[Http2Limits.MAX_FRAME_SIZE_LOCAL];
for (int i = 0; i < 32; i++) {
flow.receiveConnectionBytes(frame.length);
flow.receiveStreamBytes(stream, frame.length);
body.offer(1, frame, 0, frame.length, frame.length);
}
assertEquals(0, updates.get(), "receiving alone must not reopen either window");
body.finish(1);
assertEquals(32L * frame.length, body.readAllBytes().length);
assertEquals(2 * 32 * frame.length, updates.get());
assertEquals(32, pool.availableCount());
}
}

Some files were not shown because too many files have changed in this diff Show More