Files
Flash5/flash/docs/http2/MESSAGE-MODEL.md
T
Zakaria El OrcheandClaude Sonnet 5 d882ea255c 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>
2026-08-13 15:26:08 +00:00

13 KiB
Raw Blame History

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-20EX-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:

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 modelshttp1 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-38EX-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 Strings 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.