Files
Flash5/flash/docs/http2/IMPLEMENTATION-PLAN.md
T
Zakaria El OrcheandClaude Sonnet 5 a315e1df8b feat(core): HTTP/2 Phase 2 — transport decomposition
Breaks HttpServer (563 lines, eleven responsibilities) into named,
single-purpose components and introduces the ConnectionProtocol seam
HTTP/2 plugs into starting Phase 8, per
flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 2.

New packages:
- dev.relism.flash.transport: TransportFactory (composition root, EX-34),
  ListenerBinder, BoundListener, TransportTuning, AcceptLoop,
  ConnectionRunner (per-connection setup/teardown), ConnectionProtocol
  (the h1/h2 seam), ConnectionContext, ConnectionScratch + ScratchPool
  (EX-06), ServerLifecycle (implements ServerHandle; start/stop/graceful
  shutdown, EX-32).
- dev.relism.flash.http1: Http1Connection (the keep-alive request loop,
  implements ConnectionProtocol), Http1ResponseWriter, Http1KeepAlive
  (the shared Connection-header token-list scanner, EX-13).
- dev.relism.flash.websocket additions: WebSocketUpgrade (detection +
  handshake), WebSocketLoop (session loop), WebSocketProtocolException.

Existing-code defects fixed (EX-nn):
- EX-01: WebSocketSession's two blocking-write sites use ReentrantLock
  instead of synchronized (out) -- a virtual thread blocking inside
  synchronized pins its carrier platform thread on Java 21.
- EX-06: HttpServer's three ThreadLocals (SHA1, LONG_BUF,
  STREAM_RELAY_BUFFER) replaced by ConnectionScratch, pooled via
  ScratchPool instead of one-per-virtual-thread (i.e. one-per-connection)
  growth. The router's ThreadLocals are deliberately deferred to Phase 4
  per this EX item's own phasing -- see DEC-15 for the plan-wording fix.
- EX-11: WebSocketSession.readFrame's extended-length and mask-key bytes
  are now read in a single bounded readFully instead of one at a time.
- EX-12: full RFC 6455 frame validation -- continuation-frame
  reassembly, mandatory masking-direction enforcement, opcode
  validation, control-frame constraints (not fragmented, <=125 bytes),
  and WebSocketProtocolException carrying the correct close code (1002
  protocol error, 1009 message too big).
- EX-13: Connection header token-list scanning shared between the
  keep-alive decision and the WebSocket upgrade check.
- EX-14: HEAD responses report Content-Length but write no body.
- EX-15: Content-Type omitted when empty; Content-Length and the body
  omitted entirely for 204/304/1xx responses.
- EX-16: Date header (dev.relism.flash.http.DateHeader), refreshed once
  per second by a shared daemon thread; FlashConfiguration.sendDate.
- EX-32: two-stage graceful shutdown -- stop accepting, force
  Connection: close on the response an in-flight handler is still
  producing (re-checked after the handler runs, not just before
  dispatch, so a shutdown beginning mid-handler is still honoured),
  drain up to shutdownDrainTimeoutMs, then force-close.
- EX-34: ServerHandle.create delegates to TransportFactory instead of
  constructing HttpServer directly.

Two plan corrections recorded: DEC-15 (Phase 2's "no ThreadLocal
anywhere" DoD line contradicted EX-06's own multi-phase assignment --
corrected to match the registry) and DEC-16 (no separate
WebSocketFrameCodec class this phase; the EX-11/EX-12 fixes stay inside
WebSocketSession, which is one cohesive state machine under R6's own
carve-out -- revisit at Phase 15 if RFC 8441 needs the decoupling for
real).

HttpServer.java deleted.

311/311 tests green (flash module), run three times for stability of
the wall-clock-based timeout/shutdown tests. Whole-repo build green.
h1 benchmark regression check remains unverified in the plan's DoD (no
JMH harness until Phase 3, same caveat as Phase 1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:03:44 +00:00

