Builds dev.relism.flash.bytes: ByteScan (scanning/comparison/hashing, scalar + SWAR, property-tested against each other on every boundary and 20,000 random fuzz trials each), ArrayBackedByteView/SegmentedByteView capability hierarchy, PooledSlice/SlicePool, ByteWriter, Pairs. Cashes in the allocation and scanning wins the existing code left on the table: EX-04 (word-at-a-time router matching, verified directly against fpr-core's own ByteCompare), EX-05 (pooled views replacing per-call anonymous ByteView allocations in HeaderMap/QueryParams/PathParams), EX-09 (HeaderMap index built once per reset() instead of rescanning per lookup), EX-19 (reusable PathParams on the router's per-connection scratch), EX-25/EX-26 (single-allocation String construction), EX-33 (SWAR header-terminator scan in RequestParser). Also closes EX-06's router half, missing from this phase's own EX-item list in the plan (same class of omission DEC-12 recorded for Phase 1): FastPathRouterImpl/FastPathWsRouterImpl's ThreadLocals (unbounded under one-virtual-thread-per-connection) are replaced by an opaque, caller-owned per-connection scratch object (AbstractRouter#newScratch), not by extending ConnectionScratch as its own Javadoc originally assumed -- that would have created transport's first dependency on routing in the reverse direction. Full rationale in DEC-19. Every optimization is measured, not asserted (DEC-20): SWAR scan 35.4% faster than scalar, kept; EX-04's word-path 32.1% faster than byte-at-a-time at the mechanism level, kept for its real future consumers even though today's router doesn't yet route through it (MethodPathByteView stays deliberately non-array-backed, per the plan's own text). Router matching itself is ~0 B/op including parametric routes. The full h1 pipeline is not literally 0 B/op yet -- 120 B/op is Request/RequestBody/RequestLine construction, honestly attributed to Phase 6's explicit scope rather than hidden. 395/395 tests green, both with and without -Pjmh. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
3112 lines
187 KiB
Markdown
3112 lines
187 KiB
Markdown
# Flash — HTTP/2 Implementation Plan
|
||
|
||
> **Status**: design document, not yet implemented.
|
||
> **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.h2`
|
||
> **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`, `DECISIONS.md` (`DEC-01`…`DEC-11`), `package-info.java`. 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 | not started | — | — |
|
||
| 6 — Request/Response model refactor | not started | — | — |
|
||
| 7 — HPACK decoder | not started | — | — |
|
||
| 8 — Connection state machine | not started | — | — |
|
||
| 9 — HPACK encoder + h2 response path | not started | — | — |
|
||
| 10 — Stream state machine + dispatch | not started | — | — |
|
||
| 11 — DATA, flow control, bodies | not started | — | — |
|
||
| 12 — Trailers, half-close, gRPC | not started | — | — |
|
||
| 13 — Security hardening & abuse resistance | not started | — | — |
|
||
| 14 — h2c prior knowledge + proxy support | not started | — | — |
|
||
| 15 — RFC 8441 extended CONNECT (WS over h2) | not started | — | — |
|
||
| 16 — Compliance test suite | not started | — | — |
|
||
| 17 — Benchmarks, allocation gates, tuning | not started | — | — |
|
||
| 18 — Documentation | not started | — | — |
|
||
|
||
---
|
||
|
||
# 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-<phase-slug>`.
|
||
- 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 `<version>` 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<MessageDigest> 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<byte[]> LONG_BUF` (20 B) and `ThreadLocal<byte[]> 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<byte[]> 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.
|
||
|
||
---
|
||
|
||
# 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/h2/package-info.java
|
||
flash/src/main/java/dev/relism/flash/h2/Http2Limits.java
|
||
flash/src/main/java/dev/relism/flash/h2/Http2ErrorCode.java
|
||
flash/src/main/java/dev/relism/flash/h2/Http2Exception.java
|
||
flash/src/main/java/dev/relism/flash/h2/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.h2
|
||
├── package-info.java module-level Javadoc: the whole architecture in one page
|
||
├── 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 `dev/relism/flash/h2/package-info.java` containing the one-page architecture
|
||
description: the demux loop, the virtual-thread-per-stream model, the writer discipline, the
|
||
arena strategy, and the explicit list of what Flash does not implement (server push,
|
||
priority scheduling) with the RFC citation permitting it.
|
||
3. 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. `package-info.java` written.
|
||
|
||
### 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<String>` 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.h2` 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/h2/frame/Http2FrameWriter.java`
|
||
- `flash/src/main/java/dev/relism/flash/h2/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/h2/frame/IntrusiveMpscQueue.java` — the Vyukov queue,
|
||
operating on a `Node` interface that `Http2Stream` implements.
|
||
- `flash/src/test/java/dev/relism/flash/h2/frame/Http2FrameWriterTest.java`
|
||
- `flash/src/test/java/dev/relism/flash/h2/frame/Http2FrameWriterStressTest.java`
|
||
- `flash/src/jmh/java/dev/relism/flash/h2/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).
|
||
|
||
### Safety checks
|
||
- [ ] Declared length checked against `SETTINGS_MAX_FRAME_SIZE` **before** any buffer growth
|
||
- [ ] Buffer growth bounded and monotonic (never shrink mid-connection; shrink only on release
|
||
to the pool if the high-water mark was pathological)
|
||
- [ ] Per-type length/stream-id/flag validation table complete for all 10 types
|
||
- [ ] Unknown types ignored; unknown types inside a header block rejected
|
||
- [ ] Reserved bit masked, not rejected
|
||
- [ ] Padding length validated against frame length
|
||
- [ ] Frame read is timeout-bounded (reuse `bodyReadTimeoutMs` semantics or add
|
||
`Http2Limits.FRAME_READ_TIMEOUT_MS`)
|
||
|
||
### 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.
|
||
- `FrameValidatorTest` — one test per RFC-mandated rejection, asserting the **specific** error
|
||
code, not merely that an error occurred.
|
||
- `Http2FrameReaderFuzzTest` — random bytes into the reader; assert only `Http2Exception` or
|
||
`Http2StreamException` escapes (never `ArrayIndexOutOfBoundsException`, `NegativeArraySizeException`,
|
||
`OutOfMemoryError`, or an infinite loop — enforce with a per-case timeout).
|
||
- `PaddingTest`.
|
||
|
||
### Docs
|
||
`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
|
||
- [ ] All 10 frame types read, validated, and written.
|
||
- [ ] Fuzz test green for 10 million random inputs.
|
||
- [ ] `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`.
|
||
|
||
### 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<byte[]> 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
|
||
- [ ] Recycled `Request`/`Response`/`RequestBody` fully cleared; no cross-request data leak
|
||
(explicit security test: connection A's `Authorization` header must never be visible on
|
||
connection B through a recycled object)
|
||
- [ ] Dev-mode use-after-recycle detection works and has a test
|
||
- [ ] Response header region bounded (`Http1Limits.MAX_RESPONSE_HEADER_BYTES`) — a handler in a
|
||
loop calling `header(...)` must not grow the scratch without limit
|
||
- [ ] `Multipart` limits enforced
|
||
|
||
### Tests
|
||
- Every existing test in `models/`, `routing/`, `template/`, `api/multipart/` passes.
|
||
- `RequestPoolingTest`, `ResponsePoolingTest` — including the cross-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).
|
||
- `MultipartSecurityTest` — the limits from task 9.
|
||
|
||
### 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
|
||
- [ ] h1 full cycle is 0 B/op.
|
||
- [ ] Public API unchanged for every example in `README.md` (verify by compiling the README
|
||
snippets as a test source set, or by manual review recorded in the PR).
|
||
- [ ] `Multipart` audited, findings registered as `EX-nn`, fixes shipped.
|
||
|
||
---
|
||
|
||
## 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:
|
||
- `h2/hpack/HpackIntegers.java` — prefix-coded integer decode/encode.
|
||
- `h2/hpack/Huffman.java` — decode FSM + encode LUT, both built in a static initializer from
|
||
the RFC's code table.
|
||
- `h2/hpack/HpackStaticTable.java` — the 61 entries as `byte[][]`, plus a name→lowest-index
|
||
lookup for the encoder (built at class init).
|
||
- `h2/hpack/HpackDynamicTable.java` — ring buffer of entry descriptors + a byte arena.
|
||
- `h2/hpack/HpackDecoder.java` — the state machine.
|
||
- `h2/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.
|
||
|
||
### 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
|
||
- [ ] Prefix-integer overflow rejected (continuation octet limit)
|
||
- [ ] Huffman padding validated (all ones, < 8 bits)
|
||
- [ ] Huffman EOS in input rejected
|
||
- [ ] Decoded string length bounded during decode, not after
|
||
- [ ] Index 0 rejected; out-of-range index rejected
|
||
- [ ] Dynamic Table Size Update position and magnitude validated
|
||
- [ ] `MAX_HEADER_LIST_SIZE` enforced, **with full decode before rejection** so the table stays
|
||
in sync
|
||
- [ ] CONTINUATION frame count and total block size bounded
|
||
- [ ] 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
|
||
- [ ] Every RFC 7541 Appendix C vector passes, including dynamic table state assertions.
|
||
- [ ] Fuzz test green for 10 million inputs.
|
||
- [ ] `HpackEvictionRaceTest` demonstrates the hazard and the fix.
|
||
- [ ] 0 B/op decode.
|
||
|
||
---
|
||
|
||
## 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:
|
||
- `h2/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.
|
||
- `h2/Http2Settings.java` — local and remote settings with per-parameter validation.
|
||
- `h2/Http2ConnectionScratch.java` — extends/holds the shared `ConnectionScratch` plus the h2
|
||
buffers: read buffer, HPACK assembly buffer, HPACK decode scratch, write scratch, the dynamic
|
||
table arena, the stream-arena pool, the body-buffer free list.
|
||
- `h2/Http2Preface.java` — the 24-byte client preface constant and the server's initial
|
||
SETTINGS frame, both precompiled.
|
||
|
||
Modified:
|
||
- `transport/ProtocolNegotiator.java` — `H2` now dispatches to `Http2Connection`.
|
||
- `transport/ServerLifecycle.java` — graceful shutdown sends GOAWAY to h2 connections
|
||
(`EX-32`).
|
||
- `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
|
||
- [ ] Preface verified byte-exact
|
||
- [ ] First frame from peer must be SETTINGS
|
||
- [ ] Every SETTINGS parameter validated per the table above
|
||
- [ ] Unknown SETTINGS identifiers ignored
|
||
- [ ] SETTINGS ACK with non-zero length rejected
|
||
- [ ] SETTINGS ACK timeout enforced
|
||
- [ ] `INITIAL_WINDOW_SIZE` delta applied to all open streams, negative windows permitted,
|
||
overflow rejected
|
||
- [ ] PING length and stream id validated; PING response queue bounded
|
||
- [ ] WINDOW_UPDATE zero-increment and overflow rejected
|
||
- [ ] GOAWAY two-stage graceful shutdown implemented
|
||
- [ ] 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
|
||
- [ ] `curl --http2 https://localhost:port/` completes the handshake and receives a clean
|
||
GOAWAY (no stream handling yet).
|
||
- [ ] The listed `h2spec` sections are green.
|
||
- [ ] 0 B/op for the connection lifecycle.
|
||
|
||
---
|
||
|
||
## 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:
|
||
- `h2/hpack/HpackEncoder.java`
|
||
- `h2/hpack/PreEncodedHeader.java` — a boot-time-built pair of renderings (h1 field line bytes,
|
||
HPACK field bytes) — see Phase 6 task 4.
|
||
- `h2/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
|
||
- [ ] Field names lowercase (dev-mode assertion)
|
||
- [ ] Connection-specific headers stripped
|
||
- [ ] Encoded block split correctly at `MAX_FRAME_SIZE`, with CONTINUATION frames not
|
||
interleaved with anything
|
||
- [ ] 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)
|
||
- [ ] `content-length`, when emitted, matches the actual DATA byte count (assert in dev mode;
|
||
a mismatch is a gRPC-breaking bug that is otherwise invisible)
|
||
|
||
### 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
|
||
- [ ] `:status 200` encodes to exactly one byte.
|
||
- [ ] Round-trip tests green.
|
||
- [ ] Parity test green.
|
||
- [ ] 0 B/op.
|
||
|
||
---
|
||
|
||
## 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:
|
||
- `h2/stream/Http2Stream.java` — per-stream state. Also the intrusive MPSC node (Phase 3) and
|
||
the owner of the per-stream arena (Phase 7).
|
||
- `h2/stream/Http2StreamState.java` — the state machine as an explicit transition table, not a
|
||
pile of `if`s. Every transition cites its RFC clause.
|
||
- `h2/stream/Http2StreamTable.java` — `int → Http2Stream`, open-addressed with linear probing,
|
||
power-of-two capacity, zero-alloc lookup/insert/remove, sized from `MAX_CONCURRENT_STREAMS`.
|
||
- `h2/message/Http2HeaderMap.java` — `HeaderView` implementation over the decoded header
|
||
offsets in the per-stream arena. Same indexed lookup as Phase 4's `Http1HeaderMap`.
|
||
- `h2/message/PseudoHeaders.java` — validation and extraction.
|
||
- `h2/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
|
||
- [ ] Stream id parity, monotonicity, and zero-id validated
|
||
- [ ] Closed-stream frame handling per §5.1, including the race grace period
|
||
- [ ] `MAX_CONCURRENT_STREAMS` enforced; exceeding it → RST_STREAM `REFUSED_STREAM`
|
||
(not `PROTOCOL_ERROR`; `REFUSED_STREAM` tells the client it may retry)
|
||
- [ ] Every malformed-request rule from task 4
|
||
- [ ] Stream table cannot grow past `MAX_CONCURRENT_STREAMS` + a small grace
|
||
- [ ] 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
|
||
- [ ] `curl --http2 https://localhost:port/ping` returns `pong`.
|
||
- [ ] A handler written for h1 works unmodified over h2 — proven by running a subset of the
|
||
existing `HttpServerTest` suite against an h2 client.
|
||
- [ ] `FastPathRouterImpl` unchanged.
|
||
- [ ] 0 B/op for the h2 GET path.
|
||
- [ ] `h2spec` sections 5 and 8 green.
|
||
|
||
---
|
||
|
||
## 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:
|
||
- `h2/stream/Http2FlowController.java` — connection and stream windows, both directions.
|
||
- `h2/message/Http2RequestBody.java` — DATA frames → the `RequestBody` contract.
|
||
- `h2/message/DataBufferPool.java` — the fixed-size buffer free list.
|
||
|
||
Modified:
|
||
- `h2/Http2Connection.java` — DATA dispatch.
|
||
- `h2/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
|
||
- [ ] Connection-level **and** stream-level WINDOW_UPDATE both sent
|
||
- [ ] Window overflow (> 2^31-1) rejected
|
||
- [ ] Window underflow (peer exceeds its window) rejected with the correct scope
|
||
- [ ] Padding counted toward flow control
|
||
- [ ] Flow control accounted for RST streams until settled
|
||
- [ ] `content-length` verified against actual DATA
|
||
- [ ] Empty DATA frame flood bounded
|
||
- [ ] `DataBufferPool` bounded; exhaustion applies backpressure rather than allocating without
|
||
limit
|
||
- [ ] 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
|
||
- [ ] 100 MB upload and 100 MB download both correct, both bounded memory.
|
||
- [ ] `h2spec` DATA and WINDOW_UPDATE sections green.
|
||
- [ ] Small-body path allocates exactly one `byte[]` (the user's 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<ResponseStream> 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
|
||
- [ ] Trailers without `END_STREAM` rejected
|
||
- [ ] Pseudo-headers in trailers rejected
|
||
- [ ] Trailer count and size bounded (they go through the same HPACK limits)
|
||
- [ ] `ResponseStream.write` after `close` throws, does not corrupt the stream
|
||
- [ ] 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
|
||
- [ ] `grpcurl` completes a unary and a server-streaming call against a Flash handler.
|
||
- [ ] Trailers work on both protocols through one API.
|
||
- [ ] `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.
|
||
|
||
---
|
||
|
||
## 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
|
||
- [ ] Every attack in this phase has a test that proves the defence.
|
||
- [ ] Every limit is documented with its rationale.
|
||
- [ ] A `security-review` pass over the whole `h2` package is completed and its findings fixed.
|
||
|
||
---
|
||
|
||
## 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.h2.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
|
||
- [ ] gRPC over h2c works end to end.
|
||
- [ ] 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
|
||
- [ ] A browser negotiating h2 can open a WebSocket to a Flash `ws()` route.
|
||
- [ ] `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/h2/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
|
||
- [ ] `h2spec` 100 % pass, both modes, zero skips, in CI.
|
||
- [ ] Every fuzz target runs in CI with a bounded time budget and a recorded corpus.
|
||
- [ ] The interop matrix is filled in with actual versions and dates.
|
||
|
||
---
|
||
|
||
## 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
|
||
- [ ] Allocation gates green in CI and wired to fail the build.
|
||
- [ ] Latency baselines recorded.
|
||
- [ ] h1 performance is not worse than the pre-Phase-1 baseline.
|
||
- [ ] No carrier pinning anywhere.
|
||
- [ ] `flash/docs/http2/PERFORMANCE.md` complete with the comparison against a reference server.
|
||
|
||
---
|
||
|
||
## 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.h2` and the new `transport`/`http1`/`bytes` packages.
|
||
- `package-info.java` for each new package.
|
||
- 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
|
||
- [ ] Every document listed above exists and is accurate.
|
||
- [ ] `mvn javadoc:javadoc` produces no warnings.
|
||
- [ ] A reader who knows HTTP/1.1 and nothing about HTTP/2 can read `flash/docs/http2/README.md` and
|
||
understand the architecture. (Verify by having someone who did not implement it read it.)
|
||
|
||
---
|
||
|
||
# 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/h2/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.h2`, 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.
|
||
|