From 95c33e7bf20f1261ba0b6bae20fface703820673 Mon Sep 17 00:00:00 2001 From: Zakaria El Orche Date: Thu, 13 Aug 2026 17:17:29 +0000 Subject: [PATCH] feat(core): add HPACK decoder --- flash/docs/http2/DECISIONS.md | 19 + flash/docs/http2/HPACK.md | 63 +++ flash/docs/http2/IMPLEMENTATION-PLAN.md | 52 +- .../flash/RequestPipelineBenchmark.java | 161 +++--- .../relism/flash/bytes/ByteScanBenchmark.java | 69 ++- .../http2/frame/FrameLayerBenchmark.java | 148 +++--- .../http2/frame/FrameWriterBenchmark.java | 493 ++++++++++-------- .../http2/hpack/HpackDecoderBenchmark.java | 32 ++ .../FastPathRouterBenchmark.java | 147 +++--- .../relism/flash/api/multipart/Multipart.java | 1 + .../http2/hpack/ContinuationAssembler.java | 84 +++ .../http2/hpack/HeaderListSizeException.java | 20 + .../relism/flash/http2/hpack/HeaderSink.java | 13 + .../flash/http2/hpack/HpackDecoder.java | 156 ++++++ .../flash/http2/hpack/HpackDynamicTable.java | 132 +++++ .../flash/http2/hpack/HpackHeaderBlock.java | 85 +++ .../flash/http2/hpack/HpackStaticTable.java | 169 ++++++ .../hpack/ContinuationAssemblerTest.java | 40 ++ .../http2/hpack/HpackDecoderFuzzTest.java | 39 ++ .../http2/hpack/HpackDecoderSecurityTest.java | 68 +++ .../flash/http2/hpack/HpackDecoderTest.java | 162 ++++++ .../http2/hpack/HpackDynamicTableTest.java | 75 +++ .../http2/hpack/HpackEvictionRaceTest.java | 87 ++++ .../http2/hpack/HpackStaticTableTest.java | 42 ++ 24 files changed, 1851 insertions(+), 506 deletions(-) create mode 100644 flash/docs/http2/HPACK.md create mode 100644 flash/src/jmh/java/dev/relism/flash/http2/hpack/HpackDecoderBenchmark.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/hpack/ContinuationAssembler.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/hpack/HeaderListSizeException.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/hpack/HeaderSink.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/hpack/HpackDecoder.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/hpack/HpackDynamicTable.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/hpack/HpackHeaderBlock.java create mode 100644 flash/src/main/java/dev/relism/flash/http2/hpack/HpackStaticTable.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/hpack/ContinuationAssemblerTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderFuzzTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderSecurityTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/hpack/HpackDynamicTableTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/hpack/HpackEvictionRaceTest.java create mode 100644 flash/src/test/java/dev/relism/flash/http2/hpack/HpackStaticTableTest.java diff --git a/flash/docs/http2/DECISIONS.md b/flash/docs/http2/DECISIONS.md index 1fca653..59e98bc 100644 --- a/flash/docs/http2/DECISIONS.md +++ b/flash/docs/http2/DECISIONS.md @@ -865,3 +865,22 @@ a fourth per-request view (e.g. an h2 equivalent), extend this same pooled-`rese 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. + +--- diff --git a/flash/docs/http2/HPACK.md b/flash/docs/http2/HPACK.md new file mode 100644 index 0000000..771f632 --- /dev/null +++ b/flash/docs/http2/HPACK.md @@ -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.1–C.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. diff --git a/flash/docs/http2/IMPLEMENTATION-PLAN.md b/flash/docs/http2/IMPLEMENTATION-PLAN.md index 75d71a0..296fb45 100644 --- a/flash/docs/http2/IMPLEMENTATION-PLAN.md +++ b/flash/docs/http2/IMPLEMENTATION-PLAN.md @@ -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`). | | 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. | -| 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 | — | — | | 9 — HPACK encoder + h2 response path | 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). **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 @@ -1977,16 +1985,19 @@ the continue flag): ### Files Created: -- `h2/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/HpackIntegers.java` — prefix-coded integer decode/encode. +- `http2/hpack/Huffman.java` — decode FSM + encode LUT, both built in a static initializer from 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). -- `h2/hpack/HpackDynamicTable.java` — ring buffer of entry descriptors + a byte arena. -- `h2/hpack/HpackDecoder.java` — the state machine. -- `h2/hpack/HeaderSink.java` — the callback the decoder emits into: +- `http2/hpack/HpackDynamicTable.java` — ring buffer of entry descriptors + a byte arena. +- `http2/hpack/HpackDecoder.java` — the state machine. +- `http2/hpack/HeaderSink.java` — the callback the decoder emits into: `void accept(ByteView name, ByteView value, boolean neverIndexed)`. Implemented by `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 @@ -2071,16 +2082,16 @@ arena, the per-stream arena and the CONTINUATION assembly buffer are all per-con pooled. ### Safety checks -- [ ] Prefix-integer overflow rejected (continuation octet limit) -- [ ] Huffman padding validated (all ones, < 8 bits) -- [ ] Huffman EOS in input rejected -- [ ] Decoded string length bounded during decode, not after -- [ ] Index 0 rejected; out-of-range index rejected -- [ ] Dynamic Table Size Update position and magnitude validated -- [ ] `MAX_HEADER_LIST_SIZE` enforced, **with full decode before rejection** so the table stays +- [x] Prefix-integer overflow rejected (continuation octet limit) +- [x] Huffman padding validated (all ones, < 8 bits) +- [x] Huffman EOS in input rejected +- [x] Decoded string length bounded during decode, not after +- [x] Index 0 rejected; out-of-range index rejected +- [x] Dynamic Table Size Update position and magnitude validated +- [x] `MAX_HEADER_LIST_SIZE` enforced, **with full decode before rejection** so the table stays in sync -- [ ] CONTINUATION frame count and total block size bounded -- [ ] Dynamic table arena cannot be written past its bound +- [x] CONTINUATION frame count and total block size bounded +- [x] Dynamic table arena cannot be written past its bound ### Tests - `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`. ### DoD -- [ ] Every RFC 7541 Appendix C vector passes, including dynamic table state assertions. -- [ ] Fuzz test green for 10 million inputs. -- [ ] `HpackEvictionRaceTest` demonstrates the hazard and the fix. -- [ ] 0 B/op decode. +- [x] Every RFC 7541 Appendix C vector passes, including dynamic table state assertions. +- [x] Fuzz test green for 10 million inputs (2.58 s on JDK 21.0.11; clean profiled build). +- [x] `HpackEvictionRaceTest` demonstrates the hazard and the fix. +- [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. --- diff --git a/flash/src/jmh/java/dev/relism/flash/RequestPipelineBenchmark.java b/flash/src/jmh/java/dev/relism/flash/RequestPipelineBenchmark.java index c484da6..44fb366 100644 --- a/flash/src/jmh/java/dev/relism/flash/RequestPipelineBenchmark.java +++ b/flash/src/jmh/java/dev/relism/flash/RequestPipelineBenchmark.java @@ -7,6 +7,10 @@ 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 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.BenchmarkMode; 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.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 - * 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 number with {@code -prof gc}. - * At Phase 4 ({@code DEC-20}) {@code parseAndRoute} measured 120.008 B/op, entirely attributable - * to {@code Request}/{@code RequestBody}/{@code RequestLine} construction (explicitly deferred to - * Phase 6, not a Phase 4 regression). Phase 6's pooling ({@code EX-20}–{@code EX-24}) plus one - * more allocation this benchmark caught underneath it ({@code EX-42}: {@code RequestParser} was - * 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. + * The h1 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 number with {@code -prof gc}. Before + * model and view pooling, {@code parseAndRoute} measured 120.008 B/op. It now measures at JMH's + * allocation noise floor. The two methods below isolate the parser/router path from the unavoidable + * cost of explicit {@code String} reads by comparing a route with no header or parameter access + * against one that reads a path parameter and two headers. * *

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). + * 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) @@ -55,74 +48,76 @@ import java.util.concurrent.TimeUnit; @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; + /** + * 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; - } + RepeatingByteStream(byte[] template) { + this.template = template; } - 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(); + @Override + public int read() { + byte b = template[pos]; + pos = (pos + 1) % template.length; + return b & 0xFF; } - /** 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); + @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; } + } - /** 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); - } + 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 and route without requesting user-facing header or parameter strings. */ + @Benchmark + public RequestHandler parseAndRoute() throws IOException { + Request request = parser.parse(in); + request.drain(); + return router.route(request, routeScratch); + } + + /** Parse, route, and read one path parameter and two headers as strings. */ + @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); + } } diff --git a/flash/src/jmh/java/dev/relism/flash/bytes/ByteScanBenchmark.java b/flash/src/jmh/java/dev/relism/flash/bytes/ByteScanBenchmark.java index 6019fa6..ec05344 100644 --- a/flash/src/jmh/java/dev/relism/flash/bytes/ByteScanBenchmark.java +++ b/flash/src/jmh/java/dev/relism/flash/bytes/ByteScanBenchmark.java @@ -1,5 +1,7 @@ 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.BenchmarkMode; 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.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. + * Compares {@link ByteScan#indexOfCrLfCrLf} (SWAR) against {@link ByteScan#indexOfCrLfCrLfScalar} + * on a realistic HTTP/1.1 request header block. It lives in this package to reach the + * package-private scalar reference method without widening that method's visibility solely for + * measurement. * - *

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}. + *

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}. */ @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @@ -36,30 +32,31 @@ import java.util.concurrent.TimeUnit; @Measurement(iterations = 5, time = 1) public class ByteScanBenchmark { - /** A realistic request: request line + 7 headers + terminator, ~330 bytes. */ - private byte[] requestBuf; + /** 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); - } + @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_swar() { + return ByteScan.indexOfCrLfCrLf(requestBuf, 0, requestBuf.length); + } - @Benchmark - public int headerEndScan_scalar() { - return ByteScan.indexOfCrLfCrLfScalar(requestBuf, 0, requestBuf.length); - } + @Benchmark + public int headerEndScan_scalar() { + return ByteScan.indexOfCrLfCrLfScalar(requestBuf, 0, requestBuf.length); + } } diff --git a/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameLayerBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameLayerBenchmark.java index 7fe0cae..b7229f9 100644 --- a/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameLayerBenchmark.java +++ b/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameLayerBenchmark.java @@ -2,6 +2,9 @@ package dev.relism.flash.http2.frame; import dev.relism.flash.bytes.ByteWriter; 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.BenchmarkMode; 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.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 - * a frame header: 0 B/op." Measured with {@code -prof gc}, not merely asserted — see - * {@code DECISIONS.md}, {@code DEC-21}, for the recorded numbers. + * Measures allocation and latency for reading, validating and discarding a frame and for writing a + * frame header. Allocation is measured with {@code -prof gc}, not inferred from inspection. * - *

Uses the same hand-rolled repeating {@link InputStream} technique - * {@code RequestPipelineBenchmark} (Phase 4) established: one {@link BufferedByteSource}/ - * {@link Http2FrameReader} pair created once per trial and reused across every invocation, - * matching how a real connection's demux loop owns exactly one of each for its whole lifetime, - * rather than paying for harness-side (re)construction inside the timed path. + *

Uses the same hand-rolled repeating {@link InputStream} technique {@code + * RequestPipelineBenchmark} established: one {@link BufferedByteSource}/ {@link Http2FrameReader} + * pair created once per trial and reused across every invocation, matching how a real connection's + * demux loop owns exactly one of each for its whole lifetime, rather than paying for harness-side + * (re)construction inside the timed path. */ @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @@ -37,77 +35,77 @@ import java.util.concurrent.TimeUnit; @Measurement(iterations = 5, time = 1) public class FrameLayerBenchmark { - private static final class RepeatingByteStream extends InputStream { - private final byte[] template; - private int pos; + 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; - } + RepeatingByteStream(byte[] template) { + this.template = template; } - // ── Read + validate ────────────────────────────────────────────────────── - - private Http2FrameReader reader; - - @Setup(Level.Trial) - public void setupReader() { - FrameWriteBuffer out = new FrameWriteBuffer(new ByteWriter(64)); - out.beginFrame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1); - byte[] payload = new byte[48]; - for (int i = 0; i < payload.length; i++) payload[i] = (byte) i; - out.writer().writeBytes(payload); - out.endFrame(); - byte[] template = new byte[out.writer().length()]; - System.arraycopy(out.writer().array(), 0, template, 0, template.length); - - BufferedByteSource src = new BufferedByteSource(new RepeatingByteStream(template), null); - reader = new Http2FrameReader(src); + @Override + public int read() { + byte b = template[pos]; + pos = (pos + 1) % template.length; + return b & 0xFF; } - @Benchmark - public int readValidateAndDiscard() throws IOException { - FrameHeader header = reader.readFrame(); - FrameValidator.validate(header, false); - int checksum = header.buffer()[header.payloadOffset()]; - reader.consumeFrame(); - return checksum; + @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; } + } - // ── Write ──────────────────────────────────────────────────────────────── + // ── Read + validate ────────────────────────────────────────────────────── - private FrameWriteBuffer writeBuffer; - private byte[] writePayload; + private Http2FrameReader reader; - @Setup(Level.Trial) - public void setupWriter() { - writeBuffer = new FrameWriteBuffer(new ByteWriter(64)); - writePayload = new byte[48]; - for (int i = 0; i < writePayload.length; i++) writePayload[i] = (byte) i; - } + @Setup(Level.Trial) + public void setupReader() { + FrameWriteBuffer out = new FrameWriteBuffer(new ByteWriter(64)); + out.beginFrame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1); + byte[] payload = new byte[48]; + for (int i = 0; i < payload.length; i++) payload[i] = (byte) i; + out.writer().writeBytes(payload); + out.endFrame(); + byte[] template = new byte[out.writer().length()]; + System.arraycopy(out.writer().array(), 0, template, 0, template.length); - @Benchmark - public int writeFrame() { - writeBuffer.writer().reset(); - writeBuffer.beginFrame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1); - writeBuffer.writer().writeBytes(writePayload); - writeBuffer.endFrame(); - return writeBuffer.writer().length(); - } + BufferedByteSource src = new BufferedByteSource(new RepeatingByteStream(template), null); + reader = new Http2FrameReader(src); + } + + @Benchmark + public int readValidateAndDiscard() throws IOException { + FrameHeader header = reader.readFrame(); + FrameValidator.validate(header, false); + int checksum = header.buffer()[header.payloadOffset()]; + reader.consumeFrame(); + return checksum; + } + + // ── Write ──────────────────────────────────────────────────────────────── + + private FrameWriteBuffer writeBuffer; + private byte[] writePayload; + + @Setup(Level.Trial) + public void setupWriter() { + writeBuffer = new FrameWriteBuffer(new ByteWriter(64)); + writePayload = new byte[48]; + for (int i = 0; i < writePayload.length; i++) writePayload[i] = (byte) i; + } + + @Benchmark + public int writeFrame() { + writeBuffer.writer().reset(); + writeBuffer.beginFrame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1); + writeBuffer.writer().writeBytes(writePayload); + writeBuffer.endFrame(); + return writeBuffer.writer().length(); + } } diff --git a/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java index 4d93c37..74c91ff 100644 --- a/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java +++ b/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java @@ -1,5 +1,15 @@ 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.BenchmarkMode; 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.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 - * designs at N ∈ {1, 2, 4, 8, 16, 64} concurrent virtual-thread writers: + * Compares three writer designs at N ∈ {1, 2, 4, 8, 16, 64} concurrent virtual-thread + * writers: * *

* *

Why this benchmark drives its own concurrency instead of JMH's {@code @Threads}

+ * * {@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 - * test. Each {@code @Benchmark} invocation therefore spawns {@link #threads} virtual threads - * itself, has them race a fixed burst of writes to a counting no-op sink, and reports the - * burst's wall-clock rate; JMH still owns fork/warmup/measurement-iteration control and (via - * {@code -prof gc}) the zero-allocation verification. + * thread pool is platform threads, not virtual threads — the exact scheduling behaviour under test. + * Each {@code @Benchmark} invocation therefore spawns {@link #threads} virtual threads itself, has + * them race a fixed burst of writes to a counting no-op sink, and reports the burst's wall-clock + * rate; JMH still owns fork/warmup/measurement-iteration control and (via {@code -prof gc}) the + * zero-allocation verification. * *

Why {@code runBurst} waits on a write counter, not just thread completion

+ * * {@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 * 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 - * designs that most aggressively defer work — the opposite of a fair comparison. Every harness - * here writes through {@link CountingSink} and {@link #runBurst} waits for its counter to reach - * the expected total before returning, so the timed interval always covers real completion. + * designs that most aggressively defer work — the opposite of a fair comparison. Every harness here + * writes through {@link CountingSink} and {@link #runBurst} waits for its counter to reach the + * expected total before returning, so the timed interval always covers real completion. * *

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 - * custom-concurrency benchmark method) and printed once per (design, threads) combination — see - * {@code WRITER.md} for the recorded results and the gate decision. + * collected during the burst (JMH's own {@code Mode.SampleTime} does not fit a custom-concurrency + * benchmark method) and printed once per (design, threads) combination — see {@code WRITER.md} for + * the recorded results and the gate decision. * - *

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 FrameWriterBenchmark -prof gc}. + *

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 FrameWriterBenchmark -prof gc}. */ @State(Scope.Benchmark) @BenchmarkMode(Mode.Throughput) @@ -71,232 +72,282 @@ import java.util.concurrent.locks.ReentrantLock; @Measurement(iterations = 5, time = 1) public class FrameWriterBenchmark { - private static final int FRAMES_PER_THREAD = 4000; - private static final int FRAME_SIZE = 512; + private static final int FRAMES_PER_THREAD = 4000; + private static final int FRAME_SIZE = 512; - @Param({"trylock_mpsc", "plain_lock", "dedicated_thread", "raw_unsynchronized"}) - public String design; + @Param({"trylock_mpsc", "plain_lock", "dedicated_thread", "raw_unsynchronized"}) + public String design; - @Param({"1", "2", "4", "8", "16", "64"}) - public int threads; + @Param({"1", "2", "4", "8", "16", "64"}) + public int threads; - private DesignHarness harness; - private byte[] payload; + private DesignHarness harness; + private byte[] payload; - @Setup(Level.Trial) - public void setup() { - payload = new byte[FRAME_SIZE]; - harness = switch (design) { - case "trylock_mpsc" -> new TryLockMpscHarness(); - case "plain_lock" -> new PlainLockHarness(); - case "dedicated_thread" -> new DedicatedThreadHarness(); - case "raw_unsynchronized" -> new RawUnsynchronizedHarness(); - default -> throw new IllegalStateException("unknown design: " + design); + @Setup(Level.Trial) + public void setup() { + payload = new byte[FRAME_SIZE]; + harness = + switch (design) { + case "trylock_mpsc" -> new TryLockMpscHarness(); + case "plain_lock" -> new PlainLockHarness(); + case "dedicated_thread" -> new DedicatedThreadHarness(); + case "raw_unsynchronized" -> new RawUnsynchronizedHarness(); + default -> throw new IllegalStateException("unknown design: " + design); }; + } + + @TearDown(Level.Trial) + public void teardown() { + harness.shutdown(); + } + + /** + * One "operation" here is a full burst: {@link #threads} virtual threads each writing {@link + * #FRAMES_PER_THREAD} frames. Reported ops/sec must be multiplied by {@code threads * + * FRAMES_PER_THREAD} to get frames/sec — done during result analysis, not via + * {@code @OperationsPerInvocation} (which requires a compile-time constant and cannot vary with + * the {@code threads} @Param). + */ + @Benchmark + public void burst() throws Exception { + harness.runBurst(threads, FRAMES_PER_THREAD, payload); + } + + // ── Harness abstraction and the three designs under comparison ───────────── + + private interface DesignHarness { + void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception; + + 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 submission — see the + * class Javadoc. + */ + private static final class CountingSink implements Http2FrameWriter.Sink { + final AtomicLong count = new AtomicLong(); + + @Override + public void write(byte[] buf, int off, int len) { + count.incrementAndGet(); + } + } + + private static final class BenchIntent implements WriteIntent { + final byte[] buf; + WriteIntent next; + + BenchIntent(byte[] buf) { + this.buf = buf; } - @TearDown(Level.Trial) - public void teardown() { - harness.shutdown(); + @Override + public byte[] buffer() { + return buf; } - /** - * One "operation" here is a full burst: {@link #threads} virtual threads each writing - * {@link #FRAMES_PER_THREAD} frames. Reported ops/sec must be multiplied by - * {@code threads * FRAMES_PER_THREAD} to get frames/sec — done during result analysis, not - * via {@code @OperationsPerInvocation} (which requires a compile-time constant and cannot - * vary with the {@code threads} @Param). - */ - @Benchmark - public void burst() throws Exception { - harness.runBurst(threads, FRAMES_PER_THREAD, payload); + @Override + public int offset() { + return 0; } - // ── Harness abstraction and the three designs under comparison ───────────── - - private interface DesignHarness { - void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception; - void shutdown(); + @Override + public int length() { + return buf.length; } - /** 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 - * submission — see the class Javadoc. */ - private static final class CountingSink implements Http2FrameWriter.Sink { - final AtomicLong count = new AtomicLong(); - @Override - public void write(byte[] buf, int off, int len) { - count.incrementAndGet(); - } + @Override + public WriteIntent mpscNext() { + return next; } - private static final class BenchIntent implements WriteIntent { - final byte[] buf; - WriteIntent next; - BenchIntent(byte[] buf) { this.buf = buf; } - @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; } + @Override + public void setMpscNext(WriteIntent next) { + this.next = next; } + } - private interface ThrowingConsumer { - void accept(T t) throws Exception; - } + private interface ThrowingConsumer { + void accept(T t) throws Exception; + } - /** - * Spawns {@code threadCount} virtual threads, has each write {@code framesPerThread} fresh - * {@link BenchIntent}s (one per write — matches production usage, where a stream's scratch - * buffer holds exactly one in-flight frame at a time), records per-write latency samples, - * then blocks until {@code sink}'s counter reflects every one of them actually written. - */ - private static void race(int threadCount, int framesPerThread, CountingSink sink, - ThrowingConsumer write) throws Exception { - long target = sink.count.get() + (long) threadCount * framesPerThread; - byte[] payload = new byte[FRAME_SIZE]; - long[][] samplesByThread = new long[threadCount][framesPerThread]; - try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { - Future[] futures = new Future[threadCount]; - for (int t = 0; t < threadCount; t++) { - int idx = t; - futures[t] = exec.submit(() -> { - long[] samples = samplesByThread[idx]; - for (int i = 0; i < framesPerThread; i++) { - BenchIntent intent = new BenchIntent(payload); - long start = System.nanoTime(); - try { - write.accept(intent); - } catch (Exception e) { - throw new RuntimeException(e); - } - samples[i] = System.nanoTime() - start; + /** + * Spawns {@code threadCount} virtual threads, has each write {@code framesPerThread} fresh {@link + * BenchIntent}s (one per write — matches production usage, where a stream's scratch buffer holds + * exactly one in-flight frame at a time), records per-write latency samples, then blocks until + * {@code sink}'s counter reflects every one of them actually written. + */ + private static void race( + int threadCount, int framesPerThread, CountingSink sink, ThrowingConsumer write) + throws Exception { + long target = sink.count.get() + (long) threadCount * framesPerThread; + byte[] payload = new byte[FRAME_SIZE]; + long[][] samplesByThread = new long[threadCount][framesPerThread]; + try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { + Future[] futures = new Future[threadCount]; + for (int t = 0; t < threadCount; t++) { + int idx = t; + futures[t] = + exec.submit( + () -> { + long[] samples = samplesByThread[idx]; + for (int i = 0; i < framesPerThread; i++) { + BenchIntent intent = new BenchIntent(payload); + long start = System.nanoTime(); + try { + write.accept(intent); + } catch (Exception e) { + throw new RuntimeException(e); } + samples[i] = System.nanoTime() - start; + } }); + } + for (Future f : futures) f.get(); + } + while (sink.count.get() < target) { + Thread.onSpinWait(); + } + 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 measurement + * iteration. + */ + private static final class LatencyReport { + private static final Set PRINTED = ConcurrentHashMap.newKeySet(); + + static void recordAndMaybePrint(long[][] samplesByThread) { + String key = samplesByThread.length + "t"; + if (!PRINTED.add(key)) return; + + int total = 0; + for (long[] s : samplesByThread) total += s.length; + long[] all = new long[total]; + int pos = 0; + for (long[] s : samplesByThread) { + System.arraycopy(s, 0, all, pos, s.length); + pos += s.length; + } + Arrays.sort(all); + long p50 = all[(int) (all.length * 0.50)]; + long p99 = all[(int) (all.length * 0.99)]; + 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", + samplesByThread.length, p50 / 1000.0, p99 / 1000.0, p999 / 1000.0, all.length); + } + } + + // ── Baseline: no synchronization at all ───────────────────────────────────── + // Not a candidate design (concurrent writers would tear each other's frames) — exists + // purely to establish "what a write costs with zero coordination overhead" for the N=1 + // gate criterion ("per-frame overhead versus a raw unsynchronized write is within 50 ns"). + // At N=1 there genuinely is no concurrent writer, so the missing safety is moot there. + + private static final class RawUnsynchronizedHarness implements DesignHarness { + private final CountingSink sink = new CountingSink(); + + @Override + public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { + race( + threads, + framesPerThread, + sink, + intent -> sink.write(intent.buffer(), intent.offset(), intent.length())); + } + + @Override + public void shutdown() {} + } + + // ── Design (a): plain lock ────────────────────────────────────────────────── + + private static final class PlainLockHarness implements DesignHarness { + private final CountingSink sink = new CountingSink(); + private final ReentrantLock lock = new ReentrantLock(); + + @Override + public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { + race( + threads, + framesPerThread, + sink, + intent -> { + lock.lock(); + try { + sink.write(intent.buffer(), intent.offset(), intent.length()); + } finally { + lock.unlock(); } - for (Future f : futures) f.get(); - } - while (sink.count.get() < target) { - Thread.onSpinWait(); - } - 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 - * measurement iteration. */ - private static final class LatencyReport { - private static final Set PRINTED = ConcurrentHashMap.newKeySet(); + @Override + public void shutdown() {} + } - static void recordAndMaybePrint(long[][] samplesByThread) { - String key = samplesByThread.length + "t"; - if (!PRINTED.add(key)) return; + // ── Design (b): tryLock + intrusive MPSC — the shipped design ────────────── - int total = 0; - for (long[] s : samplesByThread) total += s.length; - long[] all = new long[total]; - int pos = 0; - for (long[] s : samplesByThread) { - System.arraycopy(s, 0, all, pos, s.length); - pos += s.length; - } - Arrays.sort(all); - long p50 = all[(int) (all.length * 0.50)]; - long p99 = all[(int) (all.length * 0.99)]; - 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", - samplesByThread.length, p50 / 1000.0, p99 / 1000.0, p999 / 1000.0, all.length); - } + private static final class TryLockMpscHarness implements DesignHarness { + private final CountingSink sink = new CountingSink(); + private final Http2FrameWriter writer = new Http2FrameWriter(sink, 30_000); + + @Override + public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { + race(threads, framesPerThread, sink, writer::write); } - // ── Baseline: no synchronization at all ───────────────────────────────────── - // Not a candidate design (concurrent writers would tear each other's frames) — exists - // purely to establish "what a write costs with zero coordination overhead" for the N=1 - // gate criterion ("per-frame overhead versus a raw unsynchronized write is within 50 ns"). - // At N=1 there genuinely is no concurrent writer, so the missing safety is moot there. + @Override + public void shutdown() { + writer.close(); + } + } - private static final class RawUnsynchronizedHarness implements DesignHarness { - private final CountingSink sink = new CountingSink(); + // ── Design (c): always hand off to one dedicated writer thread ───────────── - @Override - public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { - race(threads, framesPerThread, sink, - intent -> sink.write(intent.buffer(), intent.offset(), intent.length())); - } + private static final class DedicatedThreadHarness implements DesignHarness { + private final CountingSink sink = new CountingSink(); + private final IntrusiveMpscQueue queue = new IntrusiveMpscQueue(); + private final Thread writerThread; + private volatile boolean running = true; - @Override public void shutdown() { } + DedicatedThreadHarness() { + this.writerThread = Thread.ofPlatform().name("bench-dedicated-writer").start(this::loop); } - // ── Design (a): plain lock ────────────────────────────────────────────────── - - private static final class PlainLockHarness implements DesignHarness { - private final CountingSink sink = new CountingSink(); - private final ReentrantLock lock = new ReentrantLock(); - - @Override - public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { - race(threads, framesPerThread, sink, intent -> { - lock.lock(); - try { - sink.write(intent.buffer(), intent.offset(), intent.length()); - } finally { - lock.unlock(); - } - }); + private void loop() { + while (running) { + WriteIntent intent = queue.poll(); + if (intent == null) { + LockSupport.park(); + continue; } - - @Override public void shutdown() { } + sink.write(intent.buffer(), intent.offset(), intent.length()); + } } - // ── Design (b): tryLock + intrusive MPSC — the shipped design ────────────── - - private static final class TryLockMpscHarness implements DesignHarness { - private final CountingSink sink = new CountingSink(); - private final Http2FrameWriter writer = new Http2FrameWriter(sink, 30_000); - - @Override - public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { - race(threads, framesPerThread, sink, writer::write); - } - - @Override public void shutdown() { writer.close(); } + @Override + public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { + race( + threads, + framesPerThread, + sink, + intent -> { + queue.offer(intent); + LockSupport.unpark(writerThread); + }); } - // ── Design (c): always hand off to one dedicated writer thread ───────────── - - private static final class DedicatedThreadHarness implements DesignHarness { - private final CountingSink sink = new CountingSink(); - private final IntrusiveMpscQueue queue = new IntrusiveMpscQueue(); - private final Thread writerThread; - private volatile boolean running = true; - - DedicatedThreadHarness() { - this.writerThread = Thread.ofPlatform().name("bench-dedicated-writer").start(this::loop); - } - - private void loop() { - while (running) { - WriteIntent intent = queue.poll(); - if (intent == null) { - LockSupport.park(); - continue; - } - sink.write(intent.buffer(), intent.offset(), intent.length()); - } - } - - @Override - public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { - race(threads, framesPerThread, sink, intent -> { - queue.offer(intent); - LockSupport.unpark(writerThread); - }); - } - - @Override - public void shutdown() { - running = false; - writerThread.interrupt(); - } + @Override + public void shutdown() { + running = false; + writerThread.interrupt(); } + } } diff --git a/flash/src/jmh/java/dev/relism/flash/http2/hpack/HpackDecoderBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/hpack/HpackDecoderBenchmark.java new file mode 100644 index 0000000..8f2ee3f --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/hpack/HpackDecoderBenchmark.java @@ -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(); + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterBenchmark.java b/flash/src/jmh/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterBenchmark.java index cf8fc57..e9e2068 100644 --- a/flash/src/jmh/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterBenchmark.java +++ b/flash/src/jmh/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterBenchmark.java @@ -5,6 +5,8 @@ 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 java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; import org.openjdk.jmh.annotations.Benchmark; import org.openjdk.jmh.annotations.BenchmarkMode; 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.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. + * Two related but distinct measurements of router and byte-comparison performance. * - *

{@code router_*}: 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). + *

{@code router_*} exercises the shipped {@link FastPathRouterImpl#route} end to end, + * including lazy-compiled route-table lookup, {@link FastPathRouterImpl.RouteScratch} reuse and + * path-parameter extraction. * - *

{@code byteCompare_*}: 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. + *

{@code byteCompare_*} directly compares {@code ByteCompare.equals} with its + * word-at-a-time path enabled and disabled over representative array-backed content. This is + * separate because router matching uses the composite, non-array-backed {@link + * FastPathViews.MethodPathByteView}, so the router measurements cannot expose the word path's + * effect. */ @State(Scope.Thread) @BenchmarkMode(Mode.AverageTime) @@ -46,69 +40,80 @@ import java.util.concurrent.TimeUnit; @Measurement(iterations = 5, time = 1) public class FastPathRouterBenchmark { - // ── router_*: the real, shipped router, end to end ────────────────────── + // ── router_*: the real, shipped router, end to end ────────────────────── - private FastPathRouterImpl router; - private Object scratch; - private Request staticRequest; - private Request paramRequest; + 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(); + @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"); - } + 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_staticRoute() { + return router.route(staticRequest, scratch); + } - @Benchmark - public RequestHandler router_parametricRoute() { - return router.route(paramRequest, 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.Http1HeaderMap() - ); - return new Request(line, new byte[0]); - } + 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.Http1HeaderMap()); + 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 byte[] cmpOther; + 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(); - } + @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_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); - } + @Benchmark + public boolean byteCompare_byteAtATime() { + return ByteCompare.equals(cmpView, 0, cmpOther, 0, cmpOther.length, false); + } } diff --git a/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java b/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java index 81703f9..e8f2367 100644 --- a/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java +++ b/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java @@ -43,6 +43,7 @@ import java.util.*; *

Thread safety: not thread-safe; one instance per request. */ public final class Multipart { + private int partCount; private static final int BUF_CAP = 8192; diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/ContinuationAssembler.java b/flash/src/main/java/dev/relism/flash/http2/hpack/ContinuationAssembler.java new file mode 100644 index 0000000..5a26a9b --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/ContinuationAssembler.java @@ -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; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderListSizeException.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderListSizeException.java new file mode 100644 index 0000000..79d4e02 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderListSizeException.java @@ -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; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderSink.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderSink.java new file mode 100644 index 0000000..2d455a7 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderSink.java @@ -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); +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDecoder.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDecoder.java new file mode 100644 index 0000000..4d1afa9 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDecoder.java @@ -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; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDynamicTable.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDynamicTable.java new file mode 100644 index 0000000..7b48595 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDynamicTable.java @@ -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); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackHeaderBlock.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackHeaderBlock.java new file mode 100644 index 0000000..fae66a8 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackHeaderBlock.java @@ -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); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackStaticTable.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackStaticTable.java new file mode 100644 index 0000000..a2a247d --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackStaticTable.java @@ -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); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/ContinuationAssemblerTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/ContinuationAssemblerTest.java new file mode 100644 index 0000000..b02cf80 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/ContinuationAssemblerTest.java @@ -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)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderFuzzTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderFuzzTest.java new file mode 100644 index 0000000..8f0bc40 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderFuzzTest.java @@ -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); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderSecurityTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderSecurityTest.java new file mode 100644 index 0000000..9781321 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderSecurityTest.java @@ -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)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderTest.java new file mode 100644 index 0000000..fd96b12 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderTest.java @@ -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 fields = new ArrayList<>(); + final List 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 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 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); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDynamicTableTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDynamicTableTest.java new file mode 100644 index 0000000..e68d83e --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDynamicTableTest.java @@ -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)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEvictionRaceTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEvictionRaceTest.java new file mode 100644 index 0000000..ae78218 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEvictionRaceTest.java @@ -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[] 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 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); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackStaticTableTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackStaticTableTest.java new file mode 100644 index 0000000..4d822f7 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackStaticTableTest.java @@ -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"))); + } +}