180 KiB
Raw Blame History

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-01DEC-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) not started
4 — Byte-layer foundations not started
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 ints 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 1531 is the reference example), and calls out the allocation model explicitly (HttpServer lines 4959). 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 610 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:327new 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 23 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:123socket.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 02 touch only existing code and ship value on their own even if h2 were abandoned. Phase 3 is the go/no-go gate. Phases 46 are shared foundations. Phases 715 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-01DEC-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

  • 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).
  • DECISIONS.md seeded with DEC-01DEC-08 (seeded with DEC-01DEC-11: the extra DEC-11 records the AGENTS.md commit-scope choice from task 7 below).
  • Http2Limits and Http1Limits complete, every field documented with its rationale.
  • 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.javaplan 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 chunked501.
  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\nH2 (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)

  • Content-Length strict-numeric, bounded, single-valued — RequestParserSecurityTest
  • Content-Length + Transfer-Encoding rejected, regardless of order — RequestParserSecurityTest
  • Non-chunked final transfer coding rejected — RequestParserSecurityTest
  • Bare CR / missing LF rejected — RequestParserSecurityTest
  • obs-fold (leading whitespace continuation line) rejected — RequestParserSecurityTest
  • Header name tchar validated — RequestParserSecurityTest
  • Header count / name length / value length / request-line length bounded — RequestParserSecurityTest
  • Chunk size, chunk count, chunk-extension length, trailer count bounded — ChunkedInputStreamTest
  • Header-read absolute deadline enforced (not just setSoTimeout) — HttpServerTimeoutTest.slowlorisHeaderDribble_disconnectedWithinHeaderReadTimeout
  • Idle keep-alive timeout enforced — HttpServerTimeoutTest.idleKeepAliveConnection_disconnectedWithinIdleTimeout
  • Body-read timeout enforced — HttpServerTimeoutTest.slowBodyDribble_disconnectedWithinBodyReadTimeout
  • 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

  • Every checklist item above is implemented and tested.
  • 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).
  • 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.javaHttpServer.bind (lines 193-208), extracted. Sole responsibility: turn a FlashConfiguration.Listener into a bound ServerSocket.
  • AcceptLoop.javaHttpServer.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:
    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.javathe 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.javastart/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.javawriteResponse, writeStreamingBody, relay, writeStatusPhrase, writeLong, writeHex, writeChunked (lines 469-563).
  • Http1ChunkedEncoder.java — split out of the above if it does not stay trivially small.
  • Http1KeepAlive.javaisKeepAlive (line 454), fixed per EX-13.

Created — dev.relism.flash.websocket:

  • WebSocketUpgrade.javaisWebSocketUpgrade, connectionContainsUpgrade, tokenEqualsIgnoreCase, performHandshake (lines 373-424).
  • WebSocketLoop.javarunWsLoop (lines 428-450).
  • WebSocketFrameCodec.java — frame header encode/decode extracted from WebSocketSession.

Modified:

  • HttpServer.javadeleted, 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.javaEX-01, EX-11, EX-12.
  • ServerHandle.javaEX-34.
  • FastPathRouterImpl.java — drop FastPathRouterContext's ThreadLocals 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

  • ScratchPool is bounded and cannot grow without limit — ScratchPoolTest.bound_isRespected_excessReleasesAreDropped
  • A scratch is always released, including on exception paths (try/finally in ConnectionRunner.handle) — ConnectionRunnerTest.scratchAndActiveSocketEntry_alwaysReleased_evenWhenTheProtocolThrows
  • A scratch returned to the pool is fully reset; no request data leaks between connections — ScratchPoolTest.reset_clearsTheMessageDigestState
  • WebSocket: unmasked client frame → close 1002 — WebSocketFragmentationAndValidationTest.serverSession_unmaskedIncomingFrame_rejected1002
  • WebSocket: message exceeding the bound → close 1009 — WebSocketFragmentationAndValidationTest.reassembledMessageExceedingBuffer_rejected1009
  • WebSocket: invalid opcode → close 1002 — WebSocketFragmentationAndValidationTest.reservedOpcode_rejected1002
  • WebSocket: fragmented control frame → close 1002 — WebSocketFragmentationAndValidationTest.fragmentedControlFrame_rejected1002

