feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10
@@ -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 6–10 times per request. `dev.relism.flash.bytes` is the single home these fixes converge on,
|
||||
so no later phase (HPACK, the frame layer) has to invent its own scanning primitives.
|
||||
|
||||
## Package layout
|
||||
|
||||
```
|
||||
dev.relism.flash.bytes
|
||||
├── ByteScan static scanning/comparison/hashing utilities, scalar + SWAR
|
||||
├── ArrayBackedByteView capability interface: a ByteView backed by one contiguous byte[]
|
||||
├── SegmentedByteView the deliberate non-array-backed case (K discontiguous segments)
|
||||
├── PooledSlice reusable ArrayBackedByteView, the EX-05 fix
|
||||
├── SlicePool a small fixed-size ring of PooledSlice
|
||||
├── ByteWriter index-based writer into a growable byte[] scratch buffer
|
||||
└── Pairs the (hi<<32)|lo allocation-free pair-return idiom, named
|
||||
```
|
||||
|
||||
## The `ByteView` capability hierarchy
|
||||
|
||||
```
|
||||
ByteView (fpr-core)
|
||||
├── ArrayBackedByteView capability: array() + offset()
|
||||
│ ├── FastPathViews.RequestByteView RequestParser's request-line/header slices
|
||||
│ ├── FastPathViews.SocketByteView a bare byte[] (e.g. a WebSocket payload)
|
||||
│ ├── FastPathViews.StringByteView a String's UTF-8 bytes
|
||||
│ └── PooledSlice the EX-05 reusable, pool-issued slice
|
||||
└── (bare ByteView, not array-backed)
|
||||
├── SegmentedByteView K discontiguous segments (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 0–256
|
||||
and every match position (including unaligned starts and matches at the very last valid byte),
|
||||
and `ByteScanFuzzTest` throws 20 000 fully-random trials at each, per the plan's task 1. All
|
||||
green — see the class's own Javadoc for the full technique writeup.
|
||||
|
||||
## `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 6–10 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.
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -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.8–14.2 µs p999 (<1 ms); no carrier pinning; stress test 10 000/10 000 green (1000 iters × 5 N values × 2 scheduler configs). Full numbers in `WRITER.md`, `DEC-09`. 321/321 non-JMH tests green. |
|
||||
| 4 — Byte-layer foundations | 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 0–256 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);
|
||||
}
|
||||
}
|
||||
+114
@@ -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 6–10 lookups per
|
||||
* request (OIDC reads {@code Authorization}/{@code Cookie}, the limiter reads
|
||||
* {@code X-Forwarded-For}, CORS reads {@code Origin}, keep-alive reads {@code Connection}); before
|
||||
* this, each of those rescanned the entire header block from scratch — O(n·m). Now the header
|
||||
* section is scanned once regardless of how many lookups follow — strictly less total work even
|
||||
* for a single lookup, and asymptotically better for the realistic multi-lookup case.
|
||||
*/
|
||||
@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,19 +141,12 @@ 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;
|
||||
consumer.accept(nameSlice, valueSlice);
|
||||
}
|
||||
i = lineEnd + 2;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+58
-21
@@ -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,21 +29,49 @@ 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);
|
||||
|
||||
public static FastPathViews.MethodPathByteView getCombinedView() {
|
||||
return COMBINED_VIEW_HOLDER.get();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object newScratch() {
|
||||
return new RouteScratch();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) {
|
||||
builder.add(StringRouteParser.parse(method.name() + path), handler);
|
||||
@@ -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) {
|
||||
int methodLen = method.getBytes().length;
|
||||
String[] all = cachedParamNames;
|
||||
String[] names = new String[count];
|
||||
int[] starts = new int[count];
|
||||
int[] lens = new int[count];
|
||||
scratch.ensureParamCapacity(count);
|
||||
int methodLen = method.getBytes().length;
|
||||
String[] all = cachedParamNames;
|
||||
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();
|
||||
|
||||
+114
-4
@@ -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;
|
||||
}
|
||||
|
||||
@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 ByteView {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+27
-13
@@ -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) {
|
||||
|
||||
+38
-5
@@ -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"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+116
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user