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: * *
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 {@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