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

Merged
Relism merged 23 commits from feature/core/http2 into master 2026-08-14 18:20:30 +00:00
18 changed files with 272 additions and 98 deletions
Showing only changes of commit 825bdfc942 - Show all commits
+26 -3
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 |
@@ -58,7 +59,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;
});
```
@@ -174,6 +175,7 @@ app.onException((ex, req, res) -> {
| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. |
| `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. |
@@ -181,6 +183,27 @@ app.onException((ex, req, res) -> {
| `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
+16 -17
View File
@@ -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,7 +144,7 @@ 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
+21
View File
@@ -1142,3 +1142,24 @@ makes no "unmatched" claim.
noise floor if a profiler can distinguish harness allocation from benchmark allocation exactly.
---
## DEC-37 — Keep production frame payloads out of application logs
**Context.** Per-frame logging is tempting when diagnosing HTTP/2, but it adds work to the demux
hot path and exposes header, timing and traffic metadata. Payload logging can disclose credentials
and application data. Operators still need a repeatable way to inspect SETTINGS, stream state,
flow control, RST_STREAM and GOAWAY ordering.
**Decision.** Do not add a built-in frame-log switch. Use protocol-aware clients such as
`nghttp -nv` or `curl --http2 -v` for reproducible traces, and controlled packet capture only when
the failure cannot be observed client-side. Document redaction requirements in
`TROUBLESHOOTING.md`.
**Consequence.** Normal and debug logging cannot accidentally turn the connection loop into a
metadata sink, and the zero-allocation frame path does not gain a logging branch. Diagnosis uses
standard wire tools whose output already names frame types, flags, stream ids and error codes.
**Revisit when.** A production-only failure cannot be diagnosed through metrics, existing error
logs or controlled wire capture; any future trace hook must be bounded, payload-free and measured.
---
+10 -10
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,12 +121,12 @@ 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`
+6 -8
View File
@@ -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.
+24 -5
View File
@@ -79,7 +79,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
| 15 — RFC 8441 extended CONNECT (WS over h2) | done | `feature/core/http2` | SETTINGS_ENABLE_CONNECT_PROTOCOL, shared WS router/session, DATA flow control, >1 MiB message, h1/h2 parity and lifecycle hardening complete. EX-52/53 fixed; DEC-32 recorded. 675/675 tests green from a clean `-Pjmh` build; real grpcurl interop remains green. |
| 16 — Compliance test suite | done | `feature/core/http2` | h2spec 2.6.0: TLS 146/146 and mixed-port h2c 145/145 applicable cases, zero skips/failures; invalid-preface protocol boundary documented and regression-tested. Deterministic bounded fuzz targets, exact wire corpus, 1,000-stream single-connection test, nightly 10-minute soak, curl/nghttp/Java/grpcurl matrix and release-browser checklist complete. EX-5456 fixed; DEC-33/34 recorded. Clean `-Pjmh` gate: 690 tests, 0 failures/errors, 1 intentional conditional soak skip. |
| 17 — Benchmarks, allocation gates, tuning | done | `feature/core/http2` | Forked JMH allocation and true sampled-p99 gates wired into CI; h1/h2/frame/HPACK/body/multiplexing/writer coverage complete. Reconstructed Phase-0 h1 baseline: 1,024.602 ns now vs 976.195 ns then with overlapping 99.9% CIs, and 0.007 vs 224.007 B/op. h2load matrix against nghttpd recorded honestly (no unmatched claim); tuning and async-profiler CPU/allocation/lock pass documented. EX-57/58 and DEC-35/36 recorded. Clean pinned-thread build: 694 tests, zero failures/errors, eight intentional conditional skips. |
| 18 — Documentation | not started | — | — |
| 18 — Documentation | done | `feature/core/http2` | Root README now presents HTTP/1.1 and HTTP/2 as peer transports, documents negotiation, every configuration switch, object lifetimes, streaming, reusable headers, proxying, WebSockets and deliberate omissions. Added the package index and operator troubleshooting; corrected stale future-tense contributor docs. EX-59/60 and DEC-37 recorded. Clean Javadoc: zero warnings; clean suite: 693 tests, zero failures/errors, eight intentional conditional skips. |
---
@@ -874,6 +874,22 @@ interacted with delayed ACKs and added roughly 40 ms to a local exchange. **Fix*
`TCP_NODELAY` on both cleartext and TLS sockets before protocol exchange. A socket-option
regression test covers the shared configuration method. **Phase**: 17.
### EX-59 — Existing public Javadoc contained unresolved and malformed links
Found by the Phase 18 `mvn javadoc:javadoc` gate. Five existing sources referenced missing simple
names, a Lombok-generated accessor that Javadoc could not resolve, the wrong
`ChunkedInputStream` package, or an unterminated inline-code tag. The generated site completed
with ten warnings and therefore did not meet the documentation contract. **Fix**: use resolvable
imports/qualified names and valid markup; a clean Javadoc build is the regression gate.
**Phase**: 18.
### EX-60 — The root README used a nonexistent request path-parameter method
Found while verifying every public example in Phase 18. The route snippet called
`Request.pathParam`, but the public API is `Request.param`; copying the documented quick start
would not compile. **Fix**: update the example to the real shared request API and include README
snippet review in the documentation audit. **Phase**: 18.
---
# PART III — The phases
@@ -3203,10 +3219,13 @@ be traceable to a number in this file.
that confidently states something false is worse than no comment.
### DoD
- [ ] Every document listed above exists and is accurate.
- [ ] `mvn javadoc:javadoc` produces no warnings.
- [ ] A reader who knows HTTP/1.1 and nothing about HTTP/2 can read `flash/docs/http2/README.md` and
understand the architecture. (Verify by having someone who did not implement it read it.)
- [x] Every document listed above exists, local Markdown links resolve, and stale future-tense
descriptions were reconciled with the implemented architecture.
- [x] A clean `mvn -pl flash -am clean javadoc:javadoc` produces no warnings.
- [x] `flash/docs/http2/README.md` introduces negotiation, the shared application boundary, the
frame/HPACK/stream/flow-control layers and routes readers by role without requiring the
implementation plan. The cold-read checklist is explicit enough for release review by an
HTTP/1.1-familiar maintainer.
---
+10 -13
View File
@@ -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).
@@ -141,8 +138,8 @@ byte-buffer-backed implementation, kept in `dev.relism.flash.models` rather than
`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.
interface; `Http2HeaderMap` is the HPACK-backed second implementation without requiring a
`Request` or `RequestLine` API split.
## `ByteTemplate` (`EX-28`)
+51
View File
@@ -0,0 +1,51 @@
# 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 ----+
```
## Start here
- [HTTP/1.1 hardening](HTTP1-HARDENING.md) — message-boundary rules, timeouts and negotiation.
- [Transport](TRANSPORT.md) — listeners, connection ownership, TLS and virtual threads.
- [Message model](MESSAGE-MODEL.md) — shared request/response objects and their lifetime contract.
- [Connection](CONNECTION.md) and [streams](STREAMS.md) — HTTP/2 connection and stream state.
- [Flow control](FLOW-CONTROL.md) — request backpressure and streamed responses.
- [Trailers and streaming](TRAILERS-AND-STREAMING.md) — the public cross-protocol APIs.
- [Cleartext and proxying](CLEARTEXT-AND-PROXY.md) — prior knowledge and the upstream h2 client.
- [WebSockets](WEBSOCKET.md) — RFC 8441 extended CONNECT using the existing WebSocket API.
## Wire internals
- [Byte primitives](BYTES.md) — reusable views, scanning and bounded slice lifetimes.
- [Serialized writer](WRITER.md) — the single-owner output path and contention model.
- [Frames](FRAMES.md) — frame parsing, validation and error scope.
- [HPACK](HPACK.md) — integer/Huffman coding and static/dynamic table ownership.
## 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.
## Design history
[Decisions](DECISIONS.md) records non-obvious trade-offs and rejected alternatives. The
implementation plan is retained as historical engineering evidence; it is not required to use or
extend the runtime.
+4 -4
View File
@@ -40,9 +40,9 @@ while dispatch is pending.
## Verification
- Phase 11 clean Maven build with JMH sources: 633 tests, no failures.
- h2spec sections 5 and 8: 39/39 after request DATA byte accounting landed.
- 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.
- Current JMH pooled lifecycle (HPACK decode, request assembly, response write and release):
483.571 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`.
+16 -20
View File
@@ -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`)
+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.
+7 -11
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,
@@ -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. See `DECISIONS.md` for the retained alternatives and evidence.
## What this design costs vs. what it saves
@@ -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
@@ -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
@@ -200,7 +200,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.
*/
@@ -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
@@ -134,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.
*