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

Merged
Relism merged 23 commits from feature/core/http2 into master 2026-08-14 18:20:30 +00:00
24 changed files with 1851 additions and 506 deletions
Showing only changes of commit 95c33e7bf2 - Show all commits
+19
View File
@@ -865,3 +865,22 @@ a fourth per-request view (e.g. an h2 equivalent), extend this same pooled-`rese
than reintroducing a fresh allocation. than reintroducing a fresh allocation.
--- ---
## DEC-24 — Compact the HPACK arena and copy decoded headers into stream-owned storage
**Context.** Dynamic-table entries must be contiguous for cheap indexed lookup, but FIFO eviction
leaves holes at the front of a bounded arena. Views into that arena also cannot outlive later
decodes on a multiplexed connection.
**Decision.** Compact live dynamic entries when the free tail cannot hold an insertion. Do not use
`SegmentedByteView` for wrapped entries or CONTINUATION fragments. At the decoder boundary,
`HpackHeaderBlock` copies fields into a reusable arena owned by the stream.
**Consequence.** Compaction is occasionally O(table size), bounded by the advertised table size,
while all ordinary lookups and consumer copies remain contiguous. Stream handlers never observe
dynamic-table eviction or compaction. The JMH decode benchmark remains at the allocation noise
floor (0.001 B/op).
**Revisit when.** Profiling shows compaction is material under realistic dynamic-table churn.
---
+63
View File
@@ -0,0 +1,63 @@
# HPACK decoder
This document records the implementation constraints of Flash's RFC 7541 decoder. It is
contributor documentation, not application API documentation.
## Representation model
`HpackDecoder` accepts all RFC 7541 field representations: indexed fields, literals with
incremental indexing, literals without indexing, never-indexed literals, and dynamic-table size
updates. Prefix integers are bounded against overflow, Huffman padding and EOS are validated, and
decoded string lengths are checked while bytes are produced.
The static table is stored as 61 immutable name/value byte pairs. Encoder-oriented reverse lookup
uses fixed open-addressed integer tables built during class initialization; lookup never converts
header bytes to `String` and never calls `HashMap` on the hot path.
The dynamic table owns a bounded byte arena and a ring of primitive entry descriptors. Entry size
is `name length + value length + 32`, and eviction is oldest-first as required by RFC 7541 §4.1.
When the arena tail is too short, live entries are compacted into a contiguous prefix. This avoids
segmented views in every consumer and keeps indexed fields cheap to copy.
## Ownership and eviction safety
Views emitted by `HeaderSink` are callback-scoped. Production decoding targets a reusable
`HpackHeaderBlock` owned by the stream, which copies each name and value into its own arena.
The copy is required for correctness. Consider stream A referencing a dynamic-table entry while
its handler is running. The connection thread can then decode stream B, evict that entry, and
reuse its bytes. If stream A retained the dynamic-table view, its headers would silently change.
Per-stream storage removes that race without reference counting or synchronization.
The precise copy model is:
- HTTP/1.1 copies nothing per request but scans header bytes in the connection buffer.
- HTTP/2 copies decoded request headers into stream-owned storage because multiplexed handlers
outlive subsequent HPACK mutations.
- Novel incrementally-indexed fields are also copied once into the connection's dynamic table.
`HpackEvictionRaceTest` contains both the unsafe borrowed-view demonstration and the stable
stream-owned result.
## Header-list rejection
The decoder counts RFC header-list size cumulatively. Once the configured limit is crossed it
stops emitting fields, but continues parsing the entire block and applying dynamic-table updates.
Only after the block ends does it throw `HeaderListSizeException`. The stream layer can reject the
request while the connection's compression state remains synchronized.
## CONTINUATION assembly
`ContinuationAssembler` copies HEADERS and CONTINUATION fragments into one bounded connection
buffer. It rejects interleaving, stream-id changes, excessive continuation count, and blocks that
exceed the configured capacity. `SegmentedByteView` is intentionally not used here: RFC 9113 §6.10
requires a contiguous, non-interleaved continuation sequence, and one bounded copy makes the HPACK
decoder and all downstream views simpler.
## Verification
- RFC 7541 Appendix C.1C.6 vectors, including dynamic-table state after every sequence.
- Invalid integer, Huffman, index, size-update, and header-list inputs.
- Ten million deterministic random blocks; only typed protocol rejections may escape.
- JMH `-prof gc`: `decodeStaticRequest` measured 102.725 ns/op and 0.001 B/op on JDK 21.0.11. The
latter is the profiler's sampling noise floor; no garbage collections occurred.
+32 -20
View File
@@ -68,7 +68,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
| 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`). | | 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 | done | `feature/core/http2` | `FrameType`/`FrameFlags`/`FrameHeader`/`Http2FrameReader`/`FrameValidator`/`Padding`/`FrameWriteBuffer` built. All 10 frame types read/validated/written; per-type RFC error codes verified individually (`FrameValidatorTest`); fuzz-tested 10M random inputs (~14s, green). Zero-alloc contract measured, not asserted: read+validate+consume 0.002 B/op, write ≈10⁻⁴ B/op (`DEC-21`). Found+fixed `EX-37` (`BufferedByteSource`'s deadline mechanism NPE'd against a `null` socket — zero prior test coverage of `EX-07`'s own fix; added `BufferedByteSourceTest`). `FRAMES.md` written. 449/449 tests green. | | 5 — Frame layer | done | `feature/core/http2` | `FrameType`/`FrameFlags`/`FrameHeader`/`Http2FrameReader`/`FrameValidator`/`Padding`/`FrameWriteBuffer` built. All 10 frame types read/validated/written; per-type RFC error codes verified individually (`FrameValidatorTest`); fuzz-tested 10M random inputs (~14s, green). Zero-alloc contract measured, not asserted: read+validate+consume 0.002 B/op, write ≈10⁻⁴ B/op (`DEC-21`). Found+fixed `EX-37` (`BufferedByteSource`'s deadline mechanism NPE'd against a `null` socket — zero prior test coverage of `EX-07`'s own fix; added `BufferedByteSourceTest`). `FRAMES.md` written. 449/449 tests green. |
| 6 — Request/Response model refactor | done | `feature/core/http2` | `Request`/`RequestBody`/`RequestLine`/`Response` all pooled per connection (`EX-20``EX-24`), same `reset()`/dev-mode-guard idiom as `Http1HeaderMap`. `HeaderMap` split into `HeaderView` (interface) + `Http1HeaderMap` (impl, stays in `models``DEC-22`). `Response` gained byte-level structured headers + `PreEncodedHeader`; `ResponseSerializer` is the one source of truth for a response's header sequence, consumed by `Http1ResponseWriter`'s single-bulk-write rewrite (`EX-27`). `ByteTemplate` fixed to O(1) slot lookup + a buffer-writing overload (`EX-28`). `Multipart` audited: found and fixed 3 resource-exhaustion gaps (unbounded buffered part size/part count/per-part header parsing — `EX-38``EX-40`), confirmed boundary length already bounded (`EX-41`, non-finding). Re-measuring `RequestPipelineBenchmark` after the pooling work found one more allocation underneath it — `RequestParser` was still building fresh `RequestByteView`s per request — fixed (`EX-42`). Zero-alloc contract closed: `parseAndRoute` 120.008 → 0.008 B/op (`DEC-20`/`DEC-23`). Verifying the DoD's own "Response header region bounded" checkbox found it unimplemented — fixed (`EX-43`). `MESSAGE-MODEL.md` written; README gained an "Object lifetime" section. 503/503 tests green. | | 6 — Request/Response model refactor | done | `feature/core/http2` | `Request`/`RequestBody`/`RequestLine`/`Response` all pooled per connection (`EX-20``EX-24`), same `reset()`/dev-mode-guard idiom as `Http1HeaderMap`. `HeaderMap` split into `HeaderView` (interface) + `Http1HeaderMap` (impl, stays in `models``DEC-22`). `Response` gained byte-level structured headers + `PreEncodedHeader`; `ResponseSerializer` is the one source of truth for a response's header sequence, consumed by `Http1ResponseWriter`'s single-bulk-write rewrite (`EX-27`). `ByteTemplate` fixed to O(1) slot lookup + a buffer-writing overload (`EX-28`). `Multipart` audited: found and fixed 3 resource-exhaustion gaps (unbounded buffered part size/part count/per-part header parsing — `EX-38``EX-40`), confirmed boundary length already bounded (`EX-41`, non-finding). Re-measuring `RequestPipelineBenchmark` after the pooling work found one more allocation underneath it — `RequestParser` was still building fresh `RequestByteView`s per request — fixed (`EX-42`). Zero-alloc contract closed: `parseAndRoute` 120.008 → 0.008 B/op (`DEC-20`/`DEC-23`). Verifying the DoD's own "Response header region bounded" checkbox found it unimplemented — fixed (`EX-43`). `MESSAGE-MODEL.md` written; README gained an "Object lifetime" section. 503/503 tests green. |
| 7 — HPACK decoder | in progress | `feature/core/http2` | `HpackIntegers` and `Huffman` are implemented and tested; next: RFC 7541 static table. | | 7 — HPACK decoder | done | `feature/core/http2` | Full RFC 7541 decoder, bounded CONTINUATION assembly, per-stream header ownership, 10M-input fuzz run, eviction-race stress test, and JMH allocation gate complete; 563 tests green from a clean build. |
| 8 — Connection state machine | not started | — | — | | 8 — Connection state machine | not started | — | — |
| 9 — HPACK encoder + h2 response path | not started | — | — | | 9 — HPACK encoder + h2 response path | not started | — | — |
| 10 — Stream state machine + dispatch | not started | — | — | | 10 — Stream state machine + dispatch | not started | — | — |
@@ -740,6 +740,14 @@ total bytes via `checkHeaderRegionBudget()` after writing. Both throw `IllegalSt
`MalformedRequestException`'s HTTP-status-carrying path). `MalformedRequestException`'s HTTP-status-carrying path).
**Phase**: 6. **Phase**: 6.
### EX-44 — Comment cleanup removed `Multipart.partCount` from compiled source
Found during the Phase 7 clean build. The process-reference cleanup commit removed the complete
field declaration because its trailing comment contained an `EX-nn` marker. Incremental builds
initially reused the previously compiled class and hid the source-level failure. **Fix**: restored
the counter without the process comment and audited every non-comment line removed by the cleanup
commit. `MultipartTest`'s part-count limit coverage remains the regression test; phase closure now
uses `mvn clean test` so stale classes cannot mask source damage. **Phase**: 7.
--- ---
# PART III — The phases # PART III — The phases
@@ -1977,16 +1985,19 @@ the continue flag):
### Files ### Files
Created: Created:
- `h2/hpack/HpackIntegers.java` — prefix-coded integer decode/encode. - `http2/hpack/HpackIntegers.java` — prefix-coded integer decode/encode.
- `h2/hpack/Huffman.java` — decode FSM + encode LUT, both built in a static initializer from - `http2/hpack/Huffman.java` — decode FSM + encode LUT, both built in a static initializer from
the RFC's code table. the RFC's code table.
- `h2/hpack/HpackStaticTable.java` — the 61 entries as `byte[][]`, plus a name→lowest-index - `http2/hpack/HpackStaticTable.java` — the 61 entries as `byte[][]`, plus a name→lowest-index
lookup for the encoder (built at class init). lookup for the encoder (built at class init).
- `h2/hpack/HpackDynamicTable.java` — ring buffer of entry descriptors + a byte arena. - `http2/hpack/HpackDynamicTable.java` — ring buffer of entry descriptors + a byte arena.
- `h2/hpack/HpackDecoder.java` — the state machine. - `http2/hpack/HpackDecoder.java` — the state machine.
- `h2/hpack/HeaderSink.java` — the callback the decoder emits into: - `http2/hpack/HeaderSink.java` — the callback the decoder emits into:
`void accept(ByteView name, ByteView value, boolean neverIndexed)`. Implemented by `void accept(ByteView name, ByteView value, boolean neverIndexed)`. Implemented by
`Http2HeaderMap` (Phase 10) and by tests. `Http2HeaderMap` (Phase 10) and by tests.
- `http2/hpack/HpackHeaderBlock.java` — reusable stream-owned storage for decoded fields.
- `http2/hpack/ContinuationAssembler.java` — bounded contiguous header-block assembly.
- `http2/hpack/HeaderListSizeException.java` — delayed stream-level oversize signal.
### Tasks ### Tasks
@@ -2071,16 +2082,16 @@ arena, the per-stream arena and the CONTINUATION assembly buffer are all per-con
pooled. pooled.
### Safety checks ### Safety checks
- [ ] Prefix-integer overflow rejected (continuation octet limit) - [x] Prefix-integer overflow rejected (continuation octet limit)
- [ ] Huffman padding validated (all ones, < 8 bits) - [x] Huffman padding validated (all ones, < 8 bits)
- [ ] Huffman EOS in input rejected - [x] Huffman EOS in input rejected
- [ ] Decoded string length bounded during decode, not after - [x] Decoded string length bounded during decode, not after
- [ ] Index 0 rejected; out-of-range index rejected - [x] Index 0 rejected; out-of-range index rejected
- [ ] Dynamic Table Size Update position and magnitude validated - [x] Dynamic Table Size Update position and magnitude validated
- [ ] `MAX_HEADER_LIST_SIZE` enforced, **with full decode before rejection** so the table stays - [x] `MAX_HEADER_LIST_SIZE` enforced, **with full decode before rejection** so the table stays
in sync in sync
- [ ] CONTINUATION frame count and total block size bounded - [x] CONTINUATION frame count and total block size bounded
- [ ] Dynamic table arena cannot be written past its bound - [x] Dynamic table arena cannot be written past its bound
### Tests ### Tests
- `HpackIntegersTest` — every RFC 7541 Appendix C.1 vector, plus overflow cases. - `HpackIntegersTest` — every RFC 7541 Appendix C.1 vector, plus overflow cases.
@@ -2106,10 +2117,11 @@ eviction hazard with its worked example, and the explicit statement of what is c
This document must contain the honest framing from `R3`. This document must contain the honest framing from `R3`.
### DoD ### DoD
- [ ] Every RFC 7541 Appendix C vector passes, including dynamic table state assertions. - [x] Every RFC 7541 Appendix C vector passes, including dynamic table state assertions.
- [ ] Fuzz test green for 10 million inputs. - [x] Fuzz test green for 10 million inputs (2.58 s on JDK 21.0.11; clean profiled build).
- [ ] `HpackEvictionRaceTest` demonstrates the hazard and the fix. - [x] `HpackEvictionRaceTest` demonstrates the hazard and the fix.
- [ ] 0 B/op decode. - [x] Zero-allocation decode measured by JMH: 0.001 B/op (profiler noise floor), 102.725 ns/op.
- [x] Clean suite green with the JMH profile enabled: 563 tests, 0 failures/errors/skips.
--- ---
@@ -7,6 +7,10 @@ import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.Middleware; import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl; import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
import dev.relism.flash.transport.BufferedByteSource; import dev.relism.flash.transport.BufferedByteSource;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork; import org.openjdk.jmh.annotations.Fork;
@@ -19,33 +23,22 @@ import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup; import org.openjdk.jmh.annotations.Warmup;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
/** /**
* The h1 zero-alloc contract: "an h1 {@code GET /users/{id}} request that reads three headers * The h1 zero-alloc contract: "an h1 {@code GET /users/{id}} request that reads three headers and
* and one path param must be 0 B/op end to end except for the user-facing {@code String}s the * one path param must be 0 B/op end to end except for the user-facing {@code String}s the handler
* handler explicitly asks for." This benchmark measures the actual number with {@code -prof gc}. * explicitly asks for." This benchmark measures the actual number with {@code -prof gc}. Before
* At Phase 4 ({@code DEC-20}) {@code parseAndRoute} measured 120.008 B/op, entirely attributable * model and view pooling, {@code parseAndRoute} measured 120.008 B/op. It now measures at JMH's
* to {@code Request}/{@code RequestBody}/{@code RequestLine} construction (explicitly deferred to * allocation noise floor. The two methods below isolate the parser/router path from the unavoidable
* Phase 6, not a Phase 4 regression). Phase 6's pooling ({@code EX-20}{@code EX-24}) plus one * cost of explicit {@code String} reads by comparing a route with no header or parameter access
* more allocation this benchmark caught underneath it ({@code EX-42}: {@code RequestParser} was * against one that reads a path parameter and two headers.
* still allocating fresh {@code RequestByteView}s per request) closed the gap — see
* {@code DECISIONS.md}, {@code DEC-23}, for the full before/after numbers. {@code parseAndRoute}
* is now 0 B/op (JMH's noise floor); the two benchmark methods below isolate that from the
* unavoidable, DoD-exempted cost of the explicit {@code String} reads a real handler performs
* (header lookups, path-param extraction) 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 * <p>Uses a hand-rolled repeating {@link InputStream} (never allocates, cycles the same request
* bytes indefinitely) rather than a fresh {@code ByteArrayInputStream}/{@code BufferedByteSource} * bytes indefinitely) rather than a fresh {@code ByteArrayInputStream}/{@code BufferedByteSource}
* per invocation, so the timed path matches production exactly: one {@link 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 * 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 * 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 * harness allocation unrelated to the parser/router/model code under test (the same lesson {@code
* {@code WRITER.md} documents for {@code FrameWriterBenchmark}'s own harness). * WRITER.md} documents for {@code FrameWriterBenchmark}'s own harness).
*/ */
@State(Scope.Thread) @State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime) @BenchmarkMode(Mode.AverageTime)
@@ -55,8 +48,10 @@ import java.util.concurrent.TimeUnit;
@Measurement(iterations = 5, time = 1) @Measurement(iterations = 5, time = 1)
public class RequestPipelineBenchmark { public class RequestPipelineBenchmark {
/** Cycles a fixed byte[] indefinitely — simulates an infinite pipelined keep-alive stream /**
* of identical requests without allocating anything per read. */ * 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 static final class RepeatingByteStream extends InputStream {
private final byte[] template; private final byte[] template;
private int pos; private int pos;
@@ -89,7 +84,8 @@ public class RequestPipelineBenchmark {
@Setup(Level.Trial) @Setup(Level.Trial)
public void setup() { public void setup() {
String req = "GET /users/12345 HTTP/1.1\r\n" String req =
"GET /users/12345 HTTP/1.1\r\n"
+ "Host: api.example.com\r\n" + "Host: api.example.com\r\n"
+ "Accept: application/json\r\n" + "Accept: application/json\r\n"
+ "Authorization: Bearer abcdefghijklmnopqrstuvwxyz\r\n" + "Authorization: Bearer abcdefghijklmnopqrstuvwxyz\r\n"
@@ -105,8 +101,7 @@ public class RequestPipelineBenchmark {
routeScratch = router.newScratch(); routeScratch = router.newScratch();
} }
/** Parse + route only — isolates Phase 4's own scope from Request/RequestBody construction /** Parse and route without requesting user-facing header or parameter strings. */
* by not touching header()/param() (the "user-facing String" opt-in the DoD text carves out). */
@Benchmark @Benchmark
public RequestHandler parseAndRoute() throws IOException { public RequestHandler parseAndRoute() throws IOException {
Request request = parser.parse(in); Request request = parser.parse(in);
@@ -114,7 +109,7 @@ public class RequestPipelineBenchmark {
return router.route(request, routeScratch); return router.route(request, routeScratch);
} }
/** Parse + route + exactly what the DoD text describes: one path param, two headers read. */ /** Parse, route, and read one path parameter and two headers as strings. */
@Benchmark @Benchmark
public Object parseRouteAndExtractThreeFields() throws IOException { public Object parseRouteAndExtractThreeFields() throws IOException {
Request request = parser.parse(in); Request request = parser.parse(in);
@@ -1,5 +1,7 @@
package dev.relism.flash.bytes; package dev.relism.flash.bytes;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork; import org.openjdk.jmh.annotations.Fork;
@@ -12,21 +14,15 @@ import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup; 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 * Compares {@link ByteScan#indexOfCrLfCrLf} (SWAR) against {@link ByteScan#indexOfCrLfCrLfScalar}
* ... Measure — if the win is under 3% on the h1 benchmark, keep the scalar version." Compares * on a realistic HTTP/1.1 request header block. It lives in this package to reach the
* {@link ByteScan#indexOfCrLfCrLf} (SWAR) against {@link ByteScan#indexOfCrLfCrLfScalar} on a * package-private scalar reference method without widening that method's visibility solely for
* realistic HTTP/1.1 request header block. Lives in this package (not {@code src/test/java}) * measurement.
* 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 * <p>Run: {@code mvn -Pjmh -pl flash test-compile} then {@code java -cp
* {@code java -cp flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath -Dmdep.outputFile=/dev/stdout -q) * flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath
* org.openjdk.jmh.Main ByteScanBenchmark}. Results recorded in {@code DECISIONS.md}, {@code DEC-20}. * -Dmdep.outputFile=/dev/stdout -q) org.openjdk.jmh.Main ByteScanBenchmark}.
*/ */
@State(Scope.Thread) @State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime) @BenchmarkMode(Mode.AverageTime)
@@ -41,7 +37,8 @@ public class ByteScanBenchmark {
@Setup(Level.Trial) @Setup(Level.Trial)
public void setup() { public void setup() {
String req = "GET /users/12345?sort=desc&limit=20 HTTP/1.1\r\n" String req =
"GET /users/12345?sort=desc&limit=20 HTTP/1.1\r\n"
+ "Host: api.example.com\r\n" + "Host: api.example.com\r\n"
+ "User-Agent: Mozilla/5.0 (compatible; FlashBench/1.0)\r\n" + "User-Agent: Mozilla/5.0 (compatible; FlashBench/1.0)\r\n"
+ "Accept: application/json\r\n" + "Accept: application/json\r\n"
@@ -2,6 +2,9 @@ package dev.relism.flash.http2.frame;
import dev.relism.flash.bytes.ByteWriter; import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.transport.BufferedByteSource; import dev.relism.flash.transport.BufferedByteSource;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork; import org.openjdk.jmh.annotations.Fork;
@@ -14,20 +17,15 @@ import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup; import org.openjdk.jmh.annotations.Warmup;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.TimeUnit;
/** /**
* Phase 5's zero-alloc contract: "Reading, validating and discarding a frame: 0 B/op ... Writing * Measures allocation and latency for reading, validating and discarding a frame and for writing a
* a frame header: 0 B/op." Measured with {@code -prof gc}, not merely asserted — see * frame header. Allocation is measured with {@code -prof gc}, not inferred from inspection.
* {@code DECISIONS.md}, {@code DEC-21}, for the recorded numbers.
* *
* <p>Uses the same hand-rolled repeating {@link InputStream} technique * <p>Uses the same hand-rolled repeating {@link InputStream} technique {@code
* {@code RequestPipelineBenchmark} (Phase 4) established: one {@link BufferedByteSource}/ * RequestPipelineBenchmark} established: one {@link BufferedByteSource}/ {@link Http2FrameReader}
* {@link Http2FrameReader} pair created once per trial and reused across every invocation, * pair created once per trial and reused across every invocation, matching how a real connection's
* matching how a real connection's demux loop owns exactly one of each for its whole lifetime, * demux loop owns exactly one of each for its whole lifetime, rather than paying for harness-side
* rather than paying for harness-side (re)construction inside the timed path. * (re)construction inside the timed path.
*/ */
@State(Scope.Thread) @State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime) @BenchmarkMode(Mode.AverageTime)
@@ -1,5 +1,15 @@
package dev.relism.flash.http2.frame; package dev.relism.flash.http2.frame;
import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;
import java.util.concurrent.locks.ReentrantLock;
import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork; import org.openjdk.jmh.annotations.Fork;
@@ -14,54 +24,45 @@ import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown; import org.openjdk.jmh.annotations.TearDown;
import org.openjdk.jmh.annotations.Warmup; import org.openjdk.jmh.annotations.Warmup;
import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;
import java.util.concurrent.locks.ReentrantLock;
/** /**
* Phase 3's go/no-go benchmark (flash/docs/http2/IMPLEMENTATION-PLAN.md). Compares three writer * Compares three writer designs at N ∈ {1, 2, 4, 8, 16, 64} concurrent <b>virtual-thread</b>
* designs at N ∈ {1, 2, 4, 8, 16, 64} concurrent <b>virtual-thread</b> writers: * writers:
* *
* <ul> * <ul>
* <li>{@code trylock_mpsc} — the design that ships as {@link Http2FrameWriter}: {@code tryLock()} * <li>{@code trylock_mpsc} — the design that ships as {@link Http2FrameWriter}: {@code tryLock()}
* fast path, intrusive MPSC fallback.</li> * fast path, intrusive MPSC fallback.
* <li>{@code plain_lock} — every write blocks on {@code ReentrantLock#lock()}, unconditionally.</li> * <li>{@code plain_lock} — every write blocks on {@code ReentrantLock#lock()}, unconditionally.
* <li>{@code dedicated_thread} — every write hands off to a single dedicated platform thread * <li>{@code dedicated_thread} — every write hands off to a single dedicated platform thread via
* via the same {@link IntrusiveMpscQueue}, parked/unparked (never a busy poll).</li> * the same {@link IntrusiveMpscQueue}, parked/unparked (never a busy poll).
* </ul> * </ul>
* *
* <h3>Why this benchmark drives its own concurrency instead of JMH's {@code @Threads}</h3> * <h3>Why this benchmark drives its own concurrency instead of JMH's {@code @Threads}</h3>
*
* {@code @Threads} requires a compile-time constant, not a {@code @Param}-swept value, and JMH's * {@code @Threads} requires a compile-time constant, not a {@code @Param}-swept value, and JMH's
* thread pool is platform threads, not virtual threads — the exact scheduling behaviour under * thread pool is platform threads, not virtual threads — the exact scheduling behaviour under test.
* test. Each {@code @Benchmark} invocation therefore spawns {@link #threads} virtual threads * Each {@code @Benchmark} invocation therefore spawns {@link #threads} virtual threads itself, has
* itself, has them race a fixed burst of writes to a counting no-op sink, and reports the * them race a fixed burst of writes to a counting no-op sink, and reports the burst's wall-clock
* burst's wall-clock rate; JMH still owns fork/warmup/measurement-iteration control and (via * rate; JMH still owns fork/warmup/measurement-iteration control and (via {@code -prof gc}) the
* {@code -prof gc}) the zero-allocation verification. * zero-allocation verification.
* *
* <h3>Why {@code runBurst} waits on a write counter, not just thread completion</h3> * <h3>Why {@code runBurst} waits on a write counter, not just thread completion</h3>
*
* {@code write()} does not mean "already on the wire" for every design: the shipped design's * {@code write()} does not mean "already on the wire" for every design: the shipped design's
* contended path, and the dedicated-thread design's handoff, can both return once the frame is * contended path, and the dedicated-thread design's handoff, can both return once the frame is
* merely *queued*. Timing only "how long until every producer's {@code write()} call returned" * merely *queued*. Timing only "how long until every producer's {@code write()} call returned"
* would therefore measure submission speed, not completion speed, and would flatter exactly the * would therefore measure submission speed, not completion speed, and would flatter exactly the
* designs that most aggressively defer work — the opposite of a fair comparison. Every harness * designs that most aggressively defer work — the opposite of a fair comparison. Every harness here
* here writes through {@link CountingSink} and {@link #runBurst} waits for its counter to reach * writes through {@link CountingSink} and {@link #runBurst} waits for its counter to reach the
* the expected total before returning, so the timed interval always covers real completion. * expected total before returning, so the timed interval always covers real completion.
* *
* <p>Per-write latency percentiles are computed by hand from {@code System.nanoTime()} samples * <p>Per-write latency percentiles are computed by hand from {@code System.nanoTime()} samples
* collected during the burst (JMH's own {@code Mode.SampleTime} does not fit a * collected during the burst (JMH's own {@code Mode.SampleTime} does not fit a custom-concurrency
* custom-concurrency benchmark method) and printed once per (design, threads) combination — see * benchmark method) and printed once per (design, threads) combination — see {@code WRITER.md} for
* {@code WRITER.md} for the recorded results and the gate decision. * the recorded results and the gate decision.
* *
* <p>Run: {@code mvn -Pjmh -pl flash test-compile} then * <p>Run: {@code mvn -Pjmh -pl flash test-compile} then {@code java -cp
* {@code java -cp flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath -Dmdep.outputFile=/dev/stdout -q) * flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath
* org.openjdk.jmh.Main FrameWriterBenchmark -prof gc}. * -Dmdep.outputFile=/dev/stdout -q) org.openjdk.jmh.Main FrameWriterBenchmark -prof gc}.
*/ */
@State(Scope.Benchmark) @State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput) @BenchmarkMode(Mode.Throughput)
@@ -86,7 +87,8 @@ public class FrameWriterBenchmark {
@Setup(Level.Trial) @Setup(Level.Trial)
public void setup() { public void setup() {
payload = new byte[FRAME_SIZE]; payload = new byte[FRAME_SIZE];
harness = switch (design) { harness =
switch (design) {
case "trylock_mpsc" -> new TryLockMpscHarness(); case "trylock_mpsc" -> new TryLockMpscHarness();
case "plain_lock" -> new PlainLockHarness(); case "plain_lock" -> new PlainLockHarness();
case "dedicated_thread" -> new DedicatedThreadHarness(); case "dedicated_thread" -> new DedicatedThreadHarness();
@@ -101,11 +103,11 @@ public class FrameWriterBenchmark {
} }
/** /**
* One "operation" here is a full burst: {@link #threads} virtual threads each writing * One "operation" here is a full burst: {@link #threads} virtual threads each writing {@link
* {@link #FRAMES_PER_THREAD} frames. Reported ops/sec must be multiplied by * #FRAMES_PER_THREAD} frames. Reported ops/sec must be multiplied by {@code threads *
* {@code threads * FRAMES_PER_THREAD} to get frames/sec — done during result analysis, not * FRAMES_PER_THREAD} to get frames/sec — done during result analysis, not via
* via {@code @OperationsPerInvocation} (which requires a compile-time constant and cannot * {@code @OperationsPerInvocation} (which requires a compile-time constant and cannot vary with
* vary with the {@code threads} @Param). * the {@code threads} @Param).
*/ */
@Benchmark @Benchmark
public void burst() throws Exception { public void burst() throws Exception {
@@ -116,14 +118,18 @@ public class FrameWriterBenchmark {
private interface DesignHarness { private interface DesignHarness {
void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception; void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception;
void shutdown(); void shutdown();
} }
/** Discards everything (isolating the writer designs from real socket variance) but counts /**
* every completed write, so callers can wait for true completion rather than mere * Discards everything (isolating the writer designs from real socket variance) but counts every
* submission — see the class Javadoc. */ * completed write, so callers can wait for true completion rather than mere submission — see the
* class Javadoc.
*/
private static final class CountingSink implements Http2FrameWriter.Sink { private static final class CountingSink implements Http2FrameWriter.Sink {
final AtomicLong count = new AtomicLong(); final AtomicLong count = new AtomicLong();
@Override @Override
public void write(byte[] buf, int off, int len) { public void write(byte[] buf, int off, int len) {
count.incrementAndGet(); count.incrementAndGet();
@@ -133,12 +139,35 @@ public class FrameWriterBenchmark {
private static final class BenchIntent implements WriteIntent { private static final class BenchIntent implements WriteIntent {
final byte[] buf; final byte[] buf;
WriteIntent next; WriteIntent next;
BenchIntent(byte[] buf) { this.buf = buf; }
@Override public byte[] buffer() { return buf; } BenchIntent(byte[] buf) {
@Override public int offset() { return 0; } this.buf = buf;
@Override public int length() { return buf.length; } }
@Override public WriteIntent mpscNext() { return next; }
@Override public void setMpscNext(WriteIntent next) { this.next = next; } @Override
public byte[] buffer() {
return buf;
}
@Override
public int offset() {
return 0;
}
@Override
public int length() {
return buf.length;
}
@Override
public WriteIntent mpscNext() {
return next;
}
@Override
public void setMpscNext(WriteIntent next) {
this.next = next;
}
} }
private interface ThrowingConsumer<T> { private interface ThrowingConsumer<T> {
@@ -146,13 +175,14 @@ public class FrameWriterBenchmark {
} }
/** /**
* Spawns {@code threadCount} virtual threads, has each write {@code framesPerThread} fresh * Spawns {@code threadCount} virtual threads, has each write {@code framesPerThread} fresh {@link
* {@link BenchIntent}s (one per write — matches production usage, where a stream's scratch * BenchIntent}s (one per write — matches production usage, where a stream's scratch buffer holds
* buffer holds exactly one in-flight frame at a time), records per-write latency samples, * exactly one in-flight frame at a time), records per-write latency samples, then blocks until
* then blocks until {@code sink}'s counter reflects every one of them actually written. * {@code sink}'s counter reflects every one of them actually written.
*/ */
private static void race(int threadCount, int framesPerThread, CountingSink sink, private static void race(
ThrowingConsumer<WriteIntent> write) throws Exception { int threadCount, int framesPerThread, CountingSink sink, ThrowingConsumer<WriteIntent> write)
throws Exception {
long target = sink.count.get() + (long) threadCount * framesPerThread; long target = sink.count.get() + (long) threadCount * framesPerThread;
byte[] payload = new byte[FRAME_SIZE]; byte[] payload = new byte[FRAME_SIZE];
long[][] samplesByThread = new long[threadCount][framesPerThread]; long[][] samplesByThread = new long[threadCount][framesPerThread];
@@ -160,7 +190,9 @@ public class FrameWriterBenchmark {
Future<?>[] futures = new Future<?>[threadCount]; Future<?>[] futures = new Future<?>[threadCount];
for (int t = 0; t < threadCount; t++) { for (int t = 0; t < threadCount; t++) {
int idx = t; int idx = t;
futures[t] = exec.submit(() -> { futures[t] =
exec.submit(
() -> {
long[] samples = samplesByThread[idx]; long[] samples = samplesByThread[idx];
for (int i = 0; i < framesPerThread; i++) { for (int i = 0; i < framesPerThread; i++) {
BenchIntent intent = new BenchIntent(payload); BenchIntent intent = new BenchIntent(payload);
@@ -182,9 +214,11 @@ public class FrameWriterBenchmark {
LatencyReport.recordAndMaybePrint(samplesByThread); LatencyReport.recordAndMaybePrint(samplesByThread);
} }
/** Prints p50/p99/p999 to stdout once per thread-count actually exercised, from the first /**
* burst observed for it — cheap, and avoids flooding the JMH log with one line per * Prints p50/p99/p999 to stdout once per thread-count actually exercised, from the first burst
* measurement iteration. */ * observed for it — cheap, and avoids flooding the JMH log with one line per measurement
* iteration.
*/
private static final class LatencyReport { private static final class LatencyReport {
private static final Set<String> PRINTED = ConcurrentHashMap.newKeySet(); private static final Set<String> PRINTED = ConcurrentHashMap.newKeySet();
@@ -204,7 +238,8 @@ public class FrameWriterBenchmark {
long p50 = all[(int) (all.length * 0.50)]; long p50 = all[(int) (all.length * 0.50)];
long p99 = all[(int) (all.length * 0.99)]; long p99 = all[(int) (all.length * 0.99)];
long p999 = all[Math.min(all.length - 1, (int) (all.length * 0.999))]; long p999 = all[Math.min(all.length - 1, (int) (all.length * 0.999))];
System.out.printf("[latency threads=%d] p50=%.1fus p99=%.1fus p999=%.1fus (n=%d)%n", System.out.printf(
"[latency threads=%d] p50=%.1fus p99=%.1fus p999=%.1fus (n=%d)%n",
samplesByThread.length, p50 / 1000.0, p99 / 1000.0, p999 / 1000.0, all.length); samplesByThread.length, p50 / 1000.0, p99 / 1000.0, p999 / 1000.0, all.length);
} }
} }
@@ -220,11 +255,15 @@ public class FrameWriterBenchmark {
@Override @Override
public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception {
race(threads, framesPerThread, sink, race(
threads,
framesPerThread,
sink,
intent -> sink.write(intent.buffer(), intent.offset(), intent.length())); intent -> sink.write(intent.buffer(), intent.offset(), intent.length()));
} }
@Override public void shutdown() { } @Override
public void shutdown() {}
} }
// ── Design (a): plain lock ────────────────────────────────────────────────── // ── Design (a): plain lock ──────────────────────────────────────────────────
@@ -235,7 +274,11 @@ public class FrameWriterBenchmark {
@Override @Override
public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception {
race(threads, framesPerThread, sink, intent -> { race(
threads,
framesPerThread,
sink,
intent -> {
lock.lock(); lock.lock();
try { try {
sink.write(intent.buffer(), intent.offset(), intent.length()); sink.write(intent.buffer(), intent.offset(), intent.length());
@@ -245,7 +288,8 @@ public class FrameWriterBenchmark {
}); });
} }
@Override public void shutdown() { } @Override
public void shutdown() {}
} }
// ── Design (b): tryLock + intrusive MPSC — the shipped design ────────────── // ── Design (b): tryLock + intrusive MPSC — the shipped design ──────────────
@@ -259,7 +303,10 @@ public class FrameWriterBenchmark {
race(threads, framesPerThread, sink, writer::write); race(threads, framesPerThread, sink, writer::write);
} }
@Override public void shutdown() { writer.close(); } @Override
public void shutdown() {
writer.close();
}
} }
// ── Design (c): always hand off to one dedicated writer thread ───────────── // ── Design (c): always hand off to one dedicated writer thread ─────────────
@@ -287,7 +334,11 @@ public class FrameWriterBenchmark {
@Override @Override
public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception {
race(threads, framesPerThread, sink, intent -> { race(
threads,
framesPerThread,
sink,
intent -> {
queue.offer(intent); queue.offer(intent);
LockSupport.unpark(writerThread); LockSupport.unpark(writerThread);
}); });
@@ -0,0 +1,32 @@
package dev.relism.flash.http2.hpack;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
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.State;
import org.openjdk.jmh.annotations.Warmup;
/** Measures steady-state decoding into reusable per-stream storage. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class HpackDecoderBenchmark {
private final HpackDecoder decoder = new HpackDecoder();
private final HpackHeaderBlock headers = new HpackHeaderBlock();
private final byte[] block = {(byte) 0x82, (byte) 0x87, (byte) 0x84, (byte) 0x88};
@Benchmark
public int decodeStaticRequest() {
headers.reset();
decoder.decode(block, 0, block.length, headers);
return headers.count();
}
}
@@ -5,6 +5,8 @@ import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler; import dev.relism.flash.models.SimpleHandler;
import dev.relism.fpr.core.internal.runtime.ByteCompare; import dev.relism.fpr.core.internal.runtime.ByteCompare;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode; import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork; import org.openjdk.jmh.annotations.Fork;
@@ -17,26 +19,18 @@ import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State; import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup; 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}, * Two related but distinct measurements of router and byte-comparison performance.
* 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 * <p><b>{@code router_*}</b> exercises the shipped {@link FastPathRouterImpl#route} end to end,
* the h1 router benchmark" — exercised through the real, shipped {@link FastPathRouterImpl#route} * including lazy-compiled route-table lookup, {@link FastPathRouterImpl.RouteScratch} reuse and
* end to end (lazy-compiled route table, {@link FastPathRouterImpl.RouteScratch} reuse, path-param * path-parameter extraction.
* extraction included).
* *
* <p><b>{@code byteCompare_*}</b>: a direct measurement of the mechanism {@code EX-04} actually * <p><b>{@code byteCompare_*}</b> directly compares {@code ByteCompare.equals} with its
* implements ({@code ByteCompare.equals}, {@code useLong} true vs. false) over array-backed * word-at-a-time path enabled and disabled over representative array-backed content. This is
* content shaped like what a future call site (HPACK static-table matching, frame validation) * separate because router matching uses the composite, non-array-backed {@link
* would compare. This exists because the router's own match call passes * FastPathViews.MethodPathByteView}, so the router measurements cannot expose the word path's
* {@link FastPathViews.MethodPathByteView} — a deliberate composite, never array-backed (see * effect.
* {@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) @State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime) @BenchmarkMode(Mode.AverageTime)
@@ -59,9 +53,17 @@ public class FastPathRouterBenchmark {
RequestHandler h = new SimpleHandler((req, res) -> "ok"); RequestHandler h = new SimpleHandler((req, res) -> "ok");
router.doRegister(HttpMethod.GET, "/health", h, new dev.relism.flash.routing.Middleware[0]); 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}", 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.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.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.doRegister(
HttpMethod.GET,
"/api/v1/products/{category}/{id}",
h,
new dev.relism.flash.routing.Middleware[0]);
router.compile(); router.compile();
scratch = router.newScratch(); scratch = router.newScratch();
@@ -81,16 +83,19 @@ public class FastPathRouterBenchmark {
private static Request mockRequest(HttpMethod method, String path) { private static Request mockRequest(HttpMethod method, String path) {
byte[] bytes = path.getBytes(StandardCharsets.UTF_8); byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(bytes, 0, bytes.length); FastPathViews.RequestByteView pathView =
dev.relism.flash.models.RequestLine line = new dev.relism.flash.models.RequestLine( new FastPathViews.RequestByteView(bytes, 0, bytes.length);
method, pathView, null, 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 FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
new dev.relism.flash.models.Http1HeaderMap() new dev.relism.flash.models.Http1HeaderMap());
);
return new Request(line, new byte[0]); return new Request(line, new byte[0]);
} }
// ── byteCompare_*: the direct EX-04 mechanism, in isolation ───────────── // ── byteCompare_*: word-at-a-time comparison in isolation ─────────────
private FastPathViews.RequestByteView cmpView; private FastPathViews.RequestByteView cmpView;
private byte[] cmpOther; private byte[] cmpOther;
@@ -43,6 +43,7 @@ import java.util.*;
* <p><b>Thread safety:</b> not thread-safe; one instance per request. * <p><b>Thread safety:</b> not thread-safe; one instance per request.
*/ */
public final class Multipart { public final class Multipart {
private int partCount;
private static final int BUF_CAP = 8192; private static final int BUF_CAP = 8192;
@@ -0,0 +1,84 @@
package dev.relism.flash.http2.hpack;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
/** Reassembles one HEADERS/CONTINUATION sequence into a bounded contiguous connection buffer. */
public final class ContinuationAssembler {
private final byte[] buffer;
private int streamId;
private int length;
private int continuationCount;
private boolean active;
private boolean complete;
public ContinuationAssembler() {
this(Http2Limits.MAX_HEADER_LIST_SIZE);
}
public ContinuationAssembler(int maximumBlockSize) {
if (maximumBlockSize <= 0) throw new IllegalArgumentException("non-positive block size");
buffer = new byte[maximumBlockSize];
}
public void begin(
int streamId, byte[] source, int offset, int fragmentLength, boolean endHeaders) {
if (active || streamId <= 0) throw Http2Exception.PROTOCOL_ERROR;
reset();
this.streamId = streamId;
append(source, offset, fragmentLength);
complete = endHeaders;
active = !endHeaders;
}
public void continuation(
int streamId, byte[] source, int offset, int fragmentLength, boolean endHeaders) {
if (!active || streamId != this.streamId) throw Http2Exception.PROTOCOL_ERROR;
if (++continuationCount > Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK) {
throw Http2Exception.PROTOCOL_ERROR;
}
append(source, offset, fragmentLength);
complete = endHeaders;
active = !endHeaders;
}
public byte[] buffer() {
return buffer;
}
public int length() {
return length;
}
public int streamId() {
return streamId;
}
public boolean isComplete() {
return complete;
}
public boolean isActive() {
return active;
}
public void reset() {
streamId = 0;
length = 0;
continuationCount = 0;
active = false;
complete = false;
}
private void append(byte[] source, int offset, int fragmentLength) {
if (source == null
|| offset < 0
|| fragmentLength < 0
|| offset > source.length - fragmentLength
|| fragmentLength > buffer.length - length) {
throw Http2Exception.COMPRESSION_ERROR;
}
System.arraycopy(source, offset, buffer, length, fragmentLength);
length += fragmentLength;
}
}
@@ -0,0 +1,20 @@
package dev.relism.flash.http2.hpack;
/**
* Signals that a fully decoded HPACK block exceeded the configured header-list limit. The decoder
* delays this exception until the complete block has been consumed so dynamic-table state remains
* synchronized with the peer. The stream layer maps it to a request rejection without closing the
* HTTP/2 connection.
*/
public final class HeaderListSizeException extends RuntimeException {
private final long decodedSize;
HeaderListSizeException(long decodedSize) {
super("decoded header list exceeds limit: " + decodedSize, null, false, false);
this.decodedSize = decodedSize;
}
public long decodedSize() {
return decodedSize;
}
}
@@ -0,0 +1,13 @@
package dev.relism.flash.http2.hpack;
import dev.relism.fpr.core.ByteView;
/** Receives decoded HPACK fields in wire order. */
@FunctionalInterface
public interface HeaderSink {
/**
* Accepts one field. The views are valid only for the duration of this call; a sink that needs
* them afterwards must copy them into storage owned by the stream.
*/
void accept(ByteView name, ByteView value, boolean neverIndexed);
}
@@ -0,0 +1,156 @@
package dev.relism.flash.http2.hpack;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.fpr.core.ByteView;
/** Stateful, allocation-free HPACK decoder for one HTTP/2 connection direction. */
public final class HpackDecoder {
private final HpackDynamicTable dynamicTable;
private final int maximumHeaderListSize;
private final byte[] nameScratch = new byte[Http2Limits.MAX_HPACK_STRING_LENGTH];
private final byte[] valueScratch = new byte[Http2Limits.MAX_HPACK_STRING_LENGTH];
private final PooledSlice nameView = new PooledSlice();
private final PooledSlice valueView = new PooledSlice();
public HpackDecoder(int advertisedTableSize, int maximumHeaderListSize) {
if (maximumHeaderListSize < 0) throw new IllegalArgumentException("negative header-list size");
this.dynamicTable = new HpackDynamicTable(advertisedTableSize);
this.maximumHeaderListSize = maximumHeaderListSize;
}
public HpackDecoder() {
this(Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL, Http2Limits.MAX_HEADER_LIST_SIZE);
}
/** Decodes one complete header block. */
public void decode(byte[] buffer, int offset, int length, HeaderSink sink) {
if (buffer == null
|| sink == null
|| offset < 0
|| length < 0
|| offset > buffer.length - length) {
throw new IllegalArgumentException("invalid HPACK decode arguments");
}
int position = offset;
int limit = offset + length;
boolean sawHeader = false;
boolean oversized = false;
long headerListSize = 0;
while (position < limit) {
int first = buffer[position] & 0xff;
if ((first & 0x80) != 0) {
long decoded = HpackIntegers.decode(buffer, position, limit, 7);
int index = Pairs.hi(decoded);
position = Pairs.lo(decoded);
if (index == 0) throw Http2Exception.COMPRESSION_ERROR;
resolve(index, nameView, valueView);
sawHeader = true;
headerListSize += fieldSize(nameView, valueView);
if (headerListSize <= maximumHeaderListSize) sink.accept(nameView, valueView, false);
else oversized = true;
continue;
}
if ((first & 0x40) != 0) {
long decoded = HpackIntegers.decode(buffer, position, limit, 6);
int nameIndex = Pairs.hi(decoded);
position = Pairs.lo(decoded);
position = decodeName(buffer, position, limit, nameIndex);
position = decodeString(buffer, position, limit, valueScratch, valueView);
sawHeader = true;
headerListSize += fieldSize(nameView, valueView);
if (headerListSize <= maximumHeaderListSize) sink.accept(nameView, valueView, false);
else oversized = true;
dynamicTable.add(nameView, valueView);
continue;
}
if ((first & 0x20) != 0) {
if (sawHeader) throw Http2Exception.COMPRESSION_ERROR;
long decoded = HpackIntegers.decode(buffer, position, limit, 5);
dynamicTable.setMaximumSize(Pairs.hi(decoded));
position = Pairs.lo(decoded);
continue;
}
boolean neverIndexed = (first & 0x10) != 0;
long decoded = HpackIntegers.decode(buffer, position, limit, 4);
int nameIndex = Pairs.hi(decoded);
position = Pairs.lo(decoded);
position = decodeName(buffer, position, limit, nameIndex);
position = decodeString(buffer, position, limit, valueScratch, valueView);
sawHeader = true;
headerListSize += fieldSize(nameView, valueView);
if (headerListSize <= maximumHeaderListSize) sink.accept(nameView, valueView, neverIndexed);
else oversized = true;
}
if (oversized) throw new HeaderListSizeException(headerListSize);
}
public HpackDynamicTable dynamicTable() {
return dynamicTable;
}
private int decodeName(byte[] buffer, int position, int limit, int index) {
if (index != 0) {
resolveName(index, nameView);
return position;
}
return decodeString(buffer, position, limit, nameScratch, nameView);
}
private static int decodeString(
byte[] buffer, int position, int limit, byte[] scratch, PooledSlice output) {
if (position >= limit) throw Http2Exception.COMPRESSION_ERROR;
boolean huffman = (buffer[position] & 0x80) != 0;
long decoded = HpackIntegers.decode(buffer, position, limit, 7);
int encodedLength = Pairs.hi(decoded);
int dataStart = Pairs.lo(decoded);
if (encodedLength > limit - dataStart) throw Http2Exception.COMPRESSION_ERROR;
if (huffman) {
int decodedLength =
Huffman.decode(buffer, dataStart, encodedLength, scratch, 0, scratch.length);
output.reset(scratch, 0, decodedLength);
} else {
if (encodedLength > Http2Limits.MAX_HPACK_STRING_LENGTH)
throw Http2Exception.COMPRESSION_ERROR;
output.reset(buffer, dataStart, encodedLength);
}
return dataStart + encodedLength;
}
private void resolve(int index, PooledSlice name, PooledSlice value) {
if (index <= HpackStaticTable.LENGTH) {
byte[] staticName = HpackStaticTable.name(index);
byte[] staticValue = HpackStaticTable.value(index);
name.reset(staticName, 0, staticName.length);
value.reset(staticValue, 0, staticValue.length);
return;
}
dynamicTable.get(index - HpackStaticTable.LENGTH, name, value);
}
private void resolveName(int index, PooledSlice name) {
if (index <= 0) throw Http2Exception.COMPRESSION_ERROR;
if (index <= HpackStaticTable.LENGTH) {
byte[] staticName = HpackStaticTable.name(index);
name.reset(staticName, 0, staticName.length);
return;
}
dynamicTable.get(index - HpackStaticTable.LENGTH, name, valueView);
// An incremental-indexing representation can evict or compact the entry that supplied its
// indexed name. Preserve the name before insertion mutates the dynamic table arena.
System.arraycopy(name.array(), name.offset(), nameScratch, 0, name.length());
name.reset(nameScratch, 0, name.length());
}
private static long fieldSize(ByteView name, ByteView value) {
return (long) name.length() + value.length() + 32;
}
}
@@ -0,0 +1,132 @@
package dev.relism.flash.http2.hpack;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.fpr.core.ByteView;
/**
* Per-connection HPACK dynamic table. Entries are kept in FIFO order in a descriptor ring while
* their bytes live in one bounded arena. The arena is compacted only when its free tail cannot hold
* the next entry, keeping every returned view contiguous.
*/
public final class HpackDynamicTable {
private final byte[] arena;
private final int[] nameOffsets;
private final int[] nameLengths;
private final int[] valueOffsets;
private final int[] valueLengths;
private final int advertisedMaximum;
private int maximumSize;
private int currentSize;
private int head;
private int count;
private int arenaEnd;
public HpackDynamicTable(int advertisedMaximum) {
if (advertisedMaximum < 0) throw new IllegalArgumentException("negative HPACK table size");
this.advertisedMaximum = advertisedMaximum;
this.maximumSize = advertisedMaximum;
this.arena = new byte[Math.max(1, advertisedMaximum)];
int entryCapacity = Math.max(1, advertisedMaximum / 32 + 1);
this.nameOffsets = new int[entryCapacity];
this.nameLengths = new int[entryCapacity];
this.valueOffsets = new int[entryCapacity];
this.valueLengths = new int[entryCapacity];
}
public int count() {
return count;
}
public int size() {
return currentSize;
}
public int maximumSize() {
return maximumSize;
}
/** Applies an RFC 7541 §4.2 table-size update and evicts oldest entries as necessary. */
public void setMaximumSize(int newMaximum) {
if (newMaximum < 0 || newMaximum > advertisedMaximum) throw Http2Exception.COMPRESSION_ERROR;
maximumSize = newMaximum;
evictToFit(0);
if (count == 0) arenaEnd = 0;
}
/** Inserts a new entry, copying its bytes before performing FIFO eviction. */
public void add(ByteView name, ByteView value) {
int byteLength = name.length() + value.length();
int entrySize = byteLength + 32;
if (entrySize > maximumSize) {
clear();
return;
}
evictToFit(entrySize);
if (arena.length - arenaEnd < byteLength) compact();
int slot = (head + count) % nameOffsets.length;
nameOffsets[slot] = arenaEnd;
nameLengths[slot] = name.length();
copy(name, arena, arenaEnd);
arenaEnd += name.length();
valueOffsets[slot] = arenaEnd;
valueLengths[slot] = value.length();
copy(value, arena, arenaEnd);
arenaEnd += value.length();
count++;
currentSize += entrySize;
}
/** Resolves a dynamic index where {@code 1} is the newest entry. */
public void get(int relativeIndex, PooledSlice name, PooledSlice value) {
if (relativeIndex < 1 || relativeIndex > count) throw Http2Exception.COMPRESSION_ERROR;
int slot = (head + count - relativeIndex) % nameOffsets.length;
name.reset(arena, nameOffsets[slot], nameLengths[slot]);
value.reset(arena, valueOffsets[slot], valueLengths[slot]);
}
public void clear() {
head = 0;
count = 0;
currentSize = 0;
arenaEnd = 0;
}
private void evictToFit(int incomingSize) {
while (count > 0 && currentSize + incomingSize > maximumSize) {
int slot = head;
currentSize -= nameLengths[slot] + valueLengths[slot] + 32;
head = (head + 1) % nameOffsets.length;
count--;
}
if (count == 0) arenaEnd = 0;
}
private void compact() {
int destination = 0;
for (int i = 0; i < count; i++) {
int slot = (head + i) % nameOffsets.length;
int nameLength = nameLengths[slot];
int valueLength = valueLengths[slot];
System.arraycopy(arena, nameOffsets[slot], arena, destination, nameLength);
nameOffsets[slot] = destination;
destination += nameLength;
System.arraycopy(arena, valueOffsets[slot], arena, destination, valueLength);
valueOffsets[slot] = destination;
destination += valueLength;
}
arenaEnd = destination;
}
private static void copy(ByteView source, byte[] target, int offset) {
if (source instanceof ArrayBackedByteView contiguous) {
System.arraycopy(contiguous.array(), contiguous.offset(), target, offset, source.length());
return;
}
for (int i = 0; i < source.length(); i++) target[offset + i] = source.byteAt(i);
}
}
@@ -0,0 +1,85 @@
package dev.relism.flash.http2.hpack;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.fpr.core.ByteView;
/**
* Reusable per-stream storage for decoded header fields. Copying at the decoder boundary makes a
* stream independent of later HPACK dynamic-table eviction on the connection thread.
*/
public final class HpackHeaderBlock implements HeaderSink {
private final byte[] arena;
private final int[] nameOffsets;
private final int[] nameLengths;
private final int[] valueOffsets;
private final int[] valueLengths;
private final boolean[] neverIndexed;
private int arenaEnd;
private int count;
public HpackHeaderBlock() {
this(Http2Limits.MAX_HEADER_LIST_SIZE, Http2Limits.MAX_HEADER_LIST_SIZE / 32 + 1);
}
HpackHeaderBlock(int arenaCapacity, int fieldCapacity) {
arena = new byte[arenaCapacity];
nameOffsets = new int[fieldCapacity];
nameLengths = new int[fieldCapacity];
valueOffsets = new int[fieldCapacity];
valueLengths = new int[fieldCapacity];
neverIndexed = new boolean[fieldCapacity];
}
public void reset() {
arenaEnd = 0;
count = 0;
}
public int count() {
return count;
}
public boolean neverIndexed(int index) {
checkIndex(index);
return neverIndexed[index];
}
public void get(int index, PooledSlice name, PooledSlice value) {
checkIndex(index);
name.reset(arena, nameOffsets[index], nameLengths[index]);
value.reset(arena, valueOffsets[index], valueLengths[index]);
}
@Override
public void accept(ByteView name, ByteView value, boolean sensitive) {
int bytes = name.length() + value.length();
if (count >= nameOffsets.length || bytes > arena.length - arenaEnd) {
throw new IllegalStateException("decoded header block exceeds its configured storage");
}
nameOffsets[count] = arenaEnd;
nameLengths[count] = name.length();
copy(name, arenaEnd);
arenaEnd += name.length();
valueOffsets[count] = arenaEnd;
valueLengths[count] = value.length();
copy(value, arenaEnd);
arenaEnd += value.length();
neverIndexed[count] = sensitive;
count++;
}
private void copy(ByteView source, int destination) {
if (source instanceof ArrayBackedByteView contiguous) {
System.arraycopy(
contiguous.array(), contiguous.offset(), arena, destination, source.length());
return;
}
for (int i = 0; i < source.length(); i++) arena[destination + i] = source.byteAt(i);
}
private void checkIndex(int index) {
if (index < 0 || index >= count) throw new IndexOutOfBoundsException(index);
}
}
@@ -0,0 +1,169 @@
package dev.relism.flash.http2.hpack;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
/** The immutable 61-entry HPACK static table defined by RFC 7541 Appendix A. */
public final class HpackStaticTable {
public static final int LENGTH = 61;
private static final byte[][] NAMES = new byte[LENGTH + 1][];
private static final byte[][] VALUES = new byte[LENGTH + 1][];
private static final int[] NAME_INDEX = new int[128];
private static final int[] PAIR_INDEX = new int[128];
static {
add(1, ":authority", "");
add(2, ":method", "GET");
add(3, ":method", "POST");
add(4, ":path", "/");
add(5, ":path", "/index.html");
add(6, ":scheme", "http");
add(7, ":scheme", "https");
add(8, ":status", "200");
add(9, ":status", "204");
add(10, ":status", "206");
add(11, ":status", "304");
add(12, ":status", "400");
add(13, ":status", "404");
add(14, ":status", "500");
add(15, "accept-charset", "");
add(16, "accept-encoding", "gzip, deflate");
add(17, "accept-language", "");
add(18, "accept-ranges", "");
add(19, "accept", "");
add(20, "access-control-allow-origin", "");
add(21, "age", "");
add(22, "allow", "");
add(23, "authorization", "");
add(24, "cache-control", "");
add(25, "content-disposition", "");
add(26, "content-encoding", "");
add(27, "content-language", "");
add(28, "content-length", "");
add(29, "content-location", "");
add(30, "content-range", "");
add(31, "content-type", "");
add(32, "cookie", "");
add(33, "date", "");
add(34, "etag", "");
add(35, "expect", "");
add(36, "expires", "");
add(37, "from", "");
add(38, "host", "");
add(39, "if-match", "");
add(40, "if-modified-since", "");
add(41, "if-none-match", "");
add(42, "if-range", "");
add(43, "if-unmodified-since", "");
add(44, "last-modified", "");
add(45, "link", "");
add(46, "location", "");
add(47, "max-forwards", "");
add(48, "proxy-authenticate", "");
add(49, "proxy-authorization", "");
add(50, "range", "");
add(51, "referer", "");
add(52, "refresh", "");
add(53, "retry-after", "");
add(54, "server", "");
add(55, "set-cookie", "");
add(56, "strict-transport-security", "");
add(57, "transfer-encoding", "");
add(58, "user-agent", "");
add(59, "vary", "");
add(60, "via", "");
add(61, "www-authenticate", "");
for (int i = LENGTH; i >= 1; i--) {
put(NAME_INDEX, hash(NAMES[i]), i, false);
put(PAIR_INDEX, hashPair(NAMES[i], VALUES[i]), i, true);
}
}
private HpackStaticTable() {}
private static void add(int index, String name, String value) {
NAMES[index] = name.getBytes(StandardCharsets.US_ASCII);
VALUES[index] = value.getBytes(StandardCharsets.US_ASCII);
}
public static byte[] name(int index) {
checkIndex(index);
return NAMES[index];
}
public static byte[] value(int index) {
checkIndex(index);
return VALUES[index];
}
public static int findName(ByteView name) {
int slot = hash(name) & (NAME_INDEX.length - 1);
while (NAME_INDEX[slot] != 0) {
int index = NAME_INDEX[slot];
if (equals(name, NAMES[index])) return index;
slot = (slot + 1) & (NAME_INDEX.length - 1);
}
return 0;
}
public static int findPair(ByteView name, ByteView value) {
int slot = hashPair(name, value) & (PAIR_INDEX.length - 1);
while (PAIR_INDEX[slot] != 0) {
int index = PAIR_INDEX[slot];
if (equals(name, NAMES[index]) && equals(value, VALUES[index])) return index;
slot = (slot + 1) & (PAIR_INDEX.length - 1);
}
return 0;
}
private static void put(int[] table, int hash, int index, boolean pair) {
int slot = hash & (table.length - 1);
while (table[slot] != 0
&& !(equals(NAMES[index], NAMES[table[slot]])
&& (!pair || equals(VALUES[index], VALUES[table[slot]])))) {
slot = (slot + 1) & (table.length - 1);
}
table[slot] = index;
}
private static int hash(ByteView value) {
int hash = 0x811C9DC5;
for (int i = 0; i < value.length(); i++) hash = (hash ^ (value.byteAt(i) & 0xff)) * 0x01000193;
return hash;
}
private static int hash(byte[] value) {
int hash = 0x811C9DC5;
for (byte b : value) hash = (hash ^ (b & 0xff)) * 0x01000193;
return hash;
}
private static int hashPair(ByteView name, ByteView value) {
int hash = hash(name);
for (int i = 0; i < value.length(); i++) hash = (hash ^ (value.byteAt(i) & 0xff)) * 0x01000193;
return hash;
}
private static int hashPair(byte[] name, byte[] value) {
int hash = hash(name);
for (byte b : value) hash = (hash ^ (b & 0xff)) * 0x01000193;
return hash;
}
private static boolean equals(ByteView view, byte[] bytes) {
if (view.length() != bytes.length) return false;
for (int i = 0; i < bytes.length; i++) if (view.byteAt(i) != bytes[i]) return false;
return true;
}
private static boolean equals(byte[] left, byte[] right) {
return java.util.Arrays.equals(left, right);
}
private static void checkIndex(int index) {
if (index < 1 || index > LENGTH)
throw new IndexOutOfBoundsException("HPACK static index " + index);
}
}
@@ -0,0 +1,40 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class ContinuationAssemblerTest {
@Test
void assemblesContiguousBlock() {
ContinuationAssembler assembler = new ContinuationAssembler(16);
assembler.begin(3, "abc".getBytes(StandardCharsets.US_ASCII), 0, 3, false);
assembler.continuation(3, "def".getBytes(StandardCharsets.US_ASCII), 0, 3, true);
assertTrue(assembler.isComplete());
assertFalse(assembler.isActive());
assertEquals(
"abcdef", new String(assembler.buffer(), 0, assembler.length(), StandardCharsets.US_ASCII));
}
@Test
void rejectsInterleavingWrongStreamAndOversizedBlocks() {
ContinuationAssembler assembler = new ContinuationAssembler(4);
assembler.begin(1, new byte[] {1}, 0, 1, false);
assertThrows(Http2Exception.class, () -> assembler.begin(3, new byte[0], 0, 0, true));
assertThrows(Http2Exception.class, () -> assembler.continuation(3, new byte[0], 0, 0, true));
assertThrows(Http2Exception.class, () -> assembler.continuation(1, new byte[4], 0, 4, true));
}
@Test
void boundsContinuationCount() {
ContinuationAssembler assembler = new ContinuationAssembler(32);
assembler.begin(1, new byte[0], 0, 0, false);
for (int i = 0; i < Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK; i++) {
assembler.continuation(1, new byte[0], 0, 0, false);
}
assertThrows(Http2Exception.class, () -> assembler.continuation(1, new byte[0], 0, 0, false));
}
}
@@ -0,0 +1,39 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.fail;
import dev.relism.flash.http2.Http2Exception;
import org.junit.jupiter.api.Test;
class HpackDecoderFuzzTest {
private static final int CASES = 10_000_000;
private static final HeaderSink DISCARD = (name, value, never) -> {};
@Test
void tenMillionRandomBlocksOnlyProduceTypedRejections() {
HpackDecoder decoder = new HpackDecoder(256, 1024);
byte[] input = new byte[64];
long state = 0x7541_9113_C0DEL;
for (int iteration = 0; iteration < CASES; iteration++) {
state = next(state);
int length = (int) state & 63;
for (int i = 0; i < length; i++) {
state = next(state);
input[i] = (byte) state;
}
try {
decoder.decode(input, 0, length, DISCARD);
} catch (Http2Exception | HeaderListSizeException expected) {
// Typed protocol rejection.
} catch (Throwable unexpected) {
fail("unexpected failure at iteration " + iteration + ", length " + length, unexpected);
}
}
}
private static long next(long value) {
value ^= value << 13;
value ^= value >>> 7;
return value ^ (value << 17);
}
}
@@ -0,0 +1,68 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.Http2Exception;
import org.junit.jupiter.api.Test;
class HpackDecoderSecurityTest {
private static final HeaderSink DISCARD = (name, value, never) -> {};
@Test
void rejectsZeroAndOutOfRangeIndices() {
HpackDecoder decoder = new HpackDecoder();
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0x80}, 0, 1, DISCARD));
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0xff, 0}, 0, 2, DISCARD));
}
@Test
void rejectsLateAndOversizedTableUpdates() {
HpackDecoder decoder = new HpackDecoder(128, 1024);
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0x82, 0x20}, 0, 2, DISCARD));
ByteWriter update = new ByteWriter(8);
HpackIntegers.encode(update, 0x20, 5, 129);
assertThrows(
Http2Exception.class, () -> decoder.decode(update.array(), 0, update.length(), DISCARD));
}
@Test
void headerListLimitIsReportedOnlyAfterDynamicStateIsUpdated() {
HpackDecoder decoder = new HpackDecoder(256, 40);
byte[] block = java.util.HexFormat.of().parseHex("40016101624001630164");
HeaderListSizeException error =
assertThrows(
HeaderListSizeException.class, () -> decoder.decode(block, 0, block.length, DISCARD));
assertTrue(error.decodedSize() > 40);
assertEquals(2, decoder.dynamicTable().count());
}
@Test
void malformedStringsAndIntegerBombsAreCompressionErrors() {
HpackDecoder decoder = new HpackDecoder();
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {0x40, 0x01}, 0, 2, DISCARD));
byte[] bomb = {0x3f, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0x80};
assertThrows(Http2Exception.class, () -> decoder.decode(bomb, 0, bomb.length, DISCARD));
}
@Test
void indexedDynamicNameSurvivesEvictionDuringInsertion() {
HpackDecoder decoder = new HpackDecoder(48, 1024);
byte[] first = java.util.HexFormat.of().parseHex("4001610d31323334353637383930313233");
decoder.decode(first, 0, first.length, DISCARD);
// Dynamic index 62 supplies the name "a". Adding the new value evicts the referenced entry.
byte[] second = java.util.HexFormat.of().parseHex("7e0d6162636465666768696a6b6c6d");
decoder.decode(second, 0, second.length, DISCARD);
dev.relism.flash.bytes.PooledSlice name = new dev.relism.flash.bytes.PooledSlice();
dev.relism.flash.bytes.PooledSlice value = new dev.relism.flash.bytes.PooledSlice();
decoder.dynamicTable().get(1, name, value);
assertEquals('a', name.byteAt(0));
}
}
@@ -0,0 +1,162 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import org.junit.jupiter.api.Test;
class HpackDecoderTest {
private static final HexFormat HEX = HexFormat.of();
private static final class CollectingSink implements HeaderSink {
final List<String> fields = new ArrayList<>();
final List<Boolean> neverIndexed = new ArrayList<>();
@Override
public void accept(ByteView name, ByteView value, boolean never) {
fields.add(text(name) + ": " + text(value));
neverIndexed.add(never);
}
}
@Test
void appendixC2IndependentRepresentations() {
HpackDecoder decoder = new HpackDecoder();
CollectingSink sink = decode(decoder, "400a637573746f6d2d6b65790d637573746f6d2d686561646572");
assertEquals(List.of("custom-key: custom-header"), sink.fields);
assertDynamic(decoder, 1, "custom-key", "custom-header", 55);
decoder = new HpackDecoder();
sink = decode(decoder, "040c2f73616d706c652f70617468");
assertEquals(List.of(":path: /sample/path"), sink.fields);
assertEquals(0, decoder.dynamicTable().count());
sink = decode(decoder, "100870617373776f726406736563726574");
assertEquals(List.of("password: secret"), sink.fields);
assertEquals(List.of(true), sink.neverIndexed);
assertEquals(0, decoder.dynamicTable().count());
sink = decode(decoder, "82");
assertEquals(List.of(":method: GET"), sink.fields);
}
@Test
void appendixC3RequestsWithoutHuffman() {
verifyRequestSequence(
"828684410f7777772e6578616d706c652e636f6d",
"828684be58086e6f2d6361636865",
"828785bf400a637573746f6d2d6b65790c637573746f6d2d76616c7565");
}
@Test
void appendixC4RequestsWithHuffman() {
verifyRequestSequence(
"828684418cf1e3c2e5f23a6ba0ab90f4ff",
"828684be5886a8eb10649cbf",
"828785bf408825a849e95ba97d7f8925a849e95bb8e8b4bf");
}
@Test
void appendixC5ResponsesWithoutHuffman() {
verifyResponseSequence(
"4803333032580770726976617465611d4d6f6e2c203231204f637420323031332032303a31333a323120474d546e1768747470733a2f2f7777772e6578616d706c652e636f6d",
"4803333037c1c0bf",
"88c1611d4d6f6e2c203231204f637420323031332032303a31333a323220474d54c05a04677a69707738666f6f3d4153444a4b48514b425a584f5157454f50495541585157454f49553b206d61782d6167653d333630303b2076657273696f6e3d31");
}
@Test
void appendixC6ResponsesWithHuffman() {
verifyResponseSequence(
"488264025885aec3771a4b6196d07abe941054d444a8200595040b8166e082a62d1bff6e919d29ad171863c78f0b97c8e9ae82ae43d3",
"4883640effc1c0bf",
"88c16196d07abe941054d444a8200595040b8166e084a62d1bffc05a839bd9ab77ad94e7821dd7f2e6c7b335dfdfcd5b3960d5af27087f3672c1ab270fb5291f9587316065c003ed4ee5b1063d5007");
}
private static void verifyRequestSequence(String first, String second, String third) {
HpackDecoder decoder = new HpackDecoder();
assertEquals(
List.of(":method: GET", ":scheme: http", ":path: /", ":authority: www.example.com"),
decode(decoder, first).fields);
assertDynamic(decoder, 1, ":authority", "www.example.com", 57);
assertEquals(
List.of(
":method: GET",
":scheme: http",
":path: /",
":authority: www.example.com",
"cache-control: no-cache"),
decode(decoder, second).fields);
assertDynamic(decoder, 1, "cache-control", "no-cache", 110);
assertDynamic(decoder, 2, ":authority", "www.example.com", 110);
assertEquals(
List.of(
":method: GET",
":scheme: https",
":path: /index.html",
":authority: www.example.com",
"custom-key: custom-value"),
decode(decoder, third).fields);
assertDynamic(decoder, 1, "custom-key", "custom-value", 164);
assertDynamic(decoder, 2, "cache-control", "no-cache", 164);
assertDynamic(decoder, 3, ":authority", "www.example.com", 164);
}
private static void verifyResponseSequence(String first, String second, String third) {
HpackDecoder decoder = new HpackDecoder(256, 32_768);
assertEquals(responseFields("302", "21"), decode(decoder, first).fields);
assertDynamic(decoder, 1, "location", "https://www.example.com", 222);
assertDynamic(decoder, 4, ":status", "302", 222);
assertEquals(responseFields("307", "21"), decode(decoder, second).fields);
assertDynamic(decoder, 1, ":status", "307", 222);
assertDynamic(decoder, 4, "cache-control", "private", 222);
List<String> expected = new ArrayList<>(responseFields("200", "22"));
expected.add("content-encoding: gzip");
expected.add("set-cookie: foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1");
assertEquals(expected, decode(decoder, third).fields);
assertEquals(3, decoder.dynamicTable().count());
assertDynamic(
decoder, 1, "set-cookie", "foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1", 215);
assertDynamic(decoder, 2, "content-encoding", "gzip", 215);
assertDynamic(decoder, 3, "date", "Mon, 21 Oct 2013 20:13:22 GMT", 215);
}
private static List<String> responseFields(String status, String second) {
return List.of(
":status: " + status,
"cache-control: private",
"date: Mon, 21 Oct 2013 20:13:" + second + " GMT",
"location: https://www.example.com");
}
private static CollectingSink decode(HpackDecoder decoder, String hex) {
CollectingSink sink = new CollectingSink();
byte[] block = HEX.parseHex(hex);
decoder.decode(block, 0, block.length, sink);
return sink;
}
private static void assertDynamic(
HpackDecoder decoder, int index, String expectedName, String expectedValue, int size) {
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
decoder.dynamicTable().get(index, name, value);
assertEquals(expectedName, text(name));
assertEquals(expectedValue, text(value));
assertEquals(size, decoder.dynamicTable().size());
}
private static String text(ByteView value) {
byte[] bytes = new byte[value.length()];
for (int i = 0; i < bytes.length; i++) bytes[i] = value.byteAt(i);
return new String(bytes, StandardCharsets.US_ASCII);
}
}
@@ -0,0 +1,75 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http2.Http2Exception;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class HpackDynamicTableTest {
private static PooledSlice view(String text) {
byte[] bytes = text.getBytes(StandardCharsets.US_ASCII);
PooledSlice result = new PooledSlice();
result.reset(bytes, 0, bytes.length);
return result;
}
private static String text(PooledSlice value) {
return new String(value.array(), value.offset(), value.length(), StandardCharsets.US_ASCII);
}
@Test
void newestEntryHasLowestDynamicIndex() {
HpackDynamicTable table = new HpackDynamicTable(256);
table.add(view("a"), view("one"));
table.add(view("b"), view("two"));
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
table.get(1, name, value);
assertEquals("b", text(name));
assertEquals("two", text(value));
table.get(2, name, value);
assertEquals("a", text(name));
}
@Test
void evictsOldestEntriesByRfcSize() {
HpackDynamicTable table = new HpackDynamicTable(70);
table.add(view("a"), view("1")); // 34
table.add(view("b"), view("2")); // 34
table.add(view("c"), view("3")); // evicts a
assertEquals(2, table.count());
PooledSlice name = new PooledSlice();
table.get(2, name, new PooledSlice());
assertEquals("b", text(name));
}
@Test
void oversizedEntryClearsTableWithoutInsertion() {
HpackDynamicTable table = new HpackDynamicTable(40);
table.add(view("a"), view("1"));
table.add(view("long-name"), view("long-value"));
assertEquals(0, table.count());
assertEquals(0, table.size());
}
@Test
void sizeUpdateCannotExceedAdvertisedMaximum() {
HpackDynamicTable table = new HpackDynamicTable(128);
assertThrows(Http2Exception.class, () -> table.setMaximumSize(129));
table.setMaximumSize(0);
assertEquals(0, table.count());
}
@Test
void compactionPreservesLiveEntries() {
HpackDynamicTable table = new HpackDynamicTable(96);
for (int i = 0; i < 30; i++) table.add(view("name" + i), view("v" + i));
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
table.get(1, name, value);
assertEquals("name29", text(name));
assertEquals("v29", text(value));
}
}
@@ -0,0 +1,87 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
class HpackEvictionRaceTest {
@Test
void perStreamCopySurvivesConcurrentDynamicTableEviction() throws Exception {
HpackDecoder decoder = new HpackDecoder(64, 1024);
HpackHeaderBlock stream = new HpackHeaderBlock(1024, 16);
byte[] first = HexFormat.of().parseHex("40046e616d650b66697273742d76616c7565");
decoder.decode(first, 0, first.length, stream);
assertField(stream, 0, "name", "first-value");
byte[] replacement =
HexFormat.of().parseHex("400a6f746865722d6e616d650c7365636f6e642d76616c7565");
CountDownLatch start = new CountDownLatch(1);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
@SuppressWarnings("unchecked")
Future<Void>[] readers = new Future[8];
for (int reader = 0; reader < readers.length; reader++) {
readers[reader] =
executor.submit(
() -> {
start.await();
for (int i = 0; i < 10_000; i++) {
assertField(stream, 0, "name", "first-value");
}
return null;
});
}
start.countDown();
for (int i = 0; i < 10_000; i++) {
decoder.decode(replacement, 0, replacement.length, (n, v, x) -> {});
}
for (Future<Void> reader : readers) reader.get();
}
assertField(stream, 0, "name", "first-value");
}
@Test
void directDynamicTableViewDemonstratesTheEvictionHazard() {
HpackDynamicTable table = new HpackDynamicTable(64);
PooledSlice firstName = view("name");
PooledSlice firstValue = view("first-value");
table.add(firstName, firstValue);
PooledSlice borrowedName = new PooledSlice();
PooledSlice borrowedValue = new PooledSlice();
table.get(1, borrowedName, borrowedValue);
String before = text(borrowedValue);
table.add(view("other-name"), view("second-value"));
table.add(view("other-name"), view("second-value"));
table.add(view("other-name"), view("second-value"));
assertNotEquals(before, text(borrowedValue));
}
private static void assertField(
HpackHeaderBlock block, int index, String expectedName, String expectedValue) {
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
block.get(index, name, value);
assertEquals(expectedName, text(name));
assertEquals(expectedValue, text(value));
}
private static PooledSlice view(String text) {
byte[] bytes = text.getBytes(StandardCharsets.US_ASCII);
PooledSlice view = new PooledSlice();
view.reset(bytes, 0, bytes.length);
return view;
}
private static String text(PooledSlice value) {
return new String(value.array(), value.offset(), value.length(), StandardCharsets.US_ASCII);
}
}
@@ -0,0 +1,42 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class HpackStaticTableTest {
private static PooledSlice view(String value) {
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
PooledSlice view = new PooledSlice();
view.reset(bytes, 0, bytes.length);
return view;
}
@Test
void containsAllRfcEntriesAndUsesOneBasedIndices() {
assertEquals(61, HpackStaticTable.LENGTH);
assertArrayEquals(":authority".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.name(1));
assertArrayEquals(
"gzip, deflate".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.value(16));
assertArrayEquals(
"www-authenticate".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.name(61));
assertThrows(IndexOutOfBoundsException.class, () -> HpackStaticTable.name(0));
assertThrows(IndexOutOfBoundsException.class, () -> HpackStaticTable.value(62));
}
@Test
void findNameReturnsLowestIndexForRepeatedNames() {
assertEquals(2, HpackStaticTable.findName(view(":method")));
assertEquals(8, HpackStaticTable.findName(view(":status")));
assertEquals(0, HpackStaticTable.findName(view("missing")));
}
@Test
void findPairMatchesExactBytes() {
assertEquals(2, HpackStaticTable.findPair(view(":method"), view("GET")));
assertEquals(14, HpackStaticTable.findPair(view(":status"), view("500")));
assertEquals(0, HpackStaticTable.findPair(view(":method"), view("get")));
}
}