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:
co-authored by
Claude Sonnet 5
parent
0e1bbed42c
commit
d882ea255c
@@ -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.
|
||||
Reference in New Issue
Block a user