feat(core): HTTP/2 Phase 6 — Request/Response model refactor

Pools Request/RequestBody/RequestLine/Response per connection (EX-20..EX-24),
following the same reset()/dev-mode-guard idiom Http1HeaderMap already used.
HeaderMap splits into HeaderView (interface) + Http1HeaderMap (impl, DEC-22).
Response gains byte-level structured headers, PreEncodedHeader, and
ResponseSerializer as the single source of truth for a response's header
sequence, consumed by Http1ResponseWriter's single-bulk-write rewrite (EX-27).
ByteTemplate gets O(1) slot lookup plus a buffer-writing overload (EX-28).
Multipart audited: three resource-exhaustion gaps found and fixed — unbounded
buffered part size, part count, and per-part header parsing (EX-38..EX-40) —
and boundary length confirmed already bounded (EX-41).

Re-measuring RequestPipelineBenchmark after the pooling work surfaced one more
per-request allocation underneath it (RequestParser building fresh
RequestByteViews every call) and, while checking the phase's own DoD text, an
unbounded Response.header(...) loop hazard neither had a limit — both fixed
(EX-42, EX-43). The h1 zero-alloc contract now holds: parseAndRoute measures
0.008 B/op (JMH noise floor), down from Phase 4's 120.008 B/op (DEC-20, DEC-23).

