refactor(core): remove out-of-scope HTTP/2 client/proxy, reorganize docs, refresh README
HttpProxy and Http2Client (719 LOC) shipped a reverse-proxy adapter and outbound HTTP/2 client from flash core with zero callers anywhere in the server itself — only each other and their own tests. An HTTP/1.1+2 server framework has no business bundling an outbound client; that capability belongs in its own flash-extensions/flash-ext-* module if/when it's needed. Removed, along with the now-dead src/bench load driver that depended on Http2Client (no replacement client written here — flagged as follow-up work, not silently dropped). docs/http2/ had accumulated core, cross-protocol documentation alongside genuine HTTP/2-protocol internals: HTTP1-HARDENING, TRANSPORT, MESSAGE-MODEL, TRAILERS-AND-STREAMING and BYTES all describe machinery HTTP/1.1 and HTTP/2 share, not HTTP/2 specifically. Moved to a new docs/core/, leaving docs/http2/ to the protocol layers, wire internals and operational docs that are actually HTTP/2-specific. CLEARTEXT-AND-PROXY.md renamed to CLEARTEXT.md and its now-removed upstream-client section cut, matching the source removal above. README.md: removed the "HTTP/2 upstream proxy" section (documented the deleted HttpProxy/Http2Client), the flash-bench module row and build command (not a module that exists in this repo), and fixed every doc link to the new docs/core/ paths. Added the new FlashConfiguration.maxConnections field to the configuration reference. src/bench/ (a load-test harness distinct from the JMH suite, not wired into any Maven profile or CI) is committed here for the first time.
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
# The byte layer
|
||||
|
||||
Audience: contributors. This is the design record for `dev.relism.flash.bytes` — the
|
||||
protocol-neutral byte primitives both HTTP/1.1 and HTTP/2 build on — and for the Phase 4
|
||||
allocation/scanning fixes (`EX-04`, `EX-05`, `EX-09`, `EX-19`, `EX-25`, `EX-26`, `EX-33`, plus
|
||||
`EX-06`'s router half) that consume them.
|
||||
|
||||
## Why this exists
|
||||
|
||||
Before Phase 4, byte-scanning and case-insensitive comparison logic was duplicated, slightly
|
||||
differently, in `RequestParser`, `HeaderMap`, and `Http1KeepAlive`; `fpr-core`'s word-at-a-time
|
||||
router-matching fast path (`ByteCompare`) was wired up but never actually enabled anywhere
|
||||
(`EX-04` — every `ByteView` implementation returned `supportsLong() == false`); and four call
|
||||
sites allocated a fresh view, array, or `String` per call on paths a realistic middleware chain
|
||||
hits 6–10 times per request. `dev.relism.flash.bytes` is the single home these fixes converge on,
|
||||
so no later phase (HPACK, the frame layer) has to invent its own scanning primitives.
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
dev.relism.flash.bytes
|
||||
├── ByteScan static scanning/comparison/hashing utilities, scalar + SWAR
|
||||
├── ArrayBackedByteView capability interface: a ByteView backed by one contiguous byte[]
|
||||
├── SegmentedByteView the deliberate non-array-backed case (K discontiguous segments)
|
||||
├── PooledSlice reusable ArrayBackedByteView, the EX-05 fix
|
||||
├── SlicePool a small fixed-size ring of PooledSlice
|
||||
├── ByteWriter index-based writer into a growable byte[] scratch buffer
|
||||
└── Pairs the (hi<<32)|lo allocation-free pair-return idiom, named
|
||||
```
|
||||
|
||||
## The `ByteView` capability hierarchy
|
||||
|
||||
```
|
||||
ByteView (fpr-core)
|
||||
├── ArrayBackedByteView capability: array() + offset()
|
||||
│ ├── FastPathViews.RequestByteView RequestParser's request-line/header slices
|
||||
│ ├── FastPathViews.SocketByteView a bare byte[] (e.g. a WebSocket payload)
|
||||
│ ├── FastPathViews.StringByteView a String's UTF-8 bytes
|
||||
│ └── PooledSlice the EX-05 reusable, pool-issued slice
|
||||
└── (bare ByteView, not array-backed)
|
||||
├── SegmentedByteView K discontiguous segments (general-purpose; HPACK stays contiguous)
|
||||
└── FastPathViews.MethodPathByteView method bytes + another ByteView, composed
|
||||
```
|
||||
|
||||
Code holding a bare `ByteView` and wanting the fast path when the concrete instance happens to
|
||||
be array-backed does `instanceof ArrayBackedByteView` and falls back to the byte-at-a-time path
|
||||
otherwise — see `ArrayBackedByteView`'s own Javadoc. This is used throughout Phase 4's fixes:
|
||||
`Request.path()`, `PathParams.get()` (`EX-25`), and `QueryParams.decode`'s clean-value fast path
|
||||
(`EX-26`) all take this shape.
|
||||
|
||||
## `EX-04`: the `supportsLong()`/`longAt()` contract
|
||||
|
||||
`fpr-core`'s `ByteCompare` (decompiled from `fpr-core-1.1.1`, since no source jar is published)
|
||||
reads its comparison word via
|
||||
`MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN)` and only takes the
|
||||
word-at-a-time branch when the caller passed `useLong = true`, comparing the result bit-for-bit
|
||||
against whatever `ByteView#longAt` returns. The contract this imposes on any `longAt`
|
||||
implementation:
|
||||
|
||||
- Return the same value `LONG_VIEW.get(array, pos)` would, for the identical 8 bytes — meaning
|
||||
**little-endian**, fixed, regardless of the host's native byte order (unlike `ByteScan`'s own
|
||||
SWAR internals, which use `ByteOrder.nativeOrder()` for speed — see below for why that's a
|
||||
different, safe choice in a different context).
|
||||
- The caller (`ByteCompare`) never calls `longAt(i)` without first establishing `i + 8 <=
|
||||
length()` — so `longAt` implementations do not re-check this themselves (a defensive check
|
||||
would be dead code on every real call path).
|
||||
|
||||
`FastPathViews.RequestByteView`/`SocketByteView`/`StringByteView` implement this;
|
||||
`MethodPathByteView` (composite, no single backing array) and `SegmentedByteView` (genuinely
|
||||
discontiguous) both stay at the inherited `false` default — a word-at-a-time read is not merely
|
||||
unimplemented for these, it is structurally unsound (a read could straddle two sources).
|
||||
|
||||
**Verified against `fpr-core` directly** (`FastPathViewsLongAtTest`), not merely by reading
|
||||
bytecode: `ByteCompare.equals`/`indexOf` called with `useLong=true` and `useLong=false` are
|
||||
asserted to agree on identical content, on content diverging at every position across an
|
||||
8+-byte range (word-interior, word-boundary, and scalar-tail cases), and end-to-end through a
|
||||
real compiled `fpr-core` router with literal route segments ≥ 8 bytes — including a near-miss
|
||||
route differing only in its last byte, to catch exactly the kind of bounds/endianness bug that
|
||||
would otherwise silently mis-route a request (the failure mode `EX-04`'s registry entry calls out
|
||||
by name as the worst possible one here).
|
||||
|
||||
## `ByteScan`'s SWAR technique
|
||||
|
||||
Both `ByteScan.indexOf` (single byte) and `ByteScan.indexOfCrLfCrLf` (the `\r\n\r\n` header
|
||||
terminator, `EX-33`) use the classic "does this word contain byte `b`" bit trick: XOR the 8-byte
|
||||
word against `b` broadcast into every lane, then test for any zero lane with
|
||||
`(v - 0x0101...01) & ~v & 0x8080...80`. `indexOfCrLfCrLf` uses this as a pre-filter to find a
|
||||
candidate `CR` byte 8 at a time, then a cheap scalar 3-byte check verifies the full 4-byte match
|
||||
at each candidate — so a scan touches every byte once per 8-byte stride in the common
|
||||
no-CR-yet case, rather than once per byte.
|
||||
|
||||
This reads the word via `ByteOrder.nativeOrder()`, not a fixed order — safe here (unlike
|
||||
`EX-04`'s `longAt`) because nothing compares this word against an independently-decoded one;
|
||||
byte-equality detection itself (finding *that* a matching lane exists) is indifferent to lane
|
||||
order, and position extraction (`laneIndexOf`) branches on the actual native order once, at
|
||||
class-init time, to convert a matching bit back into the correct array index either way.
|
||||
|
||||
Every SWAR method has a scalar counterpart (`indexOfScalar`, `indexOfCrLfCrLfScalar`) used as
|
||||
the correctness oracle: `ByteScanTest` property-tests SWAR against scalar at every length 0–256
|
||||
and every match position (including unaligned starts and matches at the very last valid byte),
|
||||
and `ByteScanFuzzTest` throws 20 000 fully-random trials at each, per the plan's task 1. All
|
||||
green — see the class's own Javadoc for the full technique writeup.
|
||||
|
||||
## `Http1HeaderMap`'s index
|
||||
|
||||
Originally, every `Http1HeaderMap` lookup (`first`, `all`, `view`, `valueEqualsIgnoreCase`)
|
||||
rescanned the entire header section from scratch — O(n·m) for a realistic middleware chain
|
||||
performing 6–10 lookups per request. `RequestParser` now populates the index while it validates
|
||||
each header line; direct `Http1HeaderMap.reset()` callers scan the section exactly once. It records
|
||||
per-header `(nameOffset, nameLength, valueOffset, valueLength)` and a case-insensitive
|
||||
32-bit FNV-1a hash of the name (`ByteScan.hashNameIgnoreCaseAscii`) into `int[]` arrays grown
|
||||
(never shrunk) to the connection's high-water mark, capped by `Http1Limits.MAX_HEADER_COUNT`
|
||||
(asserted, not silently truncated — the parser rejects a request that would exceed it).
|
||||
Every lookup then compares the caller's own hash (`ByteScan.hashNameIgnoreCaseAscii(String)`,
|
||||
computed once) against the index's hashes before ever falling back to a full case-insensitive
|
||||
name comparison. Production therefore performs one combined validation/index pass rather than
|
||||
one parse-time pass plus one rescan per lookup. `forEach` uses the same index rather than keeping
|
||||
an independent scanner.
|
||||
|
||||
## `EX-05`: pooled slices
|
||||
|
||||
`Http1HeaderMap.view`, `QueryParams.view`, and `PathParams.view` used to allocate a fresh anonymous
|
||||
`ByteView` (plus its capturing instance) on every call. Each now draws from a small
|
||||
(`VIEW_POOL_SIZE = 4`) `SlicePool` of reusable `PooledSlice` instances instead. The lifetime
|
||||
contract, restated on each method: **a returned view stays valid until either the request ends,
|
||||
or the same `view()` method is called `VIEW_POOL_SIZE` more times on the same instance —
|
||||
whichever comes first** — at which point the ring silently repositions the same object over
|
||||
different bytes. This is a real, demonstrated hazard, not a hypothetical one:
|
||||
`SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice` and the analogous tests in
|
||||
`Http1HeaderMapIndexTest`, `QueryParamsFastPathTest`, and `PathParamsTest` all show a 5th call
|
||||
returning the exact same object instance the 1st call did, now aliased to different content.
|
||||
|
||||
`QueryParams` and `PathParams`'s pools are created **lazily**, on the first actual `view()` call
|
||||
— not eagerly in the constructor — because both classes are otherwise-cheap objects created per
|
||||
request (or, for `PathParams`'s `FastPathRouterImpl`-owned reusable instance, once per
|
||||
connection) regardless of whether `view()` is ever invoked; an eager pool would add
|
||||
`VIEW_POOL_SIZE` allocations to every such object whether or not it needed them; `Http1HeaderMap`'s
|
||||
pool, by contrast, is unconditionally useful (every request's map handles headers) and is
|
||||
constructed eagerly for simplicity.
|
||||
|
||||
**Two documented, deliberately-kept exceptions to "no `new ByteView()` remains"**: `QueryParams.view`
|
||||
and `PathParams.view` each retain a fallback anonymous `ByteView` for the case where their
|
||||
backing source is *not* `ArrayBackedByteView` — structurally unreachable on the real request path
|
||||
today (`RequestParser` only ever constructs array-backed views), kept because both constructors
|
||||
are `public` and could in principle be called with an arbitrary `ByteView`. A silent, correct,
|
||||
allocating fallback was judged preferable to either crashing on a technically-valid input or
|
||||
deleting a case that only test code could exercise. `Http1HeaderMap.view` has no such fallback
|
||||
— it is always buffer-backed by construction.
|
||||
|
||||
## `EX-19`/`EX-06` (router half): the `FastPathRouterImpl` scratch
|
||||
|
||||
`FastPathRouterImpl.RouteScratch` (created once per connection via `AbstractRouter#newScratch`,
|
||||
replacing the `ThreadLocal<MatchResult>`/`ThreadLocal<MethodPathByteView>` pair, as an opaque
|
||||
caller-owned object rather than an extension of `ConnectionScratch`) also owns the reusable
|
||||
path-param arrays and a single long-lived
|
||||
`PathParams` instance, grown (via `ensureParamCapacity`, doubling) to the largest param count any
|
||||
route on that connection has ever matched, and repositioned (`PathParams#reset`) rather than
|
||||
reallocated on every match. `PathParams` gained a second, count-explicit constructor and a public
|
||||
`reset(ByteView, int)` specifically for this: the reusable arrays can be larger than a given
|
||||
request's actual param count, so `count` must be tracked independently of `names.length`.
|
||||
|
||||
## `EX-25`/`EX-26`: single-allocation `String` construction
|
||||
|
||||
`Request.path()`, `PathParams.get()`, and (for the common "no `%`/`+` in the value" case)
|
||||
`QueryParams.decode` now build their result `String` directly from the backing array via
|
||||
`new String(array, offset, length, UTF_8)` when the source is `ArrayBackedByteView`, instead of a
|
||||
byte-at-a-time copy into a scratch `byte[]` followed by a second allocation for the `String`
|
||||
itself. `QueryParams.decode` scans the value once for `%`/`+` first; only a value that actually
|
||||
needs percent-decoding pays for the scratch-buffer path — verified to produce byte-identical
|
||||
output to the always-decode path it bypasses, across clean values, `+`-only, `%XX`-only, invalid
|
||||
escapes, and mixed queries (`QueryParamsFastPathTest`).
|
||||
|
||||
## Performance measurement
|
||||
|
||||
`EX-04` (the router's word-at-a-time path) and `EX-33` (the SWAR header-end scan) both carried an
|
||||
explicit "measure, and keep only if it doesn't cost" requirement. Both were measured together
|
||||
with the phase's overall zero-allocation contract in one JMH pass, and both were kept.
|
||||
@@ -0,0 +1,92 @@
|
||||
# HTTP/1.1 hardening
|
||||
|
||||
Audience: operators. This is the document to read when a `400`/`413`/`414`/`431`/`501` shows up
|
||||
in the logs and it isn't obvious why. Every rejection rule Flash's HTTP/1.1 parser enforces is
|
||||
listed here with its RFC citation and the status it produces. Contributor-level detail (why each
|
||||
check is implemented the way it is, the exact code paths) lives in the Javadoc of
|
||||
`RequestParser`, `ChunkedInputStream`, and `dev.relism.flash.exceptions.MalformedRequestException`.
|
||||
|
||||
Every rejection in this document has one thing in common: **the connection is always closed
|
||||
afterwards, never kept alive.** A rejected request is exactly the situation a smuggling attack
|
||||
needs a reusable connection for, so none of these rejections offer one — see
|
||||
`MalformedRequestException`'s Javadoc.
|
||||
|
||||
## Request-smuggling defenses (RFC 9112 §6.1)
|
||||
|
||||
| Rule | Status | Detail |
|
||||
|---|---|---|
|
||||
| `Content-Length` and `Transfer-Encoding` both present | `400` | The canonical CL.TE/TE.CL smuggling vector. Rejected regardless of which header appears first. |
|
||||
| Multiple `Content-Length` lines with **differing** values | `400` | Identical repeated values are tolerated (RFC 9110 §8.6 permits treating them as one). |
|
||||
| `Transfer-Encoding` whose **final** coding is not `chunked` | `501` | Flash implements only `chunked`; anything else (`gzip` alone, or `chunked, gzip` — chunked must be *last*) is unsupported. |
|
||||
|
||||
## Strict `Content-Length` parsing (RFC 9110 §8.6)
|
||||
|
||||
| Input | Status |
|
||||
|---|---|
|
||||
| Empty value | `400` |
|
||||
| Any non-digit byte (including a leading `+` or `-`) | `400` |
|
||||
| More than 19 digits | `400` |
|
||||
| Value overflows `Long.MAX_VALUE` | `400` |
|
||||
| Value exceeds `Http1Limits.MAX_CONTENT_LENGTH` (4 GiB by default) | `413` |
|
||||
|
||||
The previous parser silently skipped non-digit characters (`"5abc"` parsed as `5`; `"-1"` parsed
|
||||
as `1`) instead of rejecting them — this is the fix.
|
||||
|
||||
## Header and request-line limits (`Http1Limits`)
|
||||
|
||||
| Limit | Default | Status when exceeded |
|
||||
|---|---|---|
|
||||
| `MAX_HEADER_COUNT` | 100 | `431 Request Header Fields Too Large` |
|
||||
| `MAX_HEADER_NAME_LENGTH` | 256 B | `431` |
|
||||
| `MAX_HEADER_VALUE_LENGTH` | 8192 B | `431` |
|
||||
| `MAX_REQUEST_LINE_LENGTH` | 8192 B | `431` |
|
||||
| Header block exceeds `maxHeaderBufferSize` (or the connection ends before it completes) | configurable, default 64 KiB | `431` |
|
||||
|
||||
## Line-terminator and header-syntax correctness (RFC 9112 §5)
|
||||
|
||||
| Rule | Status |
|
||||
|---|---|
|
||||
| A `\r` not immediately followed by `\n` (bare CR) | `400` — a known desynchronization/smuggling surface |
|
||||
| A header line beginning with whitespace (obsolete line folding, RFC 9112 §5.2) | `400` |
|
||||
| A header name containing a byte outside RFC 9110 §5.6.2's `tchar` set | `400` |
|
||||
| A header line with no `:` | `400` |
|
||||
|
||||
## Chunked transfer safety (RFC 9112 §7.1, `Http1Limits`)
|
||||
|
||||
| Limit | Default | Status when exceeded |
|
||||
|---|---|---|
|
||||
| `MAX_CHUNK_SIZE` | 16 MiB | `413` |
|
||||
| Chunk-size line longer than 16 hex digits | — | `400` |
|
||||
| `MAX_CHUNK_EXT_LENGTH` (the optional `;name=value` after a chunk size) | 256 B | `400` |
|
||||
| `MAX_CHUNKS_PER_BODY` | 100 000 | `413` |
|
||||
| `MAX_TRAILER_COUNT` | 50 | `431` |
|
||||
| A chunk's data not followed by `\r\n`, or a malformed chunk-size/trailer terminator | — | `400` |
|
||||
|
||||
Trailers are consumed within the bounds above and exposed through `Request.trailers()` on both
|
||||
HTTP/1.1 and HTTP/2.
|
||||
|
||||
## Timeouts (`FlashConfiguration`)
|
||||
|
||||
| Setting | Default | Covers |
|
||||
|---|---|---|
|
||||
| `idleKeepAliveTimeoutMs` | 60 000 | How long a keep-alive connection may sit idle waiting for its next request. |
|
||||
| `headerReadTimeoutMs` | 10 000 | Once the first byte of a request arrives, how long the full header block may take. |
|
||||
| `bodyReadTimeoutMs` | 30 000 | How long reading the body (by the handler, or the automatic post-response drain) may take. |
|
||||
| `shutdownDrainTimeoutMs` | 15 000 | How long graceful shutdown waits for in-flight requests before force-closing. |
|
||||
|
||||
These are enforced by an **absolute deadline**, not merely `Socket.setSoTimeout`. A per-read
|
||||
socket timeout alone never trips against a peer that sends one byte just often enough to keep
|
||||
each individual read alive (the classic slowloris shape) — see
|
||||
`dev.relism.flash.transport.BufferedByteSource`'s Javadoc for how the absolute deadline is
|
||||
implemented on top of the JDK's per-read-only timeout API.
|
||||
|
||||
## TLS (RFC 9113 §9.2.2, applies once a listener offers `h2` over ALPN)
|
||||
|
||||
- The TLS handshake is forced explicitly (not left to the JDK's lazy on-first-read trigger)
|
||||
before any protocol decision is made, and is bounded by `headerReadTimeoutMs`.
|
||||
- When a listener's `TlsConfig.applicationProtocols` includes `"h2"`, the enabled TLS 1.2 cipher
|
||||
suite list is filtered against the RFC 9113 Appendix A blocklist
|
||||
(`TlsConfig.TLS12_H2_BLOCKED_CIPHERS`, ~280 entries). TLS 1.3 is never affected — none of its
|
||||
cipher suites are on that list.
|
||||
- `FlashConfiguration.http2Enabled` advertises `h2` on TLS listeners. The independent
|
||||
`http2CleartextEnabled` switch accepts the h2c prior-knowledge preface on plaintext listeners.
|
||||
@@ -0,0 +1,191 @@
|
||||
# The message model
|
||||
|
||||
Audience: contributors. This is the design record for `dev.relism.flash.models`'s shared
|
||||
request/response model: what is pooled, what that pooling means for callers, and how HTTP/1.1 and
|
||||
HTTP/2 retain the same public contract.
|
||||
|
||||
## 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 and value ASCII bytes once (for example, a constant
|
||||
response header set at boot). Preserving the boundary lets HTTP/1.1 render a field line and HTTP/2
|
||||
encode the same pair through HPACK without a second public header type.
|
||||
|
||||
`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; `Http2ResponseWriter` renders 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`: `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; `Http2HeaderMap` is the HPACK-backed second implementation without requiring a
|
||||
`Request` or `RequestLine` API split.
|
||||
|
||||
## `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.
|
||||
|
||||
## 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").
|
||||
@@ -0,0 +1,10 @@
|
||||
# Flash core
|
||||
|
||||
The parts of Flash shared by every protocol it speaks — HTTP/1.1 and HTTP/2 alike. Protocol-specific
|
||||
internals (frames, HPACK, stream state) live in [`../http2/`](../http2/README.md).
|
||||
|
||||
- [HTTP/1.1 hardening](HTTP1-HARDENING.md) — message-boundary rules, timeouts and negotiation.
|
||||
- [Transport](TRANSPORT.md) — listeners, connection ownership, TLS and virtual threads.
|
||||
- [Message model](MESSAGE-MODEL.md) — shared request/response objects and their lifetime contract.
|
||||
- [Trailers and streaming](TRAILERS-AND-STREAMING.md) — the public cross-protocol APIs.
|
||||
- [Byte primitives](BYTES.md) — reusable views, scanning and bounded slice lifetimes.
|
||||
@@ -0,0 +1,36 @@
|
||||
# Trailers and streaming
|
||||
|
||||
Flash exposes the same request and response model on HTTP/1.1 and HTTP/2. Request trailers are
|
||||
available through `Request.trailers()` after the body has reached EOF. Calling it earlier throws
|
||||
`IllegalStateException`; this prevents handlers from observing an incomplete trailer section.
|
||||
HTTP/1.1 reads trailers from the final chunk, while HTTP/2 decodes the trailing HEADERS block in
|
||||
the connection's existing HPACK context.
|
||||
|
||||
Response trailers are added with `Response.trailer(name, value)` or a `PreEncodedHeader`. HTTP/1.1
|
||||
uses chunked framing and writes the fields after the zero chunk. HTTP/2 writes a trailing HEADERS
|
||||
block with `END_STREAM`; the final DATA frame deliberately does not carry `END_STREAM`.
|
||||
|
||||
`Response.streaming(producer)` is the push alternative to `stream(InputStream, length)` and
|
||||
`chunked(InputStream)`. Its `ResponseStream` is a bounded blocking bridge. A producer runs on a
|
||||
virtual thread and blocks when the protocol writer or the HTTP/2 flow-control windows cannot make
|
||||
progress. This keeps backpressure explicit without callbacks or reactive types:
|
||||
|
||||
```java
|
||||
return response.type("application/grpc").streaming(stream -> {
|
||||
try {
|
||||
for (byte[] message : messages) stream.write(message, 0, message.length);
|
||||
stream.trailer("grpc-status", "0");
|
||||
} catch (IOException failure) {
|
||||
throw new UncheckedIOException(failure);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The transport supports the primitives required by gRPC, but the core does not provide protobuf
|
||||
codecs, generated stubs, service descriptors, or a gRPC service API. Those belong in a future
|
||||
`flash-ext-grpc` module. `GrpcInteropTest` verifies the boundary with the external `grpcurl` client
|
||||
and a hand-written wire-format handler.
|
||||
|
||||
CONNECT requests follow RFC 9113 request pseudo-header rules: `:authority` is required and
|
||||
`:scheme`/`:path` are forbidden. Their DATA remains subject to the ordinary request limits,
|
||||
timeouts and two-level flow control.
|
||||
@@ -0,0 +1,148 @@
|
||||
# Transport architecture
|
||||
|
||||
Audience: contributors. This document describes the shared listener and connection layer behind
|
||||
the HTTP/1.1 and HTTP/2 implementations.
|
||||
|
||||
## Why this exists
|
||||
|
||||
The original `HttpServer` (563 lines) did bind, accept, virtual-thread dispatch, WebSocket
|
||||
upgrade detection, WebSocket handshake, the WebSocket session loop, keep-alive detection, HTTP
|
||||
response serialization, chunked encoding, hex encoding, and decimal encoding — eleven reasons to
|
||||
change in one class (R6). It also held three `ThreadLocal`s that meant "one per connection" under
|
||||
virtual threads, not "one per core" (`EX-06`), and used `synchronized` around blocking socket
|
||||
writes in `WebSocketSession`, which pins a virtual thread's carrier on Java 21 (`EX-01`).
|
||||
|
||||
The current design replaces it with named, single-responsibility components and a
|
||||
`ConnectionProtocol` seam implemented by both wire protocols.
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
dev.relism.flash.transport
|
||||
├── TransportFactory composes everything below; ServerHandle.create()'s implementation (EX-34)
|
||||
├── ListenerBinder FlashConfiguration.Listener -> bound ServerSocket
|
||||
├── BoundListener record: the bound socket + whether it is TLS
|
||||
├── TransportTuning accept-thread count / backlog / socket buffer size constants
|
||||
├── AcceptLoop one listener's accept loop body
|
||||
├── ConnectionRunner per-connection setup/teardown: TLS handshake, protocol negotiation,
|
||||
│ dispatch to a ConnectionProtocol, guaranteed cleanup
|
||||
├── ConnectionProtocol the h1/h2 seam: void run(ConnectionContext)
|
||||
├── ConnectionContext everything a ConnectionProtocol needs, bundled (record)
|
||||
├── ConnectionScratch per-connection reusable buffers (EX-06's fix)
|
||||
├── ScratchPool a bounded cache of ConnectionScratch instances
|
||||
├── ServerLifecycle implements ServerHandle: start/startAndBlock/stop, graceful shutdown (EX-32)
|
||||
├── BufferedByteSource the buffered, deadline-aware, peekable inbound-byte source (Phase 1, EX-10)
|
||||
└── ProtocolNegotiator/NegotiatedProtocol ALPN + h2c preface detection (Phase 1)
|
||||
|
||||
dev.relism.flash.http1
|
||||
├── Http1Connection implements ConnectionProtocol: the h1 keep-alive request loop
|
||||
├── Http1ResponseWriter serializes a Response as an HTTP/1.1 message
|
||||
└── Http1KeepAlive keep-alive decision + the shared Connection-header token scanner (EX-13)
|
||||
|
||||
dev.relism.flash.websocket (existing package, extended)
|
||||
├── WebSocketUpgrade upgrade detection + handshake response
|
||||
├── WebSocketLoop the session read/dispatch loop
|
||||
├── WebSocketSession per-connection WS I/O (frame codec + send API), EX-01/EX-11/EX-12
|
||||
└── WebSocketProtocolException RFC 6455 violation, carries the correct close code
|
||||
```
|
||||
|
||||
## The connection lifecycle
|
||||
|
||||
```
|
||||
TransportFactory.create(configuration, router, wsRouter)
|
||||
binds every configured listener (ListenerBinder)
|
||||
builds one ConnectionRunner (shared virtual-thread executor, ScratchPool, both protocols)
|
||||
returns a ServerLifecycle (implements ServerHandle)
|
||||
|
||||
ServerLifecycle.start()
|
||||
for each listener, spawns TransportTuning.ACCEPT_THREADS platform threads
|
||||
each runs AcceptLoop.run(listener, runner, this::isStopped)
|
||||
|
||||
AcceptLoop.run(...)
|
||||
loop: listener.socket().accept() -> runner.accept(socket, stopped)
|
||||
|
||||
ConnectionRunner.accept(socket, stopped)
|
||||
submits to the virtual-thread executor -> handle(socket, stopped)
|
||||
|
||||
ConnectionRunner.handle(socket, stopped)
|
||||
activeSockets.add(socket); scratch = scratchPool.acquire()
|
||||
try:
|
||||
configure TCP_NODELAY / send buffer size
|
||||
if SSLSocket: force startHandshake() under headerReadTimeoutMs (EX-30)
|
||||
wrap streams: BufferedByteSource in, buffered OutputStream out, raw OutputStream rawOut
|
||||
negotiated = negotiateProtocol(socket, in) # ALPN or h2c preface
|
||||
build ConnectionContext
|
||||
dispatch to http1Protocol.run(ctx) or http2Protocol.run(ctx)
|
||||
finally:
|
||||
activeSockets.remove(socket); scratchPool.release(scratch)
|
||||
```
|
||||
|
||||
`Http1Connection.run(ConnectionContext)` is where HTTP/1.1 semantics actually live: the
|
||||
keep-alive loop, the idle/header/body deadline transitions (Phase 1), the `MalformedRequestException`
|
||||
rejection path, the WebSocket upgrade handoff, and the response write.
|
||||
|
||||
## `ConnectionScratch` and `ScratchPool` (`EX-06`)
|
||||
|
||||
`ThreadLocal` is the right idiom when "one per thread" means "one per core" — a bounded
|
||||
platform-thread pool. Flash runs one **virtual** thread per connection
|
||||
(`Executors.newVirtualThreadPerTaskExecutor()`), so a `ThreadLocal` there means one per
|
||||
*connection*, with no upper bound: at 100 000 concurrent connections, an 8 KB relay buffer alone
|
||||
would be ~800 MB that a bounded pool would otherwise cap.
|
||||
|
||||
`ConnectionScratch` is therefore an explicit, plain object (decimal-encoding buffer, streaming
|
||||
relay buffer, the WebSocket-handshake `MessageDigest`) acquired from a `ScratchPool` at
|
||||
connection start and released at connection end. The pool is a bounded *cache*, not a
|
||||
leak-free arena: above its bound (`min(availableProcessors * 64, 4096)` by default), a released
|
||||
scratch is simply dropped for the garbage collector rather than queued, so an unusually large
|
||||
burst of connections cannot grow it without limit.
|
||||
|
||||
The routers use an explicit per-connection scratch passed through `AbstractRouter.route`; neither
|
||||
`FastPathRouterImpl` nor `FastPathWsRouterImpl` retains connection state in a `ThreadLocal`.
|
||||
|
||||
## The `ConnectionProtocol` seam (R1 / `DEC-02`)
|
||||
|
||||
```java
|
||||
public interface ConnectionProtocol {
|
||||
void run(ConnectionContext ctx) throws IOException;
|
||||
}
|
||||
```
|
||||
|
||||
`ConnectionRunner` decides h1 vs h2 exactly once, immediately after ALPN/preface detection, and
|
||||
dispatches to `Http1Connection` or `Http2Connection`. Neither implementation is aware the other
|
||||
exists — `dev.relism.flash.http1` and `dev.relism.flash.http2` do not import each other, enforced
|
||||
by `PackageBoundaryTest`.
|
||||
|
||||
## Graceful shutdown (`EX-32`)
|
||||
|
||||
Two stages, driven by `ServerLifecycle.stop()`:
|
||||
|
||||
1. **Stop accepting.** Every listener socket is closed immediately; `stopped` flips to `true`.
|
||||
2. **Drain, then force-close.** `Http1Connection`'s request loop checks `ctx.stopped()` twice:
|
||||
once before waiting for the next request (exits immediately if already stopped, rather than
|
||||
waiting out the idle-keep-alive timeout), and again right before writing the *current*
|
||||
response — forcing `Connection: close` on it even if the response's own `Connection` header
|
||||
logic would have said keep-alive, and even if shutdown began *while the handler was running*
|
||||
(the common case). `ServerLifecycle.stop()` polls `activeSockets` for up to
|
||||
`shutdownDrainTimeoutMs`, then force-closes whatever remains and shuts down the executor.
|
||||
|
||||
HTTP/2 shutdown sends the two-stage `GOAWAY` sequence from RFC 9113 §6.8 before the lifecycle's
|
||||
drain deadline force-closes remaining sockets.
|
||||
|
||||
## What changed for WebSocket (`EX-01`, `EX-11`, `EX-12`, `EX-13`)
|
||||
|
||||
- **`EX-01`**: `WebSocketSession`'s two blocking-write sites (`close`, `writeFrame`) now
|
||||
serialize on a `ReentrantLock` instead of `synchronized (out)` — a virtual thread blocking
|
||||
inside `synchronized` pins its carrier platform thread on Java 21 (JEP 491, which removes
|
||||
this, is JDK 24+). `ReentrantLock` unmounts the blocked virtual thread instead.
|
||||
- **`EX-11`**: `readFrame` used to read the extended-length and mask-key bytes one at a time.
|
||||
It now reads that whole variable-length remainder in a single bounded `readFully` into the
|
||||
existing `hdrScratch` array, then decodes with shifts.
|
||||
- **`EX-12`**: `readFrame` now reassembles continuation frames into one logical message (bounded
|
||||
by the same buffer a single frame already had), enforces the masking direction RFC 6455 §5.1
|
||||
requires for this session's role, validates the opcode against the RFC's defined set, enforces
|
||||
control-frame constraints (not fragmented, ≤125 bytes), and reports violations via
|
||||
`WebSocketProtocolException` carrying the correct close code (1002 protocol error, 1009
|
||||
message too big) for `WebSocketLoop` to send before closing.
|
||||
- **`EX-13`**: the `Connection` header is a comma-separated token list, not a single value —
|
||||
`Http1KeepAlive.tokenListContains` is the one scanner both the keep-alive decision and
|
||||
`WebSocketUpgrade`'s `Connection: Upgrade` check use, so they cannot drift apart again.
|
||||
Reference in New Issue
Block a user