Tests

  • 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).
  • 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.
  • 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).
  • Http1ResponseWriterTest — HEAD, 204, 304, 1xx, ContentType.NONE, Date present/absent.
  • ServerLifecycleGracefulShutdownTest (named ServerLifecycleTest here) — graceful drain completes an in-flight request (forced to Connection: close); listener stops accepting immediately.
  • 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

  • HttpServer.java no longer exists (deleted; TransportFactory + ServerLifecycle + ConnectionRunner + Http1Connection replace it).
  • 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 ThreadLocals 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).
  • 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.
  • 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:
    /** 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

  • Write timeout bounded and enforced
  • Lost-wakeup protocol implemented and stress-tested
  • A frame's bytes are never interleaved with another frame's bytes
  • Queue depth bounded — a stream that cannot be drained must not let the queue grow without limit (bounded by MAX_CONCURRENT_STREAMS, since each stream is at most one node; assert this invariant)
  • Exception inside a WriteIntent.serialize must not leave the lock held or the queue corrupted

Gate criteria — the project continues only if all of these hold

  • N=1: 0 B/op, and per-frame overhead versus a raw unsynchronized write is within 50 ns.
  • N=64: throughput does not collapse (no worse than 60 % of the N=1 per-thread aggregate) and p999 latency stays under 1 ms for a 1 KB frame on loopback.
  • No carrier pinning observed under -Djdk.tracePinnedThreads=full.
  • The stress test is green at every N, 1000 iterations, including with parallelism=1.

If a criterion fails, do not proceed to Phase 4. Try design (c), or a hybrid where large payloads are written by the owning thread outside the lock via a reserved byte range. Record the failure and the retry in DECISIONS.md.

Docs

  • 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 (~80 bytes of header per response).

DoD

  • All gate criteria met and recorded.
  • DEC-09 written with raw numbers.
  • 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.

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:
    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.javapack(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.javaRequestByteView, 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 longs 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 Strings the handler explicitly asks for. Add this as a JMH allocation test now; it becomes a CI gate in Phase 17.

Safety checks

  • longAt bounds contract documented and asserted in debug builds (an assert, which is off in production, plus an explicit test)
  • Header index arrays bounded by MAX_HEADER_COUNT; overflow is impossible because Phase 1 already rejects over-limit requests — assert the invariant rather than silently truncating
  • SWAR scan never reads past the array bound (test with a target at the very last byte and with a buffer whose length is not a multiple of 8)
  • Pooled slice reuse cannot alias two live views the caller believes are independent — documented, and covered by a test that demonstrates the hazard so the contract is visible

Tests

  • ByteScanTest — property tests, SWAR vs scalar, every boundary.
  • ByteScanFuzzTest — random bytes, assert no exception and agreement with scalar.
  • FastPathViewsLongAtTestlongAt 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; and an allocation assertion.
  • PathParamsReuseTest, QueryParamsFastPathTest.
  • All existing models and routing tests pass unmodified.

Docs

  • flash/docs/http2/BYTES.md — the byte-layer primitives, the ByteView capability hierarchy (ByteViewArrayBackedByteView → concrete; SegmentedByteView as the deliberate non-array-backed case), the supportsLong contract, and the pooled-slice lifetime rules.
  • Update HeaderMap's class Javadoc (its lifetime contract section is the model the rest of the codebase follows; it must stay accurate).

DoD

  • h1 happy path is 0 B/op in JMH.
  • h1 throughput improved or unchanged; numbers recorded.
  • Every anonymous ByteView allocation in flash core is gone. (Grep new ByteView().)
  • 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.javaEND_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:
    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_ERRORcheck 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.javaEX-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) ByteViews 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_VALUEHttp2Exception(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.
  • HpackDecoderTestall 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.javaH2 now dispatches to Http2Connection.
  • transport/ServerLifecycle.java — graceful shutdown sends GOAWAY to h2 connections (EX-32).
  • tls/TlsConfig.java / FlashConfiguration.javah2 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 WriteIntents.

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 ifs. Every transition cites its RFC clause.
  • h2/stream/Http2StreamTable.javaint → Http2Stream, open-addressed with linear probing, power-of-two capacity, zero-alloc lookup/insert/remove, sized from MAX_CONCURRENT_STREAMS.
  • h2/message/Http2HeaderMap.javaHeaderView 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 trailersPROTOCOL_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 :methodHttpMethod (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 closedSTREAM_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:
    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.

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 16. 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 161:

 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.