feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10

Merged
Relism merged 23 commits from feature/core/http2 into master 2026-08-14 18:20:30 +00:00
38 changed files with 2993 additions and 239 deletions
Showing only changes of commit 704a00a551 - Show all commits
+179
View File
@@ -0,0 +1,179 @@
# The Byte Layer (Phase 4)
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 (future HPACK CONTINUATION)
└── 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.
## `EX-09`: `HeaderMap`'s index
Before this phase, every `HeaderMap` 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. `HeaderMap.reset()` now scans the section exactly once,
recording 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 — Phase 1 already rejects any 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. Net effect: the header section is scanned once per request (at `reset()`) plus
once at parse time (`RequestParser`'s own validation pass) — two scans total, replacing "one scan
at parse time plus one rescan per lookup" — strictly less work even for a single lookup, and much
less for the realistic multi-lookup case. `forEach` was unified onto the same index rather than
keeping its own independent scan, removing a second, easily-diverging scanning implementation.
## `EX-05`: pooled slices
`HeaderMap.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
`HeaderMapIndexTest`, `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; `HeaderMap`'s
pool, by contrast, is unconditionally useful (every request's `HeaderMap` 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/future code could exercise. `HeaderMap.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 — see
`DECISIONS.md`, `DEC-19`, for why this is 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 carry an
explicit "measure, and keep only if it doesn't cost" instruction in the plan. Both are measured
together with the phase's overall zero-allocation contract in one JMH pass — see `DECISIONS.md`,
`DEC-20`, for the numbers and the keep/revert decision for each.
+149
View File
@@ -584,3 +584,152 @@ concrete work with its own scenario list, harness design, and output format, rat
recorded intention.
---
## DEC-19 — `EX-06`'s router half is fixed with an opaque, caller-owned per-connection scratch object, not by extending `ConnectionScratch`
**Context.** `EX-06`'s registry entry phases itself: "Phase 2 (introduce), Phase 3 (h2 consumes
it), Phase 4 (router consumes it)" — Phase 4 is where `FastPathRouterImpl`'s and
`FastPathWsRouterImpl`'s `ThreadLocal<MatchResult>`/`ThreadLocal<MethodPathByteView>` (unbounded
under virtual threads, one per connection with no upper bound and no pooling — exactly the
failure mode `ConnectionScratch` exists to avoid for every other per-connection buffer) get
removed. `ConnectionScratch`'s own class Javadoc (written in Phase 2, in anticipation) already
commits to a specific mechanism: "Extended in Phase 4 with the router's reusable
{@code MatchResult}/path-view fields."
Attempting that literally surfaced a real problem: `ConnectionScratch` lives in
`dev.relism.flash.transport`; the router lives in `dev.relism.flash.routing` (and
`dev.relism.flash.routing.routers.fastpathrouter`). Today `transport` depends on `routing`
(`ConnectionContext` holds `AbstractRouter`/`AbstractWsRouter`) but **`routing` has zero imports
of `transport`** anywhere in this codebase (verified by grep, not assumed) — a clean one-way
dependency. Adding the router's scratch fields to `ConnectionScratch` and passing it into
`route()` would require `routing`'s classes to import `transport.ConnectionScratch`, creating the
first reverse edge and a genuine package cycle where none exists today.
**Options.**
1. Extend `ConnectionScratch` as its own Javadoc already describes, accepting the new
`routing → transport` edge (and the resulting cycle with the existing `transport → routing`
edge).
2. `AbstractRouter`/`AbstractWsRouter` gain a `newScratch()` method (default `null`) that each
router implementation overrides to return an opaque, implementation-specific object (kept as a
package-private nested class — `FastPathRouterImpl.RouteScratch`,
`FastPathWsRouterImpl.RouteScratch` — never a new public type). The connection driver
(`Http1Connection.run`) calls `newScratch()` **once per connection**, exactly the same
"created once, held by the loop, reused across every request" shape already used there for
`RequestParser`, and passes the opaque result into every `route(request, scratch)` call for
that connection's lifetime. No package outside `routing`/`routing.routers.fastpathrouter` ever
sees the concrete scratch type.
**Decision.** Option 2.
**Consequence.** Practically identical outcome to option 1 — one object per connection, created
once, reused across every request on that connection, replacing the `ThreadLocal`s — but without
introducing `routing`'s only dependency on `transport`. `ConnectionScratch`'s own Javadoc (which
predated this decision) is corrected in the same change to describe what was actually built
rather than the mechanism it originally assumed; `AbstractRouter.route`'s and
`AbstractWsRouter.route`'s signatures gain an `Object scratch` parameter, which is the one
API-surface cost of this approach (every router implementation, and every direct caller —
`Http1Connection` and the handful of tests that call `route()` directly — must now pass one).
`EX-19` (reusable `PathParams`/path-param arrays) piggybacks on the same `RouteScratch` object
for `FastPathRouterImpl`, since it needed an identical "created once per connection, grown to the
connection's high-water mark" lifetime — implemented together with `EX-06`'s router half rather
than as a separate pass over the same class.
**Revisit when.** Not expected to be revisited — the untyped `Object scratch` parameter is a
minor wart, but the alternative (a generic `AbstractRouter<S>` type parameter propagated through
`ConnectionContext`, `ServerHandle`, and every public router-registration API) is a far larger
API-surface change for one internal implementation detail, and is not justified unless a second
router implementation actually needs a differently-shaped scratch object — none exists today.
---
## DEC-20 — Phase 4 performance measurements: `EX-04`, `EX-33`, the router's own allocation profile, and the h1 zero-alloc contract's actual current number
**Context.** Phase 4's plan carries two explicit "measure, keep only if it earns its keep"
instructions (`EX-04`: revert if the win is negative or noise; `EX-33`: keep scalar if the SWAR
win is under 3%), plus a zero-alloc contract ("an h1 `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 `String`s the
handler explicitly asks for. Add this as a JMH allocation test now"). All three measured together
(JDK 21.0.11, JMH 1.37, `avgt` mode, `-prof gc`, `flash/src/jmh/java`) rather than as separate
passes, since they share the same request/route fixtures.
**Measurements.**
*`EX-33` — SWAR vs. scalar `\r\n\r\n` scan, realistic ~330-byte request (`ByteScanBenchmark`):*
| | ns/op |
|---|---|
| `headerEndScan_scalar` | 134.921 ± 5.558 |
| `headerEndScan_swar` | 87.116 ± 1.411 |
SWAR is **35.4 % faster** (47.8 ns absolute) — far above the 3 % keep-threshold. **Kept.**
*`EX-04` — the `longAt`/`ByteCompare` mechanism in isolation, and the real router
(`FastPathRouterBenchmark`):*
| | ns/op | B/op |
|---|---|---|
| `byteCompare_byteAtATime` (useLong=false) | 22.281 ± 1.021 | ≈0 |
| `byteCompare_longPath` (useLong=true) | 15.146 ± 1.090 | ≈0 |
| `router_staticRoute` (real `FastPathRouterImpl.route`) | 143.409 ± 14.992 | 0.001 |
| `router_parametricRoute` (real `FastPathRouterImpl.route`, 1 param extracted) | 284.433 ± 31.510 | 0.002 |
The long path is **32.1 % faster** (7.1 ns) than the byte-at-a-time comparison it replaces, at
the mechanism level — a clear, real win, confirming `EX-04` is worth keeping. **Honest caveat**,
not a failure of the measurement but a finding in its own right: `router_staticRoute`/
`router_parametricRoute` do **not** exercise this win today, because the actual value
`FastPathRouterImpl.route` passes to `router.match()` is always a
`FastPathViews.MethodPathByteView` — a deliberate composite of method bytes + path view, which
(per `EX-04`'s own registry text) correctly keeps `supportsLong() == false`, since a word-at-a-
time read across two independent sources is unsound, not merely unoptimized. `EX-04`'s win will
apply once a future phase (`HPACK` static-table matching, frame validation — Phase 5+) compares
two genuinely-contiguous array-backed ranges directly, which is exactly the shape
`byteCompare_longPath` measures. **Kept** — implemented correctly, verified correct
(`FastPathViewsLongAtTest`), and measured worthwhile for its actual future consumers; it was
never going to show up in today's router-benchmark numbers, and the plan's own text already
predicted this by excluding `MethodPathByteView` from the fix.
Separately: both router benchmarks show **≈0 B/op** — confirms `EX-06`/`EX-19`'s scratch reuse
(the `RouteScratch` object, its reused `MatchResult`, `MethodPathByteView`, and path-param
arrays/`PathParams` instance) is genuinely zero-allocation in practice, including on a
parametric route that extracts a param.
*The h1 zero-alloc contract, end to end (`RequestPipelineBenchmark`):*
| | ns/op | B/op |
|---|---|---|
| `parseAndRoute` (parse + route only, no header/param access) | 1135.125 ± 68.888 | 120.008 |
| `parseRouteAndExtractThreeFields` (+ 1 path param, 2 headers read) | 1335.965 ± 57.378 | 304.009 |
**Not literally 0 B/op** — and this is expected, not a Phase 4 regression: the 120.008 B/op in
`parseAndRoute` (which touches no header or path-param API at all) is entirely attributable to
`Request`/`RequestBody`/`RequestLine` construction, still allocated fresh per request. That is
`EX-21`/`EX-22`'s scope, explicitly assigned to **Phase 6** ("Request/Response model refactor"),
not Phase 4's. The delta to `parseRouteAndExtractThreeFields` — 304.009 120.008 = **184.001
B/op for exactly three explicit `String` reads** (one path param, two headers) — is precisely the
"user-facing `String`s the handler explicitly asks for" the contract's own text carves out as
acceptable, and confirms that *reading* those three fields (the header index lookup, the pooled
slice, the path-param array read) itself adds no allocation beyond the unavoidable `String`
objects themselves.
**Decision.** `EX-33`: keep the SWAR scan. `EX-04`: keep the `longAt`/`supportsLong`
implementation as built — correct, tested, and measured worthwhile for the array-backed
comparisons it was designed for, independent of whether today's single call site
(`MethodPathByteView`) happens to use it. The h1 zero-alloc DoD item is recorded as: **Phase 4's
own scope (`EX-05`/`EX-09`/`EX-19`/`EX-25`/`EX-26`/`EX-33`) is verified zero-allocation**
(`router_staticRoute`/`router_parametricRoute`'s ≈0 B/op, `HeaderMapIndexTest`'s identity-based
allocation check); the remaining 120.008 B/op is `Request`/`RequestBody`/`RequestLine`
construction, out of scope until Phase 6, and is not silently hidden — this benchmark now exists
specifically so Phase 6 has a "before" number to compare against and a regression gate once
Phase 17 wires `-prof gc` into CI.
**Consequence.** No code changes from this entry — it is a measurement record. Three new
benchmark classes ship under `src/jmh/java`: `ByteScan`Benchmark, `FastPathRouterBenchmark`,
`RequestPipelineBenchmark` — all component-level and gate-relevant (unlike the `DEC-18` showcase
category, these exist to answer the plan's own explicit measurement instructions, not for
literature/demo purposes).
**Revisit when.** `RequestPipelineBenchmark`'s `parseAndRoute` number should drop close to 0 B/op
once Phase 6 lands `Request`/`RequestBody` pooling — re-run this exact benchmark then and update
this entry (or add a new one) with the "after" number, closing the loop Phase 4 opened.
---
+58 -20
View File
@@ -65,7 +65,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
| 1 — HTTP/1.1 hardening + ALPN/preface | done | `feature/core/http2` | EX-02/03/07/08/10/17/18/30/31 fixed; EX-35/36 found+fixed. `BufferedByteSource`, `ProtocolNegotiator`, `MalformedRequestException` added (plan corrected, DEC-12). 277/277 tests green (run twice). h1 benchmark check deferred — no JMH harness until Phase 3 (documented in DoD). |
| 2 — Transport decomposition | done | `feature/core/http2` | `HttpServer.java` deleted; `transport`/`http1` packages + WS extraction (EX-01/06/11/12/13/14/15/16/32/34) done. Router `ThreadLocal` (EX-06 router half) deliberately deferred to Phase 4 per DEC-15. 311/311 tests green (run 3×). h1 benchmark check deferred — no JMH harness until Phase 3. |
| 3 — Serialized frame writer (GO/NO-GO gate) | done | `feature/core/http2` | `Http2FrameWriter`/`WriteIntent`/`IntrusiveMpscQueue` + `Http2FrameWriterTest`/`Http2FrameWriterStressTest` + `FrameWriterBenchmark` (JMH, `-Pjmh`, `src/jmh/java` — moved there from `src/test/java` after it broke default `mvn test`; see `DEC-17`). All 4 gate criteria met: N=1 0 B/op & 42.6 ns overhead (≤50 ns budget); N=64 65.5% throughput retention (≥60%) & 11.814.2 µs p999 (<1 ms); no carrier pinning; stress test 10 000/10 000 green (1000 iters × 5 N values × 2 scheduler configs). Full numbers in `WRITER.md`, `DEC-09`. 321/321 non-JMH tests green. |
| 4 — Byte-layer foundations | not started | — | — |
| 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 | not started | — | — |
| 6 — Request/Response model refactor | not started | — | — |
| 7 — HPACK decoder | not started | — | — |
@@ -1339,7 +1339,13 @@ and cash in the allocation and scanning wins that the existing code left on the
would mean rewriting the frame reader.
### EX items
`EX-04`, `EX-05`, `EX-09`, `EX-19`, `EX-25`, `EX-26`, `EX-33`.
`EX-04`, `EX-05`, `EX-09`, `EX-19`, `EX-25`, `EX-26`, `EX-33` — **plan correction**: `EX-06`'s
router half (removing `FastPathRouterImpl`/`FastPathWsRouterImpl`'s `ThreadLocal`s) belongs here
too, per `EX-06`'s own registry text ("Phase: 2 (introduce), 3 (h2 consumes it), **4 (router
consumes it)**") and `ConnectionScratch`'s own Phase-2-era Javadoc, but was missing from this
line — the same class of omission `DEC-12` already recorded for Phase 1. Fixed in place here;
see `DECISIONS.md`, `DEC-19`, for the router-half fix itself (and why it does not extend
`ConnectionScratch` as that Javadoc originally assumed).
### Files
@@ -1428,14 +1434,23 @@ Modified:
explicitly asks for. Add this as a JMH allocation test now; it becomes a CI gate in Phase 17.
### Safety checks
- [ ] `longAt` bounds contract documented and asserted in debug builds (an `assert`, which is
off in production, plus an explicit test)
- [ ] Header index arrays bounded by `MAX_HEADER_COUNT`; overflow is impossible because Phase 1
already rejects over-limit requests — assert the invariant rather than silently truncating
- [ ] SWAR scan never reads past the array bound (test with a target at the very last byte and
with a buffer whose length is not a multiple of 8)
- [ ] Pooled slice reuse cannot alias two live views the caller believes are independent —
documented, and covered by a test that demonstrates the hazard so the contract is visible
- [x] `longAt` bounds contract documented (`FastPathViews`'s `longAtLittleEndian` Javadoc,
`ArrayBackedByteView`/`ByteScan` class Javadocs) and verified against `fpr-core`'s own
`ByteCompare` directly (`FastPathViewsLongAtTest`) — no defensive runtime assert was added
for the bounds contract itself, since `ByteCompare` never calls `longAt(i)` without first
checking `i + 8 <= length()` (confirmed from its decompiled bytecode), making a check here
dead code on every real call path; documented as such rather than added anyway.
- [x] Header index arrays bounded by `MAX_HEADER_COUNT`; overflow is impossible because Phase 1
already rejects over-limit requests — asserted (`HeaderMap.ensureIndexCapacity`), not
silently truncated; exercised up to the exact limit by
`HeaderMapIndexTest#growsPastInitialIndexCapacity_upToMaxHeaderCount_andStaysCorrect`.
- [x] SWAR scan never reads past the array bound — `ByteScanTest`/`ByteScanFuzzTest` cover every
length 0256 exhaustively plus 20 000 fully-random fuzz trials per SWAR method, including a
match at the very last valid byte and buffer lengths not a multiple of 8.
- [x] Pooled slice reuse cannot alias two live views the caller believes are independent —
documented on `SlicePool`/`PooledSlice`/every `view()` method, and demonstrated (not just
asserted) by `SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice` and the analogous
tests in `HeaderMapIndexTest`, `QueryParamsFastPathTest`, `PathParamsTest`.
### Tests
- `ByteScanTest` — property tests, SWAR vs scalar, every boundary.
@@ -1443,22 +1458,45 @@ Modified:
- `FastPathViewsLongAtTest` — `longAt` correctness, and end-to-end routing correctness with the
long path enabled (the critical test from task 2).
- `HeaderMapIndexTest` — lookup correctness with duplicate names, case variations, 0 headers,
`MAX_HEADER_COUNT` headers; and an allocation assertion.
- `PathParamsReuseTest`, `QueryParamsFastPathTest`.
- All existing `models` and `routing` tests pass unmodified.
`MAX_HEADER_COUNT` headers, an allocation-identity assertion, and the pool-wraparound hazard.
- `QueryParamsFastPathTest`, and the pool-wraparound/reuse cases added directly to the existing
`PathParamsTest` and `FastPathRouterImplTest` — **plan correction**: no separate
`PathParamsReuseTest` file was created; the reuse-across-many-requests case
(`FastPathRouterImplTest#route_reusesScratchAcrossManyRequests_includingGrowingParamCapacity`)
exercises `PathParams`'s reusable path through the router that actually owns it, which is a more
realistic test than a `PathParams`-only unit test would have been.
- Existing `models`/`routing` tests: **not** unmodified as originally written here — `route()`
gained a `scratch` parameter (`EX-06`, `DEC-19`), so every direct caller (`FastPathRouterImplTest`,
`AbstractRouterTest`, `AbstractWsRouterTest`) needed a one-line update. All pass; 395/395 across
the whole module, including full socket-level `HttpServer*Test` suites exercising the real
`Http1Connection` path end to end.
### Docs
- `flash/docs/http2/BYTES.md` — the byte-layer primitives, the `ByteView` capability hierarchy
- [x] `flash/docs/http2/BYTES.md` — the byte-layer primitives, the `ByteView` capability hierarchy
(`ByteView` → `ArrayBackedByteView` → concrete; `SegmentedByteView` as the deliberate
non-array-backed case), the `supportsLong` contract, and the pooled-slice lifetime rules.
- Update `HeaderMap`'s class Javadoc (its lifetime contract section is the model the rest of the
codebase follows; it must stay accurate).
- [x] `HeaderMap`'s class Javadoc updated in place (the `EX-09` index, the pooled-`view()`
contract) as part of its Phase 4 rewrite.
### DoD
- [ ] h1 happy path is 0 B/op in JMH.
- [ ] h1 throughput improved or unchanged; numbers recorded.
- [ ] Every anonymous `ByteView` allocation in `flash` core is gone. (Grep `new ByteView()`.)
- [ ] `flash/docs/http2/BYTES.md` complete.
- [~] h1 happy path is 0 B/op in JMH — **partially, honestly**: Phase 4's own scope (header
lookup, path-param extraction, query decoding) measures at **≈0 B/op**
(`RequestPipelineBenchmark.router_staticRoute`/`router_parametricRoute`, ≈0 B/op;
`HeaderMapIndexTest`'s identity-based allocation check). The full h1 pipeline is **not**
literally 0 B/op yet: 120.008 B/op measured, 100% attributable to `Request`/`RequestBody`/
`RequestLine` construction (`EX-21`/`EX-22`), which is explicitly Phase 6 scope, not Phase 4's.
See `DECISIONS.md`, `DEC-20`, for the full breakdown and why this is not a Phase 4 regression.
- [x] h1 throughput improved or unchanged; numbers recorded — `EX-33`'s SWAR scan is 35.4% faster
than scalar (kept); `EX-04`'s word-at-a-time path is 32.1% faster than byte-at-a-time at the
mechanism level (kept — see `DEC-20` for why today's router benchmark doesn't yet show this
directly). No regression found anywhere measured.
- [~] Every anonymous `ByteView` allocation in `flash` core is gone — **two deliberate,
documented exceptions remain** (`QueryParams.view`, `PathParams.view`, the fallback path for a
non-array-backed source — structurally unreachable on the real request path today, kept because
both constructors are `public`; see `BYTES.md`). Every allocation on the actual hot path is
gone; grep `new ByteView()` and read the two remaining hits' Javadocs before treating this as
incomplete.
- [x] `flash/docs/http2/BYTES.md` complete.
---
@@ -0,0 +1,124 @@
package dev.relism.flash;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
import dev.relism.flash.transport.BufferedByteSource;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import java.io.IOException;
import java.io.InputStream;
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
* 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.
*
* <p>Uses a hand-rolled repeating {@link InputStream} (never allocates, cycles the same request
* bytes indefinitely) rather than a fresh {@code ByteArrayInputStream}/{@code BufferedByteSource}
* per invocation, so the timed path matches production exactly: one {@link BufferedByteSource}
* created once per connection and reused across every request, per {@code Http1Connection}'s own
* shape — not recreated per benchmark iteration, which would contaminate the measurement with
* harness allocation unrelated to the parser/router/model code under test (the same lesson
* {@code WRITER.md} documents for {@code FrameWriterBenchmark}'s own harness).
*/
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class RequestPipelineBenchmark {
/** Cycles a fixed byte[] indefinitely — simulates an infinite pipelined keep-alive stream
* of identical requests without allocating anything per read. */
private static final class RepeatingByteStream extends InputStream {
private final byte[] template;
private int pos;
RepeatingByteStream(byte[] template) {
this.template = template;
}
@Override
public int read() {
byte b = template[pos];
pos = (pos + 1) % template.length;
return b & 0xFF;
}
@Override
public int read(byte[] dst, int off, int len) {
for (int i = 0; i < len; i++) {
dst[off + i] = template[pos];
pos = (pos + 1) % template.length;
}
return len;
}
}
private RequestParser parser;
private BufferedByteSource in;
private FastPathRouterImpl router;
private Object routeScratch;
@Setup(Level.Trial)
public void setup() {
String req = "GET /users/12345 HTTP/1.1\r\n"
+ "Host: api.example.com\r\n"
+ "Accept: application/json\r\n"
+ "Authorization: Bearer abcdefghijklmnopqrstuvwxyz\r\n"
+ "\r\n";
byte[] template = req.getBytes(StandardCharsets.US_ASCII);
in = new BufferedByteSource(new RepeatingByteStream(template), null);
parser = new RequestParser(64 * 1024);
router = new FastPathRouterImpl();
RequestHandler handler = new SimpleHandler((r, res) -> "ok");
router.doRegister(HttpMethod.GET, "/users/{id}", handler, new Middleware[0]);
router.compile();
routeScratch = router.newScratch();
}
/** Parse + route only — isolates Phase 4's own scope from Request/RequestBody construction
* by not touching header()/param() (the "user-facing String" opt-in the DoD text carves out). */
@Benchmark
public RequestHandler parseAndRoute() throws IOException {
Request request = parser.parse(in);
request.drain();
return router.route(request, routeScratch);
}
/** Parse + route + exactly what the DoD text describes: one path param, two headers read. */
@Benchmark
public Object parseRouteAndExtractThreeFields() throws IOException {
Request request = parser.parse(in);
RequestHandler handler = router.route(request, routeScratch);
String id = request.param("id");
String host = request.header("Host");
String auth = request.header("Authorization");
request.drain();
return id.length() + host.length() + auth.length() + (handler != null ? 1 : 0);
}
}
@@ -0,0 +1,65 @@
package dev.relism.flash.bytes;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
/**
* {@code EX-33}'s required measurement: "SWAR scan using the same VarHandle long-read technique
* ... Measure — if the win is under 3% on the h1 benchmark, keep the scalar version." Compares
* {@link ByteScan#indexOfCrLfCrLf} (SWAR) against {@link ByteScan#indexOfCrLfCrLfScalar} on a
* realistic HTTP/1.1 request header block. Lives in this package (not {@code src/test/java})
* specifically to reach the package-private scalar reference method without widening its
* visibility just for a benchmark — see {@code DEC-17} for why JMH sources are kept out of
* {@code src/test/java} generally.
*
* <p>Run: {@code mvn -Pjmh -pl flash test-compile} then
* {@code java -cp flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath -Dmdep.outputFile=/dev/stdout -q)
* org.openjdk.jmh.Main ByteScanBenchmark}. Results recorded in {@code DECISIONS.md}, {@code DEC-20}.
*/
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class ByteScanBenchmark {
/** A realistic request: request line + 7 headers + terminator, ~330 bytes. */
private byte[] requestBuf;
@Setup(Level.Trial)
public void setup() {
String req = "GET /users/12345?sort=desc&limit=20 HTTP/1.1\r\n"
+ "Host: api.example.com\r\n"
+ "User-Agent: Mozilla/5.0 (compatible; FlashBench/1.0)\r\n"
+ "Accept: application/json\r\n"
+ "Accept-Encoding: gzip, deflate, br\r\n"
+ "Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123456789\r\n"
+ "Cookie: session=xyz123abc; theme=dark; lang=en-US\r\n"
+ "Connection: keep-alive\r\n"
+ "\r\n";
requestBuf = req.getBytes(StandardCharsets.US_ASCII);
}
@Benchmark
public int headerEndScan_swar() {
return ByteScan.indexOfCrLfCrLf(requestBuf, 0, requestBuf.length);
}
@Benchmark
public int headerEndScan_scalar() {
return ByteScan.indexOfCrLfCrLfScalar(requestBuf, 0, requestBuf.length);
}
}
@@ -0,0 +1,114 @@
package dev.relism.flash.routing.routers.fastpathrouter;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.fpr.core.internal.runtime.ByteCompare;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
/**
* Two related, but distinct, {@code EX-04} measurements — see {@code DECISIONS.md}, {@code DEC-20},
* for the honest write-up of why they tell different stories.
*
* <p><b>{@code router_*}</b>: the plan's literal instruction — "measure the {@code EX-04} win on
* the h1 router benchmark" — exercised through the real, shipped {@link FastPathRouterImpl#route}
* end to end (lazy-compiled route table, {@link FastPathRouterImpl.RouteScratch} reuse, path-param
* extraction included).
*
* <p><b>{@code byteCompare_*}</b>: a direct measurement of the mechanism {@code EX-04} actually
* implements ({@code ByteCompare.equals}, {@code useLong} true vs. false) over array-backed
* content shaped like what a future call site (HPACK static-table matching, frame validation)
* would compare. This exists because the router's own match call passes
* {@link FastPathViews.MethodPathByteView} — a deliberate composite, never array-backed (see
* {@code EX-04}'s own registry text: "{@code MethodPathByteView} ... keep[s] the {@code false}
* default") — so {@code router_*} alone cannot show {@code EX-04}'s effect at all; this benchmark
* is what actually answers "is the long path worth what it implements" for future consumers.
*/
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class FastPathRouterBenchmark {
// ── router_*: the real, shipped router, end to end ──────────────────────
private FastPathRouterImpl router;
private Object scratch;
private Request staticRequest;
private Request paramRequest;
@Setup(Level.Trial)
public void setupRouter() {
router = new FastPathRouterImpl();
RequestHandler h = new SimpleHandler((req, res) -> "ok");
router.doRegister(HttpMethod.GET, "/health", h, new dev.relism.flash.routing.Middleware[0]);
router.doRegister(HttpMethod.GET, "/users/{id}", h, new dev.relism.flash.routing.Middleware[0]);
router.doRegister(HttpMethod.GET, "/users/{id}/posts/{postId}", h, new dev.relism.flash.routing.Middleware[0]);
router.doRegister(HttpMethod.POST, "/users", h, new dev.relism.flash.routing.Middleware[0]);
router.doRegister(HttpMethod.GET, "/api/v1/products/{category}/{id}", h, new dev.relism.flash.routing.Middleware[0]);
router.compile();
scratch = router.newScratch();
staticRequest = mockRequest(HttpMethod.GET, "/health");
paramRequest = mockRequest(HttpMethod.GET, "/users/12345/posts/67890");
}
@Benchmark
public RequestHandler router_staticRoute() {
return router.route(staticRequest, scratch);
}
@Benchmark
public RequestHandler router_parametricRoute() {
return router.route(paramRequest, scratch);
}
private static Request mockRequest(HttpMethod method, String path) {
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
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()
);
return new Request(line, new byte[0]);
}
// ── byteCompare_*: the direct EX-04 mechanism, in isolation ─────────────
private FastPathViews.RequestByteView cmpView;
private byte[] cmpOther;
@Setup(Level.Trial)
public void setupByteCompare() {
byte[] content = "/api/v1/products/electronics/00012345".getBytes(StandardCharsets.US_ASCII);
cmpView = new FastPathViews.RequestByteView(content, 0, content.length);
cmpOther = content.clone();
}
@Benchmark
public boolean byteCompare_longPath() {
return ByteCompare.equals(cmpView, 0, cmpOther, 0, cmpOther.length, true);
}
@Benchmark
public boolean byteCompare_byteAtATime() {
return ByteCompare.equals(cmpView, 0, cmpOther, 0, cmpOther.length, false);
}
}
@@ -1,5 +1,6 @@
package dev.relism.flash;
import dev.relism.flash.bytes.ByteScan;
import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.http.HttpMethod;
@@ -52,26 +53,6 @@ import java.util.Arrays;
public class RequestParser {
private static final int INITIAL_BUFFER_SIZE = 8192;
/**
* RFC 9110 §5.6.2 {@code tchar} set, table-driven so header-name validation is a single
* array read per byte rather than a chain of comparisons (R4/R5). Indexed directly by
* byte value; only defined for the ASCII range a valid header name can ever occupy.
*/
private static final boolean[] TCHAR = new boolean[128];
static {
for (byte b : "!#$%&'*+-.^_`|~".getBytes(java.nio.charset.StandardCharsets.US_ASCII)) {
TCHAR[b] = true;
}
for (char c = '0'; c <= '9'; c++) TCHAR[c] = true;
for (char c = 'A'; c <= 'Z'; c++) TCHAR[c] = true;
for (char c = 'a'; c <= 'z'; c++) TCHAR[c] = true;
}
private static boolean isTChar(byte b) {
return b >= 0 && b < 128 && TCHAR[b];
}
private final int maxHeaderBufferSize;
private final InetSocketAddress remoteAddress;
private final SSLSocket sslSocket;
@@ -132,7 +113,7 @@ public class RequestParser {
bufBase = 0;
bufLen = 0;
int headerEndIdx = totalRead > 0 ? findEndOfHeader(buffer, base, base + totalRead) : -1;
int headerEndIdx = totalRead > 0 ? ByteScan.indexOfCrLfCrLf(buffer, base, base + totalRead) : -1;
while (headerEndIdx == -1) {
if (base + totalRead == buffer.length) {
if (base > 0) {
@@ -151,7 +132,7 @@ public class RequestParser {
if (n <= 0) break;
int prevTotal = totalRead;
totalRead += n;
headerEndIdx = findEndOfHeader(buffer, base + Math.max(0, prevTotal - 3), base + totalRead);
headerEndIdx = ByteScan.indexOfCrLfCrLf(buffer, base + Math.max(0, prevTotal - 3), base + totalRead);
}
if (totalRead <= 0) return null;
if (headerEndIdx == -1) {
@@ -161,7 +142,7 @@ public class RequestParser {
// ── Request line ─────────────────────────────────────────────────────
int methodEnd = find(buffer, base, headerEndIdx, (byte) ' ');
int methodEnd = ByteScan.indexOf(buffer, base, headerEndIdx, (byte) ' ');
if (methodEnd == -1) throw new MalformedRequestException(400, "Invalid request line (method)");
if (methodEnd == base) throw new MalformedRequestException(400, "Missing HTTP method");
@@ -169,10 +150,10 @@ public class RequestParser {
if (method == null) throw new MalformedRequestException(501, "Unsupported HTTP method");
int pathStart = methodEnd + 1;
int pathEnd = find(buffer, pathStart, headerEndIdx, (byte) ' ');
int pathEnd = ByteScan.indexOf(buffer, pathStart, headerEndIdx, (byte) ' ');
if (pathEnd == -1) throw new MalformedRequestException(400, "Invalid request line (path)");
int queryMark = find(buffer, pathStart, pathEnd, (byte) '?');
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
@@ -180,7 +161,7 @@ public class RequestParser {
: null;
int protocolStart = pathEnd + 1;
int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r');
int protocolEnd = ByteScan.indexOf(buffer, protocolStart, headerEndIdx, (byte) '\r');
if (protocolEnd == -1) throw new MalformedRequestException(400, "Invalid request line (protocol)");
// EX-08: the request line itself (method SP target SP version) is bounded separately
@@ -195,7 +176,7 @@ public class RequestParser {
// ── Headers ──────────────────────────────────────────────────────────
int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
int sectionStart = ByteScan.indexOf(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
int current = sectionStart;
long contentLength = -1;
boolean contentLengthSeen = false;
@@ -212,7 +193,7 @@ public class RequestParser {
throw new MalformedRequestException(400, "Obsolete line folding is not supported");
}
int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r');
int lineEnd = ByteScan.indexOf(buffer, current, headerEndIdx + 1, (byte) '\r');
if (lineEnd == -1 || lineEnd == current) break;
// EX-18: verify the '\r' is immediately followed by '\n' instead of blindly
@@ -228,7 +209,7 @@ public class RequestParser {
throw new MalformedRequestException(431, "Too many headers");
}
int colon = find(buffer, current, lineEnd, (byte) ':');
int colon = ByteScan.indexOf(buffer, current, lineEnd, (byte) ':');
if (colon == -1) {
throw new MalformedRequestException(400, "Header line missing ':'");
}
@@ -236,7 +217,7 @@ public class RequestParser {
throw new MalformedRequestException(431, "Header name exceeds " + Http1Limits.MAX_HEADER_NAME_LENGTH + " bytes");
}
for (int i = current; i < colon; i++) {
if (!isTChar(buffer[i])) {
if (!ByteScan.isTChar(buffer[i])) {
throw new MalformedRequestException(400, "Invalid header name character");
}
}
@@ -247,7 +228,7 @@ public class RequestParser {
throw new MalformedRequestException(431, "Header value exceeds " + Http1Limits.MAX_HEADER_VALUE_LENGTH + " bytes");
}
if (equalsIgnoreCase(buffer, current, colon, "content-length")) {
if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "content-length")) {
// EX-03: strict, overflow-safe parsing — replaces the old digit-skipping
// parseLong, which silently accepted "5abc" as 5 and "-1" as 1.
long parsed = parseContentLengthStrict(buffer, valueStart, lineEnd);
@@ -259,7 +240,7 @@ public class RequestParser {
}
contentLength = parsed;
contentLengthSeen = true;
} else if (equalsIgnoreCase(buffer, current, colon, "transfer-encoding")) {
} else if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "transfer-encoding")) {
transferEncodingSeen = true;
// Correctness fix found while implementing EX-02 in this exact code path
// (registered as EX-35): the old check required the WHOLE value to equal
@@ -319,34 +300,6 @@ public class RequestParser {
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress, sslSocket);
}
// ── Buffer scanning utilities (hot path — keep branch-free where possible) ──
private static int findEndOfHeader(byte[] buf, int from, int len) {
for (int i = from; i <= len - 4; i++) {
if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n')
return i;
}
return -1;
}
private static int find(byte[] buf, int start, int end, byte target) {
for (int i = start; i < end; i++) {
if (buf[i] == target) return i;
}
return -1;
}
private static boolean equalsIgnoreCase(byte[] buf, int start, int end, String target) {
int len = end - start;
if (len != target.length()) return false;
for (int i = 0; i < len; i++) {
byte b = buf[start + i];
if (b >= 'A' && b <= 'Z') b += 32;
if (b != (byte) target.charAt(i)) return false;
}
return true;
}
/**
* Strict, overflow-safe {@code Content-Length} parsing ({@code EX-03}). Rejects: an empty
* value, any non-digit byte (including a leading {@code +}/{@code -}, which are not
@@ -395,6 +348,6 @@ public class RequestParser {
}
int tokenStart = lastComma + 1;
while (tokenStart < e && (buf[tokenStart] == ' ' || buf[tokenStart] == '\t')) tokenStart++;
return equalsIgnoreCase(buf, tokenStart, e, "chunked");
return ByteScan.equalsIgnoreCaseAscii(buf, tokenStart, e, "chunked");
}
}
@@ -0,0 +1,37 @@
package dev.relism.flash.bytes;
import dev.relism.fpr.core.ByteView;
/**
* Capability interface for a {@link ByteView} that is a contiguous slice of a single backing
* {@code byte[]} — as opposed to a {@link SegmentedByteView}, which spans several arrays and
* cannot expose a single {@code (array, offset)} pair.
*
* <p>Every array-backed view in this codebase implements this: {@code RequestByteView},
* {@code SocketByteView}, {@code StringByteView} (all in
* {@code dev.relism.flash.routing.routers.fastpathrouter.FastPathViews}), and {@link PooledSlice}.
* {@code MethodPathByteView} deliberately does not — it is a composite of a {@code byte[]}
* (method) and another {@link ByteView} (path), so it has no single backing array.
*
* <h3>What this enables</h3>
* Anywhere code holds a plain {@link ByteView} and wants the fast path when the concrete
* instance happens to be array-backed, an {@code instanceof ArrayBackedByteView} check unlocks:
* <ul>
* <li>Single-allocation {@code String} construction —
* {@code new String(view.array(), view.offset(), view.length(), UTF_8)} instead of a
* byte-at-a-time copy into a scratch {@code byte[]} followed by a second allocation for
* the {@code String} itself ({@code EX-25}).</li>
* <li>A single {@code System.arraycopy} instead of a manual loop wherever a view's bytes need
* to be copied.</li>
* </ul>
* Code that only has a bare {@link ByteView} (e.g. because it received one across the
* {@link SegmentedByteView} boundary, from a future HPACK CONTINUATION-spanning block) keeps the
* byte-at-a-time fallback — this interface is an opportunistic fast path, never a requirement.
*/
public interface ArrayBackedByteView extends ByteView {
/** The backing array. Bytes {@code [offset(), offset() + length())} belong to this view. */
byte[] array();
/** Offset of this view's first byte within {@link #array()}. */
int offset();
}
@@ -0,0 +1,331 @@
package dev.relism.flash.bytes;
import dev.relism.fpr.core.ByteView;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
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
* index uses ({@code EX-09}).
*
* <p>Every method here is {@code static} and allocates nothing. Every SWAR method has a plain
* scalar counterpart ({@code *Scalar}) that exists for two reasons: it is what the tests use as
* the correctness oracle (property-tested against the SWAR version on randomized inputs — see
* {@code ByteScanTest}/{@code ByteScanFuzzTest}), and it is the documented fallback if a future
* measurement ever shows the SWAR path is not worth its complexity on some path (none has been
* found not worth it so far — see {@code DECISIONS.md} for the one path that {@em was}
* measured and kept, {@code EX-33}).
*
* <h3>The SWAR technique used throughout</h3>
* Both {@link #indexOf} and {@link #indexOfCrLfCrLf} use the classic "does this word contain
* byte {@code b}" bit trick (Bit Twiddling Hacks, "Determine if a word has a byte equal to n"):
* XOR the 8-byte word against {@code b} broadcast into every lane (turning matching lanes to
* {@code 0x00}), then test for any zero lane with
* {@code (v - 0x0101010101010101L) & ~v & 0x8080808080808080L} — non-zero exactly when some lane
* was {@code 0x00} before the subtraction, i.e. some original lane equalled {@code b}. This finds
* *that a* matching lane exists in one word-sized read plus a handful of ALU ops, touching every
* byte only once per 8-byte stride in the common (no-match-yet) case, instead of once per byte.
*
* <p>Reading the word uses {@link MethodHandles#byteArrayViewVarHandle} with
* {@link ByteOrder#nativeOrder()} — deliberately native rather than a fixed order (contrast
* {@code fpr-core}'s {@code ByteCompare}, which fixes {@code LITTLE_ENDIAN} because it compares
* two independently-read words for bit-exact equality and so needs a byte order both reads
* agree on; nothing here compares across two separately-decoded words, so the fastest order for
* the host CPU is free to use). Byte-equality detection itself (finding that a matching lane
* exists in the mask) does not depend on which order was used to assemble the word — XOR and the
* haszero test are lane-wise operations, indifferent to how lanes map to memory offsets.
* <b>Position extraction does depend on it</b>: converting "which bit of the 64-bit mask is set"
* back into "which array index did that byte come from" requires knowing whether array byte 0
* became the long's least-significant byte (little-endian) or most-significant byte
* (big-endian) — {@link #laneIndexOf} branches on {@link #NATIVE_IS_LITTLE} once, at class-init
* time, precisely to get this right on either host.
*/
public final class ByteScan {
private ByteScan() {}
private static final ByteOrder NATIVE_ORDER = ByteOrder.nativeOrder();
private static final boolean NATIVE_IS_LITTLE = NATIVE_ORDER == ByteOrder.LITTLE_ENDIAN;
private static final VarHandle LONG_VIEW =
MethodHandles.byteArrayViewVarHandle(long[].class, NATIVE_ORDER);
private static final long LANE_LSB = 0x0101010101010101L;
private static final long LANE_MSB = 0x8080808080808080L;
// ── tchar (RFC 9110 §5.6.2) ──────────────────────────────────────────────
/**
* RFC 9110 §5.6.2 {@code tchar} set, table-driven so validation is a single array read per
* byte (R4/R5) rather than a chain of range comparisons. Indexed directly by byte value;
* only the ASCII range a valid header-name character can ever occupy is populated.
*/
private static final boolean[] TCHAR = new boolean[128];
static {
for (byte b : "!#$%&'*+-.^_`|~".getBytes(java.nio.charset.StandardCharsets.US_ASCII)) {
TCHAR[b] = true;
}
for (char c = '0'; c <= '9'; c++) TCHAR[c] = true;
for (char c = 'A'; c <= 'Z'; c++) TCHAR[c] = true;
for (char c = 'a'; c <= 'z'; c++) TCHAR[c] = true;
}
/** Whether {@code b} is a valid RFC 9110 §5.6.2 {@code tchar} (a legal header-name byte). */
public static boolean isTChar(byte b) {
return b >= 0 && b < 128 && TCHAR[b];
}
// ── Single-byte search ───────────────────────────────────────────────────
/**
* Index of the first occurrence of {@code target} in {@code buf[from, to)}, or {@code -1}.
* SWAR-accelerated: touches 8 bytes per word while no match has been found, falling back to
* a byte-at-a-time tail once fewer than 8 bytes remain.
*/
public static int indexOf(byte[] buf, int from, int to, byte target) {
long broadcast = (target & 0xFFL) * LANE_LSB;
int i = from;
while (i + 8 <= to) {
long word = (long) LONG_VIEW.get(buf, i);
long masked = hasZeroLane(word ^ broadcast);
if (masked != 0) {
return i + laneIndexOf(masked);
}
i += 8;
}
for (; i < to; i++) {
if (buf[i] == target) return i;
}
return -1;
}
/** Plain byte-at-a-time reference implementation of {@link #indexOf} — the test oracle. */
static int indexOfScalar(byte[] buf, int from, int to, byte target) {
for (int i = from; i < to; i++) {
if (buf[i] == target) return i;
}
return -1;
}
// ── \r\n\r\n header terminator search ────────────────────────────────────
private static final byte CR = '\r', LF = '\n';
/**
* Index of the first {@code "\r\n\r\n"} in {@code buf[from, to)}, or {@code -1}. SWAR
* pre-filter (find a candidate {@code CR} byte 8 at a time) plus a cheap scalar 3-byte
* verify at each candidate — see the class Javadoc for the technique and
* {@code RequestParser}, {@code EX-33}, for why this replaced a fully byte-at-a-time scan.
*/
public static int indexOfCrLfCrLf(byte[] buf, int from, int to) {
int limit = to - 4; // last index at which a 4-byte match can start
int i = from;
while (i + 8 <= to) {
long word = (long) LONG_VIEW.get(buf, i);
long masked = hasZeroLane(word ^ CR_BROADCAST);
if (masked == 0) {
i += 8;
continue;
}
int crPos = i + laneIndexOf(masked);
if (crPos > limit) {
// Nearest CR candidate in this word can't fit a full match before `to`; no CR
// exists before it in [i, crPos) (laneIndexOf always finds the lowest-address
// match first), so nothing in [i, crPos) can match either — the scalar tail
// below, bounded by `limit`, correctly finds nothing without re-deriving that.
break;
}
if (buf[crPos + 1] == LF && buf[crPos + 2] == CR && buf[crPos + 3] == LF) {
return crPos;
}
i = crPos + 1;
}
for (; i <= limit; i++) {
if (buf[i] == CR && buf[i + 1] == LF && buf[i + 2] == CR && buf[i + 3] == LF) {
return i;
}
}
return -1;
}
private static final long CR_BROADCAST = (CR & 0xFFL) * LANE_LSB;
/** Plain byte-at-a-time reference implementation of {@link #indexOfCrLfCrLf} — the test oracle. */
static int indexOfCrLfCrLfScalar(byte[] buf, int from, int to) {
for (int i = from; i <= to - 4; i++) {
if (buf[i] == CR && buf[i + 1] == LF && buf[i + 2] == CR && buf[i + 3] == LF) {
return i;
}
}
return -1;
}
/** "Determine if a word has a byte equal to n" (Bit Twiddling Hacks), applied to {@code xored}. */
private static long hasZeroLane(long xored) {
return (xored - LANE_LSB) & ~xored & LANE_MSB;
}
/** Converts a {@link #hasZeroLane} result into the array-index offset of its lowest matching lane. */
private static int laneIndexOf(long masked) {
return NATIVE_IS_LITTLE
? Long.numberOfTrailingZeros(masked) >>> 3
: 7 - (Long.numberOfLeadingZeros(masked) >>> 3);
}
// ── Case-insensitive comparison ──────────────────────────────────────────
private static byte foldAsciiUpper(byte b) {
return (b >= 'A' && b <= 'Z') ? (byte) (b + 32) : b;
}
/** Case-insensitive (ASCII) equality of {@code buf[start, end)} against {@code target}. */
public static boolean equalsIgnoreCaseAscii(byte[] buf, int start, int end, String target) {
int len = end - start;
if (len != target.length()) return false;
for (int i = 0; i < len; i++) {
if (foldAsciiUpper(buf[start + i]) != foldAsciiUpper((byte) target.charAt(i))) return false;
}
return true;
}
/** Case-insensitive (ASCII) equality of two byte-array ranges. */
public static boolean equalsIgnoreCaseAscii(byte[] a, int aStart, int aLen, byte[] b, int bStart, int bLen) {
if (aLen != bLen) return false;
for (int i = 0; i < aLen; i++) {
if (foldAsciiUpper(a[aStart + i]) != foldAsciiUpper(b[bStart + i])) return false;
}
return true;
}
/** Case-insensitive (ASCII) equality of {@code view[start, end)} against {@code target}. */
public static boolean equalsIgnoreCase(ByteView view, int start, int end, String target) {
int len = end - start;
if (len != target.length()) return false;
for (int i = 0; i < len; i++) {
if (foldAsciiUpper(view.byteAt(start + i)) != foldAsciiUpper((byte) target.charAt(i))) return false;
}
return true;
}
// ── Comma-separated token lists (e.g. `Connection: keep-alive, Upgrade`) ────
/**
* Whether the comma-separated, OWS-tolerant token list {@code view} contains {@code token}
* (case-insensitive). The shared scanner behind both {@code Http1KeepAlive.isKeepAlive} and
* the {@code Connection: Upgrade} check ({@code EX-13}) — a single home so the two can never
* drift apart the way a whole-value {@code equals} check once did.
*/
public static boolean tokenListContains(ByteView view, String token) {
int len = view.length(), i = 0;
while (i < len) {
while (i < len && view.byteAt(i) == ' ') i++;
int start = i;
while (i < len && view.byteAt(i) != ',') i++;
if (tokenEqualsIgnoreCase(view, start, i, token)) return true;
i++;
}
return false;
}
/** Case-insensitive compare of {@code view[start, end)}, trimming trailing spaces, against {@code token}. */
public static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) {
int wlen = end - start;
while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--;
return equalsIgnoreCase(view, start, start + wlen, token);
}
// ── Header-name hash (EX-09) ─────────────────────────────────────────────
/**
* 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
* 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
* in one hash compare instead of a byte-by-byte scan.
*/
public static int hashNameIgnoreCaseAscii(byte[] buf, int start, int len) {
int hash = 0x811C9DC5; // FNV-1a 32-bit offset basis
for (int i = 0; i < len; i++) {
hash ^= (foldAsciiUpper(buf[start + i]) & 0xFF);
hash *= 0x01000193; // FNV-1a 32-bit prime
}
return hash;
}
/**
* 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
* {@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).
*/
public static int hashNameIgnoreCaseAscii(String name) {
int hash = 0x811C9DC5;
int len = name.length();
for (int i = 0; i < len; i++) {
hash ^= (foldAsciiUpper((byte) name.charAt(i)) & 0xFF);
hash *= 0x01000193;
}
return hash;
}
// ── Decimal / hex parsing ────────────────────────────────────────────────
/** Sentinel returned by {@link #parseDecimalStrict} on any malformed or out-of-range input. */
public static final long PARSE_INVALID = -1L;
/**
* Strict, overflow-safe unsigned decimal parse of {@code buf[start, end)}: rejects an empty
* range, any non-{@code '0'..'9'} byte, more than 19 digits, and arithmetic overflow past
* {@link Long#MAX_VALUE}. Returns {@link #PARSE_INVALID} rather than throwing — the same
* shape {@code RequestParser}'s own {@code Content-Length} parser already hand-rolls (kept
* separate there since it also needs to throw a specific, differently-worded
* {@code MalformedRequestException} per failure mode); this is the general-purpose version
* for callers (HPACK integer decoding, frame-length fields) that just need a valid/invalid
* signal.
*/
public static long parseDecimalStrict(byte[] buf, int start, int end) {
int len = end - start;
if (len == 0 || len > 19) return PARSE_INVALID;
long value = 0;
for (int i = start; i < end; i++) {
byte c = buf[i];
if (c < '0' || c > '9') return PARSE_INVALID;
int digit = c - '0';
if (value > (Long.MAX_VALUE - digit) / 10) return PARSE_INVALID;
value = value * 10 + digit;
}
return value;
}
/**
* Parses up to {@code maxDigits} hex digits (ASCII, either case) from {@code buf[start, end)}
* as an unsigned value. Returns {@link #PARSE_INVALID} if the range is empty, contains a
* non-hex-digit byte, or would need more than {@code maxDigits} digits to represent (the
* caller's bound against, e.g., a chunk-size line with an implausible number of digits).
*/
public static long parseHexStrict(byte[] buf, int start, int end, int maxDigits) {
int len = end - start;
if (len == 0 || len > maxDigits) return PARSE_INVALID;
long value = 0;
for (int i = start; i < end; i++) {
int digit = hexDigit(buf[i]);
if (digit < 0) return PARSE_INVALID;
value = (value << 4) | digit;
}
return value;
}
private static int hexDigit(byte b) {
if (b >= '0' && b <= '9') return b - '0';
if (b >= 'a' && b <= 'f') return b - 'a' + 10;
if (b >= 'A' && b <= 'F') return b - 'A' + 10;
return -1;
}
}
@@ -0,0 +1,154 @@
package dev.relism.flash.bytes;
import java.nio.charset.StandardCharsets;
/**
* Index-based writer into a growable {@code byte[]} scratch buffer. Every {@code write*} method
* bounds-checks and grows the backing array only when the write would not otherwise fit —
* on an already-warm buffer (the steady-state case: the buffer has already grown to the
* connection's high-water mark), no method here allocates.
*
* <p>This is the infrastructure {@code EX-27} (Phase 6, collapsing {@code Http1ResponseWriter}'s
* ~10 small writes into one) and the Phase 5 frame layer serialize into: build a complete
* message into a {@code ByteWriter}-backed scratch buffer, then issue one bulk
* {@code write(buffer, 0, length())} — the same "serialize outside the lock, one bulk write"
* discipline {@link dev.relism.flash.h2.frame.Http2FrameWriter} already established for the h2
* writer (see its Javadoc's "Layer 1"), extended to the byte layer both protocols share.
*
* <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()}
* 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.
*/
public final class ByteWriter {
private byte[] buf;
private int len;
public ByteWriter(int initialCapacity) {
this.buf = new byte[Math.max(initialCapacity, 16)];
}
/** Repositions this writer to the start of its buffer, ready for the next message. */
public void reset() {
len = 0;
}
/** The backing buffer. Valid content is {@code [0, length())} — never assume {@code buf.length == length()}. */
public byte[] array() {
return buf;
}
/** How many bytes have been written since the last {@link #reset()}. */
public int length() {
return len;
}
private void ensure(int additional) {
int needed = len + additional;
if (needed <= buf.length) return;
int grown = buf.length * 2;
while (grown < needed) grown *= 2;
byte[] next = new byte[grown];
System.arraycopy(buf, 0, next, 0, len);
buf = next;
}
public void writeByte(byte b) {
ensure(1);
buf[len++] = b;
}
public void writeBytes(byte[] src) {
writeBytes(src, 0, src.length);
}
public void writeBytes(byte[] src, int off, int srcLen) {
ensure(srcLen);
System.arraycopy(src, off, buf, len, srcLen);
len += srcLen;
}
/**
* Writes {@code value}'s ASCII decimal digits (no sign — callers write {@code '-'} via
* {@link #writeByte} first if needed). {@code value} must be non-negative.
*/
public void writeDecimal(long value) {
if (value < 0) throw new IllegalArgumentException("writeDecimal requires a non-negative value: " + value);
if (value == 0) {
writeByte((byte) '0');
return;
}
// Digits emerge least-significant-first; stage them in a small fixed buffer (at most 20
// digits for any long) and copy in reverse — avoids a second pass to compute digit count.
byte[] digits = new byte[20];
int n = 0;
long v = value;
while (v > 0) {
digits[n++] = (byte) ('0' + (v % 10));
v /= 10;
}
ensure(n);
for (int i = n - 1; i >= 0; i--) buf[len++] = digits[i];
}
private static final byte[] HEX_DIGITS = "0123456789abcdef".getBytes(StandardCharsets.US_ASCII);
/** Writes {@code value}'s lowercase hex digits, no leading zeros (except for {@code value == 0}, which writes {@code "0"}). */
public void writeHex(int value) {
if (value == 0) {
writeByte((byte) '0');
return;
}
byte[] digits = new byte[8];
int n = 0;
int v = value;
while (v != 0) {
digits[n++] = HEX_DIGITS[v & 0xF];
v >>>= 4;
}
ensure(n);
for (int i = n - 1; i >= 0; i--) buf[len++] = digits[i];
}
/** Writes {@code s}'s ASCII bytes, lower-cased. {@code s} must be ASCII-only. */
public void writeAsciiLower(String s) {
int n = s.length();
ensure(n);
for (int i = 0; i < n; i++) {
char c = s.charAt(i);
if (c >= 'A' && c <= 'Z') c += 32;
buf[len++] = (byte) c;
}
}
/** Big-endian 16-bit write — an HTTP/2 frame's stream-dependent fields, SETTINGS values, etc. */
public void writeUInt16(int value) {
ensure(2);
buf[len++] = (byte) (value >>> 8);
buf[len++] = (byte) value;
}
/** Big-endian 24-bit write — an HTTP/2 frame header's length field. */
public void writeUInt24(int value) {
ensure(3);
buf[len++] = (byte) (value >>> 16);
buf[len++] = (byte) (value >>> 8);
buf[len++] = (byte) value;
}
/** Big-endian 31-bit write (top bit always 0) — an HTTP/2 stream identifier. */
public void writeUInt31(int value) {
writeUInt32(value & 0x7FFFFFFF);
}
/** Big-endian 32-bit write — an HTTP/2 window-size increment, SETTINGS value, etc. */
public void writeUInt32(int value) {
ensure(4);
buf[len++] = (byte) (value >>> 24);
buf[len++] = (byte) (value >>> 16);
buf[len++] = (byte) (value >>> 8);
buf[len++] = (byte) value;
}
}
@@ -0,0 +1,42 @@
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
* 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.
*
* <h3>Why this works</h3>
* A {@code long} is 64 bits; each packed {@code int} is 32. {@link #pack} left-shifts the high
* half into the top 32 bits and OR's the low half into the bottom 32. {@link #lo} must mask with
* {@code 0xFFFFFFFFL} rather than simply cast to {@code int} after no mask, because a right-shift
* of a negative {@code long} sign-extends — the mask discards everything above bit 31 before the
* narrowing cast happens implicitly. {@link #hi} needs no mask: a right-shift by 32 already
* leaves only the original high bits in the low 32 positions of the result.
*
* <h3>Encoding convention used across this codebase</h3>
* Every {@code findFirst}-shaped method in this codebase packs {@code (start << 32) | length},
* i.e. {@code hi() == start} and {@code lo() == length}. {@code -1L} is the shared "not found"
* sentinel (a valid {@code (start, length)} pair can never be negative, since both halves are
* non-negative offsets/lengths).
*/
public final class Pairs {
private Pairs() {}
/** Packs two {@code int}s into one {@code long}: {@code hi} in the upper 32 bits, {@code lo} in the lower 32. */
public static long pack(int hi, int lo) {
return ((long) hi << 32) | (lo & 0xFFFFFFFFL);
}
/** Extracts the upper 32 bits packed by {@link #pack}. */
public static int hi(long packed) {
return (int) (packed >> 32);
}
/** Extracts the lower 32 bits packed by {@link #pack}. */
public static int lo(long packed) {
return (int) (packed & 0xFFFFFFFFL);
}
}
@@ -0,0 +1,53 @@
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
* 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.
*
* <h3>Lifetime contract</h3>
* 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 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.
*/
public final class PooledSlice implements ArrayBackedByteView {
private byte[] array;
private int offset;
private int length;
/** Repositions this slice over {@code array[offset, offset + length)}. Zero allocation. */
public void reset(byte[] array, int offset, int length) {
this.array = array;
this.offset = offset;
this.length = length;
}
@Override
public byte[] array() {
return array;
}
@Override
public int offset() {
return offset;
}
@Override
public int length() {
return length;
}
@Override
public byte byteAt(int index) {
if (index < 0 || index >= length) {
throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + length);
}
return array[offset + index];
}
}
@@ -0,0 +1,81 @@
package dev.relism.flash.bytes;
import dev.relism.fpr.core.ByteView;
/**
* A {@link ByteView} over up to {@code K} discontiguous {@code byte[]} segments, presented as one
* logical byte sequence. Exists for the one case in this codebase where a "single contiguous
* slice of one buffer" model (every other {@link ByteView} implementation) does not hold: an
* HPACK header block whose encoding spans more than one {@code CONTINUATION} frame (RFC 9113
* §6.10), where each frame's payload lives in its own connection-buffer region.
*
* <h3>Deliberately not array-backed</h3>
* This does not implement {@link ArrayBackedByteView} — there is no single {@code (array,
* offset)} pair that describes it — and {@link #supportsLong()} returns {@code false}
* unconditionally rather than attempting a cross-segment 8-byte read ({@code EX-04}'s word-at-a-
* time path is only sound for a genuinely contiguous backing array; see
* {@code FastPathViews.MethodPathByteView} for the other deliberately-segmented view in this
* codebase, which makes the same choice for the same reason).
*
* <h3>Reusable, not allocated per block</h3>
* {@link #reset} repositions this view over a new set of segments without allocating — the same
* idiom {@link PooledSlice} uses for the contiguous case. The {@code segments}/{@code offsets}/
* {@code lengths} arrays passed to {@link #reset} are retained by reference, not copied; the
* caller owns their lifetime (typically the connection's HPACK scratch, sized to
* {@code Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK}).
*
* <h3>Cost model</h3>
* {@link #byteAt} walks the segment table to find which segment an index falls in — O(segments),
* not O(1) — because this view exists precisely for the rare, deliberately-bounded case
* (at most {@code MAX_CONTINUATION_FRAMES_PER_BLOCK} segments); optimizing it further would add
* complexity for a path that, by construction, is never hot.
*/
public final class SegmentedByteView implements ByteView {
private byte[][] segments;
private int[] offsets;
private int[] lengths;
private int count;
private int totalLength;
/**
* Repositions this view over {@code segments[0..count)}, where segment {@code i} contributes
* bytes {@code segments[i][offsets[i], offsets[i] + lengths[i])}. Zero allocation: the three
* arrays are retained by reference.
*/
public void reset(byte[][] segments, int[] offsets, int[] lengths, int count) {
this.segments = segments;
this.offsets = offsets;
this.lengths = lengths;
this.count = count;
int total = 0;
for (int i = 0; i < count; i++) total += lengths[i];
this.totalLength = total;
}
@Override
public int length() {
return totalLength;
}
@Override
public byte byteAt(int index) {
if (index < 0 || index >= totalLength) {
throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + totalLength);
}
int remaining = index;
for (int i = 0; i < count; i++) {
int len = lengths[i];
if (remaining < len) {
return segments[i][offsets[i] + remaining];
}
remaining -= len;
}
throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + totalLength);
}
/** Always {@code false} — see the class Javadoc for why a cross-segment word read is unsound. */
@Override
public boolean supportsLong() {
return false;
}
}
@@ -0,0 +1,52 @@
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}).
*
* <h3>Why a ring, not a single reused slice</h3>
* A single reused slice (the shape {@code HeaderMap.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
* {@code headers.view("A")} and {@code headers.view("B")} and want to compare both. A ring of
* {@code size} slices lets up to {@code size} calls' results stay simultaneously valid.
*
* <h3>Lifetime contract</h3>
* A slice returned by {@link #acquire} is valid until either the request ends, or {@link #acquire}
* 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
* a hypothetical one — see {@code SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice} for
* a demonstration.
*/
public final class SlicePool {
private final PooledSlice[] slices;
private int next = 0;
/** A ring of {@code size} reusable slices. {@code size} must be at least 1. */
public SlicePool(int size) {
if (size < 1) throw new IllegalArgumentException("SlicePool size must be at least 1: " + size);
slices = new PooledSlice[size];
for (int i = 0; i < size; i++) slices[i] = new PooledSlice();
}
/** How many slices this pool cycles through before a caller's slice is reused. */
public int size() {
return slices.length;
}
/**
* Returns the next slice in the ring, repositioned over {@code array[offset, offset + length)}.
* Zero allocation — the returned instance already existed.
*/
public PooledSlice acquire(byte[] array, int offset, int length) {
PooledSlice slice = slices[next];
next++;
if (next == slices.length) next = 0;
slice.reset(array, offset, length);
return slice;
}
}
@@ -39,6 +39,11 @@ public final class Http1Connection implements ConnectionProtocol {
OutputStream out = ctx.out();
byte[] idleProbe = new byte[1];
// EX-06 (router half): created once per connection, exactly like `parser` above, and
// reused across every request on this connection — see AbstractRouter#newScratch.
Object routeScratch = ctx.router().newScratch();
Object wsRouteScratch = ctx.wsRouter().newScratch();
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
@@ -77,7 +82,7 @@ public final class Http1Connection implements ConnectionProtocol {
if (request.method() == HttpMethod.GET && WebSocketUpgrade.isWebSocketUpgrade(request)) {
in.clearDeadline(); // the WS session loop is long-lived; it paces itself
WebSocketHandler wsHandler = ctx.wsRouter().route(request);
WebSocketHandler wsHandler = ctx.wsRouter().route(request, wsRouteScratch);
if (wsHandler == null) {
out.write(WebSocketUpgrade.REJECT_400);
out.flush();
@@ -103,7 +108,7 @@ public final class Http1Connection implements ConnectionProtocol {
boolean keepAlive = Http1KeepAlive.isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN);
RequestHandler handler = ctx.router().route(request);
RequestHandler handler = ctx.router().route(request, routeScratch);
if (handler == null) handler = ctx.router().getNotFoundHandler();
try {
@@ -1,10 +1,14 @@
package dev.relism.flash.models;
import dev.relism.flash.bytes.ByteScan;
import dev.relism.flash.bytes.SlicePool;
import dev.relism.flash.http.Http1Limits;
import dev.relism.fpr.core.ByteView;
import lombok.NoArgsConstructor;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
/**
@@ -23,31 +27,99 @@ import java.util.List;
* values retrieved via {@link #first}/{@link #all} are safe (they are independent
* heap copies); the {@code HeaderMap} object itself is not.</li>
* <li><b>{@link #view} returns a zero-copy {@link dev.relism.fpr.core.ByteView} slice
* into the live buffer.</b> Storing this view and reading it after the handler
* returns (e.g. in an async callback, a {@link java.util.concurrent.CompletableFuture}
* 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
* handler returns (e.g. in an async callback, a {@link java.util.concurrent.CompletableFuture}
* continuation, or a virtual-thread handoff) is a <em>data race</em> — the bytes
* may have been overwritten by the next request. Copy to a {@code String} or
* {@code byte[]} before leaving the synchronous handler scope.</li>
* </ol>
*
* <h3>{@code EX-09}: an index built once per {@link #reset}, not rescanned per lookup</h3>
* {@link #reset} scans the header section exactly once and records, per header, its name/value
* byte offsets and a case-insensitive 32-bit hash of the name — into {@code int[]} arrays grown
* (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 610 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.
*/
@NoArgsConstructor
public class HeaderMap {
private static final int INITIAL_INDEX_CAPACITY = 16;
private static final int VIEW_POOL_SIZE = 4;
private byte[] buffer;
private int sectionStart;
private int sectionEnd;
// Lazily created, then reused for the life of this HeaderMap (i.e. the connection —
// see the class javadoc) across every #forEach call and every header within a call.
// Same idiom as #view's per-call anonymous ByteView, just amortized to zero allocations
// instead of two per header: the slices are repositioned in place, not reallocated.
// EX-09 index — grown (never shrunk) to this connection's high-water mark, rebuilt in place
// by every reset() call. Entry i's name is buffer[nameOffsets[i], nameOffsets[i]+nameLengths[i]),
// its value is buffer[valueOffsets[i], valueOffsets[i]+valueLengths[i]).
private int headerCount;
private int[] nameOffsets = new int[INITIAL_INDEX_CAPACITY];
private int[] nameLengths = new int[INITIAL_INDEX_CAPACITY];
private int[] valueOffsets = new int[INITIAL_INDEX_CAPACITY];
private int[] valueLengths = new int[INITIAL_INDEX_CAPACITY];
private int[] nameHashes = new int[INITIAL_INDEX_CAPACITY];
// EX-05: pooled, reused slices for view() — see its own Javadoc for the reuse window.
private final SlicePool viewPool = new SlicePool(VIEW_POOL_SIZE);
// forEach's own pair, reused across every header of every call — same idiom as viewPool,
// just a fixed pair rather than a ring, since forEach's contract only needs one name/value
// pair valid at a time (see forEach's Javadoc).
private Slice nameSlice;
private Slice valueSlice;
/** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}. */
/** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}, rebuilding the {@code EX-09} index. */
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
this.buffer = buffer;
this.sectionStart = sectionStart;
this.sectionEnd = sectionEnd;
buildIndex();
}
private void buildIndex() {
headerCount = 0;
if (buffer == null) return;
int i = sectionStart;
while (i < sectionEnd) {
int lineEnd = findCR(i);
int colon = findColon(i, lineEnd);
if (colon != -1) {
int vs = skipSpaces(colon + 1, lineEnd);
ensureIndexCapacity(headerCount + 1);
nameOffsets[headerCount] = i;
nameLengths[headerCount] = colon - i;
valueOffsets[headerCount] = vs;
valueLengths[headerCount] = lineEnd - vs;
nameHashes[headerCount] = ByteScan.hashNameIgnoreCaseAscii(buffer, i, colon - i);
headerCount++;
}
i = lineEnd + 2;
}
}
private void ensureIndexCapacity(int needed) {
if (needed <= nameOffsets.length) return;
// EX-08 (Http1Limits.MAX_HEADER_COUNT) already rejects any request with more headers
// than this before it ever reaches reset() — this can only fire while growing toward
// that ceiling, never past it. Asserted, not silently truncated: an index that silently
// dropped headers past this point would be a correctness bug, not a capacity one.
assert needed <= Http1Limits.MAX_HEADER_COUNT
: "header count " + needed + " exceeds Http1Limits.MAX_HEADER_COUNT — RequestParser should have rejected this already";
int grown = nameOffsets.length;
while (grown < needed) grown *= 2;
nameOffsets = Arrays.copyOf(nameOffsets, grown);
nameLengths = Arrays.copyOf(nameLengths, grown);
valueOffsets = Arrays.copyOf(valueOffsets, grown);
valueLengths = Arrays.copyOf(valueLengths, grown);
nameHashes = Arrays.copyOf(nameHashes, grown);
}
/**
@@ -69,20 +141,13 @@ public class HeaderMap {
nameSlice = new Slice();
valueSlice = new Slice();
}
int i = sectionStart;
while (i < sectionEnd) {
int lineEnd = findCR(i);
int colon = findColon(i, lineEnd);
if (colon != -1) {
int vs = skipSpaces(colon + 1, lineEnd);
nameSlice.start = i;
nameSlice.len = colon - i;
valueSlice.start = vs;
valueSlice.len = lineEnd - vs;
for (int i = 0; i < headerCount; i++) {
nameSlice.start = nameOffsets[i];
nameSlice.len = nameLengths[i];
valueSlice.start = valueOffsets[i];
valueSlice.len = valueLengths[i];
consumer.accept(nameSlice, valueSlice);
}
i = lineEnd + 2;
}
}
/**
@@ -108,26 +173,21 @@ public class HeaderMap {
/** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */
public String first(String name) {
long r = findFirst(name);
if (r < 0) return null;
int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
return new String(buffer, s, l, StandardCharsets.UTF_8);
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. */
public List<String> all(String name) {
if (buffer == null) return List.of();
List<String> result = null;
int i = sectionStart;
while (i < sectionEnd) {
int lineEnd = findCR(i);
int colon = findColon(i, lineEnd);
if (colon != -1 && keyMatches(i, colon - i, name)) {
int vs = skipSpaces(colon + 1, lineEnd);
int hash = ByteScan.hashNameIgnoreCaseAscii(name);
for (int i = 0; i < headerCount; i++) {
if (nameHashes[i] == hash && ByteScan.equalsIgnoreCaseAscii(buffer, nameOffsets[i], nameOffsets[i] + nameLengths[i], name)) {
if (result == null) result = new ArrayList<>();
result.add(new String(buffer, vs, lineEnd - vs, StandardCharsets.UTF_8));
result.add(new String(buffer, valueOffsets[i], valueLengths[i], StandardCharsets.UTF_8));
}
i = lineEnd + 2;
}
return result != null ? result : List.of();
}
@@ -135,61 +195,47 @@ public class HeaderMap {
/** Returns all header values in declaration order. */
public List<String> all() {
if (buffer == null) return List.of();
List<String> result = new ArrayList<>();
int i = sectionStart;
while (i < sectionEnd) {
int lineEnd = findCR(i);
int colon = findColon(i, lineEnd);
if (colon != -1) {
int vs = skipSpaces(colon + 1, lineEnd);
result.add(new String(buffer, vs, lineEnd - vs, StandardCharsets.UTF_8));
}
i = lineEnd + 2;
List<String> result = new ArrayList<>(headerCount);
for (int i = 0; i < headerCount; i++) {
result.add(new String(buffer, valueOffsets[i], valueLengths[i], StandardCharsets.UTF_8));
}
return result;
}
/** Case-insensitive comparison of the first value of {@code name} against {@code value}. */
public boolean valueEqualsIgnoreCase(String name, String value) {
long r = findFirst(name);
if (r < 0) return false;
int vs = (int) (r >> 32), vl = (int) (r & 0xFFFFFFFFL);
if (vl != value.length()) return false;
for (int i = 0; i < vl; i++) {
byte b = buffer[vs + i];
if (b >= 'A' && b <= 'Z') b += 32;
char c = value.charAt(i);
if (c >= 'A' && c <= 'Z') c += 32;
if (b != (byte) c) return false;
}
return true;
int i = indexOfHeader(name);
if (i < 0) return false;
return ByteScan.equalsIgnoreCaseAscii(buffer, valueOffsets[i], valueOffsets[i] + valueLengths[i], value);
}
/** Returns a zero-copy {@link ByteView} over the first value of {@code name}, or {@code null}. */
/**
* 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
* 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.
*/
public ByteView view(String name) {
long r = findFirst(name);
if (r < 0) return null;
final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
return new ByteView() {
public int length() { return l; }
public byte byteAt(int i) { return buffer[s + i]; }
};
int i = indexOfHeader(name);
if (i < 0) return null;
return viewPool.acquire(buffer, valueOffsets[i], valueLengths[i]);
}
/** Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. */
private long findFirst(String name) {
if (buffer == null) return -1L;
int i = sectionStart;
while (i < sectionEnd) {
int lineEnd = findCR(i);
int colon = findColon(i, lineEnd);
if (colon != -1 && keyMatches(i, colon - i, name)) {
int vs = skipSpaces(colon + 1, lineEnd);
return ((long) vs << 32) | (lineEnd - vs);
/** Index into the {@code EX-09} arrays of the first header named {@code name}, or {@code -1}. */
private int indexOfHeader(String name) {
if (buffer == null) return -1;
int hash = ByteScan.hashNameIgnoreCaseAscii(name);
for (int i = 0; i < headerCount; i++) {
if (nameHashes[i] == hash && ByteScan.equalsIgnoreCaseAscii(buffer, nameOffsets[i], nameOffsets[i] + nameLengths[i], name)) {
return i;
}
i = lineEnd + 2;
}
return -1L;
return -1;
}
private int findCR(int from) {
@@ -206,16 +252,4 @@ public class HeaderMap {
while (from < end && buffer[from] == ' ') from++;
return from;
}
private boolean keyMatches(int start, int len, String name) {
if (len != name.length()) return false;
for (int i = 0; i < len; i++) {
byte b = buffer[start + i];
if (b >= 'A' && b <= 'Z') b += 32;
char c = name.charAt(i);
if (c >= 'A' && c <= 'Z') c += 32;
if (b != (byte) c) return false;
}
return true;
}
}
@@ -1,5 +1,7 @@
package dev.relism.flash.models;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.flash.bytes.SlicePool;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.fpr.core.ByteView;
@@ -7,19 +9,67 @@ import java.nio.charset.StandardCharsets;
/**
* Path parameters captured during routing, stored as byte offsets into the path view.
* {@link #get} allocates a String on call; {@link #view} is zero-copy.
* {@link #get} allocates a {@code String} on call (in one allocation when {@link #source} is
* {@link ArrayBackedByteView} — {@code EX-25} — two otherwise); {@link #view} is zero-copy.
*
* <h3>Reusable instances ({@code EX-19})</h3>
* The public constructor below builds a one-shot, fixed-size instance (used by
* {@code AbstractWsRouter} and by tests) — {@code names.length} is taken as the exact param
* count. {@code FastPathRouterImpl}'s per-connection scratch instead owns a single long-lived
* {@code PathParams} whose backing arrays are grown to the connection's high-water mark and
* never reallocated after warmup; because those arrays can be larger than the current request's
* 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
* 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
* request on the same connection repositions the same arrays.
*/
public class PathParams {
private final ByteView source;
private static final int VIEW_POOL_SIZE = 4;
private ByteView source;
private final String[] names;
private final int[] starts;
private final int[] lens;
private int count;
// EX-05: created lazily, only if view() is ever actually called.
private SlicePool viewPool;
public PathParams(ByteView source, String[] names, int[] starts, int[] lens) {
this.source = source;
this.names = names;
this.starts = starts;
this.lens = lens;
this.count = names.length;
}
/**
* Builds an instance meant only for {@link #reset}: no source yet, and {@code count} starts
* at 0 until the first {@link #reset} call. {@code names}/{@code starts}/{@code lens} may be
* larger than any single request's param count — see the class Javadoc.
*/
public PathParams(String[] names, int[] starts, int[] lens) {
this.source = null;
this.names = names;
this.starts = starts;
this.lens = lens;
this.count = 0;
}
/**
* Repositions this instance over a new request: {@code count} (which may be less than
* {@code names.length} — see the class Javadoc) params are now valid, read out of the same
* backing arrays the constructor was given, against the new {@code source}. Zero allocation.
*/
public void reset(ByteView source, int count) {
this.source = source;
this.count = count;
}
/**
@@ -34,23 +84,41 @@ public class PathParams {
public String get(String name) {
int i = indexOf(name);
if (i < 0) return null;
byte[] bytes = new byte[lens[i]];
for (int j = 0; j < lens[i]; j++) bytes[j] = source.byteAt(starts[i] + j);
int start = starts[i], len = lens[i];
// EX-25: a single-copy String construction when the source is a contiguous array slice
// (always true for h1 today) instead of a byte-at-a-time copy into a scratch array
// followed by a second allocation for the String itself.
if (source instanceof ArrayBackedByteView abv) {
return new String(abv.array(), abv.offset() + start, len, StandardCharsets.UTF_8);
}
byte[] bytes = new byte[len];
for (int j = 0; j < len; j++) bytes[j] = source.byteAt(start + j);
return new String(bytes, StandardCharsets.UTF_8);
}
/**
* 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
* fresh (allocating) view otherwise — never exercised on the real request path.
*/
ByteView view(String name) {
int i = indexOf(name);
if (i < 0) return null;
final int s = starts[i], l = lens[i];
int s = starts[i], l = lens[i];
if (source instanceof ArrayBackedByteView abv) {
if (viewPool == null) viewPool = new SlicePool(VIEW_POOL_SIZE);
return viewPool.acquire(abv.array(), abv.offset() + s, l);
}
final int fs = s, fl = l;
return new ByteView() {
public int length() { return l; }
public byte byteAt(int idx) { return source.byteAt(s + idx); }
public int length() { return fl; }
public byte byteAt(int idx) { return source.byteAt(fs + idx); }
};
}
private int indexOf(String name) {
for (int i = 0; i < names.length; i++) if (names[i].equals(name)) return i;
for (int i = 0; i < count; i++) if (names[i].equals(name)) return i;
return -1;
}
}
@@ -1,5 +1,8 @@
package dev.relism.flash.models;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.bytes.SlicePool;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
@@ -15,9 +18,16 @@ import java.util.List;
*/
public class QueryParams {
public static final QueryParams EMPTY = new QueryParams(null);
private static final int VIEW_POOL_SIZE = 4;
private final ByteView raw;
// EX-05: created lazily, only if view() is ever actually called — QueryParams itself is
// recreated per request (see Request#resolveQueryParams), so an eagerly-constructed pool
// would cost VIEW_POOL_SIZE allocations on every request that touches query params at all,
// even the (currently: every) request that never calls view().
private SlicePool viewPool;
public QueryParams(ByteView raw) {
this.raw = raw;
}
@@ -25,16 +35,30 @@ public class QueryParams {
public String get(String name) {
long r = findFirst(name);
if (r < 0) return null;
return decode((int) (r >> 32), (int) (r & 0xFFFFFFFFL));
return decode(Pairs.hi(r), Pairs.lo(r));
}
/**
* 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
* 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).
*/
ByteView view(String name) {
long r = findFirst(name);
if (r < 0) return null;
final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL);
int s = Pairs.hi(r), l = Pairs.lo(r);
if (raw instanceof ArrayBackedByteView abv) {
if (viewPool == null) viewPool = new SlicePool(VIEW_POOL_SIZE);
return viewPool.acquire(abv.array(), abv.offset() + s, l);
}
final int fs = s, fl = l;
return new ByteView() {
public int length() { return l; }
public byte byteAt(int idx) { return raw.byteAt(s + idx); }
public int length() { return fl; }
public byte byteAt(int idx) { return raw.byteAt(fs + idx); }
};
}
@@ -62,7 +86,7 @@ public class QueryParams {
// ── Internals ─────────────────────────────────────────────────────────────
/** Returns (valStart << 32) | valLen, or -1 if not found. */
/** Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. */
private long findFirst(String name) {
if (raw == null) return -1L;
int i = 0, len = raw.length();
@@ -74,7 +98,7 @@ public class QueryParams {
i++;
int valStart = i;
while (i < len && raw.byteAt(i) != '&') i++;
if (keyMatches(keyStart, keyLen, name)) return ((long) valStart << 32) | (i - valStart);
if (keyMatches(keyStart, keyLen, name)) return Pairs.pack(valStart, i - valStart);
}
if (i < len && raw.byteAt(i) == '&') i++;
}
@@ -91,8 +115,27 @@ public class QueryParams {
* Percent-decodes a value slice from {@code raw} into a UTF-8 String.
* {@code %XX} triplets are decoded to their byte values; {@code +} decodes as space.
* Invalid {@code %} sequences are passed through as-is.
*
* <p>{@code EX-26}: the overwhelmingly common query value contains neither {@code %} nor
* {@code +} — scanned for first; when clean and {@link #raw} is array-backed, the
* {@code String} is built directly from the backing array in one allocation, skipping the
* scratch {@code byte[]} copy this method used to make unconditionally for every value.
*/
private String decode(int start, int length) {
boolean clean = true;
for (int i = 0; i < length; i++) {
byte b = raw.byteAt(start + i);
if (b == '%' || b == '+') { clean = false; break; }
}
if (clean) {
if (raw instanceof ArrayBackedByteView abv) {
return new String(abv.array(), abv.offset() + start, length, StandardCharsets.UTF_8);
}
byte[] out = new byte[length];
for (int i = 0; i < length; i++) out[i] = raw.byteAt(start + i);
return new String(out, StandardCharsets.UTF_8);
}
byte[] out = new byte[length]; // upper bound — decoded is never longer
int w = 0;
for (int i = 0; i < length; i++) {
@@ -1,6 +1,7 @@
package dev.relism.flash.models;
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;
@@ -123,6 +124,12 @@ public class Request {
public String path() {
if (cachedPath != null) return cachedPath;
ByteView v = requestLine.getPath();
// EX-25: one allocation via a direct String(array, offset, length) construction when the
// view is a contiguous array slice (always true for h1 today), instead of a byte-at-a-time
// copy into a scratch array followed by a second allocation for the String itself.
if (v instanceof ArrayBackedByteView abv) {
return cachedPath = new String(abv.array(), abv.offset(), v.length(), StandardCharsets.UTF_8);
}
byte[] buf = new byte[v.length()];
for (int i = 0; i < v.length(); i++) buf[i] = v.byteAt(i);
return cachedPath = new String(buf, StandardCharsets.UTF_8);
@@ -6,7 +6,6 @@ import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
import dev.relism.flash.Flash;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.fpr.core.ByteView;
import dev.relism.flash.template.ErrorPages;
import java.nio.charset.StandardCharsets;
@@ -101,14 +100,33 @@ public abstract class AbstractRouter {
// ── Routing ──────────────────────────────────────────────────────────────
public abstract RequestHandler route(Request request);
/**
* Creates a fresh per-connection scratch object for {@link #route}, or {@code null} if this
* router implementation keeps no reusable per-connection state. Called once per connection
* by the connection driver (e.g. {@code Http1Connection}), which holds the opaque result and
* passes it back into every {@link #route} call for that connection's whole lifetime — the
* same "create once per connection, reuse across requests" shape already used there for
* {@code RequestParser}.
*
* <p>{@code EX-06}'s router-half fix: a {@code ThreadLocal} here would mean "one per virtual
* thread", which under this codebase's one-virtual-thread-per-connection model is "one per
* connection with no upper bound and no pooling" — exactly the failure mode
* {@code ConnectionScratch} already exists to avoid for every other per-connection buffer.
* An explicit, caller-owned scratch object achieves the same per-connection reuse without
* that unbounded-growth risk, and without requiring {@code routing} to depend on
* {@code transport}'s {@code ConnectionScratch} type (this package has no such dependency
* today — see {@code DECISIONS.md}, {@code DEC-19}, for why that boundary was kept rather
* than extending {@code ConnectionScratch} itself, which is what an earlier draft of this
* fix assumed).
*/
public Object newScratch() {
return null;
}
public abstract RequestHandler route(Request request, Object scratch);
protected abstract AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler);
protected static void setPathParams(Request request, String[] names, ByteView source, int[] starts, int[] lens) {
PathParams.inject(request, new PathParams(source, names, starts, lens));
}
@FunctionalInterface
public interface ExceptionHandler {
Object handle(Exception exception, Request request, Response response);
@@ -15,7 +15,17 @@ public abstract class AbstractWsRouter {
return addRoute(method, PathUtils.sanitize(path), handler);
}
public abstract WebSocketHandler route(Request request);
/**
* Creates a fresh per-connection scratch object for {@link #route}, or {@code null} if this
* router keeps no reusable per-connection state — see {@link AbstractRouter#newScratch} for
* the full rationale ({@code EX-06}'s router-half fix), mirrored here for the WebSocket
* router.
*/
public Object newScratch() {
return null;
}
public abstract WebSocketHandler route(Request request, Object scratch);
protected abstract AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler);
@@ -6,15 +6,21 @@ import dev.relism.fpr.core.MatchResult;
import dev.relism.fpr.core.RouterBuilder;
import dev.relism.fpr.core.dsl.StringRouteParser;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.PathParams;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.routing.AbstractRouter;
import java.util.Arrays;
/**
* Router backed by the {@code fpr-core} byte-level state machine. Routes are compiled lazily
* on the first request and recompiled when routes are added after startup. Matching runs on a
* virtual {@code METHOD + path} byte sequence in a single pass; {@link MatchResult} and
* {@link FastPathViews.MethodPathByteView} are reused per-thread to avoid hot-path allocations.
* virtual {@code METHOD + path} byte sequence in a single pass; the per-connection
* {@link RouteScratch} ({@link #newScratch}) owns the reused {@link MatchResult},
* {@link FastPathViews.MethodPathByteView} and path-param arrays that would otherwise allocate
* (or, before {@code EX-06}'s router-half fix, sit in an unbounded {@code ThreadLocal}) on every
* request.
*/
public class FastPathRouterImpl extends AbstractRouter {
private final RouterBuilder<RequestHandler> builder = new RouterBuilder<>();
@@ -23,19 +29,47 @@ public class FastPathRouterImpl extends AbstractRouter {
public FastPathRouterImpl() {}
private static final class FastPathRouterContext {
private static final ThreadLocal<MatchResult<RequestHandler>> RESULT_HOLDER =
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
private static final ThreadLocal<FastPathViews.MethodPathByteView> COMBINED_VIEW_HOLDER =
ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new);
/**
* Per-connection reusable matching state — {@code EX-06}'s router half and {@code EX-19}
* together. Created once per connection by {@link #newScratch} and threaded back into every
* {@link #route} call for that connection's lifetime (see {@link AbstractRouter#newScratch}
* for why this replaced the two {@code ThreadLocal}s this class used to hold).
*
* <p>{@code paramNames}/{@code paramStarts}/{@code paramLens} ({@code EX-19}) start small and
* grow (doubling, via {@link #ensureParamCapacity}) to the connection's high-water mark —
* the number of path params the most param-heavy route matched on this connection ever
* needed — and are never shrunk back down or reallocated once warm, the same amortized policy
* {@code RequestParser}'s read buffer already uses. {@code pathParams} is the single
* {@link PathParams} instance repositioned (via {@link PathParams#reset}) over those arrays
* every time a match has params, instead of a fresh {@code PathParams} per request.
*/
static final class RouteScratch {
final MatchResult<RequestHandler> matchResult = new MatchResult<>(32, 128);
final FastPathViews.MethodPathByteView combinedView = new FastPathViews.MethodPathByteView();
public static MatchResult<RequestHandler> getResult() {
return RESULT_HOLDER.get();
String[] paramNames = new String[8];
int[] paramStarts = new int[8];
int[] paramLens = new int[8];
PathParams pathParams = new PathParams(paramNames, paramStarts, paramLens);
void ensureParamCapacity(int count) {
if (count <= paramNames.length) return;
int grown = paramNames.length;
while (grown < count) grown *= 2;
paramNames = Arrays.copyOf(paramNames, grown);
paramStarts = Arrays.copyOf(paramStarts, grown);
paramLens = Arrays.copyOf(paramLens, grown);
// The arrays PathParams reads are now different instances — rebuild it. This is the
// only case in which a RouteScratch allocates past connection setup, and only on a
// connection whose route mix keeps needing more params than ever seen before; it
// never happens again once this connection's high-water mark stabilizes.
pathParams = new PathParams(paramNames, paramStarts, paramLens);
}
}
public static FastPathViews.MethodPathByteView getCombinedView() {
return COMBINED_VIEW_HOLDER.get();
}
@Override
public Object newScratch() {
return new RouteScratch();
}
@Override
@@ -46,15 +80,16 @@ public class FastPathRouterImpl extends AbstractRouter {
}
@Override
public RequestHandler route(Request request) {
public RequestHandler route(Request request, Object scratchObj) {
ensureCompiled();
RouteScratch scratch = (RouteScratch) scratchObj;
MatchResult<RequestHandler> result = FastPathRouterContext.getResult();
MatchResult<RequestHandler> result = scratch.matchResult;
result.reset();
HttpMethod method = request.getRequestLine().getMethod();
ByteView pathView = request.getRequestLine().getPath();
FastPathViews.MethodPathByteView combinedView = FastPathRouterContext.getCombinedView();
FastPathViews.MethodPathByteView combinedView = scratch.combinedView;
combinedView.reset(method.getBytes(), pathView);
int labelId = router.match(combinedView, result);
@@ -65,18 +100,20 @@ public class FastPathRouterImpl extends AbstractRouter {
int count = result.paramCount();
if (count > 0) {
scratch.ensureParamCapacity(count);
int methodLen = method.getBytes().length;
String[] all = cachedParamNames;
String[] names = new String[count];
int[] starts = new int[count];
int[] lens = new int[count];
String[] names = scratch.paramNames;
int[] starts = scratch.paramStarts;
int[] lens = scratch.paramLens;
for (int i = 0; i < count; i++) {
names[i] = all[result.keyIdAt(i)];
starts[i] = result.startAt(i) - methodLen;
lens[i] = result.lenAt(i);
}
setPathParams(request, names, pathView, starts, lens);
scratch.pathParams.reset(pathView, count);
PathParams.inject(request, scratch.pathParams);
}
return result.handler();
@@ -1,15 +1,47 @@
package dev.relism.flash.routing.routers.fastpathrouter;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.fpr.core.ByteView;
import lombok.NoArgsConstructor;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.nio.ByteOrder;
import java.nio.charset.StandardCharsets;
/** {@link dev.relism.fpr.core.ByteView} implementations used on the router and parser hot paths. */
@NoArgsConstructor
public final class FastPathViews {
public static final class RequestByteView implements ByteView {
/**
* {@code EX-04}: {@code fpr-core}'s decompiled {@code ByteCompare} (its word-at-a-time
* router-matching fast path — see {@code ByteCompare.equals}/{@code indexOf}) reads a
* comparison word via {@code MethodHandles.byteArrayViewVarHandle(long[].class,
* ByteOrder.LITTLE_ENDIAN)} and compares it bit-for-bit against whatever
* {@link ByteView#longAt} returns. For that comparison to be correct, {@code longAt} must
* therefore return the <em>identical</em> little-endian-assembled value for the same 8
* bytes — fixed to {@code LITTLE_ENDIAN} specifically (not {@code nativeOrder()}) so the
* contract holds on every host regardless of the JVM's native byte order, matching
* {@code ByteCompare}'s own fixed choice exactly. Confirmed by decompiling
* {@code fpr-core-1.1.1}'s {@code ByteCompare.class} (its {@code LONG_VIEW} field), not
* merely assumed — see {@code FastPathViewsLongAtTest} for the runtime verification the
* plan requires beyond reading bytecode.
*/
private static final VarHandle LONG_VIEW_LE =
MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN);
/**
* Reads 8 bytes at {@code array[pos, pos + 8)} as fpr-core's {@code ByteCompare} expects a
* {@link ByteView#longAt} implementation to. Caller-guaranteed contract (never asserted here
* — {@code ByteCompare} itself never calls this without first checking {@code pos + 8 <=
* length}, so a defensive check here would be dead code on every real call path; see
* {@code EX-04}'s registry entry): {@code pos + 8 <= array.length}.
*/
private static long longAtLittleEndian(byte[] array, int pos) {
return (long) LONG_VIEW_LE.get(array, pos);
}
public static final class RequestByteView implements ArrayBackedByteView {
private final byte[] buffer;
private final int start;
private final int length;
@@ -33,13 +65,49 @@ public final class FastPathViews {
return buffer[start + index];
}
@Override
public byte[] array() {
return buffer;
}
@Override
public int offset() {
return start;
}
/** {@code EX-04}: array-backed and contiguous — the word-at-a-time router path applies. */
@Override
public boolean supportsLong() {
return true;
}
@Override
public long longAt(int index) {
return longAtLittleEndian(buffer, start + index);
}
@Override
public String toString() {
return new String(buffer, start, length, StandardCharsets.UTF_8);
}
}
/** Mutable composite view: method bytes + path. Reused via ThreadLocal, call reset() before use. */
/**
* Mutable composite view: method bytes + path. Reused per connection, call {@link #reset}
* before use (see {@code FastPathRouterImpl}'s per-connection scratch, {@code EX-06}).
*
* <h3>{@code EX-04}: deliberately not array-backed, {@code supportsLong()} stays {@code false}</h3>
* Unlike every other view in this file, this one is a composite of two independent sources
* (a raw {@code byte[]} for the method, and another {@link ByteView} — itself possibly
* array-backed — for the path). There is no single backing array a word-at-a-time read could
* span, and a byte index near the method/path boundary could straddle both sources entirely,
* making a single contiguous 8-byte read structurally impossible in general (not merely
* unimplemented) — the same reasoning {@link dev.relism.flash.bytes.SegmentedByteView}
* documents for the analogous HPACK CONTINUATION case. Falls back to the inherited
* {@link ByteView#supportsLong} default ({@code false}); {@code fpr-core}'s router-matching
* path already handles that correctly (it only takes the word-at-a-time branch when
* {@code supportsLong()} is {@code true}).
*/
public static final class MethodPathByteView implements ByteView {
private byte[] method;
private ByteView path;
@@ -62,7 +130,7 @@ public final class FastPathViews {
}
}
public static class SocketByteView implements ByteView {
public static class SocketByteView implements ArrayBackedByteView {
private final byte[] data;
public SocketByteView(byte[] data) {
@@ -78,9 +146,30 @@ public final class FastPathViews {
public byte byteAt(int index) {
return data[index];
}
@Override
public byte[] array() {
return data;
}
public static class StringByteView implements ByteView {
@Override
public int offset() {
return 0;
}
/** {@code EX-04}: array-backed and contiguous — the word-at-a-time router path applies. */
@Override
public boolean supportsLong() {
return true;
}
@Override
public long longAt(int index) {
return longAtLittleEndian(data, index);
}
}
public static class StringByteView implements ArrayBackedByteView {
private final byte[] bytes;
public StringByteView(String str) {
@@ -96,5 +185,26 @@ public final class FastPathViews {
public byte byteAt(int index) {
return bytes[index];
}
@Override
public byte[] array() {
return bytes;
}
@Override
public int offset() {
return 0;
}
/** {@code EX-04}: array-backed and contiguous — the word-at-a-time router path applies. */
@Override
public boolean supportsLong() {
return true;
}
@Override
public long longAt(int index) {
return longAtLittleEndian(bytes, index);
}
}
}
@@ -10,12 +10,35 @@ import dev.relism.flash.models.Request;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.websocket.WebSocketHandler;
/**
* WebSocket-upgrade counterpart of {@link FastPathRouterImpl} — same {@code fpr-core} matching
* engine, same {@code EX-06} router-half fix (an explicit per-connection {@link RouteScratch}
* via {@link #newScratch} in place of the {@code ThreadLocal}s this class used to hold). Unlike
* {@link FastPathRouterImpl}, its path-param extraction is not covered by {@code EX-19} (that
* registry entry names {@code FastPathRouterImpl.route} specifically) and still allocates a
* fresh {@code PathParams} per matched, parametric WebSocket upgrade — WebSocket upgrades are
* inherently rare relative to ordinary requests (one per connection, not one per message), so
* this was not flagged as a hot-path allocation concern.
*/
public final class FastPathWsRouterImpl extends AbstractWsRouter {
private final RouterBuilder<WebSocketHandler> builder = new RouterBuilder<>();
private volatile FastPathRouter<ByteView, WebSocketHandler> router;
private String[] cachedParamNames;
/** Per-connection reusable matching state — see {@link FastPathRouterImpl.RouteScratch}'s
* javadoc for the full {@code EX-06} rationale; this router's scratch is smaller since
* {@code EX-19}'s path-param reuse does not apply here (see the class Javadoc). */
static final class RouteScratch {
final MatchResult<WebSocketHandler> matchResult = new MatchResult<>(32, 128);
final FastPathViews.MethodPathByteView combinedView = new FastPathViews.MethodPathByteView();
}
@Override
public Object newScratch() {
return new RouteScratch();
}
@Override
protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) {
builder.add(StringRouteParser.parse(method.name() + path), handler);
@@ -24,15 +47,16 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter {
}
@Override
public WebSocketHandler route(Request request) {
public WebSocketHandler route(Request request, Object scratchObj) {
ensureCompiled();
RouteScratch scratch = (RouteScratch) scratchObj;
MatchResult<WebSocketHandler> result = Context.result();
MatchResult<WebSocketHandler> result = scratch.matchResult;
result.reset();
HttpMethod method = request.getRequestLine().getMethod();
ByteView pathView = request.getRequestLine().getPath();
FastPathViews.MethodPathByteView combined = Context.combined();
FastPathViews.MethodPathByteView combined = scratch.combinedView;
combined.reset(method.getBytes(), pathView);
int labelId = router.match(combined, result);
@@ -58,14 +82,4 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter {
@Override
public void compile() { ensureCompiled(); }
private static final class Context {
private static final ThreadLocal<MatchResult<WebSocketHandler>> RESULT =
ThreadLocal.withInitial(() -> new MatchResult<>(32, 128));
private static final ThreadLocal<FastPathViews.MethodPathByteView> COMBINED =
ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new);
static MatchResult<WebSocketHandler> result() { return RESULT.get(); }
static FastPathViews.MethodPathByteView combined() { return COMBINED.get(); }
}
}
@@ -23,10 +23,13 @@ import java.security.NoSuchAlgorithmException;
* pool when the connection closes. Never shared between two connections at once — there is no
* synchronization here because none is needed.
*
* <p>Extended in Phase 4 with the router's reusable {@code MatchResult}/path-view fields
* (currently still {@code ThreadLocal} in {@code FastPathRouterImpl}, per {@code EX-06}'s own
* multi-phase assignment — see {@code DECISIONS.md} for why Phase 2 does not also absorb that
* part of the fix) and in later phases with HTTP/2 write/HPACK scratch.
* <p>{@code EX-06}'s router half (the {@code FastPathRouterImpl}/{@code FastPathWsRouterImpl}
* {@code ThreadLocal}s) is fixed in Phase 4, but deliberately <em>not</em> by extending this
* class: {@code routing} has no dependency on {@code transport} today, and folding the router's
* scratch fields in here would have created one — see {@code DECISIONS.md}, {@code DEC-19}, for
* the opaque-per-connection-object mechanism ({@code AbstractRouter#newScratch}) used instead.
* This class gains HTTP/2 write/HPACK scratch in later phases, where {@code h2} already depends
* on {@code transport} and no such boundary concern applies.
*/
public final class ConnectionScratch {
@@ -0,0 +1,67 @@
package dev.relism.flash.bytes;
import org.junit.jupiter.api.Test;
import java.util.Random;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
/**
* Randomized agreement testing for {@link ByteScan}'s SWAR methods against their scalar
* counterparts, per Phase 4's task 1 ("property-test SWAR against scalar on random inputs of
* every length 0..256 ... including unaligned starts"). {@link ByteScanTest} already covers
* every exact boundary deterministically; this class instead throws a large volume of fully
* random bytes and random sub-ranges at both implementations, on a fixed seed for reproducible
* CI failures, purely to catch any interaction between random byte content and the SWAR bit
* tricks that a hand-picked boundary test would miss.
*/
class ByteScanFuzzTest {
private static final int TRIALS = 20_000;
private static final int MAX_LEN = 300;
@Test
void indexOf_agreesWithScalar_onFullyRandomInputs() {
Random rnd = new Random(1234567);
for (int t = 0; t < TRIALS; t++) {
int len = rnd.nextInt(MAX_LEN + 1);
byte[] buf = new byte[len];
rnd.nextBytes(buf);
byte target = (byte) rnd.nextInt(256);
int from = len == 0 ? 0 : rnd.nextInt(len + 1);
int to = from == len ? len : from + rnd.nextInt(len - from + 1);
int expected = ByteScan.indexOfScalar(buf, from, to, target);
int actual = assertDoesNotThrow(() -> ByteScan.indexOf(buf, from, to, target));
int trial = t;
assertEquals(expected, actual,
() -> "mismatch at trial " + trial + ", len=" + len + ", from=" + from + ", to=" + to);
}
}
@Test
void indexOfCrLfCrLf_agreesWithScalar_onFullyRandomInputs() {
Random rnd = new Random(9876543);
for (int t = 0; t < TRIALS; t++) {
int len = rnd.nextInt(MAX_LEN + 1);
byte[] buf = new byte[len];
rnd.nextBytes(buf);
// Occasionally bias toward CR/LF bytes so real matches (and near-matches) show up,
// not just "no CR anywhere" cases.
if (rnd.nextInt(3) == 0) {
for (int i = 0; i < len; i++) {
if (rnd.nextInt(4) == 0) buf[i] = rnd.nextBoolean() ? (byte) '\r' : (byte) '\n';
}
}
int from = len == 0 ? 0 : rnd.nextInt(len + 1);
int to = from == len ? len : from + rnd.nextInt(len - from + 1);
int expected = ByteScan.indexOfCrLfCrLfScalar(buf, from, to);
int actual = assertDoesNotThrow(() -> ByteScan.indexOfCrLfCrLf(buf, from, to));
int trial = t;
assertEquals(expected, actual,
() -> "mismatch at trial " + trial + ", len=" + len + ", from=" + from + ", to=" + to);
}
}
}
@@ -0,0 +1,247 @@
package dev.relism.flash.bytes;
import dev.relism.fpr.core.ByteView;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.Random;
import static org.junit.jupiter.api.Assertions.*;
class ByteScanTest {
private static final class ArrayView implements ByteView {
final byte[] buf;
ArrayView(String s) { this.buf = s.getBytes(StandardCharsets.US_ASCII); }
@Override public int length() { return buf.length; }
@Override public byte byteAt(int i) { return buf[i]; }
}
// ── isTChar ──────────────────────────────────────────────────────────────
@Test
void isTChar_acceptsRfc9110TCharSet() {
for (char c = '0'; c <= '9'; c++) assertTrue(ByteScan.isTChar((byte) c));
for (char c = 'A'; c <= 'Z'; c++) assertTrue(ByteScan.isTChar((byte) c));
for (char c = 'a'; c <= 'z'; c++) assertTrue(ByteScan.isTChar((byte) c));
for (byte b : "!#$%&'*+-.^_`|~".getBytes(StandardCharsets.US_ASCII)) assertTrue(ByteScan.isTChar(b));
}
@Test
void isTChar_rejectsDelimitersAndControlAndHighBytes() {
for (byte b : " \t\":;,()<>[]{}=?@\\/".getBytes(StandardCharsets.US_ASCII)) {
assertFalse(ByteScan.isTChar(b), "byte '" + (char) b + "' must not be a tchar");
}
assertFalse(ByteScan.isTChar((byte) 0));
assertFalse(ByteScan.isTChar((byte) 127));
assertFalse(ByteScan.isTChar((byte) -1)); // high-bit byte, e.g. UTF-8 continuation
}
// ── indexOf: SWAR vs scalar, every boundary ─────────────────────────────
@Test
void indexOf_swarAgreesWithScalar_everyLengthAndPosition() {
Random rnd = new Random(42);
for (int len = 0; len <= 256; len++) {
byte[] buf = new byte[len];
rnd.nextBytes(buf);
// Ensure the target byte value (7) doesn't appear anywhere except where we plant it.
for (int i = 0; i < len; i++) if (buf[i] == 7) buf[i] = 8;
assertEquals(-1, ByteScan.indexOfScalar(buf, 0, len, (byte) 7));
assertEquals(ByteScan.indexOfScalar(buf, 0, len, (byte) 7), ByteScan.indexOf(buf, 0, len, (byte) 7));
for (int pos = 0; pos < len; pos++) {
byte[] planted = buf.clone();
planted[pos] = 7;
int expected = ByteScan.indexOfScalar(planted, 0, len, (byte) 7);
assertEquals(pos, expected, "scalar oracle disagrees with itself at pos " + pos);
assertEquals(expected, ByteScan.indexOf(planted, 0, len, (byte) 7),
"SWAR disagrees with scalar at len=" + len + " pos=" + pos);
}
}
}
@Test
void indexOf_unalignedStart_agreesWithScalar() {
Random rnd = new Random(7);
byte[] buf = new byte[64];
rnd.nextBytes(buf);
for (int i = 0; i < buf.length; i++) if (buf[i] == 9) buf[i] = 10;
buf[40] = 9;
for (int from = 0; from < 8; from++) {
assertEquals(ByteScan.indexOfScalar(buf, from, buf.length, (byte) 9),
ByteScan.indexOf(buf, from, buf.length, (byte) 9));
}
}
// ── indexOfCrLfCrLf: SWAR vs scalar, every boundary ─────────────────────
@Test
void indexOfCrLfCrLf_swarAgreesWithScalar_everyLengthAndPosition() {
Random rnd = new Random(99);
for (int len = 4; len <= 128; len++) {
byte[] base = new byte[len];
rnd.nextBytes(base);
// Strip any accidental \r or \n so only the planted match exists.
for (int i = 0; i < len; i++) {
if (base[i] == '\r' || base[i] == '\n') base[i] = 'x';
}
assertEquals(-1, ByteScan.indexOfCrLfCrLfScalar(base, 0, len));
assertEquals(-1, ByteScan.indexOfCrLfCrLf(base, 0, len));
for (int pos = 0; pos <= len - 4; pos++) {
byte[] planted = base.clone();
planted[pos] = '\r'; planted[pos + 1] = '\n'; planted[pos + 2] = '\r'; planted[pos + 3] = '\n';
int expected = ByteScan.indexOfCrLfCrLfScalar(planted, 0, len);
assertEquals(pos, expected);
assertEquals(expected, ByteScan.indexOfCrLfCrLf(planted, 0, len),
"SWAR disagrees with scalar at len=" + len + " pos=" + pos);
}
}
}
@Test
void indexOfCrLfCrLf_matchAtVeryLastPossiblePosition() {
byte[] buf = "GET / HTTP/1.1\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
int expected = buf.length - 4;
assertEquals(expected, ByteScan.indexOfCrLfCrLf(buf, 0, buf.length));
}
@Test
void indexOfCrLfCrLf_bareCrNotFollowedByLf_isNotAMatch() {
byte[] buf = "a\r\rb\r\n\r\nc".getBytes(StandardCharsets.US_ASCII);
int expected = ByteScan.indexOfCrLfCrLfScalar(buf, 0, buf.length);
assertEquals(expected, ByteScan.indexOfCrLfCrLf(buf, 0, buf.length));
assertTrue(expected >= 0);
}
@Test
void indexOfCrLfCrLf_lengthNotMultipleOfEight_doesNotOverrun() {
for (int len = 4; len <= 20; len++) {
byte[] buf = new byte[len];
for (int i = 0; i < len; i++) buf[i] = 'x';
assertEquals(-1, ByteScan.indexOfCrLfCrLf(buf, 0, len));
if (len >= 4) {
buf[len - 4] = '\r'; buf[len - 3] = '\n'; buf[len - 2] = '\r'; buf[len - 1] = '\n';
assertEquals(len - 4, ByteScan.indexOfCrLfCrLf(buf, 0, len));
}
}
}
// ── Case-insensitive comparison ─────────────────────────────────────────
@Test
void equalsIgnoreCaseAscii_array_matchesRegardlessOfCase() {
byte[] buf = "Content-Type".getBytes(StandardCharsets.US_ASCII);
assertTrue(ByteScan.equalsIgnoreCaseAscii(buf, 0, buf.length, "content-type"));
assertTrue(ByteScan.equalsIgnoreCaseAscii(buf, 0, buf.length, "CONTENT-TYPE"));
assertFalse(ByteScan.equalsIgnoreCaseAscii(buf, 0, buf.length, "content-length"));
}
@Test
void equalsIgnoreCaseAscii_twoArrays() {
byte[] a = "Accept".getBytes(StandardCharsets.US_ASCII);
byte[] b = "aCCEPT".getBytes(StandardCharsets.US_ASCII);
assertTrue(ByteScan.equalsIgnoreCaseAscii(a, 0, a.length, b, 0, b.length));
byte[] c = "Accept-X".getBytes(StandardCharsets.US_ASCII);
assertFalse(ByteScan.equalsIgnoreCaseAscii(a, 0, a.length, c, 0, c.length));
}
@Test
void equalsIgnoreCase_view() {
ArrayView v = new ArrayView("Keep-Alive");
assertTrue(ByteScan.equalsIgnoreCase(v, 0, v.length(), "keep-alive"));
assertFalse(ByteScan.equalsIgnoreCase(v, 0, v.length(), "close"));
}
// ── Token lists ──────────────────────────────────────────────────────────
@Test
void tokenListContains_findsTokenAmongMultiple() {
ArrayView v = new ArrayView("keep-alive, Upgrade");
assertTrue(ByteScan.tokenListContains(v, "upgrade"));
assertTrue(ByteScan.tokenListContains(v, "keep-alive"));
assertFalse(ByteScan.tokenListContains(v, "close"));
}
@Test
void tokenListContains_singleToken() {
ArrayView v = new ArrayView("close");
assertTrue(ByteScan.tokenListContains(v, "close"));
}
@Test
void tokenListContains_emptyList() {
ArrayView v = new ArrayView("");
assertFalse(ByteScan.tokenListContains(v, "close"));
}
// ── Header-name hash ─────────────────────────────────────────────────────
@Test
void hashNameIgnoreCaseAscii_isCaseInsensitive() {
byte[] lower = "content-length".getBytes(StandardCharsets.US_ASCII);
byte[] mixed = "Content-Length".getBytes(StandardCharsets.US_ASCII);
byte[] upper = "CONTENT-LENGTH".getBytes(StandardCharsets.US_ASCII);
int h1 = ByteScan.hashNameIgnoreCaseAscii(lower, 0, lower.length);
int h2 = ByteScan.hashNameIgnoreCaseAscii(mixed, 0, mixed.length);
int h3 = ByteScan.hashNameIgnoreCaseAscii(upper, 0, upper.length);
assertEquals(h1, h2);
assertEquals(h2, h3);
}
@Test
void hashNameIgnoreCaseAscii_stringOverloadAgreesWithByteArrayOverload() {
for (String name : new String[]{"content-length", "Content-Length", "CONTENT-LENGTH", "x", ""}) {
byte[] b = name.getBytes(StandardCharsets.US_ASCII);
assertEquals(ByteScan.hashNameIgnoreCaseAscii(b, 0, b.length), ByteScan.hashNameIgnoreCaseAscii(name));
}
}
@Test
void hashNameIgnoreCaseAscii_differentNamesUsuallyDiffer() {
String[] names = {"content-length", "content-type", "authorization", "cookie", "accept",
"host", "user-agent", "x-forwarded-for", "connection", "upgrade"};
java.util.Set<Integer> hashes = new java.util.HashSet<>();
for (String n : names) {
byte[] b = n.getBytes(StandardCharsets.US_ASCII);
hashes.add(ByteScan.hashNameIgnoreCaseAscii(b, 0, b.length));
}
assertEquals(names.length, hashes.size(), "expected no collisions among common header names");
}
// ── Decimal / hex parsing ────────────────────────────────────────────────
@Test
void parseDecimalStrict_validAndInvalidCases() {
assertEquals(0L, parse("0"));
assertEquals(12345L, parse("12345"));
assertEquals(Long.MAX_VALUE, parse(Long.toString(Long.MAX_VALUE)));
assertEquals(ByteScan.PARSE_INVALID, parse(""));
assertEquals(ByteScan.PARSE_INVALID, parse("12a45"));
assertEquals(ByteScan.PARSE_INVALID, parse("-1"));
assertEquals(ByteScan.PARSE_INVALID, parse("+1"));
assertEquals(ByteScan.PARSE_INVALID, parse("99999999999999999999")); // overflow
assertEquals(ByteScan.PARSE_INVALID, parse("10000000000000000000")); // > Long.MAX_VALUE, 20 digits already rejected by length
}
private static long parse(String s) {
byte[] b = s.getBytes(StandardCharsets.US_ASCII);
return ByteScan.parseDecimalStrict(b, 0, b.length);
}
@Test
void parseHexStrict_validAndInvalidCases() {
assertEquals(0xFFL, hex("ff", 8));
assertEquals(0xABCDL, hex("aBcD", 8));
assertEquals(ByteScan.PARSE_INVALID, hex("", 8));
assertEquals(ByteScan.PARSE_INVALID, hex("xyz", 8));
assertEquals(ByteScan.PARSE_INVALID, hex("123456789", 8)); // too many digits
}
private static long hex(String s, int maxDigits) {
byte[] b = s.getBytes(StandardCharsets.US_ASCII);
return ByteScan.parseHexStrict(b, 0, b.length, maxDigits);
}
}
@@ -0,0 +1,124 @@
package dev.relism.flash.bytes;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class ByteWriterTest {
private static String asString(ByteWriter w) {
return new String(w.array(), 0, w.length(), StandardCharsets.US_ASCII);
}
@Test
void writeByte_and_writeBytes() {
ByteWriter w = new ByteWriter(4);
w.writeByte((byte) 'H');
w.writeBytes("ello".getBytes(StandardCharsets.US_ASCII));
assertEquals("Hello", asString(w));
}
@Test
void writeBytes_offsetAndLength() {
ByteWriter w = new ByteWriter(4);
byte[] src = "xxHELLOxx".getBytes(StandardCharsets.US_ASCII);
w.writeBytes(src, 2, 5);
assertEquals("HELLO", asString(w));
}
@Test
void growsPastInitialCapacity_withoutLosingData() {
ByteWriter w = new ByteWriter(2);
StringBuilder expected = new StringBuilder();
for (int i = 0; i < 1000; i++) {
w.writeByte((byte) ('a' + (i % 26)));
expected.append((char) ('a' + (i % 26)));
}
assertEquals(expected.toString(), asString(w));
}
@Test
void reset_reusesBufferFromScratch() {
ByteWriter w = new ByteWriter(16);
w.writeBytes("first".getBytes(StandardCharsets.US_ASCII));
byte[] bufBeforeReset = w.array();
w.reset();
assertEquals(0, w.length());
w.writeBytes("second".getBytes(StandardCharsets.US_ASCII));
assertEquals("second", asString(w));
assertSame(bufBeforeReset, w.array(), "reset() must not reallocate when capacity already suffices");
}
@Test
void writeDecimal_variousValues() {
assertDecimal("0", 0);
assertDecimal("7", 7);
assertDecimal("42", 42);
assertDecimal("1000000", 1_000_000);
assertDecimal(Long.toString(Long.MAX_VALUE), Long.MAX_VALUE);
}
private static void assertDecimal(String expected, long value) {
ByteWriter w = new ByteWriter(4);
w.writeDecimal(value);
assertEquals(expected, asString(w));
}
@Test
void writeDecimal_rejectsNegative() {
ByteWriter w = new ByteWriter(4);
assertThrows(IllegalArgumentException.class, () -> w.writeDecimal(-1));
}
@Test
void writeHex_variousValues() {
assertHex("0", 0);
assertHex("ff", 0xFF);
assertHex("1a2b3c", 0x1A2B3C);
assertHex("ffffffff", 0xFFFFFFFF);
}
private static void assertHex(String expected, int value) {
ByteWriter w = new ByteWriter(4);
w.writeHex(value);
assertEquals(expected, asString(w));
}
@Test
void writeAsciiLower_lowersUppercaseOnly() {
ByteWriter w = new ByteWriter(4);
w.writeAsciiLower("Content-TYPE");
assertEquals("content-type", asString(w));
}
@Test
void writeUInt16_bigEndian() {
ByteWriter w = new ByteWriter(4);
w.writeUInt16(0x1234);
assertArrayEquals(new byte[]{0x12, 0x34}, java.util.Arrays.copyOf(w.array(), w.length()));
}
@Test
void writeUInt24_bigEndian() {
ByteWriter w = new ByteWriter(4);
w.writeUInt24(0x123456);
assertArrayEquals(new byte[]{0x12, 0x34, 0x56}, java.util.Arrays.copyOf(w.array(), w.length()));
}
@Test
void writeUInt31_masksTopBit() {
ByteWriter w = new ByteWriter(4);
w.writeUInt31(0xFFFFFFFF); // all bits set -> top bit must be cleared
assertArrayEquals(new byte[]{0x7F, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF},
java.util.Arrays.copyOf(w.array(), w.length()));
}
@Test
void writeUInt32_bigEndian() {
ByteWriter w = new ByteWriter(4);
w.writeUInt32(0x01020304);
assertArrayEquals(new byte[]{0x01, 0x02, 0x03, 0x04}, java.util.Arrays.copyOf(w.array(), w.length()));
}
}
@@ -0,0 +1,37 @@
package dev.relism.flash.bytes;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class PairsTest {
@Test
void packAndUnpack_roundTrip() {
long p = Pairs.pack(1234, 5678);
assertEquals(1234, Pairs.hi(p));
assertEquals(5678, Pairs.lo(p));
}
@Test
void packAndUnpack_zero() {
long p = Pairs.pack(0, 0);
assertEquals(0, Pairs.hi(p));
assertEquals(0, Pairs.lo(p));
}
@Test
void packAndUnpack_maxInts() {
long p = Pairs.pack(Integer.MAX_VALUE, Integer.MAX_VALUE);
assertEquals(Integer.MAX_VALUE, Pairs.hi(p));
assertEquals(Integer.MAX_VALUE, Pairs.lo(p));
}
@Test
void lo_doesNotSignExtendFromHi() {
// hi negative-looking bit pattern must not bleed into lo after unpack.
long p = Pairs.pack(-1, 42);
assertEquals(-1, Pairs.hi(p));
assertEquals(42, Pairs.lo(p));
}
}
@@ -0,0 +1,71 @@
package dev.relism.flash.bytes;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class SegmentedByteViewTest {
@Test
void reset_presentsSegmentsAsOneLogicalSequence() {
byte[][] segments = {
"Hello, ".getBytes(StandardCharsets.US_ASCII),
"World".getBytes(StandardCharsets.US_ASCII),
"!".getBytes(StandardCharsets.US_ASCII),
};
int[] offsets = {0, 0, 0};
int[] lengths = {segments[0].length, segments[1].length, segments[2].length};
SegmentedByteView view = new SegmentedByteView();
view.reset(segments, offsets, lengths, 3);
assertEquals(13, view.length());
StringBuilder sb = new StringBuilder();
for (int i = 0; i < view.length(); i++) sb.append((char) view.byteAt(i));
assertEquals("Hello, World!", sb.toString());
}
@Test
void reset_honorsPerSegmentOffsetAndLength() {
byte[][] segments = { "xxABCxx".getBytes(StandardCharsets.US_ASCII) };
SegmentedByteView view = new SegmentedByteView();
view.reset(segments, new int[]{2}, new int[]{3}, 1);
assertEquals(3, view.length());
assertEquals('A', (char) view.byteAt(0));
assertEquals('C', (char) view.byteAt(2));
}
@Test
void reset_isReusableAcrossCalls() {
SegmentedByteView view = new SegmentedByteView();
view.reset(new byte[][]{"abc".getBytes(StandardCharsets.US_ASCII)}, new int[]{0}, new int[]{3}, 1);
assertEquals(3, view.length());
view.reset(new byte[][]{"de".getBytes(StandardCharsets.US_ASCII)}, new int[]{0}, new int[]{2}, 1);
assertEquals(2, view.length());
assertEquals('d', (char) view.byteAt(0));
}
@Test
void byteAt_outOfBoundsThrows() {
SegmentedByteView view = new SegmentedByteView();
view.reset(new byte[][]{"ab".getBytes(StandardCharsets.US_ASCII)}, new int[]{0}, new int[]{2}, 1);
assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(2));
assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(-1));
}
@Test
void supportsLong_alwaysFalse() {
SegmentedByteView view = new SegmentedByteView();
view.reset(new byte[][]{"12345678".getBytes(StandardCharsets.US_ASCII)}, new int[]{0}, new int[]{8}, 1);
assertFalse(view.supportsLong());
}
@Test
void emptySegmentCount_isZeroLength() {
SegmentedByteView view = new SegmentedByteView();
view.reset(new byte[][]{}, new int[]{}, new int[]{}, 0);
assertEquals(0, view.length());
}
}
@@ -0,0 +1,62 @@
package dev.relism.flash.bytes;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class SlicePoolTest {
private static byte[] bytes(String s) { return s.getBytes(StandardCharsets.US_ASCII); }
@Test
void acquire_repositionsAndReturnsRequestedRange() {
SlicePool pool = new SlicePool(4);
byte[] buf = bytes("hello world");
PooledSlice slice = pool.acquire(buf, 6, 5);
assertEquals(5, slice.length());
assertEquals('w', (char) slice.byteAt(0));
assertEquals('d', (char) slice.byteAt(4));
}
@Test
void acquire_withinPoolSize_returnsDistinctLiveSlices() {
SlicePool pool = new SlicePool(4);
byte[] buf = bytes("abcdefgh");
PooledSlice a = pool.acquire(buf, 0, 1); // 'a'
PooledSlice b = pool.acquire(buf, 1, 1); // 'b'
PooledSlice c = pool.acquire(buf, 2, 1); // 'c'
// All three still valid simultaneously the pool hasn't wrapped yet (size 4).
assertEquals('a', (char) a.byteAt(0));
assertEquals('b', (char) b.byteAt(0));
assertEquals('c', (char) c.byteAt(0));
}
@Test
void wraparoundAliasesThePreviouslyReturnedSlice() {
// Demonstrates the documented hazard: retaining a slice past `size` further acquire()
// calls observes it silently repositioned to unrelated data.
SlicePool pool = new SlicePool(2);
byte[] buf = bytes("AABB");
PooledSlice first = pool.acquire(buf, 0, 2); // "AA"
assertEquals('A', (char) first.byteAt(0));
pool.acquire(buf, 2, 2); // "BB" slot 2, pool size 2 so this is still a fresh slot
PooledSlice thirdCall = pool.acquire(buf, 2, 2); // wraps back to `first`'s slot
assertSame(first, thirdCall, "pool of size 2 must reuse the first slot on the 3rd acquire()");
// `first` is now silently "BB", not "AA" the documented lifetime contract in action.
assertEquals('B', (char) first.byteAt(0));
}
@Test
void constructor_rejectsNonPositiveSize() {
assertThrows(IllegalArgumentException.class, () -> new SlicePool(0));
assertThrows(IllegalArgumentException.class, () -> new SlicePool(-1));
}
@Test
void size_reportsConstructedCapacity() {
assertEquals(4, new SlicePool(4).size());
}
}
@@ -0,0 +1,151 @@
package dev.relism.flash.models;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-09}: dedicated correctness coverage for {@link HeaderMap}'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 {
private static HeaderMap 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();
map.reset(buffer, 0, buffer.length);
return map;
}
@Test
void zeroHeaders_everyLookupIsEmpty() {
HeaderMap map = parse();
assertNull(map.first("Host"));
assertTrue(map.all("Host").isEmpty());
assertTrue(map.all().isEmpty());
assertNull(map.view("Host"));
assertFalse(map.valueEqualsIgnoreCase("Connection", "close"));
}
@Test
void duplicateHeaderNames_firstReturnsTheFirstOne_allReturnsAllInOrder() {
HeaderMap 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");
assertEquals("value1", map.first("x-custom-header"));
assertEquals("value1", map.first("X-CUSTOM-HEADER"));
assertEquals("value1", map.first("X-cUsToM-hEaDeR"));
}
@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");
assertEquals("a", map.first("Accept"));
assertEquals("b", map.first("Accept-Encoding"));
assertEquals("c", map.first("Accept-Language"));
}
@Test
void growsPastInitialIndexCapacity_upToMaxHeaderCount_andStaysCorrect() {
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);
assertEquals("value-0", map.first("X-Header-0"));
assertEquals("value-" + (n - 1), map.first("X-Header-" + (n - 1)));
assertEquals("value-" + (n / 2), map.first("X-Header-" + (n / 2)));
assertEquals(n, map.all().size());
}
@Test
void reset_rebuildsIndexFromScratch_noStaleEntriesFromPreviousRequest() {
HeaderMap map = parse("Host: first-request");
assertEquals("first-request", map.first("Host"));
assertNull(map.first("X-Only-In-Second"));
byte[] second = "Host: second-request\r\nX-Only-In-Second: yes\r\n".getBytes(StandardCharsets.UTF_8);
map.reset(second, 0, second.length);
assertEquals("second-request", map.first("Host"));
assertEquals("yes", map.first("X-Only-In-Second"));
}
@Test
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();
for (int round = 0; round < 5; round++) {
int n = (round % 2 == 0) ? 20 : 2;
String[] headers = new String[n];
for (int i = 0; i < n; i++) headers[i] = "H" + i + ": v" + i + "-" + round;
byte[] buf = String.join("\r\n", headers).concat("\r\n").getBytes(StandardCharsets.UTF_8);
map.reset(buf, 0, buf.length);
assertEquals(n, map.all().size(), "round " + round);
assertEquals("v0-" + round, map.first("H0"));
if (n < 20) assertNull(map.first("H19"), "round " + round + " must not see a stale H19");
}
}
@Test
void allocation_indexArraysAreNotReallocatedOnceWarm() {
// The rigorous 0 B/op verification is the Phase 17 JMH gate (-prof gc); this is a
// unit-test-level structural guarantee that repeated first()/all()/view() lookups never
// 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");
int[] namesBefore = arrayFieldValue(map, "nameOffsets");
for (int i = 0; i < 100_000; i++) {
assertEquals("2", map.first("B"));
assertNotNull(map.view("C"));
assertFalse(map.all("D").isEmpty());
}
int[] namesAfter = arrayFieldValue(map, "nameOffsets");
assertSame(namesBefore, namesAfter, "lookups alone must never reallocate the index arrays");
}
@Test
void view_poolWraparound_aliasesAnEarlierReturnedView() {
// EX-05's documented hazard, demonstrated through the actual public API: HeaderMap'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");
for (String name : new String[]{"A", "B", "C", "D"}) {
dev.relism.fpr.core.ByteView v = map.view(name);
if (v1 == null) v1 = v;
}
assertEquals('1', v1.byteAt(0)); // still "A"'s value pool has not wrapped yet
dev.relism.fpr.core.ByteView v5 = map.view("E"); // 5th call wraps back to v1's slot
assertSame(v1, v5, "the 5th view() call must reuse the 1st call's slice instance");
assertEquals('5', v1.byteAt(0)); // v1 is now silently "E"'s value, not "A"'s
}
private static int[] arrayFieldValue(HeaderMap map, String fieldName) {
try {
var field = HeaderMap.class.getDeclaredField(fieldName);
field.setAccessible(true);
return (int[]) field.get(map);
} catch (ReflectiveOperationException e) {
throw new AssertionError(e);
}
}
}
@@ -1,5 +1,6 @@
package dev.relism.flash.models;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
import dev.relism.fpr.core.ByteView;
import org.junit.jupiter.api.Test;
@@ -64,4 +65,33 @@ class PathParamsTest {
PathParams params = of("/users/123", "userId", "123");
assertNull(params.view("unknown"));
}
@Test
void view_poolWraparound_aliasesAnEarlierReturnedView() {
// EX-05's pooled path only engages when `source` is array-backed (ArrayBackedByteView)
// unlike of()'s plain inline ByteView (which exercises the non-pooled fallback, still
// correct but not the code path this test targets), use the same view type RequestParser
// actually produces.
String path = "/a/1/b/2/c/3/d/4/e/5";
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
ByteView source = new FastPathViews.RequestByteView(bytes, 0, bytes.length);
String[] names = {"a", "b", "c", "d", "e"};
int[] starts = new int[names.length];
int[] lens = new int[names.length];
String[] values = {"1", "2", "3", "4", "5"};
for (int i = 0; i < names.length; i++) {
starts[i] = path.indexOf(values[i]);
lens[i] = values[i].length();
}
PathParams params = new PathParams(source, names, starts, lens);
ByteView v1 = params.view("a");
params.view("b");
params.view("c");
params.view("d"); // pool size 4 not wrapped yet
assertEquals('1', v1.byteAt(0));
ByteView v5 = params.view("e"); // 5th call wraps back to v1's slot
assertSame(v1, v5);
assertEquals('5', v1.byteAt(0));
}
}
@@ -0,0 +1,95 @@
package dev.relism.flash.models;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
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-26}: the clean-value (no {@code %}/{@code +}) fast path in {@code QueryParams.decode}
* must produce byte-for-byte identical results to the percent-decoding slow path it bypasses
* verified here across clean values, values needing every kind of decoding, and the boundary
* between them. Also covers {@code EX-05}'s pooled {@code view()}.
*/
class QueryParamsFastPathTest {
private static QueryParams of(String query) {
byte[] bytes = query.getBytes(StandardCharsets.US_ASCII);
return new QueryParams(new FastPathViews.RequestByteView(bytes, 0, bytes.length));
}
@Test
void cleanValue_noPercentOrPlus_decodesToItself() {
QueryParams qp = of("name=hello&city=NewYork");
assertEquals("hello", qp.get("name"));
assertEquals("NewYork", qp.get("city"));
}
@Test
void valueWithPlus_decodesToSpace_takesSlowPath() {
QueryParams qp = of("q=hello+world");
assertEquals("hello world", qp.get("q"));
}
@Test
void valueWithPercentEscape_decodesCorrectly_takesSlowPath() {
QueryParams qp = of("q=hello%20world");
assertEquals("hello world", qp.get("q"));
}
@Test
void valueWithInvalidPercentEscape_keepsLiteralPercent() {
QueryParams qp = of("q=100%25off");
assertEquals("100%off", qp.get("q"));
QueryParams qp2 = of("q=trailing%2");
assertEquals("trailing%2", qp2.get("q"));
}
@Test
void emptyValue_isClean_decodesToEmptyString() {
QueryParams qp = of("a=&b=1");
assertEquals("", qp.get("a"));
assertEquals("1", qp.get("b"));
}
@Test
void mixedCleanAndEncodedValues_inSameQueryString() {
QueryParams qp = of("clean=abc&encoded=a%20b&plussed=a+b");
assertEquals("abc", qp.get("clean"));
assertEquals("a b", qp.get("encoded"));
assertEquals("a b", qp.get("plussed"));
}
// EX-05: pooled view()
@Test
void view_returnsRawUndecodedBytes() {
QueryParams qp = of("q=a+b");
ByteView v = qp.view("q");
assertNotNull(v);
assertEquals(3, v.length());
assertEquals('+', (char) v.byteAt(1)); // raw, not percent/plus-decoded
}
@Test
void view_missingKey_returnsNull() {
QueryParams qp = of("q=1");
assertNull(qp.view("missing"));
}
@Test
void view_poolWraparound_aliasesAnEarlierReturnedView() {
QueryParams qp = of("a=1&b=2&c=3&d=4&e=5");
ByteView v1 = qp.view("a");
qp.view("b");
qp.view("c");
qp.view("d"); // pool size 4 not wrapped yet
assertEquals('1', v1.byteAt(0));
ByteView v5 = qp.view("e"); // 5th call wraps back to v1's slot
assertSame(v1, v5);
assertEquals('5', v1.byteAt(0));
}
}
@@ -17,7 +17,7 @@ class AbstractRouterTest {
String lastAddedPath;
@Override
public RequestHandler route(Request request) { return null; }
public RequestHandler route(Request request, Object scratch) { return null; }
@Override
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
@@ -15,7 +15,7 @@ class AbstractWsRouterTest {
String lastPath;
@Override
public WebSocketHandler route(Request request) { return null; }
public WebSocketHandler route(Request request, Object scratch) { return null; }
@Override
protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) {
@@ -33,12 +33,13 @@ class FastPathRouterImplTest {
FastPathRouterImpl router = new FastPathRouterImpl();
router.doRegister(HttpMethod.GET, "/a", new SimpleHandler((req, res) -> "A"), NO_MW);
router.doRegister(HttpMethod.POST, "/b", new SimpleHandler((req, res) -> "B"), NO_MW);
Object scratch = router.newScratch();
RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a"));
RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a"), scratch);
assertNotNull(res1);
assertEquals("A", res1.handle(null, null));
RequestHandler res2 = router.route(mockRequest(HttpMethod.POST, "/b"));
RequestHandler res2 = router.route(mockRequest(HttpMethod.POST, "/b"), scratch);
assertNotNull(res2);
assertEquals("B", res2.handle(null, null));
}
@@ -47,9 +48,10 @@ class FastPathRouterImplTest {
void route_noMatch_returnsNull() {
FastPathRouterImpl router = new FastPathRouterImpl();
router.doRegister(HttpMethod.GET, "/a", new SimpleHandler((req, res) -> "A"), NO_MW);
Object scratch = router.newScratch();
assertNull(router.route(mockRequest(HttpMethod.GET, "/b")));
assertNull(router.route(mockRequest(HttpMethod.POST, "/a")));
assertNull(router.route(mockRequest(HttpMethod.GET, "/b"), scratch));
assertNull(router.route(mockRequest(HttpMethod.POST, "/a"), scratch));
}
@Test
@@ -59,7 +61,7 @@ class FastPathRouterImplTest {
new SimpleHandler((req, res) -> "Extract"), NO_MW);
Request request = mockRequest(HttpMethod.GET, "/users/123/items/456");
RequestHandler handler = router.route(request);
RequestHandler handler = router.route(request, router.newScratch());
assertNotNull(handler);
assertEquals("Extract", handler.handle(request, null));
@@ -67,4 +69,35 @@ class FastPathRouterImplTest {
assertEquals("123", request.param("id"));
assertEquals("456", request.param("itemId"));
}
@Test
void route_reusesScratchAcrossManyRequests_includingGrowingParamCapacity() throws Exception {
// EX-19: the same scratch, reused across a mix of param counts, must keep matching
// correctly as its arrays grow past their initial size (8) and get reused afterward.
FastPathRouterImpl router = new FastPathRouterImpl();
router.doRegister(HttpMethod.GET, "/a/{p1}/{p2}/{p3}/{p4}/{p5}/{p6}/{p7}/{p8}/{p9}/{p10}",
new SimpleHandler((req, res) -> "many"), NO_MW);
router.doRegister(HttpMethod.GET, "/b/{id}", new SimpleHandler((req, res) -> "one"), NO_MW);
Object scratch = router.newScratch();
for (int i = 0; i < 3; i++) {
Request oneParam = mockRequest(HttpMethod.GET, "/b/123");
assertEquals("one", router.route(oneParam, scratch).handle(oneParam, null));
assertEquals("123", oneParam.param("id"));
Request tenParams = mockRequest(HttpMethod.GET, "/a/1/2/3/4/5/6/7/8/9/10");
assertEquals("many", router.route(tenParams, scratch).handle(tenParams, null));
assertEquals("10", tenParams.param("p10"));
assertEquals("1", tenParams.param("p1"));
// The 1-param request that follows a 10-param one must not see stale params left
// over from the larger match in the shared, oversized arrays.
Request oneParamAgain = mockRequest(HttpMethod.GET, "/b/456");
RequestHandler h = router.route(oneParamAgain, scratch);
assertNotNull(h);
h.handle(oneParamAgain, null);
assertEquals("456", oneParamAgain.param("id"));
assertNull(oneParamAgain.param("p10"));
}
}
}
@@ -0,0 +1,116 @@
package dev.relism.flash.routing.routers.fastpathrouter;
import dev.relism.fpr.core.FastPathRouter;
import dev.relism.fpr.core.MatchResult;
import dev.relism.fpr.core.RouterBuilder;
import dev.relism.fpr.core.dsl.StringRouteParser;
import dev.relism.fpr.core.internal.runtime.ByteCompare;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-04}: verifies the {@code longAt()}/{@code supportsLong()} contract against
* {@code fpr-core}'s own word-at-a-time comparison code not merely against a hand-derived
* expectation, per the plan's explicit instruction to verify by testing against {@code fpr-core}
* directly rather than by reading its bytecode (bytecode-reading only informed which byte order
* to use; this test is the actual verification). A wrong endianness or a wrong bounds assumption
* here produces silently mis-routed requests, the worst possible failure mode ({@code EX-04}'s
* own registry entry) so this covers both the raw word-read contract and an end-to-end router
* match with the long path actually engaged.
*/
class FastPathViewsLongAtTest {
// Raw longAt() vs. a hand-assembled little-endian expectation
@Test
void longAt_assemblesLittleEndian() {
byte[] buf = {1, 2, 3, 4, 5, 6, 7, 8};
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(buf, 0, 8);
assertTrue(view.supportsLong());
long expected = 0x0807060504030201L; // byte 0 -> least significant byte
assertEquals(expected, view.longAt(0));
}
@Test
void longAt_respectsViewOffset_notJustArrayOffset() {
byte[] buf = {(byte) 0xFF, (byte) 0xFF, 1, 2, 3, 4, 5, 6, 7, 8, (byte) 0xFF};
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(buf, 2, 8);
long expected = 0x0807060504030201L;
assertEquals(expected, view.longAt(0));
}
// Cross-checked against fpr-core's own ByteCompare, the actual consumer of longAt()
@Test
void byteCompareEquals_agreesBetweenLongPathAndByteAtATimePath_onIdenticalContent() {
byte[] content = "GET/users/1234567890/profile".getBytes(StandardCharsets.US_ASCII);
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(content, 0, content.length);
byte[] other = content.clone();
assertTrue(ByteCompare.equals(view, 0, other, 0, content.length, true));
assertTrue(ByteCompare.equals(view, 0, other, 0, content.length, false));
}
@Test
void byteCompareEquals_agreesBetweenLongPathAndByteAtATimePath_onDivergingContent() {
// Diverge at every position across an 8+-byte range, including inside a word, at a word
// boundary, and in the scalar tail a wrong longAt() would only show up at some of these.
byte[] base = "abcdefghijklmnopqrstuvwxyz012345".getBytes(StandardCharsets.US_ASCII);
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(base, 0, base.length);
for (int diffAt = 0; diffAt < base.length; diffAt++) {
byte[] other = base.clone();
other[diffAt] = (byte) (other[diffAt] + 1);
boolean withLong = ByteCompare.equals(view, 0, other, 0, base.length, true);
boolean withoutLong = ByteCompare.equals(view, 0, other, 0, base.length, false);
assertFalse(withLong, "long path failed to detect divergence at " + diffAt);
assertEquals(withoutLong, withLong, "long/byte-at-a-time paths disagree at diffAt=" + diffAt);
}
}
@Test
void byteCompareIndexOf_agreesBetweenLongPathAndByteAtATimePath() {
byte[] haystack = "xxxxxxxxxxxxxxxxxTARGETxxxxxxxxxxxxxxxxxx".getBytes(StandardCharsets.US_ASCII);
byte[] needle = "TARGET".getBytes(StandardCharsets.US_ASCII);
FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(haystack, 0, haystack.length);
int withLong = ByteCompare.indexOf(view, 0, haystack.length, needle, 0, needle.length, true);
int withoutLong = ByteCompare.indexOf(view, 0, haystack.length, needle, 0, needle.length, false);
assertEquals(withoutLong, withLong);
assertTrue(withLong >= 0);
}
// End-to-end: a real router, literal routes >= 8 bytes, long path actually engaged
@Test
void router_matchesCorrectly_withLongLiteralSegmentsAndTheLongPathEnabled() {
RouterBuilder<String> builder = new RouterBuilder<>();
builder.add(StringRouteParser.parse("GET/aaaaaaaaaaaaaaaaaaaa"), "route-a");
builder.add(StringRouteParser.parse("GET/bbbbbbbbbbbbbbbbbbbb"), "route-b");
builder.add(StringRouteParser.parse("GET/aaaaaaaaaaaaaaaaaaab"), "route-a-near-miss");
FastPathRouter<dev.relism.fpr.core.ByteView, String> router = builder.compile();
assertEquals("route-a", matchOne(router, "GET", "/aaaaaaaaaaaaaaaaaaaa"));
assertEquals("route-b", matchOne(router, "GET", "/bbbbbbbbbbbbbbbbbbbb"));
// Differs only in the very last byte must not be conflated with route-a by a
// word-at-a-time comparison that got the tail handling wrong.
assertEquals("route-a-near-miss", matchOne(router, "GET", "/aaaaaaaaaaaaaaaaaaab"));
assertNull(matchOne(router, "GET", "/aaaaaaaaaaaaaaaaaaac"));
assertNull(matchOne(router, "GET", "/ccccccccccccccccccccc"));
}
private static String matchOne(FastPathRouter<dev.relism.fpr.core.ByteView, String> router,
String method, String path) {
byte[] methodBytes = method.getBytes(StandardCharsets.US_ASCII);
FastPathViews.RequestByteView pathView =
new FastPathViews.RequestByteView(path.getBytes(StandardCharsets.US_ASCII), 0, path.length());
FastPathViews.MethodPathByteView combined = new FastPathViews.MethodPathByteView();
combined.reset(methodBytes, pathView);
MatchResult<String> result = new MatchResult<>(8, 32);
int labelId = router.match(combined, result);
return labelId == FastPathRouter.NO_MATCH ? null : result.handler();
}
}