Files
Flash5/flash/docs/core/BYTES.md
T
Zakaria El Orche a0dda8e47a 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.
2026-08-14 18:13:03 +00:00

12 KiB
Raw Blame History

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 610 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 0256 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 610 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.