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
@@ -765,3 +765,103 @@ or `FrameWriteBuffer` are ever modified in a way that could plausibly affect the
|
||||
profile.
|
||||
|
||||
---
|
||||
|
||||
## DEC-22 — `HeaderMap` splits into `HeaderView` (interface) + `Http1HeaderMap` (impl, staying in `models`, not moving to `http1`)
|
||||
|
||||
**Context.** Phase 6 task 1 requires splitting the concrete `HeaderMap` class into a
|
||||
protocol-neutral read contract (so a future `Http2HeaderMap` can implement it) plus the existing
|
||||
h1 byte-buffer-backed implementation, and explicitly asks for two decisions to be recorded:
|
||||
whether the public-facing name stays `HeaderMap` or moves to the interface, and (implicitly, via
|
||||
the plan's own Files list) whether the concrete class moves to `dev.relism.flash.http1`.
|
||||
|
||||
**Decision 1 — naming.** Checked whether `HeaderMap` is actually part of `Request`'s public
|
||||
surface first, since the task's hard constraint is "the public API of `Request` must not
|
||||
change": `Request`'s own methods (`header`, `headers`, `param`, `query`) return `String`/
|
||||
`List<String>`, never a `HeaderMap`/`HeaderView` — the only exposure is the transitive,
|
||||
Javadoc'd-as-"Internal" `Request.getRequestLine().getHeaders()` path. Concluded the type name
|
||||
itself is not public API in the sense the constraint cares about, so took the plan's Files list
|
||||
literally: new interface named `HeaderView` (the read contract), concrete implementation renamed
|
||||
`Http1HeaderMap`. `RequestLine.headers` (and its Lombok-generated `getHeaders()`) is now typed
|
||||
`HeaderView`.
|
||||
|
||||
**Decision 2 — package placement.** The plan's Files list suggests `http1/Http1HeaderMap.java`.
|
||||
Verified first (as `DEC-19` did for the same class of question): `RequestParser`, which owns and
|
||||
resets the one `Http1HeaderMap` instance per connection, lives in the root `dev.relism.flash`
|
||||
package, not `http1`. `http1` already depends on root (`Http1Connection` imports
|
||||
`RequestParser`); moving the header-map implementation into `http1` would require root to import
|
||||
back from `http1` for `RequestParser` to construct one — the same reverse-edge problem `DEC-19`
|
||||
found and avoided for `routing`/`transport`. Kept `Http1HeaderMap` in `models` instead, alongside
|
||||
`HeaderView` — deviating from the plan's literal suggested path, not from its intent.
|
||||
|
||||
**Consequence.** `HeaderView` is the new protocol-neutral interface (`first`, `all`, `view`,
|
||||
`valueEqualsIgnoreCase`, `contains`, `count`, `forEach`); `contains`/`count` did not exist on the
|
||||
old `HeaderMap` and were added to satisfy the interface's stated method list. `Http1HeaderMap`
|
||||
carries the full `EX-09`/`EX-05` implementation unchanged, just renamed and re-typed against the
|
||||
interface. Every call site across `main` and `test` sources updated (`RequestParser`, test files
|
||||
constructing header maps directly); `HeaderMapTest`/`HeaderMapIndexTest` renamed to
|
||||
`Http1HeaderMapTest`/`Http1HeaderMapIndexTest` to match. 449/449 tests green, unchanged count —
|
||||
this was a pure rename/re-type, no behavior change.
|
||||
|
||||
**Revisit when.** Phase 10, when `Http2HeaderMap` is built — confirms whether `HeaderView`'s
|
||||
method list is actually sufficient for an HPACK-backed implementation, or needs extending.
|
||||
|
||||
---
|
||||
|
||||
## DEC-23 — Phase 6 closes `DEC-20`'s revisit loop: the h1 zero-alloc contract, re-measured after `Request`/`RequestBody`/`RequestLine`/`Response` pooling, plus one more allocation found and fixed (`EX-42`)
|
||||
|
||||
**Context.** `DEC-20` (Phase 4) measured `RequestPipelineBenchmark.parseAndRoute` at 120.008 B/op
|
||||
and attributed it entirely to `Request`/`RequestBody`/`RequestLine` construction, explicitly
|
||||
deferring the fix to Phase 6 and asking for a re-run once that pooling landed. Phase 6 tasks 2–7
|
||||
(`EX-20`–`EX-24`) did that pooling; this entry is the promised re-run (same JDK 21.0.11, JMH 1.37,
|
||||
`avgt` mode, `-prof gc`, `flash/src/jmh/java`, same fixture: `GET /users/12345 HTTP/1.1` with
|
||||
`Host`/`Accept`/`Authorization`).
|
||||
|
||||
**First re-run, after `EX-20`–`EX-24` alone:**
|
||||
|
||||
| | ns/op | B/op |
|
||||
|---|---|---|
|
||||
| `parseAndRoute` | 1194.105 ± 944.469 | 48.008 |
|
||||
| `parseRouteAndExtractThreeFields` | 1324.679 ± 296.883 | 232.009 |
|
||||
|
||||
Down from 120.008 to 48.008 B/op — real progress, but not the 0 B/op the phase's own DoD text
|
||||
requires for `parseAndRoute` (no header/param access). Investigated rather than accepted: reading
|
||||
`RequestParser.parse` line by line turned up three `new FastPathViews.RequestByteView(...)`
|
||||
allocations (path, query when present, protocol) on every call — pre-existing since at least Phase
|
||||
4, just smaller than the `Request`/`RequestBody`/`RequestLine` cost `DEC-20` measured and therefore
|
||||
invisible until this phase's pooling removed the larger cost sitting on top of it. Registered as
|
||||
`EX-42` and fixed the same way every other per-connection object in this codebase already is:
|
||||
`RequestByteView` gained a `reset(byte[], int, int)`, `RequestParser` now owns one pooled instance
|
||||
per role instead of allocating fresh ones.
|
||||
|
||||
**Second re-run, after `EX-42`:**
|
||||
|
||||
| | ns/op | B/op |
|
||||
|---|---|---|
|
||||
| `parseAndRoute` | 1111.260 ± 104.692 | 0.008 |
|
||||
| `parseRouteAndExtractThreeFields` | 1301.840 ± 228.068 | 184.009 |
|
||||
|
||||
`parseAndRoute` — 0.008 B/op is JMH's noise floor (a `-prof gc` sampling artifact, not a real
|
||||
allocation); this is the 0 B/op the contract asks for. `parseRouteAndExtractThreeFields` dropped
|
||||
from 232.009 to 184.009 B/op — the exact 48 bytes `EX-42` removed, confirming the fix's accounting
|
||||
and leaving only the "user-facing `String`s the handler explicitly asks for" the contract's own
|
||||
text carves out (one path param, two headers — three `String` allocations plus their backing
|
||||
`byte[]`s).
|
||||
|
||||
**Decision.** The h1 zero-alloc contract is met: `parseAndRoute` (parse + route with a parametric
|
||||
match) is 0 B/op; the residual cost in `parseRouteAndExtractThreeFields` is entirely the explicit
|
||||
`String` reads the DoD text itself exempts. `DEC-20`'s revisit item is closed.
|
||||
|
||||
**Consequence.** `RequestByteView`'s public 3-arg constructor is unchanged (still used for
|
||||
one-shot views by tests, `AbstractWsRouter`, `ErrorPagesTest`, etc.) — only `RequestParser`'s three
|
||||
call sites moved to the pooled `reset()` path. `queryView` is only reset and wired into
|
||||
`RequestLine` when a query string is actually present, preserving
|
||||
`RequestLine.getQuery()`'s existing `null`-means-absent contract — verified by
|
||||
`RequestParserTest.samePooledParser_secondRequestWithoutQuery_doesNotLeakFirstRequestsQuery`, the
|
||||
pooling-leak class of test this codebase writes for every pooled object (`RequestPoolingTest`,
|
||||
`ResponsePoolingTest`, `RequestBodyTest`'s new pooling tests). 500/500 tests green.
|
||||
|
||||
**Revisit when.** Never expected to — this closes the loop `DEC-20` opened. If a future phase adds
|
||||
a fourth per-request view (e.g. an h2 equivalent), extend this same pooled-`reset()` pattern rather
|
||||
than reintroducing a fresh allocation.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.8–14.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`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -25,15 +25,19 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Phase 4's zero-alloc contract: "an h1 {@code GET /users/{id}} request that reads three headers
|
||||
* The h1 zero-alloc contract: "an h1 {@code 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 {@code String}s the
|
||||
* handler explicitly asks for." This benchmark measures the actual current number with
|
||||
* {@code -prof gc} — see {@code DECISIONS.md}, {@code DEC-20}, for the honest result and why it
|
||||
* is not literally 0 B/op yet: {@code Request}/{@code RequestBody}/{@code RequestLine} are still
|
||||
* allocated per request ({@code EX-21}/{@code EX-22}, explicitly Phase 6 scope, not Phase 4's).
|
||||
* The two benchmark methods below isolate that cost from Phase 4's own scope (header lookups,
|
||||
* path-param extraction, query decoding) by comparing a route with no header/param access against
|
||||
* one that performs exactly the access the DoD text describes.
|
||||
* handler explicitly asks for." This benchmark measures the actual number with {@code -prof gc}.
|
||||
* At Phase 4 ({@code DEC-20}) {@code parseAndRoute} measured 120.008 B/op, entirely attributable
|
||||
* to {@code Request}/{@code RequestBody}/{@code RequestLine} construction (explicitly deferred to
|
||||
* Phase 6, not a Phase 4 regression). Phase 6's pooling ({@code EX-20}–{@code EX-24}) plus one
|
||||
* more allocation this benchmark caught underneath it ({@code EX-42}: {@code RequestParser} was
|
||||
* still allocating fresh {@code RequestByteView}s per request) closed the gap — see
|
||||
* {@code DECISIONS.md}, {@code DEC-23}, for the full before/after numbers. {@code parseAndRoute}
|
||||
* is now 0 B/op (JMH's noise floor); the two benchmark methods below isolate that from the
|
||||
* unavoidable, DoD-exempted cost of the explicit {@code String} reads a real handler performs
|
||||
* (header lookups, path-param extraction) by comparing a route with no header/param access
|
||||
* against one that performs exactly the access the DoD text describes.
|
||||
*
|
||||
* <p>Uses a hand-rolled repeating {@link InputStream} (never allocates, cycles the same request
|
||||
* bytes indefinitely) rather than a fresh {@code ByteArrayInputStream}/{@code BufferedByteSource}
|
||||
|
||||
+1
-1
@@ -85,7 +85,7 @@ public class FastPathRouterBenchmark {
|
||||
dev.relism.flash.models.RequestLine line = new dev.relism.flash.models.RequestLine(
|
||||
method, pathView, null,
|
||||
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
|
||||
new dev.relism.flash.models.HeaderMap()
|
||||
new dev.relism.flash.models.Http1HeaderMap()
|
||||
);
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,9 @@ import dev.relism.flash.bytes.ByteScan;
|
||||
import dev.relism.flash.exceptions.MalformedRequestException;
|
||||
import dev.relism.flash.http.Http1Limits;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.HeaderMap;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestBody;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||
import dev.relism.flash.transport.BufferedByteSource;
|
||||
@@ -56,7 +57,19 @@ public class RequestParser {
|
||||
private final int maxHeaderBufferSize;
|
||||
private final InetSocketAddress remoteAddress;
|
||||
private final SSLSocket sslSocket;
|
||||
private final HeaderMap headerMap = new HeaderMap();
|
||||
private final Http1HeaderMap headerMap = new Http1HeaderMap();
|
||||
// EX-22: one Request/RequestLine per connection, repositioned (never reallocated) per
|
||||
// request — same idiom as headerMap above.
|
||||
private final RequestLine requestLine = new RequestLine();
|
||||
private final Request request = new Request();
|
||||
private final RequestBody requestBody = new RequestBody();
|
||||
// EX-42: one pooled RequestByteView per role, repositioned (never reallocated) per request —
|
||||
// closes the last per-request allocation left after EX-20..EX-24 pooled Request/RequestBody/
|
||||
// RequestLine/Response themselves. queryView is only reset and used when a query string is
|
||||
// actually present; RequestLine.getQuery() must keep returning null otherwise (see reset()).
|
||||
private final FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(null, 0, 0);
|
||||
private final FastPathViews.RequestByteView queryView = new FastPathViews.RequestByteView(null, 0, 0);
|
||||
private final FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(null, 0, 0);
|
||||
private byte[] buffer;
|
||||
|
||||
// Unconsumed bytes belonging to the NEXT request.
|
||||
@@ -154,11 +167,8 @@ public class RequestParser {
|
||||
if (pathEnd == -1) throw new MalformedRequestException(400, "Invalid request line (path)");
|
||||
|
||||
int queryMark = ByteScan.indexOf(buffer, pathStart, pathEnd, (byte) '?');
|
||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
|
||||
queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
|
||||
FastPathViews.RequestByteView queryView = queryMark != -1
|
||||
? new FastPathViews.RequestByteView(buffer, queryMark + 1, pathEnd - queryMark - 1)
|
||||
: null;
|
||||
pathView.reset(buffer, pathStart, queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
|
||||
if (queryMark != -1) queryView.reset(buffer, queryMark + 1, pathEnd - queryMark - 1);
|
||||
|
||||
int protocolStart = pathEnd + 1;
|
||||
int protocolEnd = ByteScan.indexOf(buffer, protocolStart, headerEndIdx, (byte) '\r');
|
||||
@@ -171,8 +181,7 @@ public class RequestParser {
|
||||
throw new MalformedRequestException(431, "Request line exceeds " + Http1Limits.MAX_REQUEST_LINE_LENGTH + " bytes");
|
||||
}
|
||||
|
||||
FastPathViews.RequestByteView protocolView =
|
||||
new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
|
||||
protocolView.reset(buffer, protocolStart, protocolEnd - protocolStart);
|
||||
|
||||
// ── Headers ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -290,14 +299,18 @@ public class RequestParser {
|
||||
preBufLen = (int) contentLength;
|
||||
}
|
||||
|
||||
RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap);
|
||||
requestLine.reset(method, pathView, queryMark != -1 ? queryView : null, protocolView, headerMap);
|
||||
|
||||
// EX-22: requestBody is this connection's single pooled instance (see its own class
|
||||
// Javadoc) -- reset() repositions it for the fixed-length/empty case (contentLength == 0
|
||||
// is handled by the same call: preBufLen is already forced to 0 for it above) or the
|
||||
// chunked case, never reallocated.
|
||||
if (isChunked) {
|
||||
return Request.forParsed(requestLine,
|
||||
new ChunkedInputStream(in, buffer, bodyStart, preBufLen),
|
||||
-1L, null, 0, 0, remoteAddress, sslSocket);
|
||||
requestBody.reset(new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0);
|
||||
} else {
|
||||
requestBody.reset(in, contentLength, buffer, bodyStart, preBufLen);
|
||||
}
|
||||
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress, sslSocket);
|
||||
return Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package dev.relism.flash.api.multipart;
|
||||
|
||||
import dev.relism.flash.http.Http1Limits;
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -55,6 +57,7 @@ public final class Multipart {
|
||||
|
||||
private final List<Part> scanned = new ArrayList<>();
|
||||
private PartBodyStream active = null; // open file stream; must be drained before next scan
|
||||
private int partCount = 0; // EX-29: bounds Http1Limits.MAX_MULTIPART_PARTS
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Factory
|
||||
@@ -156,6 +159,12 @@ public final class Multipart {
|
||||
Map<String, String> headers = readPartHeaders();
|
||||
if (headers == null) { done = true; return null; }
|
||||
|
||||
// EX-29: without this bound, a peer sending an unbounded number of minimal parts forces
|
||||
// unbounded growth of `scanned` and unbounded cumulative header-parsing work.
|
||||
if (++partCount > Http1Limits.MAX_MULTIPART_PARTS) {
|
||||
throw new IOException("multipart body exceeds max part count (" + Http1Limits.MAX_MULTIPART_PARTS + ")");
|
||||
}
|
||||
|
||||
String disp = headers.get("content-disposition");
|
||||
String name = extractParam(disp, "name");
|
||||
String filename = extractParam(disp, "filename");
|
||||
@@ -168,8 +177,10 @@ public final class Multipart {
|
||||
// File part — expose streaming body; not cached (stream is consumed once)
|
||||
p = Part.streaming(name, filename, ct, active);
|
||||
} else {
|
||||
// Text part, or full-scan path: buffer body now
|
||||
byte[] body = active.readAllBytes();
|
||||
// Text part, or full-scan path: buffer body now. EX-29: bounded, not
|
||||
// InputStream.readAllBytes() — an unbounded field/file body would otherwise let a
|
||||
// hostile peer force an arbitrarily large single heap allocation.
|
||||
byte[] body = readBoundedBody(active);
|
||||
active = null;
|
||||
p = Part.buffered(name, filename, ct, body);
|
||||
scanned.add(p);
|
||||
@@ -177,6 +188,27 @@ public final class Multipart {
|
||||
return p;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads {@code in} to EOF into a {@code byte[]}, bounded by
|
||||
* {@link Http1Limits#MAX_MULTIPART_BUFFERED_PART_SIZE} — see that constant's Javadoc for why
|
||||
* this bound is necessary even though the overall request body already has one.
|
||||
*/
|
||||
private static byte[] readBoundedBody(InputStream in) throws IOException {
|
||||
ByteArrayOutputStream out = new ByteArrayOutputStream(BUF_CAP);
|
||||
byte[] chunk = new byte[BUF_CAP];
|
||||
long total = 0;
|
||||
int n;
|
||||
while ((n = in.read(chunk)) > 0) {
|
||||
total += n;
|
||||
if (total > Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE) {
|
||||
throw new IOException("multipart part body exceeds max buffered size ("
|
||||
+ Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + " bytes)");
|
||||
}
|
||||
out.write(chunk, 0, n);
|
||||
}
|
||||
return out.toByteArray();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// PartBodyStream — inner class sharing the window buffer
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -266,9 +298,16 @@ public final class Multipart {
|
||||
|
||||
private Map<String, String> readPartHeaders() throws IOException {
|
||||
Map<String, String> map = new HashMap<>();
|
||||
int count = 0;
|
||||
while (true) {
|
||||
String line = readLine();
|
||||
if (line == null || line.isEmpty()) break;
|
||||
// EX-29: without this bound a peer can send an effectively unlimited number of
|
||||
// header lines before the blank line that ends a part's header block.
|
||||
if (++count > Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT) {
|
||||
throw new IOException("multipart part exceeds max header count ("
|
||||
+ Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT + ")");
|
||||
}
|
||||
int colon = line.indexOf(':');
|
||||
if (colon > 0)
|
||||
map.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT),
|
||||
@@ -289,6 +328,7 @@ public final class Multipart {
|
||||
sb.append(new String(win, wPos, i - wPos, StandardCharsets.UTF_8));
|
||||
int consumed = i - wPos + 2;
|
||||
wPos += consumed; wLen -= consumed;
|
||||
checkHeaderLineLength(sb.length());
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -298,14 +338,27 @@ public final class Multipart {
|
||||
sb.append(new String(win, wPos, append, StandardCharsets.UTF_8));
|
||||
wPos += append; wLen -= append;
|
||||
}
|
||||
// EX-29: without this bound, a peer that never sends \r\n keeps this StringBuilder
|
||||
// growing for as long as it keeps streaming bytes — the multipart-header analogue of
|
||||
// RequestParser's Http1Limits.MAX_HEADER_VALUE_LENGTH check, which does not apply
|
||||
// here since these header lines live inside the body, not the top-level HTTP headers.
|
||||
checkHeaderLineLength(sb.length());
|
||||
if (srcEof && wLen > 0) {
|
||||
sb.append(new String(win, wPos, wLen, StandardCharsets.UTF_8));
|
||||
wPos += wLen; wLen = 0;
|
||||
checkHeaderLineLength(sb.length());
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void checkHeaderLineLength(int length) throws IOException {
|
||||
if (length > Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH) {
|
||||
throw new IOException("multipart header line exceeds "
|
||||
+ Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH + " bytes");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Utilities
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@@ -10,7 +10,7 @@ import java.nio.ByteOrder;
|
||||
* The single home for protocol-neutral byte scanning: single-byte search, the four-byte
|
||||
* {@code \r\n\r\n} header-terminator search (SWAR-accelerated), case-insensitive comparison,
|
||||
* comma-separated token-list scanning ({@code Connection: a, b, c}), RFC 9110 {@code tchar}
|
||||
* validation, and the case-insensitive header-name hash {@link dev.relism.flash.models.HeaderMap}'s
|
||||
* validation, and the case-insensitive header-name hash {@link dev.relism.flash.models.Http1HeaderMap}'s
|
||||
* index uses ({@code EX-09}).
|
||||
*
|
||||
* <p>Every method here is {@code static} and allocates nothing. Every SWAR method has a plain
|
||||
@@ -242,7 +242,7 @@ public final class ByteScan {
|
||||
|
||||
/**
|
||||
* Case-insensitive (ASCII fold) 32-bit FNV-1a hash of {@code buf[start, start + len)}. Used
|
||||
* by {@link dev.relism.flash.models.HeaderMap}'s per-request index to compare a cheap hash
|
||||
* by {@link dev.relism.flash.models.Http1HeaderMap}'s per-request index to compare a cheap hash
|
||||
* before falling back to a full case-insensitive {@code memcmp}-equivalent
|
||||
* ({@link #equalsIgnoreCaseAscii}) — two header names that differ anywhere hash differently
|
||||
* with overwhelming probability, so the common "not the header I'm looking for" case resolves
|
||||
@@ -261,7 +261,7 @@ public final class ByteScan {
|
||||
* Same hash as {@link #hashNameIgnoreCaseAscii(byte[], int, int)}, computed directly from a
|
||||
* lookup-key {@code String} (e.g. {@code "Content-Type"}) instead of already-scanned bytes —
|
||||
* the two must agree bit-for-bit on equivalent ASCII content for
|
||||
* {@link dev.relism.flash.models.HeaderMap}'s index (hash the request-declared bytes once at
|
||||
* {@link dev.relism.flash.models.Http1HeaderMap}'s index (hash the request-declared bytes once at
|
||||
* {@code reset()}; hash the caller's lookup key once per {@code first()}/{@code all()} call;
|
||||
* compare the two cheap hashes before ever touching a full case-insensitive comparison).
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,7 @@ import java.nio.charset.StandardCharsets;
|
||||
*
|
||||
* <h3>Lifetime and thread-safety contract</h3>
|
||||
* Not thread-safe — exactly one writer at a time, matching every other per-connection scratch
|
||||
* object in this codebase ({@code ConnectionScratch}, {@code HeaderMap}). {@link #reset()}
|
||||
* object in this codebase ({@code ConnectionScratch}, {@code Http1HeaderMap}). {@link #reset()}
|
||||
* repositions this writer to the start of its backing array for the next message; the backing
|
||||
* array itself is never shrunk back down, only grown — the same amortized-to-zero-allocation
|
||||
* growth policy {@code RequestParser}'s read buffer already uses.
|
||||
@@ -123,6 +123,21 @@ public final class ByteWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes {@code s}'s ASCII bytes, case preserved. {@code s} must be ASCII-only. Unlike
|
||||
* {@code new String(...).getBytes(UTF_8)}, writes each character directly into this
|
||||
* writer's buffer — no intermediate {@code byte[]} ({@code EX-20}: this is what lets
|
||||
* {@code Response.header(String, String)} avoid the {@code StringBuilder}+concat+
|
||||
* {@code getBytes} allocation chain it used to pay per call).
|
||||
*/
|
||||
public void writeAscii(String s) {
|
||||
int n = s.length();
|
||||
ensure(n);
|
||||
for (int i = 0; i < n; i++) {
|
||||
buf[len++] = (byte) s.charAt(i);
|
||||
}
|
||||
}
|
||||
|
||||
/** Big-endian 16-bit write — an HTTP/2 frame's stream-dependent fields, SETTINGS values, etc. */
|
||||
public void writeUInt16(int value) {
|
||||
ensure(2);
|
||||
|
||||
@@ -3,7 +3,7 @@ package dev.relism.flash.bytes;
|
||||
/**
|
||||
* The allocation-free idiom for returning two {@code int}s from a method without an object:
|
||||
* pack both into one {@code long}, unpack at the call site. Already used, hand-rolled, in four
|
||||
* places ({@code HeaderMap.findFirst}, {@code QueryParams.findFirst}, and others) before this
|
||||
* places ({@code Http1HeaderMap.findFirst}, {@code QueryParams.findFirst}, and others) before this
|
||||
* class existed — this is the single named home for the shifts so they are not duplicated (and
|
||||
* potentially inconsistently duplicated — e.g. one copy masking with {@code 0xFFFFFFFFL} and
|
||||
* another forgetting to) five times over.
|
||||
|
||||
@@ -3,7 +3,7 @@ package dev.relism.flash.bytes;
|
||||
/**
|
||||
* A mutable, reusable {@link ArrayBackedByteView} — the {@code EX-05} fix. Replaces the
|
||||
* per-call {@code new ByteView() { ... }} anonymous-class allocation that used to live in
|
||||
* {@code HeaderMap.view}, {@code QueryParams.view}, and {@code PathParams.view}: instead of
|
||||
* {@code Http1HeaderMap.view}, {@code QueryParams.view}, and {@code PathParams.view}: instead of
|
||||
* allocating a fresh view object (plus its capturing instance) on every call, a small
|
||||
* {@link SlicePool} of these hands out an existing instance, repositioned in place.
|
||||
*
|
||||
@@ -11,7 +11,7 @@ package dev.relism.flash.bytes;
|
||||
* A {@code PooledSlice} handed out by {@link SlicePool#acquire} is valid only until the pool
|
||||
* wraps around and reuses the same slot — see {@link SlicePool}'s own Javadoc for the exact
|
||||
* "valid until the Nth subsequent acquire, or end of request" rule the owning class (e.g.
|
||||
* {@code HeaderMap}) documents precisely for its own {@code view()} method. Never retain a
|
||||
* {@code Http1HeaderMap}) documents precisely for its own {@code view()} method. Never retain a
|
||||
* {@code PooledSlice} past that window, for the same reason the old anonymous view could not be
|
||||
* retained past the handler: the bytes (and, here, additionally the slice object itself) are
|
||||
* about to be repositioned out from under a stale reference.
|
||||
|
||||
@@ -3,10 +3,10 @@ package dev.relism.flash.bytes;
|
||||
/**
|
||||
* A small, fixed-size ring of {@link PooledSlice} instances — one per {@code ConnectionScratch}-
|
||||
* held call site that used to allocate a fresh {@code ByteView} per call ({@code EX-05}:
|
||||
* {@code HeaderMap.view}, {@code QueryParams.view}, {@code PathParams.view}).
|
||||
* {@code Http1HeaderMap.view}, {@code QueryParams.view}, {@code PathParams.view}).
|
||||
*
|
||||
* <h3>Why a ring, not a single reused slice</h3>
|
||||
* A single reused slice (the shape {@code HeaderMap.forEach} already uses for its two
|
||||
* A single reused slice (the shape {@code Http1HeaderMap.forEach} already uses for its two
|
||||
* {@code nameSlice}/{@code valueSlice} fields) is correct only when the caller is guaranteed to
|
||||
* finish with one slice before the next is produced — true for a single {@code forEach} callback
|
||||
* invocation, false for {@code view()}: a handler might reasonably call
|
||||
@@ -18,7 +18,7 @@ package dev.relism.flash.bytes;
|
||||
* is called {@code size} more times on the same pool (at which point the ring has wrapped around
|
||||
* and repositioned that same slot for a new caller) — whichever comes first. This must be
|
||||
* restated precisely on every method that hands out a slice from a pool (see
|
||||
* {@code HeaderMap.view}'s Javadoc for the canonical wording); it is a real, testable hazard, not
|
||||
* {@code Http1HeaderMap.view}'s Javadoc for the canonical wording); it is a real, testable hazard, not
|
||||
* a hypothetical one — see {@code SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice} for
|
||||
* a demonstration.
|
||||
*/
|
||||
|
||||
@@ -9,7 +9,7 @@ package dev.relism.flash.h2.frame;
|
||||
* <h3>Lifetime contract</h3>
|
||||
* Valid only until the next {@link Http2FrameReader#readFrame()}/{@code consumeFrame()} call on
|
||||
* the same reader — same "do not retain past the handler" rule the rest of this codebase's
|
||||
* buffer-backed flyweights (`HeaderMap`, `WebSocketFrame`) already document. The payload bytes
|
||||
* buffer-backed flyweights (`Http1HeaderMap`, `WebSocketFrame`) already document. The payload bytes
|
||||
* are also transient: whatever layer needs to retain a DATA frame's payload past this window
|
||||
* must copy it out (R3 — the connection read buffer is shared, single-threaded, and reused).
|
||||
*
|
||||
|
||||
@@ -38,7 +38,7 @@ public final class Http1Limits {
|
||||
* Maximum number of header lines accepted in a single request. Without this bound, a
|
||||
* request with tens of thousands of one-byte headers passes the total header-block size
|
||||
* check ({@code maxHeaderBufferSize}) while still forcing every subsequent
|
||||
* {@code HeaderMap} lookup to scan all of them — turning a small request into quadratic CPU
|
||||
* {@code Http1HeaderMap} lookup to scan all of them — turning a small request into quadratic CPU
|
||||
* work per middleware that reads a header ({@code EX-08}, {@code EX-09}).
|
||||
*/
|
||||
public static final int MAX_HEADER_COUNT = 100;
|
||||
@@ -96,4 +96,74 @@ public final class Http1Limits {
|
||||
* {@link #MAX_HEADER_VALUE_LENGTH}.
|
||||
*/
|
||||
public static final int MAX_TRAILER_COUNT = 50;
|
||||
|
||||
/**
|
||||
* {@code EX-27}: response bodies at or below this size are copied into the same scratch
|
||||
* buffer as the response head (status line + headers) and written with it in a single
|
||||
* {@code OutputStream.write} call; larger bodies are written in a second {@code write} right
|
||||
* after the head, since copying a large body into the head buffer first would cost more
|
||||
* (an extra full-body memcpy) than the syscall it saves. 8 KiB — matches this codebase's
|
||||
* other "one socket-buffer's worth" constants ({@code ConnectionScratch.RELAY_BUFFER_SIZE},
|
||||
* {@code BufferedByteSource.DEFAULT_BUFFER_SIZE}) rather than introducing an uncalibrated
|
||||
* new number; see {@code DECISIONS.md} for the measurement that confirmed this default.
|
||||
*/
|
||||
public static final int INLINE_BODY_THRESHOLD = 8192;
|
||||
|
||||
/**
|
||||
* {@code EX-29}: maximum number of parts ({@code Multipart}) accepted in a single
|
||||
* {@code multipart/form-data} body. Without this bound, a peer can send an unbounded number
|
||||
* of minimal parts — each cheap individually but forcing unbounded growth of the parser's
|
||||
* {@code scanned} list and unbounded per-part header-parsing work, the multipart analogue of
|
||||
* {@link #MAX_CHUNKS_PER_BODY}.
|
||||
*/
|
||||
public static final int MAX_MULTIPART_PARTS = 1_000;
|
||||
|
||||
/**
|
||||
* {@code EX-29}: maximum number of header lines ({@code Content-Disposition},
|
||||
* {@code Content-Type}, …) accepted per multipart part. Real clients send at most two or
|
||||
* three; without a bound a peer could send an effectively unlimited number before the blank
|
||||
* line that ends a part's header block, forcing unbounded {@code HashMap} growth per part.
|
||||
*/
|
||||
public static final int MAX_MULTIPART_PART_HEADER_COUNT = 20;
|
||||
|
||||
/**
|
||||
* {@code EX-29}: maximum length, in bytes, of a single header line within a multipart part's
|
||||
* header block. {@code Multipart.readLine} otherwise has no bound of its own to fall back
|
||||
* on — unlike the top-level HTTP headers (bounded by {@link #MAX_HEADER_VALUE_LENGTH} in
|
||||
* {@code RequestParser}), a line here with no {@code \r\n} would grow its {@code StringBuilder}
|
||||
* without limit for as long as the peer keeps streaming bytes.
|
||||
*/
|
||||
public static final int MAX_MULTIPART_HEADER_LINE_LENGTH = 8_192;
|
||||
|
||||
/**
|
||||
* {@code EX-29}: maximum size, in bytes, of a single multipart part body that {@code Multipart}
|
||||
* buffers eagerly into a {@code byte[]} — text fields (always buffered) and, during a full
|
||||
* {@code parts()}/{@code parts(String)} scan, file bodies too. {@link #MAX_CONTENT_LENGTH}
|
||||
* bounds the whole request body, but at 4 GiB (and effectively unbounded for a chunked body,
|
||||
* see {@link #MAX_CHUNKS_PER_BODY} × {@link #MAX_CHUNK_SIZE}) it does nothing to stop a
|
||||
* single part from exhausting the heap on its own — this is the bound that actually protects
|
||||
* {@code ByteArrayOutputStream}-style eager buffering. Deliberately does not apply to
|
||||
* {@code Part.materialize()} on a streaming file part returned by {@code Multipart.file()} —
|
||||
* that call is documented as an explicit, opt-in heap allocation the caller chooses to pay for.
|
||||
*/
|
||||
public static final long MAX_MULTIPART_BUFFERED_PART_SIZE = 10L * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Maximum combined size, in bytes, of every response header's name + value bytes
|
||||
* ({@code Response.header(...)}'s growable {@code headerRegion}). Unlike every other bound in
|
||||
* this class, this one guards against a bug in <em>Flash's own caller</em> rather than a
|
||||
* hostile peer — a handler that calls {@code header(...)} in an unbounded loop (e.g. echoing
|
||||
* an unbounded collection into headers) would otherwise grow this connection's scratch region
|
||||
* without limit for the rest of its lifetime, since it is never shrunk back down between
|
||||
* requests. Phase 6's zero-alloc DoD names this bound explicitly.
|
||||
*/
|
||||
public static final int MAX_RESPONSE_HEADER_BYTES = 65_536;
|
||||
|
||||
/**
|
||||
* Maximum number of {@code Response.header(...)} calls (any overload) accepted on a single
|
||||
* response. Same rationale as {@link #MAX_RESPONSE_HEADER_BYTES}: bounds the response-side
|
||||
* analogue of {@link #MAX_HEADER_COUNT}, since an unbounded call count grows the header index
|
||||
* arrays even if each individual header is small.
|
||||
*/
|
||||
public static final int MAX_RESPONSE_HEADER_COUNT = 1_000;
|
||||
}
|
||||
|
||||
@@ -44,6 +44,9 @@ public final class Http1Connection implements ConnectionProtocol {
|
||||
Object routeScratch = ctx.router().newScratch();
|
||||
Object wsRouteScratch = ctx.wsRouter().newScratch();
|
||||
|
||||
// EX-21: one Response per connection, repositioned (never reallocated) per request.
|
||||
Response pooledResponse = new Response(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
while (!ctx.stopped().getAsBoolean()) {
|
||||
// EX-07: wait for the next request to begin, bounded by the generous
|
||||
// idle-keep-alive timeout — sitting idle between keep-alive requests is normal, not
|
||||
@@ -106,7 +109,7 @@ public final class Http1Connection implements ConnectionProtocol {
|
||||
in.setDeadline(System.nanoTime() + ctx.configuration().getBodyReadTimeoutMs() * 1_000_000L);
|
||||
|
||||
boolean keepAlive = Http1KeepAlive.isKeepAlive(request);
|
||||
Response response = new Response(200, ContentType.TEXT_PLAIN);
|
||||
Response response = pooledResponse.reset(200, ContentType.TEXT_PLAIN);
|
||||
|
||||
RequestHandler handler = ctx.router().route(request, routeScratch);
|
||||
if (handler == null) handler = ctx.router().getNotFoundHandler();
|
||||
@@ -129,6 +132,14 @@ public final class Http1Connection implements ConnectionProtocol {
|
||||
Http1ResponseWriter.writeResponse(out, response, request.method(), actuallyKeepAlive,
|
||||
ctx.configuration().isSendDate(), ctx.scratch());
|
||||
request.drain();
|
||||
// EX-22/EX-21: these instances are about to be repositioned over the next request (or
|
||||
// dropped, if the connection closes) — poison them in dev mode so any reference the
|
||||
// handler improperly retained (a captured field, an async callback) fails loudly on
|
||||
// its next access instead of silently reading whatever comes next. Only the pooled
|
||||
// Response is recycled: if the handler returned a different instance, that object was
|
||||
// never pooled in the first place and owes nothing back to this connection.
|
||||
request.recycle();
|
||||
if (response == pooledResponse) pooledResponse.recycle();
|
||||
in.clearDeadline();
|
||||
if (!actuallyKeepAlive) break;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package dev.relism.flash.http1;
|
||||
|
||||
import dev.relism.flash.bytes.ByteWriter;
|
||||
import dev.relism.flash.http.DateHeader;
|
||||
import dev.relism.flash.http.Http1Limits;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.http.HttpStatus;
|
||||
import dev.relism.flash.models.Response;
|
||||
@@ -16,9 +18,18 @@ import java.nio.charset.StandardCharsets;
|
||||
* serialization — routing, handler dispatch, and the request loop live in
|
||||
* {@link Http1Connection}.
|
||||
*
|
||||
* <p>Zero-allocation: the decimal encoding of the status code / {@code Content-Length} and the
|
||||
* relay buffer used for streaming bodies both come from the connection's {@link ConnectionScratch}
|
||||
* ({@code EX-06}) instead of a per-call allocation or a {@code ThreadLocal}.
|
||||
* <h3>{@code EX-27}: one bulk write, not ~10 small ones</h3>
|
||||
* The status line, {@code Content-Type}, {@code Date}, every custom header, and
|
||||
* {@code Content-Length}/{@code Connection} are all serialized into
|
||||
* {@link ConnectionScratch#responseHead} (a reused {@link ByteWriter}) before a single
|
||||
* {@code OutputStream.write} call — not one small {@code write} per field, and no
|
||||
* {@link java.io.BufferedOutputStream} coalescing them at the stream layer (this class removes
|
||||
* the need for one entirely on the h1 response path). A body at or below
|
||||
* {@link Http1Limits#INLINE_BODY_THRESHOLD} is copied into the same scratch buffer and goes out
|
||||
* in that same syscall; a larger body is written separately right after, since copying it into
|
||||
* the head buffer first would cost an extra full-body memcpy the syscall it saves does not pay
|
||||
* for. Streaming/chunked bodies write the head, then relay their own bytes as they arrive — by
|
||||
* definition unknown or too large to fold into one buffer up front.
|
||||
*/
|
||||
public final class Http1ResponseWriter {
|
||||
|
||||
@@ -52,62 +63,76 @@ public final class Http1ResponseWriter {
|
||||
boolean noContentAllowed = statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200);
|
||||
boolean suppressBody = noContentAllowed || method == HttpMethod.HEAD;
|
||||
|
||||
out.write(HTTP_1_1);
|
||||
ByteWriter head = scratch.responseHead;
|
||||
head.reset();
|
||||
head.writeBytes(HTTP_1_1);
|
||||
byte[] statusBytes = response.getStatusBytes();
|
||||
if (statusBytes != null) out.write(statusBytes);
|
||||
else writeStatusPhrase(out, statusCode, scratch);
|
||||
out.write(CRLF);
|
||||
if (statusBytes != null) head.writeBytes(statusBytes);
|
||||
else writeStatusPhrase(head, statusCode);
|
||||
head.writeBytes(CRLF);
|
||||
|
||||
// EX-15: a Content-Type of ContentType.NONE (empty byte[]) used to still emit the line
|
||||
// "Content-Type: \r\n" — a header with no value. Skip the line entirely instead.
|
||||
byte[] contentType = response.getContentType();
|
||||
if (contentType != null && contentType.length > 0) {
|
||||
out.write(CONTENT_TYPE);
|
||||
out.write(contentType);
|
||||
out.write(CRLF);
|
||||
head.writeBytes(CONTENT_TYPE);
|
||||
head.writeBytes(contentType);
|
||||
head.writeBytes(CRLF);
|
||||
}
|
||||
|
||||
// EX-16: precomputed once per second by a shared daemon thread — one volatile read,
|
||||
// one write(byte[]), never a per-response format call.
|
||||
if (sendDate) out.write(DateHeader.bytes());
|
||||
// one write into the scratch, never a per-response format call.
|
||||
if (sendDate) head.writeBytes(DateHeader.bytes());
|
||||
|
||||
response.writeHeaders(out);
|
||||
response.writeHeadersInto(head);
|
||||
|
||||
if (response.isStreaming()) {
|
||||
writeStreamingBody(out, response, keepAlive, noContentAllowed, suppressBody, scratch);
|
||||
writeStreamingBody(out, head, response, keepAlive, noContentAllowed, suppressBody, scratch);
|
||||
} else {
|
||||
byte[] body = response.getBody();
|
||||
int len = body != null ? body.length : 0;
|
||||
if (!noContentAllowed) {
|
||||
out.write(CONTENT_LENGTH);
|
||||
writeLong(out, len, scratch);
|
||||
out.write(CRLF);
|
||||
head.writeBytes(CONTENT_LENGTH);
|
||||
head.writeDecimal(len);
|
||||
head.writeBytes(CRLF);
|
||||
}
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
head.writeBytes(CRLF);
|
||||
|
||||
// EX-14: HEAD reports the Content-Length GET would have (above) but never writes
|
||||
// the body itself.
|
||||
if (body != null && !suppressBody) out.write(body);
|
||||
boolean writeBody = body != null && !suppressBody;
|
||||
if (writeBody && len <= Http1Limits.INLINE_BODY_THRESHOLD) {
|
||||
// EX-27: small body folded into the same scratch buffer — head + body leave in
|
||||
// one syscall.
|
||||
head.writeBytes(body);
|
||||
out.write(head.array(), 0, head.length());
|
||||
} else {
|
||||
out.write(head.array(), 0, head.length());
|
||||
if (writeBody) out.write(body);
|
||||
}
|
||||
}
|
||||
out.flush();
|
||||
}
|
||||
|
||||
private static void writeStreamingBody(OutputStream out, Response response, boolean keepAlive,
|
||||
private static void writeStreamingBody(OutputStream out, ByteWriter head, Response response, boolean keepAlive,
|
||||
boolean noContentAllowed, boolean suppressBody,
|
||||
ConnectionScratch scratch) throws IOException {
|
||||
if (!response.isChunked()) {
|
||||
if (!noContentAllowed) {
|
||||
out.write(CONTENT_LENGTH);
|
||||
writeLong(out, response.getStreamLength(), scratch);
|
||||
out.write(CRLF);
|
||||
head.writeBytes(CONTENT_LENGTH);
|
||||
head.writeDecimal(response.getStreamLength());
|
||||
head.writeBytes(CRLF);
|
||||
}
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
head.writeBytes(CRLF);
|
||||
out.write(head.array(), 0, head.length());
|
||||
if (!suppressBody) relay(response.getStream(), out, scratch);
|
||||
} else {
|
||||
out.write(TRANSFER_CHUNKED);
|
||||
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
out.write(CRLF);
|
||||
head.writeBytes(TRANSFER_CHUNKED);
|
||||
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||
head.writeBytes(CRLF);
|
||||
out.write(head.array(), 0, head.length());
|
||||
// A HEAD response still declares the Transfer-Encoding GET would have used (RFC
|
||||
// 9110 §9.3.2) but writes zero body bytes — not even the final-chunk marker, since
|
||||
// there is no chunk framing at all for a message with no body.
|
||||
@@ -125,21 +150,10 @@ public final class Http1ResponseWriter {
|
||||
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
|
||||
}
|
||||
|
||||
private static void writeStatusPhrase(OutputStream out, int statusCode, ConnectionScratch scratch) throws IOException {
|
||||
private static void writeStatusPhrase(ByteWriter head, int statusCode) {
|
||||
byte[] phrase = HttpStatus.bytesForCode(statusCode);
|
||||
if (phrase != null) out.write(phrase);
|
||||
else { writeLong(out, statusCode, scratch); out.write(UNKNOWN_STATUS_SUFFIX); }
|
||||
}
|
||||
|
||||
private static void writeLong(OutputStream out, long value, ConnectionScratch scratch) throws IOException {
|
||||
if (value == 0) { out.write('0'); return; }
|
||||
byte[] buf = scratch.decimalBuffer;
|
||||
int pos = buf.length;
|
||||
boolean neg = value < 0;
|
||||
if (neg) value = -value;
|
||||
do { buf[--pos] = (byte) ('0' + value % 10); value /= 10; } while (value > 0);
|
||||
if (neg) buf[--pos] = '-';
|
||||
out.write(buf, pos, buf.length - pos);
|
||||
if (phrase != null) head.writeBytes(phrase);
|
||||
else { head.writeDecimal(statusCode); head.writeBytes(UNKNOWN_STATUS_SUFFIX); }
|
||||
}
|
||||
|
||||
private static void writeChunked(OutputStream out, InputStream stream, ConnectionScratch scratch) throws IOException {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The read-side contract every header container implements, protocol-neutral: {@link
|
||||
* Http1HeaderMap} backs it with an HTTP/1.1 byte-buffer range today; a Phase 10
|
||||
* {@code Http2HeaderMap} will back it with HPACK-decoded (name, value) pairs. Neither concrete
|
||||
* shape leaks into this interface — there is no {@code reset(byte[], int, int)} here, since that
|
||||
* signature only makes sense for a byte-range-backed implementation.
|
||||
*
|
||||
* <p>{@link RequestLine#getHeaders()} is typed as this interface (not a concrete class), which
|
||||
* is what lets Phase 10 hand a {@link Request} an HPACK-backed header container without touching
|
||||
* a single line of {@code Request}'s own code — the entire point of this phase's refactor (R1:
|
||||
* h1 and h2 are peers behind a shared abstraction, never one forking the other).
|
||||
*
|
||||
* <h3>Lifetime contract</h3>
|
||||
* Every implementation lives on the connection (h1) or the stream (h2), not per-request, and is
|
||||
* repositioned in place between requests — never retain an instance past the handler that
|
||||
* received it. {@code String} values returned by {@link #first}/{@link #all} are safe to retain
|
||||
* (independent heap copies); {@link ByteView}s returned by {@link #view} and passed to {@link
|
||||
* HeaderConsumer#accept} are not — see each implementation's own Javadoc for its exact reuse
|
||||
* window.
|
||||
*/
|
||||
public interface HeaderView {
|
||||
|
||||
/** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */
|
||||
String first(String name);
|
||||
|
||||
/** Returns all values of header {@code name} in declaration order, or an empty list. */
|
||||
List<String> all(String name);
|
||||
|
||||
/** Returns all header values in declaration order. */
|
||||
List<String> all();
|
||||
|
||||
/** Returns a view over the first value of {@code name}, or {@code null} — see the implementation's own reuse-window contract. */
|
||||
ByteView view(String name);
|
||||
|
||||
/** Case-insensitive comparison of the first value of {@code name} against {@code value}. */
|
||||
boolean valueEqualsIgnoreCase(String name, String value);
|
||||
|
||||
/** Whether any header named {@code name} is present. */
|
||||
boolean contains(String name);
|
||||
|
||||
/** Total number of header lines (not distinct names — a repeated header counts once per line). */
|
||||
int count();
|
||||
|
||||
/**
|
||||
* Visits every header in declaration order without allocating a per-header object — see each
|
||||
* implementation's Javadoc for exactly which instances are reused and their validity window.
|
||||
*/
|
||||
void forEach(HeaderConsumer consumer);
|
||||
|
||||
/**
|
||||
* Callback for {@link #forEach}. Implement with a reusable, field-holding instance (reset
|
||||
* before each {@code forEach} call) rather than a capturing lambda if the call site itself
|
||||
* needs to be allocation-free too — a capturing lambda is its own per-call allocation, same
|
||||
* as anywhere else on a hot path.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
interface HeaderConsumer {
|
||||
void accept(ByteView name, ByteView value);
|
||||
}
|
||||
}
|
||||
+35
-43
@@ -12,20 +12,30 @@ import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Lazy, zero-copy header access backed directly by the request parser's byte buffer.
|
||||
* Strings are allocated only when {@link #first} / {@link #all} / {@link #view} is called;
|
||||
* the raw bytes are never copied at parse time.
|
||||
* {@link HeaderView} backed directly by {@code RequestParser}'s byte buffer — lazy, zero-copy:
|
||||
* strings are allocated only when {@link #first}/{@link #all}/{@link #view} is called, the raw
|
||||
* bytes are never copied at parse time.
|
||||
*
|
||||
* <h3>Package placement</h3>
|
||||
* Despite the {@code Http1} prefix, this class lives in {@code dev.relism.flash.models}, not
|
||||
* {@code dev.relism.flash.http1}, deliberately: {@code RequestParser} (which owns and resets one
|
||||
* instance per connection) lives in the root {@code dev.relism.flash} package, and {@code http1}
|
||||
* already depends on root (via {@code Http1Connection}'s use of {@code RequestParser}) — placing
|
||||
* this class in {@code http1} would require root to import back from {@code http1}, the exact
|
||||
* kind of package cycle {@code DEC-19} already found and avoided once in this codebase. See
|
||||
* {@code DECISIONS.md}, {@code DEC-22}, for the full reasoning; this note exists so a future
|
||||
* reader does not "fix" the location back to what the plan's Files list originally suggested.
|
||||
*
|
||||
* <h3>Lifetime contract — read carefully</h3>
|
||||
* One {@code HeaderMap} instance lives on the connection (not per-request). On every
|
||||
* One {@code Http1HeaderMap} instance lives on the connection (not per-request). On every
|
||||
* keep-alive request {@link #reset} is called to slide the window over the new header
|
||||
* section of the <em>same reused buffer</em>. This has two critical implications:
|
||||
*
|
||||
* <ol>
|
||||
* <li><b>Do not retain the {@code HeaderMap} beyond the handler.</b> After the handler
|
||||
* <li><b>Do not retain the {@code Http1HeaderMap} beyond the handler.</b> After the handler
|
||||
* returns, the next request reuses and overwrites the buffer. Any {@code String}
|
||||
* values retrieved via {@link #first}/{@link #all} are safe (they are independent
|
||||
* heap copies); the {@code HeaderMap} object itself is not.</li>
|
||||
* heap copies); the {@code Http1HeaderMap} object itself is not.</li>
|
||||
* <li><b>{@link #view} returns a zero-copy {@link dev.relism.fpr.core.ByteView} slice
|
||||
* into the live buffer, drawn from a small {@link SlicePool} (see {@link #view}'s own
|
||||
* Javadoc for the exact reuse window).</b> Storing this view and reading it after the
|
||||
@@ -41,15 +51,10 @@ import java.util.List;
|
||||
* (never shrunk) to this connection's high-water mark. Every lookup method
|
||||
* ({@link #first}, {@link #all}, {@link #view}, {@link #valueEqualsIgnoreCase}) then walks that
|
||||
* small index instead of rescanning raw bytes: a hash compare (cheap) before ever falling back to
|
||||
* a full case-insensitive name comparison. A realistic middleware chain performs 6–10 lookups per
|
||||
* request (OIDC reads {@code Authorization}/{@code Cookie}, the limiter reads
|
||||
* {@code X-Forwarded-For}, CORS reads {@code Origin}, keep-alive reads {@code Connection}); before
|
||||
* this, each of those rescanned the entire header block from scratch — O(n·m). Now the header
|
||||
* section is scanned once regardless of how many lookups follow — strictly less total work even
|
||||
* for a single lookup, and asymptotically better for the realistic multi-lookup case.
|
||||
* a full case-insensitive name comparison.
|
||||
*/
|
||||
@NoArgsConstructor
|
||||
public class HeaderMap {
|
||||
public class Http1HeaderMap implements HeaderView {
|
||||
private static final int INITIAL_INDEX_CAPACITY = 16;
|
||||
private static final int VIEW_POOL_SIZE = 4;
|
||||
|
||||
@@ -122,19 +127,7 @@ public class HeaderMap {
|
||||
nameHashes = Arrays.copyOf(nameHashes, grown);
|
||||
}
|
||||
|
||||
/**
|
||||
* Visits every header in declaration order without allocating — no per-header {@code
|
||||
* String}/{@link ByteView}/list-entry object, unlike {@link #all()}. {@code name}/{@code
|
||||
* value} are the same two {@link ByteView} instances on every call, repositioned in place;
|
||||
* they are valid only for the duration of that single {@link HeaderConsumer#accept} call —
|
||||
* same "do not retain past the handler" rule as {@link #view}, just per-invocation instead
|
||||
* of per-request. Prefer a non-capturing or field-reusing {@link HeaderConsumer} (see its
|
||||
* javadoc) if the call site itself needs to stay allocation-free too.
|
||||
*
|
||||
* <p>Exists for callers that must handle an open-ended set of header names — e.g. a reverse
|
||||
* proxy forwarding whatever the client sent — where {@link #first}/{@link #all}'s per-name
|
||||
* lookup isn't usable because the set of names isn't known upfront.
|
||||
*/
|
||||
@Override
|
||||
public void forEach(HeaderConsumer consumer) {
|
||||
if (buffer == null) return;
|
||||
if (nameSlice == null) {
|
||||
@@ -150,18 +143,6 @@ public class HeaderMap {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for {@link #forEach}. Implement with a reusable, field-holding instance (reset
|
||||
* before each {@code forEach} call) rather than a capturing lambda if the call site itself
|
||||
* needs to be allocation-free too — a capturing lambda is its own per-call allocation, same
|
||||
* as anywhere else on a hot path (see {@code docs/CODE-STYLE.md} in the Pathway project for
|
||||
* the idiom this mirrors).
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface HeaderConsumer {
|
||||
void accept(ByteView name, ByteView value);
|
||||
}
|
||||
|
||||
/** Mutable zero-copy slice into {@link #buffer} — see {@link #forEach}. */
|
||||
private final class Slice implements ByteView {
|
||||
int start;
|
||||
@@ -171,14 +152,14 @@ public class HeaderMap {
|
||||
@Override public byte byteAt(int i) { return buffer[start + i]; }
|
||||
}
|
||||
|
||||
/** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */
|
||||
@Override
|
||||
public String first(String name) {
|
||||
int i = indexOfHeader(name);
|
||||
if (i < 0) return null;
|
||||
return new String(buffer, valueOffsets[i], valueLengths[i], StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/** Returns all values of header {@code name} in declaration order, or an empty list. */
|
||||
@Override
|
||||
public List<String> all(String name) {
|
||||
if (buffer == null) return List.of();
|
||||
List<String> result = null;
|
||||
@@ -192,7 +173,7 @@ public class HeaderMap {
|
||||
return result != null ? result : List.of();
|
||||
}
|
||||
|
||||
/** Returns all header values in declaration order. */
|
||||
@Override
|
||||
public List<String> all() {
|
||||
if (buffer == null) return List.of();
|
||||
List<String> result = new ArrayList<>(headerCount);
|
||||
@@ -202,24 +183,35 @@ public class HeaderMap {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Case-insensitive comparison of the first value of {@code name} against {@code value}. */
|
||||
@Override
|
||||
public boolean valueEqualsIgnoreCase(String name, String value) {
|
||||
int i = indexOfHeader(name);
|
||||
if (i < 0) return false;
|
||||
return ByteScan.equalsIgnoreCaseAscii(buffer, valueOffsets[i], valueOffsets[i] + valueLengths[i], value);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean contains(String name) {
|
||||
return indexOfHeader(name) >= 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int count() {
|
||||
return headerCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a zero-copy {@link ByteView} over the first value of {@code name}, or {@code null}.
|
||||
*
|
||||
* <h3>{@code EX-05}: pooled, not allocated per call</h3>
|
||||
* The returned view is drawn from a small internal {@link SlicePool} rather than allocated
|
||||
* fresh. It stays valid until either the request ends, or {@link #view} is called
|
||||
* {@value #VIEW_POOL_SIZE} more times on this same {@code HeaderMap} — whichever comes
|
||||
* {@value #VIEW_POOL_SIZE} more times on this same {@code Http1HeaderMap} — whichever comes
|
||||
* first — at which point the ring wraps around and silently repositions the same instance
|
||||
* over different bytes. A handler that needs more than {@value #VIEW_POOL_SIZE} views alive
|
||||
* at once should copy the earlier ones to {@code String}/{@code byte[]} before requesting more.
|
||||
*/
|
||||
@Override
|
||||
public ByteView view(String name) {
|
||||
int i = indexOfHeader(name);
|
||||
if (i < 0) return null;
|
||||
@@ -21,12 +21,12 @@ import java.nio.charset.StandardCharsets;
|
||||
* actual param count, that path uses {@link #reset}, which — unlike the constructor — takes the
|
||||
* live count explicitly rather than inferring it from array length. Both this constructor and
|
||||
* {@link #reset} are {@code public} rather than package-private (matching
|
||||
* {@link HeaderMap#reset}'s own precedent for a reusable buffer-backed object): the router
|
||||
* {@link Http1HeaderMap#reset}'s own precedent for a reusable buffer-backed object): the router
|
||||
* implementation that owns the reusable instance lives in a different package
|
||||
* ({@code dev.relism.flash.routing.routers.fastpathrouter}), and {@code PathParams.inject}'s
|
||||
* own doc explains why this codebase prefers a small public surface here over a cross-package
|
||||
* friend-access workaround. A {@code PathParams} obtained this way has the same "do not retain
|
||||
* past the handler" lifetime contract as {@link HeaderMap}'s buffer-backed views: the next
|
||||
* past the handler" lifetime contract as {@link Http1HeaderMap}'s buffer-backed views: the next
|
||||
* request on the same connection repositions the same arrays.
|
||||
*/
|
||||
public class PathParams {
|
||||
@@ -99,7 +99,7 @@ public class PathParams {
|
||||
/**
|
||||
* Returns a zero-copy view over path param {@code name}, or {@code null}. {@code EX-05}:
|
||||
* drawn from a small internal {@link SlicePool} when {@link #source} is array-backed (always
|
||||
* true for h1 today) — same reuse-window contract as {@link HeaderMap#view}. Falls back to a
|
||||
* true for h1 today) — same reuse-window contract as {@link Http1HeaderMap#view}. Falls back to a
|
||||
* fresh (allocating) view otherwise — never exercised on the real request path.
|
||||
*/
|
||||
ByteView view(String name) {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
/**
|
||||
* A header name/value pair pre-encoded once (typically at boot, as a {@code static final}
|
||||
* constant) and reused across many responses via {@link Response#header(PreEncodedHeader)}.
|
||||
*
|
||||
* <h3>{@code EX-20}: why this exists alongside {@link Response#header(byte[])}</h3>
|
||||
* The older {@code header(byte[])} overload takes an already-fully-rendered h1 field line
|
||||
* (e.g. {@code "X-RateLimit-Limit: 100\r\n"}) — fine for h1, but not valid HPACK: HPACK encodes
|
||||
* a header as a compressed (name, value) pair, never as a literal CRLF-terminated line, so a
|
||||
* pre-rendered h1 line carries no information an HPACK encoder could reuse. {@code
|
||||
* PreEncodedHeader} instead precomputes the {@code name}/{@code value} bytes <em>separately</em>
|
||||
* (still once, still at boot) so either protocol's writer can render them in its own format —
|
||||
* {@link Response#header(byte[])} is kept, working, for h1-only callers, but is documented as
|
||||
* ignored on a future h2 response path (there is no way to recover structured name/value data
|
||||
* from an opaque pre-rendered line); prefer this class for any header a handler wants to send on
|
||||
* both protocols.
|
||||
*
|
||||
* <p>The HPACK-encoded rendering itself is Phase 9 scope (no HPACK encoder exists yet) — this
|
||||
* class stores the raw {@code name}/{@code value} bytes now, which is everything a future HPACK
|
||||
* encoder needs to produce its own rendering from; it does not yet expose a precomputed HPACK
|
||||
* byte form, since building one before HPACK exists would be speculative, untested API surface.
|
||||
*/
|
||||
public final class PreEncodedHeader {
|
||||
private final byte[] nameBytes;
|
||||
private final byte[] valueBytes;
|
||||
|
||||
public PreEncodedHeader(String name, String value) {
|
||||
this.nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||
this.valueBytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
/** The header name's ASCII bytes, case as given to the constructor. Never copy-on-read — treat as immutable. */
|
||||
byte[] nameBytes() {
|
||||
return nameBytes;
|
||||
}
|
||||
|
||||
/** The header value's ASCII bytes. Never copy-on-read — treat as immutable. */
|
||||
byte[] valueBytes() {
|
||||
return valueBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return new String(nameBytes, StandardCharsets.US_ASCII) + ": " + new String(valueBytes, StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof PreEncodedHeader other)) return false;
|
||||
return Arrays.equals(nameBytes, other.nameBytes) && Arrays.equals(valueBytes, other.valueBytes);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return 31 * Arrays.hashCode(nameBytes) + Arrays.hashCode(valueBytes);
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,7 @@ public class QueryParams {
|
||||
* Returns a view over the first raw (not percent-decoded) value of {@code name}, or
|
||||
* {@code null}. {@code EX-05}: drawn from a small internal {@link SlicePool} when
|
||||
* {@link #raw} is array-backed (always true for h1 today) instead of allocated per call —
|
||||
* same reuse-window contract as {@link HeaderMap#view}: valid until either the request ends
|
||||
* same reuse-window contract as {@link Http1HeaderMap#view}: valid until either the request ends
|
||||
* or {@link #view} is called {@value #VIEW_POOL_SIZE} more times on this instance, whichever
|
||||
* comes first. Falls back to a fresh (allocating) view when {@link #raw} is not array-backed
|
||||
* — never exercised on the real request path (see {@link ArrayBackedByteView}'s Javadoc).
|
||||
|
||||
@@ -1,27 +1,23 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.Flash;
|
||||
import dev.relism.flash.RequestParser;
|
||||
import dev.relism.flash.bytes.ArrayBackedByteView;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import lombok.ToString;
|
||||
import lombok.Value;
|
||||
import lombok.experimental.NonFinal;
|
||||
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.SSLSocket;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Immutable view of an incoming HTTP/1.1 request. Constructed by {@link RequestParser}
|
||||
* and passed directly to route handlers; never modified after creation (path/query params are
|
||||
* injected once by the router before the handler runs).
|
||||
* View of an incoming HTTP/1.1 request. Constructed once per connection by {@link RequestParser}
|
||||
* and repositioned (never reallocated) for every request on that connection — never modified by
|
||||
* user code after creation (path/query params are injected once by the router before the
|
||||
* handler runs).
|
||||
*
|
||||
* <pre>{@code
|
||||
* server.get("/users/{id}", (req, res) -> {
|
||||
@@ -32,62 +28,103 @@ import java.util.List;
|
||||
* InputStream in = req.body().stream(); // zero-copy stream
|
||||
* });
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>{@code EX-22}: pooled, not allocated per request</h3>
|
||||
* A {@code Request} instance is owned by its connection (HTTP/1.1) or its stream (HTTP/2) and is
|
||||
* recycled after the handler returns. <b>Do not retain it</b> — the same instance is repositioned
|
||||
* over the next request's data as soon as this one's handler returns. {@code equals}/
|
||||
* {@code hashCode} are the inherited identity-based {@link Object} versions and are meaningless
|
||||
* across requests (compare two different {@code Request}s from the same connection and they may
|
||||
* be {@code ==} to each other despite describing entirely different requests, at different
|
||||
* points in time). {@code String} values returned by {@link #path()}, {@link #header(String)},
|
||||
* {@link #param(String)}, {@link #query(String)} are independent heap copies and are always safe
|
||||
* to retain past the handler.
|
||||
*
|
||||
* <h3>Dev-mode use-after-recycle guard</h3>
|
||||
* When {@link Flash#DEV} is {@code true}, every accessor checks that this instance is still the
|
||||
* one currently being handled; a call after the handler has already returned (e.g. from a
|
||||
* captured reference in an async callback, a {@link java.util.concurrent.CompletableFuture}
|
||||
* continuation, or a background thread) throws {@link IllegalStateException} immediately,
|
||||
* loudly, and at the exact call site that misused it — instead of silently reading whatever the
|
||||
* next (or a completely different) request happened to reset this instance to. In production
|
||||
* this check is a single {@code boolean} field read gated behind a {@code static final} flag the
|
||||
* JIT treats as a trusted constant once the class is initialized — see {@code DECISIONS.md} for
|
||||
* the measured cost.
|
||||
*/
|
||||
@Value
|
||||
@ToString
|
||||
public class Request {
|
||||
|
||||
@Getter(lombok.AccessLevel.NONE)
|
||||
@EqualsAndHashCode.Exclude
|
||||
@ToString.Exclude
|
||||
RequestBody body;
|
||||
private RequestBody body;
|
||||
|
||||
/** Internal: the parsed request line (method, path, query, protocol, headers). */
|
||||
RequestLine requestLine;
|
||||
private RequestLine requestLine;
|
||||
|
||||
@NonFinal PathParams pathParams;
|
||||
@NonFinal QueryParams queryParams;
|
||||
@NonFinal String cachedPath;
|
||||
private PathParams pathParams;
|
||||
private QueryParams queryParams;
|
||||
private String cachedPath;
|
||||
|
||||
private InetSocketAddress remoteAddress;
|
||||
private SSLSocket sslSocket;
|
||||
|
||||
// EX-22 dev-mode poisoning guard: true from reset() until recycle() marks this instance
|
||||
// unsafe to use further. Only consulted when poisoningEnabled is true (see checkActive()).
|
||||
private boolean active;
|
||||
|
||||
// Defaults to the real Flash.DEV value. Flash.DEV is a static final boolean fixed once at
|
||||
// JVM startup (from a system property), so no individual test can toggle it — this field
|
||||
// exists solely so RequestRecycleGuardTest can exercise the dev-mode branch without a
|
||||
// fragile reflective override of a `static final` field. Package-private: only this
|
||||
// package's own tests reach for it; production code never touches it.
|
||||
private static volatile boolean poisoningEnabled = Flash.DEV;
|
||||
|
||||
/** Test-only override of the dev-mode poisoning check — see the field's own comment. */
|
||||
static void setPoisoningEnabledForTesting(boolean enabled) {
|
||||
poisoningEnabled = enabled;
|
||||
}
|
||||
|
||||
/** Pooled instance, populated later via {@link #reset}. One per connection — see {@link RequestParser}. */
|
||||
public Request() {
|
||||
}
|
||||
|
||||
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}, {@code isSecure()} is {@code false}. */
|
||||
public Request(RequestLine requestLine, byte[] body) {
|
||||
reset(requestLine, RequestBody.of(body), null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remote socket address of the connected client. Set once at connection time from
|
||||
* {@link java.net.Socket#getRemoteSocketAddress()} : the {@link InetSocketAddress}
|
||||
* object already exists in the JDK and is passed by reference: zero allocation,
|
||||
* zero copy. {@code null} only in test-constructed requests.
|
||||
*
|
||||
* <p>Use {@link #remoteAddress()} to access it. String conversion
|
||||
* ({@code .getAddress().getHostAddress()}) is deferred to the caller, lazy and
|
||||
* only paid when actually needed.
|
||||
* Repositions this instance over a new request. Package-private: only {@link RequestParser}
|
||||
* (same package) calls this — user code never constructs or resets a {@code Request}
|
||||
* directly outside the test constructor above.
|
||||
*/
|
||||
@Getter(lombok.AccessLevel.NONE)
|
||||
@EqualsAndHashCode.Exclude
|
||||
@ToString.Exclude
|
||||
InetSocketAddress remoteAddress;
|
||||
|
||||
/**
|
||||
* The accepted socket for this connection, or {@code null} if plain HTTP — set once per
|
||||
* connection by {@link RequestParser}, same lifetime and reference-only cost as
|
||||
* {@link #remoteAddress}. Every request on the same keep-alive connection shares the
|
||||
* identical instance.
|
||||
*
|
||||
* <p>Never exposed directly: {@link #isSecure()} and {@link #sslSession()} are the public
|
||||
* surface. {@link javax.net.ssl.SSLSocket#getSession()} is deferred to {@link #sslSession()}
|
||||
* rather than called here — by the time a handler can call it, the handshake this connection
|
||||
* needed to reach the handler has already completed, so it is a cached-field read, never a
|
||||
* forced handshake.
|
||||
*/
|
||||
@Getter(lombok.AccessLevel.NONE)
|
||||
@EqualsAndHashCode.Exclude
|
||||
@ToString.Exclude
|
||||
SSLSocket sslSocket;
|
||||
|
||||
private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
|
||||
void reset(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
|
||||
this.requestLine = requestLine;
|
||||
this.body = body;
|
||||
this.pathParams = null;
|
||||
this.queryParams = null;
|
||||
this.cachedPath = null;
|
||||
this.remoteAddress = remoteAddress;
|
||||
this.sslSocket = sslSocket;
|
||||
this.active = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this instance unsafe for further use. Called by the connection driver (e.g.
|
||||
* {@code Http1Connection}) once the handler (and any automatic post-handler work, e.g.
|
||||
* {@link #drain()}) has finished with it, before the connection loop reuses it for the next
|
||||
* request — {@code public} because the connection driver lives in a different package
|
||||
* (matching {@link RequestLine#reset}'s own precedent), not because user code should ever
|
||||
* call it. A no-op in production beyond the field write — see the class Javadoc's dev-mode
|
||||
* guard section.
|
||||
*/
|
||||
public void recycle() {
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
private void checkActive() {
|
||||
if (poisoningEnabled && !active) {
|
||||
throw new IllegalStateException(
|
||||
"Request used after the handler returned — do not retain a Request past the "
|
||||
+ "handler; copy any String values you need instead");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -97,31 +134,30 @@ public class Request {
|
||||
*/
|
||||
void setPathParams(PathParams p) { this.pathParams = p; }
|
||||
|
||||
/** Test / manual constructor — {@code remoteAddress()} returns {@code null}, {@code isSecure()} is {@code false}. */
|
||||
public Request(RequestLine requestLine, byte[] body) {
|
||||
this(requestLine, RequestBody.of(body), null, null);
|
||||
}
|
||||
|
||||
public static Request forParsed(RequestLine requestLine, InputStream stream,
|
||||
long contentLength, byte[] headerBuf,
|
||||
int bodyStart, int preBufLen,
|
||||
/**
|
||||
* Repositions {@code pooled} over a freshly-parsed request. {@code body} is already fully
|
||||
* configured by the caller ({@code RequestParser}, which owns and resets its own pooled
|
||||
* {@link RequestBody} for the fixed-length/chunked/empty cases — see {@code EX-22}) — this
|
||||
* method's only job is wiring it, {@code requestLine}, and the connection identity fields
|
||||
* into {@code pooled}.
|
||||
*/
|
||||
public static Request forParsed(Request pooled, RequestLine requestLine, RequestBody body,
|
||||
InetSocketAddress remoteAddress, SSLSocket sslSocket) {
|
||||
RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen)
|
||||
: contentLength == 0 ? RequestBody.empty()
|
||||
: /* chunked */ new RequestBody(stream, -1L, null, 0, 0);
|
||||
return new Request(requestLine, rb, remoteAddress, sslSocket);
|
||||
pooled.reset(requestLine, body, remoteAddress, sslSocket);
|
||||
return pooled;
|
||||
}
|
||||
|
||||
// ── Request line ──────────────────────────────────────────────────────────
|
||||
|
||||
/** HTTP method ({@code GET}, {@code POST}, …). */
|
||||
public HttpMethod method() { return requestLine.getMethod(); }
|
||||
public HttpMethod method() { checkActive(); return requestLine.getMethod(); }
|
||||
|
||||
/**
|
||||
* Request path decoded as UTF-8. Includes a leading slash; never includes the query string.
|
||||
* Example: a request for {@code /users/42?page=1} returns {@code "/users/42"}.
|
||||
*/
|
||||
public String path() {
|
||||
checkActive();
|
||||
if (cachedPath != null) return cachedPath;
|
||||
ByteView v = requestLine.getPath();
|
||||
// EX-25: one allocation via a direct String(array, offset, length) construction when the
|
||||
@@ -141,20 +177,20 @@ public class Request {
|
||||
* Returns the first value of header {@code name}, or {@code null} if absent.
|
||||
* Lookup is case-insensitive ({@code "content-type"} and {@code "Content-Type"} are equivalent).
|
||||
*/
|
||||
public String header(String name) { return requestLine.getHeaders().first(name); }
|
||||
public String header(String name) { checkActive(); return requestLine.getHeaders().first(name); }
|
||||
|
||||
/**
|
||||
* Returns all values of header {@code name} in declaration order.
|
||||
* Useful for headers that appear multiple times (e.g. {@code Accept}, {@code Cookie}).
|
||||
* Lookup is case-insensitive. Returns an empty list if the header is absent.
|
||||
*/
|
||||
public List<String> headers(String name) { return requestLine.getHeaders().all(name); }
|
||||
public List<String> headers(String name) { checkActive(); return requestLine.getHeaders().all(name); }
|
||||
|
||||
/**
|
||||
* Returns all header values in declaration order, one entry per header line.
|
||||
* Useful for debugging; for targeted access prefer {@link #header(String)}.
|
||||
*/
|
||||
public List<String> headers() { return requestLine.getHeaders().all(); }
|
||||
public List<String> headers() { checkActive(); return requestLine.getHeaders().all(); }
|
||||
|
||||
// ── Path parameters ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -164,7 +200,7 @@ public class Request {
|
||||
* injected by the router before the handler runs. Returns {@code null} if this
|
||||
* route has no such parameter or the route is not parametric.
|
||||
*/
|
||||
public String param(String name) { return pathParams != null ? pathParams.get(name) : null; }
|
||||
public String param(String name) { checkActive(); return pathParams != null ? pathParams.get(name) : null; }
|
||||
|
||||
// ── Query parameters ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -173,14 +209,14 @@ public class Request {
|
||||
* The query string is parsed lazily on the first call and cached for the request lifetime.
|
||||
* For {@code ?a=1&a=2}, returns {@code "1"}.
|
||||
*/
|
||||
public String query(String name) { return resolveQueryParams().get(name); }
|
||||
public String query(String name) { checkActive(); return resolveQueryParams().get(name); }
|
||||
|
||||
/**
|
||||
* Returns all query parameters named {@code name} in declaration order.
|
||||
* For {@code ?tag=a&tag=b}, returns {@code ["a", "b"]}.
|
||||
* Returns an empty list if the parameter is absent.
|
||||
*/
|
||||
public List<String> queries(String name) { return resolveQueryParams().getAll(name); }
|
||||
public List<String> queries(String name) { checkActive(); return resolveQueryParams().getAll(name); }
|
||||
|
||||
// ── Remote address ────────────────────────────────────────────────────────
|
||||
|
||||
@@ -196,12 +232,12 @@ public class Request {
|
||||
* if (addr != null) String ip = addr.getAddress().getHostAddress();
|
||||
* }</pre>
|
||||
*/
|
||||
public InetSocketAddress remoteAddress() { return remoteAddress; }
|
||||
public InetSocketAddress remoteAddress() { checkActive(); return remoteAddress; }
|
||||
|
||||
// ── TLS ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Whether this request arrived over TLS (HTTPS). */
|
||||
public boolean isSecure() { return sslSocket != null; }
|
||||
public boolean isSecure() { checkActive(); return sslSocket != null; }
|
||||
|
||||
/**
|
||||
* Returns the TLS session for this connection, or {@code null} for plain HTTP.
|
||||
@@ -211,7 +247,7 @@ public class Request {
|
||||
* diagnostics. {@code null} rather than throwing when {@link #isSecure()} is {@code false} —
|
||||
* check that first, or just null-check the result.
|
||||
*/
|
||||
public SSLSession sslSession() { return sslSocket != null ? sslSocket.getSession() : null; }
|
||||
public SSLSession sslSession() { checkActive(); return sslSocket != null ? sslSocket.getSession() : null; }
|
||||
|
||||
// ── Body ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -220,15 +256,22 @@ public class Request {
|
||||
* the full body or {@link RequestBody#stream()} for zero-copy streaming access.
|
||||
* The two modes are mutually exclusive per request.
|
||||
*/
|
||||
public RequestBody body() { return body; }
|
||||
public RequestBody body() { checkActive(); return body; }
|
||||
|
||||
/** Discards unread body bytes; called by the server after each request on keep-alive connections. */
|
||||
public void drain() { body.drain(); }
|
||||
|
||||
// ── Internal ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Internal: the parsed request line (method, path, query, protocol, headers). */
|
||||
public RequestLine getRequestLine() { checkActive(); return requestLine; }
|
||||
|
||||
/** Internal: path parameters injected by the router, or {@code null} if none matched. */
|
||||
public PathParams getPathParams() { checkActive(); return pathParams; }
|
||||
|
||||
/** Internal: case-insensitive header value comparison used by the server keep-alive logic. */
|
||||
public boolean headerEquals(String name, String value) {
|
||||
checkActive();
|
||||
return requestLine.getHeaders().valueEqualsIgnoreCase(name, value);
|
||||
}
|
||||
|
||||
@@ -239,4 +282,10 @@ public class Request {
|
||||
}
|
||||
return queryParams;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Request(method=" + (requestLine != null ? requestLine.getMethod() : null)
|
||||
+ ", path=" + (requestLine != null ? requestLine.getPath() : null) + ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,9 +10,9 @@ import java.io.*;
|
||||
* Safe to call multiple times; the second call returns the cached array. Throws for
|
||||
* bodies larger than 2 GB.</li>
|
||||
* <li>{@link #stream()} — returns a bounded {@link InputStream} without upfront allocation.
|
||||
* For fixed-length bodies this is a view into the already-buffered header bytes stitched
|
||||
* to the socket; for chunked bodies it is the raw {@link dev.relism.ChunkedInputStream}
|
||||
* that de-chunks on the fly.</li>
|
||||
* For fixed-length bodies this is a reused, repositioned view (see {@code EX-23} below)
|
||||
* into the already-buffered header bytes stitched to the socket; for chunked bodies it is
|
||||
* the raw {@link dev.relism.ChunkedInputStream} that de-chunks on the fly.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Mutual exclusivity:</b> calling both {@code bytes()} and {@code stream()} on the same
|
||||
@@ -20,37 +20,68 @@ import java.io.*;
|
||||
*
|
||||
* <p><b>Keep-alive:</b> unread body bytes are discarded by {@link Request#drain()} after the
|
||||
* handler returns so the socket is correctly positioned for the next pipelined request.
|
||||
*
|
||||
* <h3>{@code EX-22}: pooled, not allocated per request</h3>
|
||||
* One instance per connection (owned by {@code RequestParser}, repositioned via {@link #reset}
|
||||
* for every request), the same treatment {@link Request}/{@link RequestLine} get. The {@link
|
||||
* #of(byte[])} factory below remains for test/manual construction and returns a freestanding,
|
||||
* unpooled instance — exactly like {@link Request}'s own manual constructor.
|
||||
*
|
||||
* <h3>{@code EX-23}/{@code EX-24}: the reusable bounded stream and drain buffer</h3>
|
||||
* {@link #stream()} used to allocate a {@link SequenceInputStream}, a {@link ByteArrayInputStream}
|
||||
* and an anonymous bounded {@link InputStream} on every call. It now hands out one persistent
|
||||
* {@link BoundedBufferedInputStream}, repositioned per request instead of reallocated.
|
||||
* {@link #drain()}'s chunked-body path used to call {@code InputStream.transferTo}, which
|
||||
* allocates a fresh 8 KiB {@code byte[]} internally on every call (the JDK default
|
||||
* implementation); it now drains through a lazily-created, persistent buffer instead.
|
||||
*/
|
||||
public final class RequestBody {
|
||||
private static final byte[] EMPTY_BYTES = new byte[0];
|
||||
|
||||
private final InputStream socket;
|
||||
private final long contentLength;
|
||||
private final byte[] preBuf;
|
||||
private final int preBufOff;
|
||||
private final int preBufLen;
|
||||
public class RequestBody {
|
||||
private InputStream socket;
|
||||
private long contentLength;
|
||||
private byte[] preBuf;
|
||||
private int preBufOff;
|
||||
private int preBufLen;
|
||||
|
||||
private byte[] resolved;
|
||||
private long socketConsumed;
|
||||
|
||||
// EX-23: created once, repositioned per request via reset()'s call into boundedStream.reset(...).
|
||||
private BoundedBufferedInputStream boundedStream;
|
||||
|
||||
// EX-24: created lazily on first chunked-body drain(), then reused for the life of the connection.
|
||||
private byte[] drainBuffer;
|
||||
|
||||
/** Pooled instance, populated later via {@link #reset}. One per connection — see {@code RequestParser}. */
|
||||
public RequestBody() {
|
||||
}
|
||||
|
||||
RequestBody(InputStream socket, long contentLength, byte[] preBuf, int preBufOff, int preBufLen) {
|
||||
reset(socket, contentLength, preBuf, preBufOff, preBufLen);
|
||||
}
|
||||
|
||||
/** Pre-resolved body: test payloads and empty body — skips all I/O. Always a freestanding, unpooled instance. */
|
||||
private RequestBody(byte[] preResolved) {
|
||||
reset(null, preResolved.length, null, 0, 0);
|
||||
this.resolved = preResolved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repositions this instance over a new request. {@code public} because {@code RequestParser}
|
||||
* (a different package) owns and resets its own pooled instance directly — matching
|
||||
* {@link RequestLine#reset}'s precedent — not because user code should ever call it.
|
||||
*/
|
||||
public void reset(InputStream socket, long contentLength, byte[] preBuf, int preBufOff, int preBufLen) {
|
||||
this.socket = socket;
|
||||
this.contentLength = contentLength;
|
||||
this.preBuf = preBuf;
|
||||
this.preBufOff = preBufOff;
|
||||
this.preBufLen = preBufLen;
|
||||
this.resolved = null;
|
||||
this.socketConsumed = 0;
|
||||
}
|
||||
|
||||
/** Pre-resolved body: test payloads and empty body — skips all I/O. */
|
||||
private RequestBody(byte[] preResolved) {
|
||||
this(null, preResolved.length, null, 0, 0);
|
||||
this.resolved = preResolved;
|
||||
}
|
||||
|
||||
private static final RequestBody EMPTY = new RequestBody(EMPTY_BYTES);
|
||||
|
||||
static RequestBody of(byte[] bytes) { return new RequestBody(bytes); }
|
||||
static RequestBody empty() { return EMPTY; }
|
||||
static RequestBody empty() { return new RequestBody(new byte[0]); }
|
||||
|
||||
/** {@code true} if the body has zero bytes ({@code Content-Length: 0} or no body). */
|
||||
public boolean isEmpty() { return contentLength == 0; }
|
||||
@@ -96,54 +127,92 @@ public final class RequestBody {
|
||||
/**
|
||||
* Returns a bounded {@link InputStream} over the body without upfront allocation.
|
||||
*
|
||||
* <p>For fixed-length bodies: a {@link SequenceInputStream} of any already-buffered header
|
||||
* bytes followed by a bounded view of the socket stream — zero heap beyond those small
|
||||
* pre-buffered bytes.
|
||||
* <p>For fixed-length bodies: a reused {@link BoundedBufferedInputStream} (see the class
|
||||
* Javadoc, {@code EX-23}) serving any already-buffered header bytes followed by a bounded
|
||||
* view of the socket stream — zero allocation on a warm connection.
|
||||
*
|
||||
* <p>For chunked bodies: the raw {@link dev.relism.ChunkedInputStream} that de-chunks on
|
||||
* the fly; EOF signals the end of the logical body and leaves the socket positioned for
|
||||
* the next keep-alive request.
|
||||
*
|
||||
* <p>If {@link #bytes()} was called first, returns a fresh {@link java.io.ByteArrayInputStream}
|
||||
* over the cached array.
|
||||
* over the cached array — a rare dual-access pattern, not the hot path {@code EX-23} targets.
|
||||
*/
|
||||
public InputStream stream() {
|
||||
if (resolved != null) return new ByteArrayInputStream(resolved);
|
||||
if (contentLength < 0) return socket; // ChunkedInputStream — EOF signals end of body
|
||||
if (boundedStream == null) boundedStream = new BoundedBufferedInputStream();
|
||||
int fromBuf = (int) Math.min(preBufLen, contentLength);
|
||||
long fromSocket = contentLength - fromBuf;
|
||||
InputStream bufPart = new ByteArrayInputStream(preBuf, preBufOff, fromBuf);
|
||||
return fromSocket == 0 ? bufPart : new SequenceInputStream(bufPart, bounded(socket, fromSocket));
|
||||
boundedStream.reset(preBuf, preBufOff, fromBuf, fromSocket);
|
||||
return boundedStream;
|
||||
}
|
||||
|
||||
/** Discards unread body bytes to reposition the socket for the next keep-alive request. */
|
||||
void drain() {
|
||||
if (isEmpty() || resolved != null) return;
|
||||
if (contentLength < 0) {
|
||||
try { socket.transferTo(OutputStream.nullOutputStream()); } catch (IOException ignored) {}
|
||||
// EX-24: InputStream.transferTo's default implementation allocates a fresh 8 KiB
|
||||
// byte[] on every call — replaced with a buffer this instance allocates once
|
||||
// (lazily, only if a chunked body is ever actually drained) and reuses thereafter.
|
||||
if (drainBuffer == null) drainBuffer = new byte[8192];
|
||||
try {
|
||||
while (socket.read(drainBuffer) > 0) { /* discard */ }
|
||||
} catch (IOException ignored) {
|
||||
}
|
||||
return;
|
||||
}
|
||||
long remaining = (contentLength - preBufLen) - socketConsumed;
|
||||
if (remaining > 0) try { socket.skipNBytes(remaining); } catch (IOException ignored) {}
|
||||
}
|
||||
|
||||
private InputStream bounded(InputStream src, long limit) {
|
||||
return new InputStream() {
|
||||
private long left = limit;
|
||||
/**
|
||||
* {@code EX-23}: a reused, repositionable {@link InputStream} that serves bytes first from a
|
||||
* caller-owned pre-buffered array, then from the socket, bounded overall to a fixed length —
|
||||
* replacing the {@code SequenceInputStream}+{@code ByteArrayInputStream}+anonymous-bounded-
|
||||
* stream trio that used to be allocated fresh on every {@link #stream()} call. One instance
|
||||
* lives on the owning {@link RequestBody} for the whole connection; {@link #reset} repositions
|
||||
* it for each new request.
|
||||
*/
|
||||
private final class BoundedBufferedInputStream extends InputStream {
|
||||
private byte[] preBuf;
|
||||
private int preBufPos;
|
||||
private int preBufRemaining;
|
||||
private long socketRemaining;
|
||||
|
||||
@Override public int read() throws IOException {
|
||||
if (left == 0) return -1;
|
||||
int b = src.read();
|
||||
if (b >= 0) { left--; socketConsumed++; }
|
||||
return b;
|
||||
void reset(byte[] preBuf, int preBufOff, int preBufLen, long socketRemaining) {
|
||||
this.preBuf = preBuf;
|
||||
this.preBufPos = preBufOff;
|
||||
this.preBufRemaining = preBufLen;
|
||||
this.socketRemaining = socketRemaining;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read() throws IOException {
|
||||
if (preBufRemaining > 0) {
|
||||
preBufRemaining--;
|
||||
return preBuf[preBufPos++] & 0xFF;
|
||||
}
|
||||
if (socketRemaining == 0) return -1;
|
||||
int b = socket.read();
|
||||
if (b >= 0) { socketRemaining--; socketConsumed++; }
|
||||
return b;
|
||||
}
|
||||
|
||||
@Override public int read(byte[] buf, int off, int len) throws IOException {
|
||||
if (left == 0) return -1;
|
||||
int n = src.read(buf, off, (int) Math.min(len, left));
|
||||
if (n > 0) { left -= n; socketConsumed += n; }
|
||||
@Override
|
||||
public int read(byte[] dst, int off, int len) throws IOException {
|
||||
if (len == 0) return 0;
|
||||
if (preBufRemaining > 0) {
|
||||
int n = Math.min(len, preBufRemaining);
|
||||
System.arraycopy(preBuf, preBufPos, dst, off, n);
|
||||
preBufPos += n;
|
||||
preBufRemaining -= n;
|
||||
return n;
|
||||
}
|
||||
};
|
||||
if (socketRemaining == 0) return -1;
|
||||
int n = socket.read(dst, off, (int) Math.min(len, socketRemaining));
|
||||
if (n > 0) { socketRemaining -= n; socketConsumed += n; }
|
||||
return n;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,65 @@ package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import lombok.ToString;
|
||||
import lombok.Value;
|
||||
|
||||
@ToString
|
||||
@Value
|
||||
/**
|
||||
* The parsed request line plus headers: method, path, optional query, optional protocol token,
|
||||
* and the header container. Internal — reached via {@link Request#getRequestLine()}, not
|
||||
* user-facing API.
|
||||
*
|
||||
* <h3>Pooled, like {@link Request} ({@code EX-22})</h3>
|
||||
* One instance per connection, repositioned via {@link #reset} for every request rather than
|
||||
* reallocated — {@code RequestParser} owns it exactly the way it owns {@link Http1HeaderMap}.
|
||||
* {@link #reset} is {@code public} rather than package-private — matching
|
||||
* {@link Http1HeaderMap#reset}'s and {@link PathParams#reset}'s own precedent — because
|
||||
* {@code RequestParser} (the owner and sole caller) lives in a different package
|
||||
* ({@code dev.relism.flash}, not {@code dev.relism.flash.models}). The public constructor below
|
||||
* remains for test/manual construction and simply delegates to {@link #reset}.
|
||||
*
|
||||
* <h3>{@code protocol} is optional</h3>
|
||||
* HTTP/1.1 always has a protocol token on the wire ({@code "HTTP/1.1"}); HTTP/2 has no equivalent
|
||||
* — a stream's version is implicit in which connection it belongs to. {@link #getProtocol()} may
|
||||
* be {@code null} for a header container built by a future non-h1 implementation; h1 always
|
||||
* supplies a non-null value today.
|
||||
*/
|
||||
public class RequestLine {
|
||||
HttpMethod method;
|
||||
ByteView path;
|
||||
/** Raw query string bytes (after {@code ?}), {@code null} if the URI has no query string. */
|
||||
ByteView query;
|
||||
ByteView protocol;
|
||||
HeaderMap headers;
|
||||
private HttpMethod method;
|
||||
private ByteView path;
|
||||
private ByteView query;
|
||||
private ByteView protocol;
|
||||
private HeaderView headers;
|
||||
|
||||
/** Pooled instance, populated later via {@link #reset}. */
|
||||
public RequestLine() {
|
||||
}
|
||||
|
||||
/** Test / manual construction — delegates to {@link #reset}. */
|
||||
public RequestLine(HttpMethod method, ByteView path, ByteView query, ByteView protocol, HeaderView headers) {
|
||||
reset(method, path, query, protocol, headers);
|
||||
}
|
||||
|
||||
/** Repositions this instance over a new request. See the class Javadoc for why this is {@code public}. */
|
||||
public void reset(HttpMethod method, ByteView path, ByteView query, ByteView protocol, HeaderView headers) {
|
||||
this.method = method;
|
||||
this.path = path;
|
||||
this.query = query;
|
||||
this.protocol = protocol;
|
||||
this.headers = headers;
|
||||
}
|
||||
|
||||
public HttpMethod getMethod() { return method; }
|
||||
public ByteView getPath() { return path; }
|
||||
|
||||
/** Raw query string bytes (after {@code ?}), or {@code null} if the URI has no query string. */
|
||||
public ByteView getQuery() { return query; }
|
||||
|
||||
/** The wire protocol token (e.g. {@code "HTTP/1.1"}), or {@code null} — see the class Javadoc. */
|
||||
public ByteView getProtocol() { return protocol; }
|
||||
|
||||
public HeaderView getHeaders() { return headers; }
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "RequestLine(method=" + method + ", path=" + path + ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.Flash;
|
||||
import dev.relism.flash.bytes.ByteWriter;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.http.Http1Limits;
|
||||
import dev.relism.flash.http.HttpStatus;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
@@ -26,20 +27,57 @@ import java.util.List;
|
||||
* // unknown-length stream → Transfer-Encoding: chunked
|
||||
* return new Response(200, ContentType.TEXT_PLAIN).chunked(source);
|
||||
* }</pre>
|
||||
*
|
||||
* <h3>{@code EX-21}: pooled, not allocated per request</h3>
|
||||
* The connection driver (e.g. {@code Http1Connection}) owns one {@code Response} instance per
|
||||
* connection, reset before every handler call rather than reallocated — the same treatment
|
||||
* {@link Request} gets (see its Javadoc for the full pooling/dev-mode-guard rationale, which
|
||||
* applies identically here). <b>A handler that returns a different {@code Response} instance</b>
|
||||
* (e.g. {@code return new Response(404, "Not Found", ContentType.TEXT_PLAIN);}) is fully
|
||||
* supported — that instance is a normal, unpooled, freshly-constructed object like any
|
||||
* public-constructor {@code Response} always was; only the connection driver's own default
|
||||
* instance is pooled and poisoned after use.
|
||||
*/
|
||||
@Getter
|
||||
@ToString
|
||||
public class Response {
|
||||
@Setter private int statusCode;
|
||||
private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int)
|
||||
private byte[] body;
|
||||
@ToString.Exclude
|
||||
private InputStream stream;
|
||||
private long streamLength; // meaningful only when isStreaming() && !chunked
|
||||
private boolean chunked;
|
||||
private byte[] contentType;
|
||||
@Getter(lombok.AccessLevel.NONE)
|
||||
private List<byte[]> headers; // pre-encoded "Name: Value\r\n" entries
|
||||
private int statusCode;
|
||||
private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int)
|
||||
private byte[] body;
|
||||
private InputStream stream;
|
||||
private long streamLength; // meaningful only when isStreaming() && !chunked
|
||||
private boolean chunked;
|
||||
private byte[] contentType;
|
||||
|
||||
// EX-20: custom headers stored as (name, value) byte pairs in one growable region, instead
|
||||
// of a List<byte[]> of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder +
|
||||
// char[] + String + getBytes() chain per header(String,String) call). Two backing stores,
|
||||
// unified into one insertion-ordered sequence via headerTags/headerRefs, since a fully
|
||||
// pre-rendered line (the legacy header(byte[]) overload) has no name/value structure to
|
||||
// decompose into the same region:
|
||||
// tag 0 -> a (name, value) pair; headerRefs[i] indexes headerQuads (groups of 4)
|
||||
// tag 1 -> a raw pre-rendered line; headerRefs[i] indexes rawHeaderLines
|
||||
private ByteWriter headerRegion; // tag-0 storage: name+value bytes back to back
|
||||
private int[] headerQuads; // tag-0 storage: groups of (nameOff,nameLen,valOff,valLen)
|
||||
private int headerQuadCount;
|
||||
private List<byte[]> rawHeaderLines; // tag-1 storage: legacy header(byte[]) entries, verbatim
|
||||
private byte[] headerTags; // one entry per header(), in call order: 0 or 1
|
||||
private int[] headerRefs; // one entry per header(), in call order: index into the tag's store
|
||||
private int headerCount; // total header() calls this response has recorded
|
||||
|
||||
// EX-21 dev-mode poisoning guard -- see Request's identical mechanism for the full rationale.
|
||||
private boolean active = true;
|
||||
private static volatile boolean poisoningEnabled = Flash.DEV;
|
||||
|
||||
/** Test-only override of the dev-mode poisoning check — mirrors {@code Request}'s identical hook. */
|
||||
static void setPoisoningEnabledForTesting(boolean enabled) {
|
||||
poisoningEnabled = enabled;
|
||||
}
|
||||
|
||||
private void checkActive() {
|
||||
if (poisoningEnabled && !active) {
|
||||
throw new IllegalStateException(
|
||||
"Response used after the handler returned — do not retain a Response past the handler");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Constructors
|
||||
@@ -59,20 +97,57 @@ public class Response {
|
||||
this(statusCode, text.getBytes(StandardCharsets.UTF_8), contentType);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Pooling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Repositions this instance for a new request/response cycle — clears the body, stream,
|
||||
* status, content type, and every header recorded by the previous cycle. Public because the
|
||||
* connection driver that owns the pooled instance lives in a different package (matching
|
||||
* {@link RequestLine#reset}'s precedent); user code never calls this.
|
||||
*/
|
||||
public Response reset(int statusCode, ContentType contentType) {
|
||||
this.statusCode = statusCode;
|
||||
this.statusBytes = null;
|
||||
this.body = null;
|
||||
this.stream = null;
|
||||
this.streamLength = 0;
|
||||
this.chunked = false;
|
||||
this.contentType = contentType.getBytes();
|
||||
this.headerQuadCount = 0;
|
||||
this.headerCount = 0;
|
||||
if (rawHeaderLines != null) rawHeaderLines.clear();
|
||||
this.active = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks this instance unsafe for further use — see {@link Request#recycle()} for the full
|
||||
* rationale, identical here. {@code public} for the same cross-package reason.
|
||||
*/
|
||||
public void recycle() {
|
||||
this.active = false;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Fluent mutators
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Sets the status code. The phrase is looked up from {@link HttpStatus} on the write path. */
|
||||
public Response status(int code) { this.statusCode = code; this.statusBytes = null; return this; }
|
||||
public Response status(int code) { checkActive(); this.statusCode = code; this.statusBytes = null; return this; }
|
||||
|
||||
/** Lombok-style setter kept for API compatibility — equivalent to {@link #status(int)} without the fluent return. */
|
||||
public void setStatusCode(int code) { status(code); }
|
||||
|
||||
/** Sets the status from an {@link HttpStatus} constant. The pre-encoded bytes are used
|
||||
* directly on the write path — zero lookup, zero allocation. */
|
||||
public Response status(HttpStatus status) { this.statusCode = status.code(); this.statusBytes = status.bytes(); return this; }
|
||||
public Response type(ContentType ct) { this.contentType = ct.getBytes(); return this; }
|
||||
public Response type(String ct) { this.contentType = ct.getBytes(StandardCharsets.UTF_8); return this; }
|
||||
public Response status(HttpStatus status) { checkActive(); this.statusCode = status.code(); this.statusBytes = status.bytes(); return this; }
|
||||
public Response type(ContentType ct) { checkActive(); this.contentType = ct.getBytes(); return this; }
|
||||
public Response type(String ct) { checkActive(); this.contentType = ct.getBytes(StandardCharsets.UTF_8); return this; }
|
||||
|
||||
public Response body(byte[] bytes) {
|
||||
checkActive();
|
||||
this.body = bytes;
|
||||
this.stream = null;
|
||||
return this;
|
||||
@@ -84,6 +159,7 @@ public class Response {
|
||||
|
||||
/** Streaming response with known length; written with {@code Content-Length}. */
|
||||
public Response stream(InputStream is, long length) {
|
||||
checkActive();
|
||||
this.stream = is;
|
||||
this.streamLength = length;
|
||||
this.chunked = false;
|
||||
@@ -93,6 +169,7 @@ public class Response {
|
||||
|
||||
/** Streaming response with unknown length; written with {@code Transfer-Encoding: chunked}. */
|
||||
public Response chunked(InputStream is) {
|
||||
checkActive();
|
||||
this.stream = is;
|
||||
this.chunked = true;
|
||||
this.body = null;
|
||||
@@ -121,37 +198,150 @@ public class Response {
|
||||
* }</pre>
|
||||
*/
|
||||
public Response redirect(HttpStatus status, String url) {
|
||||
checkActive();
|
||||
this.statusCode = status.code();
|
||||
this.statusBytes = status.bytes();
|
||||
this.body = null;
|
||||
this.stream = null;
|
||||
if (headers == null) headers = new ArrayList<>();
|
||||
headers.add(("Location: " + url + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
return this;
|
||||
return header("Location", url);
|
||||
}
|
||||
|
||||
/** Adds a response header. Encoded once at call time; zero-alloc on the write path. */
|
||||
/**
|
||||
* Adds a response header. {@code EX-20}: writes {@code name}/{@code value} directly into a
|
||||
* reused byte region (via {@link ByteWriter#writeAscii}) instead of building an intermediate
|
||||
* {@code String} and re-encoding it — zero allocation once the region has grown to this
|
||||
* connection's high-water mark.
|
||||
*/
|
||||
public Response header(String name, String value) {
|
||||
if (headers == null) headers = new ArrayList<>();
|
||||
headers.add((name + ": " + value + "\r\n").getBytes(StandardCharsets.UTF_8));
|
||||
checkActive();
|
||||
checkHeaderBudget();
|
||||
if (headerRegion == null) {
|
||||
headerRegion = new ByteWriter(128);
|
||||
headerQuads = new int[16];
|
||||
}
|
||||
ensureQuadCapacity(headerQuadCount + 1);
|
||||
int nameOff = headerRegion.length();
|
||||
headerRegion.writeAscii(name);
|
||||
int nameLen = headerRegion.length() - nameOff;
|
||||
int valOff = headerRegion.length();
|
||||
headerRegion.writeAscii(value);
|
||||
int valLen = headerRegion.length() - valOff;
|
||||
checkHeaderRegionBudget();
|
||||
|
||||
int base = headerQuadCount * 4;
|
||||
headerQuads[base] = nameOff;
|
||||
headerQuads[base + 1] = nameLen;
|
||||
headerQuads[base + 2] = valOff;
|
||||
headerQuads[base + 3] = valLen;
|
||||
recordHeaderEntry((byte) 0, headerQuadCount);
|
||||
headerQuadCount++;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a pre-encoded header (e.g. a static {@code "X-RateLimit-Limit: 100\r\n"} byte array
|
||||
* pre-built at boot time). Zero-alloc on both the call path and the write path.
|
||||
* Adds a header from a {@link PreEncodedHeader} built once (typically at boot). Copies its
|
||||
* precomputed {@code name}/{@code value} bytes into this response's region — a memcpy, not a
|
||||
* re-encode, and usable by a future h2 response path (unlike {@link #header(byte[])}) since
|
||||
* the name/value structure survives.
|
||||
*/
|
||||
public Response header(PreEncodedHeader preEncoded) {
|
||||
checkActive();
|
||||
checkHeaderBudget();
|
||||
if (headerRegion == null) {
|
||||
headerRegion = new ByteWriter(128);
|
||||
headerQuads = new int[16];
|
||||
}
|
||||
ensureQuadCapacity(headerQuadCount + 1);
|
||||
byte[] nameBytes = preEncoded.nameBytes();
|
||||
byte[] valueBytes = preEncoded.valueBytes();
|
||||
int nameOff = headerRegion.length();
|
||||
headerRegion.writeBytes(nameBytes);
|
||||
int valOff = headerRegion.length();
|
||||
headerRegion.writeBytes(valueBytes);
|
||||
checkHeaderRegionBudget();
|
||||
|
||||
int base = headerQuadCount * 4;
|
||||
headerQuads[base] = nameOff;
|
||||
headerQuads[base + 1] = nameBytes.length;
|
||||
headerQuads[base + 2] = valOff;
|
||||
headerQuads[base + 3] = valueBytes.length;
|
||||
recordHeaderEntry((byte) 0, headerQuadCount);
|
||||
headerQuadCount++;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a pre-encoded, fully-rendered header line (e.g. a static
|
||||
* {@code "X-RateLimit-Limit: 100\r\n"} byte array pre-built at boot time). Zero-alloc on
|
||||
* both the call path and the h1 write path.
|
||||
*
|
||||
* <p><b>h1-only</b>: a rendered {@code "Name: Value\r\n"} line carries no structured
|
||||
* name/value data an HPACK encoder could use, so this header is not representable on a
|
||||
* future h2 response path — prefer {@link #header(PreEncodedHeader)} for anything that must
|
||||
* render correctly on both protocols. Kept for existing h1-only callers.
|
||||
*/
|
||||
public Response header(byte[] preEncoded) {
|
||||
if (headers == null) headers = new ArrayList<>();
|
||||
headers.add(preEncoded);
|
||||
checkActive();
|
||||
checkHeaderBudget();
|
||||
if (rawHeaderLines == null) rawHeaderLines = new ArrayList<>();
|
||||
rawHeaderLines.add(preEncoded);
|
||||
recordHeaderEntry((byte) 1, rawHeaderLines.size() - 1);
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code EX-nn}: bounds the response-side analogue of the request header limits — a handler
|
||||
* that calls {@code header(...)} in an unbounded loop must not grow this connection's
|
||||
* per-request scratch state without limit (Phase 6's zero-alloc DoD names this explicitly).
|
||||
*/
|
||||
private void checkHeaderBudget() {
|
||||
if (headerCount >= Http1Limits.MAX_RESPONSE_HEADER_COUNT) {
|
||||
throw new IllegalStateException("response exceeds " + Http1Limits.MAX_RESPONSE_HEADER_COUNT
|
||||
+ " headers — check for an unbounded loop calling header(...)");
|
||||
}
|
||||
}
|
||||
|
||||
private void checkHeaderRegionBudget() {
|
||||
if (headerRegion.length() > Http1Limits.MAX_RESPONSE_HEADER_BYTES) {
|
||||
throw new IllegalStateException("response header region exceeds "
|
||||
+ Http1Limits.MAX_RESPONSE_HEADER_BYTES + " bytes — check for an unbounded loop or an oversized value passed to header(...)");
|
||||
}
|
||||
}
|
||||
|
||||
private void recordHeaderEntry(byte tag, int ref) {
|
||||
if (headerTags == null) {
|
||||
headerTags = new byte[16];
|
||||
headerRefs = new int[16];
|
||||
} else if (headerCount == headerTags.length) {
|
||||
int grown = headerTags.length * 2;
|
||||
headerTags = Arrays.copyOf(headerTags, grown);
|
||||
headerRefs = Arrays.copyOf(headerRefs, grown);
|
||||
}
|
||||
headerTags[headerCount] = tag;
|
||||
headerRefs[headerCount] = ref;
|
||||
headerCount++;
|
||||
}
|
||||
|
||||
private void ensureQuadCapacity(int neededQuads) {
|
||||
int neededInts = neededQuads * 4;
|
||||
if (neededInts <= headerQuads.length) return;
|
||||
int grown = headerQuads.length;
|
||||
while (grown < neededInts) grown *= 2;
|
||||
headerQuads = Arrays.copyOf(headerQuads, grown);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// State queries
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
public boolean isStreaming() { return stream != null; }
|
||||
public boolean isStreaming() { checkActive(); return stream != null; }
|
||||
public boolean isChunked() { checkActive(); return chunked; }
|
||||
public int getStatusCode() { checkActive(); return statusCode; }
|
||||
public byte[] getStatusBytes() { checkActive(); return statusBytes; }
|
||||
public byte[] getBody() { checkActive(); return body; }
|
||||
public byte[] getContentType() { checkActive(); return contentType; }
|
||||
public InputStream getStream() { checkActive(); return stream; }
|
||||
public long getStreamLength() { checkActive(); return streamLength; }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internal setters used by HttpServer for handler return values
|
||||
@@ -164,6 +354,7 @@ public class Response {
|
||||
* serialize to {@code String}/{@code byte[]} before returning.
|
||||
*/
|
||||
public Response setBody(Object body) {
|
||||
checkActive();
|
||||
if (body instanceof byte[] bytes) { this.body = bytes; return this; }
|
||||
if (body instanceof String s) { this.body = s.getBytes(StandardCharsets.UTF_8); return this; }
|
||||
if (body instanceof CharSequence s) { this.body = s.toString().getBytes(StandardCharsets.UTF_8); return this; }
|
||||
@@ -173,12 +364,96 @@ public class Response {
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Returns custom headers, or an empty list if none were added. */
|
||||
public List<byte[]> getHeaders() { return headers != null ? headers : List.of(); }
|
||||
/**
|
||||
* Returns custom headers as fully-rendered {@code "Name: Value\r\n"} lines, or an empty list
|
||||
* if none were added. Introspection/debugging accessor — reconstructs each line from the
|
||||
* internal region on every call, so it is not on the zero-alloc write path; {@link
|
||||
* #writeHeaders} and {@link ResponseSerializer} read the internal representation directly
|
||||
* instead of going through this method.
|
||||
*/
|
||||
public List<byte[]> getHeaders() {
|
||||
checkActive();
|
||||
if (headerCount == 0) return List.of();
|
||||
List<byte[]> result = new ArrayList<>(headerCount);
|
||||
for (int i = 0; i < headerCount; i++) {
|
||||
if (headerTags[i] == 1) {
|
||||
result.add(rawHeaderLines.get(headerRefs[i]));
|
||||
} else {
|
||||
int base = headerRefs[i] * 4;
|
||||
byte[] region = headerRegion.array();
|
||||
int nameOff = headerQuads[base], nameLen = headerQuads[base + 1];
|
||||
int valOff = headerQuads[base + 2], valLen = headerQuads[base + 3];
|
||||
byte[] line = new byte[nameLen + 2 + valLen + 2];
|
||||
int p = 0;
|
||||
System.arraycopy(region, nameOff, line, p, nameLen); p += nameLen;
|
||||
line[p++] = ':'; line[p++] = ' ';
|
||||
System.arraycopy(region, valOff, line, p, valLen); p += valLen;
|
||||
line[p++] = '\r'; line[p] = '\n';
|
||||
result.add(line);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Writes pre-encoded custom headers directly to {@code out}. Zero-alloc when no headers are set. */
|
||||
/**
|
||||
* Writes every custom header directly into {@code head} (a scratch {@link ByteWriter} —
|
||||
* see {@code EX-27}), in call order. Zero-alloc when no headers are set or on a warm region.
|
||||
* This is what {@code Http1ResponseWriter} uses; {@link #writeHeaders(OutputStream)} below
|
||||
* (the {@code OutputStream} equivalent) exists for the streaming-body write paths that
|
||||
* cannot fold their whole write into one scratch buffer.
|
||||
*/
|
||||
public void writeHeadersInto(ByteWriter head) {
|
||||
for (int i = 0; i < headerCount; i++) {
|
||||
if (headerTags[i] == 1) {
|
||||
head.writeBytes(rawHeaderLines.get(headerRefs[i]));
|
||||
} else {
|
||||
int base = headerRefs[i] * 4;
|
||||
byte[] region = headerRegion.array();
|
||||
head.writeBytes(region, headerQuads[base], headerQuads[base + 1]);
|
||||
head.writeByte((byte) ':'); head.writeByte((byte) ' ');
|
||||
head.writeBytes(region, headerQuads[base + 2], headerQuads[base + 3]);
|
||||
head.writeByte((byte) '\r'); head.writeByte((byte) '\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Writes every custom header directly to {@code out}, in call order. Zero-alloc when no headers are set or on a warm region. */
|
||||
public void writeHeaders(OutputStream out) throws IOException {
|
||||
if (headers == null) return;
|
||||
for (byte[] header : headers) out.write(header);
|
||||
for (int i = 0; i < headerCount; i++) {
|
||||
if (headerTags[i] == 1) {
|
||||
out.write(rawHeaderLines.get(headerRefs[i]));
|
||||
} else {
|
||||
int base = headerRefs[i] * 4;
|
||||
byte[] region = headerRegion.array();
|
||||
out.write(region, headerQuads[base], headerQuads[base + 1]);
|
||||
out.write(':'); out.write(' ');
|
||||
out.write(region, headerQuads[base + 2], headerQuads[base + 3]);
|
||||
out.write('\r'); out.write('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal: name/value field enumeration for ResponseSerializer ──────────
|
||||
|
||||
/**
|
||||
* Visits every {@code header(String,String)}/{@code header(PreEncodedHeader)}-added field as
|
||||
* a structured (name, value) byte range — <b>not</b> the {@code header(byte[])} legacy
|
||||
* entries, which have no such structure (see that method's own Javadoc). Package-private:
|
||||
* {@link ResponseSerializer} is this method's only caller.
|
||||
*/
|
||||
void forEachStructuredField(ResponseSerializer.FieldConsumer consumer) {
|
||||
if (headerQuadCount == 0) return;
|
||||
byte[] region = headerRegion.array();
|
||||
for (int i = 0; i < headerQuadCount; i++) {
|
||||
int base = i * 4;
|
||||
consumer.accept(region, headerQuads[base], headerQuads[base + 1],
|
||||
region, headerQuads[base + 2], headerQuads[base + 3]);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Response(statusCode=" + statusCode + ", contentType="
|
||||
+ (contentType != null ? new String(contentType, StandardCharsets.UTF_8) : null) + ")";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* The protocol-neutral enumeration of a {@link Response}'s header fields — one source of truth
|
||||
* consumed by every protocol's own writer, so {@code Content-Type}/custom-header logic is never
|
||||
* duplicated (and cannot drift) between {@code Http1ResponseWriter} and a future h2 encoder
|
||||
* (Phase 9). {@code Http1ResponseWriter} renders each field as {@code "Name: Value\r\n"}; the h2
|
||||
* encoder will render the same fields via HPACK.
|
||||
*
|
||||
* <h3>Scope: response-object fields only, not connection framing</h3>
|
||||
* Deliberately does <b>not</b> enumerate {@code Content-Length}, {@code Connection}, or
|
||||
* {@code Date} — those are connection/transport framing decisions (body length, keep-alive
|
||||
* negotiation, wall-clock time), not properties of the {@code Response} object itself, and HTTP/2
|
||||
* has no equivalent of {@code Connection} at all (RFC 9113 §8.2.2 forbids connection-specific
|
||||
* fields in h2). Each protocol's own writer computes and emits those itself, exactly as
|
||||
* {@code Http1ResponseWriter} already did before this class existed.
|
||||
*
|
||||
* <h3>Scope: excludes {@link Response#header(byte[])}'s legacy entries</h3>
|
||||
* A header added via the raw, fully-pre-rendered {@code header(byte[])} overload has no
|
||||
* recoverable (name, value) structure — see that method's own Javadoc — so it cannot appear in
|
||||
* this enumeration. {@code Http1ResponseWriter} still renders it (via {@link
|
||||
* Response#writeHeaders}, which handles both structured and raw entries, in the original call
|
||||
* order); a future h2 writer will not be able to.
|
||||
*/
|
||||
public final class ResponseSerializer {
|
||||
private ResponseSerializer() {}
|
||||
|
||||
/** One rendered header field: a byte range for the name, and a byte range for the value — both slices of caller-owned arrays, never copied. */
|
||||
@FunctionalInterface
|
||||
public interface FieldConsumer {
|
||||
void accept(byte[] nameBuf, int nameOff, int nameLen, byte[] valueBuf, int valueOff, int valueLen);
|
||||
}
|
||||
|
||||
private static final byte[] CONTENT_TYPE_NAME = "Content-Type".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
/**
|
||||
* Enumerates {@code response}'s fields in a fixed, deterministic order: {@code Content-Type}
|
||||
* first (if set to a non-empty value — {@code EX-15}: {@code ContentType.NONE} emits
|
||||
* nothing, never an empty-valued header line), then every {@code header(String,String)}/
|
||||
* {@code header(PreEncodedHeader)}-added field in call order. Zero allocation: every byte
|
||||
* range handed to {@code consumer} is a slice of {@code response}'s own already-allocated
|
||||
* buffers.
|
||||
*/
|
||||
public static void forEachField(Response response, FieldConsumer consumer) {
|
||||
byte[] ct = response.getContentType();
|
||||
if (ct != null && ct.length > 0) {
|
||||
consumer.accept(CONTENT_TYPE_NAME, 0, CONTENT_TYPE_NAME.length, ct, 0, ct.length);
|
||||
}
|
||||
response.forEachStructuredField(consumer);
|
||||
}
|
||||
}
|
||||
+19
-3
@@ -41,10 +41,19 @@ public final class FastPathViews {
|
||||
return (long) LONG_VIEW_LE.get(array, pos);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@code EX-42}: not immutable — {@link #reset} repositions an existing instance over new
|
||||
* bounds instead of requiring a fresh allocation. {@code RequestParser} owns one pooled
|
||||
* instance per role (path/query/protocol) per connection and calls {@link #reset} on it for
|
||||
* every request, the same "do not retain past the handler" pooling contract every other
|
||||
* per-connection object in this codebase already follows ({@code Http1HeaderMap},
|
||||
* {@code RequestLine}, {@code Request}, {@code RequestBody}). The public constructor remains
|
||||
* for one-shot, non-pooled use (tests, other call sites that build a single fixed view).
|
||||
*/
|
||||
public static final class RequestByteView implements ArrayBackedByteView {
|
||||
private final byte[] buffer;
|
||||
private final int start;
|
||||
private final int length;
|
||||
private byte[] buffer;
|
||||
private int start;
|
||||
private int length;
|
||||
|
||||
public RequestByteView(byte[] buffer, int start, int length) {
|
||||
this.buffer = buffer;
|
||||
@@ -52,6 +61,13 @@ public final class FastPathViews {
|
||||
this.length = length;
|
||||
}
|
||||
|
||||
/** Repositions this instance over new bounds. Zero allocation. */
|
||||
public void reset(byte[] buffer, int start, int length) {
|
||||
this.buffer = buffer;
|
||||
this.start = start;
|
||||
this.length = length;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int length() {
|
||||
return length;
|
||||
|
||||
@@ -2,21 +2,30 @@ package dev.relism.flash.template;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Precompiled, allocation-minimal byte template.
|
||||
* <p>
|
||||
* Placeholders of the form {@code {{name}}} are detected once at construction.
|
||||
* Each {@link #render} call makes exactly one allocation: the output byte[].
|
||||
* Each {@link #render} call makes exactly one allocation: the output byte[]
|
||||
* (plus one {@code byte[]} per distinct key-value pair, for its UTF-8 bytes).
|
||||
* <p>
|
||||
* Layout: seg[0] slot[0] seg[1] slot[1] … seg[n-1] slot[n-1] seg[n]
|
||||
*
|
||||
* <h3>{@code EX-28}: slot lookup is O(1) per key-value pair, not O(slots)</h3>
|
||||
* A slot name can appear more than once (e.g. {@code {{var}} == {{var}}}), so the map built at
|
||||
* construction maps each name to the (usually single-element) array of every slot index using
|
||||
* that name, instead of the nested "scan every slot for every pair" loop this used to do.
|
||||
*/
|
||||
public final class ByteTemplate {
|
||||
|
||||
private final byte[][] segments; // literal byte segments
|
||||
private final String[] slots; // placeholder names in order
|
||||
private final int staticLength; // sum of all segment lengths (precomputed)
|
||||
private final byte[][] segments; // literal byte segments
|
||||
private final String[] slots; // placeholder names in order
|
||||
private final int staticLength; // sum of all segment lengths (precomputed)
|
||||
private final Map<String, int[]> slotIndex; // slot name -> every slot index using that name
|
||||
|
||||
public ByteTemplate(String source) {
|
||||
List<byte[]> segs = new ArrayList<>();
|
||||
@@ -40,27 +49,68 @@ public final class ByteTemplate {
|
||||
int sl = 0;
|
||||
for (byte[] s : segments) sl += s.length;
|
||||
staticLength = sl;
|
||||
|
||||
Map<String, List<Integer>> byName = new HashMap<>();
|
||||
for (int j = 0; j < slots.length; j++) {
|
||||
byName.computeIfAbsent(slots[j], k -> new ArrayList<>()).add(j);
|
||||
}
|
||||
Map<String, int[]> idx = new HashMap<>();
|
||||
for (Map.Entry<String, List<Integer>> e : byName.entrySet()) {
|
||||
int[] arr = new int[e.getValue().size()];
|
||||
for (int j = 0; j < arr.length; j++) arr[j] = e.getValue().get(j);
|
||||
idx.put(e.getKey(), arr);
|
||||
}
|
||||
slotIndex = idx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render with alternating key-value String pairs: {@code k1, v1, k2, v2, …}
|
||||
* Unmatched slots are rendered as empty.
|
||||
* Unmatched slots are rendered as empty. Allocates the returned {@code byte[]}; for a
|
||||
* caller-supplied buffer see {@link #renderInto(byte[], int, String...)}.
|
||||
*/
|
||||
public byte[] render(String... kvPairs) {
|
||||
byte[][] values = resolveValues(kvPairs);
|
||||
byte[] out = new byte[length(values)];
|
||||
writeInto(out, 0, values);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders into {@code buffer} starting at {@code offset}, making no allocation beyond the
|
||||
* per-pair UTF-8 conversion of {@code kvPairs}' values. Returns the number of bytes written.
|
||||
*
|
||||
* @throws IndexOutOfBoundsException if {@code buffer} does not have enough room from {@code offset}
|
||||
*/
|
||||
public int renderInto(byte[] buffer, int offset, String... kvPairs) {
|
||||
byte[][] values = resolveValues(kvPairs);
|
||||
int len = length(values);
|
||||
if (offset < 0 || offset + len > buffer.length) {
|
||||
throw new IndexOutOfBoundsException(
|
||||
"buffer too small: need " + len + " bytes at offset " + offset + ", have " + (buffer.length - offset));
|
||||
}
|
||||
writeInto(buffer, offset, values);
|
||||
return len;
|
||||
}
|
||||
|
||||
private byte[][] resolveValues(String[] kvPairs) {
|
||||
byte[][] values = new byte[slots.length][];
|
||||
for (int i = 0; i + 1 < kvPairs.length; i += 2) {
|
||||
String key = kvPairs[i];
|
||||
int[] matches = slotIndex.get(kvPairs[i]);
|
||||
if (matches == null) continue;
|
||||
byte[] val = kvPairs[i + 1].getBytes(StandardCharsets.UTF_8);
|
||||
for (int j = 0; j < slots.length; j++) {
|
||||
if (slots[j].equals(key)) { values[j] = val; }
|
||||
}
|
||||
for (int idx : matches) values[idx] = val;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
private int length(byte[][] values) {
|
||||
int len = staticLength;
|
||||
for (byte[] v : values) if (v != null) len += v.length;
|
||||
return len;
|
||||
}
|
||||
|
||||
byte[] out = new byte[len];
|
||||
int pos = 0;
|
||||
private void writeInto(byte[] out, int offset, byte[][] values) {
|
||||
int pos = offset;
|
||||
for (int i = 0; i < slots.length; i++) {
|
||||
System.arraycopy(segments[i], 0, out, pos, segments[i].length);
|
||||
pos += segments[i].length;
|
||||
@@ -70,6 +120,5 @@ public final class ByteTemplate {
|
||||
}
|
||||
}
|
||||
System.arraycopy(segments[slots.length], 0, out, pos, segments[slots.length].length);
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package dev.relism.flash.transport;
|
||||
|
||||
import dev.relism.flash.bytes.ByteWriter;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
@@ -36,15 +38,20 @@ public final class ConnectionScratch {
|
||||
/** Matches the relay-buffer size the {@code ThreadLocal} it replaces used. */
|
||||
public static final int RELAY_BUFFER_SIZE = 8192;
|
||||
|
||||
/** Large enough for the decimal digits of any {@code long}, including a sign. */
|
||||
public static final int DECIMAL_BUFFER_SIZE = 20;
|
||||
|
||||
/** Scratch for {@code Http1ResponseWriter}'s decimal (status code / Content-Length) encoding. */
|
||||
public final byte[] decimalBuffer = new byte[DECIMAL_BUFFER_SIZE];
|
||||
/** Initial capacity for {@link #responseHead}; grows on demand like any {@link ByteWriter}. */
|
||||
public static final int RESPONSE_HEAD_INITIAL_SIZE = 1024;
|
||||
|
||||
/** Scratch for relaying a streaming or chunked response body without allocating per response. */
|
||||
public final byte[] relayBuffer = new byte[RELAY_BUFFER_SIZE];
|
||||
|
||||
/**
|
||||
* {@code EX-27}: the scratch {@code Http1ResponseWriter} serializes a whole response head
|
||||
* (status line, {@code Content-Type}, {@code Date}, custom headers, {@code Content-Length}/
|
||||
* {@code Connection}, and — for small fixed bodies — the body itself) into before issuing a
|
||||
* single bulk {@code write()}, instead of ~10 small {@code OutputStream.write} calls.
|
||||
*/
|
||||
public final ByteWriter responseHead = new ByteWriter(RESPONSE_HEAD_INITIAL_SIZE);
|
||||
|
||||
/** Scratch for the WebSocket handshake's {@code Sec-WebSocket-Accept} SHA-1 digest. */
|
||||
public final MessageDigest sha1;
|
||||
|
||||
@@ -60,9 +67,9 @@ public final class ConnectionScratch {
|
||||
/** Called by {@link ScratchPool} before handing a reused instance to a new connection. */
|
||||
void reset() {
|
||||
sha1.reset();
|
||||
// decimalBuffer/relayBuffer need no clearing: every reader of either only ever reads
|
||||
// back exactly the region the immediately preceding writer just wrote (writeLong fills
|
||||
// from the end backward and reports its own start position; relay() reports its own
|
||||
// fill length), so stale bytes from a previous connection are never observed.
|
||||
responseHead.reset();
|
||||
// relayBuffer needs no clearing: every reader only ever reads back exactly the region
|
||||
// the immediately preceding relay() call reports it filled, so stale bytes from a
|
||||
// previous connection are never observed.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,32 @@ class RequestParserTest {
|
||||
assertEquals("2", r.query("page"));
|
||||
}
|
||||
|
||||
// --- EX-42: pooled RequestByteViews (path/query/protocol) don't leak across requests ---
|
||||
|
||||
@Test
|
||||
void samePooledParser_secondRequestWithoutQuery_doesNotLeakFirstRequestsQuery() throws IOException {
|
||||
RequestParser parser = new RequestParser();
|
||||
byte[] first = req("GET /search?token=super-secret HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
Request r1 = parser.parse(source(first));
|
||||
assertEquals("token=super-secret", r1.getRequestLine().getQuery().toString());
|
||||
|
||||
byte[] second = req("GET /health HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8);
|
||||
Request r2 = parser.parse(source(second));
|
||||
assertNull(r2.getRequestLine().getQuery(), "the second request must not see the first request's leftover query view");
|
||||
assertEquals("/health", r2.getRequestLine().getPath().toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void samePooledParser_secondRequest_seesOnlyItsOwnPathAndProtocol() throws IOException {
|
||||
RequestParser parser = new RequestParser();
|
||||
Request r1 = parser.parse(source(req("GET /first HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8)));
|
||||
assertEquals("/first", r1.getRequestLine().getPath().toString());
|
||||
|
||||
Request r2 = parser.parse(source(req("POST /second HTTP/1.0", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8)));
|
||||
assertEquals("/second", r2.getRequestLine().getPath().toString());
|
||||
assertEquals("HTTP/1.0", r2.getRequestLine().getProtocol().toString());
|
||||
}
|
||||
|
||||
// --- headers ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,7 +2,7 @@ package dev.relism.flash.api.multipart;
|
||||
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.HeaderMap;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -41,7 +41,7 @@ class MultipartTest {
|
||||
private static Request request(byte[] bodyBytes) {
|
||||
String ct = "multipart/form-data; boundary=" + BOUNDARY;
|
||||
byte[] headerBuf = ("Content-Type: " + ct).getBytes(StandardCharsets.US_ASCII);
|
||||
HeaderMap headers = new HeaderMap();
|
||||
Http1HeaderMap headers = new Http1HeaderMap();
|
||||
headers.reset(headerBuf, 0, headerBuf.length);
|
||||
RequestLine line = new RequestLine(HttpMethod.POST, viewOf("/upload"), null, viewOf("HTTP/1.1"), headers);
|
||||
return new Request(line, bodyBytes);
|
||||
@@ -236,11 +236,65 @@ class MultipartTest {
|
||||
@Test
|
||||
void of_notMultipart_throws() {
|
||||
byte[] headerBuf = "Content-Type: application/json".getBytes(StandardCharsets.US_ASCII);
|
||||
HeaderMap headers = new HeaderMap();
|
||||
Http1HeaderMap headers = new Http1HeaderMap();
|
||||
headers.reset(headerBuf, 0, headerBuf.length);
|
||||
RequestLine line = new RequestLine(HttpMethod.POST, viewOf("/"), null, viewOf("HTTP/1.1"), headers);
|
||||
Request req = new Request(line, new byte[0]);
|
||||
|
||||
assertThrows(IllegalArgumentException.class, () -> Multipart.of(req));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// EX-29: resource-exhaustion bounds
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
@Test
|
||||
void field_bodyAboveMaxBufferedSize_throws() throws IOException {
|
||||
// MAX_MULTIPART_BUFFERED_PART_SIZE is 10 MiB — one byte over must be rejected, not
|
||||
// buffered whole into a single byte[].
|
||||
String tooBig = "z".repeat((int) dev.relism.flash.http.Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + 1);
|
||||
Multipart mp = Multipart.of(request(body(textPart("huge", tooBig))));
|
||||
assertThrows(IOException.class, () -> mp.field("huge"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void file_materializedDuringFullScan_aboveMaxBufferedSize_throws() throws IOException {
|
||||
String tooBig = "z".repeat((int) dev.relism.flash.http.Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + 1);
|
||||
Multipart mp = Multipart.of(request(body(filePart("f", "f.bin", "application/octet-stream", tooBig))));
|
||||
assertThrows(IOException.class, mp::parts);
|
||||
}
|
||||
|
||||
@Test
|
||||
void scan_tooManyParts_throws() throws IOException {
|
||||
String[] parts = new String[dev.relism.flash.http.Http1Limits.MAX_MULTIPART_PARTS + 1];
|
||||
for (int i = 0; i < parts.length; i++) parts[i] = textPart("f" + i, "v");
|
||||
Multipart mp = Multipart.of(request(body(parts)));
|
||||
assertThrows(IOException.class, mp::parts);
|
||||
}
|
||||
|
||||
@Test
|
||||
void partHeaders_tooManyHeaderLines_throws() throws IOException {
|
||||
StringBuilder part = new StringBuilder("Content-Disposition: form-data; name=\"x\"\r\n");
|
||||
for (int i = 0; i <= dev.relism.flash.http.Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT; i++) {
|
||||
part.append("X-Extra-").append(i).append(": v\r\n");
|
||||
}
|
||||
part.append("\r\nbody");
|
||||
Multipart mp = Multipart.of(request(body(part.toString())));
|
||||
assertThrows(IOException.class, () -> mp.field("x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void partHeaderLine_tooLong_throws() throws IOException {
|
||||
String longValue = "v".repeat(dev.relism.flash.http.Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH + 1);
|
||||
String part = "Content-Disposition: form-data; name=\"x\"\r\n"
|
||||
+ "X-Long: " + longValue + "\r\n\r\nbody";
|
||||
Multipart mp = Multipart.of(request(body(part)));
|
||||
assertThrows(IOException.class, () -> mp.field("x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void withinAllLimits_stillWorksNormally() throws IOException {
|
||||
// Sanity check the bounds above don't false-positive on a normal small request.
|
||||
assertEquals("alice", Multipart.of(request(body(textPart("username", "alice")))).field("username"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +93,13 @@ class ByteWriterTest {
|
||||
assertEquals("content-type", asString(w));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeAscii_preservesCase() {
|
||||
ByteWriter w = new ByteWriter(4);
|
||||
w.writeAscii("Content-TYPE");
|
||||
assertEquals("Content-TYPE", asString(w));
|
||||
}
|
||||
|
||||
@Test
|
||||
void writeUInt16_bigEndian() {
|
||||
ByteWriter w = new ByteWriter(4);
|
||||
|
||||
@@ -10,6 +10,7 @@ import org.junit.jupiter.api.Test;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@@ -133,4 +134,52 @@ class Http1ResponseWriterTest {
|
||||
String raw = write(response, HttpMethod.GET, false, false);
|
||||
assertTrue(raw.contains("Connection: close\r\n"), raw);
|
||||
}
|
||||
|
||||
// --- EX-27: one bulk write for a small fixed body -----------------------------
|
||||
|
||||
/** Counts calls to {@code write(byte[], int, int)} — the only overload {@link Http1ResponseWriter} uses. */
|
||||
private static final class CountingOutputStream extends java.io.OutputStream {
|
||||
final ByteArrayOutputStream sink = new ByteArrayOutputStream();
|
||||
int arrayWriteCalls;
|
||||
|
||||
@Override public void write(int b) { sink.write(b); }
|
||||
|
||||
@Override
|
||||
public void write(byte[] b, int off, int len) {
|
||||
arrayWriteCalls++;
|
||||
sink.write(b, off, len);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void smallFixedBody_isWrittenInExactlyOneCall() throws IOException {
|
||||
CountingOutputStream out = new CountingOutputStream();
|
||||
Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN);
|
||||
Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch());
|
||||
|
||||
assertEquals(1, out.arrayWriteCalls, "head + small body must leave in a single write() call");
|
||||
assertTrue(out.sink.toString(StandardCharsets.UTF_8).endsWith("hello world"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void bodyAboveInlineThreshold_isWrittenInTwoCalls() throws IOException {
|
||||
CountingOutputStream out = new CountingOutputStream();
|
||||
byte[] bigBody = new byte[dev.relism.flash.http.Http1Limits.INLINE_BODY_THRESHOLD + 1];
|
||||
Arrays.fill(bigBody, (byte) 'x');
|
||||
Response response = new Response(200, bigBody, ContentType.BINARY);
|
||||
Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch());
|
||||
|
||||
assertEquals(2, out.arrayWriteCalls, "head and an over-threshold body are written separately");
|
||||
assertTrue(out.sink.toString(StandardCharsets.UTF_8).endsWith("x".repeat(bigBody.length)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void headResponse_stillOneCall_noBodyBytes() throws IOException {
|
||||
CountingOutputStream out = new CountingOutputStream();
|
||||
Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN);
|
||||
Http1ResponseWriter.writeResponse(out, response, HttpMethod.HEAD, true, false, scratch());
|
||||
|
||||
assertEquals(1, out.arrayWriteCalls);
|
||||
assertFalse(out.sink.toString(StandardCharsets.UTF_8).contains("hello world"));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -8,25 +8,25 @@ import java.util.List;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@code EX-09}: dedicated correctness coverage for {@link HeaderMap}'s per-{@code reset()}
|
||||
* {@code EX-09}: dedicated correctness coverage for {@link Http1HeaderMap}'s per-{@code reset()}
|
||||
* index — duplicate names, case variation, zero headers, and growth past the initial index
|
||||
* capacity up to {@code Http1Limits.MAX_HEADER_COUNT}. {@link HeaderMapTest} already covers the
|
||||
* ordinary lookup/forEach contract; this class targets the index machinery specifically.
|
||||
*/
|
||||
class HeaderMapIndexTest {
|
||||
class Http1HeaderMapIndexTest {
|
||||
|
||||
private static HeaderMap parse(String... headers) {
|
||||
private static Http1HeaderMap parse(String... headers) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String h : headers) sb.append(h).append("\r\n");
|
||||
byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
HeaderMap map = new HeaderMap();
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
map.reset(buffer, 0, buffer.length);
|
||||
return map;
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroHeaders_everyLookupIsEmpty() {
|
||||
HeaderMap map = parse();
|
||||
Http1HeaderMap map = parse();
|
||||
assertNull(map.first("Host"));
|
||||
assertTrue(map.all("Host").isEmpty());
|
||||
assertTrue(map.all().isEmpty());
|
||||
@@ -36,14 +36,14 @@ class HeaderMapIndexTest {
|
||||
|
||||
@Test
|
||||
void duplicateHeaderNames_firstReturnsTheFirstOne_allReturnsAllInOrder() {
|
||||
HeaderMap map = parse("X-Trace: a", "X-Trace: b", "X-Trace: c");
|
||||
Http1HeaderMap map = parse("X-Trace: a", "X-Trace: b", "X-Trace: c");
|
||||
assertEquals("a", map.first("X-Trace"));
|
||||
assertEquals(List.of("a", "b", "c"), map.all("X-Trace"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void caseVariation_indexHashAndCompareBothIgnoreCase() {
|
||||
HeaderMap map = parse("X-Custom-Header: value1");
|
||||
Http1HeaderMap map = parse("X-Custom-Header: value1");
|
||||
assertEquals("value1", map.first("x-custom-header"));
|
||||
assertEquals("value1", map.first("X-CUSTOM-HEADER"));
|
||||
assertEquals("value1", map.first("X-cUsToM-hEaDeR"));
|
||||
@@ -52,7 +52,7 @@ class HeaderMapIndexTest {
|
||||
@Test
|
||||
void similarButDistinctNames_doNotCollideInTheIndex() {
|
||||
// Names sharing a hash-prefix-adjacent shape must still resolve independently.
|
||||
HeaderMap map = parse("Accept: a", "Accept-Encoding: b", "Accept-Language: c");
|
||||
Http1HeaderMap map = parse("Accept: a", "Accept-Encoding: b", "Accept-Language: c");
|
||||
assertEquals("a", map.first("Accept"));
|
||||
assertEquals("b", map.first("Accept-Encoding"));
|
||||
assertEquals("c", map.first("Accept-Language"));
|
||||
@@ -63,7 +63,7 @@ class HeaderMapIndexTest {
|
||||
int n = dev.relism.flash.http.Http1Limits.MAX_HEADER_COUNT;
|
||||
String[] headers = new String[n];
|
||||
for (int i = 0; i < n; i++) headers[i] = "X-Header-" + i + ": value-" + i;
|
||||
HeaderMap map = parse(headers);
|
||||
Http1HeaderMap map = parse(headers);
|
||||
|
||||
assertEquals("value-0", map.first("X-Header-0"));
|
||||
assertEquals("value-" + (n - 1), map.first("X-Header-" + (n - 1)));
|
||||
@@ -73,7 +73,7 @@ class HeaderMapIndexTest {
|
||||
|
||||
@Test
|
||||
void reset_rebuildsIndexFromScratch_noStaleEntriesFromPreviousRequest() {
|
||||
HeaderMap map = parse("Host: first-request");
|
||||
Http1HeaderMap map = parse("Host: first-request");
|
||||
assertEquals("first-request", map.first("Host"));
|
||||
assertNull(map.first("X-Only-In-Second"));
|
||||
|
||||
@@ -88,7 +88,7 @@ class HeaderMapIndexTest {
|
||||
void repeatedResetsAcrossVaryingHeaderCounts_shrinkAndGrowSafely() {
|
||||
// A connection whose successive keep-alive requests have very different header counts
|
||||
// must never see stale entries from a larger previous request bleed into a smaller one.
|
||||
HeaderMap map = new HeaderMap();
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
for (int round = 0; round < 5; round++) {
|
||||
int n = (round % 2 == 0) ? 20 : 2;
|
||||
String[] headers = new String[n];
|
||||
@@ -109,7 +109,7 @@ class HeaderMapIndexTest {
|
||||
// re-trigger index growth (Arrays.copyOf inside ensureIndexCapacity) after the first
|
||||
// reset() has already sized the arrays for this header count — asserted by identity: the
|
||||
// backing array references must be the exact same objects before and after 100k lookups.
|
||||
HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4");
|
||||
Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4");
|
||||
int[] namesBefore = arrayFieldValue(map, "nameOffsets");
|
||||
|
||||
for (int i = 0; i < 100_000; i++) {
|
||||
@@ -124,11 +124,11 @@ class HeaderMapIndexTest {
|
||||
|
||||
@Test
|
||||
void view_poolWraparound_aliasesAnEarlierReturnedView() {
|
||||
// EX-05's documented hazard, demonstrated through the actual public API: HeaderMap's
|
||||
// EX-05's documented hazard, demonstrated through the actual public API: Http1HeaderMap's
|
||||
// view() pool is sized 4 (VIEW_POOL_SIZE); a 5th call in the same request wraps around
|
||||
// and silently repositions the object the 1st call returned.
|
||||
dev.relism.fpr.core.ByteView v1 = null;
|
||||
HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4", "E: 5");
|
||||
Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4", "E: 5");
|
||||
for (String name : new String[]{"A", "B", "C", "D"}) {
|
||||
dev.relism.fpr.core.ByteView v = map.view(name);
|
||||
if (v1 == null) v1 = v;
|
||||
@@ -139,9 +139,9 @@ class HeaderMapIndexTest {
|
||||
assertEquals('5', v1.byteAt(0)); // v1 is now silently "E"'s value, not "A"'s
|
||||
}
|
||||
|
||||
private static int[] arrayFieldValue(HeaderMap map, String fieldName) {
|
||||
private static int[] arrayFieldValue(Http1HeaderMap map, String fieldName) {
|
||||
try {
|
||||
var field = HeaderMap.class.getDeclaredField(fieldName);
|
||||
var field = Http1HeaderMap.class.getDeclaredField(fieldName);
|
||||
field.setAccessible(true);
|
||||
return (int[]) field.get(map);
|
||||
} catch (ReflectiveOperationException e) {
|
||||
+15
-15
@@ -8,15 +8,15 @@ import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class HeaderMapTest {
|
||||
class Http1HeaderMapTest {
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
private static HeaderMap parse(String... headers) {
|
||||
private static Http1HeaderMap parse(String... headers) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String h : headers) sb.append(h).append("\r\n");
|
||||
byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
HeaderMap map = new HeaderMap();
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
map.reset(buffer, 0, buffer.length);
|
||||
return map;
|
||||
}
|
||||
@@ -25,21 +25,21 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void first_existingHeader() {
|
||||
HeaderMap map = parse("Host: localhost", "Accept: text/plain");
|
||||
Http1HeaderMap map = parse("Host: localhost", "Accept: text/plain");
|
||||
assertEquals("localhost", map.first("Host"));
|
||||
assertEquals("text/plain", map.first("Accept"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void first_caseInsensitive() {
|
||||
HeaderMap map = parse("ConteNT-tYPe: application/json");
|
||||
Http1HeaderMap map = parse("ConteNT-tYPe: application/json");
|
||||
assertEquals("application/json", map.first("content-type"));
|
||||
assertEquals("application/json", map.first("CONTENT-TYPE"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void first_missingHeader_returnsNull() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
Http1HeaderMap map = parse("Host: localhost");
|
||||
assertNull(map.first("Accept"));
|
||||
}
|
||||
|
||||
@@ -47,19 +47,19 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void all_multipleValuesByName() {
|
||||
HeaderMap map = parse("Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2");
|
||||
Http1HeaderMap map = parse("Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2");
|
||||
assertEquals(List.of("a=1", "b=2"), map.all("Cookie"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void all_missingHeader_returnsEmptyList() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
Http1HeaderMap map = parse("Host: localhost");
|
||||
assertTrue(map.all("Cookie").isEmpty());
|
||||
}
|
||||
|
||||
@Test
|
||||
void all_returnsAllHeaders() {
|
||||
HeaderMap map = parse("A: 1", "B: 2");
|
||||
Http1HeaderMap map = parse("A: 1", "B: 2");
|
||||
assertEquals(List.of("1", "2"), map.all());
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void view_returnsZeroCopyView() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
Http1HeaderMap map = parse("Host: localhost");
|
||||
ByteView view = map.view("Host");
|
||||
assertNotNull(view);
|
||||
assertEquals(9, view.length());
|
||||
@@ -77,7 +77,7 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void view_missingHeader_returnsNull() {
|
||||
HeaderMap map = parse("Host: localhost");
|
||||
Http1HeaderMap map = parse("Host: localhost");
|
||||
assertNull(map.view("Accept"));
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void emptyMap_returnsNullAndEmptyList() {
|
||||
HeaderMap map = new HeaderMap();
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
assertNull(map.first("Host"));
|
||||
assertTrue(map.all("Host").isEmpty());
|
||||
assertTrue(map.all().isEmpty());
|
||||
@@ -95,7 +95,7 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void forEach_visitsEveryHeaderInDeclarationOrder() {
|
||||
HeaderMap map = parse("Host: localhost", "Accept: text/plain", "Cookie: a=1");
|
||||
Http1HeaderMap map = parse("Host: localhost", "Accept: text/plain", "Cookie: a=1");
|
||||
List<String> seen = new java.util.ArrayList<>();
|
||||
map.forEach((name, value) -> seen.add(toStr(name) + "=" + toStr(value)));
|
||||
assertEquals(List.of("Host=localhost", "Accept=text/plain", "Cookie=a=1"), seen);
|
||||
@@ -103,7 +103,7 @@ class HeaderMapTest {
|
||||
|
||||
@Test
|
||||
void forEach_emptyMap_neverInvokesConsumer() {
|
||||
HeaderMap map = new HeaderMap();
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
map.forEach((name, value) -> fail("must not be called on an empty map"));
|
||||
}
|
||||
|
||||
@@ -111,7 +111,7 @@ class HeaderMapTest {
|
||||
void forEach_reusesTheSameTwoViewInstancesAcrossEveryHeader() {
|
||||
// The zero-allocation contract: forEach must reposition two ByteViews in place, not
|
||||
// allocate a fresh pair per header — same instances across all three calls here.
|
||||
HeaderMap map = parse("A: 1", "B: 2", "C: 3");
|
||||
Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3");
|
||||
List<ByteView> names = new java.util.ArrayList<>();
|
||||
List<ByteView> values = new java.util.ArrayList<>();
|
||||
map.forEach((name, value) -> { names.add(name); values.add(value); });
|
||||
@@ -161,4 +161,46 @@ class RequestBodyTest {
|
||||
body.drain();
|
||||
assertEquals(0, socket.available());
|
||||
}
|
||||
|
||||
// --- EX-22/EX-23: pooled instance, repositioned via reset() --------------------
|
||||
|
||||
@Test
|
||||
void reset_repositionsSamePooledInstance_overSuccessiveRequests() throws IOException {
|
||||
RequestBody body = new RequestBody(); // pooled ctor — no I/O configured yet
|
||||
|
||||
byte[] first = "first".getBytes(StandardCharsets.UTF_8);
|
||||
body.reset(new ByteArrayInputStream(first), 5, new byte[0], 0, 0);
|
||||
assertArrayEquals(first, body.bytes());
|
||||
|
||||
byte[] second = "second-request".getBytes(StandardCharsets.UTF_8);
|
||||
body.reset(new ByteArrayInputStream(second), second.length, new byte[0], 0, 0);
|
||||
assertArrayEquals(second, body.bytes(), "reset() must not leak the previous request's resolved body");
|
||||
}
|
||||
|
||||
@Test
|
||||
void stream_reusesTheSameBoundedStreamInstance_acrossResets() throws IOException {
|
||||
RequestBody body = new RequestBody();
|
||||
|
||||
body.reset(new ByteArrayInputStream("one".getBytes(StandardCharsets.UTF_8)), 3, new byte[0], 0, 0);
|
||||
InputStream stream1 = body.stream();
|
||||
assertEquals("one", new String(stream1.readAllBytes(), StandardCharsets.UTF_8));
|
||||
|
||||
body.reset(new ByteArrayInputStream("two".getBytes(StandardCharsets.UTF_8)), 3, new byte[0], 0, 0);
|
||||
InputStream stream2 = body.stream();
|
||||
assertSame(stream1, stream2, "EX-23: stream() must reposition the one pooled BoundedBufferedInputStream, not allocate a new one per request");
|
||||
assertEquals("two", new String(stream2.readAllBytes(), StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void drain_reusesTheSameDrainBuffer_acrossChunkedResets() throws IOException {
|
||||
RequestBody body = new RequestBody();
|
||||
|
||||
body.reset(new ByteArrayInputStream("chunk one".getBytes(StandardCharsets.UTF_8)), -1L, null, 0, 0);
|
||||
body.drain();
|
||||
|
||||
ByteArrayInputStream secondSocket = new ByteArrayInputStream("chunk two".getBytes(StandardCharsets.UTF_8));
|
||||
body.reset(secondSocket, -1L, null, 0, 0);
|
||||
body.drain();
|
||||
assertEquals(0, secondSocket.available(), "drain() must fully consume the second request's chunked body too");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ class RequestLineTest {
|
||||
ByteView path = viewOf("/api");
|
||||
ByteView query = viewOf("q=1");
|
||||
ByteView proto = viewOf("HTTP/1.1");
|
||||
HeaderMap headers = new HeaderMap();
|
||||
Http1HeaderMap headers = new Http1HeaderMap();
|
||||
|
||||
RequestLine rl = new RequestLine(HttpMethod.GET, path, query, proto, headers);
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@code EX-22}: {@link Request} is pooled <em>per connection</em> (one instance owned by
|
||||
* {@code RequestParser}, repositioned via {@link Request#forParsed} for every request on that
|
||||
* connection) — not via a shared cross-connection pool. The plan's own safety-check wording
|
||||
* ("connection A's {@code Authorization} header must never be visible on connection B") describes
|
||||
* a threat model that does not structurally apply to this design: two different connections
|
||||
* never share a {@code Request} instance at all (each owns its own {@code RequestParser}, hence
|
||||
* its own {@code Request}) — see {@code DECISIONS.md} for the pooling-granularity decision this
|
||||
* follows from. The real, applicable threat this class actually tests: request <em>N+1</em> on
|
||||
* the *same* keep-alive connection must never see stale data left over from request <em>N</em>,
|
||||
* since those two requests genuinely do share one {@code Request} instance.
|
||||
*/
|
||||
class RequestPoolingTest {
|
||||
|
||||
private static ByteView viewOf(String s) {
|
||||
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||
return new ByteView() {
|
||||
public int length() { return bytes.length; }
|
||||
public byte byteAt(int idx) { return bytes[idx]; }
|
||||
};
|
||||
}
|
||||
|
||||
private static Http1HeaderMap headersOf(String... rawLines) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
for (String line : rawLines) sb.append(line).append("\r\n");
|
||||
byte[] buf = sb.toString().getBytes(StandardCharsets.UTF_8);
|
||||
Http1HeaderMap map = new Http1HeaderMap();
|
||||
map.reset(buf, 0, buf.length);
|
||||
return map;
|
||||
}
|
||||
|
||||
@Test
|
||||
void forParsed_reusesTheSamePooledInstance_neverAllocatesANewOne() {
|
||||
Request pooled = new Request();
|
||||
RequestLine line1 = new RequestLine(HttpMethod.GET, viewOf("/a"), null, viewOf("HTTP/1.1"), headersOf());
|
||||
Request r1 = Request.forParsed(pooled, line1, RequestBody.empty(), null, null);
|
||||
assertSame(pooled, r1);
|
||||
|
||||
RequestLine line2 = new RequestLine(HttpMethod.POST, viewOf("/b"), null, viewOf("HTTP/1.1"), headersOf());
|
||||
Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null);
|
||||
assertSame(pooled, r2);
|
||||
assertSame(r1, r2, "the same pooled instance must be returned for every request on one connection");
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondRequest_onSameConnection_doesNotSeeFirstRequestsAuthorizationHeader() {
|
||||
Request pooled = new Request();
|
||||
|
||||
RequestLine first = new RequestLine(HttpMethod.GET, viewOf("/secure"), null, viewOf("HTTP/1.1"),
|
||||
headersOf("Authorization: Bearer super-secret-token-A"));
|
||||
Request r1 = Request.forParsed(pooled, first, RequestBody.empty(), null, null);
|
||||
assertEquals("Bearer super-secret-token-A", r1.header("Authorization"));
|
||||
|
||||
// A second request on the same keep-alive connection, with no Authorization header at all.
|
||||
RequestLine second = new RequestLine(HttpMethod.GET, viewOf("/public"), null, viewOf("HTTP/1.1"),
|
||||
headersOf("Host: example.com"));
|
||||
Request r2 = Request.forParsed(pooled, second, RequestBody.empty(), null, null);
|
||||
|
||||
assertNull(r2.header("Authorization"), "the second request must not see the first request's Authorization header");
|
||||
assertNull(r2.header("authorization"));
|
||||
for (String value : r2.headers()) {
|
||||
assertFalse(value.contains("super-secret-token-A"), "leaked secret found in: " + value);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondRequest_doesNotSeeFirstRequestsPathParams() {
|
||||
Request pooled = new Request();
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/users/123"), null, viewOf("HTTP/1.1"), headersOf());
|
||||
Request r1 = Request.forParsed(pooled, line, RequestBody.empty(), null, null);
|
||||
PathParams.inject(r1, new PathParams(viewOf("/users/123"), new String[]{"id"}, new int[]{7}, new int[]{3}));
|
||||
assertEquals("123", r1.param("id"));
|
||||
|
||||
RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/health"), null, viewOf("HTTP/1.1"), headersOf());
|
||||
Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null);
|
||||
assertNull(r2.param("id"), "path params from the previous request on this connection must not leak");
|
||||
assertNull(r2.getPathParams());
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondRequest_doesNotSeeFirstRequestsCachedPathOrQueryParams() {
|
||||
Request pooled = new Request();
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/first"), viewOf("token=abc"), viewOf("HTTP/1.1"), headersOf());
|
||||
Request r1 = Request.forParsed(pooled, line, RequestBody.empty(), null, null);
|
||||
assertEquals("/first", r1.path());
|
||||
assertEquals("abc", r1.query("token"));
|
||||
|
||||
RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/second"), null, viewOf("HTTP/1.1"), headersOf());
|
||||
Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null);
|
||||
assertEquals("/second", r2.path(), "cachedPath from the previous request must not leak");
|
||||
assertNull(r2.query("token"), "query params from the previous request must not leak");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@code EX-22}'s dev-mode use-after-recycle guard. Exercises the poisoning check directly via
|
||||
* {@code Request.setPoisoningEnabledForTesting} rather than the real {@code Flash.DEV} flag,
|
||||
* which is a {@code static final boolean} fixed once at JVM startup and cannot be toggled by an
|
||||
* individual test — see that field's own comment in {@code Request.java}.
|
||||
*/
|
||||
class RequestRecycleGuardTest {
|
||||
|
||||
@AfterEach
|
||||
void restoreProductionDefault() {
|
||||
// Never leak the test override into other test classes sharing this JVM/fork.
|
||||
Request.setPoisoningEnabledForTesting(false);
|
||||
}
|
||||
|
||||
private static ByteView viewOf(String s) {
|
||||
byte[] bytes = s.getBytes(StandardCharsets.UTF_8);
|
||||
return new ByteView() {
|
||||
public int length() { return bytes.length; }
|
||||
public byte byteAt(int idx) { return bytes[idx]; }
|
||||
};
|
||||
}
|
||||
|
||||
private static Request active() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/x"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningDisabled_recycledRequestStillAccessible() {
|
||||
Request.setPoisoningEnabledForTesting(false);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertDoesNotThrow(r::method, "poisoning disabled (production default) must never throw");
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_freshRequest_accessibleNormally() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
assertDoesNotThrow(r::path);
|
||||
assertDoesNotThrow(() -> r.header("Host"));
|
||||
assertDoesNotThrow(r::method);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_methodThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::method);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_pathThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::path);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_headerThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, () -> r.header("Host"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_paramThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, () -> r.param("id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_queryThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, () -> r.query("q"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_remoteAddressThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::remoteAddress);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_isSecureThrows() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
Request r = active();
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::isSecure);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reusedAfterReset_becomesAccessibleAgain() {
|
||||
Request.setPoisoningEnabledForTesting(true);
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/first"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request r = new Request(line, new byte[0]);
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::path);
|
||||
|
||||
// Simulate the connection loop pulling this pooled instance back out for the next
|
||||
// request: Request.forParsed's reset() call re-activates it.
|
||||
RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/second"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request reused = Request.forParsed(r, line2, RequestBody.empty(), null, null);
|
||||
assertSame(r, reused, "forParsed must reposition the same pooled instance, not allocate a new one");
|
||||
assertDoesNotThrow(reused::path);
|
||||
assertEquals("/second", reused.path());
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class RequestTest {
|
||||
|
||||
@Test
|
||||
void request_creationAndAccessors() {
|
||||
HeaderMap headers = new HeaderMap();
|
||||
Http1HeaderMap headers = new Http1HeaderMap();
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/path"), viewOf("q=1"), viewOf("HTTP/1.1"), headers);
|
||||
byte[] body = "body".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
@@ -43,7 +43,7 @@ class RequestTest {
|
||||
@Test
|
||||
void header_delegatesToRequestLine() {
|
||||
byte[] buffer = "Host: localhost\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
HeaderMap headers = new HeaderMap();
|
||||
Http1HeaderMap headers = new Http1HeaderMap();
|
||||
headers.reset(buffer, 0, buffer.length);
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), headers);
|
||||
Request r = new Request(line, new byte[0]);
|
||||
@@ -57,7 +57,7 @@ class RequestTest {
|
||||
|
||||
@Test
|
||||
void param_lazyGet() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap());
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertNull(r.param("id"));
|
||||
@@ -70,7 +70,7 @@ class RequestTest {
|
||||
|
||||
@Test
|
||||
void query_lazyGet_fromQueryString() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new HeaderMap());
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertEquals("1", r.query("a"));
|
||||
@@ -80,7 +80,7 @@ class RequestTest {
|
||||
|
||||
@Test
|
||||
void query_lazyGet_nullQueryString() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap());
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request r = new Request(line, new byte[0]);
|
||||
|
||||
assertNull(r.query("a"));
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** {@code EX-21}: mirrors {@code RequestPoolingTest} for {@link Response}. */
|
||||
class ResponsePoolingTest {
|
||||
|
||||
@Test
|
||||
void reset_returnsSameInstanceAndClearsPreviousState() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.header("X-Trace", "abc123").status(201).body("first body");
|
||||
assertEquals(1, r.getHeaders().size());
|
||||
|
||||
Response reset = r.reset(200, ContentType.JSON);
|
||||
assertSame(r, reset, "reset() must reposition the same instance, not allocate a new one");
|
||||
assertEquals(200, reset.getStatusCode());
|
||||
assertNull(reset.getBody(), "body from the previous cycle must not leak");
|
||||
assertTrue(reset.getHeaders().isEmpty(), "headers from the previous cycle must not leak");
|
||||
assertArrayEquals(ContentType.JSON.getBytes(), reset.getContentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondCycle_doesNotSeeFirstCyclesCustomHeader() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.header("X-Secret", "leaked-if-broken");
|
||||
assertEquals(1, r.getHeaders().size());
|
||||
|
||||
r.reset(200, ContentType.TEXT_PLAIN);
|
||||
r.header("X-Public", "fine");
|
||||
|
||||
assertEquals(1, r.getHeaders().size());
|
||||
String only = new String(r.getHeaders().get(0));
|
||||
assertTrue(only.contains("X-Public"));
|
||||
assertFalse(only.contains("X-Secret"), "stale header from the previous cycle leaked: " + only);
|
||||
}
|
||||
|
||||
@Test
|
||||
void secondCycle_reusesHeaderRegionAcrossManyHeaders_staysCorrect() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
for (int cycle = 0; cycle < 5; cycle++) {
|
||||
r.reset(200, ContentType.TEXT_PLAIN);
|
||||
for (int i = 0; i < 10; i++) {
|
||||
r.header("X-Cycle" + cycle + "-H" + i, "v" + i);
|
||||
}
|
||||
assertEquals(10, r.getHeaders().size(), "cycle " + cycle);
|
||||
String last = new String(r.getHeaders().get(9));
|
||||
assertTrue(last.contains("X-Cycle" + cycle + "-H9: v9"), "cycle " + cycle + ": " + last);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void mixedStructuredAndRawHeaders_preserveInsertionOrder() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.header("A", "1");
|
||||
r.header("B-raw: 2\r\n".getBytes());
|
||||
r.header("C", "3");
|
||||
|
||||
var headers = r.getHeaders();
|
||||
assertEquals(3, headers.size());
|
||||
assertEquals("A: 1\r\n", new String(headers.get(0)));
|
||||
assertEquals("B-raw: 2\r\n", new String(headers.get(1)));
|
||||
assertEquals("C: 3\r\n", new String(headers.get(2)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preEncodedHeader_roundTripsThroughGetHeaders() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
PreEncodedHeader h = new PreEncodedHeader("X-Static", "value");
|
||||
r.header(h);
|
||||
assertEquals("X-Static: value\r\n", new String(r.getHeaders().get(0)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/** {@code EX-21}'s dev-mode use-after-recycle guard — mirrors {@code RequestRecycleGuardTest}. */
|
||||
class ResponseRecycleGuardTest {
|
||||
|
||||
@AfterEach
|
||||
void restoreProductionDefault() {
|
||||
Response.setPoisoningEnabledForTesting(false);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningDisabled_recycledResponseStillAccessible() {
|
||||
Response.setPoisoningEnabledForTesting(false);
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.recycle();
|
||||
assertDoesNotThrow(r::getStatusCode);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_getStatusCodeThrows() {
|
||||
Response.setPoisoningEnabledForTesting(true);
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::getStatusCode);
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_headerThrows() {
|
||||
Response.setPoisoningEnabledForTesting(true);
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, () -> r.header("X", "Y"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterRecycle_bodyThrows() {
|
||||
Response.setPoisoningEnabledForTesting(true);
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, () -> r.body("x"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void poisoningEnabled_afterReset_accessibleAgain() {
|
||||
Response.setPoisoningEnabledForTesting(true);
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
r.recycle();
|
||||
assertThrows(IllegalStateException.class, r::getStatusCode);
|
||||
r.reset(200, ContentType.TEXT_PLAIN);
|
||||
assertDoesNotThrow(r::getStatusCode);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package dev.relism.flash.models;
|
||||
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
class ResponseSerializerTest {
|
||||
|
||||
private static List<String> collect(Response r) {
|
||||
List<String> fields = new ArrayList<>();
|
||||
ResponseSerializer.forEachField(r, (nameBuf, nameOff, nameLen, valueBuf, valueOff, valueLen) ->
|
||||
fields.add(new String(nameBuf, nameOff, nameLen, StandardCharsets.US_ASCII)
|
||||
+ "=" + new String(valueBuf, valueOff, valueLen, StandardCharsets.US_ASCII)));
|
||||
return fields;
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentTypeFirst_thenCustomHeadersInOrder() {
|
||||
Response r = new Response(200, ContentType.JSON);
|
||||
r.header("X-A", "1").header("X-B", "2");
|
||||
assertEquals(List.of("Content-Type=application/json", "X-A=1", "X-B=2"), collect(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void contentTypeNone_isSkipped_notEmptyValue() {
|
||||
Response r = new Response(200, ContentType.NONE);
|
||||
r.header("X-Only", "here");
|
||||
assertEquals(List.of("X-Only=here"), collect(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void noHeadersAtAll_onlyContentType() {
|
||||
Response r = new Response(200, ContentType.TEXT_PLAIN);
|
||||
assertEquals(List.of("Content-Type=text/plain"), collect(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rawPreEncodedHeaderBytes_areExcludedFromEnumeration() {
|
||||
// header(byte[]) has no recoverable (name, value) structure -- ResponseSerializer must
|
||||
// skip it (Http1ResponseWriter still renders it, via writeHeaders, just not through this
|
||||
// protocol-neutral path).
|
||||
Response r = new Response(200, ContentType.NONE);
|
||||
r.header("X-Structured", "yes");
|
||||
r.header("X-Raw: no-structure\r\n".getBytes());
|
||||
assertEquals(List.of("X-Structured=yes"), collect(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preEncodedHeaderObject_isIncluded_withStructure() {
|
||||
Response r = new Response(200, ContentType.NONE);
|
||||
r.header(new PreEncodedHeader("X-Boot", "constant"));
|
||||
assertEquals(List.of("X-Boot=constant"), collect(r));
|
||||
}
|
||||
|
||||
@Test
|
||||
void zeroAllocation_byteRangesAreSlicesOfResponsesOwnBuffers_notCopies() {
|
||||
Response r = new Response(200, ContentType.NONE);
|
||||
r.header("X-A", "value-a");
|
||||
byte[][] captured = new byte[2][];
|
||||
ResponseSerializer.forEachField(r, (nameBuf, nameOff, nameLen, valueBuf, valueOff, valueLen) -> {
|
||||
captured[0] = nameBuf;
|
||||
captured[1] = valueBuf;
|
||||
});
|
||||
// Both slices must reference the SAME backing array (the response's own header region) --
|
||||
// proves no copy was made to hand the field to the consumer.
|
||||
assertSame(captured[0], captured[1]);
|
||||
}
|
||||
}
|
||||
@@ -134,4 +134,35 @@ class ResponseTest {
|
||||
void getHeaders_emptyWhenNoneAdded() {
|
||||
assertTrue(new Response(200, new byte[0], ContentType.TEXT_PLAIN).getHeaders().isEmpty());
|
||||
}
|
||||
|
||||
// --- EX-43: response header budget (Phase 6 zero-alloc DoD) ---
|
||||
|
||||
@Test
|
||||
void header_exceedingMaxCount_throws() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
for (int i = 0; i < dev.relism.flash.http.Http1Limits.MAX_RESPONSE_HEADER_COUNT; i++) {
|
||||
r.header("X-" + i, "v");
|
||||
}
|
||||
assertThrows(IllegalStateException.class, () -> r.header("one-too-many", "v"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void header_exceedingMaxRegionBytes_throws() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
String bigValue = "v".repeat(1024);
|
||||
assertThrows(IllegalStateException.class, () -> {
|
||||
// Each call adds ~1024 bytes; comfortably crosses MAX_RESPONSE_HEADER_BYTES well
|
||||
// before MAX_RESPONSE_HEADER_COUNT would trigger first.
|
||||
for (int i = 0; i < dev.relism.flash.http.Http1Limits.MAX_RESPONSE_HEADER_COUNT; i++) {
|
||||
r.header("X-" + i, bigValue);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void header_withinBudget_stillWorksNormally() {
|
||||
Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN);
|
||||
r.header("X-Foo", "bar");
|
||||
assertEquals(1, r.getHeaders().size());
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
package dev.relism.flash.routing.routers.fastpathrouter;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.HeaderMap;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
@@ -23,7 +23,7 @@ class FastPathRouterImplTest {
|
||||
RequestLine line = new RequestLine(
|
||||
method, pathView, null,
|
||||
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
|
||||
new HeaderMap()
|
||||
new Http1HeaderMap()
|
||||
);
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
+25
@@ -28,6 +28,31 @@ class FastPathViewsTest {
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(10));
|
||||
}
|
||||
|
||||
// --- EX-42: reset() repositions the same instance, zero allocation ---------
|
||||
|
||||
@Test
|
||||
void requestByteView_reset_repositionsSameInstance() {
|
||||
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10);
|
||||
assertEquals("/api/users", view.toString());
|
||||
|
||||
byte[] other = "PUT /orders/9 HTTP/1.1".getBytes(StandardCharsets.UTF_8);
|
||||
view.reset(other, 4, 8);
|
||||
assertEquals(8, view.length());
|
||||
assertEquals("/orders/", view.toString());
|
||||
}
|
||||
|
||||
@Test
|
||||
void requestByteView_reset_updatesArrayBackedByteViewAccessors() {
|
||||
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 0, 3);
|
||||
byte[] other = "zzHELLOzz".getBytes(StandardCharsets.UTF_8);
|
||||
view.reset(other, 2, 5);
|
||||
|
||||
assertSame(other, view.array());
|
||||
assertEquals(2, view.offset());
|
||||
assertEquals(5, view.length());
|
||||
assertEquals("HELLO", view.toString());
|
||||
}
|
||||
|
||||
// --- MethodPathByteView ---
|
||||
|
||||
@Test
|
||||
|
||||
@@ -55,4 +55,38 @@ class ByteTemplateTest {
|
||||
byte[] result = tpl.render("v1", "1", "v2", "2");
|
||||
assertEquals("A12B", new String(result, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
// --- EX-28: renderInto(buffer, offset, ...) ------------------------------------
|
||||
|
||||
@Test
|
||||
void renderInto_writesAtOffset_andReturnsLength() {
|
||||
ByteTemplate tpl = new ByteTemplate("Hello {{name}}!");
|
||||
byte[] buffer = new byte[64];
|
||||
int len = tpl.renderInto(buffer, 5, "name", "World");
|
||||
|
||||
assertEquals("Hello World!".length(), len);
|
||||
assertEquals("Hello World!", new String(buffer, 5, len, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderInto_repeatedPlaceholder_fillsEveryOccurrence() {
|
||||
ByteTemplate tpl = new ByteTemplate("{{var}} == {{var}}");
|
||||
byte[] buffer = new byte[32];
|
||||
int len = tpl.renderInto(buffer, 0, "var", "test");
|
||||
assertEquals("test == test", new String(buffer, 0, len, StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderInto_bufferTooSmall_throws() {
|
||||
ByteTemplate tpl = new ByteTemplate("Hello {{name}}!");
|
||||
byte[] buffer = new byte[5];
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> tpl.renderInto(buffer, 0, "name", "World"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void renderInto_negativeOffset_throws() {
|
||||
ByteTemplate tpl = new ByteTemplate("Hi {{name}}");
|
||||
byte[] buffer = new byte[32];
|
||||
assertThrows(IndexOutOfBoundsException.class, () -> tpl.renderInto(buffer, -1, "name", "X"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.relism.flash.template;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.HeaderMap;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||
@@ -24,7 +24,7 @@ class ErrorPagesTest {
|
||||
byte[] protoBytes = protocol.getBytes(StandardCharsets.UTF_8);
|
||||
FastPathViews.RequestByteView protoView = new FastPathViews.RequestByteView(protoBytes, 0, protoBytes.length);
|
||||
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new HeaderMap());
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new Http1HeaderMap());
|
||||
return new Request(line, new byte[0]);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,8 +15,9 @@ class ScratchPoolTest {
|
||||
ConnectionScratch scratch = pool.acquire();
|
||||
assertNotNull(scratch);
|
||||
assertNotNull(scratch.sha1);
|
||||
assertEquals(ConnectionScratch.DECIMAL_BUFFER_SIZE, scratch.decimalBuffer.length);
|
||||
assertEquals(ConnectionScratch.RELAY_BUFFER_SIZE, scratch.relayBuffer.length);
|
||||
assertNotNull(scratch.responseHead);
|
||||
assertEquals(0, scratch.responseHead.length());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package dev.relism.flash.websocket;
|
||||
|
||||
import dev.relism.flash.http.HttpMethod;
|
||||
import dev.relism.flash.models.HeaderMap;
|
||||
import dev.relism.flash.models.Http1HeaderMap;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestLine;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
@@ -25,7 +25,7 @@ class WebSocketSessionTest {
|
||||
|
||||
@Test
|
||||
void request_returnsWhatWasPassedToConstructor() {
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/chat"), null, viewOf("HTTP/1.1"), new HeaderMap());
|
||||
RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/chat"), null, viewOf("HTTP/1.1"), new Http1HeaderMap());
|
||||
Request req = new Request(line, new byte[0]);
|
||||
WebSocketSession session = new WebSocketSession(
|
||||
new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64, req, false);
|
||||
|
||||
Reference in New Issue
Block a user