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.
12 KiB
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 (unlikeByteScan's own SWAR internals, which useByteOrder.nativeOrder()for speed — see below for why that's a different, safe choice in a different context). - The caller (
ByteCompare) never callslongAt(i)without first establishingi + 8 <= length()— solongAtimplementations 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.