MESSAGE-MODEL.md records the pooling model; README gains an "Object lifetime"
section documenting the do-not-retain-past-the-handler contract. 503/503 tests
green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-13 15:26:08 +00:00
co-authored by Claude Sonnet 5
parent 0e1bbed42c
commit d882ea255c
51 changed files with 2395 additions and 372 deletions
+196
View File
@@ -0,0 +1,196 @@
# The Message Model (Phase 6)
Audience: contributors. This is the design record for `dev.relism.flash.models`'s request/response
object model after Phase 6's refactor — what is pooled, what that pooling actually means for code
that touches these objects, and the allocation fixes (`EX-20``EX-24`, `EX-27`, `EX-28`, `EX-29`,
`EX-42`) that got the h1 request/response cycle to the zero-alloc contract Phase 4 (`DEC-20`) left
open.
## Why this exists
Through Phase 5, `Request`, `RequestLine`, `RequestBody`, and `Response` were all allocated fresh
per request — `DEC-20` measured this at 120.008 B/op for parse+route alone, and traced 100% of it
to these four objects. Phase 6 pools all of them, following the same "one instance per connection,
repositioned via `reset()`, never reallocated" idiom `Http1HeaderMap` and `RequestLine` already
established in earlier phases. This document is the single place that idiom's contract — and the
hazards of misusing it — is written down for the whole model, instead of being re-derived from
each class's own Javadoc.
## What is pooled, and by whom
```
RequestParser (one per connection)
├── Http1HeaderMap headerMap — reset() per request
├── RequestLine requestLine — reset() per request
├── Request request — reset() per request (via Request.forParsed)
├── RequestBody requestBody — reset() per request
├── RequestByteView pathView — reset() per request (EX-42)
├── RequestByteView queryView — reset() per request, only when present (EX-42)
└── RequestByteView protocolView — reset() per request (EX-42)
Http1Connection (one per connection)
└── Response pooledResponse — reset() per request (unless a handler returns its own Response)
FastPathRouterImpl.RouteScratch (one per connection, via AbstractRouter#newScratch)
└── PathParams pathParams — reset() per matched request (see BYTES.md, EX-19)
```
Every one of these follows the same three rules:
1. **One instance per connection**, created once (in `RequestParser`'s or `Http1Connection`'s
constructor, or in `newScratch()`), never re-allocated for the connection's lifetime except a
backing array growing to a new high-water mark (e.g. `RequestParser.buffer` doubling, or
`RouteScratch.ensureParamCapacity`).
2. **`reset(...)` repositions, it does not allocate** — the method that transitions the instance
from "describes request N" to "describes request N+1".
3. **Do not retain past the handler.** A reference captured in a closure, a `CompletableFuture`
continuation, or a background thread and read after the handler returns will observe whatever
the *next* request repositioned the instance to — silently, unless the dev-mode guard below
catches it.
## The dev-mode use-after-recycle guard (`Request`, `Response`)
`Request` and `Response` — the two objects most likely to be captured by user code — additionally
track an `active` flag, set `true` by `reset()` and `false` by `recycle()` (called by
`Http1Connection` once the handler and `drain()` have finished). Every public accessor calls
`checkActive()` first:
```java
private void checkActive() {
if (poisoningEnabled && !active) {
throw new IllegalStateException("... do not retain a Request past the handler ...");
}
}
```
`poisoningEnabled` defaults to `Flash.DEV` (`-Dflash.env=dev`), so this is a zero-cost `static
final`-guarded branch in production and a loud, precise `IllegalStateException` — thrown at the
exact misusing call site — in development. Since `Flash.DEV` is itself `static final` (fixed at
JVM startup) and therefore not something a single test can toggle, both classes expose a
package-private `setPoisoningEnabledForTesting(boolean)` hook purely so
`RequestRecycleGuardTest`/`ResponseRecycleGuardTest` can exercise the dev-mode branch without a
fragile reflective override of a `static final` field — production code never touches it.
`RequestBody`, `RequestLine`, `Http1HeaderMap`, and `PathParams` do **not** carry this guard: they
are reached only through `Request`/`Response` (or, for `PathParams`, through `Request.param`),
so `Request`/`Response`'s own guard already catches a stale read before it would reach these.
## `RequestBody`: two read modes, one reused bounded stream
`RequestBody.stream()` and `.bytes()` are mutually exclusive per request (calling both is
undefined). `EX-23`/`EX-24` (Phase 6) replaced two allocation sources in the streaming path:
- `stream()` used to build a fresh `SequenceInputStream` + `ByteArrayInputStream` + anonymous
bounded `InputStream` on every call. It now repositions one persistent
`BoundedBufferedInputStream` (a private inner class) via `reset(preBuf, preBufOff, preBufLen,
socketRemaining)` — the same object is returned every time, just pointed at different bytes.
- `drain()`'s chunked-body path used to call `InputStream.transferTo`, whose default
implementation allocates a fresh 8 KiB `byte[]` on every call. It now drains through a lazily
created (only if a chunked body is ever actually drained), persistent `drainBuffer`.
`RequestBody.of(byte[])`/`.empty()` remain as freestanding, unpooled factories for test/manual
construction (mirroring `Request`'s own manual constructor) — production's only pooled instance is
the one `RequestParser` owns.
## `Response`: byte-level headers, one write, `ResponseSerializer` as the source of truth
Before Phase 6, `Response.header(String, String)` stored headers as `List<byte[]>` — one `String`
concatenation and one `byte[]` allocation per call. `Response` now stores structured headers in a
`ByteWriter`-backed name/value region plus parallel `int[]` quads (`nameOff, nameLen, valOff,
valLen`), written via `ByteWriter.writeAscii` — zero-allocation on a warm connection. A second,
separate store (`List<byte[]>`) still holds the legacy `header(byte[])` raw-line entries; a tagged
sequence (`headerTags`/`headerRefs`) interleaves the two stores back into declaration order when
serialized, so mixing `header(String,String)` and `header(byte[])` calls on the same response still
produces headers in the order they were added.
`PreEncodedHeader` precomputes a header's name+value ASCII bytes once (e.g. for a constant response
header set at boot) — deliberately does **not** yet expose HPACK-encoded bytes, since HPACK does
not exist until Phase 9; that scope boundary is recorded in the class's own Javadoc rather than
building untested, speculative API surface now.
`ResponseSerializer.forEachField(Response, FieldConsumer)` is the **one source of truth for what
headers a response has** — it enumerates `Content-Type` (if set) plus every structured custom
header, in order, and is the only place that knowledge lives. `Http1ResponseWriter` renders that
sequence as `Name: Value\r\n` lines; the Phase 9 h2 encoder will render the same sequence as HPACK.
Deliberately excluded: `Content-Length`/`Connection`/`Date` (connection framing, not response
object properties — and HTTP/2 has no `Connection` header at all, RFC 9113 §8.2.2) and raw
`header(byte[])` entries (no recoverable name/value structure to hand the h2 encoder).
`Http1ResponseWriter` (`EX-27`) serializes the entire response head — status line, `Content-Type`,
`Date`, every custom header, `Content-Length`/`Connection` — into
`ConnectionScratch.responseHead` (a reused `ByteWriter`) and issues **one** `OutputStream.write`
call for the head plus any body at or below `Http1Limits.INLINE_BODY_THRESHOLD` (8 KiB), instead
of roughly ten small writes. A larger body is written in a second `write` call right after — folding
it into the head buffer first would cost an extra full-body `memcpy` the syscall reduction does not
pay for. Streaming/chunked bodies write the head, then relay their own bytes as they arrive, by
definition too large or unbounded to fold into one buffer up front.
`Response.header(...)` (any overload) is bounded by `Http1Limits.MAX_RESPONSE_HEADER_BYTES`/
`MAX_RESPONSE_HEADER_COUNT` (`EX-43`) — unlike every other `Http1Limits` constant, this guards
against a bug in the *caller* (a handler looping over an unbounded collection while building
headers) rather than a hostile peer: since `Response` is now pooled per connection, an unbounded
`headerRegion` would otherwise grow for the rest of the connection's lifetime, never shrinking
back down between requests. Both checks throw `IllegalStateException`, not
`MalformedRequestException` — this is an application-code misuse, not a wire-input rejection.
## `HeaderView` / `Http1HeaderMap` (`DEC-22`)
`HeaderMap` split into `HeaderView` (the protocol-neutral read contract: `first`, `all`, `view`,
`valueEqualsIgnoreCase`, `contains`, `count`, `forEach`) and `Http1HeaderMap` (the existing
byte-buffer-backed implementation, kept in `dev.relism.flash.models` rather than moved to
`dev.relism.flash.http1` — see `DECISIONS.md`, `DEC-22`, for why: `RequestParser` (root package)
owns and constructs it, and `http1`→root already exists via `Http1Connection`, so moving it to
`http1` would create a `models``http1` package cycle). `RequestLine.headers` is typed as the
interface, so a future `Http2HeaderMap` (Phase 10, HPACK-backed) is a drop-in second
implementation, not a `Request`/`RequestLine` API change.
## `ByteTemplate` (`EX-28`)
Off the h1 request/response hot path (used only by `ErrorPages`, on 404/500), but in scope because
it was a clean instance of the "precompute at boot" category the phase's own text calls out.
`render(String...)` used a nested loop — for every key-value pair, scan every slot — to find
matching placeholders, and a repeated placeholder name (`{{var}} == {{var}}`) meant a naive
name→single-index map would be wrong. Fixed by mapping each slot name to the (usually
one-element) array of every slot index using that name, built once at construction. A new
`renderInto(byte[], int, String...)` overload writes into a caller-supplied buffer and returns the
length written, for future callers with a reusable scratch buffer available; `render(String...)`
keeps its allocating signature for compatibility.
## `Multipart` (`EX-29`, and `EX-38``EX-41`)
Audited per the plan's mandatory rules for any file over 300 lines. Findings and fixes: an eagerly
buffered part body (text fields, and — during a full `parts()`/`parts(String)` scan — file bodies
too) had no size bound (`EX-38`, fixed with a bounded read capped by
`Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE`); the part count was unbounded (`EX-39`, capped by
`Http1Limits.MAX_MULTIPART_PARTS`); per-part header parsing had neither a header-count nor a
line-length bound (`EX-40`, capped by `Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT`/
`MAX_MULTIPART_HEADER_LINE_LENGTH`); the multipart boundary's length was checked and found to
already be bounded transitively, via `Http1Limits.MAX_HEADER_VALUE_LENGTH` on the `Content-Type`
header it comes from (`EX-41`, a non-finding, recorded so "checked, found fine" isn't mistaken for
"wasn't checked"). None of these bounds apply to `Part.materialize()` on a streaming file part
returned by `Multipart.file()` — that call is documented as an explicit, opt-in heap allocation the
caller chooses to pay for, the same way `RequestBody.bytes()` is.
## `EX-42`: the last per-request allocation, found by re-measuring
Pooling `Request`/`RequestBody`/`RequestLine`/`Response` dropped `RequestPipelineBenchmark`'s
`parseAndRoute` from 120.008 B/op to 48.008 B/op — real progress, but not the 0 B/op the phase's
own DoD text requires. Reading `RequestParser.parse` turned up three `new
FastPathViews.RequestByteView(...)` allocations (path, query when present, protocol) on every
call — pre-existing since at least Phase 4, invisible until the larger `Request`/`RequestBody`/
`RequestLine` cost sitting on top of them was removed. Fixed the same way as everything else in
this document: `RequestByteView` gained a `reset(byte[], int, int)`; `RequestParser` now owns one
pooled instance per role. `parseAndRoute` measures 0.008 B/op after the fix — JMH's noise floor,
effectively 0. Full numbers in `DECISIONS.md`, `DEC-23`.
## The zero-alloc contract, closed
> 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.
`RequestPipelineBenchmark.parseAndRoute` (parse + route with a parametric match, no header/param
access) measures 0 B/op. `parseRouteAndExtractThreeFields` (the same, plus one path param and two
header reads) measures 184.009 B/op — entirely the `String` allocations the contract's own text
exempts ("except for the user-facing `String`s the handler explicitly asks for"). See
`DECISIONS.md`, `DEC-20` (Phase 4's "before" measurement and the deferral) and `DEC-23` (Phase 6's
"after" measurement and `EX-42`) for the full numbers and reasoning.