diff --git a/README.md b/README.md index 2d81bf0..bce2420 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,6 @@ a zero-allocation FSM router, bounded protocol state, and one shared request/res | `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension | -| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) | ## Requirements @@ -169,10 +168,11 @@ app.onException((ex, req, res) -> { | `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) | | `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) | | `wsFrameBufferSize` | `65536` | Per-connection WebSocket read buffer (bytes) | -| `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/http2/HTTP1-HARDENING.md). | +| `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/core/HTTP1-HARDENING.md). | | `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. | | `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. | | `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. | +| `maxConnections` | auto (~heap/10MB) | Maximum concurrent connections across all listeners before new ones are closed immediately at accept time, before any per-connection state (TLS handshake included) is created. Auto-scales from `Runtime.maxMemory()`; set explicitly for a known deployment size, or `0` to disable. | | `http2Enabled` | `false` | Whether TLS listeners advertise HTTP/2 through ALPN. | | `http2CleartextEnabled` | `false` | Whether plaintext listeners accept HTTP/2 prior knowledge (h2c). Independent from TLS HTTP/2. | | `h2HuffmanDynamicValues` | `false` | HPACK-Huffman encode runtime response values. Constants remain pre-encoded; the measured default avoids an extra encode pass. | @@ -310,7 +310,7 @@ upgrading `Request` — no separate TLS state is tracked for WS. `Request` and `Response` are **pooled per connection**, not allocated per request: one instance is created per connection and repositioned (`reset()`) over each new request/response in turn — the same idiom Java NIO buffers use, applied to the whole request/response model -(`flash/docs/http2/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1 +(`flash/docs/core/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1 request/response cycle 0 B/op. **Do not retain a `Request` or `Response` past the handler that received it.** A reference kept in @@ -389,20 +389,6 @@ The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a future `flash-ext-grpc` extension. -### HTTP/2 upstream proxy - -The core includes a deliberately small, proxy-oriented HTTP/2 client and a protocol-neutral relay: - -```java -Http2Client upstream = new Http2Client(); -app.post("/service/{path}", - HttpProxy.toHttp2(URI.create("http://service.internal:8080"), upstream)); -``` - -The relay preserves the path, query, body and trailers and applies one shared hop-by-hop field -policy for HTTP/1.1 and HTTP/2. Close the client when the application stops. Cleartext upstreams -use prior knowledge; Flash never implements the obsolete `Upgrade: h2c` mechanism. - ## Architecture ``` @@ -433,7 +419,4 @@ mvn test # Run a single test class mvn test -pl flash -Dtest=RequestParserTest - -# Run the benchmark demo server -java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar ``` diff --git a/flash/docs/http2/BYTES.md b/flash/docs/core/BYTES.md similarity index 95% rename from flash/docs/http2/BYTES.md rename to flash/docs/core/BYTES.md index 6613a60..5325394 100644 --- a/flash/docs/http2/BYTES.md +++ b/flash/docs/core/BYTES.md @@ -150,9 +150,9 @@ deleting a case that only test code could exercise. `Http1HeaderMap.view` has no ## `EX-19`/`EX-06` (router half): the `FastPathRouterImpl` scratch `FastPathRouterImpl.RouteScratch` (created once per connection via `AbstractRouter#newScratch`, -replacing the `ThreadLocal`/`ThreadLocal` 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`/`ThreadLocal` pair, as an opaque +caller-owned object rather than an extension of `ConnectionScratch`) also owns the reusable +path-param arrays and a single long-lived `PathParams` instance, grown (via `ensureParamCapacity`, doubling) to the largest param count any route on that connection has ever matched, and repositioned (`PathParams#reset`) rather than reallocated on every match. `PathParams` gained a second, count-explicit constructor and a public @@ -172,7 +172,6 @@ escapes, and mixed queries (`QueryParamsFastPathTest`). ## Performance measurement -`EX-04` (the router's word-at-a-time path) and `EX-33` (the SWAR header-end scan) both carry an -explicit "measure, and keep only if it doesn't cost" instruction in the plan. Both are measured -together with the phase's overall zero-allocation contract in one JMH pass — see `DECISIONS.md`, -`DEC-20`, for the numbers and the keep/revert decision for each. +`EX-04` (the router's word-at-a-time path) and `EX-33` (the SWAR header-end scan) both carried an +explicit "measure, and keep only if it doesn't cost" requirement. Both were measured together +with the phase's overall zero-allocation contract in one JMH pass, and both were kept. diff --git a/flash/docs/http2/HTTP1-HARDENING.md b/flash/docs/core/HTTP1-HARDENING.md similarity index 100% rename from flash/docs/http2/HTTP1-HARDENING.md rename to flash/docs/core/HTTP1-HARDENING.md diff --git a/flash/docs/http2/MESSAGE-MODEL.md b/flash/docs/core/MESSAGE-MODEL.md similarity index 95% rename from flash/docs/http2/MESSAGE-MODEL.md rename to flash/docs/core/MESSAGE-MODEL.md index 3fb91c7..4436cee 100644 --- a/flash/docs/http2/MESSAGE-MODEL.md +++ b/flash/docs/core/MESSAGE-MODEL.md @@ -135,9 +135,9 @@ back down between requests. Both checks throw `IllegalStateException`, not `HeaderMap` split into `HeaderView` (the protocol-neutral read contract: `first`, `all`, `view`, `valueEqualsIgnoreCase`, `contains`, `count`, `forEach`) and `Http1HeaderMap` (the existing byte-buffer-backed implementation, kept in `dev.relism.flash.models` rather than moved to -`dev.relism.flash.http1` — see `DECISIONS.md`, `DEC-22`, for why: `RequestParser` (root package) -owns and constructs it, and `http1`→root already exists via `Http1Connection`, so moving it to -`http1` would create a `models`↔`http1` package cycle). `RequestLine.headers` is typed as the +`dev.relism.flash.http1`: `RequestParser` (root package) owns and constructs it, and +`http1`→root already exists via `Http1Connection`, so moving it to `http1` would create a +`models`↔`http1` package cycle). `RequestLine.headers` is typed as the interface; `Http2HeaderMap` is the HPACK-backed second implementation without requiring a `Request` or `RequestLine` API split. @@ -178,7 +178,7 @@ call — pre-existing since at least Phase 4, invisible until the larger `Reques `RequestLine` cost sitting on top of them was removed. Fixed the same way as everything else in this document: `RequestByteView` gained a `reset(byte[], int, int)`; `RequestParser` now owns one pooled instance per role. `parseAndRoute` measures 0.008 B/op after the fix — JMH's noise floor, -effectively 0. Full numbers in `DECISIONS.md`, `DEC-23`. +effectively 0. ## The zero-alloc contract, closed @@ -188,6 +188,4 @@ effectively 0. Full numbers in `DECISIONS.md`, `DEC-23`. `RequestPipelineBenchmark.parseAndRoute` (parse + route with a parametric match, no header/param access) measures 0 B/op. `parseRouteAndExtractThreeFields` (the same, plus one path param and two header reads) measures 184.009 B/op — entirely the `String` allocations the contract's own text -exempts ("except for the user-facing `String`s the handler explicitly asks for"). See -`DECISIONS.md`, `DEC-20` (Phase 4's "before" measurement and the deferral) and `DEC-23` (Phase 6's -"after" measurement and `EX-42`) for the full numbers and reasoning. +exempts ("except for the user-facing `String`s the handler explicitly asks for"). diff --git a/flash/docs/core/README.md b/flash/docs/core/README.md new file mode 100644 index 0000000..9e62071 --- /dev/null +++ b/flash/docs/core/README.md @@ -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. diff --git a/flash/docs/http2/TRAILERS-AND-STREAMING.md b/flash/docs/core/TRAILERS-AND-STREAMING.md similarity index 100% rename from flash/docs/http2/TRAILERS-AND-STREAMING.md rename to flash/docs/core/TRAILERS-AND-STREAMING.md diff --git a/flash/docs/http2/TRANSPORT.md b/flash/docs/core/TRANSPORT.md similarity index 100% rename from flash/docs/http2/TRANSPORT.md rename to flash/docs/core/TRANSPORT.md diff --git a/flash/docs/http2/CLEARTEXT-AND-PROXY.md b/flash/docs/http2/CLEARTEXT.md similarity index 50% rename from flash/docs/http2/CLEARTEXT-AND-PROXY.md rename to flash/docs/http2/CLEARTEXT.md index 7c012fe..2ff8583 100644 --- a/flash/docs/http2/CLEARTEXT-AND-PROXY.md +++ b/flash/docs/http2/CLEARTEXT.md @@ -1,4 +1,4 @@ -# HTTP/2 cleartext and proxying +# HTTP/2 cleartext TLS HTTP/2 and cleartext HTTP/2 have independent rollout controls: @@ -8,18 +8,6 @@ TLS HTTP/2 and cleartext HTTP/2 have independent rollout controls: Both default to `false`. Cleartext support follows RFC 9113 prior knowledge. The obsolete HTTP/1.1 `Upgrade: h2c` transition is intentionally unsupported. -## Upstream client - -`Http2Client` is a synchronous, pooled client for reverse-proxy handlers. It supports TLS ALPN and -h2c prior knowledge, request and response bodies, flow control, response status, trailers, -SETTINGS, PING, GOAWAY and RST_STREAM. Connections are pooled by origin and reused across -sequential exchanges. A connection serializes its exchanges deliberately; this keeps ownership -and HPACK state explicit and bounded while virtual threads allow independent origins to progress. -It is not intended to replace a general-purpose HTTP client. - -`HttpProxy.toHttp2(origin, client)` adapts Flash's shared `Request` and `Response` models to that -client. It preserves the incoming raw path and query, body, end-to-end fields and trailers. - ## Header conversion `HopByHopHeaders` is the single policy used at connection boundaries. It removes fields named by @@ -34,9 +22,8 @@ subject alternative names. An authority outside that served set receives `421 Mi Request`, allowing a coalescing client to retry on a different connection. Exact names and single-label wildcards are supported; h2c has no certificate identity and is unaffected. -## Trailer guarantee - -The proxy copies request trailers only after the incoming body reaches EOF and emits upstream -trailers as a trailing HEADERS block. Response trailers follow the reverse path and remain -trailers on both HTTP/2 and HTTP/1.1 chunked downstream connections. The live relay tests cover -both downstream protocols. +An outbound HTTP/2 client and reverse-proxy adapter (`Http2Client`, `HttpProxy`) were built +against this cleartext support but had no caller anywhere in `flash` core — an HTTP/1.1+2 server +framework has no business shipping an outbound client. That code has been removed; if a +reverse-proxy capability is needed later, it belongs in its own `flash-extensions/flash-ext-*` +module, not in core. diff --git a/flash/docs/http2/DECISIONS.md b/flash/docs/http2/DECISIONS.md deleted file mode 100644 index 1abb371..0000000 --- a/flash/docs/http2/DECISIONS.md +++ /dev/null @@ -1,1165 +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.8–14.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.6–2.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.5–6.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 `` 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 ``. 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 -`` 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 - 2–3), 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 1–3, 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`/`ThreadLocal` (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` 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`, 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 2–7 -(`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 9–11. 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 9–11 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 9–11 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. - ---- - -## DEC-28 — Align the receive window with a coalescing bounded DATA pool - -**Context.** The demultiplexer cannot block waiting for an application handler, but delaying -WINDOW_UPDATE only provides backpressure after the peer has spent the window it already owns. A -pool smaller than that outstanding credit can be exhausted legitimately. Allocating one buffer per -DATA frame is also unsafe because many tiny or heavily padded frames can consume little payload -storage while exhausting an object-per-frame pool. - -**Decision.** Advertise 1 MiB at both the connection and stream receive levels and back the -connection with exactly 64 reusable 16 KiB buffers (also 1 MiB). Adjacent DATA payloads coalesce -into available tail space; padding contributes to flow credit but not storage. WINDOW_UPDATE is -sent at half-window consumption, never merely on receipt. Bodies at or below 64 KiB with a known -length use one reusable contiguous stream buffer and dispatch at END_STREAM; all other bodies -dispatch immediately onto the same protocol-neutral `RequestBody` over a blocking pooled source. - -Response DATA uses the same serialized writer with a progress cursor. A stream object itself is -the executor task for initial handling and resumptions, so no per-resume closure is created. -WINDOW_UPDATE only schedules work; it never reads an application `InputStream` on the demux thread. - -**Consequence.** Outstanding peer credit and worst-case pooled payload storage match exactly, -small frames do not multiply objects, slow consumers withhold credit naturally, and streaming -responses resume without recursive writer completion or demux blocking. The 100 MiB bidirectional -integration test remains bounded, while JMH measures the streaming request and response paths at -the allocation noise floor. - -**Revisit when.** Production memory/throughput measurements justify a different window. Change the -window and pool byte capacity together; never raise credit independently of bounded storage. - ---- - -## DEC-29 — Keep TLS HTTP/2 opt-in until the compliance gate - -**Context.** Trailers and push streaming make the protocol feature-complete for ordinary and gRPC- -shaped traffic, but the dedicated rate-based and composite abuse controls are deliberately owned -by the following security phase. - -**Decision.** Keep `FlashConfiguration.http2Enabled` defaulting to `false`. Phase 14 separates -cleartext behind its own `http2CleartextEnabled` opt-in, also defaulting to `false`. Passing the -hostile-peer gate removes the security blocker, but changing the TLS default remains deferred -until the complete external conformance gate is green. - -**Consequence.** Existing deployments do not silently expose a newly completed protocol before its -adversarial gate. This is rollout sequencing, not an architectural separation: both protocols use -the same public request/response, header, trailer and streaming APIs. - -**Revisit when.** At Phase 16 closure, after the external compliance matrix is green. - ---- - -## DEC-30 — Rate-limit aggregate non-progress work as one class - -**Context.** SETTINGS, PING, PRIORITY, WINDOW_UPDATE, empty DATA and unknown extension frames have -different wire semantics but share the abuse property that they can consume parser/control work -without advancing an application message. Separate limits leave gaps when an attacker alternates -frame types below every individual threshold. - -**Decision.** Keep dedicated lower limits for mandatory SETTINGS and PING replies, plus one -connection-owned two-bucket counter for the aggregate non-progress class. RST_STREAM and stream -creation retain dedicated CVE-2023-44487 counters because their expensive effect is stream -lifecycle churn, not merely frame parsing. - -**Consequence.** Mixed floods are bounded without six timers or maps. All counters are fixed fields -on the connection, use `System.nanoTime()`, allocate nothing per increment and require no reaper -thread. A fixed control-intent pool and one-in-flight intent per live stream bound write queues. - -**Revisit when.** Production telemetry shows legitimate control-heavy traffic approaching the -aggregate default; tune the threshold from evidence without splitting the defence by frame type. - ---- - -## DEC-31 — Keep the upstream HTTP/2 client proxy-oriented and single-owner - -**Context.** A general-purpose HTTP client would introduce a second large public API, redirect, -cookie, authentication and retry policy, while the immediate requirement is a reliable Flash -reverse-proxy hop with trailers. - -**Decision.** Pool one reusable connection per origin and serialize exchanges on that connection. -Reuse the core frame reader/writer and HPACK codec, but keep response assembly and ownership inside -the client connection. Expose `HttpProxy.toHttp2` as the protocol-neutral adapter and one shared -`HopByHopHeaders` policy for every conversion direction. - -**Consequence.** HPACK and socket state have one clear owner, upstream connections are reused, and -trailer semantics cannot diverge by downstream protocol. Concurrent calls to one origin queue -behind its active exchange rather than pretending this minimal client is a fully multiplexed -general-purpose stack. - -**Revisit when.** Proxy production traces show per-origin serialization is a bottleneck; add a -bounded pool or client-side multiplexing without changing the proxy-facing API. - ---- - -## DEC-32 — Reuse the WebSocket router and session for extended CONNECT - -**Context.** RFC 8441 changes the HTTP handshake and transport framing, but not the application -route, RFC 6455 message semantics, or handler lifecycle. Introducing an HTTP/2-specific router, -handler, or session would duplicate public and internal behavior. - -**Decision.** Validate CONNECT and `:protocol` at the HTTP/2 wire boundary, then expose a -`websocket` extended CONNECT as GET only while resolving the existing `AbstractWsRouter` route. -Feed request DATA to the existing `WebSocketSession` and adapt the protocol-neutral -`ResponseStream` to its `OutputStream` contract. Publish response HEADERS in their own first batch -so the full-duplex producer cannot block the handshake while waiting for request DATA. - -**Consequence.** One `ws(path, handler)` registration behaves the same on HTTP/1.1 and HTTP/2; -masking, fragmentation, callbacks, and close handling have one implementation. HTTP/2 contributes -only pseudo-header validation and DATA flow control, while the shared response bridge remains -usable by other streaming adapters. - -**Revisit when.** Only if a future WebSocket transport cannot be represented by the existing -stream pair without losing protocol semantics. - ---- - -## DEC-33 — Retain bounded closed-stream provenance - -**Context.** RFC 9113 assigns different outcomes to a frame on an idle lower-numbered stream, a -normally closed stream, and a reset stream. Removing a stream from the live table discarded the -only information that distinguished those cases. - -**Decision.** Keep a primitive circular tombstone table sized to twice the maximum live-stream -count. Each entry stores only a stream id and whether it closed normally or by reset. - -**Consequence.** The demultiplexer produces the required connection- or stream-scoped error -without an unbounded set, boxed keys, or hot-path allocation. Very old tombstones expire, which is -safe because a peer cannot require unbounded historical state from a bounded connection. - -**Revisit when.** Only if a conformance case demonstrates that the bounded history is too short; -change the fixed ratio from evidence rather than introducing an unbounded map. - ---- - -## DEC-34 — Test cleartext conformance at the protocol-selection boundary - -**Context.** h2spec's invalid-preface case assumes a dedicated HTTP/2 socket. Flash intentionally -multiplexes HTTP/1.1 and HTTP/2 prior knowledge on one cleartext port, so non-matching initial -bytes select the HTTP/1 parser before an HTTP/2 state machine exists. - -**Decision.** Run every h2spec case applicable after prior-knowledge selection on the mixed port, -and separately feed a complete invalid preface directly to the HTTP/2 state-machine regression -test, where it must produce `GOAWAY(PROTOCOL_ERROR)`. - -**Consequence.** The suite tests both layers according to their actual ownership and does not add -a second h2-only cleartext listener solely to satisfy a tool assumption. - -**Revisit when.** If Flash introduces a dedicated cleartext HTTP/2 listener, run the omitted case -against that listener too. - ---- - -## DEC-35 — Separate live-stream admission from final-write ownership - -**Context.** A stream becomes closed on the wire before the asynchronous serialized writer calls -back for its final batch. Counting that object as live rejects legal replacement streams; pooling -it before the callback lets the next stream mutate memory still referenced by the writer. - -**Decision.** Detach a wire-closed stream from the primitive live table immediately before its -final batch is submitted, but retain the stream object until write completion. Bound the combined -live and detached population to twice `MAX_CONCURRENT_STREAMS`; output congestion therefore -remains bounded and eventually applies `REFUSED_STREAM` backpressure rather than growing memory. - -**Consequence.** The peer can use all advertised live-stream slots while final writes drain, and -the callback always owns the correct object generation. The closed-stream tombstone is recorded -at detach time, so protocol error classification is unchanged. - -**Revisit when.** If production traces show the two-generation object bound rejecting healthy -traffic, measure writer-drain latency first; increasing the bound without evidence would only hide -output backpressure. - ---- - -## DEC-36 — Performance gates distinguish profiler noise, latency sampling, and load results - -**Context.** JMH's sampling mode allocates bookkeeping records, so combining `Mode.SampleTime` -with `GCProfiler` falsely reports allocations on otherwise allocation-free operations. End-to-end -h2load results also show that Flash does not outperform the reference server, so the plan's -"unmatched" wording cannot honestly become a product claim. - -**Decision.** Run two independent forked CI passes over the same six hot paths: average-time plus -`GCProfiler` for allocation, and sample-time without the allocation profiler for p50/p99/p999. -Treat up to 0.05 B/op with zero observed collections as the profiler's measurement floor. Gate -p99 with documented per-benchmark ceilings and keep h2load comparative results informational. - -**Consequence.** CI detects real allocation and latency regressions without measuring its own -sampling machinery. Performance documentation reports Flash and nghttpd numbers directly and -makes no "unmatched" claim. - -**Revisit when.** Recalibrate baselines deliberately on a controlled CI runner, or replace the -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. - ---- diff --git a/flash/docs/http2/FRAMES.md b/flash/docs/http2/FRAMES.md index d0cc738..397c182 100644 --- a/flash/docs/http2/FRAMES.md +++ b/flash/docs/http2/FRAMES.md @@ -131,8 +131,7 @@ needing. `BufferedByteSource`'s deadline mechanism (`EX-07`'s actual fix) turned out to have zero dedicated unit tests and an unconditional `socket.setSoTimeout(...)` call that NPE'd against the `null` socket every isolated unit test in this codebase uses. Found while writing -`Http2FrameReaderTest`, fixed, and given its own regression suite (`BufferedByteSourceTest`) — -full writeup in the plan's registry, `EX-37`. +`Http2FrameReaderTest`, fixed, and given its own regression suite (`BufferedByteSourceTest`). ## Testing diff --git a/flash/docs/http2/IMPLEMENTATION-PLAN.md b/flash/docs/http2/IMPLEMENTATION-PLAN.md deleted file mode 100644 index da7cbb7..0000000 --- a/flash/docs/http2/IMPLEMENTATION-PLAN.md +++ /dev/null @@ -1,3434 +0,0 @@ -# Flash — HTTP/2 Implementation Plan - -> **Status**: working implementation ledger; it is not product documentation or an API contract. -> **Target branch**: `feature/core/http2` -> **Target module**: `flash` (core). HTTP/2 is a transport concern and must live where -> `HttpServer` lives; it cannot be an extension. -> **Target package root**: `dev.relism.flash.http2` -> **Java baseline**: 21 (`maven.compiler.source/target=21` in the root `pom.xml`). Every -> decision in this document assumes Java 21 semantics, in particular that -> **`synchronized` pins the carrier thread of a virtual thread** (JEP 491, which removes -> pinning, only lands in JDK 24 — we cannot rely on it). - ---- - -## How to read this document - -This plan is written for an agent (or engineer) who will implement it end to end, possibly -across many sessions, without further clarification. It is deliberately verbose and -deliberately repetitive: **every phase restates the constraints it must satisfy**, so that a -phase can be picked up in isolation without re-reading the whole document. - -Structure: - -- **Part I** — Non-negotiable rules that apply to every phase. -- **Part II** — The defect/optimization registry (`EX-nn`) for **existing** code. These are - real problems found by reading the current codebase. Each is assigned to a phase. -- **Part III** — The phases themselves, in strict dependency order. -- **Part IV** — Testing strategy. -- **Part V** — Documentation deliverables. -- **Part VI** — Appendices: RFC constant tables, checklists, decision log. - -Every phase has: - -| Field | Meaning | -|---|---| -| **Goal** | One sentence. What exists after this phase that did not before. | -| **Why now** | Dependency justification. Why this phase cannot come later or earlier. | -| **Files** | Created / modified / deleted, with full paths. | -| **Tasks** | Numbered, atomic, verifiable. | -| **EX items** | Existing-code defects addressed in this phase. | -| **Zero-alloc contract** | What must allocate zero on the steady-state path, and what may not. | -| **Safety checks** | Validation that must be present. Omission is a bug, not a TODO. | -| **Tests** | What must be green before the phase is considered done. | -| **Docs** | Documentation that must be written/updated in the same PR. | -| **DoD** | Definition of Done — a binary checklist. | - -**Nothing in a phase's DoD may be deferred to a later phase.** If a task turns out to be -bigger than expected, split the phase; do not carry debt forward. - ---- - -## Progress Ledger - -This table is the single source of truth for where the project stands. It is updated **at the -moment** work happens, not at the end of a session: mark a phase `in progress` when it is -started, tick DoD checkboxes as they are actually verified, and update the `Notes` column with -the exact resume point — task number, file, what is missing — whenever a phase is left -incomplete. Anyone picking this up cold must be able to continue from the `Notes` column alone. - -Status values: `not started` / `in progress` / `blocked` / `done`. - -| Phase | Status | Branch/PR | Notes | -|---|---|---|---| -| 0 — Groundwork | done | `feature/core/http2` | Package skeleton, `Http2Limits`, `Http1Limits`, `Http2ErrorCode`, `Http2Exception`/`Http2StreamException`, and `DECISIONS.md`. 226/226 tests green. | -| 1 — HTTP/1.1 hardening + ALPN/preface | done | `feature/core/http2` | EX-02/03/07/08/10/17/18/30/31 fixed; EX-35/36 found+fixed. `BufferedByteSource`, `ProtocolNegotiator`, `MalformedRequestException` added (plan corrected, DEC-12). 277/277 tests green (run twice). h1 benchmark check deferred — no JMH harness until Phase 3 (documented in DoD). | -| 2 — Transport decomposition | done | `feature/core/http2` | `HttpServer.java` deleted; `transport`/`http1` packages + WS extraction (EX-01/06/11/12/13/14/15/16/32/34) done. Router `ThreadLocal` (EX-06 router half) deliberately deferred to Phase 4 per DEC-15. 311/311 tests green (run 3×). h1 benchmark check deferred — no JMH harness until Phase 3. | -| 3 — Serialized frame writer (GO/NO-GO gate) | done | `feature/core/http2` | `Http2FrameWriter`/`WriteIntent`/`IntrusiveMpscQueue` + `Http2FrameWriterTest`/`Http2FrameWriterStressTest` + `FrameWriterBenchmark` (JMH, `-Pjmh`, `src/jmh/java` — moved there from `src/test/java` after it broke default `mvn test`; see `DEC-17`). All 4 gate criteria met: N=1 0 B/op & 42.6 ns overhead (≤50 ns budget); N=64 65.5% throughput retention (≥60%) & 11.8–14.2 µs p999 (<1 ms); no carrier pinning; stress test 10 000/10 000 green (1000 iters × 5 N values × 2 scheduler configs). Full numbers in `WRITER.md`, `DEC-09`. 321/321 non-JMH tests green. | -| 4 — Byte-layer foundations | done | `feature/core/http2` | `dev.relism.flash.bytes` package (`ByteScan`+SWAR, `ArrayBackedByteView`, `SegmentedByteView`, `PooledSlice`/`SlicePool`, `ByteWriter`, `Pairs`) built. `EX-04`/`EX-05`/`EX-09`/`EX-19`/`EX-25`/`EX-26`/`EX-33` done, plus `EX-06`'s router half (plan correction, `DEC-19`) removing `FastPathRouterImpl`/`FastPathWsRouterImpl`'s `ThreadLocal`s via an opaque per-connection scratch (`AbstractRouter#newScratch`) instead of extending `ConnectionScratch` (would have created a `routing`→`transport` package cycle). `AbstractRouter`/`AbstractWsRouter.route()` gained a `scratch` param — all call sites updated. Measured (`DEC-20`): SWAR scan 35.4% faster (kept), `EX-04`'s word-path 32.1% faster at the mechanism level (kept; today's router doesn't route through it — `MethodPathByteView` stays non-array-backed by design). Router matching itself is ≈0 B/op including parametric routes. Full h1 pipeline is 120.008 B/op, 100% attributable to `Request`/`RequestBody`/`RequestLine` construction — explicitly Phase 6 scope, not a Phase 4 regression. Two documented (non-hot-path) anonymous-`ByteView` fallbacks remain in `QueryParams`/`PathParams.view`. `BYTES.md` written. 395/395 tests green (both with and without `-Pjmh`). | -| 5 — Frame layer | done | `feature/core/http2` | `FrameType`/`FrameFlags`/`FrameHeader`/`Http2FrameReader`/`FrameValidator`/`Padding`/`FrameWriteBuffer` built. All 10 frame types read/validated/written; per-type RFC error codes verified individually (`FrameValidatorTest`); fuzz-tested 10M random inputs (~14s, green). Zero-alloc contract measured, not asserted: read+validate+consume 0.002 B/op, write ≈10⁻⁴ B/op (`DEC-21`). Found+fixed `EX-37` (`BufferedByteSource`'s deadline mechanism NPE'd against a `null` socket — zero prior test coverage of `EX-07`'s own fix; added `BufferedByteSourceTest`). `FRAMES.md` written. 449/449 tests green. | -| 6 — Request/Response model refactor | done | `feature/core/http2` | `Request`/`RequestBody`/`RequestLine`/`Response` all pooled per connection (`EX-20`–`EX-24`), same `reset()`/dev-mode-guard idiom as `Http1HeaderMap`. `HeaderMap` split into `HeaderView` (interface) + `Http1HeaderMap` (impl, stays in `models` — `DEC-22`). `Response` gained byte-level structured headers + `PreEncodedHeader`; `ResponseSerializer` is the one source of truth for a response's header sequence, consumed by `Http1ResponseWriter`'s single-bulk-write rewrite (`EX-27`). `ByteTemplate` fixed to O(1) slot lookup + a buffer-writing overload (`EX-28`). `Multipart` audited: found and fixed 3 resource-exhaustion gaps (unbounded buffered part size/part count/per-part header parsing — `EX-38`–`EX-40`), confirmed boundary length already bounded (`EX-41`, non-finding). Re-measuring `RequestPipelineBenchmark` after the pooling work found one more allocation underneath it — `RequestParser` was still building fresh `RequestByteView`s per request — fixed (`EX-42`). Zero-alloc contract closed: `parseAndRoute` 120.008 → 0.008 B/op (`DEC-20`/`DEC-23`). Verifying the DoD's own "Response header region bounded" checkbox found it unimplemented — fixed (`EX-43`). `MESSAGE-MODEL.md` written; README gained an "Object lifetime" section. 503/503 tests green. | -| 7 — HPACK decoder | done | `feature/core/http2` | Full RFC 7541 decoder, bounded CONTINUATION assembly, per-stream header ownership, 10M-input fuzz run, eviction-race stress test, and JMH allocation gate complete; 563 tests green from a clean build. | -| 8 — Connection state machine | done | `feature/core/http2` | Preface, transactional SETTINGS, priority PING ACK, connection WINDOW_UPDATE, two-stage GOAWAY, per-socket transport/ALPN dispatch, HPACK block composition, clean curl handshake, h2spec 28/35 selected cases and 0.008 B/op JMH gate complete. Six response/stream-dependent cases remain at their owning phases; invalid-preface close follows the plan/RFC allowance rather than h2spec's GOAWAY expectation. | -| 9 — HPACK encoder + h2 response path | done | `feature/core/http2` | Stateless static-table HPACK encoder; precompiled status/content-type/date fields; reusable response writer with header filtering, bounds, CONTINUATION splitting and fixed DATA happy path; HTTP/1/2 serializer parity test. EX-46 fixed the one-digit Date day-of-month bug. JMH: 174.309 ns/op, 0.001 B/op (noise floor), no GC. 603/603 tests green from a clean `-Pjmh` build. | -| 10 — Stream state machine + dispatch | done | `feature/core/http2` | Explicit stream transition table, bounded primitive stream table and pool, pseudo-header/message validation, protocol-neutral `Request` assembly, virtual-thread dispatch and exception path, cancellation-safe release, raw h2c + Java HTTP/2 integration. Phase 11 closed the two deferred content-length/DATA cases; h2spec sections 5/8 are now 39/39. JMH pooled lifecycle: 458.499 ns/op, 0.003 B/op, no GC. 618/618 tests green at phase closure. | -| 11 — DATA, flow control, bodies | done | `feature/core/http2` | Two-level receive/send flow control, consumption-driven WINDOW_UPDATE hysteresis, bounded/coalescing DATA pool, inline and blocking streaming request bodies through the existing `RequestBody`, resumable fixed/known/unknown response streams, content-length and empty-DATA validation. Real TLS HTTP/2 transfer: 100 MiB upload + 100 MiB download verified byte-for-byte. h2spec combined sections 5, 6.1, 6.9 and 8: 50 passed, 1 tool-skipped, 0 failed. JMH: inline materialization exactly one 1,040-byte array; request streaming 0.001 B/op; response streaming 0.002 B/op; full pooled lifecycle 0.003 B/op. 633/633 tests green from a clean `-Pjmh` build. | -| 12 — Trailers, half-close, gRPC | done | `feature/core/http2` | Protocol-neutral request/response trailers, bounded push streaming, four half-close orderings and authority-form CONNECT tunnels complete. Real grpcurl 1.9.3 unary/server-streaming/error interop passes. EX-48/49 fixed. HTTP/2 remains opt-in until the Phase 13 hostile-peer gate (DEC-29). 649/649 tests green from a clean `-Pjmh` build. | -| 13 — Security hardening & abuse resistance | done | `feature/core/http2` | Two-bucket Rapid Reset/stream/settings/ping/aggregate counters, control/write queue bounds, optional stream/byte/lifetime budgets, absolute header and idle-stream deadlines, and hostile-peer suite complete. Security review found+fixed EX-50/51. JMH counter: 38.083 ns/op, ~10^-4 B/op, no GC. 100k-CONTINUATION attack terminates in under 2 s with bounded retained heap. 663/663 tests green from a clean `-Pjmh` build. | -| 14 — h2c prior knowledge + proxy support | done | `feature/core/http2` | Independent TLS/h2c gates, pooled proxy-oriented h2 client with TLS ALPN and h2c, bidirectional h1/h2 trailer relay, shared four-direction hop-by-hop policy and certificate-backed 421 handling complete. Real grpcurl h2c interop passes. 670/670 tests green from a clean `-Pjmh` build. | -| 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-54–56 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 | 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. | - ---- - -# PART I — Non-negotiable rules - -These apply to **every line of code written or touched** by this plan, including refactors of -existing code. - -## R1. Coexistence, not monkey-patching - -HTTP/1.1 and HTTP/2 are two peers of the same abstraction, not a base case and a special case. - -- **No `if (isHttp2)` branches inside HTTP/1.1 code paths.** The protocol decision is made - **once**, immediately after ALPN/preface detection, and dispatches to a - `ConnectionProtocol` implementation. After that point neither implementation knows the - other exists. -- The HTTP/1.1 code path after this work must be **measurably no slower** than before it. - This is enforced by benchmark gates (Phase 17). If an abstraction costs h1 throughput, the - abstraction is wrong, not the benchmark. -- Shared code (byte scanning, buffer pools, the writer discipline, `Request`/`Response`) is - **extracted upward** into protocol-neutral components, never **pushed sideways** with - protocol flags. - -## R2. Zero allocation on the steady-state path - -"Steady state" means: the connection is established, buffers/pools are warm, and a -request/response cycle is being served on an already-open connection. - -Allowed to allocate: -- Connection setup (once per TCP connection). -- Pool growth (amortized to zero). -- Explicit user-facing conversions (`Request.path()`, `HeaderMap.first()`, `PathParams.get()`) - — these are documented as allocating and the user opts into them. -- Error paths that terminate the connection. - -Forbidden to allocate on the steady-state path: -- Any frame object, header object, view object, param object, list, iterator, lambda capture, - boxed primitive, varargs array, or `String`. -- Anonymous inner classes created per call (this is currently violated — see `EX-05`). -- `InputStream`/`OutputStream` wrappers created per request (currently violated — `EX-29`). - -**Verification**: Phase 17 adds a JMH `-prof gc` gate. `gc.alloc.rate.norm` must be -**0 B/op** for the canonical happy paths (h1 GET, h2 GET, h2 unary POST with small body). -A non-zero value fails CI. - -## R3. Zero copy where the protocol permits it, and honest naming where it does not - -- HTTP/1.1: request bytes are a contiguous range of the connection read buffer. Views are - slices. This is genuinely zero-copy and stays that way. -- HTTP/2 headers: HPACK is a **stateful compression protocol**. Values entering the dynamic - table must outlive the read buffer, and Huffman-coded values must be decoded somewhere. - Copies are mandatory. Do not pretend otherwise in code comments or docs. - The honest formulation, which must be used in documentation: - > *HTTP/1.1 copies nothing per request but re-scans every header on every request. - > HTTP/2 copies each novel header once per connection and then references it by index. - > Over a connection of realistic length, HTTP/2 does strictly less total work.* -- HTTP/2 DATA: payload must be transferred out of the shared read buffer, because holding it - would head-of-line-block the whole connection — which is the exact thing HTTP/2 exists to - prevent. This is a **pooled buffer handoff**, not an allocation. - -## R4. Everything constant is precompiled at boot - -If a byte sequence is derivable from a compile-time-constant set, it is computed **once** in a -static initializer or enum constructor and never again. The codebase already does this -(`HttpStatus.bytes`, `ContentType.bytes`, `HttpMethod.bytes`, `AbstractRouter.JSON_404`) — the -h2 work extends it, it does not introduce it. - -Mandatory precompilation targets introduced by this plan: -- HPACK static-table encodings for every `HttpStatus` constant. -- HPACK-encoded, Huffman-compressed `content-type` field lines for every `ContentType` constant. -- The HPACK Huffman encode LUT and decode FSM tables. -- The HTTP/2 connection preface bytes, all SETTINGS frames we ever send, the SETTINGS ACK - frame, the PING ACK template, and all GOAWAY frames with a constant error code. -- The `Date` header value, refreshed once per second by a single shared daemon thread, not - formatted per response (`EX-33`). - -## R5. Bit-level and word-level operations - -- Frame headers are decoded with explicit shifts and masks, never via `ByteBuffer` or - `DataInputStream`. -- Multi-byte scans over array-backed data use `VarHandle`-based `long` reads (SWAR) where the - scan is longer than 8 bytes. `fpr-core` already ships this technique in - `dev.relism.fpr.core.internal.runtime.ByteCompare` (it holds a `LONG_VIEW` `VarHandle`); - Flash currently never enables it (see `EX-04`). This plan enables it. -- Integer packing of two `int`s into a `long` (the `(hi << 32) | lo` idiom already used in - `HeaderMap.findFirst` and `QueryParams.findFirst`) is the accepted way to return a pair - without allocating. Keep it, and add a small documented helper so the shifts are not - duplicated in five places. - -## R6. No god classes - -A class has **one reason to change**. Concretely, for this codebase: - -- `HttpServer` (currently 563 lines) does bind, accept loop, lifecycle, virtual-thread - dispatch, WebSocket upgrade detection, WebSocket handshake, WebSocket session loop, - keep-alive detection, HTTP response serialization, chunked encoding, hex encoding, and - decimal encoding. That is eleven reasons to change. It is decomposed in Phase 2. -- Every new h2 class has a single, nameable responsibility. If you cannot name it in four - words without "and", split it. -- Soft guidance: a class over ~250 lines, or with more than one clearly separable state - machine, is a smell. This is guidance, not a lint rule — a 300-line class that is one - cohesive state machine (e.g. `Http2StreamState`) is fine; a 150-line class doing two things - is not. - -## R7. Readability is a hard requirement, not a trade-off - -The existing codebase has an unusually high standard of Javadoc: it explains *why*, documents -lifetime contracts (`HeaderMap` lines 15–31 is the reference example), and calls out the -allocation model explicitly (`HttpServer` lines 49–59). **Match that standard.** Specifically: - -- Every public type gets a class-level Javadoc explaining its role and its **lifetime and - thread-safety contract**. -- Every zero-alloc trick gets a comment explaining what it avoids and why the obvious code - would be worse. A bare `long r = findFirst(name)` with no explanation is not acceptable. -- Every RFC-mandated behaviour cites the section: `// RFC 9113 §6.10 — CONTINUATION frames - MUST NOT be interleaved`. This is how the compliance suite stays auditable. -- Every deviation from the RFC (there will be a few, e.g. "we never emit PUSH_PROMISE") is - documented with the justification and the RFC's own permission for it. - -## R8. Safety checks are features - -Any place that reads a length, an index, a count, or a size from the network gets an explicit -bound check with a named limit constant, and a named error path. "The buffer would have -thrown `ArrayIndexOutOfBoundsException`" is not a safety check — it is an uncaught exception -that leaks a stack trace and kills a connection with the wrong error code. - -Every limit is a constant on a single `Http2Limits` class (h2) or `Http1Limits` class (h1), -each with a Javadoc explaining the attack it prevents and the RFC/CVE reference. - -## R9. Commit and branch discipline - -Per `AGENTS.md`: -- Branch: `feature/core/http2` (already created). Sub-work stays on this branch or on - short-lived branches off it named `feature/core/http2-`. -- Commits: Conventional Commits with scope `core`, e.g. - `feat(core): add HPACK Huffman decoder`, `refactor(core): split HttpServer into transport - components`, `fix(core): reject Content-Length with Transfer-Encoding`. -- Never edit `` in any POM. Never push to `master`. Every phase lands via PR with - green CI. -- If the `AGENTS.md` allowed-scope list needs `h2`, that is a separate `docs:` commit; until - then use `core`. - -## R10. When you find a problem in existing code, fix it - -This is an explicit instruction from the project owner and overrides any instinct to minimize -diff size. - -While implementing any phase, if you find that existing code: -- does something extra that is not needed, -- lacks a safety check, -- allocates where it could not, -- could be precompiled at boot, -- has a correctness or protocol-compliance bug, -- or is structured in a way that blocks the phase, - -then **fix it in that phase**, add it to the registry in Part II with a new `EX-nn` id, -document it in the PR description, and add a regression test. Do not open a TODO. Do not -"leave it for later". The registry in Part II is a starting point found by reading the code -once — it is explicitly expected to grow. - ---- - -# PART II — Existing-code defect & optimization registry - -Found by reading the current `master`. Each entry has an owner phase. Entries marked -**BLOCKER** must be fixed before the phase that depends on them can proceed. - -## Critical — correctness / security - -### EX-01 — `synchronized` on the WebSocket write path pins carrier threads · **BLOCKER for Phase 3** -`WebSocketSession.writeFrame` (`websocket/WebSocketSession.java:207`) and -`WebSocketSession.close` (line 112) hold `synchronized (out)` across a **blocking socket -write**. On Java 21 a virtual thread that blocks inside a `synchronized` block **pins its -carrier platform thread**. With WebSocket this is tolerable (one session, one thread, near-zero -contention). With HTTP/2 the same pattern applied to a shared connection writer with N -concurrent streams would pin carriers en masse and starve the scheduler under exactly the load -h2 exists to serve. -**Fix**: replace with `java.util.concurrent.locks.ReentrantLock`, which is virtual-thread aware -(a blocked virtual thread unmounts). Applies to WebSocket now and sets the precedent the h2 -writer must follow. **Never introduce a new `synchronized` block that can block on I/O.** -**Phase**: 2 (as part of the WebSocket extraction). - -### EX-02 — Request smuggling: `Content-Length` + `Transfer-Encoding` accepted together -`RequestParser.parse` (`RequestParser.java:146-162`) reads both headers into local variables and -lets `isChunked` win, but never rejects the combination. RFC 9112 §6.1 requires that a message -with both is treated as an error by an origin server (it is the canonical CL.TE/TE.CL smuggling -vector, particularly dangerous once Flash is used as a proxy in Pathway). -**Fix**: if both are present → `400 Bad Request`, close connection. Also reject: multiple -`Content-Length` header lines with differing values; any `Transfer-Encoding` whose final coding -is not `chunked`. -**Phase**: 1. - -### EX-03 — `RequestParser.parseLong` silently accepts malformed values -`RequestParser.java:222-229` skips any non-digit character instead of rejecting it. -`Content-Length: 5abc` parses as `5`; `Content-Length: -1` parses as `1`; -`Content-Length: 99999999999999999999` silently overflows. Combined with `EX-02` this is a -smuggling primitive. -**Fix**: strict parse — reject empty, reject any non-digit, reject leading `+`/`-`, reject -overflow past `Long.MAX_VALUE`, reject values above a configured -`Http1Limits.MAX_CONTENT_LENGTH`. Return a sentinel and raise `400`. -**Phase**: 1. - -### EX-04 — `supportsLong()` is never implemented, so `fpr-core`'s word-at-a-time path is dead -Decompiled `fpr-core-1.1.1`: -``` -public default boolean supportsLong(); → iconst_0; ireturn // always false -public default long longAt(int); → throw new UnsupportedOperationException -``` -No Flash implementation overrides them: not `FastPathViews.RequestByteView`, not -`MethodPathByteView`, not `HeaderMap.Slice` (line 101), not the anonymous view in -`HeaderMap.view` (line 173). `ByteCompare` holds a `VarHandle LONG_VIEW` for 8-byte-at-a-time -comparison that Flash has **never once executed**. -**Fix**: implement `supportsLong()`/`longAt(int)` on every array-backed contiguous view -(`RequestByteView`, `SocketByteView`, `StringByteView`, `HeaderMap.Slice`, the `HeaderMap.view` -result once it is pooled). `MethodPathByteView` and any future segmented view keep the -`false` default. This is a free throughput win on the **existing** HTTP/1.1 router path and it -must be measured before/after. -**Phase**: 4. - -### EX-05 — `HeaderMap.view(String)` allocates an anonymous `ByteView` per call -`models/HeaderMap.java:169-177` returns `new ByteView() { ... }` — one allocation plus a -capturing instance per call. `HttpServer.isWebSocketUpgrade` calls it twice per WebSocket -upgrade, and every middleware that inspects a header via `view()` pays it per request. -The same class already solves this correctly for `forEach` (lines 66-86: two reusable `Slice` -instances repositioned in place). Apply the same idiom. -**Fix**: a small pool of reusable `Slice` instances owned by the `HeaderMap`, handed out -round-robin, with the lifetime contract documented (valid until the next `view()` call that -wraps around, or the end of the request — whichever comes first). Same treatment for -`QueryParams.view` (line 32) and `PathParams.view` (line 44). -**Phase**: 4. - -### EX-06 — `ThreadLocal` + virtual threads = per-connection memory, not per-core memory · **BLOCKER for Phase 3** -This is the single worst existing issue and its Javadoc is actively misleading. - -`HttpServer.java:137-156` declares: -- `ThreadLocal SHA1` — Javadoc claims *"one per accept thread (there are now - ACCEPT_THREADS of them, not one)"*. **This is false.** `performHandshake` runs inside the - lambda submitted to `executorService` (line 275), i.e. on a **virtual thread**, one per - connection. So it is one `MessageDigest` per connection, not one per accept thread. -- `ThreadLocal LONG_BUF` (20 B) and `ThreadLocal STREAM_RELAY_BUFFER` (8 KB) — - same story. The class Javadoc (lines 49-59) frames these as a saving ("per-connection, not - per-request"), which is true, but omits that with virtual threads *per-thread means - per-connection* and there is no upper bound on connections. - -`FastPathRouterImpl.FastPathRouterContext` (lines 26-39) is worse: -`ThreadLocal.withInitial(() -> new MatchResult<>(32, 128))` plus a `MethodPathByteView`, both -per virtual thread, i.e. **per connection**. - -At 100 000 concurrent connections the `STREAM_RELAY_BUFFER` alone is ~800 MB, and the -`MatchResult(32,128)` instances add hundreds of MB more. `ThreadLocal` is the correct idiom for -platform-thread pools and the **wrong** idiom for virtual threads. - -**Fix**: introduce an explicit, pooled `ConnectionScratch` object allocated once per connection -in the connection runner and passed down the call chain (or carried on the connection context -object). It owns: the decimal buffer, the relay buffer, the `MessageDigest`, the router -`MatchResult`, the combined method+path view, and — once Phase 5+ lands — the h2 encode -scratch, HPACK scratch and body-buffer free list. Scratch objects are returned to a bounded -global pool on connection close so that a burst of 100 k connections does not leave 100 k -scratches resident. -This refactor is **required** by h2 anyway (the h2 connection needs exactly such an object), so -it is not incidental work — it is the same work. -**Phase**: 2 (introduce), 3 (h2 consumes it), 4 (router consumes it). - -### EX-07 — No socket read timeout: slowloris -Neither `HttpServer.bind` nor `HttpServer.process` ever calls `Socket.setSoTimeout`. A client -that opens a connection and sends one byte per minute holds a virtual thread, a -`RequestParser`, its buffer, and a socket forever. There is also no header-read deadline and no -idle keep-alive timeout. -**Fix**: three configurable timeouts on `FlashConfiguration`, all with sane defaults: -`headerReadTimeoutMs` (default 10 000), `idleKeepAliveTimeoutMs` (default 60 000), -`bodyReadTimeoutMs` (default 30 000). Enforced via `setSoTimeout` plus explicit deadline -tracking where `setSoTimeout` is insufficient (it resets per read). -**Phase**: 1. - -### EX-08 — No limit on header count or individual header size -`RequestParser` bounds only the **total** header block via `maxHeaderBufferSize` (64 KB -default). A request with 60 000 one-byte headers passes, and every subsequent -`HeaderMap.first()` lookup then scans all of them (see `EX-09`), turning a 64 KB request into -quadratic CPU work per middleware. -**Fix**: `Http1Limits.MAX_HEADER_COUNT` (default 100), `MAX_HEADER_NAME_LENGTH` (default 256), -`MAX_HEADER_VALUE_LENGTH` (default 8192), `MAX_REQUEST_LINE_LENGTH` (default 8192, separate -from the total buffer). Each with a Javadoc naming the attack. -**Phase**: 1. - -### EX-09 — `HeaderMap` lookups are O(headers) each, and the request path does many of them -`HeaderMap.findFirst` (line 180) rescans the entire header section per lookup. A single request -through a realistic middleware chain (OIDC reads `Authorization` and `Cookie`; the limiter -reads `X-Forwarded-For`; CORS reads `Origin`; the server reads `Connection`, `Upgrade`, -`Sec-WebSocket-Key`) performs 6–10 full scans of the header block. This is O(n·m). -**Fix**: build a compact index at `reset()` time into a **reused** `int[]` owned by the -`HeaderMap` (name offset, name length, value offset, value length, plus a cheap 32-bit -case-insensitive name hash per entry). Lookup becomes hash compare + one memcmp. Index arrays -grow to the connection's high-water mark and are never reallocated after warmup. Zero -allocation, strictly less work than today even for a single lookup (the scan happens once -instead of once per lookup). -**Phase**: 4. - -### EX-10 — `ChunkedInputStream` performs one syscall per byte -`HttpServer.process` passes the **unbuffered** `socket.getInputStream()` (line 278) to the -parser and thence to `ChunkedInputStream`. `ChunkedInputStream.readChunkSize` (line 51), -`consumeTrailers` (line 66) and the trailing-CRLF consumption (`src.read(); src.read();` on -lines 32 and 46) all do single-byte reads. On a plain socket that is a `read(2)` syscall **per -byte** for every chunk header, every chunk terminator and every trailer line. -**Fix**: the connection read buffer must be the single source of truth for inbound bytes. Give -`ChunkedInputStream` a buffered view over the connection's read buffer (the same buffer -`RequestParser` already owns and already read-ahead into), not the raw socket stream. This also -removes the `SequenceInputStream`/`ByteArrayInputStream` wrappers. -**Phase**: 1. - -### EX-11 — `WebSocketSession.readFrame` performs up to 14 syscalls per frame -`websocket/WebSocketSession.java:123-147` reads the two header bytes, the extended length (2 or -8 bytes) and the 4 mask bytes with individual `in.read()` calls on the **unbuffered** socket -stream. That is up to 14 syscalls before the payload read. -**Fix**: read the frame header into the existing `hdrScratch` array with a single bounded -`readFully`, then decode with shifts. -**Phase**: 2. - -### EX-12 — WebSocket protocol gaps: no continuation frames, no mask enforcement, no length guard -`readFrame` does not handle opcode `0x0` (continuation) at all, so fragmented messages are -delivered as separate broken messages. It does not enforce that client→server frames **must** -be masked (RFC 6455 §5.1 — a server MUST close the connection on an unmasked client frame). It -does not validate the opcode. It computes `payLen` from up to 8 bytes into a `long` and only -then compares against `readBuf.length` — a 63-bit length is accepted into the comparison but -`(int) payLen` on line 149 would already have truncated if the check were reordered; today the -check is correctly placed but the negative/overflow case is untested. Control frames are not -validated for the RFC's ≤125-byte and FIN=1 requirements. -**Fix**: full RFC 6455 frame validation with named errors and correct close codes (1002 -protocol error, 1009 message too big). Continuation-frame reassembly with a bounded message -size. -**Phase**: 2. - -### EX-13 — `Connection` header is compared as a whole value, not as a token list -`HttpServer.isKeepAlive` (line 455) calls `request.headerEquals("Connection", "close")`, which -does an exact case-insensitive whole-value compare (`HeaderMap.valueEqualsIgnoreCase`, line -153). `Connection: keep-alive, close` therefore reads as keep-alive. The correct token-list -scan already exists three lines away in `connectionContainsUpgrade` (line 380) and is simply -not reused. -**Fix**: one shared token-list scanner used by both. -**Phase**: 2. - -### EX-14 — `HEAD` responses include a body -`HttpServer.process` (lines 326-344) never special-cases `HttpMethod.HEAD`. The handler's body -is written to the socket. RFC 9110 §9.3.2: a HEAD response MUST NOT have a body (the headers, -including `Content-Length`, must match what GET would return). -**Fix**: suppress body writes for HEAD while keeping the computed `Content-Length`. -**Phase**: 2. - -### EX-15 — `Content-Type` is always written, even when `ContentType.NONE` -`HttpServer.writeResponse` (lines 474-477) unconditionally writes `Content-Type: ` followed by -`response.getContentType()`. For `ContentType.NONE` (`http/ContentType.java:15`, empty byte -array) this emits the header line `Content-Type: \r\n` — a header with an empty value. Also, -`204 No Content` and `304 Not Modified` responses get `Content-Length: 0`, which RFC 9110 -§8.6 forbids for 204 and discourages for 304. -**Fix**: skip `Content-Type` when the value is empty; skip `Content-Length` for 204/304 and for -1xx. -**Phase**: 2. - -### EX-16 — No `Date` header -Flash never emits `Date`. RFC 9110 §6.6.1: an origin server with a clock **SHOULD** send it. It -is also the classic precompilation opportunity: format once per second on a shared daemon -thread into a pre-encoded `Date: ...\r\n` byte array, and have every response write that array. -Cost per response: one volatile read plus one `write(byte[])`. -**Fix**: `dev.relism.flash.http.DateHeader` — a single daemon thread, a `volatile byte[]` -holding the fully pre-encoded h1 field line, plus a parallel `volatile byte[]` holding the -HPACK-encoded h2 field line (Phase 9). -**Phase**: 2 (h1 form), 9 (h2 form). - -### EX-17 — `HttpStatus` index array is bounded by a hand-maintained constant -`http/HttpStatus.java:53` hardcodes `MAX_STATUS_CODE = 504` and sizes `INDEX`/`REASONS` to it. -Adding any constant with a code above 504 (e.g. `507 Insufficient Storage`, `511 Network -Authentication Required`, or the h2-relevant `421 Misdirected Request`) silently throws -`ArrayIndexOutOfBoundsException` in the static initializer at class-load time. -**Fix**: compute the bound from `values()` in the static initializer. Add the status codes h2 -actually needs: `421 Misdirected Request` (RFC 9110 §15.5.20, required for connection -coalescing) and `431 Request Header Fields Too Large` (needed by `EX-08`). -**Phase**: 1. - -### EX-18 — `RequestParser` accepts bare LF as a line terminator in some positions -`findEndOfHeader` requires the full `\r\n\r\n`, but the per-header loop (line 147) finds `\r` -and then unconditionally advances `current = lineEnd + 2` (line 161) without verifying that -`buffer[lineEnd + 1] == '\n'`. A header line ending in a bare `\r` followed by a non-`\n` -desynchronizes the parse. Bare-LF and bare-CR handling is a known smuggling surface. -**Fix**: validate the `\n` explicitly and reject otherwise. -**Phase**: 1. - -## High — allocation on the hot path - -### EX-19 — `FastPathRouterImpl.route` allocates 4 objects per parametric request -`FastPathRouterImpl.java:66-80`: `new String[count]`, `new int[count]`, `new int[count]`, plus -the `PathParams` object built inside `setPathParams`. Every request matching a route with a -path parameter — i.e. most REST APIs — pays four allocations. -**Fix**: a reusable `PathParams` on the `ConnectionScratch` (`EX-06`) with pre-sized arrays -grown to the connection high-water mark, repositioned per request via a package-private -`reset(...)`. The `PathParams` lifetime contract ("valid only inside the handler") is documented -exactly like `HeaderMap`'s. -**Phase**: 4. - -### EX-20 — `Response.header(String, String)` allocates 3 objects per call -`models/Response.java:134-138`: string concatenation (`StringBuilder` + `char[]` + `String`) -then `getBytes` (another `byte[]`), then possibly `new ArrayList<>()`. A response setting three -headers allocates ~10 objects. `redirect(String)` (line 129) has the same shape. -**Fix**: encode directly into the response's scratch buffer with a byte-level writer; keep the -`header(byte[] preEncoded)` overload (line 144) as the zero-cost path it already is. The -`List headers` field becomes a reusable growable `byte[]` region plus an `int[]` of -(offset, length) pairs. -**Phase**: 6. - -### EX-21 — `Response` is allocated per request -`HttpServer.process:327` — `new Response(200, ContentType.TEXT_PLAIN)` per request. -**Fix**: a pooled, resettable `Response` on the `ConnectionScratch`. Requires `Response` to -gain a package-private `reset()`. The handler-returns-a-different-`Response` path (line 334) -must still work, so the pooled instance is used only when the handler mutates the one it was -given. -**Phase**: 6. - -### EX-22 — `Request` is allocated per request, and Lombok `@Value` blocks pooling -`models/Request.java:35` is `@Value` (final class, final fields). `Request.forParsed` allocates -a `Request` **and** a `RequestBody` per request. -**Fix**: convert `Request` to a plain non-final class with a package-private `reset(...)`, and -pool it per connection (h1) / per stream slot (h2). Lombok `@Value`'s generated -`equals`/`hashCode` become meaningless under pooling and must be removed; document the change -(no user code can meaningfully depend on `Request` equality). `RequestBody` gets the same -treatment. This is the single largest API-surface-adjacent refactor in the plan and is why it -gets its own phase. -**Phase**: 6. - -### EX-23 — `RequestBody.stream()` allocates 2–3 stream wrappers per call -`models/RequestBody.java:110-117` builds a `ByteArrayInputStream` and usually a -`SequenceInputStream` plus (line 130) an anonymous bounded `InputStream` with a capturing -instance. -**Fix**: one reusable `BoundedBufferedInputStream` on the `ConnectionScratch` that knows about -the pre-buffered region and the socket, repositioned per request. -**Phase**: 6. - -### EX-24 — `RequestBody.drain()` allocates 8 KB per chunked request -`models/RequestBody.java:123` — `socket.transferTo(OutputStream.nullOutputStream())`. The JDK's -`transferTo` allocates a fresh `byte[8192]` on every call. `HttpServer` already keeps a -`STREAM_RELAY_BUFFER` precisely to avoid this on the write side (see its Javadoc, lines -150-156) — the read side was missed. -**Fix**: drain through the scratch relay buffer. -**Phase**: 6. - -### EX-25 — `Request.path()` and `PathParams.get()` allocate twice -`Request.java:123-129` copies the view byte-by-byte into a fresh `byte[]` and then constructs a -`String` from it — two allocations and a byte-at-a-time loop. When the underlying view is -array-backed and contiguous (which it always is for h1), `new String(array, off, len, UTF_8)` -does it in one. `PathParams.get` (line 33) has the identical shape. -**Fix**: add `ByteView`-adjacent capability detection (an internal `ArrayBackedByteView` -interface exposing `array()`/`offset()`) and take the single-allocation path when available. -Keep the byte-at-a-time loop as the fallback for segmented views. -**Phase**: 4. - -### EX-26 — `QueryParams.decode` always allocates, even when nothing needs decoding -`models/QueryParams.java:96-118` allocates a `byte[]` of the full length and then a `String`, -unconditionally. The overwhelmingly common case is a value containing neither `%` nor `+`. -**Fix**: scan first; if clean and array-backed, construct the `String` directly from the -backing array. -**Phase**: 4. - -### EX-27 — `HttpServer.writeResponse` issues ~10 small writes per response -`HttpServer.java:469-492`: `HTTP/1.1 `, status, CRLF, `Content-Type: `, type, CRLF, custom -headers (one write each), `Content-Length: `, digits, CRLF, connection header, CRLF, body. -`BufferedOutputStream` coalesces them into one syscall, but each `write` still costs a bounds -check, a capacity check and a `System.arraycopy` with a tiny length. -**Fix**: serialize the whole response head into a reusable scratch buffer with direct index -writes, then a **single** `write(scratch, 0, len)`. This removes `BufferedOutputStream` from -the h1 response path entirely and is a prerequisite for the h2 writer discipline (Phase 3), -where holding the connection write lock across ten small writes would be unacceptable. -**Phase**: 6. - -### EX-28 — `ByteTemplate.render` allocates and is O(slots²) -`template/ByteTemplate.java:52-75` allocates a `byte[][]` per render and does a nested loop over -slots for every key-value pair. Only used by `ErrorPages`, so it is off the hot path — but it -is called on every 404/500 in dev mode, and 404 is a hot path for some workloads. -**Fix**: precompute a slot-name → index map at construction; render into a reusable buffer. -Low priority, but in scope because it is exactly the "could be precompiled at boot" category. -**Phase**: 6. - -### EX-29 — `Multipart` (336 lines) has not been audited -`api/multipart/Multipart.java` is the second-largest file in core and was not read during the -design pass. -**Fix**: mandatory audit against every rule in Part I: allocation profile, god-class check, -missing bounds checks on part count / part size / boundary length (multipart parsers are a -classic DoS surface), and correct behaviour when the body is streamed rather than materialized. -**Phase**: 6. - -### EX-30 — `TlsConfig` cannot expose the negotiated ALPN protocol · **BLOCKER for Phase 1** -`tls/TlsConfig.java:112` can *set* `applicationProtocols`, but nothing forces the TLS handshake -before the first read, so `SSLSocket.getApplicationProtocol()` returns `null` at the point -where the protocol decision must be made. `HttpServer.process` never calls `startHandshake()`. -**Fix**: explicit `startHandshake()` on the connection's virtual thread (blocking there is free) -before protocol dispatch, with the handshake covered by `headerReadTimeoutMs`. -**Phase**: 1. - -### EX-31 — TLS cipher suites are not constrained for h2 -RFC 9113 §9.2.2 requires that an h2 endpoint MUST NOT use the cipher suites on the TLS 1.2 -blocklist, and MUST support `TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256`. Flash currently leaves -suites at the JDK default (`TlsConfig.applyTo`, lines 122-131), which on some JDKs still -includes blocked suites for TLS 1.2. -**Fix**: when `h2` is among the offered ALPN protocols, filter the enabled suite list against -the RFC 9113 Appendix A blocklist. Document that TLS 1.3 is unaffected. -**Phase**: 1. - -### EX-32 — `HttpServer.stop()` does not send a graceful shutdown signal -`stop()` (line 253) closes listeners and then force-closes every active socket. For h1 this -truncates in-flight responses. For h2 it skips `GOAWAY` entirely, which is a compliance failure -(RFC 9113 §6.8 — a server that closes without GOAWAY gives the client no way to know which -streams were processed). -**Fix**: two-stage shutdown — stop accepting, send `Connection: close` / `GOAWAY(last-stream-id)`, -wait up to a configurable drain timeout, then force-close. Applies to both protocols. -**Phase**: 8 (h2 GOAWAY) and 2 (h1 drain). - -### EX-33 — `RequestParser.findEndOfHeader` rescans and is byte-at-a-time -`RequestParser.java:196-202` scans for `\r\n\r\n` one byte at a time; the incremental re-scan on -line 106 correctly overlaps by 3 bytes but the inner loop is still scalar. -**Fix**: SWAR scan using the same `VarHandle` `long`-read technique `fpr-core`'s `ByteCompare` -uses. Fall back to scalar for the tail. Measure — if the win is under 3 % on the h1 benchmark, -keep the scalar version and document the measurement rather than carrying complexity. -**Phase**: 4. - -### EX-34 — `ServerHandle.create` hardwires the transport implementation -`ServerHandle.java:31-35` calls `new HttpServer(...)` directly. Once the transport is -decomposed (Phase 2) and a second protocol exists (Phase 3+), this factory needs to construct a -composed transport rather than a god object. -**Fix**: keep `ServerHandle` as the public contract; move construction behind a -package-private `TransportFactory`. -**Phase**: 2. - -### EX-35 — `Transfer-Encoding` multi-value handling drops the message boundary silently -Found while implementing `EX-02` in `RequestParser.java`'s header-scan loop (the exact code that -decides `isChunked`). The pre-existing check was `equalsIgnoreCase(buffer, valueStart, lineEnd, -"chunked")` — an exact **whole-value** comparison. RFC 9112 §6.1 requires only that `chunked` be -the **final** coding in a comma-separated list (e.g. `Transfer-Encoding: gzip, chunked` is valid -and self-delimiting). The old check silently treated any such multi-coding value as *not* -chunked at all — `isChunked` stayed `false`, `contentLength` stayed `0`, and the body bytes that -followed were left for the next `parse()` call to misinterpret as the start of a new request: -a real message-boundary corruption, not just a missed feature. -**Fix**: parse the comma-separated token list and inspect only the last token -(`RequestParser.isFinalCodingChunked`). A value whose final coding is not `chunked` is now -rejected with `501` (`EX-02`'s own fix), rather than silently misparsed. -**Phase**: 1. - -### EX-36 — A header line without a `:` was silently skipped instead of rejected -Found in the same loop as `EX-18`/`EX-35`. `RequestParser`'s header-line loop located the colon -via `find(...)` and, if none was found (`colon == -1`), simply did nothing for that line and -moved on to the next — a malformed header line was permissively ignored rather than rejected. -RFC 9112 §5 gives no such leniency: a header field line without a colon is not valid HTTP. -**Fix**: `colon == -1` now rejects the request with `400 Bad Request`. -**Phase**: 1. - -### EX-37 — `BufferedByteSource`'s deadline mechanism NPEs against a `null` socket, so it was never actually testable in isolation -Found while writing `Http2FrameReaderTest` (Phase 5): `BufferedByteSource.clearDeadline()` and -`fillFromUnderlying()` both call `socket.setSoTimeout(...)` unconditionally. Every isolated unit -test in this codebase that constructs a `BufferedByteSource` directly (over a -`ByteArrayInputStream`, to test a parser/reader without a real connection) passes `null` for -`socket` — the codebase's own established idiom, used throughout `RequestParserTest`, -`ChunkedInputStreamTest`, `RequestParserSecurityTest`. That idiom works today only because none -of those tests ever call `setDeadline`/trigger a deadline-bounded read — `RequestParser` itself -never calls `setDeadline` (only `Http1Connection`, which always has a real socket, does). The -moment any code under test (here, `Http2FrameReader`, which correctly uses the deadline exactly -as `EX-07` designed it) sets a deadline and then performs a read against a `null`-socket source, -both methods threw `NullPointerException` instead of the intended `SocketTimeoutException`/ -normal read. `BufferedByteSource` — the class that exists specifically to implement `EX-07`'s -slowloris defence — had **zero** dedicated unit tests (`BufferedByteSourceTest` did not exist); -its deadline mechanism was exercised only indirectly, end-to-end, via real-socket tests -(`HttpServerTimeoutTest`), which never hit this path. -**Fix**: both methods now skip the `socket.setSoTimeout(...)` call when `socket == null` — a -`null` socket means "no OS-level timeout to bound", not a misuse; the deadline-expiry check -itself (`remainingNanos <= 0` → `SocketTimeoutException`) is independent of the socket and keeps -working. Production always supplies a real socket, so no production behavior changes. -`BufferedByteSourceTest.java` added (previously absent) with direct coverage of the deadline -mechanism against a `null` socket, closing the actual test gap this bug lived in. -**Phase**: 5 (found and fixed while building `Http2FrameReaderTest`). - -### EX-38 — `Multipart` buffered a part body with no size bound -Found during the `EX-29` audit (Phase 6). `Multipart.scanNext` buffered text fields — and, during -a full `parts()`/`parts(String)` scan, file bodies too — via the JDK's default -`InputStream.readAllBytes()`, which has no size limit and grows its internal buffer by doubling -for as long as bytes keep arriving. `Http1Limits.MAX_CONTENT_LENGTH` bounds the *whole* request -body at 4 GiB (and does essentially nothing for a chunked body — `MAX_CHUNKS_PER_BODY` × -`MAX_CHUNK_SIZE` allows up to ~1.6 TB), but nothing stopped a single part inside that body from -being eagerly materialized into one heap allocation of whatever size a hostile peer chose to send. -**Fix**: `readBoundedBody` replaces the `readAllBytes()` call, throwing `IOException` once the -part exceeds `Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE` (10 MiB). Deliberately does **not** -apply to `Part.materialize()` on a streaming file part returned by `Multipart.file()` — that call -is documented as an explicit, opt-in heap allocation the caller chooses to pay for. -**Phase**: 6. - -### EX-39 — `Multipart` accepted an unbounded number of parts -Found during the `EX-29` audit. `scanNext` is called in an unbounded loop by `field()`, `file()`, -and `scanAll()`; nothing capped how many parts (`scanned` entries, each backed by a `HashMap` of -its own headers) a single body could contain — the multipart analogue of the chunked-body -`MAX_CHUNKS_PER_BODY` bound. -**Fix**: a `partCount` counter checked against the new `Http1Limits.MAX_MULTIPART_PARTS` (1,000) -at the top of every `scanNext` call. -**Phase**: 6. - -### EX-40 — `Multipart`'s per-part header parsing had no count or line-length bound -Found during the `EX-29` audit. `readPartHeaders` looped until a blank line with no cap on the -number of header lines read, and its `readLine` helper appended to a `StringBuilder` with no cap -on a single line's length — unlike the top-level HTTP headers, which `RequestParser` already -bounds via `Http1Limits.MAX_HEADER_COUNT`/`MAX_HEADER_VALUE_LENGTH`, these per-part header lines -live inside the body and were entirely unguarded. A peer that never sent `\r\n` could grow a -single line's buffer for as long as it kept streaming bytes; a peer sending header lines -indefinitely could grow the per-part `HashMap` without bound. -**Fix**: `readPartHeaders` now rejects a part once it exceeds -`Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT` (20); `readLine` now rejects a line once it exceeds -`Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH` (8,192 bytes) — both throw `IOException`. -**Phase**: 6. - -### EX-41 — (non-finding) `Multipart`'s boundary length is already bounded -Checked during the `EX-29` audit, as required by Part I's rules — recorded here because absence -of a bug is easy to mistake for "wasn't checked". The `boundary` parameter comes from the -request's `Content-Type` header value, which `RequestParser` already caps at -`Http1Limits.MAX_HEADER_VALUE_LENGTH` (8,192 bytes) before `Multipart.of` ever sees it — no -separate bound needed in `Multipart` itself. -**Phase**: 6. - -### EX-42 — `RequestParser.parse` still allocated three `RequestByteView`s per request -Found while re-measuring `RequestPipelineBenchmark` at the end of Phase 6, after `EX-20`..`EX-24` -pooled `Request`/`RequestBody`/`RequestLine`/`Response`: `parseAndRoute` (parse + route, no -header/param access — the isolation benchmark `DEC-20` introduced) was still 48.008 B/op, not the -0 B/op Phase 6's own zero-alloc contract requires. `RequestParser.parse` built a fresh -`FastPathViews.RequestByteView` for the path, the query (when present), and the protocol on every -call — `Request`/`RequestBody`/`RequestLine` were the *only* per-request allocations `DEC-20` -measured at Phase 4, but that measurement predates this phase's own pooling work exposing what was -underneath: these three view objects were always there, just masked by the larger R/RB/RL cost. -**Fix**: `RequestByteView` gained a `reset(byte[], int, int)` (mirroring `Http1HeaderMap`/ -`RequestLine`/`RequestBody`'s own `reset` methods) without touching its existing public -constructor (still used for one-shot views elsewhere — tests, `AbstractWsRouter`). `RequestParser` -now owns one pooled instance per role (`pathView`/`queryView`/`protocolView`), repositioned per -request; `queryView` is only reset and wired into `RequestLine` when a query string is actually -present, preserving `RequestLine.getQuery()`'s existing "`null` means no query" contract. -**Result**: `parseAndRoute` measured 0.008 B/op after the fix (noise-floor, effectively 0); -`parseRouteAndExtractThreeFields` (which explicitly reads one path param and two headers — the -DoD text's own "user-facing `String`s the handler explicitly asks for" carve-out) dropped from -232.009 to 184.009 B/op, the same 48 bytes accounted for exactly. -**Phase**: 6. - -### EX-43 — `Response.header(...)` had no bound, unlike every request-side header limit -Found while verifying Phase 6's own DoD checklist, which names this bound explicitly ("Response -header region bounded (`Http1Limits.MAX_RESPONSE_HEADER_BYTES`) — a handler in a loop calling -`header(...)` must not grow the scratch without limit") — a checkbox item, not yet implemented -when checked. `Response.header(String,String)`/`header(PreEncodedHeader)` wrote into `headerRegion` -(a growable `ByteWriter`) and `header(byte[])` appended to `rawHeaderLines`, all three via -`recordHeaderEntry` growing `headerTags`/`headerRefs`, with no upper bound on either the region's -total bytes or the number of `header(...)` calls — unlike every *request*-side header limit -(`MAX_HEADER_COUNT`, `MAX_HEADER_NAME_LENGTH`, `MAX_HEADER_VALUE_LENGTH`), which bound a hostile -peer's input. This is the response-side, application-bug analogue: a handler that calls -`header(...)` in an unbounded loop (e.g. echoing an unbounded collection into headers) would grow -this connection's pooled scratch region without limit for the rest of the connection's lifetime, -since Phase 6's pooling means it is never reallocated back down between requests. -**Fix**: two new limits, `Http1Limits.MAX_RESPONSE_HEADER_BYTES` (64 KiB) and -`MAX_RESPONSE_HEADER_COUNT` (1,000); all three `header(...)` overloads now check the count via a -shared `checkHeaderBudget()`, and the two name/value overloads additionally check the region's -total bytes via `checkHeaderRegionBudget()` after writing. Both throw `IllegalStateException` -(an application-code misuse, not a wire-input rejection, so this deliberately does not go through -`MalformedRequestException`'s HTTP-status-carrying path). -**Phase**: 6. - -### EX-44 — Comment cleanup removed `Multipart.partCount` from compiled source -Found during the Phase 7 clean build. The process-reference cleanup commit removed the complete -field declaration because its trailing comment contained an `EX-nn` marker. Incremental builds -initially reused the previously compiled class and hid the source-level failure. **Fix**: restored -the counter without the process comment and audited every non-comment line removed by the cleanup -commit. `MultipartTest`'s part-count limit coverage remains the regression test; phase closure now -uses `mvn clean test` so stale classes cannot mask source damage. **Phase**: 7. - -### EX-45 — Stateful HTTP/2 protocol instance was shared across accepted sockets -Found by running h2spec repeatedly against the Phase 8 transport integration. `TransportFactory` -constructed one `Http2Connection` and `ConnectionRunner` reused it for every accepted socket, which -is valid for the stateless `Http1Connection` but leaked SETTINGS, GOAWAY and flow-control state -between HTTP/2 peers. **Fix**: `ConnectionRunner` now receives an HTTP/2 protocol factory and creates -one state machine per accepted HTTP/2 connection. `Http2ConnectionIntegrationTest` first poisons one -connection with a protocol error, then verifies that a second connection completes a fresh SETTINGS -exchange and PING/PONG. **Phase**: 8. - -### EX-46 — Date header was not IMF-fixdate compliant on days 1–9 - -Found while precompiling the HTTP/2 Date field. `DateHeader` used Java's -`DateTimeFormatter.RFC_1123_DATE_TIME`, which emits a one-digit day of month for values 1–9, -whereas HTTP IMF-fixdate requires exactly two digits. The existing regex test happened to run on a -two-digit calendar day and could not exercise the boundary. **Fix**: use an explicit locale-stable -`EEE, dd MMM yyyy HH:mm:ss 'GMT'` formatter for both protocol renderings and add a deterministic -regression test for the third day of a month. **Phase**: 9. - -### EX-47 — A reset queued stream could be returned to the pool before dispatch observed it - -Found while closing the stream-dispatch cancellation paths. `receiveRstStream` transitioned a -queued stream to `CLOSED` before deciding whether its release had to be deferred. The subsequent -state check could therefore no longer see `HALF_CLOSED_REMOTE`, returned the object to the pool, -and left the same object referenced by the dispatch queue. A following request could acquire and -mutate it before the queue drained. **Fix**: capture the deferred-release condition before the -transition, mark queued/dispatched streams cancelled, and let the sole queue/worker owner perform -the final release. The regression test sends a complete request, immediately resets it, then sends -a second request and proves that only the second handler invocation and response occur. **Phase**: -10. - -### EX-48 — HTTP/1.1 request trailers were parsed and discarded - -Found while exposing the protocol-neutral request trailer API. `ChunkedInputStream` consumed and -bounded the final trailer section but discarded every field, so no honest API could provide the -same semantics on HTTP/1.1 and HTTP/2. **Fix**: parse the bounded section into a connection-owned -`MutableHeaderMap`, expose it through `Request.trailers()` only after body EOF, reject malformed and -framing-sensitive fields, and add HTTP/1 parity/regression tests. **Phase**: 12. - -### EX-49 — CONNECT routes were registered as origin-form paths - -Found while exercising an HTTP/2 tunnel. The public `connect("authority", handler)` API passed -through the ordinary path sanitizer, which prepended `/`; both HTTP/1.1 authority-form request -targets and HTTP/2 `:authority` arrive without that prefix, so the existing CONNECT API could -never match its documented target. **Fix**: normalize CONNECT authority targets separately in the -shared router registration path and verify a live bidirectional HTTP/2 tunnel. **Phase**: 12. - -### EX-50 — Declared HTTP/2 header and stream idle deadlines were not enforced - -Found during the whole-package hostile-peer review. `HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS` and -`STREAM_IDLE_TIMEOUT_MS` existed in `Http2Limits` and were described as enforced defences, but no -production path read either constant. A peer could retain a CONTINUATION assembly or an open -stream indefinitely. **Fix**: give header assembly an absolute non-renewable deadline checked on -frames and read wakeups; track per-stream activity and cancel idle streams with `RST_STREAM -CANCEL`; expose the stream deadline operationally and add deadline regression tests. **Phase**: 13. - -### EX-51 — Concurrent half-close could retire the same pooled HTTP/2 stream twice - -Found when the clean integration suite logged an internal error despite passing its assertions. -The demultiplexer and response-completion thread could both observe a closed stream, then one -thread could recycle it before the other read its id. The loser attempted to remove stream id -zero; a more unfortunate interleaving could have touched a reused pooled object. **Fix**: make -stream retirement atomic in `Http2StreamTable` and require both the expected stream id and object -identity to match the live table entry. A regression test proves that a stale retirement cannot -remove the next generation of the same pooled object. **Phase**: 13. - -### EX-52 — WebSocket `onOpen` failures bypassed lifecycle cleanup - -Found while routing extended CONNECT through the existing WebSocket loop. `onOpen` ran before the -loop's `try/finally`, and runtime failures from application callbacks were not handled alongside -I/O failures. An exception could therefore escape without `onError`, `onClose`, or guaranteed -transport release. **Fix**: include `onOpen` and all callback dispatch in the guarded lifecycle, -report runtime failures, and force-close in a nested `finally` even if `onClose` fails. -`WebSocketLoopTest` is the regression test. **Phase**: 15. - -### EX-53 — Push-streaming HTTP/2 responses could deadlock before response headers - -Found in the first live extended-CONNECT test. `Http2ResponseWriter.startFlowControlled` tried to -read the first push-streaming body byte while constructing the same batch as the response HEADERS. -A full-duplex producer waiting for request DATA therefore blocked before the client could receive -the successful response and send that DATA. **Fix**: publish push-streaming HEADERS as the first -batch and start body reads only from the post-write resume batch. `WebSocketOverH2Test` proves the -handshake completes before sending a message and then carries a message beyond the flow window. -**Phase**: 15. - -### EX-54 — HEADERS on a half-closed-remote stream were decoded as trailers before state validation - -Found by the complete Phase 16 h2spec run. `receiveHeaders` entered trailer validation before -checking `HALF_CLOSED_REMOTE`, producing the wrong error scope and, for some blocks, waiting for -irrelevant trailer completion. **Fix**: reject immediately with a stream-scoped `STREAM_CLOSED`. -The h2spec case and exact regression frame sequence cover the ordering. **Phase**: 16. - -### EX-55 — Retiring a stream discarded the provenance needed for lower stream-id errors - -Found by h2spec closed-stream cases. Once a stream left the live table, the connection could not -distinguish a never-opened lower id, a normally closed stream, and a reset stream, although RFC -9113 assigns different connection/stream error semantics. **Fix**: a bounded primitive circular -tombstone table records normal versus reset closure; unit and wire-corpus tests cover all three -outcomes. **Phase**: 16. - -### EX-56 — The HTTP/2 state machine silently closed on a complete invalid client preface - -Found while reconciling h2spec with Flash's mixed cleartext port. Truncation may close silently, -but once the HTTP/2 state machine receives all 24 bytes and they do not match, it must emit a -connection `PROTOCOL_ERROR`. **Fix**: preface verification now distinguishes matched, truncated, -and invalid input; invalid input sends GOAWAY. The exact 24 bytes are in the regression corpus. -**Phase**: 16. - -### EX-57 — Wire-closed streams occupied the live concurrency table until their final write callback - -Found by the Phase 17 h2load matrix at the advertised 64-stream concurrency. A response stream -could be closed in protocol state while its final immutable write batch was still owned by the -serialized writer. Keeping that object in the live table made a legal replacement stream receive -`REFUSED_STREAM`; recycling it immediately would instead corrupt the pending write callback. -**Fix**: detach a closed stream from live lookup before submitting its final batch, retain bounded -object ownership until the callback, and cap live plus detached objects at twice the advertised -live capacity. The regression test fills a one-entry table, detaches its final generation, admits -the next stream, and proves both objects return to the pool. **Phase**: 17. - -### EX-58 — The upstream HTTP/2 client left Nagle enabled on synchronous exchanges - -Found while building the Phase 17 end-to-end benchmark. The proxy-oriented client sends small -request and control frames and then synchronously waits for the response; with Nagle enabled this -interacted with delayed ACKs and added roughly 40 ms to a local exchange. **Fix**: configure -`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 - -``` -Phase 0 Groundwork: package layout, limits, error model, style contract -Phase 1 HTTP/1.1 hardening + ALPN/preface plumbing ← safety debt paid before new code -Phase 2 Transport decomposition (kill the HttpServer god class) -Phase 3 The serialized frame writer + JMH gate ← THE GO/NO-GO GATE -Phase 4 Byte-layer foundations: views, scanning, header index, scratch -Phase 5 Frame layer: reader, writer wiring, frame validation -Phase 6 Request/Response model refactor (pooling, protocol neutrality) -Phase 7 HPACK decoder (Huffman, static, dynamic, arena) -Phase 8 Connection state machine: SETTINGS, PING, GOAWAY, WINDOW_UPDATE -Phase 9 HPACK encoder + boot-time precompilation + h2 response path -Phase 10 Stream state machine + dispatch + h2 Request assembly -Phase 11 DATA, flow control, request/response bodies, streaming -Phase 12 Trailers, half-close, gRPC end-to-end -Phase 13 Security hardening & abuse resistance -Phase 14 h2c prior knowledge + upstream/proxy support -Phase 15 RFC 8441 extended CONNECT (WebSocket over HTTP/2) -Phase 16 Compliance test suite -Phase 17 Benchmarks, allocation gates, performance tuning -Phase 18 Documentation -``` - -Phases 0–2 touch **only existing code** and ship value on their own even if h2 were abandoned. -Phase 3 is the go/no-go gate. Phases 4–6 are shared foundations. Phases 7–15 are h2 proper. - ---- - -## Phase 0 — Groundwork - -**Goal.** Establish the package layout, the limits/error model, and the written style contract -so that no later phase has to invent conventions. - -**Why now.** Every later phase references these constants and this layout. Doing it first -prevents three different naming schemes for the same idea. - -### Files created - -``` -flash/src/main/java/dev/relism/flash/http2/Http2Limits.java -flash/src/main/java/dev/relism/flash/http2/Http2ErrorCode.java -flash/src/main/java/dev/relism/flash/http2/Http2Exception.java -flash/src/main/java/dev/relism/flash/http2/Http2StreamException.java -flash/src/main/java/dev/relism/flash/http/Http1Limits.java -flash/docs/http2/IMPLEMENTATION-PLAN.md (this file) -flash/docs/http2/DECISIONS.md (decision log, see below) -``` - -### Package layout (final; later phases fill it in) - -``` -dev.relism.flash.http2 -├── Http2Limits.java every bound, every default, each with its attack rationale -├── Http2ErrorCode.java the 14 RFC 9113 §7 codes, with pre-encoded 4-byte forms -├── Http2Exception.java connection error → GOAWAY -├── Http2StreamException.java stream error → RST_STREAM -├── Http2Settings.java the 6 SETTINGS params, local + remote, with validation -├── Http2Connection.java the demux loop and connection-level state. ONE responsibility. -├── Http2ConnectionScratch.java all per-connection reusable buffers (extends the shared one) -├── frame/ -│ ├── FrameType.java typed constants + per-type size/flag validation rules -│ ├── FrameFlags.java bitwise flag constants and predicates -│ ├── FrameHeader.java a *flyweight* over the read buffer — never allocated per frame -│ ├── Http2FrameReader.java read 9 bytes + payload into the connection buffer -│ ├── Http2FrameWriter.java the serialized writer (Phase 3) — the only thing that writes -│ └── FrameValidator.java RFC-mandated per-type checks, table-driven -├── hpack/ -│ ├── HpackStaticTable.java 61 entries, precompiled byte[][] + name→index lookup -│ ├── HpackDynamicTable.java ring buffer of (nameOff,nameLen,valOff,valLen) + arena -│ ├── HpackDecoder.java all 6 representations, integer prefix decoding -│ ├── HpackEncoder.java static-table-only encoder (see DEC-04) -│ ├── Huffman.java decode FSM tables + encode LUT, both built at class-init -│ └── HpackIntegers.java prefix-coded integer read/write, overflow-safe -├── stream/ -│ ├── Http2Stream.java per-stream state; also the intrusive MPSC queue node -│ ├── Http2StreamState.java the RFC 9113 §5.1 state machine as an explicit table -│ ├── Http2StreamTable.java int→stream, open-addressed, zero-alloc -│ └── Http2FlowController.java the two-level window accounting -├── message/ -│ ├── Http2HeaderMap.java HeaderMap implementation backed by HPACK output -│ ├── Http2RequestBody.java DATA frames → bounded InputStream -│ └── PseudoHeaders.java :method/:scheme/:authority/:path/:protocol/:status handling -└── upgrade/ - ├── Http2PrefaceDetector.java h2c prior-knowledge detection (Phase 14) - └── ExtendedConnect.java RFC 8441 (Phase 15) -``` - -And, in existing packages: - -``` -dev.relism.flash.transport (new, Phase 2) -├── BoundListener.java -├── ListenerBinder.java -├── AcceptLoop.java -├── ConnectionRunner.java -├── ConnectionProtocol.java the h1/h2 seam -├── ConnectionScratch.java EX-06 fix -├── ScratchPool.java -├── ProtocolNegotiator.java ALPN + preface (Phase 1) -└── ServerLifecycle.java - -dev.relism.flash.http1 (new, Phase 2 — moved out of the god class) -├── Http1Connection.java -├── Http1ResponseWriter.java -├── Http1ChunkedEncoder.java -└── Http1KeepAlive.java - -dev.relism.flash.bytes (new, Phase 4 — protocol-neutral byte utilities) -├── ByteScan.java SWAR + scalar scanning, token lists, case-insensitive cmp -├── ArrayBackedByteView.java capability interface (array/offset) — enables EX-25 -├── SegmentedByteView.java multi-segment view (supportsLong() == false) -├── PooledSlice.java reusable slice, fixes EX-05 -├── ByteWriter.java index-based writes into a growable scratch buffer -└── Pairs.java the (hi<<32)|lo idiom, named and documented -``` - -### Tasks - -1. Create `flash/docs/http2/DECISIONS.md` seeded with the decisions already made in this plan - (`DEC-01` … `DEC-08`, listed in Part VI). Every subsequent non-obvious choice appends an - entry: context, options, decision, consequence. This is how the next agent understands why - the encoder has no dynamic table. -2. Write `Http2ErrorCode` as an enum of the 14 RFC 9113 §7 codes with `code()` and a - **pre-encoded 4-byte big-endian `byte[]`** per constant (used in RST_STREAM and GOAWAY - payloads without formatting). -4. Write `Http2Limits` with every bound this plan will need. Each field gets a Javadoc naming - the attack or resource it bounds and, where applicable, the CVE. Initial contents: - `MAX_CONCURRENT_STREAMS` (100), `MAX_FRAME_SIZE_LOCAL` (16384 initially; tunable), - `MAX_HEADER_LIST_SIZE` (32768), `MAX_CONTINUATION_FRAMES_PER_BLOCK` (8, CVE-2024-27316), - `MAX_RESET_STREAMS_PER_INTERVAL` + `RESET_RATE_INTERVAL_MS` (CVE-2023-44487), - `MAX_SETTINGS_ENTRIES_PER_FRAME`, `MAX_PING_QUEUE_DEPTH`, - `MAX_STREAMS_CREATED_PER_INTERVAL`, `MAX_EMPTY_DATA_FRAMES_PER_STREAM`, - `INITIAL_WINDOW_SIZE_LOCAL`, `CONNECTION_WINDOW_SIZE_LOCAL`, - `HPACK_DYNAMIC_TABLE_SIZE_LOCAL` (4096), `MAX_HPACK_STRING_LENGTH`, - `HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS`, `STREAM_IDLE_TIMEOUT_MS`. -5. Write `Http1Limits` with the h1 bounds required by `EX-03`, `EX-07`, `EX-08`. -6. Define the exception model: - - `Http2Exception` — a **connection** error. Carries an `Http2ErrorCode` and a debug string. - Terminates the connection with GOAWAY. Preallocated singletons for the common codes so the - error path itself does not allocate (with stack traces disabled via the - `(msg, cause, suppression, writableStackTrace)` constructor — document why). - - `Http2StreamException` — a **stream** error. Carries code + stream id. Results in - RST_STREAM; the connection survives. - - Neither extends `IOException`; both are caught explicitly by the connection loop, so a - protocol error is never confused with a socket error. -7. Add `h2` to the allowed commit scopes in `AGENTS.md:39-41` (a `docs:` commit), or record in - `DECISIONS.md` that `core` is used instead. - -### Zero-alloc contract -Constants only; nothing runs at request time in this phase. - -### Tests -`Http2ErrorCodeTest` (round-trip code ↔ pre-encoded bytes), `Http2LimitsTest` (every limit is -positive and internally consistent, e.g. `MAX_FRAME_SIZE_LOCAL` within RFC bounds -16384..16777215). - -### Docs -`flash/docs/http2/DECISIONS.md` created. - -### DoD -- [x] Package skeleton compiles (empty classes are acceptable only for classes whose phase has - not arrived; every class listed above that belongs to Phase 0 is complete). Verified: - `mvn -pl flash -am test` — full module, 226/226 tests green, including the new - `Http2ErrorCodeTest`, `Http2LimitsTest`, `Http2ExceptionTest`, `Http2StreamExceptionTest`, - `Http1LimitsTest` (19 tests). -- [x] `DECISIONS.md` seeded with `DEC-01` … `DEC-08` (seeded with `DEC-01`…`DEC-11`: the extra - `DEC-11` records the AGENTS.md commit-scope choice from task 7 below). -- [x] `Http2Limits` and `Http1Limits` complete, every field documented with its rationale. -- [x] No `TODO` comments anywhere. (This applies to every phase.) Verified by grep. - ---- - -## Phase 1 — HTTP/1.1 hardening and protocol-negotiation plumbing - -**Goal.** Fix the security and correctness debt in the existing HTTP/1.1 parser, and make the -server able to decide "this connection is h1 or h2" without yet being able to speak h2. - -**Why now.** Two reasons. First, `EX-02`, `EX-03`, `EX-07`, `EX-08` and `EX-18` are live -vulnerabilities in shipped code and must not wait behind a large feature. Second, `EX-30` (ALPN -is unreadable) blocks every h2 phase, and fixing it is the natural companion to the negotiation -seam. - -### EX items -`EX-02`, `EX-03`, `EX-07`, `EX-08`, `EX-10`, `EX-17`, `EX-18`, `EX-30`, `EX-31`, plus two found -while implementing this phase and registered in Part II per R10: `EX-35` (multi-value -`Transfer-Encoding` silently misparsed), `EX-36` (a header line with no `:` silently skipped). - -### Files - -Modified: -- `flash/src/main/java/dev/relism/flash/RequestParser.java` -- `flash/src/main/java/dev/relism/flash/ChunkedInputStream.java` -- `flash/src/main/java/dev/relism/flash/HttpServer.java` -- `flash/src/main/java/dev/relism/flash/http/HttpStatus.java` -- `flash/src/main/java/dev/relism/flash/tls/TlsConfig.java` -- `flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java` - -Created: -- `flash/src/main/java/dev/relism/flash/http/Http1Limits.java` (from Phase 0; extended here with - the chunked-transfer bounds for `EX-10`'s safety task) -- `flash/src/main/java/dev/relism/flash/transport/ProtocolNegotiator.java` -- `flash/src/main/java/dev/relism/flash/transport/NegotiatedProtocol.java` (enum: `HTTP_1_1`, `H2`) -- `flash/src/main/java/dev/relism/flash/transport/BufferedByteSource.java` — **plan correction**: - task 8 below requires this class (the buffered, deadline-aware, peekable source `EX-10`'s fix - and `EX-07`'s absolute-deadline requirement both need), but it was missing from this phase's - original Files list. Added here; recorded as `DEC-12` in `DECISIONS.md`. -- `flash/src/main/java/dev/relism/flash/exceptions/MalformedRequestException.java` — likewise - not originally listed: the typed, status-carrying rejection `EX-02`/`EX-03`/`EX-08`/`EX-18` - all need to tell `HttpServer` which status to respond with, as distinct from - `HttpException` (which routes through the user's handler chain — a malformed request must - not). Recorded alongside `DEC-12`. - -### Tasks - -1. **Strict `Content-Length` parsing** (`EX-03`). Replace `RequestParser.parseLong` with a - strict parser: empty → reject; any byte outside `'0'..'9'` → reject; more than 19 digits → - reject; value > `Http1Limits.MAX_CONTENT_LENGTH` → reject with `413`. Return `-1` as the - "invalid" sentinel and raise a typed `HttpException` mapped to `400`. -2. **Reject `Content-Length` + `Transfer-Encoding`** (`EX-02`). Track both as booleans during - the header scan. Both present → `400`, connection closed (never keep-alive: a smuggling - attempt must not leave a reusable connection). Multiple `Content-Length` lines with - different values → `400`. `Transfer-Encoding` whose last coding is not `chunked` → `501`. -3. **Reject bare CR/LF desync** (`EX-18`). After locating `\r` at `lineEnd`, assert - `buffer[lineEnd + 1] == '\n'` before advancing; otherwise `400`. Also reject a header line - that begins with whitespace (obs-fold, deprecated by RFC 9112 §5.2 and a smuggling vector) - with `400`. -4. **Header count and size limits** (`EX-08`). Count headers during the scan; enforce - `MAX_HEADER_COUNT`, `MAX_HEADER_NAME_LENGTH`, `MAX_HEADER_VALUE_LENGTH`. Enforce - `MAX_REQUEST_LINE_LENGTH` against `headerEndIdx - base` for the request line specifically. - Over-limit → `431 Request Header Fields Too Large` (added in task 6). -5. **Header name charset validation.** Reject any header name byte outside the RFC 9110 `tchar` - set. Currently a name containing a space or a control character is accepted. Table-driven: - a `boolean[256]` (or a 4-`long` bitmap for cache friendliness) built at class-init — a - precompilation opportunity per R4. -6. **`HttpStatus` bound fix and additions** (`EX-17`). Compute `MAX_STATUS_CODE` from - `values()`. Add `MISDIRECTED_REQUEST(421)`, `REQUEST_HEADER_FIELDS_TOO_LARGE(431)`, - `EXPECTATION_FAILED(417)`, `PRECONDITION_FAILED(412)`, `RANGE_NOT_SATISFIABLE(416)`, - `INSUFFICIENT_STORAGE(507)`, `NETWORK_AUTHENTICATION_REQUIRED(511)`, and - `HTTP_VERSION_NOT_SUPPORTED(505)`. -7. **Timeouts** (`EX-07`). Add `headerReadTimeoutMs`, `idleKeepAliveTimeoutMs`, - `bodyReadTimeoutMs`, and `shutdownDrainTimeoutMs` to `FlashConfiguration` with defaults - 10 000 / 60 000 / 30 000 / 15 000. Apply via `Socket.setSoTimeout` around the appropriate - read phases, switching the value as the connection moves between idle-wait, header-read and - body-read. Document that `setSoTimeout` is per-read, so a slowloris sending one byte per - 9 seconds needs the additional absolute deadline check on the header loop — implement that - deadline, do not rely on `setSoTimeout` alone. -8. **Buffered chunked reads** (`EX-10`). `ChunkedInputStream` must read through the connection's - buffered source, not the raw socket stream. Concretely: introduce a - `BufferedByteSource` owned by the connection that wraps the read buffer plus the socket and - exposes `readByte()`, `readFully(byte[],int,int)`, `skip(long)` and `peek()` without - syscalls per byte. `RequestParser` and `ChunkedInputStream` both consume it. This also - removes the `SequenceInputStream`/`ByteArrayInputStream` construction in - `ChunkedInputStream`'s constructor. -9. **Chunk-size safety.** `readChunkSize` must reject: more than 16 hex digits, a size above - `Http1Limits.MAX_CHUNK_SIZE`, a chunk-extension longer than `MAX_CHUNK_EXT_LENGTH`, and more - than `MAX_CHUNKS_PER_BODY` chunks (a "many zero-length chunks" DoS). Trailer section bounded - by `MAX_TRAILER_COUNT` and `MAX_HEADER_VALUE_LENGTH`. -10. **ALPN readability** (`EX-30`). In the connection runner, if the socket is an `SSLSocket`, - call `startHandshake()` explicitly (under `headerReadTimeoutMs`) before protocol dispatch. - Add `TlsConfig.negotiatesH2()` so the negotiator knows whether to even look. -11. **h2 cipher constraints** (`EX-31`). When `applicationProtocols` contains `h2`, filter - enabled cipher suites against the RFC 9113 Appendix A blocklist in `TlsConfig.applyTo`. - The blocklist is a `Set` built once in a static initializer. Document that TLS 1.3 - suites are unaffected and that this only narrows TLS 1.2. -12. **`ProtocolNegotiator`**. A single class with one method: - `NegotiatedProtocol negotiate(Socket socket, BufferedByteSource source)`. - Logic, in order: - - If `SSLSocket` and `getApplicationProtocol()` equals `"h2"` → `H2`. - - If `SSLSocket` and it equals `"http/1.1"` or is null/empty → `HTTP_1_1`. - - If plain and the first 24 bytes peeked from `source` equal the client connection preface - `PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n` → `H2` (h2c prior knowledge; wired up in Phase 14, but - the detection lives here from the start so there is one place that decides). - - Otherwise → `HTTP_1_1`. - The peek must not consume: `BufferedByteSource.peek(int n)` fills the buffer without - advancing the read position. This is why the buffered source (task 8) comes first. - Note for the implementer: today an h2c prior-knowledge client gets - `"Unsupported HTTP method"` from `HttpMethod.fromBytes`, because the `'P'` branch - (`http/HttpMethod.java:26-32`) tests for `PUT`/`POST`/`PATCH`/`PURGE` and `PRI` matches - none. Confirm this is no longer reachable after the negotiator lands. -13. In this phase the negotiator's `H2` result leads to a clean rejection, not an h2 session: - for TLS, respond by closing after sending nothing (the client will retry h1 per ALPN - semantics only if we did not select h2 — so **do not offer `h2` in ALPN yet**; the - negotiator is exercised only by tests until Phase 8). For plain h2c preface, close. - Add a `FlashConfiguration.http2Enabled` flag, default `false`, which gates both offering - `h2` in ALPN and accepting the h2c preface. It flips to `true` in Phase 12's DoD. - -### Zero-alloc contract -- The strict `Content-Length` parser, the token/charset validators and the limit checks must - allocate nothing. No `String` is constructed for validation. -- `BufferedByteSource` allocates its buffer once per connection. -- Error paths may allocate (they terminate the connection), but the pre-encoded error response - bodies must come from `AbstractRouter`'s existing precompiled constants where a status - already has one. - -### Safety checks (checklist — all mandatory) -- [x] `Content-Length` strict-numeric, bounded, single-valued — `RequestParserSecurityTest` -- [x] `Content-Length` + `Transfer-Encoding` rejected, regardless of order — `RequestParserSecurityTest` -- [x] Non-`chunked` final transfer coding rejected — `RequestParserSecurityTest` -- [x] Bare CR / missing LF rejected — `RequestParserSecurityTest` -- [x] obs-fold (leading whitespace continuation line) rejected — `RequestParserSecurityTest` -- [x] Header name `tchar` validated — `RequestParserSecurityTest` -- [x] Header count / name length / value length / request-line length bounded — `RequestParserSecurityTest` -- [x] Chunk size, chunk count, chunk-extension length, trailer count bounded — `ChunkedInputStreamTest` -- [x] Header-read absolute deadline enforced (not just `setSoTimeout`) — `HttpServerTimeoutTest.slowlorisHeaderDribble_disconnectedWithinHeaderReadTimeout` -- [x] Idle keep-alive timeout enforced — `HttpServerTimeoutTest.idleKeepAliveConnection_disconnectedWithinIdleTimeout` -- [x] Body-read timeout enforced — `HttpServerTimeoutTest.slowBodyDribble_disconnectedWithinBodyReadTimeout` -- [x] TLS handshake covered by a timeout — `HttpServerTimeoutTest.tlsHandshakeNeverStarted_disconnectedWithinHeaderReadTimeout` - -### Tests -- `RequestParserSecurityTest` — one test per rejection above, each asserting both the status - code and that the connection is closed (not kept alive). -- `RequestParserTest` — existing tests must still pass unmodified except where they encoded the - buggy behaviour; any such change is called out in the PR description with justification. -- `ChunkedInputStreamTest` — extended with malformed-input cases and a syscall-count assertion - (via a counting `InputStream` wrapper) proving the per-byte syscalls are gone. -- `HttpServerTimeoutTest` — slowloris simulation: a client that dribbles bytes must be - disconnected within `headerReadTimeoutMs` ± tolerance. -- `ProtocolNegotiatorTest` — ALPN `h2`, ALPN `http/1.1`, ALPN absent, h2c preface, partial - preface, preface-lookalike (`PRI ` followed by garbage), plain `GET`. -- `TlsConfigTest` — extended for cipher filtering when `h2` is offered. - -### Docs -- `README.md`: new `FlashConfiguration` timeout fields documented in the config table - (lines 161-170). -- New `flash/docs/http2/HTTP1-HARDENING.md` listing every rejection rule and its RFC citation, so - operators can understand a `400` in their logs. - -### DoD -- [x] Every checklist item above is implemented and tested. -- [x] `mvn test` green. Full `flash` module: 277/277, run twice in a row for timing-test stability - (the four `HttpServerTimeoutTest` cases are wall-clock-based). -- [x] No behavioural change to well-formed HTTP/1.1 traffic (verified by the existing test - suite passing unmodified — the only test-file edits were signature updates for - `RequestParser.parse(BufferedByteSource)` and exception-type/status updates for the small - number of existing tests that asserted the pre-fix buggy behaviour, e.g. a 5 GB - `Content-Length` being silently accepted, or an unrecognised method producing a bare - `IOException` instead of a typed `501`; each such change is called out in the Phase 1 - commit). -- [ ] h1 benchmark shows no regression beyond noise (baseline captured before the phase). **Not - verified — no JMH harness exists yet; it is a Phase 3 deliverable.** Left unchecked - rather than claimed. Once Phase 3 adds the harness, an h1 GET benchmark should be run - against the pre-Phase-1 commit and against this one before Phase 3 is considered started, - so this box can be resolved retroactively. - ---- - -## Phase 2 — Transport decomposition - -**Goal.** Break `HttpServer` (563 lines, eleven responsibilities) into named, single-purpose -components, introduce the per-connection scratch object, and create the seam where a second -protocol will plug in — without changing any observable behaviour. - -**Why now.** Phase 3's writer needs a connection-scoped home. Phases 10+ need a place to hang -an h2 connection that is not "inside a 563-line class that also does WebSocket handshakes". -And `EX-06` (`ThreadLocal` on virtual threads) is a production memory hazard that the h2 work -would multiply. - -### EX items -`EX-01`, `EX-06`, `EX-11`, `EX-12`, `EX-13`, `EX-14`, `EX-15`, `EX-16`, `EX-32`, `EX-34`. - -### Files - -Created — `dev.relism.flash.transport`: -- `BoundListener.java` — the record currently nested in `HttpServer` (line 108), promoted. -- `ListenerBinder.java` — `HttpServer.bind` (lines 193-208), extracted. Sole responsibility: - turn a `FlashConfiguration.Listener` into a bound `ServerSocket`. -- `AcceptLoop.java` — `HttpServer.acceptLoop` (lines 238-250) plus the accept-thread spawning - from `start()` (lines 213-223). -- `ConnectionRunner.java` — the body of `HttpServer.process` (lines 273-369) minus everything - protocol-specific. Sole responsibility: own the socket lifecycle, configure socket options, - acquire a `ConnectionScratch`, run the negotiator, hand off to a `ConnectionProtocol`, - guarantee cleanup. -- `ConnectionProtocol.java` — the seam: - ```java - interface ConnectionProtocol { - /** Runs this connection to completion. Returns when the connection should be closed. */ - void run(ConnectionContext ctx) throws IOException; - } - ``` -- `ConnectionContext.java` — socket, streams, remote address, `SSLSocket` or null, - `BufferedByteSource`, `ConnectionScratch`, the routers, the configuration, a `stopped` - supplier. One object passed down instead of eight parameters. -- `ConnectionScratch.java` — **the `EX-06` fix.** Owns: decimal-format buffer (20 B), relay - buffer (8 KB), `MessageDigest` for the WS handshake, router `MatchResult`, - `MethodPathByteView`, reusable `PathParams`, reusable `Response`, reusable `Request`, - reusable `RequestBody`, the response head scratch buffer, and (from Phase 3) the h2 write - scratch. Allocated once per connection, returned to `ScratchPool` on close. -- `ScratchPool.java` — a bounded pool (`ConcurrentLinkedQueue` + an `AtomicInteger` size guard, - or a striped free-list if contention shows in the benchmark). Bound default: - `min(availableProcessors * 64, 4096)`. Above the bound, `release()` drops the scratch for GC - instead of growing forever. Documented: this is a *cache*, not a leak-free arena — a burst of - 100 k connections allocates 100 k scratches, but only the bound survives it. -- `ServerLifecycle.java` — `start`/`startAndBlock`/`stop`, the `acceptLatch`, the - `activeSockets` set, and the two-stage graceful shutdown (`EX-32`). -- `TransportFactory.java` — package-private construction, consumed by `ServerHandle.create` - (`EX-34`). - -Created — `dev.relism.flash.http1`: -- `Http1Connection.java` — implements `ConnectionProtocol`. The keep-alive request loop - (`HttpServer.process` lines 303-345). Sole responsibility: drive request→route→handle→respond - for one connection. -- `Http1ResponseWriter.java` — `writeResponse`, `writeStreamingBody`, `relay`, - `writeStatusPhrase`, `writeLong`, `writeHex`, `writeChunked` (lines 469-563). -- `Http1ChunkedEncoder.java` — split out of the above if it does not stay trivially small. -- `Http1KeepAlive.java` — `isKeepAlive` (line 454), fixed per `EX-13`. - -Created — `dev.relism.flash.websocket`: -- `WebSocketUpgrade.java` — `isWebSocketUpgrade`, `connectionContainsUpgrade`, - `tokenEqualsIgnoreCase`, `performHandshake` (lines 373-424). -- `WebSocketLoop.java` — `runWsLoop` (lines 428-450). -- `WebSocketFrameCodec.java` — frame header encode/decode extracted from `WebSocketSession`. - -Modified: -- `HttpServer.java` — **deleted**, or reduced to a thin `ServerHandle` implementation that - composes the above. Prefer deletion; `ServerHandle` is the public contract and - `TransportFactory` can build a `FlashTransport` that implements it. -- `WebSocketSession.java` — `EX-01`, `EX-11`, `EX-12`. -- `ServerHandle.java` — `EX-34`. -- `FastPathRouterImpl.java` — drop `FastPathRouterContext`'s `ThreadLocal`s in favour of the - scratch (`EX-06`); the router now takes the scratch as a parameter or reads it from the - request's context. -- `models/Response.java`, `models/Request.java` — only as needed to accept a scratch; the full - pooling refactor is Phase 6. -- `http/DateHeader.java` — new (`EX-16`). - -### Tasks - -1. **Extract in the order listed above**, one commit per extracted component, each commit - green. Do not combine extraction with behaviour change except where an `EX` item explicitly - requires it — and when it does, make it a separate commit immediately after the extraction - commit, so `git log` shows "moved" and "fixed" separately. -2. **`ConnectionScratch` + `ScratchPool`** (`EX-06`). Remove every `ThreadLocal` from - `HttpServer` and `FastPathRouterImpl`. Correct the false Javadoc at `HttpServer.java:56-58` - as part of the move — the replacement documentation must state plainly: *"With virtual - threads, a `ThreadLocal` is per connection, not per core. Scratch is therefore explicit and - pooled."* -3. **`ReentrantLock` for WebSocket writes** (`EX-01`). Replace both `synchronized (out)` blocks. - Add a Javadoc note explaining the Java 21 pinning rationale and referencing JEP 491, so that - whoever moves the project to JDK 24+ knows the constraint can be revisited. -4. **WebSocket frame header bulk read** (`EX-11`) and **full RFC 6455 validation** (`EX-12`): - continuation-frame reassembly with a bounded total message size, mandatory client masking - enforcement, opcode validation, control-frame constraints (≤125 bytes, FIN set, not - fragmented), correct close codes. -5. **`Connection` token-list parsing** (`EX-13`). One shared scanner in - `dev.relism.flash.bytes.ByteScan` (created ahead of Phase 4 if needed, or temporarily in - `WebSocketUpgrade` and moved in Phase 4 — prefer creating `ByteScan` now). -6. **HEAD suppression** (`EX-14`) in `Http1ResponseWriter`: compute and emit `Content-Length`, - skip the body write. -7. **Content-Type / Content-Length correctness** (`EX-15`): skip empty `Content-Type`; skip - `Content-Length` for 204/304/1xx; skip the body for those statuses too. -8. **`DateHeader`** (`EX-16`): one daemon thread, `volatile byte[]` holding the complete - pre-encoded `Date: Sun, 06 Nov 1994 08:49:37 GMT\r\n` line, refreshed every second, written - by `Http1ResponseWriter` with a single `write(byte[])`. Add a `FlashConfiguration.sendDate` - flag (default `true`) for users who front Flash with a proxy that already adds it. -9. **Graceful shutdown** (`EX-32`): `ServerLifecycle.stop()` becomes two-stage — stop accepting, - mark connections draining (h1 sets `Connection: close` on the next response; h2 will send - GOAWAY in Phase 8), wait up to `shutdownDrainTimeoutMs`, then force-close. -10. **Verify no behaviour change** for everything not covered by an `EX` item. The existing - test suite is the oracle; it must pass without modification apart from import updates. - -### Zero-alloc contract -Strictly better than before this phase: the per-virtual-thread `ThreadLocal` allocations are -replaced by pooled per-connection scratch, and the WebSocket header read stops allocating -nothing but stops syscalling per byte. No new steady-state allocation is introduced. - -### Safety checks -- [x] `ScratchPool` is bounded and cannot grow without limit — `ScratchPoolTest.bound_isRespected_excessReleasesAreDropped` -- [x] A scratch is always released, including on exception paths (try/finally in - `ConnectionRunner.handle`) — `ConnectionRunnerTest.scratchAndActiveSocketEntry_alwaysReleased_evenWhenTheProtocolThrows` -- [x] A scratch returned to the pool is fully reset; no request data leaks between connections — - `ScratchPoolTest.reset_clearsTheMessageDigestState` -- [x] WebSocket: unmasked client frame → close 1002 — `WebSocketFragmentationAndValidationTest.serverSession_unmaskedIncomingFrame_rejected1002` -- [x] WebSocket: message exceeding the bound → close 1009 — `WebSocketFragmentationAndValidationTest.reassembledMessageExceedingBuffer_rejected1009` -- [x] WebSocket: invalid opcode → close 1002 — `WebSocketFragmentationAndValidationTest.reservedOpcode_rejected1002` -- [x] WebSocket: fragmented control frame → close 1002 — `WebSocketFragmentationAndValidationTest.fragmentedControlFrame_rejected1002` - -### Tests -- [x] All existing tests pass with only import changes (277 pre-Phase-2 tests unmodified in - behavior; two files touched only for the log-string/class-relocation, see PR). -- [x] `ScratchPoolTest` (covers the `ConnectionScratchTest` scope named here) — pool bound - respected; reset clears digest state; a scratch reused across two acquisitions is proven - `assertSame` and proven reset. -- [x] `WebSocketFragmentationAndValidationTest` (covers the `WebSocketFrameCodecTest` scope - named here, kept inside `WebSocketSession` rather than a separate codec class — see - `TRANSPORT.md`) — continuation reassembly, masking enforcement, control-frame rules, - syscall count (`readFrame_withExtendedLengthAndMask_doesNotReadOneByteAtATime`). -- [x] `Http1ResponseWriterTest` — HEAD, 204, 304, 1xx, `ContentType.NONE`, `Date` present/absent. -- [x] `ServerLifecycleGracefulShutdownTest` (named `ServerLifecycleTest` here) — graceful drain - completes an in-flight request (forced to `Connection: close`); listener stops accepting - immediately. -- [x] `PackageBoundaryTest` — a source-scan architecture test (decision recorded in the test's - own Javadoc: no ArchUnit dependency yet, and one import check per package pair does not - need one): `dev.relism.flash.http1` must not import `dev.relism.flash.http2` and vice versa. - -### Docs -- `README.md` architecture section (lines 257-274) rewritten to reflect the new component - layout. -- `flash/docs/http2/TRANSPORT.md` — the transport architecture: listeners, accept loop, connection - runner, scratch pooling, the `ConnectionProtocol` seam. This is the document the h2 phases - will extend. - -### DoD -- [x] `HttpServer.java` no longer exists (deleted; `TransportFactory` + `ServerLifecycle` + - `ConnectionRunner` + `Http1Connection` replace it). -- [x] No `ThreadLocal` remains in the transport/connection layer that `HttpServer` owned - (`SHA1`, `LONG_BUF`, `STREAM_RELAY_BUFFER` — all moved into `ConnectionScratch`). - **Corrected wording** (`DEC-15`): the plan text originally read "No `ThreadLocal` remains - anywhere in `flash` core" unconditionally, which contradicts `EX-06`'s own registry entry - — that entry explicitly phases the fix as "Phase 2 (introduce), 3 (h2 consumes it), 4 - (router consumes it)". `FastPathRouterImpl`'s and `FastPathWsRouterImpl`'s `ThreadLocal`s - remain until Phase 4, which is also when the router gains the scratch-parameter API - surface change needed to remove them correctly. Verified by grep: the only - `main`-source `ThreadLocal` occurrences left are those two files (plus incidental, - unrelated `ThreadLocalRandom` usage in `WebSocketSession`, a different class entirely). -- [x] No `synchronized` block in `flash` core encloses a blocking I/O call. Verified by grep + - review: `WebSocketSession`'s two blocking-write sites now use `ReentrantLock` (`EX-01`); - the two remaining `synchronized (this)` blocks (`FastPathRouterImpl`/`FastPathWsRouterImpl` - `ensureCompiled()`) guard an in-memory route-table compile with no I/O at all. -- [x] Every extracted class has a class-level Javadoc naming its single responsibility. -- [ ] h1 benchmark: no regression; ideally an improvement from `EX-06` and `EX-11`. **Not - verified — no JMH harness exists yet** (Phase 3 deliverable, same caveat as Phase 1's - DoD). Functional regression-free is verified instead: the full pre-existing `flash` test - suite passes unmodified against the decomposed transport. - ---- - -## Phase 3 — The serialized frame writer · **GO/NO-GO GATE** - -**Goal.** Build and prove the one component whose failure would invalidate the entire project: -the connection-level serialized writer, with a happy path that costs one uncontended CAS. - -**Why now.** This is the only genuinely novel architectural risk in HTTP/2 for a codebase built -on "one thread owns the socket". Everything else — frames, HPACK, flow control — is -well-understood table-driven work with known cost. If the writer cannot deliver, the project -should stop here having spent one phase, not ten. - -**This phase is deliberately placed before the frame parser**, which is the fun part and also -the least risky part. - -### The problem, precisely - -Today, one thread owns the socket and writes to it without coordination. -`Http1ResponseWriter` issues a sequence of writes and nobody else is writing. - -Under HTTP/2, N streams share one connection and their frames must interleave. Every write must -pass through a serialization point that does not exist today. A lock taken naively per frame -costs more than every allocation the codebase has ever saved. - -### The design (three layers) - -**Layer 1 — serialize outside the lock.** -Never hold the lock across many small writes. A stream builds its complete output (frame -header + HPACK block + payload) into a **per-stream scratch buffer, reused**, then takes the -lock once and issues a **single** bulk `write`. The lock is held for the duration of a -`System.arraycopy` into the connection's output buffer (or one `write` syscall), not for a -serialization. This is why `EX-27` (collapse `writeResponse` into one write) is a prerequisite -and lands in Phase 6 for h1 too. - -**Layer 2 — `ReentrantLock`, never `synchronized`.** -Java 21: a virtual thread blocking inside `synchronized` pins its carrier; -blocking on a `ReentrantLock` unmounts it. Non-negotiable. See `EX-01`. - -**Layer 3 — `tryLock()` fast path with an intrusive MPSC fallback.** -The overwhelmingly common instant, even on a multiplexed connection, has exactly **one** stream -wanting to write: a browser calling one API endpoint, a gRPC unary call. In that case -`tryLock()` on an uncontended lock is **one successful CAS**; the thread writes inline and -releases. No handoff, no queue, no allocation, no context switch. - -When `tryLock()` fails — i.e. there is genuine contention, i.e. you are genuinely multiplexing — -the stream publishes its pending write and returns. The current lock holder drains the queue -before releasing. The queue is an **intrusive** Vyukov-style MPSC linked queue: `Http2Stream` -*is* the node (it carries a `next` field), so enqueue is one CAS and zero allocation. - -``` -happy path (1 active writer): tryLock → memcpy → write → unlock ≈ 1 CAS -contended (N active writers): tryLock fails → CAS enqueue → return - current holder drains before unlocking -``` - -Correctness requirement: **no lost wakeup.** The classic hazard is: producer enqueues, then the -holder checks the queue and finds it empty, then unlocks — leaving the item stranded. The -standard fix is the re-check-after-unlock pattern: after `unlock()`, re-read the queue head; if -non-empty, attempt `tryLock()` again and drain. This must be implemented deliberately, with the -race documented in the Javadoc, and verified by a dedicated stress test. - -### Files - -Created: -- `flash/src/main/java/dev/relism/flash/http2/frame/Http2FrameWriter.java` -- `flash/src/main/java/dev/relism/flash/http2/frame/WriteIntent.java` — the interface a stream - implements to describe "serialize yourself into this buffer". Implemented by `Http2Stream` - and by connection-level singletons (SETTINGS ACK, PING ACK, GOAWAY, WINDOW_UPDATE) so that - connection frames use the same path as stream frames — one writer, no exceptions. -- `flash/src/main/java/dev/relism/flash/http2/frame/IntrusiveMpscQueue.java` — the Vyukov queue, - operating on a `Node` interface that `Http2Stream` implements. -- `flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameWriterTest.java` -- `flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameWriterStressTest.java` -- `flash/src/jmh/java/dev/relism/flash/http2/FrameWriterBenchmark.java` (or a `flash-bench` - submodule — decide and record in `DECISIONS.md`; a `jmh` profile on the `flash` module is - simplest and avoids a new artifact). - -Modified: -- Root `pom.xml` — add a `jmh` profile with `jmh-core` and `jmh-generator-annprocess`. Not - bound to the default build; CI runs it in a separate, non-blocking job until Phase 17 turns - the gate on. - -### Tasks - -1. Implement `Http2FrameWriter` with the three-layer design. Public surface, deliberately tiny: - ```java - /** Serializes and writes one frame. Returns when the bytes are in the socket buffer - * or safely queued behind another writer. Never blocks on another stream's I/O - * while holding the lock. */ - void write(WriteIntent intent) throws IOException; - - /** Flushes any queued intents. Called by the demux loop when it has nothing to read. */ - void drain() throws IOException; - ``` -2. Implement `IntrusiveMpscQueue` with `offer(Node)` (one CAS on the tail) and `poll()` - (producer-consumer safe, single consumer — the lock holder). Document the memory-ordering - requirements explicitly (which fields are `volatile`, which use `VarHandle` - `setRelease`/`getAcquire`). Prefer `VarHandle` over `AtomicReferenceFieldUpdater`. -3. Implement the lost-wakeup-free unlock protocol and document it with an ASCII interleaving - diagram in the Javadoc. -4. Handle the **partial-write / backpressure** case: if the socket write blocks because the - kernel send buffer is full, the writer is holding the lock while blocked. This is - unavoidable (someone must block) but must not pin a carrier — hence `ReentrantLock` — and - must be bounded by a write timeout so a stalled peer cannot hold the connection's writer - forever. Add `Http2Limits.WRITE_TIMEOUT_MS` and a documented behaviour (write timeout → - connection error → GOAWAY → close). -5. Write the stress test: N producer virtual threads (N ∈ {1, 2, 8, 64, 256}) each writing M - frames with distinguishable payloads into a mock sink; assert every byte of every frame - arrives, in a valid frame-boundary-respecting order (frames may interleave with each other, - but a single frame's bytes must never be split by another frame's bytes), with no - duplication and no loss. Run under `-Djdk.virtualThreadScheduler.parallelism=1` as well, to - surface pinning and lost wakeups. -6. Write the JMH benchmark measuring, for N ∈ {1, 2, 4, 8, 16, 64} concurrent writer virtual - threads: throughput (frames/s), latency percentiles (p50/p99/p999), and - `gc.alloc.rate.norm`. Also benchmark the three candidate designs against each other so the - choice is defended by numbers, not assertion: - - (a) plain `ReentrantLock.lock()` per frame - - (b) `tryLock()` + intrusive MPSC (the proposed design) - - (c) a dedicated writer virtual thread fed by the MPSC queue (always-handoff) -7. Record the results in `flash/docs/http2/DECISIONS.md` as `DEC-09`, with the raw numbers. - -### Zero-alloc contract -- `write(WriteIntent)` must be **0 B/op** on both the uncontended and the contended path. - Verified by `-prof gc` in the benchmark. This is the phase's hardest requirement: it rules - out lambda capture, `Optional`, boxed integers in the queue, and any per-call node object. -- The intrusive queue allocates nothing per enqueue by construction. - -### Safety checks -- [x] Write timeout bounded and enforced (`Http2Limits.WRITE_TIMEOUT_MS`, scan-based reaper — - see `WriteTimeoutReaper`, and `WRITER.md`'s "Write timeout" section for why it is scan-based - rather than a per-write deadline) -- [x] Lost-wakeup protocol implemented and stress-tested (`Http2FrameWriterStressTest`, 5 N - values × 1000 iterations × 2 scheduler configurations, 10 000/10 000 green — see `WRITER.md`) -- [x] A frame's bytes are never interleaved with another frame's bytes (proven by the stress - test's frame-boundary reassembly/validation, not merely asserted) -- [x] Queue depth bounded — each `WriteIntent` is at most one node (intrusive linkage via - `mpscNext`/`setMpscNext`), so queue depth is inherently bounded by the number of distinct - intents that can be concurrently in flight, not by an unbounded external counter -- [x] Exception inside a sink write does not leave the lock held or the queue corrupted - (`Http2FrameWriterTest#exceptionFromSink_doesNotLeaveTheLockHeld`) - -### Gate criteria — the project continues only if all of these hold -- [x] N=1: **0 B/op** (0.0015 B/write differential vs. baseline, within measurement noise), and - per-frame overhead versus a raw unsynchronized write is within **50 ns** (42.6 ns point - estimate, ≤47.9 ns at the 99.9% CI's worst case). -- [x] N=64: throughput does not collapse (**65.5 %** of the N=1 per-thread aggregate, ≥ the - required 60 %) and p999 latency stays under **1 ms** (11.8–14.2 µs measured; see `WRITER.md` - for the honest caveat that this uses an in-memory sink, not a real loopback socket). -- [x] No carrier pinning observed under `-Djdk.tracePinnedThreads=full`. -- [x] The stress test is green at every N, 1000 iterations, including with parallelism=1 - (10 000/10 000 across both scheduler configurations). - -All criteria met — **GO**. Full numbers, methodology, and the three-design comparison are in -`flash/docs/http2/WRITER.md` and `DECISIONS.md` (`DEC-09`). - -### Docs -- [x] `flash/docs/http2/WRITER.md` — the full design, the three layers, the lost-wakeup protocol with its - diagram, the benchmark numbers, and the explicit statement of what the design costs on the - happy path (one uncontended CAS) versus what it saves. - -### DoD -- [x] All gate criteria met and recorded. -- [x] `DEC-09` written with raw numbers. -- [x] `flash/docs/http2/WRITER.md` complete. - ---- - -## Phase 4 — Byte-layer foundations - -**Goal.** Extract and strengthen the protocol-neutral byte machinery that both protocols use, -and cash in the allocation and scanning wins that the existing code left on the table. - -**Why now.** Every subsequent phase consumes these primitives. Doing it after the frame reader -would mean rewriting the frame reader. - -### EX items -`EX-04`, `EX-05`, `EX-09`, `EX-19`, `EX-25`, `EX-26`, `EX-33` — **plan correction**: `EX-06`'s -router half (removing `FastPathRouterImpl`/`FastPathWsRouterImpl`'s `ThreadLocal`s) belongs here -too, per `EX-06`'s own registry text ("Phase: 2 (introduce), 3 (h2 consumes it), **4 (router -consumes it)**") and `ConnectionScratch`'s own Phase-2-era Javadoc, but was missing from this -line — the same class of omission `DEC-12` already recorded for Phase 1. Fixed in place here; -see `DECISIONS.md`, `DEC-19`, for the router-half fix itself (and why it does not extend -`ConnectionScratch` as that Javadoc originally assumed). - -### Files - -Created — `dev.relism.flash.bytes`: -- `ByteScan.java` — the single home for: `indexOf(byte)`, `indexOfCrLfCrLf` (SWAR), - `equalsIgnoreCase(view/array, String)`, `equalsIgnoreCaseAscii(array, array)`, - token-list iteration (`Connection: a, b, c`), `tchar` validation, hex/decimal parsing, - and the case-insensitive 32-bit name hash used by the header index. - Every method static, every method zero-alloc, every method with a scalar reference - implementation used by tests as the oracle for the SWAR version. -- `ArrayBackedByteView.java` — capability interface: - ```java - public interface ArrayBackedByteView extends ByteView { - byte[] array(); - int offset(); - } - ``` - Implemented by every contiguous view. Enables single-allocation `String` construction - (`EX-25`) and single-`System.arraycopy` copies. -- `SegmentedByteView.java` — a view over K segments (`byte[][]` + offsets + lengths), for the - rare HPACK block that spans CONTINUATION frames. Returns `false` from `supportsLong()`. - Reusable: `reset(segments, offsets, lengths, count)`. -- `PooledSlice.java` — reusable slice implementing `ArrayBackedByteView`, with an explicit - documented lifetime. Replaces the anonymous views in `HeaderMap`, `QueryParams`, `PathParams`. -- `SlicePool.java` — a small fixed-size ring of `PooledSlice` per `ConnectionScratch`. -- `ByteWriter.java` — index-based writes into a growable `byte[]`: `writeByte`, - `writeBytes(byte[])`, `writeBytes(byte[],int,int)`, `writeDecimal(long)`, `writeHex(int)`, - `writeAsciiLower(String)`, `writeUInt16/24/31/32` (big-endian, for h2 frames). Bounds-checked - growth, never allocates when the buffer already fits. This is what both - `Http1ResponseWriter` and `Http2FrameWriter` serialize into. -- `Pairs.java` — `pack(int hi, int lo)`, `hi(long)`, `lo(long)`, documented as the - allocation-free pair return idiom; replaces the four hand-rolled copies of - `((long) x << 32) | y` in `HeaderMap`, `QueryParams` and elsewhere. - -Modified: -- `routing/routers/fastpathrouter/FastPathViews.java` — `RequestByteView`, `SocketByteView`, - `StringByteView` implement `ArrayBackedByteView` **and** override - `supportsLong()`/`longAt(int)` (`EX-04`). `MethodPathByteView` keeps the `false` default and - gains a Javadoc explaining why (it is segmented by construction). -- `models/HeaderMap.java` — header index (`EX-09`), pooled slices (`EX-05`). -- `models/QueryParams.java` — pooled slice, clean-value fast path (`EX-26`). -- `models/PathParams.java` — pooled slice, reusable arrays, single-allocation `get` (`EX-25`). -- `models/Request.java` — single-allocation `path()` (`EX-25`). -- `routing/routers/fastpathrouter/FastPathRouterImpl.java` — reusable `PathParams` from the - scratch (`EX-19`). -- `RequestParser.java` — consume `ByteScan` instead of its private `find`/`equalsIgnoreCase` - helpers; SWAR header-end scan (`EX-33`). - -### Tasks - -1. Build `ByteScan` with paired scalar and SWAR implementations. The SWAR CRLFCRLF scan uses - the standard "has zero byte" bit trick on `long`s read via `VarHandle` - (`MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.nativeOrder())` — note - native order, and document why endianness does not matter for a byte-equality scan but does - for the position extraction). **Property-test SWAR against scalar** on random inputs of every - length 0..256 with the target at every position, including unaligned starts. -2. `supportsLong()`/`longAt()` on contiguous views (`EX-04`). `longAt(i)` reads 8 bytes at - `offset + i` via the same `VarHandle`, with the contract that the caller guarantees - `i + 8 <= length()` — matching whatever `fpr-core`'s `ByteCompare` assumes. **Verify the - assumed contract by testing against `fpr-core` directly**, not by reading its bytecode: write - a test that builds a router with long literal segments and asserts matches are still correct - after enabling the long path. A wrong endianness or a wrong bounds assumption here produces - silently mis-routed requests, which is the worst possible failure mode. -3. Measure the `EX-04` win on the h1 router benchmark. If it is negative or within noise, - record that in `DECISIONS.md` and keep the implementation anyway only if it is neutral; - revert if it costs. -4. Header index (`EX-09`): at `HeaderMap.reset()`, populate reusable `int[]` arrays with per - header `(nameOff, nameLen, valOff, valLen)` and a parallel `int[]` of case-insensitive name - hashes. `findFirst(String)` computes the name hash once (the name is usually a compile-time - constant at the call site — consider a `HeaderName` value type with a cached hash for - library-internal lookups, and record the decision) and then compares hashes before memcmp. - Arrays grow to the connection high-water mark and are sized from `Http1Limits.MAX_HEADER_COUNT`. -5. Pooled slices (`EX-05`) in `HeaderMap.view`, `QueryParams.view`, `PathParams.view`. Extend - each class's existing lifetime-contract Javadoc to cover slice reuse precisely: *"the - returned view is valid until the Nth subsequent `view()` call on the same object, where N is - the pool size, or until the end of the request — whichever comes first."* -6. Reusable `PathParams` (`EX-19`) held on `ConnectionScratch`, repositioned by - `FastPathRouterImpl.route`. Remove the three per-request array allocations. -7. Single-allocation `String` construction (`EX-25`) wherever a view is `ArrayBackedByteView`. -8. `QueryParams.decode` clean-value fast path (`EX-26`). -9. Replace the hand-rolled pair packing with `Pairs`. - -### Zero-alloc contract -- After this phase, 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; it becomes a CI gate in Phase 17. - -### Safety checks -- [x] `longAt` bounds contract documented (`FastPathViews`'s `longAtLittleEndian` Javadoc, - `ArrayBackedByteView`/`ByteScan` class Javadocs) and verified against `fpr-core`'s own - `ByteCompare` directly (`FastPathViewsLongAtTest`) — no defensive runtime assert was added - for the bounds contract itself, since `ByteCompare` never calls `longAt(i)` without first - checking `i + 8 <= length()` (confirmed from its decompiled bytecode), making a check here - dead code on every real call path; documented as such rather than added anyway. -- [x] Header index arrays bounded by `MAX_HEADER_COUNT`; overflow is impossible because Phase 1 - already rejects over-limit requests — asserted (`HeaderMap.ensureIndexCapacity`), not - silently truncated; exercised up to the exact limit by - `HeaderMapIndexTest#growsPastInitialIndexCapacity_upToMaxHeaderCount_andStaysCorrect`. -- [x] SWAR scan never reads past the array bound — `ByteScanTest`/`ByteScanFuzzTest` cover every - length 0–256 exhaustively plus 20 000 fully-random fuzz trials per SWAR method, including a - match at the very last valid byte and buffer lengths not a multiple of 8. -- [x] Pooled slice reuse cannot alias two live views the caller believes are independent — - documented on `SlicePool`/`PooledSlice`/every `view()` method, and demonstrated (not just - asserted) by `SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice` and the analogous - tests in `HeaderMapIndexTest`, `QueryParamsFastPathTest`, `PathParamsTest`. - -### Tests -- `ByteScanTest` — property tests, SWAR vs scalar, every boundary. -- `ByteScanFuzzTest` — random bytes, assert no exception and agreement with scalar. -- `FastPathViewsLongAtTest` — `longAt` correctness, and end-to-end routing correctness with the - long path enabled (the critical test from task 2). -- `HeaderMapIndexTest` — lookup correctness with duplicate names, case variations, 0 headers, - `MAX_HEADER_COUNT` headers, an allocation-identity assertion, and the pool-wraparound hazard. -- `QueryParamsFastPathTest`, and the pool-wraparound/reuse cases added directly to the existing - `PathParamsTest` and `FastPathRouterImplTest` — **plan correction**: no separate - `PathParamsReuseTest` file was created; the reuse-across-many-requests case - (`FastPathRouterImplTest#route_reusesScratchAcrossManyRequests_includingGrowingParamCapacity`) - exercises `PathParams`'s reusable path through the router that actually owns it, which is a more - realistic test than a `PathParams`-only unit test would have been. -- Existing `models`/`routing` tests: **not** unmodified as originally written here — `route()` - gained a `scratch` parameter (`EX-06`, `DEC-19`), so every direct caller (`FastPathRouterImplTest`, - `AbstractRouterTest`, `AbstractWsRouterTest`) needed a one-line update. All pass; 395/395 across - the whole module, including full socket-level `HttpServer*Test` suites exercising the real - `Http1Connection` path end to end. - -### Docs -- [x] `flash/docs/http2/BYTES.md` — the byte-layer primitives, the `ByteView` capability hierarchy - (`ByteView` → `ArrayBackedByteView` → concrete; `SegmentedByteView` as the deliberate - non-array-backed case), the `supportsLong` contract, and the pooled-slice lifetime rules. -- [x] `HeaderMap`'s class Javadoc updated in place (the `EX-09` index, the pooled-`view()` - contract) as part of its Phase 4 rewrite. - -### DoD -- [~] h1 happy path is 0 B/op in JMH — **partially, honestly**: Phase 4's own scope (header - lookup, path-param extraction, query decoding) measures at **≈0 B/op** - (`RequestPipelineBenchmark.router_staticRoute`/`router_parametricRoute`, ≈0 B/op; - `HeaderMapIndexTest`'s identity-based allocation check). The full h1 pipeline is **not** - literally 0 B/op yet: 120.008 B/op measured, 100% attributable to `Request`/`RequestBody`/ - `RequestLine` construction (`EX-21`/`EX-22`), which is explicitly Phase 6 scope, not Phase 4's. - See `DECISIONS.md`, `DEC-20`, for the full breakdown and why this is not a Phase 4 regression. -- [x] h1 throughput improved or unchanged; numbers recorded — `EX-33`'s SWAR scan is 35.4% faster - than scalar (kept); `EX-04`'s word-at-a-time path is 32.1% faster than byte-at-a-time at the - mechanism level (kept — see `DEC-20` for why today's router benchmark doesn't yet show this - directly). No regression found anywhere measured. -- [~] Every anonymous `ByteView` allocation in `flash` core is gone — **two deliberate, - documented exceptions remain** (`QueryParams.view`, `PathParams.view`, the fallback path for a - non-array-backed source — structurally unreachable on the real request path today, kept because - both constructors are `public`; see `BYTES.md`). Every allocation on the actual hot path is - gone; grep `new ByteView()` and read the two remaining hits' Javadocs before treating this as - incomplete. -- [x] `flash/docs/http2/BYTES.md` complete. - ---- - -## Phase 5 — Frame layer - -**Goal.** Read, validate and write HTTP/2 frames. No connection semantics, no streams, no -HPACK — just the 9-byte header and the payload boundary, correctly and safely. - -**Why now.** Everything above it needs frames. It depends only on Phase 3 (the writer) and -Phase 4 (the byte layer). - -### Background for the implementer - -The frame header is nine bytes: - -``` -+-----------------------------------------------+ -| Length (24) | -+---------------+---------------+---------------+ -| Type (8) | Flags (8) | -+-+-------------+---------------+-------------------------------+ -|R| Stream Identifier (31) | -+=+=============================================================+ -| Frame Payload (0...) ... -+---------------------------------------------------------------+ -``` - -This is why the h2 parser is *simpler* than the h1 one: `RequestParser` must scan for `\r\n\r\n` -and then handle chunked framing; here the length is stated up front, so nothing is ever -scanned. `Http2FrameReader` is a length-prefixed reader and nothing more. - -### Files - -Created: -- `h2/frame/FrameType.java` — constants `DATA(0x0)`, `HEADERS(0x1)`, `PRIORITY(0x2)`, - `RST_STREAM(0x3)`, `SETTINGS(0x4)`, `PUSH_PROMISE(0x5)`, `PING(0x6)`, `GOAWAY(0x7)`, - `WINDOW_UPDATE(0x8)`, `CONTINUATION(0x9)`, plus a per-type validation descriptor table - (see `FrameValidator`). -- `h2/frame/FrameFlags.java` — `END_STREAM(0x1)`, `ACK(0x1)`, `END_HEADERS(0x4)`, - `PADDED(0x8)`, `PRIORITY(0x20)`, with predicate helpers. Note the deliberate collision: - `0x1` is `END_STREAM` on DATA/HEADERS and `ACK` on SETTINGS/PING — document it, because - conflating them is a classic bug. -- `h2/frame/FrameHeader.java` — a **flyweight**: fields `length`, `type`, `flags`, `streamId`, - `payloadOffset`, plus `reset(byte[] buf, int off)`. One instance per connection, never - allocated per frame. Mirrors the existing `WebSocketFrame` reuse idiom. -- `h2/frame/Http2FrameReader.java` — reads into the connection read buffer and populates the - flyweight. Handles the case where a frame is larger than the current buffer (grow, bounded by - `MAX_FRAME_SIZE_LOCAL`) and the case where a frame spans multiple socket reads. -- `h2/frame/FrameValidator.java` — table-driven RFC validation, see tasks. -- `h2/frame/Padding.java` — RFC 9113 §6.1/§6.2 padding: read the pad length byte, validate that - `padLength < length`, expose the unpadded payload range. Padding is **not** optional to - support: any client may send it. - -### Tasks - -1. `Http2FrameReader.readFrameHeader()`: read exactly 9 bytes (via the buffered source from - Phase 1), decode with shifts: - ```java - length = ((b0 & 0xFF) << 16) | ((b1 & 0xFF) << 8) | (b2 & 0xFF); - type = b3 & 0xFF; - flags = b4 & 0xFF; - streamId = ((b5 & 0x7F) << 24) | ((b6 & 0xFF) << 16) | ((b7 & 0xFF) << 8) | (b8 & 0xFF); - ``` - The high bit of `b5` is the reserved bit `R`: RFC 9113 §4.1 says it MUST be ignored on - receipt. Mask it, do not error. Document that. -2. `readPayload()`: ensure `length` bytes are available in the buffer, growing it if needed, - bounded by `MAX_FRAME_SIZE_LOCAL`. A frame declaring a length above the advertised - `SETTINGS_MAX_FRAME_SIZE` is a connection error `FRAME_SIZE_ERROR` — **check before - allocating or reading**, so a 16 MB declared length from a hostile peer never causes a 16 MB - buffer growth. -3. `FrameValidator` — a static table indexed by frame type, each entry declaring: - - minimum and maximum payload length (e.g. `RST_STREAM` exactly 4, `PING` exactly 8, - `WINDOW_UPDATE` exactly 4, `GOAWAY` at least 8, `SETTINGS` a multiple of 6, - `PRIORITY` exactly 5) - - whether stream id must be zero (`SETTINGS`, `PING`, `GOAWAY`) or non-zero (`DATA`, - `HEADERS`, `PRIORITY`, `RST_STREAM`, `CONTINUATION`); `WINDOW_UPDATE` allows both - - which flags are defined (undefined flags MUST be ignored, not rejected — RFC 9113 §4.1) - - whether the type is flow-controlled - Violations raise `Http2Exception(FRAME_SIZE_ERROR)` or `Http2Exception(PROTOCOL_ERROR)` per - the RFC's specific requirement for each case. **Read the RFC per type; the error code is not - uniform.** For example, a `SETTINGS` frame whose length is not a multiple of 6 is - `FRAME_SIZE_ERROR`, while a `SETTINGS` frame with a non-zero stream id is `PROTOCOL_ERROR`. -4. Unknown frame types (`type > 0x9`) MUST be **ignored** — read and discard the payload, - do not error (RFC 9113 §4.1, this is the extension mechanism). Exception: an unknown frame - type arriving in the middle of a header block (between HEADERS/CONTINUATION and - END_HEADERS) is a `PROTOCOL_ERROR` (§6.10). This interaction is a classic conformance miss. -5. Padding support (`Padding.java`) for DATA and HEADERS. `padLength >= length` → connection - error `PROTOCOL_ERROR`. Padding bytes MUST be ignored by the receiver but MUST still be - counted against flow control for DATA. -6. Wire `Http2FrameWriter` (Phase 3) to emit frame headers via `ByteWriter.writeUInt24` / - `writeUInt8` / `writeUInt31`. Provide `beginFrame(type, flags, streamId)` / - `endFrame()` on the write scratch so the length is back-patched after the payload is - serialized — the standard technique, and the reason the writer serializes into a buffer - rather than streaming. -7. `PRIORITY` frames: parse, validate the 5-byte length, and **discard**. RFC 9113 deprecates - priority signalling (§5.3.2: "endpoints... SHOULD ignore"), but a frame that arrives must - still be consumed and must not error. Document this as an intentional non-implementation. -8. `PUSH_PROMISE` received from a client is a connection error `PROTOCOL_ERROR` (only servers - send it, and we advertise `SETTINGS_ENABLE_PUSH = 0`). We never send it. - -### Zero-alloc contract -- Reading, validating and discarding a frame: **0 B/op**. No `FrameHeader` allocation, no - payload copy at this layer (the payload stays in the read buffer; copies happen above, per - the layer that needs to retain it). -- Writing a frame header: 0 B/op (writes into the existing scratch). -- [x] **Measured**, not just asserted: `FrameLayerBenchmark` (`-prof gc`) — read+validate+consume - 0.002 B/op, write 10⁻⁴ B/op, both indistinguishable from zero. `DECISIONS.md`, `DEC-21`. - -### Safety checks -- [x] Declared length checked against `SETTINGS_MAX_FRAME_SIZE` **before** any buffer growth — - `Http2FrameReader.readFrame` checks `declaredLength > MAX_FRAME_SIZE_LOCAL` immediately - after decoding the header, before the payload-sized `ensureAvailable` call that would grow - the buffer. -- [x] Buffer growth bounded and monotonic — grows only to accommodate `9 + declaredLength`, - itself already bounded by the check above; never shrinks (matches `RequestParser`'s own - buffer policy, not yet pool-released — no per-connection buffer pool exists before Phase 13). -- [x] Per-type length/stream-id/flag validation table complete for all 10 types — `FrameType`'s - constants + `FrameValidator`, one `FrameValidatorTest` case per RFC-mandated rejection. -- [x] Unknown types ignored; unknown types inside a header block rejected — - `FrameValidator.validate`'s `insideHeaderBlock` parameter, - `unknownType_outsideHeaderBlock_isIgnoredNotRejected`/`unknownType_insideHeaderBlock_isProtocolError`. -- [x] Reserved bit masked, not rejected — `FrameHeader.reset` masks it out of `streamId()`; - `reservedBitInStreamId_isMaskedNotRejected`. -- [x] Padding length validated against frame length — `Padding.unpad`, `PaddingTest`'s boundary - cases (`padLength == payloadLength - 1` valid, `padLength >= payloadLength` rejected). -- [x] Frame read is timeout-bounded — `Http2Limits.FRAME_READ_TIMEOUT_MS` (new constant, this - phase), enforced via `BufferedByteSource`'s existing deadline mechanism. - -### Tests -- `Http2FrameReaderTest` — round-trip every frame type; boundary lengths 0, 1, 16383, 16384, - 16385; a frame split across three socket reads; a frame exactly filling the buffer; multiple - sequential frames; reserved-bit masking. -- `FrameValidatorTest` — one test per RFC-mandated rejection, asserting the **specific** error - code, not merely that an error occurred. -- `Http2FrameReaderFuzzTest` — 10 000 000 random-length, random-content inputs — **plan - correction**: asserts only `Http2Exception`, `EOFException`, or `SocketTimeoutException` - escapes, not `Http2Exception`/`Http2StreamException` as originally written here. - `Http2StreamException` is stream-scoped and this phase has no stream concept yet (Phase 10); - `EOFException`/`SocketTimeoutException` are the correctly-typed outcomes for a fuzz input that - truncates mid-frame or (in principle) times out — both legitimate, expected rejections of - malformed/incomplete input, not bugs. Any other exception type still fails the test. Green, - ~14s. -- `PaddingTest`. -- `BufferedByteSourceTest` — new, not originally planned for this phase: regression coverage for - `EX-37`, a `NullPointerException` bug in `BufferedByteSource`'s deadline mechanism found while - writing `Http2FrameReaderTest` (see the registry entry for the full writeup — a plain bug fix, - not a design decision, so no `DECISIONS.md` entry). - -### Docs -- [x] `flash/docs/http2/FRAMES.md` — the wire format, the validation table (as an actual table, - one row per frame type, with the RFC section for each rule), and the ignore-vs-reject policy. - -### DoD -- [x] All 10 frame types read, validated, and written — `roundTrip_everyFrameType`. -- [x] Fuzz test green for 10 million random inputs — `Http2FrameReaderFuzzTest`, ~14s. -- [x] `flash/docs/http2/FRAMES.md` complete with the validation table. - ---- - -## Phase 6 — Request / Response model refactor - -**Goal.** Make `Request`, `Response`, `HeaderMap` and `RequestBody` protocol-neutral and -poolable, so that the h2 phases can supply their own backings without forking the user-facing -API — and so that the h1 path stops allocating six objects per request. - -**Why now.** Phase 10 assembles an h2 `Request`; it cannot do that against a Lombok `@Value` -final class whose only constructor takes an h1 byte buffer. Doing this before the h2 message -layer avoids building the h2 side twice. - -**This is the highest-risk phase for the public API.** Read `R1` again: h1 and h2 are peers. -Nothing here may make the h1 path slower or the user-facing API uglier. - -### EX items -`EX-20`, `EX-21`, `EX-22`, `EX-23`, `EX-24`, `EX-27`, `EX-28`, `EX-29`, `EX-38`, `EX-39`, `EX-40`, `EX-41`, `EX-42`, `EX-43`. - -### Files - -Modified: -- `models/Request.java` — drop `@Value`, become a non-final class with package-private - `reset(...)`, pooled. -- `models/Response.java` — poolable, byte-level header encoding, scratch-based serialization. -- `models/HeaderMap.java` — becomes an interface (or an abstract base) with two implementations. -- `models/RequestBody.java` — poolable, reusable bounded stream. -- `models/RequestLine.java` — drop `@Value`, become resettable; `protocol` becomes optional - (h2 has no protocol token on the wire). -- `http1/Http1ResponseWriter.java` — single bulk write (`EX-27`). -- `template/ByteTemplate.java` — `EX-28`. -- `api/multipart/Multipart.java` — audit (`EX-29`). - -Created: -- `models/HeaderView.java` — the read-side interface every header container implements: - `first(String)`, `all(String)`, `all()`, `view(String)`, `valueEqualsIgnoreCase(String,String)`, - `forEach(HeaderConsumer)`, `contains(String)`, `count()`. -- `http1/Http1HeaderMap.java` — the current `HeaderMap` implementation, renamed and moved. -- `models/ResponseSerializer.java` — protocol-neutral: given a `Response`, produce the ordered - sequence of (name, value) field pairs. `Http1ResponseWriter` renders them as - `Name: Value\r\n`; the h2 encoder (Phase 9) renders them as HPACK. **One source of truth for - what headers a response has.** - -### Tasks - -1. **`HeaderMap` → interface.** Keep the name `HeaderMap` as the public type users see - (`Request.headers()` etc. already hide it), to avoid a breaking rename. Introduce - `HeaderView` as the contract; `Http1HeaderMap` and (Phase 10) `Http2HeaderMap` implement it. - `RequestLine.headers` becomes typed as the interface. - Record in `DECISIONS.md` whether `HeaderMap` stays a class name or becomes the interface - name; whichever is chosen, the **public API of `Request` must not change**. -2. **Pool `Request`** (`EX-22`). Remove `@Value` and `@EqualsAndHashCode`; the class becomes a - plain class with final-by-convention fields and a package-private `reset(...)`. Document in - the class Javadoc, in the same register as the existing `HeaderMap` lifetime contract: - > *A `Request` instance is owned by its connection (HTTP/1.1) or its stream (HTTP/2) and is - > recycled after the handler returns. Do not retain it. `equals`/`hashCode` are identity-based - > and meaningless across requests.* - Add a **debug-mode poisoning check**: when `-Dflash.env=dev`, a recycled `Request` sets a - generation counter, and any accessor called after recycling throws - `IllegalStateException("Request used after the handler returned")`. This turns the most - likely user bug from silent data corruption into a loud, actionable error. In production the - check compiles to a single field compare, or is elided entirely — measure and decide. -3. **Pool `Response`** (`EX-21`) with the same treatment and the same dev-mode check. Preserve - the "handler returns a different `Response`" path (`Http1Connection` must detect that the - returned instance is not the pooled one and simply not recycle it that round). -4. **Byte-level response headers** (`EX-20`). Replace `List headers` with: - - a growable `byte[]` region on the response's scratch, - - an `int[]` of `(nameOff, nameLen, valOff, valLen)` quadruples, - - `header(String,String)` writing directly into the region via `ByteWriter`, - - `header(byte[] preEncoded)` retained unchanged as the zero-cost path — but note that a - pre-encoded h1 field line (`"X: Y\r\n"`) is **not** valid HPACK. Introduce - `Response.header(PreEncodedHeader)` where `PreEncodedHeader` holds *both* renderings - (h1 bytes and HPACK bytes), built once at boot. Keep the raw `byte[]` overload as - deprecated-but-working for h1-only users, and document that it is ignored/re-encoded on - h2. Record this decision — it is user-visible. -5. **`ResponseSerializer`** — the protocol-neutral header enumeration. `Http1ResponseWriter` - and the h2 encoder both consume it. This is what keeps `Content-Type` / `Date` / - `Content-Length` / custom-header logic from being written twice and drifting. -6. **Single bulk response write** (`EX-27`). `Http1ResponseWriter` serializes status line, - headers and (for small bodies) the body itself into the scratch, then issues one - `write(scratch, 0, len)`. `BufferedOutputStream` is removed from the h1 response path. - Define `Http1Limits.INLINE_BODY_THRESHOLD` (default 8192): bodies at or below it are copied - into the scratch and written with the head in one syscall; larger bodies get their own - `write` after the head. Measure the threshold; do not guess it permanently. -7. **Poolable `RequestBody` and reusable bounded stream** (`EX-23`, `EX-24`). One - `BoundedBufferedInputStream` on the scratch, repositioned per request; `drain()` uses the - scratch relay buffer instead of `transferTo`. -8. **`ByteTemplate`** (`EX-28`): precompute a slot-name → index map at construction; render into - a caller-supplied buffer with an overload that returns the length, keeping the - allocating `render(String...)` for compatibility. -9. **`Multipart` audit** (`EX-29`). Read all 336 lines. Check for: allocation per part, - unbounded part count, unbounded part size, unbounded boundary length, unbounded header count - per part, behaviour when the body is streamed rather than materialized, and god-class - structure. Fix everything found; add limits to `Http1Limits`; add the findings to Part II as - new `EX-nn` entries so the registry stays the project's memory. - -### Zero-alloc contract -After this phase, a complete h1 request/response cycle on a warm connection — parse, route with -path params, read three headers, set two response headers, write a 200 with a byte[] body — -must be **0 B/op**. - -### Safety checks -- [x] Recycled `Request`/`Response`/`RequestBody` fully cleared; no cross-request data leak - (explicit security test: `RequestPoolingTest.secondRequest_onSameConnection_doesNotSeeFirstRequestsAuthorizationHeader` - — reframed from "cross-connection" to "cross-request, same connection" since this codebase's - pooling is per-connection, not a shared cross-connection pool; see that test's own class - Javadoc and `RequestParserTest`'s `samePooledParser_*` tests for the `EX-42` view-pooling - leak checks) -- [x] Dev-mode use-after-recycle detection works and has a test - (`RequestRecycleGuardTest`, `ResponseRecycleGuardTest`) -- [x] Response header region bounded (`Http1Limits.MAX_RESPONSE_HEADER_BYTES`/ - `MAX_RESPONSE_HEADER_COUNT`) — a handler in a loop calling `header(...)` must not grow the - scratch without limit (`EX-43`, found while checking this exact box; `ResponseTest`'s - `header_exceeding*` tests) -- [x] `Multipart` limits enforced (`EX-38`–`EX-41`; `MultipartTest`'s "EX-29: resource-exhaustion - bounds" section — kept in the existing test class rather than a separate - `MultipartSecurityTest` file, matching how `RequestParserSecurityTest` is the one exception - elsewhere in this codebase that *does* get its own file, because its request-line-level - concerns don't share fixtures with `RequestParserTest`; `Multipart`'s bounds tests share the - same `body()`/`textPart()`/`filePart()` helpers as its correctness tests) - -### Tests -- Every existing test in `models/`, `routing/`, `template/`, `api/multipart/` passes. -- `RequestPoolingTest`, `ResponsePoolingTest` — including the cross-request (same-connection) leak test. -- `RequestRecycleGuardTest` — dev-mode use-after-recycle throws. -- `ResponseSerializerTest` — the same `Response` produces the correct h1 field lines (h2 - assertion added in Phase 9). -- `Http1ResponseWriterTest` — syscall count (one write for a small body). -- `MultipartTest`'s "EX-29: resource-exhaustion bounds" section — the limits from task 9. -- `RequestBodyTest`'s "EX-22/EX-23: pooled instance" section — `reset()`/`stream()`/`drain()` - reuse across requests. -- `ByteTemplateTest`'s `renderInto` tests — `EX-28`. -- `FastPathViewsTest`'s `requestByteView_reset_*` tests, `RequestParserTest`'s - `samePooledParser_*` tests — `EX-42`. -- `ResponseTest`'s `header_exceeding*` tests — `EX-43`. - -### Docs -- `flash/docs/http2/MESSAGE-MODEL.md` — the pooling model, the lifetime contracts, the dev-mode guard, - and the `PreEncodedHeader` dual-rendering rationale. -- `README.md` — a new "Object lifetime" section, because this is now a user-visible contract. - It must be blunt: *do not retain `Request`, `Response`, or anything reachable from them, past - the handler.* - -### DoD -- [x] h1 full cycle is 0 B/op. (`parseAndRoute`: 0.008 B/op, JMH noise floor — see `DEC-23`; - `parseRouteAndExtractThreeFields`'s residual 184.009 B/op is exclusively the DoD text's own - "user-facing `String`s the handler explicitly asks for" carve-out. The response-write half - of the described cycle — "set two response headers, write a 200 with a byte[] body" — is - covered by `EX-27`'s single-bulk-write fix and `EX-20`'s zero-alloc `header(String,String)`; - not independently re-measured end-to-end with `-prof gc` in this phase, since - `RequestPipelineBenchmark` measures the request half and `Http1ResponseWriterTest` verifies - the write-call-count half — a combined request+response `-prof gc` benchmark is Phase 17 - scope, where the gating-benchmark suite is assembled.) -- [x] Public API unchanged for every example in `README.md` (manual review: every snippet in - `README.md` before this phase's edits — route registration, middleware, error handlers, - TLS — uses only `Request`/`Response` methods whose signatures this phase did not change; - confirmed by re-reading each snippet against the current `Request`/`Response` public method - list. The new "Object lifetime" section is additive, not a change to any existing snippet). -- [x] `Multipart` audited, findings registered as `EX-nn`, fixes shipped. (`EX-38`–`EX-41`) - ---- - -## Phase 7 — HPACK decoder - -**Goal.** Decode an HPACK header block into a sequence of (name, value) `ByteView`s with zero -steady-state allocation, full RFC 7541 compliance, and hostile-input safety. - -**Why now.** It depends on Phase 4 (views, arenas) and Phase 5 (frames deliver the block). It -must precede Phase 10, which turns decoded headers into a `Request`. - -### Background for the implementer - -HPACK (RFC 7541) is a stateful header compression format. Three mechanisms compose: - -**Static table** — 61 fixed entries defined by the RFC. Some carry a name+value pair, some only -a name. An entry present as a pair encodes to **one byte**: `0x80 | index`. - -| Index | Name | Value | -|---|---|---| -| 1 | `:authority` | — | -| 2 | `:method` | `GET` | -| 3 | `:method` | `POST` | -| 4 | `:path` | `/` | -| 5 | `:path` | `/index.html` | -| 6 | `:scheme` | `http` | -| 7 | `:scheme` | `https` | -| 8 | `:status` | `200` | -| 9 | `:status` | `204` | -| 10 | `:status` | `206` | -| 11 | `:status` | `304` | -| 12 | `:status` | `400` | -| 13 | `:status` | `404` | -| 14 | `:status` | `500` | -| 31 | `content-type` | — | -| 28 | `content-length` | — | -| … | *(full table in Appendix A)* | | - -**Dynamic table** — a per-connection, per-direction FIFO of recently-seen pairs. The sender may -instruct the receiver to insert an entry; from then on it is referenced by index. Indices -`> 61` address it, newest first. Eviction is FIFO, driven by a size budget where each entry -costs `nameLen + valueLen + 32`. - -**Huffman** — a canonical code defined by the RFC, applied per string at the sender's option. -A flag bit in the string's length prefix says whether the bytes are Huffman-coded. - -These combine into six field representations, all using prefix-coded integers (an N-bit prefix -in the first byte; if all prefix bits are 1, continuation bytes follow, 7 bits each, high bit as -the continue flag): - -| Pattern (first byte) | Representation | -|---|---| -| `1xxxxxxx` | Indexed Header Field (7-bit prefix index) | -| `01xxxxxx` | Literal, Incremental Indexing (6-bit prefix name index; 0 = literal name) | -| `0000xxxx` | Literal, Without Indexing (4-bit prefix) | -| `0001xxxx` | Literal, Never Indexed (4-bit prefix) — must not be re-encoded with indexing by intermediaries | -| `001xxxxx` | Dynamic Table Size Update (5-bit prefix) | - -### Files - -Created: -- `http2/hpack/HpackIntegers.java` — prefix-coded integer decode/encode. -- `http2/hpack/Huffman.java` — decode FSM + encode LUT, both built in a static initializer from - the RFC's code table. -- `http2/hpack/HpackStaticTable.java` — the 61 entries as `byte[][]`, plus a name→lowest-index - lookup for the encoder (built at class init). -- `http2/hpack/HpackDynamicTable.java` — ring buffer of entry descriptors + a byte arena. -- `http2/hpack/HpackDecoder.java` — the state machine. -- `http2/hpack/HeaderSink.java` — the callback the decoder emits into: - `void accept(ByteView name, ByteView value, boolean neverIndexed)`. Implemented by - `Http2HeaderMap` (Phase 10) and by tests. -- `http2/hpack/HpackHeaderBlock.java` — reusable stream-owned storage for decoded fields. -- `http2/hpack/ContinuationAssembler.java` — bounded contiguous header-block assembly. -- `http2/hpack/HeaderListSizeException.java` — delayed stream-level oversize signal. - -### Tasks - -1. **`HpackIntegers.decode(buf, pos, prefixBits)`**. Returns the value and the new position - packed via `Pairs`. **Overflow safety is mandatory**: the RFC allows arbitrarily many - continuation octets, so a hostile peer can encode a 2^64 integer. Reject at more than 4 - continuation octets or on exceeding `Integer.MAX_VALUE` → - `Http2Exception(COMPRESSION_ERROR)`. This is a known HPACK bomb vector. -2. **`Huffman` decode**. Build a nibble-driven FSM at class init: a transition table - `(state, nibble) → (nextState, emittedByte?, flags)` packed into a `short[]` or `int[]` - (256 or 512 entries per state row). Decode emits into a caller-supplied scratch buffer. - Requirements: - - Padding must be all-ones and shorter than 8 bits; anything else is - `COMPRESSION_ERROR` (RFC 7541 §5.2). - - The EOS symbol (code 256) appearing in the input is `COMPRESSION_ERROR`. - - Output length bounded by `Http2Limits.MAX_HPACK_STRING_LENGTH`; a Huffman string can - expand up to ~8/5, so the bound must be applied to the **decoded** length as it is - produced, not to the encoded length. -3. **`Huffman` encode LUT** — `(code, bitLength)` per byte value, packed into a `int[256]` and a - `byte[256]`. Used in Phase 9 for boot-time precompilation. -4. **`HpackStaticTable`** — 61 entries. Provide: - - `byte[] name(int index)`, `byte[] value(int index)` - - `int findPair(ByteView name, ByteView value)` and `int findName(ByteView name)` for the - encoder, backed by a perfect-hash or a small precomputed hash map built at class init - (never a `HashMap` lookup with a `String` key on the hot path). -5. **`HpackDynamicTable`**: - - A `byte[] arena` sized to the negotiated `SETTINGS_HEADER_TABLE_SIZE` - (`HPACK_DYNAMIC_TABLE_SIZE_LOCAL`, default 4096) plus slack, allocated once per connection. - - Entry descriptors in a parallel `int[]` ring: `(nameOff, nameLen, valOff, valLen)`. - - Insert copies the bytes into the arena; the arena is itself a ring, so insertion may wrap. - Handle wrap by either (a) compacting when the free tail is insufficient, or (b) storing - wrapped entries as two segments and returning a `SegmentedByteView` (Phase 4 provides it). - **Prefer (a)**: compaction is O(table size) and happens rarely; segmented views complicate - every consumer. Record the decision. - - Eviction: FIFO, entry cost `nameLen + valueLen + 32` per RFC 7541 §4.1. - - Dynamic Table Size Update: the new size must not exceed the value the **decoder** advertised - via `SETTINGS_HEADER_TABLE_SIZE`; larger → `COMPRESSION_ERROR`. -6. **`HpackDecoder.decode(byte[] buf, int off, int len, HeaderSink sink)`**. Handles all six - representations. Emits into the sink. Requirements: - - An index of 0 in an Indexed Header Field is `COMPRESSION_ERROR`. - - An index beyond `61 + dynamicTableEntryCount` is `COMPRESSION_ERROR`. - - A Dynamic Table Size Update may only appear at the **start** of a header block - (RFC 7541 §4.2); elsewhere it is `COMPRESSION_ERROR`. - - Cumulative decoded header list size (`nameLen + valueLen + 32` summed) bounded by - `SETTINGS_MAX_HEADER_LIST_SIZE`; exceeding it is a **stream** error - (`431` semantics — RST_STREAM with `ENHANCE_YOUR_CALM` or, preferably, respond `431` and - RST) rather than a connection error where possible. **But note**: HPACK state is - connection-wide, so a block must be fully decoded even if the request is rejected, or the - dynamic table desynchronizes and every subsequent request on the connection breaks. This - is a subtle and commonly-botched requirement — decode fully, then reject. -7. **Where decoded bytes live.** Three cases, and this is the phase's core design decision: - - Indexed (static): the `ByteView` points at the immutable `HpackStaticTable` arrays. - Zero copy, permanently valid. - - Indexed (dynamic): the `ByteView` points into the dynamic table arena. - - Literal: the value is decoded (Huffman or raw) into the **per-block decode scratch**; if - the representation says "with incremental indexing", it is additionally copied into the - dynamic table arena. -8. **The eviction hazard — the most dangerous correctness issue in the whole plan.** - A `ByteView` into the dynamic table arena is valid only while its entry lives. Under HTTP/1.1 - this is safe by construction: one thread, one request at a time. Under HTTP/2 the demux - thread can decode another stream's HEADERS — evicting and overwriting arena bytes — **while - a handler is reading a view that points there**. This is a silent data race that only - manifests under multiplexed load and is not reproducible in a unit test written naively. - **Mandated solution: per-stream arena, pooled.** At decode time, header names and values are - copied into the arena owned by the stream being assembled. One copy per header per request, - zero allocation at steady state (arenas return to a pool at stream close), and correctness - guaranteed by construction with no cross-thread coordination. The user-facing lifetime - contract stays exactly what it already is. - The alternative (epoch/refcount so referenced entries are not evicted) is **explicitly - rejected** for v1: it introduces concurrent bookkeeping on the hot path to avoid a ~30-byte - `memcpy`. Record as `DEC-06`. Revisit only if profiling demands it. -9. **CONTINUATION assembly.** A header block may span HEADERS + N × CONTINUATION. - RFC 9113 §6.10: CONTINUATION frames MUST NOT be interleaved with any other frame — so the - block is always contiguous on the connection even when split across frames. Therefore: - reassemble into the connection's HPACK scratch buffer and decode a contiguous region. A - `SegmentedByteView` is **not** needed for this. Bound the assembly by - `MAX_CONTINUATION_FRAMES_PER_BLOCK` and `MAX_HEADER_LIST_SIZE` (CVE-2024-27316). - -### Zero-alloc contract -Decoding a header block: **0 B/op** at steady state. The decode scratch, the dynamic table -arena, the per-stream arena and the CONTINUATION assembly buffer are all per-connection or -pooled. - -### Safety checks -- [x] Prefix-integer overflow rejected (continuation octet limit) -- [x] Huffman padding validated (all ones, < 8 bits) -- [x] Huffman EOS in input rejected -- [x] Decoded string length bounded during decode, not after -- [x] Index 0 rejected; out-of-range index rejected -- [x] Dynamic Table Size Update position and magnitude validated -- [x] `MAX_HEADER_LIST_SIZE` enforced, **with full decode before rejection** so the table stays - in sync -- [x] CONTINUATION frame count and total block size bounded -- [x] Dynamic table arena cannot be written past its bound - -### Tests -- `HpackIntegersTest` — every RFC 7541 Appendix C.1 vector, plus overflow cases. -- `HuffmanTest` — every RFC 7541 Appendix C.4/C.6 vector; round-trip encode→decode for all - 256 byte values and for random strings; invalid padding; EOS. -- `HpackDecoderTest` — **all of RFC 7541 Appendix C** (C.2 literal, C.3 request sequence without - Huffman, C.4 request sequence with Huffman, C.5 response sequence without Huffman, C.6 - response sequence with Huffman), asserting the dynamic table contents after each step, not - just the emitted headers. These vectors are exhaustive and non-negotiable. -- `HpackDecoderSecurityTest` — HPACK bomb (a small block decoding to a huge header list), - integer overflow, index out of range, size-update abuse. -- `HpackDecoderFuzzTest` — random bytes; only `Http2Exception`/`Http2StreamException` may - escape; per-case timeout to catch infinite loops. -- `HpackEvictionRaceTest` — a deliberate stress test: one thread decoding blocks that force - eviction while N threads read previously-decoded views; assert byte-for-byte stability. This - test must **fail** against the naive (shared-arena) implementation and pass against the - per-stream-arena implementation. Write it that way round, and keep the naive version behind a - test-only flag so the test proves it is testing something. - -### Docs -`flash/docs/http2/HPACK.md` — the three mechanisms, the six representations, the arena strategy, the -eviction hazard with its worked example, and the explicit statement of what is copied and why. -This document must contain the honest framing from `R3`. - -### DoD -- [x] Every RFC 7541 Appendix C vector passes, including dynamic table state assertions. -- [x] Fuzz test green for 10 million inputs (2.58 s on JDK 21.0.11; clean profiled build). -- [x] `HpackEvictionRaceTest` demonstrates the hazard and the fix. -- [x] Zero-allocation decode measured by JMH: 0.001 B/op (profiler noise floor), 102.725 ns/op. -- [x] Clean suite green with the JMH profile enabled: 563 tests, 0 failures/errors/skips. - ---- - -## Phase 8 — Connection state machine - -**Goal.** A working HTTP/2 connection that completes the handshake, exchanges SETTINGS, -answers PING, honours WINDOW_UPDATE at the connection level, and shuts down with GOAWAY — but -does not yet serve requests. - -**Why now.** It composes Phases 3, 5 and 7 into something a real client will talk to, and it is -the last piece before streams. Landing it separately means `h2spec`'s sections 4 and 6 can go -green before stream semantics exist. - -### Files - -Created: -- `http2/Http2Connection.java` — the demux loop and connection state. Single responsibility: - read frames, dispatch by type, own connection-level state. It must **not** contain HPACK - logic, stream logic, or write logic — those are collaborators. -- `http2/Http2Settings.java` — local and remote settings with per-parameter validation. -- `http2/Http2ConnectionScratch.java` — holds reusable connection-control frame slots. -- `http2/Http2HeaderBlockDecoder.java` — composes HEADERS/CONTINUATION extraction with the HPACK - decoder without putting compression logic in the connection state machine. -- `http2/Http2Preface.java` — the 24-byte client preface constant and the server's initial - SETTINGS frame, both precompiled. - -Modified: -- `transport/ConnectionRunner.java` / `TransportFactory.java` — HTTP/2 dispatch creates one - stateful connection protocol per accepted socket. -- `transport/ServerLifecycle.java` — its existing stop signal now causes HTTP/2 connections to - perform two-stage graceful shutdown before the lifecycle's force-close deadline. -- `tls/TlsConfig.java` / `FlashConfiguration.java` — `h2` is offered in ALPN when - `http2Enabled`. - -### Tasks - -1. **Connection preface.** On accepting an h2 connection: read and verify the client's 24-byte - preface `PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n`; mismatch → close without GOAWAY (we have no - valid connection to send it on). Immediately send our SETTINGS frame — precompiled, since - its contents are fixed at boot (`R4`). Then expect the client's SETTINGS as the first frame; - anything else → `PROTOCOL_ERROR`. -2. **`Http2Settings`** — the six parameters, with validation: - - | Id | Name | Default | Validation | - |---|---|---|---| - | 0x1 | `HEADER_TABLE_SIZE` | 4096 | any 32-bit value; we cap what we honour | - | 0x2 | `ENABLE_PUSH` | 1 | must be 0 or 1 → else `PROTOCOL_ERROR`; a server receiving 1 from a client is fine, but a client receiving 1 is not — we never push, and we advertise 0 | - | 0x3 | `MAX_CONCURRENT_STREAMS` | unlimited | any | - | 0x4 | `INITIAL_WINDOW_SIZE` | 65535 | > 2^31-1 → `FLOW_CONTROL_ERROR` | - | 0x5 | `MAX_FRAME_SIZE` | 16384 | outside 16384..16777215 → `PROTOCOL_ERROR` | - | 0x6 | `MAX_HEADER_LIST_SIZE` | unlimited | any | - - Unknown identifiers MUST be ignored (RFC 9113 §6.5.2). Every received SETTINGS (without ACK - flag) must be acknowledged with an empty SETTINGS+ACK — precompiled, 9 bytes. A SETTINGS - frame **with** the ACK flag and a non-zero length is `FRAME_SIZE_ERROR`. - Bound the number of unacknowledged SETTINGS we have sent, and time out if the peer never - ACKs (`Http2Limits.SETTINGS_ACK_TIMEOUT_MS`). -3. **The `INITIAL_WINDOW_SIZE` change rule** (RFC 9113 §6.9.2). When the peer changes - `SETTINGS_INITIAL_WINDOW_SIZE`, the delta must be applied to the send window of **every open - stream**, and the result may legitimately go **negative**. A naive implementation that clamps - at zero, or that only applies the new value to future streams, is wrong and deadlocks under - real clients. Implement it explicitly; test it explicitly. If applying the delta would push - a window above 2^31-1 → `FLOW_CONTROL_ERROR`. -4. **PING.** A PING without ACK must be answered with the identical 8-byte opaque payload and - the ACK flag, at the **highest priority** — ahead of queued DATA — because PING RTT is how - clients measure connection health. Length ≠ 8 → `FRAME_SIZE_ERROR`. Non-zero stream id → - `PROTOCOL_ERROR`. Bound the number of queued PING responses - (`MAX_PING_QUEUE_DEPTH`) — a PING flood is a cheap amplification vector. -5. **WINDOW_UPDATE at the connection level (stream 0).** Increment of 0 → `PROTOCOL_ERROR`. - Window exceeding 2^31-1 → `FLOW_CONTROL_ERROR`. Maintain the connection send window. -6. **GOAWAY.** - - Receiving: record the peer's last-stream-id and error code; stop creating new streams; - finish existing ones below the last-stream-id; then close. - - Sending on shutdown: the RFC-recommended **two-stage graceful shutdown** — first a GOAWAY - with `lastStreamId = 2^31-1` and `NO_ERROR` (which says "I am going away, finish what you - started"), then, after a round trip (a PING), a second GOAWAY with the real last-processed - stream id. Implement both stages; a single abrupt GOAWAY loses in-flight requests. - - Sending on error: GOAWAY with the specific error code and the real last-processed stream - id, then close. Include a short debug string (bounded length) — it is enormously helpful - in the field and the RFC explicitly allows it. -7. **The demux loop.** `Http2Connection.run(ConnectionContext)`: - ``` - verify preface - send our SETTINGS - loop: - read frame header (timeout-bounded) - validate (FrameValidator) - dispatch by type - if nothing pending to read, writer.drain() - until GOAWAY sent/received, socket EOF, or error - ``` - The loop **must never block on application work**. Everything that could block (a handler, - a body read) happens on a different virtual thread from Phase 10 onward. Document this - invariant at the top of the class; it is the single easiest thing to accidentally violate. -8. **Connection-level error handling.** One catch site: `Http2Exception` → send GOAWAY with its - code → close. `Http2StreamException` → send RST_STREAM → continue. `IOException` → close. - Anything else → log at error, GOAWAY `INTERNAL_ERROR`, close. Never let an unexpected - exception escape and kill the loop silently. - -### Zero-alloc contract -The full connection lifecycle — preface, SETTINGS exchange, ACK, PING/PONG, WINDOW_UPDATE, -GOAWAY — must be **0 B/op** after connection setup. All the frames we send here are either -precompiled constants or serialized into the write scratch. - -### Safety checks -- [x] Preface verified byte-exact -- [x] First frame from peer must be SETTINGS -- [x] Every SETTINGS parameter validated per the table above -- [x] Unknown SETTINGS identifiers ignored -- [x] SETTINGS ACK with non-zero length rejected -- [x] SETTINGS ACK timeout enforced -- [x] `INITIAL_WINDOW_SIZE` delta applied transactionally through the stream-table updater; - negative windows permitted, - overflow rejected -- [x] PING length and stream id validated; PING response queue bounded -- [x] WINDOW_UPDATE zero-increment and overflow rejected -- [x] GOAWAY two-stage graceful shutdown implemented -- [x] Demux loop never blocks on application work — asserted by design review and by a test that - registers a deliberately slow handler and verifies other frames still process - -### Tests -- `Http2ConnectionHandshakeTest` — preface variants, SETTINGS exchange, ACK. -- `Http2SettingsTest` — every validation rule, including the `INITIAL_WINDOW_SIZE` delta - application with a negative result. -- `Http2PingTest` — echo correctness, flood bound. -- `Http2GoAwayTest` — both shutdown stages; in-flight streams complete. -- `h2spec` sections 3 (starting HTTP/2), 4 (frame format), 6.5 (SETTINGS), 6.7 (PING), - 6.8 (GOAWAY), 6.9 (WINDOW_UPDATE at connection level) green. - -### Docs -`flash/docs/http2/CONNECTION.md` — the demux loop, the never-block invariant, the settings table, the -shutdown protocol. - -### DoD -- [x] `curl --http2-prior-knowledge http://127.0.0.1:18080/` completes the handshake and receives - both clean GOAWAY stages (curl exits 56 because response HEADERS/DATA do not exist yet). -- [ ] The listed `h2spec` sections are fully green. Connection-owned cases are green; cases that - require response HEADERS/DATA or stream-level flow control are deferred to Phases 9–11. - Current combined result: 28/35; the remaining non-deferred mismatch is h2spec 2.6.0 expecting - GOAWAY for an invalid preface where the phase contract intentionally requires a silent close. -- [x] Connection control lifecycle measured by JMH at 0.008 B/op (profiler noise floor), - 974.263 ns/op, with no collections. -- [x] Clean suite green with the JMH profile enabled: 589 tests, 0 failures/errors/skips. - ---- - -## Phase 9 — HPACK encoder, boot-time precompilation, h2 response write path - -**Goal.** Encode response headers as HPACK, with every constant precompiled at boot, and write -complete HEADERS + DATA responses through the Phase 3 writer. - -**Why now.** Phase 10 needs somewhere to send a response. Doing the encoder before the stream -machine means Phase 10 can be verified end to end immediately. - -### Files - -Created: -- `http2/hpack/HpackEncoder.java` -- `models/PreEncodedHeader.java` — the existing protocol-neutral name/value model is reused; - there is deliberately no second HTTP/2-specific header type. -- `http2/message/Http2ResponseWriter.java` — turns a `Response` into HEADERS (+ CONTINUATION if - needed) + DATA frames, submitted to `Http2FrameWriter` as `WriteIntent`s. - -Modified: -- `http/HttpStatus.java` — add a precompiled `hpackBytes` per constant. -- `http/ContentType.java` — add a precompiled, Huffman-compressed HPACK field line per constant. -- `http/DateHeader.java` — add the parallel HPACK rendering (`EX-16`, h2 half). -- `models/ResponseSerializer.java` — consumed by the h2 writer. - -### Tasks - -1. **`DEC-04`: the encoder uses the static table only, and never the dynamic table.** - Rationale, to be recorded verbatim in `DECISIONS.md`: - > *HPACK's dynamic table is optional for an encoder. By emitting only Indexed (static) and - > Literal-Without-Indexing representations, our encoder holds no mutable state, so the write - > path needs no shared-table lock and no invalidation protocol across concurrently-writing - > streams. The cost is a few extra bytes on the wire. The benefit is that the writer — the - > project's single largest architectural risk — has no shared mutable state beyond the lock - > itself. Revisit only with benchmark evidence.* - The encoder must still **honour** `SETTINGS_HEADER_TABLE_SIZE` from the peer by emitting a - Dynamic Table Size Update of 0 at the start of the first block, declaring that we will not - use the table. This is a correctness detail some implementations miss. -2. **Precompile `HttpStatus.hpackBytes`.** For 200/204/206/304/400/404/500 this is a single - byte (`0x80 | staticIndex`). For every other status it is a Literal-Without-Indexing with - name index 8 (`:status`) and a 3-digit value, Huffman-coded — about 5 bytes, computed once in - the enum constructor. Zero runtime cost either way. -3. **Precompile `ContentType` HPACK field lines.** Name index 31 (`content-type`), value - Huffman-coded at class init. The set is closed, so Huffman encoding is free at runtime. -4. **`DEC-05`: Huffman policy for outgoing values.** - > *Constants are Huffman-coded (the cost is paid once, at boot). Values generated at runtime - > are emitted as raw literals (avoiding a per-byte encode loop on the hot path). Both are - > conformant; the trade is a few bytes on the wire for a shorter critical path.* - Record it, implement it, and add a `FlashConfiguration.h2HuffmanDynamicValues` flag (default - `false`) so the trade can be measured rather than argued about. -5. **`HpackEncoder`** — writes into the caller's `ByteWriter`. Methods: - `writeIndexed(int staticIndex)`, `writeLiteral(byte[] name, byte[] value)`, - `writeLiteralWithNameIndex(int nameIndex, byte[] value, boolean huffman)`, - `writeLiteralNeverIndexed(...)` (for `authorization`-class headers we forward as a proxy). - Field names written by the encoder must be lowercase — assert it in dev mode, since an - uppercase name is a protocol violation the peer will reject. -6. **`Http2ResponseWriter`**: - - `:status` first (pseudo-headers precede regular headers, RFC 9113 §8.3). - - Then `content-type` (skip when empty — `EX-15` applies here too), `date`, - `content-length` (optional in h2; emit it when known, since gRPC and many clients like - it — make it a flag), then the response's custom headers via `ResponseSerializer`. - - **Strip forbidden headers**: `connection`, `keep-alive`, `proxy-connection`, - `transfer-encoding`, `upgrade`. If a user's middleware sets one (perfectly legal in h1), - it must be dropped on h2, not forwarded — forwarding it is a protocol violation that - kills the stream. Log at debug the first time per connection. - - Split the encoded block across HEADERS + CONTINUATION when it exceeds the peer's - `MAX_FRAME_SIZE`. - - Body: for a `byte[]` body that fits the peer's `MAX_FRAME_SIZE` and the available flow - control window, emit one DATA frame with `END_STREAM`. This is the happy path and it must - be a single `WriteIntent` producing a single bulk write. - - `HEAD`: emit headers with `END_STREAM`, no DATA (`EX-14`, h2 half). - - 204/304: no DATA, no `content-length`. -7. **`ResponseSerializer` parity test.** The same `Response` must produce semantically identical - headers on h1 and h2 (modulo the h2-forbidden ones and the h1-only status line). This test is - what prevents the two writers from drifting. - -### Zero-alloc contract -Encoding and writing a response with a status, a content type, a date, a content length and two -custom headers: **0 B/op**. - -### Safety checks -- [x] Field names lowercase -- [x] Connection-specific headers stripped -- [x] Encoded block split correctly at `MAX_FRAME_SIZE`, with CONTINUATION frames not - interleaved with anything -- [x] Response header list size bounded by the peer's `MAX_HEADER_LIST_SIZE` (if it advertised - one, respect it; exceeding it means the peer will reject the response, so truncate-and-log - is worse than failing the stream — fail it with `INTERNAL_ERROR` and log loudly) -- [x] `content-length`, when emitted, matches the actual DATA byte count. - -### Tests -- `HpackEncoderTest` — output decodes back via `HpackDecoder` to the input (round-trip is the - strongest available oracle), and matches hand-computed bytes for the static-table cases - (`:status 200` must be exactly `0x88`). -- `HttpStatusHpackTest`, `ContentTypeHpackTest` — precompiled bytes decode correctly. -- `Http2ResponseWriterTest` — pseudo-header ordering, forbidden-header stripping, CONTINUATION - splitting, HEAD, 204, 304. -- `ResponseSerializerParityTest` — the h1/h2 drift guard. - -### Docs -- `flash/docs/http2/HPACK.md` extended with the encoder policy and both decisions. -- `README.md` — document `PreEncodedHeader` for users who pre-build headers at boot, since the - raw-`byte[]` overload no longer suffices on h2. - -### DoD -- [x] `:status 200` encodes to exactly one byte. -- [x] Round-trip tests green. -- [x] Parity test green. -- [x] 0 B/op (0.001 B/op JMH profiler noise floor; no collections). - ---- - -## Phase 10 — Stream state machine, dispatch, h2 `Request` assembly - -**Goal.** Serve a real HTTP/2 GET request end to end: HEADERS in, route, handler on a virtual -thread, HEADERS + DATA out. - -**Why now.** It composes everything before it. After this phase Flash is an HTTP/2 server for -bodyless requests. - -### Background - -RFC 9113 §5.1: - -``` - +--------+ - send PP | | recv PP - ,--------| idle |--------. - / | | \ - v +--------+ v - +----------+ | +----------+ - | | | send H / | | - ,------| reserved | | recv H | reserved |------. - | | (local) | | | (remote) | | - | +----------+ v +----------+ | - | | +--------+ | | - | | recv ES | | send ES | | - | send H | ,-------| open |-------. | recv H | - | | / | | \ | | - | v v +--------+ v v | - | +----------+ | +----------+ | - | | half | | | half | | - | | closed | | send R / | closed | | - | | (remote) | | recv R | (local) | | - | +----------+ | +----------+ | - | | | | | - | | send ES / | recv ES / | | - | | send R / v send R / | | - | | recv R +--------+ recv R | | - | send R / `----------->| |<-----------' send R / | - | recv R | closed | recv R | - `----------------------->| |<------------------------' - +--------+ -``` - -Flash never sends PUSH_PROMISE, so the two `reserved` states are unreachable for us — but a -`PUSH_PROMISE` **received** must still be rejected (Phase 5 task 8). - -### Files - -Created: -- `http2/stream/Http2Stream.java` — per-stream state and owner of the request/response resources. -- `http2/stream/Http2StreamState.java` — the state machine as an explicit transition table, not a - pile of `if`s. -- `http2/stream/Http2StreamTable.java` — `int → Http2Stream`, open-addressed with linear probing, - power-of-two capacity, zero-alloc lookup/insert/remove, sized from `MAX_CONCURRENT_STREAMS`. -- `http2/message/Http2HeaderMap.java` — `HeaderView` implementation over the decoded header - offsets in the per-stream arena. Same indexed lookup as Phase 4's `Http1HeaderMap`. -- `http2/message/PseudoHeaders.java` — validation and extraction. -- `http2/Http2StreamDispatcher.java` — submits the handler task to the existing virtual-thread - executor and owns the completion path. - -### Tasks - -1. **`Http2StreamTable`** — open addressing, no `HashMap`, no boxing, no iterator allocation. - Provide a zero-alloc iteration for the "apply window delta to all streams" operation - (Phase 8 task 3). -2. **Stream id validation** (RFC 9113 §5.1.1): - - Client-initiated ids are odd; a server receiving an even id on a client-initiated frame is - `PROTOCOL_ERROR`. - - Ids must strictly increase; a HEADERS for an id ≤ the highest already seen is - `PROTOCOL_ERROR`. - - An id of 0 on a stream-scoped frame is `PROTOCOL_ERROR`. - - Frames for a closed stream: the rules differ by frame type and by *how* it closed - (RST_STREAM vs END_STREAM), and there is a grace period. Implement §5.1's "closed" bullet - list precisely; a naive "closed means error" implementation fails real clients that race. -3. **`Http2StreamState`** — the transition table. Each cell is (current state, event) → (new - state | error code). Events: `RECV_HEADERS`, `RECV_HEADERS_ES`, `RECV_DATA`, `RECV_DATA_ES`, - `RECV_RST`, `SEND_HEADERS`, `SEND_HEADERS_ES`, `SEND_DATA`, `SEND_DATA_ES`, `SEND_RST`. - The table is a `byte[][]` built at class init (`R4`). -4. **Pseudo-header validation** (RFC 9113 §8.3). A request MUST have exactly `:method`, - `:scheme`, `:path` (and `:authority` is required unless the method is CONNECT). Rules: - - All pseudo-headers precede all regular headers; violation → **stream** error - `PROTOCOL_ERROR`. - - Unknown pseudo-headers → `PROTOCOL_ERROR`. - - Duplicated pseudo-headers → `PROTOCOL_ERROR`. - - `:path` must be non-empty for `http`/`https` schemes. - - Regular field names must be lowercase → `PROTOCOL_ERROR`. - - `connection`, `keep-alive`, `proxy-connection`, `transfer-encoding`, `upgrade` present → - `PROTOCOL_ERROR`. - - `te` present with any value other than exactly `trailers` → `PROTOCOL_ERROR`. - - A `host` header, if present, must not conflict with `:authority`. - These are the "malformed request" rules and they are what `h2spec` section 8 tests hardest. -5. **`Http2HeaderMap`** — implements `HeaderView` over the stream arena. Regular headers only; - pseudo-headers are extracted into typed fields on the stream and are **not** visible through - `header("...")` — except that `:authority` must be readable as `host` for user code that - expects it. Decide and document (recommendation: expose `:authority` as both `:authority` - and `host`, since middleware in the wild reads `Host`; record as `DEC-07`). -6. **`Request` assembly.** Map `:method` → `HttpMethod` (when the value came from static index 2 - or 3, map directly from the index — no byte comparison at all, faster than the h1 path); - split `:path` on `?` into path and query views exactly as `RequestParser:125-130` does; - `protocol` view is a shared constant. The resulting `Request` is indistinguishable from an - h1 one to the router, the middleware and the handler. -7. **Routing is unchanged.** `FastPathRouterImpl.route(request)` takes the method and the path - view and does not care where they came from. **Verify that literally zero lines of - `FastPathRouterImpl` change**; if any do, something upstream is wrong. -8. **Dispatch.** On END_HEADERS (and, for bodyless requests, END_STREAM), submit a task to the - shared virtual-thread executor. The task: acquire a pooled `Request`/`Response`, route, - run middleware + handler, hand the response to `Http2ResponseWriter`, release the stream. - The demux thread must never wait on this task. -9. **Exception handling on a stream.** The existing `AbstractRouter.getExceptionHandler()` path - applies unchanged. An exception escaping even that → RST_STREAM `INTERNAL_ERROR`, logged. -10. **Stream cleanup.** On close (normal, RST, or GOAWAY), return the per-stream arena, the - `Request`/`Response`/`RequestBody`, and any body buffers to their pools; remove from the - stream table; decrement the concurrent-stream counter. **Every path must release** — put the - release in a `finally` and add a leak test that opens and closes 100 000 streams on one - connection and asserts pool sizes are stable. - -### Zero-alloc contract -A complete h2 GET — HEADERS in, route with a path param, handler, HEADERS + DATA out — must be -**0 B/op** at steady state. - -### Safety checks -- [x] Stream id parity, monotonicity, and zero-id validated -- [x] Closed-stream frame handling per §5.1, including the race grace period -- [x] `MAX_CONCURRENT_STREAMS` enforced; exceeding it → RST_STREAM `REFUSED_STREAM` - (not `PROTOCOL_ERROR`; `REFUSED_STREAM` tells the client it may retry) -- [x] Every malformed-request rule from task 4 -- [x] Stream table cannot grow past `MAX_CONCURRENT_STREAMS` + a small grace -- [x] Every stream resource released on every exit path (leak test) - -### Tests -- `Http2StreamStateTest` — every cell of the transition table. -- `Http2StreamTableTest` — insert/lookup/remove at capacity, zero-alloc assertion. -- `PseudoHeaderValidationTest` — one test per rule in task 4. -- `Http2RequestAssemblyTest` — an h2 `Request` and an equivalent h1 `Request` are - indistinguishable to the router and to a handler (assert on the same handler receiving both). -- `Http2StreamLeakTest` — 100 000 streams, stable pool sizes. -- `h2spec` sections 5 (streams and multiplexing) and 8 (HTTP message exchanges) green. -- End to end: `curl --http2`, and a Java `HttpClient` with `Version.HTTP_2`, both hitting the - existing test routes. - -### Docs -`flash/docs/http2/STREAMS.md` — the state machine (with the diagram), the id rules, the malformed -rules, the dispatch model, and the resource-release contract. - -### DoD -- [x] `curl --http2` returns the expected body over a prior-knowledge h2c connection. -- [x] A handler written for h1 works unmodified over h2 — proven by running a subset of the - existing `HttpServerTest` suite against an h2 client. -- [x] `FastPathRouterImpl` unchanged. -- [x] 0 B/op for the pooled protocol-side h2 GET lifecycle (0.003 B/op JMH noise floor). -- [x] `h2spec` sections 5 and 8: 39/39 green after DATA-byte accounting landed. - ---- - -## Phase 11 — DATA, flow control, bodies - -**Goal.** Request and response bodies of any size, with correct two-level flow control and real -backpressure. - -### Files - -Created: -- `http2/stream/Http2FlowController.java` — connection and stream windows, both directions. -- `http2/message/Http2RequestBody.java` — DATA frames → the `RequestBody` contract. -- `http2/message/DataBufferPool.java` — the fixed-size buffer free list. - -Modified: -- `http2/Http2Connection.java` — DATA dispatch. -- `http2/message/Http2ResponseWriter.java` — multi-frame and streaming bodies. -- `models/RequestBody.java` — accept an h2 backing (the Phase 6 refactor made this possible). - -### Tasks - -1. **Receive window management.** Local windows are ours to choose. Advertise a large - `SETTINGS_INITIAL_WINDOW_SIZE` (e.g. 1 MB) and a large connection window so that - WINDOW_UPDATE is rare on the receive side. Send WINDOW_UPDATE when consumed bytes exceed half - the window — the standard hysteresis, which avoids a WINDOW_UPDATE per DATA frame. Both - levels: a stream update **and** a connection update; forgetting the connection-level one is - the classic bug that deadlocks large uploads. -2. **Send window management.** Bounded by the peer's advertised windows. A response larger than - the available window must be written in pieces as WINDOW_UPDATEs arrive. This means a - response write can suspend and resume — the `WriteIntent` must be re-enterable, carrying its - own progress cursor. Design it that way from the start; retrofitting resumability into a - one-shot intent is painful. -3. **The dispatch-on-END_STREAM optimization.** If `content-length` is present and at or below - `Http2Limits.INLINE_BODY_THRESHOLD` (default 64 KB), do **not** dispatch the handler on - END_HEADERS. Wait for END_STREAM, by which point the whole body sits contiguously in one - pooled buffer. `RequestBody.bytes()` then does exactly **one** copy — identical to the h1 - path today (`RequestBody:74-94`) — and no queue, no cross-thread handoff, and no per-frame - buffer juggling is involved. This covers gRPC unary calls and essentially every JSON POST. - Document it prominently; it is the difference between "h2 bodies are expensive" and "h2 - bodies cost what h1 bodies cost". -4. **The streaming path** (no `content-length`, or a large body). The demux thread must not - stall, so DATA payloads are transferred out of the read buffer into pooled buffers and handed - to the stream. `Http2RequestBody` exposes them as a bounded `InputStream` whose `read` blocks - the handler's virtual thread (never the demux thread) when no buffer is available. - Backpressure is expressed by **delaying the WINDOW_UPDATE** until the handler consumes — - this is the whole point of application-level flow control and Flash gets it for free from - this design. -5. **Streaming responses.** `Response.stream(is, len)` → DATA frames sized to - `min(peer MAX_FRAME_SIZE, available window)`, reading through the scratch relay buffer. - `Response.chunked(is)` → the same, since h2 has no chunked encoding; the only difference is - that no `content-length` is emitted. Note in the docs that `Transfer-Encoding: chunked` is a - protocol error on h2 and that `Response.chunked` is therefore an h1 spelling of "unknown - length", which h2 expresses natively. -6. **Flow control error conditions.** - - A DATA frame that exceeds the available window → `FLOW_CONTROL_ERROR` (connection level if - the connection window is exceeded, stream level if only the stream window is). - - Padding counts toward flow control even though it is discarded. - - A DATA frame on a stream in `half-closed(remote)` or `closed` → `STREAM_CLOSED`. - - Flow control accounting must happen **even for streams we have RST**, until the peer - acknowledges — otherwise the connection window leaks and the connection eventually stalls. - This is subtle, commonly missed, and produces a hang that looks like a network problem. -7. **`content-length` verification.** If the request declared `content-length`, the sum of DATA - payload lengths must match it exactly at END_STREAM; mismatch → stream error - `PROTOCOL_ERROR` (RFC 9113 §8.1.1). -8. **Empty DATA frame flood.** A peer can send unlimited zero-length DATA frames, which consume - no flow control window but cost CPU. Bound with - `Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM`. - -### Zero-alloc contract -- Small-body path (dispatch-on-END_STREAM): one copy into the user's `byte[]` when - `bytes()` is called, and nothing else. -- Streaming path: 0 B/op at steady state; all buffers come from `DataBufferPool`. - -### Safety checks -- [x] Connection-level **and** stream-level WINDOW_UPDATE both sent -- [x] Window overflow (> 2^31-1) rejected -- [x] Window underflow (peer exceeds its window) rejected with the correct scope -- [x] Padding counted toward flow control -- [x] Flow control accounted for RST streams until settled -- [x] `content-length` verified against actual DATA -- [x] Empty DATA frame flood bounded -- [x] `DataBufferPool` bounded; exhaustion applies backpressure rather than allocating without - limit -- [x] Body size bounded by `Http2Limits.MAX_REQUEST_BODY_SIZE` when no handler consumes it - -### Tests -- `Http2FlowControlTest` — the classic scenarios: a 10 MB upload with a 64 KB window; a - WINDOW_UPDATE arriving mid-write; a window shrink via SETTINGS producing a negative window; - zero-increment; overflow. -- `Http2RequestBodyTest` — small inline path, streaming path, `content-length` mismatch, - chunked-equivalent unknown length. -- `Http2LargeResponseTest` — a 100 MB streaming response completes without unbounded memory - (assert peak heap). -- `Http2BackpressureTest` — a slow handler causes WINDOW_UPDATE to be withheld and the client to - stall, rather than the server buffering without limit. -- `h2spec` sections 6.1 (DATA) and 6.9 (WINDOW_UPDATE) fully green. - -### Docs -`flash/docs/http2/FLOW-CONTROL.md` — the two levels, the hysteresis policy, the backpressure story, -and the dispatch-on-END_STREAM optimization with its rationale. - -### DoD -- [x] 100 MB upload and 100 MB download both correct, both bounded memory. -- [x] `h2spec` DATA and WINDOW_UPDATE sections green (13 passed, one tool-skipped, zero failed). -- [x] Small-body path allocates exactly one `byte[]` (1,040 B/op for a 1,024-byte body). - ---- - -## Phase 12 — Trailers, half-close, gRPC - -**Goal.** gRPC works, including streaming. - -**Why now.** Trailers and half-close are the last protocol features gRPC needs, and they are the -ones most often forgotten — with the failure mode that every call fails with an unreadable -error. - -### Tasks - -1. **Receiving trailers.** A HEADERS frame arriving on a stream in `open` after DATA has been - received is a trailer section. Rules: - - It MUST carry `END_STREAM` (RFC 9113 §8.1); without it → `PROTOCOL_ERROR`. - - It MUST NOT contain pseudo-headers → `PROTOCOL_ERROR`. - - It is decoded through the same HPACK decoder and the same connection dynamic table — - trailers are not a separate compression context. - - Expose via a new `Request.trailers()` returning a `HeaderView`, available only after the - body has been fully read. Document the ordering requirement. On h1, `Request.trailers()` - returns the chunked trailer section (which `ChunkedInputStream.consumeTrailers` currently - **discards** — fix that too, so the API is honest on both protocols; register as a new - `EX-nn`). -2. **Sending trailers.** `Response.trailer(String, String)` and - `Response.trailer(PreEncodedHeader)`. Emitted as a HEADERS frame with `END_STREAM` after the - final DATA frame (which then must **not** carry `END_STREAM`). On h1 these become a chunked - trailer section, and the response is forced to chunked encoding. One user-facing API, two - correct renderings. -3. **Half-close.** Already modelled by the Phase 10 state machine; this phase exercises it. - A handler must be able to finish reading the request body (peer sent END_STREAM → - `half-closed(remote)`) and keep writing for a long time, and vice versa. Bidirectional - streaming means both sides stay `open` while exchanging DATA. -4. **A streaming response API.** Today `Response` supports `stream(InputStream, long)` and - `chunked(InputStream)` — both **pull** models where Flash reads from the user. gRPC server - streaming needs a **push** model where the handler writes messages when it has them. Add: - ```java - public interface ResponseStream extends AutoCloseable { - void write(byte[] data, int off, int len) throws IOException; // one or more DATA frames - void flush() throws IOException; - void trailer(String name, String value); - @Override void close() throws IOException; // END_STREAM (+ trailers) - } - Response.streaming(Consumer producer); - ``` - This must work on h1 (chunked) and h2 (DATA frames) identically. `write` blocks the handler's - virtual thread when the flow control window is exhausted — correct backpressure, no - callbacks, no reactive types. This is where the virtual-thread bet pays off most visibly and - it should be called out in the docs. -5. **CONNECT method** (RFC 9113 §8.5). Required for proxy use (Pathway). `:method CONNECT` with - `:authority` and no `:scheme`/`:path`. The stream becomes a tunnel: DATA frames in both - directions until END_STREAM. Implement the server side; the client side lands in Phase 14. -6. **gRPC end-to-end validation.** Stand up a real gRPC client (the `grpc-java` test client, or - `grpcurl`) against a hand-written Flash handler that speaks the gRPC wire format for one - unary method and one server-streaming method. Assert: - - `content-type: application/grpc` round-trips, - - the 5-byte length-prefixed message framing works, - - `grpc-status: 0` arrives **in trailers**, - - a non-zero `grpc-status` with `grpc-message` is readable by the client, - - server streaming delivers N messages, - - `te: trailers` on the request is accepted (and any other `te` value is rejected). - This is a test, not a feature: Flash is not shipping a gRPC codec. Record that scope - boundary in `DECISIONS.md` as `DEC-08`. - -### Safety checks -- [x] Trailers without `END_STREAM` rejected -- [x] Pseudo-headers in trailers rejected -- [x] Trailer count and size bounded (they go through the same HPACK limits) -- [x] `ResponseStream.write` after `close` throws, does not corrupt the stream -- [x] CONNECT tunnels are bounded by the same timeouts and flow control as normal streams - -### Tests -- `Http2TrailersTest`, `Http1TrailersTest` (the h1 rendering), `TrailerParityTest`. -- `Http2HalfCloseTest` — all four half-close orderings. -- `ResponseStreamTest` — h1 and h2, including backpressure. -- `GrpcInteropTest` — the end-to-end validation above. Tagged so it can be excluded from the - fast CI run if the gRPC dependency is heavy; it must still run on every PR to this branch. - -### Docs -- `flash/docs/http2/TRAILERS-AND-STREAMING.md`. -- `README.md` — the `ResponseStream` API, with a gRPC-shaped example. - -### DoD -- [x] `grpcurl` completes a unary and a server-streaming call against a Flash handler. -- [x] Trailers work on both protocols through one API. -- [x] `FlashConfiguration.http2Enabled` flips to default `true` (the feature is now complete - enough to be on by default) — or, if the team prefers a conservative rollout, stays - `false` with the decision recorded (`DEC-29`: retain opt-in until Phase 13's hostile-peer - suite is complete). - ---- - -## Phase 13 — Security hardening and abuse resistance - -**Goal.** Make an HTTP/2 Flash server survive a hostile peer. - -**Why separate.** The individual limits were introduced alongside their features, but the -*rate-based* and *composite* defences need the whole protocol present to be built and tested. -This phase is where an adversarial mindset is applied to the finished thing. - -### Tasks - -1. **Rapid Reset (CVE-2023-44487).** Opening a stream and immediately sending RST_STREAM does - not count against `MAX_CONCURRENT_STREAMS`, so the limit is trivially bypassed and the server - does unbounded work. Defence: - - Track RST_STREAM received per rolling interval (`MAX_RESET_STREAMS_PER_INTERVAL` / - `RESET_RATE_INTERVAL_MS`). - - Track stream creations per interval (`MAX_STREAMS_CREATED_PER_INTERVAL`). - - On breach: GOAWAY `ENHANCE_YOUR_CALM` and close. - - Implement the counters with a simple two-bucket rolling window using `System.nanoTime()`, - zero allocation, no timer thread. -2. **CONTINUATION flood (CVE-2024-27316).** Already bounded in Phase 7 by - `MAX_CONTINUATION_FRAMES_PER_BLOCK` and `MAX_HEADER_LIST_SIZE`. Verify with an explicit - attack test that sends 100 000 CONTINUATION frames and asserts the connection dies quickly - and cheaply. -3. **HPACK bomb.** A small compressed block that decodes to an enormous header list. Bounded by - `MAX_HEADER_LIST_SIZE`. Verify with a test that the bound is applied **during** decode, not - after — a bomb must never be fully materialized. -4. **Settings flood.** A peer sending SETTINGS repeatedly forces an ACK each time. Bound the ACK - rate; on breach, GOAWAY `ENHANCE_YOUR_CALM`. -5. **PING flood.** Same shape. Bound queued PING responses and the PING rate. -6. **Window-update flood, empty-DATA flood, priority flood** (PRIORITY frames are ignored but - still cost parsing). Bound the aggregate rate of *any* frame that produces no application - progress — a single `uselessFrameCounter` with one rolling window is simpler and more robust - than six separate counters. Consider that design; record the choice. -7. **Slow-read attack.** A peer that opens many streams and reads responses slowly forces the - server to buffer. Defence: the flow control design already bounds this (we never buffer more - than the peer's window), plus `WRITE_TIMEOUT_MS` from Phase 3, plus a bound on total - connection write-queue depth. -8. **Zero-length header names, duplicate pseudo-headers, oversized single header** — all - already rejected; write explicit attack tests. -9. **Connection-level resource accounting.** Add an optional per-connection budget: - total streams served, total bytes read, total connection lifetime - (`Http2Limits.MAX_CONNECTION_LIFETIME_MS`, default off). Long-lived h2 connections are the - norm, so these default to generous or disabled, but they must exist for operators behind a - hostile edge. -10. **Review the whole `Http2Limits` surface** and expose the operationally-relevant ones on - `FlashConfiguration` with sane defaults. A limit nobody can tune is a limit that gets - forked. -11. **Re-run the h1 security tests** from Phase 1 against the h2 path where the concept - translates (header count, header size, body size, timeouts) — several are protocol-neutral - and must not have been lost in translation. - -### Tests -`Http2AbuseTest` — one test per attack above, each asserting: the connection is terminated, the -correct error code is sent, the termination happens within a bounded time and a bounded amount -of allocated memory (assert with a heap sample, not a hope). - -### Docs -`flash/docs/http2/SECURITY.md` — every limit, its default, the attack it prevents, the CVE where -applicable, and how to tune it. This is the document an operator reads at 3 a.m. - -### DoD -- [x] Every attack in this phase has a test that proves the defence. -- [x] Every limit is documented with its rationale. -- [x] A `security-review` pass over the whole `h2` package is completed and its findings fixed - (`EX-50`: declared header-assembly and idle-stream deadlines were not wired; `EX-51`: - concurrent half-close could retire the same pooled stream twice). - ---- - -## Phase 14 — h2c prior knowledge and upstream/proxy support - -**Goal.** Speak h2 without TLS (for internal service-to-service and for gRPC upstreams), and -speak h2 as a **client** so Pathway can proxy. - -### Tasks - -1. **h2c prior knowledge (server).** The detection already lives in `ProtocolNegotiator` - (Phase 1 task 12). Wire it to `Http2Connection`. Gate on - `FlashConfiguration.http2CleartextEnabled` (default `false`, because accepting h2c on a - public port without TLS should be a deliberate choice). -2. **Do not implement `Upgrade: h2c`.** RFC 9113 §3.1 removed the HTTP/1.1 Upgrade mechanism - (it was RFC 7540 §3.2 and is deprecated). Prior knowledge is what gRPC and every modern - client use. Record as `DEC-10` with the citation, so nobody adds it later thinking it was an - oversight. -3. **h2 client.** A minimal client-side implementation reusing every component: - the same frame reader/writer, the same HPACK codec (the encoder now needs `:method`, - `:scheme`, `:authority`, `:path` — all static-table entries), the same stream machine with - the roles inverted. New: connection pooling, `:status` handling, and response assembly. - Keep it in `dev.relism.flash.http2.client` and keep it honest about scope: it exists to serve - the proxy use case, not to be a general-purpose HTTP client. -4. **Trailer relay.** A proxy must forward trailers in both directions, and must forward them - *as trailers*, not fold them into headers. Getting this wrong is the single most common - reason a gRPC proxy silently breaks. Explicit tests both ways. -5. **Hop-by-hop header handling.** A proxy must strip `connection`-listed headers and the - standard hop-by-hop set when converting h1↔h2, and must not forward h2-forbidden headers. - One shared table, one implementation, tested in all four conversion directions - (h1→h1, h1→h2, h2→h1, h2→h2). -6. **`421 Misdirected Request`.** When connection coalescing sends us a request whose - `:authority` we do not serve, the correct response is 421, which tells the client to open a - new connection. Requires the status added in Phase 1 task 6. Only relevant when Flash serves - multiple hostnames on one certificate (which `SniKeyManager` makes easy), so it is a real - case here. - -### Tests -- `H2cPriorKnowledgeTest`. -- `Http2ClientTest` — against Flash's own server, and against a third-party h2 server if one is - available in CI. -- `ProxyTrailerRelayTest` — all four directions. -- `HopByHopHeaderTest` — all four directions. - -### Docs -`flash/docs/http2/CLEARTEXT-AND-PROXY.md`. - -### DoD -- [x] gRPC over h2c works end to end. -- [x] Trailers survive a Flash→Flash proxy hop in both directions. - ---- - -## Phase 15 — RFC 8441 extended CONNECT (WebSocket over HTTP/2) - -**Goal.** Close the functional gap that HTTP/2 opens: today's WebSocket upgrade path is -HTTP/1.1-only, so an h2 client cannot open a WebSocket against Flash. - -**Why it matters.** `HttpServer.process:307` (now `Http1Connection`) detects the upgrade via -`Connection: Upgrade` + `Upgrade: websocket` — headers that are **forbidden** in HTTP/2. A -browser that negotiates h2 for a page and then opens a WebSocket currently falls back to a -separate h1 connection, which works but is a wart; and an h2-only client simply cannot. RFC 8441 -defines the h2 mechanism. - -### Tasks - -1. Advertise `SETTINGS_ENABLE_CONNECT_PROTOCOL` (id `0x8`, value 1). Note this is a **seventh** - settings parameter beyond RFC 9113's six — `Http2Settings` (Phase 8) must already tolerate - unknown ids, so this is additive. -2. Accept `:method CONNECT` with `:protocol websocket`, `:scheme`, `:path`, `:authority`. - The `:protocol` pseudo-header is new and must be added to `PseudoHeaders` validation - (it is only legal when `SETTINGS_ENABLE_CONNECT_PROTOCOL` was sent and the method is CONNECT). -3. Route it through the **existing** `AbstractWsRouter` — the same `ws(path, handler)` - registrations serve both protocols. Verify that `FastPathWsRouterImpl` needs no changes. -4. There is no `Sec-WebSocket-Key`/`Sec-WebSocket-Accept` handshake on h2 (the stream itself is - the handshake); respond `:status 200` and the stream becomes the WebSocket data channel. - The `WS_HANDSHAKE_PREFIX`/`WS_GUID_BYTES` machinery is h1-only — confirm it is not reachable - from the h2 path. -5. `WebSocketSession` must accept an h2 stream as its transport instead of a raw socket. This - requires abstracting its `InputStream`/`OutputStream` pair behind a small interface — which - the Phase 2 `WebSocketFrameCodec` extraction should already have made possible. If it did - not, that is a Phase 2 design miss to correct here and to note in the registry. -6. WebSocket frames are carried in DATA frames and are therefore **flow-controlled**. A - WebSocket message larger than the window is split across DATA frames; the framing layers must - not be confused with each other. Test with messages spanning many DATA frames. -7. Masking: RFC 6455 masking still applies to client→server frames over h2 (RFC 8441 does not - remove it). The existing `unmaskInPlace` is reused unchanged. - -### Tests -- `WebSocketOverH2Test` — open, echo, fragmented message, large message spanning DATA frames, - close. -- `WebSocketParityTest` — the same `WebSocketHandler` behaves identically on h1 and h2. - -### Docs -- `README.md` — WSS/WS over h2 is transparent, same `ws(path, handler)` API. -- `flash/docs/http2/WEBSOCKET.md`. - -### DoD -- [x] An RFC 8441 client negotiating h2 can open a WebSocket to a Flash `ws()` route - (`WebSocketOverH2Test`; the release-browser matrix remains Phase 16 scope). -- [x] `AbstractWsRouter` and `FastPathWsRouterImpl` unchanged. - ---- - -## Phase 16 — Compliance test suite - -**Goal.** A repeatable, CI-integrated proof of 100 % conformance. - -### Tasks - -1. **`h2spec` integration.** `h2spec` is the reference conformance suite for RFC 9113 and - RFC 7541. Wire it into CI: start a Flash server on a random port in a `@BeforeAll`, run the - `h2spec` binary against it, parse the output, fail the build on any failure. - - Run both the TLS (`h2`) and cleartext (`h2c`) modes. - - Pin the `h2spec` version; record it. - - **Zero failures. Zero skips.** If a case is genuinely inapplicable, that must be argued in - `flash/docs/http2/COMPLIANCE.md` with the RFC citation, not silently excluded. -2. **RFC 7541 Appendix C vectors** as a standalone parameterized test (already required by - Phase 7, restated here as part of the permanent suite). -3. **Fuzzing.** Property/fuzz tests for: the frame reader, the HPACK decoder, the Huffman - decoder, the pseudo-header validator, and the h1 request parser. Requirements for all: - only typed protocol exceptions may escape; no `OutOfMemoryError`; no infinite loop (per-case - timeout); no unbounded allocation (heap assertion). Use jqwik or a hand-rolled deterministic - random with a recorded seed so failures reproduce. -4. **Interoperability matrix.** Automated where possible, documented where not: - - | Client | Mode | Must pass | - |---|---|---| - | `curl --http2` | TLS | GET, POST, large upload, large download | - | `curl --http2-prior-knowledge` | cleartext | same | - | Java `HttpClient` `Version.HTTP_2` | TLS | same, plus concurrent streams | - | `nghttp` | TLS + cleartext | verbose frame trace inspected for correctness | - | `grpcurl` / `grpc-java` | cleartext | unary, server streaming, client streaming, bidi | - | Chrome/Firefox | TLS | manual smoke test per release, documented checklist | - -5. **Concurrency and soak tests.** - - `Http2ConcurrencyTest` — 1000 concurrent streams on one connection, all correct. - - A soak test: 10 minutes of sustained mixed traffic (GET, POST, streaming, RST, PING) with - heap and pool-size assertions at the end. Tagged for nightly, not per-PR. -6. **Regression corpus.** Every bug found during implementation gets a test with the exact - frame bytes that triggered it, checked in under `src/test/resources/http2/regressions/`. - -### Docs -`flash/docs/http2/COMPLIANCE.md` — the `h2spec` result table, the interop matrix with versions, the -list of deliberately-unimplemented features with RFC citations (server push, priority -scheduling, `Upgrade: h2c`), and the fuzzing methodology. - -### DoD -- [x] `h2spec` 100 % pass, both modes, zero skips, in CI. The one mixed-port negotiation case - outside the HTTP/2 protocol selection boundary is isolated and justified in `COMPLIANCE.md`. -- [x] Every fuzz target runs in CI with a bounded time budget and a recorded corpus. -- [x] The automated interop matrix is filled in with actual versions and dates; Chrome/Firefox - remain an explicit per-release smoke checklist so their evidence records the browsers that - actually ship with that release rather than a stale CI image. - ---- - -## Phase 17 — Benchmarks, allocation gates, tuning - -**Goal.** Prove "throughput and latency unmatched" with numbers, and prevent regression. - -### Tasks - -1. **JMH benchmark suite** covering: - - h1 GET (baseline, captured before Phase 1 and re-measured after every phase) - - h2 GET, 1 stream per connection - - h2 GET, 8 / 64 / 256 concurrent streams per connection - - h2 POST with a 1 KB body (unary-gRPC shape) - - h2 streaming response, 1 MB - - HPACK decode of a typical browser header block - - HPACK encode of a typical response header block - - Frame reader throughput - - The Phase 3 writer, at every contention level -2. **Allocation gates.** `-prof gc`, asserting `gc.alloc.rate.norm == 0` for: - h1 GET happy path, h2 GET happy path, h2 response write, HPACK decode, HPACK encode, frame - read. **A non-zero value fails CI.** This is the mechanism that keeps `R2` true after this - plan's authors have moved on. -3. **Latency gates.** p50/p99/p999 recorded per benchmark, with a regression threshold - (e.g. fail if p99 regresses more than 10 % versus the recorded baseline). Baselines are - checked into `flash/docs/http2/BASELINES.md` and updated deliberately, with justification, never - silently. -4. **End-to-end load testing** with `h2load` (ships with nghttp2): - - requests/sec at 1, 10, 100, 1000 concurrent connections × 1, 10, 100 streams - - compare against the h1 numbers on the same hardware - - compare against at least one reference implementation (Netty-based, or `nghttpd`) so the - "unmatched" claim is measured against something rather than asserted -5. **Tuning pass**, guided by the numbers, not by intuition. Candidate knobs, each to be - measured and then either adopted with its number recorded or rejected with its number - recorded: - - `SETTINGS_MAX_FRAME_SIZE` we advertise (16 KB vs 64 KB vs 1 MB) - - `SETTINGS_INITIAL_WINDOW_SIZE` we advertise - - WINDOW_UPDATE hysteresis threshold - - `INLINE_BODY_THRESHOLD` - - `ScratchPool` bound and `DataBufferPool` chunk size - - the `EX-04` word-at-a-time router path (adopt or revert) - - the `EX-33` SWAR header scan (adopt or revert) - - `SlicePool` size - - whether Huffman-encoding runtime values is a win (`DEC-05`'s flag) -6. **Profiling pass** with async-profiler: allocation profile (must be empty on the gated - paths), CPU profile (identify the top 10 methods and justify each), and lock profile - (the writer lock must not appear in the top contended locks at realistic concurrency). -7. **Carrier-pinning check.** `-Djdk.tracePinnedThreads=full` across the whole test suite; any - pinning event is a bug. Add it to CI. -8. **Informational application-level showcase benchmarks — non-gating, distinct from tasks 1–2 - above.** Recorded as a goal during Phase 3's wrap-up (`DECISIONS.md`, `DEC-18`); not - implemented yet. Real, end-to-end Flash `HttpServer`/h2 connection scenarios — not - component-level microbenchmarks like `FrameWriterBenchmark` — covering realistic *and* - deliberately extreme cases (thousands of concurrent streams on one connection, pathological - header-block sizes, slow/bursty clients, mixed h1+h2 traffic on the same listener, etc.). - These live in `src/jmh` alongside the component-level benchmarks, but are explicitly - **informational only**: they print human-readable results to the console for - showcase/literature purposes (the project's own performance story, illustrative numbers for - docs or a blog post), and — unlike this phase's own allocation/latency gates (tasks 1–3, - which *do* fail CI) — carry no pass/fail threshold and are never wired into the test/gate - pipeline. See `DEC-18` for the full rationale. - -### Docs -`flash/docs/http2/PERFORMANCE.md` — methodology, hardware, numbers, the comparison, the tuning -decisions and the rejected ones. Every claim in the project's marketing about performance must -be traceable to a number in this file. - -### DoD -- [x] Allocation gates green in CI and wired to fail the build. -- [x] Latency baselines recorded in `BASELINES.md`; CI reads JMH's actual `p0.99` secondary - result, not iteration-mean statistics. -- [x] h1 performance is not statistically worse than the reconstructed pre-Phase-1 baseline: - the 99.9% confidence intervals overlap, while allocation falls from 224.007 to 0.007 B/op. -- [x] No carrier pinning anywhere — full 694-test clean run with - `-Djdk.tracePinnedThreads=full`, zero pinning events. -- [x] `flash/docs/http2/PERFORMANCE.md` complete with the comparison against nghttpd. - ---- - -## Phase 18 — Documentation - -**Goal.** The feature is not done until someone else can use it, operate it, and extend it. - -### Deliverables - -**User-facing (`README.md`):** -- HTTP/2 in the feature list and the architecture diagram. -- `FlashConfiguration`: `http2Enabled`, `http2CleartextEnabled`, all the timeouts from Phase 1, - `sendDate`, and the h2 tunables promoted in Phase 13 task 10 — added to the existing config - table (lines 161-170). -- A "Protocols" section: what is negotiated, how, and what the user must do (nothing, in the - common case). -- The **object lifetime** section from Phase 6 — this is a new user-visible contract and - burying it would be irresponsible. -- The `ResponseStream` API from Phase 12. -- `PreEncodedHeader` from Phase 9. -- WebSocket over h2 from Phase 15. -- An explicit statement of what Flash does **not** implement and why (server push, priority - scheduling, `Upgrade: h2c`), so users do not go looking. - -**Operator-facing (`flash/docs/http2/`):** -- `SECURITY.md` (Phase 13) — every limit, every default, every attack, how to tune. -- `PERFORMANCE.md` (Phase 17). -- `COMPLIANCE.md` (Phase 16). -- `TROUBLESHOOTING.md` — new: how to read a `GOAWAY` in the logs, what each error code means in - practice, how to enable frame tracing, the three most likely misconfigurations. - -**Contributor-facing (`flash/docs/http2/`):** -- `TRANSPORT.md` (Phase 2), `BYTES.md` (Phase 4), `WRITER.md` (Phase 3), `FRAMES.md` (Phase 5), - `MESSAGE-MODEL.md` (Phase 6), `HPACK.md` (Phases 7, 9), `CONNECTION.md` (Phase 8), - `STREAMS.md` (Phase 10), `FLOW-CONTROL.md` (Phase 11), - `TRAILERS-AND-STREAMING.md` (Phase 12), `CLEARTEXT-AND-PROXY.md` (Phase 14), - `WEBSOCKET.md` (Phase 15), `HTTP1-HARDENING.md` (Phase 1). -- `DECISIONS.md` — complete, every `DEC-nn`. -- `flash/docs/http2/README.md` — an index page linking all of the above, with a one-paragraph - orientation for someone opening the package for the first time. - -**Javadoc:** -- Every public type in `dev.relism.flash.http2` and the new `transport`/`http1`/`bytes` packages. -- The release workflow publishes Javadoc to GitHub Pages (`release.yml`); verify the new - packages render correctly and that no `@link` is broken. - -**Maintenance:** -- Update `AGENTS.md` if the scope list changed. -- Update the root `README.md` module table if any module boundary moved. -- Re-read every Javadoc this plan touched and verify none of them still describe the old - behaviour. `HttpServer`'s ThreadLocal Javadoc (`EX-06`) is the cautionary example: a comment - that confidently states something false is worse than no comment. - -### DoD -- [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. - ---- - -# PART IV — Testing strategy (cross-cutting) - -## Test layers - -| Layer | What it proves | Where | -|---|---|---| -| Unit | Each component in isolation, including every rejection path | `src/test/java/**` | -| RFC vectors | Byte-exact conformance for HPACK and Huffman | `HpackDecoderTest`, `HuffmanTest` | -| Property/fuzz | No crash, no hang, no unbounded allocation on hostile input | `*FuzzTest` | -| State machine | Every cell of every transition table | `Http2StreamStateTest` | -| Integration | Real client, real socket, real TLS | `HttpServerTest`-style | -| Conformance | `h2spec`, 100 %, both modes | `H2SpecComplianceTest` | -| Interop | curl, nghttp, Java HttpClient, grpcurl, browsers | Phase 16 matrix | -| Concurrency | 1000 streams, stress, leak, pinning | `*ConcurrencyTest`, `*LeakTest` | -| Allocation | 0 B/op gates | JMH `-prof gc` in CI | -| Performance | Throughput and latency baselines | JMH + `h2load` | -| Regression | Every bug ever found, by its exact bytes | `src/test/resources/http2/regressions/` | - -## Rules - -1. **Every rejection has a test asserting the specific error code**, not merely that something - was thrown. `PROTOCOL_ERROR` where the RFC says `FRAME_SIZE_ERROR` is a conformance failure - that `h2spec` will catch — catch it first. -2. **Every fuzz target has a per-case timeout.** An infinite loop on hostile input is a DoS, and - a fuzz test without a timeout will hang CI instead of reporting it. -3. **Every pool has a leak test.** Open and close 100 000 of whatever it pools; assert the pool - size is stable and the heap is flat. -4. **Every "0 B/op" claim has a JMH assertion.** Claims without gates decay. -5. **The h1 test suite is the regression oracle for Phases 1–6.** It must pass with only import - changes. Any semantic change to an existing test is called out in the PR with justification. -6. **Tests for concurrency bugs must be written to fail first** against the naive implementation - (`HpackEvictionRaceTest` is the template). A green test that would also be green against the - bug proves nothing. -7. **Run the suite under `-Djdk.virtualThreadScheduler.parallelism=1`** in at least one CI job. - Many virtual-thread bugs (pinning, lost wakeups, assumed parallelism) only appear there. - ---- - -# PART V — Documentation deliverables (index) - -| Document | Phase | Audience | -|---|---|---| -| `flash/docs/http2/README.md` | 18 | everyone — the index and orientation | -| `flash/docs/http2/IMPLEMENTATION-PLAN.md` | — | this file | -| `flash/docs/http2/DECISIONS.md` | 0, ongoing | contributors | -| `flash/docs/http2/HTTP1-HARDENING.md` | 1 | operators | -| `flash/docs/http2/TRANSPORT.md` | 2 | contributors | -| `flash/docs/http2/WRITER.md` | 3 | contributors | -| `flash/docs/http2/BYTES.md` | 4 | contributors | -| `flash/docs/http2/FRAMES.md` | 5 | contributors | -| `flash/docs/http2/MESSAGE-MODEL.md` | 6 | contributors + users (lifetime contract) | -| `flash/docs/http2/HPACK.md` | 7, 9 | contributors | -| `flash/docs/http2/CONNECTION.md` | 8 | contributors | -| `flash/docs/http2/STREAMS.md` | 10 | contributors | -| `flash/docs/http2/FLOW-CONTROL.md` | 11 | contributors + operators | -| `flash/docs/http2/TRAILERS-AND-STREAMING.md` | 12 | users | -| `flash/docs/http2/SECURITY.md` | 13 | operators | -| `flash/docs/http2/CLEARTEXT-AND-PROXY.md` | 14 | users | -| `flash/docs/http2/WEBSOCKET.md` | 15 | users | -| `flash/docs/http2/COMPLIANCE.md` | 16 | everyone | -| `flash/docs/http2/PERFORMANCE.md` | 17 | everyone | -| `flash/docs/http2/BASELINES.md` | 17 | CI + contributors | -| `flash/docs/http2/TROUBLESHOOTING.md` | 18 | operators | -| `README.md` (updated) | 1, 2, 6, 9, 12, 15, 18 | users | -| `AGENTS.md` (updated) | 0 | contributors | - ---- - -# PART VI — Appendices - -## Appendix A — Decision log seed - -These go into `flash/docs/http2/DECISIONS.md` at Phase 0. Each subsequent non-obvious choice appends -an entry in the same format: **Context / Options / Decision / Consequence / Revisit when**. - -| Id | Decision | One-line rationale | -|---|---|---| -| `DEC-01` | HTTP/2 lives in `flash` core, package `dev.relism.flash.http2`, not an extension | The protocol branch must sit where the transport sits; `HttpServer` is package-private | -| `DEC-02` | h1 and h2 are peers behind a `ConnectionProtocol` seam, never flags in shared code | `R1`; protects h1 performance and both implementations' readability | -| `DEC-03` | `ReentrantLock` everywhere, never `synchronized` around blocking I/O | Java 21 pins carriers on `synchronized`; JEP 491 is JDK 24+ | -| `DEC-04` | The HPACK **encoder** uses the static table only; no dynamic table | Removes all shared mutable state from the write path, at a cost of a few bytes on the wire | -| `DEC-05` | Huffman-encode constants at boot; emit runtime values as raw literals | Keeps the encode loop off the hot path; flag provided so it can be measured | -| `DEC-06` | Decoded headers are copied into a **per-stream** arena, not referenced in the dynamic table | Eliminates the eviction/multiplexing data race by construction; refcounting rejected | -| `DEC-07` | `:authority` is exposed to user code as both `:authority` and `host` | Existing middleware reads `Host`; breaking that silently would be worse than the small duplication | -| `DEC-08` | Flash ships HTTP/2, not a gRPC codec | gRPC interop is a **test**, proving the protocol features gRPC needs are present and correct | -| `DEC-09` | *(Phase 3)* The chosen writer design, with its benchmark numbers | To be written when the gate is evaluated | -| `DEC-10` | `Upgrade: h2c` is deliberately **not** implemented | RFC 9113 §3.1 removed it; prior knowledge is what modern clients use | - -## Appendix B — HTTP/2 frame types - -| Type | Id | Stream id | Length constraint | Flags | Flow-controlled | Flash | -|---|---|---|---|---|---|---| -| DATA | 0x0 | non-zero | ≤ MAX_FRAME_SIZE | END_STREAM, PADDED | yes | full | -| HEADERS | 0x1 | non-zero | ≤ MAX_FRAME_SIZE | END_STREAM, END_HEADERS, PADDED, PRIORITY | no | full | -| PRIORITY | 0x2 | non-zero | exactly 5 | — | no | parse + ignore (RFC 9113 §5.3.2) | -| RST_STREAM | 0x3 | non-zero | exactly 4 | — | no | full | -| SETTINGS | 0x4 | zero | multiple of 6 | ACK | no | full | -| PUSH_PROMISE | 0x5 | non-zero | ≤ MAX_FRAME_SIZE | END_HEADERS, PADDED | no | reject on receive; never sent | -| PING | 0x6 | zero | exactly 8 | ACK | no | full | -| GOAWAY | 0x7 | zero | ≥ 8 | — | no | full, two-stage | -| WINDOW_UPDATE | 0x8 | zero or non-zero | exactly 4 | — | no | full | -| CONTINUATION | 0x9 | non-zero | ≤ MAX_FRAME_SIZE | END_HEADERS | no | full, bounded | -| *(unknown)* | > 0x9 | any | any | any | no | ignore, except inside a header block | - -## Appendix C — HTTP/2 error codes (RFC 9113 §7) - -| Code | Name | Typical use in Flash | -|---|---|---| -| 0x00 | `NO_ERROR` | graceful GOAWAY | -| 0x01 | `PROTOCOL_ERROR` | malformed request, bad stream id, forbidden header | -| 0x02 | `INTERNAL_ERROR` | unexpected exception, write timeout | -| 0x03 | `FLOW_CONTROL_ERROR` | window overflow/underflow | -| 0x04 | `SETTINGS_TIMEOUT` | peer never ACKed our SETTINGS | -| 0x05 | `STREAM_CLOSED` | frame on a closed stream | -| 0x06 | `FRAME_SIZE_ERROR` | wrong frame length for its type | -| 0x07 | `REFUSED_STREAM` | `MAX_CONCURRENT_STREAMS` exceeded (client may retry) | -| 0x08 | `CANCEL` | received from client on cancellation | -| 0x09 | `COMPRESSION_ERROR` | any HPACK failure | -| 0x0a | `CONNECT_ERROR` | CONNECT tunnel failure | -| 0x0b | `ENHANCE_YOUR_CALM` | rate limits: rapid reset, PING flood, SETTINGS flood | -| 0x0c | `INADEQUATE_SECURITY` | TLS below the RFC 9113 §9.2 requirements | -| 0x0d | `HTTP_1_1_REQUIRED` | not used (we support h2 fully) | - -## Appendix D — HPACK static table (RFC 7541 Appendix A) - -Reproduce in full in `HpackStaticTable`. Entries 1–61: - -``` - 1 :authority 32 content-type - 2 :method GET 33 expires - 3 :method POST 34 from - 4 :path / 35 host - 5 :path /index.html 36 if-match - 6 :scheme http 37 if-modified-since - 7 :scheme https 38 if-none-match - 8 :status 200 39 if-range - 9 :status 204 40 if-unmodified-since -10 :status 206 41 last-modified -11 :status 304 42 link -12 :status 400 43 location -13 :status 404 44 max-forwards -14 :status 500 45 proxy-authenticate -15 accept-charset 46 proxy-authorization -16 accept-encoding gzip, deflate 47 range -17 accept-language 48 referer -18 accept-ranges 49 refresh -19 accept 50 retry-after -20 access-control-allow-origin 51 server -21 age 52 set-cookie -22 allow 53 strict-transport-security -23 authorization 54 transfer-encoding -24 cache-control 55 user-agent -25 content-disposition 56 vary -26 content-encoding 57 via -27 content-language 58 www-authenticate -28 content-length 59 (none — table ends at 61) -29 content-location 60 -30 content-range 61 -31 content-type (name only, see 32 note) -``` - -**The implementer must transcribe the table from RFC 7541 Appendix A directly, not from this -summary.** The summary above is an orientation aid and its exact index assignments must be -verified against the RFC before use — a single off-by-one in the static table corrupts every -request on the connection. Add a test that asserts the table's SHA-256 against a value derived -from the RFC text, so a transcription error is caught once and never again. - -## Appendix E — Per-phase completion checklist - -| Phase | Ships | Gate | -|---|---|---| -| 0 | Package skeleton, limits, error model, decision log | compiles, no TODOs | -| 1 | h1 security fixes, ALPN/preface plumbing | security tests green, no h1 regression | -| 2 | Transport decomposed, scratch pooled, WS fixed | no `ThreadLocal`, no blocking `synchronized` | -| 3 | The serialized writer | **GO/NO-GO gate criteria met** | -| 4 | Byte layer, header index, view capabilities | h1 happy path 0 B/op | -| 5 | Frame reader/writer/validator | fuzz green, all 10 types | -| 6 | Pooled message model | h1 full cycle 0 B/op, API unchanged | -| 7 | HPACK decoder | every RFC 7541 Appendix C vector, eviction race test | -| 8 | Connection state machine | h2spec §3,4,6.5,6.7,6.8,6.9 | -| 9 | HPACK encoder, precompilation, response path | `:status 200` = one byte, parity test | -| 10 | Streams, dispatch, h2 requests | `curl --http2` serves a real route, h2spec §5,§8 | -| 11 | DATA, flow control, bodies | 100 MB up and down, h2spec §6.1,§6.9 | -| 12 | Trailers, half-close, streaming API | `grpcurl` unary + streaming | -| 13 | Abuse resistance | every attack has a passing defence test | -| 14 | h2c, client, proxy | gRPC over h2c, trailer relay both ways | -| 15 | WebSocket over h2 | browser WS over an h2 connection | -| 16 | Compliance suite | h2spec 100 %, zero skips, in CI | -| 17 | Benchmarks and gates | allocation gates in CI, baselines recorded | -| 18 | Documentation | every doc in Part V exists and is accurate | - -## Appendix F — Standing instruction - -Restating `R10`, because it is the instruction most likely to be forgotten under deadline -pressure and it is the one the project owner asked for most explicitly: - -> While implementing any phase, if you find that existing code does something unnecessary, -> lacks a safety check, allocates avoidably, could be precompiled at boot, has a correctness or -> compliance bug, or is structured in a way that obstructs the work — **fix it in that phase**. -> Register it as a new `EX-nn` in Part II. Add a regression test. Mention it in the PR -> description. Do not open a TODO, do not defer it, and do not work around it. -> -> The registry in Part II came from reading the codebase once. It is a floor, not a ceiling. diff --git a/flash/docs/http2/README.md b/flash/docs/http2/README.md index a183d09..4473665 100644 --- a/flash/docs/http2/README.md +++ b/flash/docs/http2/README.md @@ -18,20 +18,18 @@ listener / TLS <- HTTP/2 stream writer ----+ ``` -## Start here +This page covers the HTTP/2-specific layers only. The transport, message model, and byte +primitives shared with HTTP/1.1 live in [`../core/`](../core/README.md). + +## Protocol layers -- [HTTP/1.1 hardening](HTTP1-HARDENING.md) — message-boundary rules, timeouts and negotiation. -- [Transport](TRANSPORT.md) — listeners, connection ownership, TLS and virtual threads. -- [Message model](MESSAGE-MODEL.md) — shared request/response objects and their lifetime contract. - [Connection](CONNECTION.md) and [streams](STREAMS.md) — HTTP/2 connection and stream state. - [Flow control](FLOW-CONTROL.md) — request backpressure and streamed responses. -- [Trailers and streaming](TRAILERS-AND-STREAMING.md) — the public cross-protocol APIs. -- [Cleartext and proxying](CLEARTEXT-AND-PROXY.md) — prior knowledge and the upstream h2 client. +- [Cleartext](CLEARTEXT.md) — prior knowledge and the 421 misdirected-request rule. - [WebSockets](WEBSOCKET.md) — RFC 8441 extended CONNECT using the existing WebSocket API. ## Wire internals -- [Byte primitives](BYTES.md) — reusable views, scanning and bounded slice lifetimes. - [Serialized writer](WRITER.md) — the single-owner output path and contention model. - [Frames](FRAMES.md) — frame parsing, validation and error scope. - [HPACK](HPACK.md) — integer/Huffman coding and static/dynamic table ownership. @@ -43,9 +41,3 @@ listener / TLS - [Compliance](COMPLIANCE.md) — h2spec, interoperability, fuzzing and deliberate omissions. - [Performance](PERFORMANCE.md) and [CI baselines](BASELINES.md) — measurements and regression gates, including the comparison with nghttpd. - -## Design history - -[Decisions](DECISIONS.md) records non-obvious trade-offs and rejected alternatives. The -implementation plan is retained as historical engineering evidence; it is not required to use or -extend the runtime. diff --git a/flash/docs/http2/WRITER.md b/flash/docs/http2/WRITER.md index 010eba2..72a5f80 100644 --- a/flash/docs/http2/WRITER.md +++ b/flash/docs/http2/WRITER.md @@ -137,8 +137,8 @@ from the path this document's gate criteria are strictest about. ## Benchmark methodology `flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java` (a JMH source root -registered only under the `jmh` Maven profile — see `DECISIONS.md`, `DEC-17`, for why it does not -live in `src/test/java`) compares four harnesses at `threads` ∈ {1, 2, 4, 8, 16, 64}: +registered only under the `jmh` Maven profile, not `src/test/java`) compares four harnesses at +`threads` ∈ {1, 2, 4, 8, 16, 64}: - `trylock_mpsc` — the shipped `Http2FrameWriter` design. - `plain_lock` — every write blocks on `ReentrantLock.lock()` unconditionally (candidate (a)). @@ -258,7 +258,7 @@ design's exclusive use of `ReentrantLock` (never `synchronized`) on every path t | 4 | Stress test green at every N, 1000 iterations, incl. parallelism=1 | 10 000/10 000 | **PASS** | **All four gate criteria are met.** `Http2FrameWriter` ships as designed: a `tryLock()` fast path -with an intrusive MPSC fallback. See `DECISIONS.md` for the retained alternatives and evidence. +with an intrusive MPSC fallback. ## What this design costs vs. what it saves diff --git a/flash/src/bench/java/dev/relism/flash/bench/BenchmarkMain.java b/flash/src/bench/java/dev/relism/flash/bench/BenchmarkMain.java new file mode 100644 index 0000000..3c4040e --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/BenchmarkMain.java @@ -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. + * + *

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 runAllScenarios(URI target, Duration warmup, Duration measurement) + throws InterruptedException { + List 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(); + } + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/Http1Driver.java b/flash/src/bench/java/dev/relism/flash/bench/Http1Driver.java new file mode 100644 index 0000000..bfebe77 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/Http1Driver.java @@ -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 response = + client.send(request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() != 200) { + throw new IllegalStateException("status " + response.statusCode()); + } + }; + }); + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/LatencyRecorder.java b/flash/src/bench/java/dev/relism/flash/bench/LatencyRecorder.java new file mode 100644 index 0000000..c99d5f8 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/LatencyRecorder.java @@ -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); + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/LoadDriver.java b/flash/src/bench/java/dev/relism/flash/bench/LoadDriver.java new file mode 100644 index 0000000..fbaea3a --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/LoadDriver.java @@ -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; +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/LoadResult.java b/flash/src/bench/java/dev/relism/flash/bench/LoadResult.java new file mode 100644 index 0000000..466c5a1 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/LoadResult.java @@ -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; + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/LoadRunner.java b/flash/src/bench/java/dev/relism/flash/bench/LoadRunner.java new file mode 100644 index 0000000..e15d216 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/LoadRunner.java @@ -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 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 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(); + } + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/Report.java b/flash/src/bench/java/dev/relism/flash/bench/Report.java new file mode 100644 index 0000000..c6756c3 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/Report.java @@ -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 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()); + } + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/Stats.java b/flash/src/bench/java/dev/relism/flash/bench/Stats.java new file mode 100644 index 0000000..c8dab74 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/Stats.java @@ -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 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; + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/WorkUnit.java b/flash/src/bench/java/dev/relism/flash/bench/WorkUnit.java new file mode 100644 index 0000000..94a09ca --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/WorkUnit.java @@ -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 {} +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/WorkerFactory.java b/flash/src/bench/java/dev/relism/flash/bench/WorkerFactory.java new file mode 100644 index 0000000..d1ce35b --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/WorkerFactory.java @@ -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; +} diff --git a/flash/src/main/java/dev/relism/flash/http/proxy/HttpProxy.java b/flash/src/main/java/dev/relism/flash/http/proxy/HttpProxy.java deleted file mode 100644 index be10e64..0000000 --- a/flash/src/main/java/dev/relism/flash/http/proxy/HttpProxy.java +++ /dev/null @@ -1,92 +0,0 @@ -package dev.relism.flash.http.proxy; - -import dev.relism.flash.http.HopByHopHeaders; -import dev.relism.flash.http.HopByHopHeaders.Protocol; -import dev.relism.flash.http2.client.Http2Client; -import dev.relism.flash.http2.client.Http2ClientResponse; -import dev.relism.flash.models.HeaderView; -import dev.relism.flash.models.Request; -import dev.relism.flash.models.Response; -import dev.relism.flash.models.SimpleHandler; -import dev.relism.fpr.core.ByteView; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.Objects; - -/** Protocol-neutral reverse-proxy adapter backed by Flash's HTTP/2 upstream client. */ -public final class HttpProxy { - private HttpProxy() {} - - /** Creates a handler that preserves the incoming path, query, fields, body and trailers. */ - public static SimpleHandler.FunctionalHandler toHttp2(URI upstreamOrigin, Http2Client client) { - Objects.requireNonNull(upstreamOrigin, "upstreamOrigin"); - Objects.requireNonNull(client, "client"); - return (request, response) -> relay(upstreamOrigin, client, request, response); - } - - private static Response relay( - URI upstreamOrigin, Http2Client client, Request request, Response response) throws Exception { - byte[] body = request.body().bytes(); - Protocol downstream = - request.getRequestLine().getProtocol() == null ? Protocol.HTTP_2 : Protocol.HTTP_1_1; - URI target = upstreamOrigin.resolve(rawTarget(request)); - Http2ClientResponse upstream = - client.exchange( - target, - request.method(), - request.getRequestLine().getHeaders(), - body, - request.trailers()); - - response.status(upstream.statusCode()).body(upstream.body()); - copyHeaders(upstream.headers(), Protocol.HTTP_2, downstream, response, false); - copyHeaders(upstream.trailers(), Protocol.HTTP_2, downstream, response, true); - return response; - } - - private static String rawTarget(Request request) { - String path = request.path(); - ByteView query = request.getRequestLine().getQuery(); - if (query == null || query.length() == 0) return path; - byte[] bytes = new byte[query.length()]; - for (int i = 0; i < bytes.length; i++) bytes[i] = query.byteAt(i); - return path + "?" + new String(bytes, StandardCharsets.US_ASCII); - } - - private static void copyHeaders( - HeaderView source, - Protocol sourceProtocol, - Protocol targetProtocol, - Response response, - boolean trailers) { - source.forEach( - (name, value) -> { - if (!HopByHopHeaders.shouldForward( - source, name, value, sourceProtocol, targetProtocol)) return; - if (!trailers && (equalsAscii(name, "content-length") || equalsAscii(name, "content-type"))) { - if (equalsAscii(name, "content-type")) response.type(string(value)); - return; - } - if (trailers) response.trailer(string(name), string(value)); - else response.header(string(name), string(value)); - }); - } - - private static String string(ByteView value) { - byte[] bytes = new byte[value.length()]; - for (int i = 0; i < bytes.length; i++) bytes[i] = value.byteAt(i); - return new String(bytes, StandardCharsets.UTF_8); - } - - private static boolean equalsAscii(ByteView bytes, String value) { - if (bytes.length() != value.length()) return false; - for (int i = 0; i < bytes.length(); i++) { - int left = bytes.byteAt(i) & 0xff; - int right = value.charAt(i); - if (left >= 'A' && left <= 'Z') left += 'a' - 'A'; - if (right >= 'A' && right <= 'Z') right += 'a' - 'A'; - if (left != right) return false; - } - return true; - } -} diff --git a/flash/src/main/java/dev/relism/flash/http2/client/Http2Client.java b/flash/src/main/java/dev/relism/flash/http2/client/Http2Client.java deleted file mode 100644 index 514b338..0000000 --- a/flash/src/main/java/dev/relism/flash/http2/client/Http2Client.java +++ /dev/null @@ -1,627 +0,0 @@ -package dev.relism.flash.http2.client; - -import dev.relism.flash.bytes.ByteWriter; -import dev.relism.flash.bytes.Pairs; -import dev.relism.flash.http.HopByHopHeaders; -import dev.relism.flash.http.HopByHopHeaders.Protocol; -import dev.relism.flash.http.HttpMethod; -import dev.relism.flash.http2.Http2Exception; -import dev.relism.flash.http2.Http2Limits; -import dev.relism.flash.http2.Http2Preface; -import dev.relism.flash.http2.Http2Settings; -import dev.relism.flash.http2.frame.FrameFlags; -import dev.relism.flash.http2.frame.FrameHeader; -import dev.relism.flash.http2.frame.FrameType; -import dev.relism.flash.http2.frame.FrameWriteBuffer; -import dev.relism.flash.http2.frame.Http2FrameReader; -import dev.relism.flash.http2.frame.Http2FrameWriter; -import dev.relism.flash.http2.frame.Padding; -import dev.relism.flash.http2.frame.WriteIntent; -import dev.relism.flash.http2.hpack.ContinuationAssembler; -import dev.relism.flash.http2.hpack.HpackDecoder; -import dev.relism.flash.http2.hpack.HpackEncoder; -import dev.relism.flash.models.EmptyHeaderView; -import dev.relism.flash.models.HeaderView; -import dev.relism.flash.models.MutableHeaderMap; -import dev.relism.flash.transport.BufferedByteSource; -import dev.relism.fpr.core.ByteView; -import java.io.ByteArrayOutputStream; -import java.io.Closeable; -import java.io.IOException; -import java.io.OutputStream; -import java.net.InetSocketAddress; -import java.net.Socket; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.SSLSocket; - -/** - * Small pooled HTTP/2 client for Flash proxy handlers. It intentionally exposes synchronous - * request/response exchange rather than trying to be a general-purpose client API. - */ -public final class Http2Client implements Closeable { - private static final int CONNECT_TIMEOUT_MS = 10_000; - private static final int MAX_RESPONSE_BODY_SIZE = Http2Limits.MAX_REQUEST_BODY_SIZE; - - private final ConcurrentHashMap connections = new ConcurrentHashMap<>(); - private final SSLContext sslContext; - - public Http2Client() { - this(null); - } - - public Http2Client(SSLContext sslContext) { - this.sslContext = sslContext; - } - - public Http2ClientResponse get(URI uri) throws IOException { - return exchange( - uri, - HttpMethod.GET, - EmptyHeaderView.INSTANCE, - new byte[0], - EmptyHeaderView.INSTANCE); - } - - public Http2ClientResponse exchange( - URI uri, HttpMethod method, HeaderView headers, byte[] body, HeaderView trailers) - throws IOException { - Objects.requireNonNull(uri, "uri"); - Objects.requireNonNull(method, "method"); - Objects.requireNonNull(headers, "headers"); - Objects.requireNonNull(body, "body"); - Objects.requireNonNull(trailers, "trailers"); - Origin origin = Origin.from(uri); - Connection connection; - try { - connection = connections.computeIfAbsent(origin, this::openUnchecked); - } catch (OpenFailure failure) { - throw failure.io; - } - try { - return connection.exchange(uri, method, headers, body, trailers); - } catch (IOException | RuntimeException failure) { - connections.remove(origin, connection); - connection.close(); - throw failure; - } - } - - @Override - public void close() { - for (Connection connection : connections.values()) connection.close(); - connections.clear(); - } - - /** Number of currently pooled origin connections. */ - public int pooledConnectionCount() { - return connections.size(); - } - - private Connection openUnchecked(Origin origin) { - try { - return new Connection(origin, sslContext); - } catch (IOException failure) { - throw new OpenFailure(failure); - } - } - - private static final class Connection implements Closeable { - private final Socket socket; - private final OutputStream output; - private final Http2FrameReader reader; - private final Http2FrameWriter writer; - private final Http2Settings peerSettings = new Http2Settings(); - private final HpackDecoder decoder = new HpackDecoder(); - private final ContinuationAssembler headers = new ContinuationAssembler(); - private final ByteWriter outgoing = new ByteWriter(16 * 1024); - private final FrameWriteBuffer frames = new FrameWriteBuffer(outgoing); - private final BufferIntent intent = new BufferIntent(); - private int nextStreamId = 1; - private int connectionSendWindow = Http2Settings.DEFAULT_INITIAL_WINDOW_SIZE; - private int streamSendWindow; - private boolean headerEndStream; - private boolean closed; - - Connection(Origin origin, SSLContext sslContext) throws IOException { - socket = connect(origin, sslContext); - output = socket.getOutputStream(); - reader = - new Http2FrameReader(new BufferedByteSource(socket.getInputStream(), socket)); - writer = new Http2FrameWriter(output::write); - writePreface(); - awaitServerSettings(); - } - - synchronized Http2ClientResponse exchange( - URI uri, HttpMethod method, HeaderView requestHeaders, byte[] body, HeaderView trailers) - throws IOException { - if (closed) throw new IOException("HTTP/2 connection is closed"); - if (nextStreamId <= 0) throw new IOException("HTTP/2 stream id space exhausted"); - int streamId = nextStreamId; - nextStreamId += 2; - streamSendWindow = peerSettings.initialWindowSize(); - Exchange exchange = new Exchange(streamId); - - writeRequestHeaders(uri, method, requestHeaders, body.length == 0 && trailers.count() == 0, - streamId); - if (body.length != 0) writeRequestBody(exchange, body, trailers.count() == 0); - if (trailers.count() != 0) writeRequestTrailers(trailers, streamId); - while (!exchange.complete) readFrame(exchange); - return exchange.response(); - } - - private void writePreface() throws IOException { - output.write(Http2Preface.clientPreface()); - outgoing.reset(); - frames.beginFrame(FrameType.SETTINGS, 0, 0); - outgoing.writeUInt16(Http2Settings.ENABLE_PUSH); - outgoing.writeUInt32(0); - outgoing.writeUInt16(Http2Settings.INITIAL_WINDOW_SIZE); - outgoing.writeUInt32(Http2Limits.INITIAL_WINDOW_SIZE_LOCAL); - frames.endFrame(); - frames.beginFrame(FrameType.WINDOW_UPDATE, 0, 0); - outgoing.writeUInt31( - Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL - Http2Settings.DEFAULT_INITIAL_WINDOW_SIZE); - frames.endFrame(); - writeOutgoing(); - } - - private void awaitServerSettings() throws IOException { - boolean received = false; - while (!received) { - FrameHeader frame = reader.readFrame(); - if (frame == null) throw new IOException("server closed before SETTINGS"); - try { - if (frame.type() == FrameType.SETTINGS && !FrameFlags.isAck(frame.flags())) { - applySettings(frame); - sendEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0); - received = true; - } else if (frame.type() == FrameType.WINDOW_UPDATE) { - applyWindowUpdate(frame, 0); - } else if (frame.type() == FrameType.GOAWAY) { - throw new IOException("server sent GOAWAY during HTTP/2 setup"); - } - } finally { - reader.consumeFrame(); - } - } - } - - private void writeRequestHeaders( - URI uri, HttpMethod method, HeaderView source, boolean endStream, int streamId) - throws IOException { - outgoing.reset(); - frames.beginFrame( - FrameType.HEADERS, - FrameFlags.END_HEADERS | (endStream ? FrameFlags.END_STREAM : 0), - streamId); - writeMethod(method); - HpackEncoder.writeIndexed(outgoing, "https".equalsIgnoreCase(uri.getScheme()) ? 7 : 6); - writeAuthority(uri); - writePath(uri); - source.forEach( - (name, value) -> { - if (HopByHopHeaders.shouldForward( - source, name, value, Protocol.HTTP_1_1, Protocol.HTTP_2) - && !equalsAscii(name, "host")) { - HpackEncoder.writeLiteral(outgoing, name, value); - } - }); - frames.endFrame(); - writeOutgoing(); - } - - private void writeRequestBody(Exchange exchange, byte[] body, boolean endStream) - throws IOException { - int offset = 0; - while (offset < body.length) { - while (connectionSendWindow <= 0 || streamSendWindow <= 0) readFrame(exchange); - int count = - Math.min( - body.length - offset, - Math.min( - peerSettings.maxFrameSize(), - Math.min(connectionSendWindow, streamSendWindow))); - outgoing.reset(); - frames.beginFrame( - FrameType.DATA, - endStream && offset + count == body.length ? FrameFlags.END_STREAM : 0, - exchange.streamId); - outgoing.writeBytes(body, offset, count); - frames.endFrame(); - writeOutgoing(); - connectionSendWindow -= count; - streamSendWindow -= count; - offset += count; - } - } - - private void writeRequestTrailers(HeaderView trailers, int streamId) throws IOException { - outgoing.reset(); - frames.beginFrame( - FrameType.HEADERS, FrameFlags.END_HEADERS | FrameFlags.END_STREAM, streamId); - trailers.forEach( - (name, value) -> { - if (HopByHopHeaders.shouldForward( - trailers, name, value, Protocol.HTTP_1_1, Protocol.HTTP_2)) { - HpackEncoder.writeLiteral(outgoing, name, value); - } - }); - frames.endFrame(); - writeOutgoing(); - } - - private void readFrame(Exchange exchange) throws IOException { - FrameHeader frame = reader.readFrame(); - if (frame == null) throw new IOException("server closed an active HTTP/2 exchange"); - try { - FrameType type = frame.type(); - if (type == null) return; - switch (type) { - case SETTINGS -> { - if (!FrameFlags.isAck(frame.flags())) { - applySettings(frame); - sendEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0); - } - } - case WINDOW_UPDATE -> applyWindowUpdate(frame, exchange.streamId); - case PING -> { - if (!FrameFlags.isAck(frame.flags())) sendPingAck(frame); - } - case HEADERS, CONTINUATION -> receiveHeaders(frame, exchange); - case DATA -> receiveData(frame, exchange); - case RST_STREAM -> receiveReset(frame, exchange); - case GOAWAY -> throw receiveGoAway(frame); - case PUSH_PROMISE -> throw new IOException("server sent PUSH_PROMISE after ENABLE_PUSH=0"); - default -> { - // PRIORITY and unknown extension semantics do not affect this single exchange. - } - } - } finally { - reader.consumeFrame(); - } - } - - private void receiveHeaders(FrameHeader frame, Exchange exchange) throws IOException { - if (frame.streamId() != exchange.streamId) { - throw new IOException("unexpected response stream " + frame.streamId()); - } - if (frame.type() == FrameType.HEADERS) { - if (headers.isActive()) throw new IOException("interleaved response header block"); - headerEndStream = FrameFlags.isEndStream(frame.flags()); - long unpadded = - Padding.unpad( - frame.buffer(), - frame.payloadOffset(), - frame.length(), - FrameFlags.isPadded(frame.flags())); - int offset = Pairs.hi(unpadded); - int length = Pairs.lo(unpadded); - if (FrameFlags.hasPriority(frame.flags())) { - if (length < 5) throw new IOException("truncated response priority fields"); - offset += 5; - length -= 5; - } - headers.begin( - frame.streamId(), - frame.buffer(), - offset, - length, - FrameFlags.isEndHeaders(frame.flags())); - } else { - headers.continuation( - frame.streamId(), - frame.buffer(), - frame.payloadOffset(), - frame.length(), - FrameFlags.isEndHeaders(frame.flags())); - } - if (!headers.isComplete()) return; - - boolean trailers = exchange.statusCode != 0; - ResponseHeaderSink sink = new ResponseHeaderSink(exchange, trailers); - decoder.decode(headers.buffer(), 0, headers.length(), sink); - headers.reset(); - sink.validate(); - if (!trailers && exchange.statusCode >= 100 && exchange.statusCode < 200) { - if (headerEndStream) throw new IOException("informational response ended the stream"); - exchange.statusCode = 0; - exchange.headers.reset(); - return; - } - if (trailers && !headerEndStream) { - throw new IOException("response trailers did not end the stream"); - } - if (headerEndStream) exchange.complete = true; - } - - private void receiveData(FrameHeader frame, Exchange exchange) throws IOException { - if (frame.streamId() != exchange.streamId || exchange.statusCode == 0) { - throw new IOException("DATA received before response headers"); - } - long unpadded = - Padding.unpad( - frame.buffer(), - frame.payloadOffset(), - frame.length(), - FrameFlags.isPadded(frame.flags())); - int dataOffset = Pairs.hi(unpadded); - int dataLength = Pairs.lo(unpadded); - if (exchange.body.size() > MAX_RESPONSE_BODY_SIZE - dataLength) { - throw new IOException("proxied HTTP/2 response body exceeds limit"); - } - exchange.body.write(frame.buffer(), dataOffset, dataLength); - if (frame.length() != 0) { - sendWindowUpdate(0, frame.length()); - sendWindowUpdate(exchange.streamId, frame.length()); - } - if (FrameFlags.isEndStream(frame.flags())) exchange.complete = true; - } - - private void receiveReset(FrameHeader frame, Exchange exchange) throws IOException { - if (frame.streamId() != exchange.streamId || frame.length() != 4) return; - int code = readInt(frame.buffer(), frame.payloadOffset()); - throw new IOException("upstream reset HTTP/2 stream with error " + code); - } - - private IOException receiveGoAway(FrameHeader frame) { - closed = true; - int code = frame.length() >= 8 ? readInt(frame.buffer(), frame.payloadOffset() + 4) : -1; - return new IOException("upstream sent GOAWAY with error " + code); - } - - private void applySettings(FrameHeader frame) { - int oldWindow = peerSettings.initialWindowSize(); - peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), delta -> {}); - streamSendWindow += peerSettings.initialWindowSize() - oldWindow; - } - - private void applyWindowUpdate(FrameHeader frame, int activeStreamId) throws IOException { - if (frame.length() != 4) throw new IOException("invalid WINDOW_UPDATE length"); - int increment = readInt(frame.buffer(), frame.payloadOffset()) & 0x7fff_ffff; - if (increment == 0) throw new IOException("zero WINDOW_UPDATE increment"); - if (frame.streamId() == 0) connectionSendWindow = addWindow(connectionSendWindow, increment); - else if (frame.streamId() == activeStreamId) streamSendWindow = addWindow(streamSendWindow, increment); - } - - private void sendPingAck(FrameHeader frame) throws IOException { - outgoing.reset(); - frames.beginFrame(FrameType.PING, FrameFlags.ACK, 0); - outgoing.writeBytes(frame.buffer(), frame.payloadOffset(), frame.length()); - frames.endFrame(); - writeOutgoing(); - } - - private void sendWindowUpdate(int streamId, int increment) throws IOException { - outgoing.reset(); - frames.beginFrame(FrameType.WINDOW_UPDATE, 0, streamId); - outgoing.writeUInt31(increment); - frames.endFrame(); - writeOutgoing(); - } - - private void sendEmpty(FrameType type, int flags, int streamId) throws IOException { - outgoing.reset(); - frames.beginFrame(type, flags, streamId); - frames.endFrame(); - writeOutgoing(); - } - - private void writeOutgoing() throws IOException { - intent.reset(outgoing.array(), outgoing.length()); - writer.write(intent); - } - - private void writeMethod(HttpMethod method) { - if (method == HttpMethod.GET) HpackEncoder.writeIndexed(outgoing, 2); - else if (method == HttpMethod.POST) HpackEncoder.writeIndexed(outgoing, 3); - else { - byte[] value = method.name().getBytes(StandardCharsets.US_ASCII); - HpackEncoder.writeLiteralWithNameIndex(outgoing, 2, value, false); - } - } - - private void writeAuthority(URI uri) { - String authority = uri.getRawAuthority(); - if (authority == null || authority.isEmpty()) { - throw new IllegalArgumentException("HTTP/2 URI requires an authority"); - } - HpackEncoder.writeLiteralWithNameIndex( - outgoing, 1, authority.getBytes(StandardCharsets.US_ASCII), false); - } - - private void writePath(URI uri) { - String path = uri.getRawPath(); - if (path == null || path.isEmpty()) path = "/"; - if (uri.getRawQuery() != null) path += "?" + uri.getRawQuery(); - if ("/".equals(path)) HpackEncoder.writeIndexed(outgoing, 4); - else if ("/index.html".equals(path)) HpackEncoder.writeIndexed(outgoing, 5); - else { - HpackEncoder.writeLiteralWithNameIndex( - outgoing, 4, path.getBytes(StandardCharsets.US_ASCII), false); - } - } - - @Override - public synchronized void close() { - if (closed) return; - closed = true; - writer.close(); - try { - socket.close(); - } catch (IOException ignored) { - // Closing a broken pooled connection is best-effort. - } - } - - private static Socket connect(Origin origin, SSLContext sslContext) throws IOException { - if (!origin.secure) { - Socket socket = new Socket(); - socket.connect(new InetSocketAddress(origin.host, origin.port), CONNECT_TIMEOUT_MS); - configureLowLatency(socket); - return socket; - } - SSLContext context; - try { - context = sslContext == null ? SSLContext.getDefault() : sslContext; - } catch (Exception failure) { - throw new IOException("cannot initialize TLS context", failure); - } - SSLSocket socket = - (SSLSocket) context.getSocketFactory().createSocket(origin.host, origin.port); - configureLowLatency(socket); - SSLParameters parameters = socket.getSSLParameters(); - parameters.setApplicationProtocols(new String[] {"h2"}); - parameters.setEndpointIdentificationAlgorithm("HTTPS"); - socket.setSSLParameters(parameters); - socket.startHandshake(); - if (!"h2".equals(socket.getApplicationProtocol())) { - socket.close(); - throw new IOException("upstream did not negotiate HTTP/2 through ALPN"); - } - return socket; - } - } - - static void configureLowLatency(Socket socket) throws IOException { - socket.setTcpNoDelay(true); - } - - private static final class Exchange { - private final int streamId; - private final MutableHeaderMap headers = new MutableHeaderMap(); - private final MutableHeaderMap trailers = new MutableHeaderMap(); - private final ByteArrayOutputStream body = new ByteArrayOutputStream(); - private int statusCode; - private boolean complete; - - private Exchange(int streamId) { - this.streamId = streamId; - } - - private Http2ClientResponse response() { - return new Http2ClientResponse(statusCode, headers, body.toByteArray(), trailers); - } - } - - private static final class ResponseHeaderSink - implements dev.relism.flash.http2.hpack.HeaderSink { - private final Exchange exchange; - private final boolean trailers; - private boolean regular; - private boolean status; - - private ResponseHeaderSink(Exchange exchange, boolean trailers) { - this.exchange = exchange; - this.trailers = trailers; - } - - @Override - public void accept(ByteView name, ByteView value, boolean neverIndexed) { - if (name.length() != 0 && name.byteAt(0) == ':') { - if (trailers || regular || status || !equalsAscii(name, ":status")) { - throw Http2Exception.PROTOCOL_ERROR; - } - exchange.statusCode = parseStatus(value); - status = true; - return; - } - regular = true; - MutableHeaderMap target = trailers ? exchange.trailers : exchange.headers; - byte[] nameBytes = copy(name); - byte[] valueBytes = copy(value); - target.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length); - } - - private void validate() throws IOException { - if (!trailers && !status) throw new IOException("HTTP/2 response omitted :status"); - } - - private static int parseStatus(ByteView value) { - if (value.length() != 3) throw Http2Exception.PROTOCOL_ERROR; - int code = 0; - for (int i = 0; i < 3; i++) { - int digit = (value.byteAt(i) & 0xff) - '0'; - if (digit < 0 || digit > 9) throw Http2Exception.PROTOCOL_ERROR; - code = code * 10 + digit; - } - return code; - } - } - - private static final class BufferIntent implements WriteIntent { - private byte[] bytes; - private int length; - private WriteIntent next; - - private void reset(byte[] bytes, int length) { - this.bytes = bytes; - this.length = length; - this.next = null; - } - - @Override public byte[] buffer() { return bytes; } - @Override public int offset() { return 0; } - @Override public int length() { return length; } - @Override public WriteIntent mpscNext() { return next; } - @Override public void setMpscNext(WriteIntent next) { this.next = next; } - } - - private record Origin(String scheme, String host, int port, boolean secure) { - private static Origin from(URI uri) { - String scheme = uri.getScheme(); - boolean secure; - if ("https".equalsIgnoreCase(scheme)) secure = true; - else if ("http".equalsIgnoreCase(scheme)) secure = false; - else throw new IllegalArgumentException("HTTP/2 URI scheme must be http or https"); - if (uri.getHost() == null) throw new IllegalArgumentException("HTTP/2 URI requires a host"); - int port = uri.getPort() >= 0 ? uri.getPort() : secure ? 443 : 80; - return new Origin(scheme.toLowerCase(), uri.getHost(), port, secure); - } - } - - private static final class OpenFailure extends RuntimeException { - private final IOException io; - - private OpenFailure(IOException io) { - super(io); - this.io = io; - } - } - - private static boolean equalsAscii(ByteView bytes, String value) { - if (bytes.length() != value.length()) return false; - for (int i = 0; i < bytes.length(); i++) { - int left = bytes.byteAt(i) & 0xff; - int right = value.charAt(i); - if (left >= 'A' && left <= 'Z') left += 'a' - 'A'; - if (right >= 'A' && right <= 'Z') right += 'a' - 'A'; - if (left != right) return false; - } - return true; - } - - private static byte[] copy(ByteView view) { - byte[] result = new byte[view.length()]; - for (int i = 0; i < result.length; i++) result[i] = view.byteAt(i); - return result; - } - - private static int addWindow(int current, int increment) throws IOException { - long next = (long) current + increment; - if (next > Integer.MAX_VALUE) throw new IOException("HTTP/2 flow-control window overflow"); - return (int) next; - } - - private static int readInt(byte[] bytes, int offset) { - return ((bytes[offset] & 0xff) << 24) - | ((bytes[offset + 1] & 0xff) << 16) - | ((bytes[offset + 2] & 0xff) << 8) - | (bytes[offset + 3] & 0xff); - } -} diff --git a/flash/src/main/java/dev/relism/flash/http2/client/Http2ClientResponse.java b/flash/src/main/java/dev/relism/flash/http2/client/Http2ClientResponse.java deleted file mode 100644 index 84cd553..0000000 --- a/flash/src/main/java/dev/relism/flash/http2/client/Http2ClientResponse.java +++ /dev/null @@ -1,7 +0,0 @@ -package dev.relism.flash.http2.client; - -import dev.relism.flash.models.HeaderView; - -/** Complete response returned by Flash's proxy-oriented HTTP/2 client. */ -public record Http2ClientResponse( - int statusCode, HeaderView headers, byte[] body, HeaderView trailers) {} diff --git a/flash/src/test/java/dev/relism/flash/http2/ProxyTrailerRelayTest.java b/flash/src/test/java/dev/relism/flash/http2/ProxyTrailerRelayTest.java deleted file mode 100644 index 432495c..0000000 --- a/flash/src/test/java/dev/relism/flash/http2/ProxyTrailerRelayTest.java +++ /dev/null @@ -1,131 +0,0 @@ -package dev.relism.flash.http2; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.relism.flash.extension.FlashApp; -import dev.relism.flash.extension.FlashConfiguration; -import dev.relism.flash.http.HttpMethod; -import dev.relism.flash.http.proxy.HttpProxy; -import dev.relism.flash.http2.client.Http2Client; -import dev.relism.flash.http2.client.Http2ClientResponse; -import dev.relism.flash.models.MutableHeaderMap; -import java.io.ByteArrayOutputStream; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; - -class ProxyTrailerRelayTest { - private FlashApp upstream; - private FlashApp proxy; - private Http2Client proxyUpstream; - - @AfterEach - void stop() { - if (proxyUpstream != null) proxyUpstream.close(); - if (proxy != null) proxy.stop().join(); - if (upstream != null) upstream.stop().join(); - } - - @Test - void requestAndResponseTrailersSurviveH2AndH1DownstreamProxyHops() throws Exception { - int upstreamPort = freePort(); - upstream = - FlashApp.create( - FlashConfiguration.builder() - .host("127.0.0.1") - .port(upstreamPort) - .http2CleartextEnabled(true) - .build()); - upstream.post( - "/relay", - (request, response) -> - response - .header("x-query", request.query("mode")) - .header("x-private-seen", String.valueOf(request.header("x-private") != null)) - .body(request.body().bytes()) - .trailer("x-relayed-trailer", request.trailers().first("x-request-trailer"))); - upstream.start(); - - int proxyPort = freePort(); - proxyUpstream = new Http2Client(); - proxy = - FlashApp.create( - FlashConfiguration.builder() - .host("127.0.0.1") - .port(proxyPort) - .http2CleartextEnabled(true) - .build()); - proxy.post( - "/relay", - HttpProxy.toHttp2(URI.create("http://127.0.0.1:" + upstreamPort), proxyUpstream)); - proxy.start(); - - MutableHeaderMap h2Headers = fields("connection", "x-private"); - add(h2Headers, "x-private", "must-not-cross"); - MutableHeaderMap h2Trailers = fields("x-request-trailer", "from-h2"); - try (Http2Client downstream = new Http2Client()) { - Http2ClientResponse response = - downstream.exchange( - URI.create("http://127.0.0.1:" + proxyPort + "/relay?mode=h2"), - HttpMethod.POST, - h2Headers, - "hello-h2".getBytes(StandardCharsets.UTF_8), - h2Trailers); - assertEquals("hello-h2", new String(response.body(), StandardCharsets.UTF_8)); - assertEquals("h2", response.headers().first("x-query")); - assertEquals("false", response.headers().first("x-private-seen")); - assertEquals("from-h2", response.trailers().first("x-relayed-trailer")); - } - - String h1 = h1Exchange(proxyPort); - assertTrue(h1.contains("hello-h1"), h1); - assertTrue(h1.toLowerCase().contains("x-query: h1"), h1); - assertTrue(h1.toLowerCase().contains("x-private-seen: false"), h1); - assertTrue(h1.toLowerCase().contains("x-relayed-trailer: from-h1"), h1); - assertFalse(h1.contains("must-not-cross"), h1); - } - - private static String h1Exchange(int port) throws Exception { - try (Socket socket = new Socket("127.0.0.1", port)) { - socket.setSoTimeout(2_000); - socket - .getOutputStream() - .write( - ("POST /relay?mode=h1 HTTP/1.1\r\n" - + "Host: 127.0.0.1\r\n" - + "Connection: x-private, close\r\n" - + "X-Private: must-not-cross\r\n" - + "Transfer-Encoding: chunked\r\n" - + "Trailer: x-request-trailer\r\n\r\n" - + "8\r\nhello-h1\r\n" - + "0\r\nX-Request-Trailer: from-h1\r\n\r\n") - .getBytes(StandardCharsets.US_ASCII)); - ByteArrayOutputStream bytes = new ByteArrayOutputStream(); - socket.getInputStream().transferTo(bytes); - return bytes.toString(StandardCharsets.UTF_8); - } - } - - private static MutableHeaderMap fields(String name, String value) { - MutableHeaderMap headers = new MutableHeaderMap(); - add(headers, name, value); - return headers; - } - - private static void add(MutableHeaderMap headers, String name, String value) { - byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII); - byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8); - headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length); - } - - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } -} diff --git a/flash/src/test/java/dev/relism/flash/http2/client/Http2ClientTest.java b/flash/src/test/java/dev/relism/flash/http2/client/Http2ClientTest.java deleted file mode 100644 index 06a180f..0000000 --- a/flash/src/test/java/dev/relism/flash/http2/client/Http2ClientTest.java +++ /dev/null @@ -1,129 +0,0 @@ -package dev.relism.flash.http2.client; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.relism.flash.extension.FlashApp; -import dev.relism.flash.extension.FlashConfiguration; -import dev.relism.flash.http.HttpMethod; -import dev.relism.flash.models.MutableHeaderMap; -import dev.relism.flash.tls.TestKeystores; -import dev.relism.flash.tls.TlsConfig; -import java.net.ServerSocket; -import java.net.Socket; -import java.net.URI; -import java.nio.charset.StandardCharsets; -import java.nio.file.Path; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -class Http2ClientTest { - private FlashApp app; - - @AfterEach - void stop() { - if (app != null) app.stop().join(); - } - - @Test - void reusesOriginConnectionAndExchangesFlowControlledBodiesAndTrailers() throws Exception { - int port = freePort(); - app = - FlashApp.create( - FlashConfiguration.builder() - .host("127.0.0.1") - .port(port) - .http2CleartextEnabled(true) - .build()); - app.post( - "/relay", - (request, response) -> { - byte[] body = request.body().bytes(); - String checksum = request.trailers().first("x-request-checksum"); - return response - .header("x-upstream", request.header("x-forwarded-test")) - .body(body) - .trailer("x-response-checksum", checksum); - }); - app.start(); - - byte[] body = new byte[2 * 1024 * 1024 + 31]; - for (int i = 0; i < body.length; i++) body[i] = (byte) (i * 29); - MutableHeaderMap requestHeaders = fields("x-forwarded-test", "yes"); - MutableHeaderMap requestTrailers = fields("x-request-checksum", "valid"); - - try (Http2Client client = new Http2Client()) { - URI uri = URI.create("http://127.0.0.1:" + port + "/relay"); - Http2ClientResponse first = - client.exchange(uri, HttpMethod.POST, requestHeaders, body, requestTrailers); - Http2ClientResponse second = - client.exchange( - uri, - HttpMethod.POST, - requestHeaders, - "again".getBytes(StandardCharsets.UTF_8), - requestTrailers); - - assertEquals(200, first.statusCode()); - assertEquals("yes", first.headers().first("x-upstream")); - assertArrayEquals(body, first.body()); - assertEquals("valid", first.trailers().first("x-response-checksum")); - assertArrayEquals("again".getBytes(StandardCharsets.UTF_8), second.body()); - assertEquals(1, client.pooledConnectionCount()); - } - } - - @Test - void negotiatesTlsAlpnAndVerifiesTheUpstreamHostname(@TempDir Path directory) throws Exception { - int port = freePort(); - Path keystore = - TestKeystores.build( - directory, - "http2-client.p12", - "changeit", - TestKeystores.Entry.of("server", "localhost", "localhost")); - app = - FlashApp.create( - FlashConfiguration.builder() - .host("127.0.0.1") - .port(port) - .tls(TlsConfig.keystore(keystore, "changeit")) - .http2Enabled(true) - .build()); - app.get("/secure", (request, response) -> "tls-h2"); - app.start(); - - try (Http2Client client = new Http2Client(TestKeystores.trustAllClientContext())) { - Http2ClientResponse response = - client.get(URI.create("https://localhost:" + port + "/secure")); - assertEquals(200, response.statusCode()); - assertEquals("tls-h2", new String(response.body(), StandardCharsets.UTF_8)); - } - } - - @Test - void configuresConnectionsForRequestResponseLatency() throws Exception { - try (Socket socket = new Socket()) { - assertFalse(socket.getTcpNoDelay()); - Http2Client.configureLowLatency(socket); - assertTrue(socket.getTcpNoDelay()); - } - } - - private static MutableHeaderMap fields(String name, String value) { - MutableHeaderMap headers = new MutableHeaderMap(); - byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII); - byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8); - headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length); - return headers; - } - - private static int freePort() throws Exception { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } -}