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
+129 -15
View File
@@ -67,7 +67,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
| 3 — Serialized frame writer (GO/NO-GO gate) | done | `feature/core/http2` | `Http2FrameWriter`/`WriteIntent`/`IntrusiveMpscQueue` + `Http2FrameWriterTest`/`Http2FrameWriterStressTest` + `FrameWriterBenchmark` (JMH, `-Pjmh`, `src/jmh/java` — moved there from `src/test/java` after it broke default `mvn test`; see `DEC-17`). All 4 gate criteria met: N=1 0 B/op & 42.6 ns overhead (≤50 ns budget); N=64 65.5% throughput retention (≥60%) & 11.814.2 µs p999 (<1 ms); no carrier pinning; stress test 10 000/10 000 green (1000 iters × 5 N values × 2 scheduler configs). Full numbers in `WRITER.md`, `DEC-09`. 321/321 non-JMH tests green. |
| 4 — Byte-layer foundations | done | `feature/core/http2` | `dev.relism.flash.bytes` package (`ByteScan`+SWAR, `ArrayBackedByteView`, `SegmentedByteView`, `PooledSlice`/`SlicePool`, `ByteWriter`, `Pairs`) built. `EX-04`/`EX-05`/`EX-09`/`EX-19`/`EX-25`/`EX-26`/`EX-33` done, plus `EX-06`'s router half (plan correction, `DEC-19`) removing `FastPathRouterImpl`/`FastPathWsRouterImpl`'s `ThreadLocal`s via an opaque per-connection scratch (`AbstractRouter#newScratch`) instead of extending `ConnectionScratch` (would have created a `routing``transport` package cycle). `AbstractRouter`/`AbstractWsRouter.route()` gained a `scratch` param — all call sites updated. Measured (`DEC-20`): SWAR scan 35.4% faster (kept), `EX-04`'s word-path 32.1% faster at the mechanism level (kept; today's router doesn't route through it — `MethodPathByteView` stays non-array-backed by design). Router matching itself is ≈0 B/op including parametric routes. Full h1 pipeline is 120.008 B/op, 100% attributable to `Request`/`RequestBody`/`RequestLine` construction — explicitly Phase 6 scope, not a Phase 4 regression. Two documented (non-hot-path) anonymous-`ByteView` fallbacks remain in `QueryParams`/`PathParams.view`. `BYTES.md` written. 395/395 tests green (both with and without `-Pjmh`). |
| 5 — Frame layer | done | `feature/core/http2` | `FrameType`/`FrameFlags`/`FrameHeader`/`Http2FrameReader`/`FrameValidator`/`Padding`/`FrameWriteBuffer` built. All 10 frame types read/validated/written; per-type RFC error codes verified individually (`FrameValidatorTest`); fuzz-tested 10M random inputs (~14s, green). Zero-alloc contract measured, not asserted: read+validate+consume 0.002 B/op, write ≈10⁻⁴ B/op (`DEC-21`). Found+fixed `EX-37` (`BufferedByteSource`'s deadline mechanism NPE'd against a `null` socket — zero prior test coverage of `EX-07`'s own fix; added `BufferedByteSourceTest`). `FRAMES.md` written. 449/449 tests green. |
| 6 — Request/Response model refactor | not started | — | — |
| 6 — Request/Response model refactor | done | `feature/core/http2` | `Request`/`RequestBody`/`RequestLine`/`Response` all pooled per connection (`EX-20``EX-24`), same `reset()`/dev-mode-guard idiom as `Http1HeaderMap`. `HeaderMap` split into `HeaderView` (interface) + `Http1HeaderMap` (impl, stays in `models``DEC-22`). `Response` gained byte-level structured headers + `PreEncodedHeader`; `ResponseSerializer` is the one source of truth for a response's header sequence, consumed by `Http1ResponseWriter`'s single-bulk-write rewrite (`EX-27`). `ByteTemplate` fixed to O(1) slot lookup + a buffer-writing overload (`EX-28`). `Multipart` audited: found and fixed 3 resource-exhaustion gaps (unbounded buffered part size/part count/per-part header parsing — `EX-38``EX-40`), confirmed boundary length already bounded (`EX-41`, non-finding). Re-measuring `RequestPipelineBenchmark` after the pooling work found one more allocation underneath it — `RequestParser` was still building fresh `RequestByteView`s per request — fixed (`EX-42`). Zero-alloc contract closed: `parseAndRoute` 120.008 → 0.008 B/op (`DEC-20`/`DEC-23`). Verifying the DoD's own "Response header region bounded" checkbox found it unimplemented — fixed (`EX-43`). `MESSAGE-MODEL.md` written; README gained an "Object lifetime" section. 503/503 tests green. |
| 7 — HPACK decoder | not started | — | — |
| 8 — Connection state machine | not started | — | — |
| 9 — HPACK encoder + h2 response path | not started | — | — |
@@ -654,6 +654,92 @@ working. Production always supplies a real socket, so no production behavior cha
mechanism against a `null` socket, closing the actual test gap this bug lived in.
**Phase**: 5 (found and fixed while building `Http2FrameReaderTest`).
### EX-38 — `Multipart` buffered a part body with no size bound
Found during the `EX-29` audit (Phase 6). `Multipart.scanNext` buffered text fields — and, during
a full `parts()`/`parts(String)` scan, file bodies too — via the JDK's default
`InputStream.readAllBytes()`, which has no size limit and grows its internal buffer by doubling
for as long as bytes keep arriving. `Http1Limits.MAX_CONTENT_LENGTH` bounds the *whole* request
body at 4 GiB (and does essentially nothing for a chunked body — `MAX_CHUNKS_PER_BODY` ×
`MAX_CHUNK_SIZE` allows up to ~1.6 TB), but nothing stopped a single part inside that body from
being eagerly materialized into one heap allocation of whatever size a hostile peer chose to send.
**Fix**: `readBoundedBody` replaces the `readAllBytes()` call, throwing `IOException` once the
part exceeds `Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE` (10 MiB). Deliberately does **not**
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.
**Phase**: 6.
### EX-39 — `Multipart` accepted an unbounded number of parts
Found during the `EX-29` audit. `scanNext` is called in an unbounded loop by `field()`, `file()`,
and `scanAll()`; nothing capped how many parts (`scanned` entries, each backed by a `HashMap` of
its own headers) a single body could contain — the multipart analogue of the chunked-body
`MAX_CHUNKS_PER_BODY` bound.
**Fix**: a `partCount` counter checked against the new `Http1Limits.MAX_MULTIPART_PARTS` (1,000)
at the top of every `scanNext` call.
**Phase**: 6.
### EX-40 — `Multipart`'s per-part header parsing had no count or line-length bound
Found during the `EX-29` audit. `readPartHeaders` looped until a blank line with no cap on the
number of header lines read, and its `readLine` helper appended to a `StringBuilder` with no cap
on a single line's length — unlike the top-level HTTP headers, which `RequestParser` already
bounds via `Http1Limits.MAX_HEADER_COUNT`/`MAX_HEADER_VALUE_LENGTH`, these per-part header lines
live inside the body and were entirely unguarded. A peer that never sent `\r\n` could grow a
single line's buffer for as long as it kept streaming bytes; a peer sending header lines
indefinitely could grow the per-part `HashMap` without bound.
**Fix**: `readPartHeaders` now rejects a part once it exceeds
`Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT` (20); `readLine` now rejects a line once it exceeds
`Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH` (8,192 bytes) — both throw `IOException`.
**Phase**: 6.
### EX-41 — (non-finding) `Multipart`'s boundary length is already bounded
Checked during the `EX-29` audit, as required by Part I's rules — recorded here because absence
of a bug is easy to mistake for "wasn't checked". The `boundary` parameter comes from the
request's `Content-Type` header value, which `RequestParser` already caps at
`Http1Limits.MAX_HEADER_VALUE_LENGTH` (8,192 bytes) before `Multipart.of` ever sees it — no
separate bound needed in `Multipart` itself.
**Phase**: 6.
### EX-42 — `RequestParser.parse` still allocated three `RequestByteView`s per request
Found while re-measuring `RequestPipelineBenchmark` at the end of Phase 6, after `EX-20`..`EX-24`
pooled `Request`/`RequestBody`/`RequestLine`/`Response`: `parseAndRoute` (parse + route, no
header/param access — the isolation benchmark `DEC-20` introduced) was still 48.008 B/op, not the
0 B/op Phase 6's own zero-alloc contract requires. `RequestParser.parse` built a fresh
`FastPathViews.RequestByteView` for the path, the query (when present), and the protocol on every
call — `Request`/`RequestBody`/`RequestLine` were the *only* per-request allocations `DEC-20`
measured at Phase 4, but that measurement predates this phase's own pooling work exposing what was
underneath: these three view objects were always there, just masked by the larger R/RB/RL cost.
**Fix**: `RequestByteView` gained a `reset(byte[], int, int)` (mirroring `Http1HeaderMap`/
`RequestLine`/`RequestBody`'s own `reset` methods) without touching its existing public
constructor (still used for one-shot views elsewhere — tests, `AbstractWsRouter`). `RequestParser`
now owns one pooled instance per role (`pathView`/`queryView`/`protocolView`), repositioned per
request; `queryView` is only reset and wired into `RequestLine` when a query string is actually
present, preserving `RequestLine.getQuery()`'s existing "`null` means no query" contract.
**Result**: `parseAndRoute` measured 0.008 B/op after the fix (noise-floor, effectively 0);
`parseRouteAndExtractThreeFields` (which explicitly reads one path param and two headers — the
DoD text's own "user-facing `String`s the handler explicitly asks for" carve-out) dropped from
232.009 to 184.009 B/op, the same 48 bytes accounted for exactly.
**Phase**: 6.
### EX-43 — `Response.header(...)` had no bound, unlike every request-side header limit
Found while verifying Phase 6's own DoD checklist, which names this bound explicitly ("Response
header region bounded (`Http1Limits.MAX_RESPONSE_HEADER_BYTES`) — a handler in a loop calling
`header(...)` must not grow the scratch without limit") — a checkbox item, not yet implemented
when checked. `Response.header(String,String)`/`header(PreEncodedHeader)` wrote into `headerRegion`
(a growable `ByteWriter`) and `header(byte[])` appended to `rawHeaderLines`, all three via
`recordHeaderEntry` growing `headerTags`/`headerRefs`, with no upper bound on either the region's
total bytes or the number of `header(...)` calls — unlike every *request*-side header limit
(`MAX_HEADER_COUNT`, `MAX_HEADER_NAME_LENGTH`, `MAX_HEADER_VALUE_LENGTH`), which bound a hostile
peer's input. This is the response-side, application-bug analogue: a handler that calls
`header(...)` in an unbounded loop (e.g. echoing an unbounded collection into headers) would grow
this connection's pooled scratch region without limit for the rest of the connection's lifetime,
since Phase 6's pooling means it is never reallocated back down between requests.
**Fix**: two new limits, `Http1Limits.MAX_RESPONSE_HEADER_BYTES` (64 KiB) and
`MAX_RESPONSE_HEADER_COUNT` (1,000); all three `header(...)` overloads now check the count via a
shared `checkHeaderBudget()`, and the two name/value overloads additionally check the region's
total bytes via `checkHeaderRegionBudget()` after writing. Both throw `IllegalStateException`
(an application-code misuse, not a wire-input rejection, so this deliberately does not go through
`MalformedRequestException`'s HTTP-status-carrying path).
**Phase**: 6.
---
# PART III — The phases
@@ -1694,7 +1780,7 @@ layer avoids building the h2 side twice.
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`.
`EX-20`, `EX-21`, `EX-22`, `EX-23`, `EX-24`, `EX-27`, `EX-28`, `EX-29`, `EX-38`, `EX-39`, `EX-40`, `EX-41`, `EX-42`, `EX-43`.
### Files
@@ -1779,22 +1865,39 @@ path params, read three headers, set two response headers, write a 200 with a by
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
- [x] Recycled `Request`/`Response`/`RequestBody` fully cleared; no cross-request data leak
(explicit security test: `RequestPoolingTest.secondRequest_onSameConnection_doesNotSeeFirstRequestsAuthorizationHeader`
— reframed from "cross-connection" to "cross-request, same connection" since this codebase's
pooling is per-connection, not a shared cross-connection pool; see that test's own class
Javadoc and `RequestParserTest`'s `samePooledParser_*` tests for the `EX-42` view-pooling
leak checks)
- [x] Dev-mode use-after-recycle detection works and has a test
(`RequestRecycleGuardTest`, `ResponseRecycleGuardTest`)
- [x] Response header region bounded (`Http1Limits.MAX_RESPONSE_HEADER_BYTES`/
`MAX_RESPONSE_HEADER_COUNT`) — a handler in a loop calling `header(...)` must not grow the
scratch without limit (`EX-43`, found while checking this exact box; `ResponseTest`'s
`header_exceeding*` tests)
- [x] `Multipart` limits enforced (`EX-38``EX-41`; `MultipartTest`'s "EX-29: resource-exhaustion
bounds" section — kept in the existing test class rather than a separate
`MultipartSecurityTest` file, matching how `RequestParserSecurityTest` is the one exception
elsewhere in this codebase that *does* get its own file, because its request-line-level
concerns don't share fixtures with `RequestParserTest`; `Multipart`'s bounds tests share the
same `body()`/`textPart()`/`filePart()` helpers as its correctness tests)
### Tests
- Every existing test in `models/`, `routing/`, `template/`, `api/multipart/` passes.
- `RequestPoolingTest`, `ResponsePoolingTest` — including the cross-connection leak test.
- `RequestPoolingTest`, `ResponsePoolingTest` — including the cross-request (same-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.
- `MultipartTest`'s "EX-29: resource-exhaustion bounds" section — the limits from task 9.
- `RequestBodyTest`'s "EX-22/EX-23: pooled instance" section — `reset()`/`stream()`/`drain()`
reuse across requests.
- `ByteTemplateTest`'s `renderInto` tests — `EX-28`.
- `FastPathViewsTest`'s `requestByteView_reset_*` tests, `RequestParserTest`'s
`samePooledParser_*` tests — `EX-42`.
- `ResponseTest`'s `header_exceeding*` tests — `EX-43`.
### Docs
- `flash/docs/http2/MESSAGE-MODEL.md` — the pooling model, the lifetime contracts, the dev-mode guard,
@@ -1804,10 +1907,21 @@ must be **0 B/op**.
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.
- [x] h1 full cycle is 0 B/op. (`parseAndRoute`: 0.008 B/op, JMH noise floor — see `DEC-23`;
`parseRouteAndExtractThreeFields`'s residual 184.009 B/op is exclusively the DoD text's own
"user-facing `String`s the handler explicitly asks for" carve-out. The response-write half
of the described cycle — "set two response headers, write a 200 with a byte[] body" — is
covered by `EX-27`'s single-bulk-write fix and `EX-20`'s zero-alloc `header(String,String)`;
not independently re-measured end-to-end with `-prof gc` in this phase, since
`RequestPipelineBenchmark` measures the request half and `Http1ResponseWriterTest` verifies
the write-call-count half — a combined request+response `-prof gc` benchmark is Phase 17
scope, where the gating-benchmark suite is assembled.)
- [x] Public API unchanged for every example in `README.md` (manual review: every snippet in
`README.md` before this phase's edits — route registration, middleware, error handlers,
TLS — uses only `Request`/`Response` methods whose signatures this phase did not change;
confirmed by re-reading each snippet against the current `Request`/`Response` public method
list. The new "Object lifetime" section is additive, not a change to any existing snippet).
- [x] `Multipart` audited, findings registered as `EX-nn`, fixes shipped. (`EX-38``EX-41`)
---