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

Merged
Relism merged 23 commits from feature/core/http2 into master 2026-08-14 18:20:30 +00:00
24 changed files with 1851 additions and 506 deletions
Showing only changes of commit 95c33e7bf2 - Show all commits
+19
View File
@@ -865,3 +865,22 @@ a fourth per-request view (e.g. an h2 equivalent), extend this same pooled-`rese
than reintroducing a fresh allocation.
---
## DEC-24 — Compact the HPACK arena and copy decoded headers into stream-owned storage
**Context.** Dynamic-table entries must be contiguous for cheap indexed lookup, but FIFO eviction
leaves holes at the front of a bounded arena. Views into that arena also cannot outlive later
decodes on a multiplexed connection.
**Decision.** Compact live dynamic entries when the free tail cannot hold an insertion. Do not use
`SegmentedByteView` for wrapped entries or CONTINUATION fragments. At the decoder boundary,
`HpackHeaderBlock` copies fields into a reusable arena owned by the stream.
**Consequence.** Compaction is occasionally O(table size), bounded by the advertised table size,
while all ordinary lookups and consumer copies remain contiguous. Stream handlers never observe
dynamic-table eviction or compaction. The JMH decode benchmark remains at the allocation noise
floor (0.001 B/op).
**Revisit when.** Profiling shows compaction is material under realistic dynamic-table churn.
---
+63
View File
@@ -0,0 +1,63 @@
# HPACK decoder
This document records the implementation constraints of Flash's RFC 7541 decoder. It is
contributor documentation, not application API documentation.
## Representation model
`HpackDecoder` accepts all RFC 7541 field representations: indexed fields, literals with
incremental indexing, literals without indexing, never-indexed literals, and dynamic-table size
updates. Prefix integers are bounded against overflow, Huffman padding and EOS are validated, and
decoded string lengths are checked while bytes are produced.
The static table is stored as 61 immutable name/value byte pairs. Encoder-oriented reverse lookup
uses fixed open-addressed integer tables built during class initialization; lookup never converts
header bytes to `String` and never calls `HashMap` on the hot path.
The dynamic table owns a bounded byte arena and a ring of primitive entry descriptors. Entry size
is `name length + value length + 32`, and eviction is oldest-first as required by RFC 7541 §4.1.
When the arena tail is too short, live entries are compacted into a contiguous prefix. This avoids
segmented views in every consumer and keeps indexed fields cheap to copy.
## Ownership and eviction safety
Views emitted by `HeaderSink` are callback-scoped. Production decoding targets a reusable
`HpackHeaderBlock` owned by the stream, which copies each name and value into its own arena.
The copy is required for correctness. Consider stream A referencing a dynamic-table entry while
its handler is running. The connection thread can then decode stream B, evict that entry, and
reuse its bytes. If stream A retained the dynamic-table view, its headers would silently change.
Per-stream storage removes that race without reference counting or synchronization.
The precise copy model is:
- HTTP/1.1 copies nothing per request but scans header bytes in the connection buffer.
- HTTP/2 copies decoded request headers into stream-owned storage because multiplexed handlers
outlive subsequent HPACK mutations.
- Novel incrementally-indexed fields are also copied once into the connection's dynamic table.
`HpackEvictionRaceTest` contains both the unsafe borrowed-view demonstration and the stable
stream-owned result.
## Header-list rejection
The decoder counts RFC header-list size cumulatively. Once the configured limit is crossed it
stops emitting fields, but continues parsing the entire block and applying dynamic-table updates.
Only after the block ends does it throw `HeaderListSizeException`. The stream layer can reject the
request while the connection's compression state remains synchronized.
## CONTINUATION assembly
`ContinuationAssembler` copies HEADERS and CONTINUATION fragments into one bounded connection
buffer. It rejects interleaving, stream-id changes, excessive continuation count, and blocks that
exceed the configured capacity. `SegmentedByteView` is intentionally not used here: RFC 9113 §6.10
requires a contiguous, non-interleaved continuation sequence, and one bounded copy makes the HPACK
decoder and all downstream views simpler.
## Verification
- RFC 7541 Appendix C.1C.6 vectors, including dynamic-table state after every sequence.
- Invalid integer, Huffman, index, size-update, and header-list inputs.
- Ten million deterministic random blocks; only typed protocol rejections may escape.
- JMH `-prof gc`: `decodeStaticRequest` measured 102.725 ns/op and 0.001 B/op on JDK 21.0.11. The
latter is the profiler's sampling noise floor; no garbage collections occurred.
+32 -20
View File
@@ -68,7 +68,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
| 4 — Byte-layer foundations | done | `feature/core/http2` | `dev.relism.flash.bytes` package (`ByteScan`+SWAR, `ArrayBackedByteView`, `SegmentedByteView`, `PooledSlice`/`SlicePool`, `ByteWriter`, `Pairs`) built. `EX-04`/`EX-05`/`EX-09`/`EX-19`/`EX-25`/`EX-26`/`EX-33` done, plus `EX-06`'s router half (plan correction, `DEC-19`) removing `FastPathRouterImpl`/`FastPathWsRouterImpl`'s `ThreadLocal`s via an opaque per-connection scratch (`AbstractRouter#newScratch`) instead of extending `ConnectionScratch` (would have created a `routing``transport` package cycle). `AbstractRouter`/`AbstractWsRouter.route()` gained a `scratch` param — all call sites updated. Measured (`DEC-20`): SWAR scan 35.4% faster (kept), `EX-04`'s word-path 32.1% faster at the mechanism level (kept; today's router doesn't route through it — `MethodPathByteView` stays non-array-backed by design). Router matching itself is ≈0 B/op including parametric routes. Full h1 pipeline is 120.008 B/op, 100% attributable to `Request`/`RequestBody`/`RequestLine` construction — explicitly Phase 6 scope, not a Phase 4 regression. Two documented (non-hot-path) anonymous-`ByteView` fallbacks remain in `QueryParams`/`PathParams.view`. `BYTES.md` written. 395/395 tests green (both with and without `-Pjmh`). |
| 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.
---
@@ -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.
*
* <p>Uses a hand-rolled repeating {@link InputStream} (never allocates, cycles the same request
* bytes indefinitely) rather than a fresh {@code ByteArrayInputStream}/{@code BufferedByteSource}
* per invocation, so the timed path matches production exactly: one {@link BufferedByteSource}
* created once per connection and reused across every request, per {@code Http1Connection}'s own
* shape — not recreated per benchmark iteration, which would contaminate the measurement with
* harness allocation unrelated to the parser/router/model code under test (the same lesson
* {@code WRITER.md} documents for {@code FrameWriterBenchmark}'s own harness).
* 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);
}
}
@@ -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.
*
* <p>Run: {@code mvn -Pjmh -pl flash test-compile} then
* {@code java -cp flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath -Dmdep.outputFile=/dev/stdout -q)
* org.openjdk.jmh.Main ByteScanBenchmark}. Results recorded in {@code DECISIONS.md}, {@code DEC-20}.
* <p>Run: {@code mvn -Pjmh -pl flash test-compile} then {@code java -cp
* flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath
* -Dmdep.outputFile=/dev/stdout -q) org.openjdk.jmh.Main ByteScanBenchmark}.
*/
@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);
}
}
@@ -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.
*
* <p>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.
* <p>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();
}
}
@@ -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 <b>virtual-thread</b> writers:
* Compares three writer designs at N ∈ {1, 2, 4, 8, 16, 64} concurrent <b>virtual-thread</b>
* writers:
*
* <ul>
* <li>{@code trylock_mpsc} — the design that ships as {@link Http2FrameWriter}: {@code tryLock()}
* fast path, intrusive MPSC fallback.</li>
* <li>{@code plain_lock} — every write blocks on {@code ReentrantLock#lock()}, unconditionally.</li>
* <li>{@code dedicated_thread} — every write hands off to a single dedicated platform thread
* via the same {@link IntrusiveMpscQueue}, parked/unparked (never a busy poll).</li>
* fast path, intrusive MPSC fallback.
* <li>{@code plain_lock} — every write blocks on {@code ReentrantLock#lock()}, unconditionally.
* <li>{@code dedicated_thread} — every write hands off to a single dedicated platform thread via
* the same {@link IntrusiveMpscQueue}, parked/unparked (never a busy poll).
* </ul>
*
* <h3>Why this benchmark drives its own concurrency instead of JMH's {@code @Threads}</h3>
*
* {@code @Threads} requires a compile-time constant, not a {@code @Param}-swept value, and JMH's
* 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.
*
* <h3>Why {@code runBurst} waits on a write counter, not just thread completion</h3>
*
* {@code write()} does not mean "already on the wire" for every design: the shipped design's
* 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.
*
* <p>Per-write latency percentiles are computed by hand from {@code System.nanoTime()} samples
* collected during the burst (JMH's own {@code Mode.SampleTime} does not fit a
* 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.
*
* <p>Run: {@code mvn -Pjmh -pl flash test-compile} then
* {@code java -cp flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath -Dmdep.outputFile=/dev/stdout -q)
* org.openjdk.jmh.Main FrameWriterBenchmark -prof gc}.
* <p>Run: {@code mvn -Pjmh -pl flash test-compile} then {@code java -cp
* flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath
* -Dmdep.outputFile=/dev/stdout -q) org.openjdk.jmh.Main 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<T> {
void accept(T t) throws Exception;
}
private interface ThrowingConsumer<T> {
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<WriteIntent> 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<WriteIntent> 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<String> 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<String> 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();
}
}
}
@@ -0,0 +1,32 @@
package dev.relism.flash.http2.hpack;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/** Measures steady-state decoding into reusable per-stream storage. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class HpackDecoderBenchmark {
private final HpackDecoder decoder = new HpackDecoder();
private final HpackHeaderBlock headers = new HpackHeaderBlock();
private final byte[] block = {(byte) 0x82, (byte) 0x87, (byte) 0x84, (byte) 0x88};
@Benchmark
public int decodeStaticRequest() {
headers.reset();
decoder.decode(block, 0, block.length, headers);
return headers.count();
}
}
@@ -5,6 +5,8 @@ import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.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.
*
* <p><b>{@code router_*}</b>: the plan's literal instruction — "measure the {@code EX-04} win on
* the h1 router benchmark" — exercised through the real, shipped {@link FastPathRouterImpl#route}
* end to end (lazy-compiled route table, {@link FastPathRouterImpl.RouteScratch} reuse, path-param
* extraction included).
* <p><b>{@code router_*}</b> exercises the shipped {@link FastPathRouterImpl#route} end to end,
* including lazy-compiled route-table lookup, {@link FastPathRouterImpl.RouteScratch} reuse and
* path-parameter extraction.
*
* <p><b>{@code byteCompare_*}</b>: a direct measurement of the mechanism {@code EX-04} actually
* implements ({@code ByteCompare.equals}, {@code useLong} true vs. false) over array-backed
* content shaped like what a future call site (HPACK static-table matching, frame validation)
* would compare. This exists because the router's own match call passes
* {@link FastPathViews.MethodPathByteView} — a deliberate composite, never array-backed (see
* {@code EX-04}'s own registry text: "{@code MethodPathByteView} ... keep[s] the {@code false}
* default") — so {@code router_*} alone cannot show {@code EX-04}'s effect at all; this benchmark
* is what actually answers "is the long path worth what it implements" for future consumers.
* <p><b>{@code byteCompare_*}</b> 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);
}
}
@@ -43,6 +43,7 @@ import java.util.*;
* <p><b>Thread safety:</b> not thread-safe; one instance per request.
*/
public final class Multipart {
private int partCount;
private static final int BUF_CAP = 8192;
@@ -0,0 +1,84 @@
package dev.relism.flash.http2.hpack;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
/** Reassembles one HEADERS/CONTINUATION sequence into a bounded contiguous connection buffer. */
public final class ContinuationAssembler {
private final byte[] buffer;
private int streamId;
private int length;
private int continuationCount;
private boolean active;
private boolean complete;
public ContinuationAssembler() {
this(Http2Limits.MAX_HEADER_LIST_SIZE);
}
public ContinuationAssembler(int maximumBlockSize) {
if (maximumBlockSize <= 0) throw new IllegalArgumentException("non-positive block size");
buffer = new byte[maximumBlockSize];
}
public void begin(
int streamId, byte[] source, int offset, int fragmentLength, boolean endHeaders) {
if (active || streamId <= 0) throw Http2Exception.PROTOCOL_ERROR;
reset();
this.streamId = streamId;
append(source, offset, fragmentLength);
complete = endHeaders;
active = !endHeaders;
}
public void continuation(
int streamId, byte[] source, int offset, int fragmentLength, boolean endHeaders) {
if (!active || streamId != this.streamId) throw Http2Exception.PROTOCOL_ERROR;
if (++continuationCount > Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK) {
throw Http2Exception.PROTOCOL_ERROR;
}
append(source, offset, fragmentLength);
complete = endHeaders;
active = !endHeaders;
}
public byte[] buffer() {
return buffer;
}
public int length() {
return length;
}
public int streamId() {
return streamId;
}
public boolean isComplete() {
return complete;
}
public boolean isActive() {
return active;
}
public void reset() {
streamId = 0;
length = 0;
continuationCount = 0;
active = false;
complete = false;
}
private void append(byte[] source, int offset, int fragmentLength) {
if (source == null
|| offset < 0
|| fragmentLength < 0
|| offset > source.length - fragmentLength
|| fragmentLength > buffer.length - length) {
throw Http2Exception.COMPRESSION_ERROR;
}
System.arraycopy(source, offset, buffer, length, fragmentLength);
length += fragmentLength;
}
}
@@ -0,0 +1,20 @@
package dev.relism.flash.http2.hpack;
/**
* Signals that a fully decoded HPACK block exceeded the configured header-list limit. The decoder
* delays this exception until the complete block has been consumed so dynamic-table state remains
* synchronized with the peer. The stream layer maps it to a request rejection without closing the
* HTTP/2 connection.
*/
public final class HeaderListSizeException extends RuntimeException {
private final long decodedSize;
HeaderListSizeException(long decodedSize) {
super("decoded header list exceeds limit: " + decodedSize, null, false, false);
this.decodedSize = decodedSize;
}
public long decodedSize() {
return decodedSize;
}
}
@@ -0,0 +1,13 @@
package dev.relism.flash.http2.hpack;
import dev.relism.fpr.core.ByteView;
/** Receives decoded HPACK fields in wire order. */
@FunctionalInterface
public interface HeaderSink {
/**
* Accepts one field. The views are valid only for the duration of this call; a sink that needs
* them afterwards must copy them into storage owned by the stream.
*/
void accept(ByteView name, ByteView value, boolean neverIndexed);
}
@@ -0,0 +1,156 @@
package dev.relism.flash.http2.hpack;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.fpr.core.ByteView;
/** Stateful, allocation-free HPACK decoder for one HTTP/2 connection direction. */
public final class HpackDecoder {
private final HpackDynamicTable dynamicTable;
private final int maximumHeaderListSize;
private final byte[] nameScratch = new byte[Http2Limits.MAX_HPACK_STRING_LENGTH];
private final byte[] valueScratch = new byte[Http2Limits.MAX_HPACK_STRING_LENGTH];
private final PooledSlice nameView = new PooledSlice();
private final PooledSlice valueView = new PooledSlice();
public HpackDecoder(int advertisedTableSize, int maximumHeaderListSize) {
if (maximumHeaderListSize < 0) throw new IllegalArgumentException("negative header-list size");
this.dynamicTable = new HpackDynamicTable(advertisedTableSize);
this.maximumHeaderListSize = maximumHeaderListSize;
}
public HpackDecoder() {
this(Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL, Http2Limits.MAX_HEADER_LIST_SIZE);
}
/** Decodes one complete header block. */
public void decode(byte[] buffer, int offset, int length, HeaderSink sink) {
if (buffer == null
|| sink == null
|| offset < 0
|| length < 0
|| offset > buffer.length - length) {
throw new IllegalArgumentException("invalid HPACK decode arguments");
}
int position = offset;
int limit = offset + length;
boolean sawHeader = false;
boolean oversized = false;
long headerListSize = 0;
while (position < limit) {
int first = buffer[position] & 0xff;
if ((first & 0x80) != 0) {
long decoded = HpackIntegers.decode(buffer, position, limit, 7);
int index = Pairs.hi(decoded);
position = Pairs.lo(decoded);
if (index == 0) throw Http2Exception.COMPRESSION_ERROR;
resolve(index, nameView, valueView);
sawHeader = true;
headerListSize += fieldSize(nameView, valueView);
if (headerListSize <= maximumHeaderListSize) sink.accept(nameView, valueView, false);
else oversized = true;
continue;
}
if ((first & 0x40) != 0) {
long decoded = HpackIntegers.decode(buffer, position, limit, 6);
int nameIndex = Pairs.hi(decoded);
position = Pairs.lo(decoded);
position = decodeName(buffer, position, limit, nameIndex);
position = decodeString(buffer, position, limit, valueScratch, valueView);
sawHeader = true;
headerListSize += fieldSize(nameView, valueView);
if (headerListSize <= maximumHeaderListSize) sink.accept(nameView, valueView, false);
else oversized = true;
dynamicTable.add(nameView, valueView);
continue;
}
if ((first & 0x20) != 0) {
if (sawHeader) throw Http2Exception.COMPRESSION_ERROR;
long decoded = HpackIntegers.decode(buffer, position, limit, 5);
dynamicTable.setMaximumSize(Pairs.hi(decoded));
position = Pairs.lo(decoded);
continue;
}
boolean neverIndexed = (first & 0x10) != 0;
long decoded = HpackIntegers.decode(buffer, position, limit, 4);
int nameIndex = Pairs.hi(decoded);
position = Pairs.lo(decoded);
position = decodeName(buffer, position, limit, nameIndex);
position = decodeString(buffer, position, limit, valueScratch, valueView);
sawHeader = true;
headerListSize += fieldSize(nameView, valueView);
if (headerListSize <= maximumHeaderListSize) sink.accept(nameView, valueView, neverIndexed);
else oversized = true;
}
if (oversized) throw new HeaderListSizeException(headerListSize);
}
public HpackDynamicTable dynamicTable() {
return dynamicTable;
}
private int decodeName(byte[] buffer, int position, int limit, int index) {
if (index != 0) {
resolveName(index, nameView);
return position;
}
return decodeString(buffer, position, limit, nameScratch, nameView);
}
private static int decodeString(
byte[] buffer, int position, int limit, byte[] scratch, PooledSlice output) {
if (position >= limit) throw Http2Exception.COMPRESSION_ERROR;
boolean huffman = (buffer[position] & 0x80) != 0;
long decoded = HpackIntegers.decode(buffer, position, limit, 7);
int encodedLength = Pairs.hi(decoded);
int dataStart = Pairs.lo(decoded);
if (encodedLength > limit - dataStart) throw Http2Exception.COMPRESSION_ERROR;
if (huffman) {
int decodedLength =
Huffman.decode(buffer, dataStart, encodedLength, scratch, 0, scratch.length);
output.reset(scratch, 0, decodedLength);
} else {
if (encodedLength > Http2Limits.MAX_HPACK_STRING_LENGTH)
throw Http2Exception.COMPRESSION_ERROR;
output.reset(buffer, dataStart, encodedLength);
}
return dataStart + encodedLength;
}
private void resolve(int index, PooledSlice name, PooledSlice value) {
if (index <= HpackStaticTable.LENGTH) {
byte[] staticName = HpackStaticTable.name(index);
byte[] staticValue = HpackStaticTable.value(index);
name.reset(staticName, 0, staticName.length);
value.reset(staticValue, 0, staticValue.length);
return;
}
dynamicTable.get(index - HpackStaticTable.LENGTH, name, value);
}
private void resolveName(int index, PooledSlice name) {
if (index <= 0) throw Http2Exception.COMPRESSION_ERROR;
if (index <= HpackStaticTable.LENGTH) {
byte[] staticName = HpackStaticTable.name(index);
name.reset(staticName, 0, staticName.length);
return;
}
dynamicTable.get(index - HpackStaticTable.LENGTH, name, valueView);
// An incremental-indexing representation can evict or compact the entry that supplied its
// indexed name. Preserve the name before insertion mutates the dynamic table arena.
System.arraycopy(name.array(), name.offset(), nameScratch, 0, name.length());
name.reset(nameScratch, 0, name.length());
}
private static long fieldSize(ByteView name, ByteView value) {
return (long) name.length() + value.length() + 32;
}
}
@@ -0,0 +1,132 @@
package dev.relism.flash.http2.hpack;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.fpr.core.ByteView;
/**
* Per-connection HPACK dynamic table. Entries are kept in FIFO order in a descriptor ring while
* their bytes live in one bounded arena. The arena is compacted only when its free tail cannot hold
* the next entry, keeping every returned view contiguous.
*/
public final class HpackDynamicTable {
private final byte[] arena;
private final int[] nameOffsets;
private final int[] nameLengths;
private final int[] valueOffsets;
private final int[] valueLengths;
private final int advertisedMaximum;
private int maximumSize;
private int currentSize;
private int head;
private int count;
private int arenaEnd;
public HpackDynamicTable(int advertisedMaximum) {
if (advertisedMaximum < 0) throw new IllegalArgumentException("negative HPACK table size");
this.advertisedMaximum = advertisedMaximum;
this.maximumSize = advertisedMaximum;
this.arena = new byte[Math.max(1, advertisedMaximum)];
int entryCapacity = Math.max(1, advertisedMaximum / 32 + 1);
this.nameOffsets = new int[entryCapacity];
this.nameLengths = new int[entryCapacity];
this.valueOffsets = new int[entryCapacity];
this.valueLengths = new int[entryCapacity];
}
public int count() {
return count;
}
public int size() {
return currentSize;
}
public int maximumSize() {
return maximumSize;
}
/** Applies an RFC 7541 §4.2 table-size update and evicts oldest entries as necessary. */
public void setMaximumSize(int newMaximum) {
if (newMaximum < 0 || newMaximum > advertisedMaximum) throw Http2Exception.COMPRESSION_ERROR;
maximumSize = newMaximum;
evictToFit(0);
if (count == 0) arenaEnd = 0;
}
/** Inserts a new entry, copying its bytes before performing FIFO eviction. */
public void add(ByteView name, ByteView value) {
int byteLength = name.length() + value.length();
int entrySize = byteLength + 32;
if (entrySize > maximumSize) {
clear();
return;
}
evictToFit(entrySize);
if (arena.length - arenaEnd < byteLength) compact();
int slot = (head + count) % nameOffsets.length;
nameOffsets[slot] = arenaEnd;
nameLengths[slot] = name.length();
copy(name, arena, arenaEnd);
arenaEnd += name.length();
valueOffsets[slot] = arenaEnd;
valueLengths[slot] = value.length();
copy(value, arena, arenaEnd);
arenaEnd += value.length();
count++;
currentSize += entrySize;
}
/** Resolves a dynamic index where {@code 1} is the newest entry. */
public void get(int relativeIndex, PooledSlice name, PooledSlice value) {
if (relativeIndex < 1 || relativeIndex > count) throw Http2Exception.COMPRESSION_ERROR;
int slot = (head + count - relativeIndex) % nameOffsets.length;
name.reset(arena, nameOffsets[slot], nameLengths[slot]);
value.reset(arena, valueOffsets[slot], valueLengths[slot]);
}
public void clear() {
head = 0;
count = 0;
currentSize = 0;
arenaEnd = 0;
}
private void evictToFit(int incomingSize) {
while (count > 0 && currentSize + incomingSize > maximumSize) {
int slot = head;
currentSize -= nameLengths[slot] + valueLengths[slot] + 32;
head = (head + 1) % nameOffsets.length;
count--;
}
if (count == 0) arenaEnd = 0;
}
private void compact() {
int destination = 0;
for (int i = 0; i < count; i++) {
int slot = (head + i) % nameOffsets.length;
int nameLength = nameLengths[slot];
int valueLength = valueLengths[slot];
System.arraycopy(arena, nameOffsets[slot], arena, destination, nameLength);
nameOffsets[slot] = destination;
destination += nameLength;
System.arraycopy(arena, valueOffsets[slot], arena, destination, valueLength);
valueOffsets[slot] = destination;
destination += valueLength;
}
arenaEnd = destination;
}
private static void copy(ByteView source, byte[] target, int offset) {
if (source instanceof ArrayBackedByteView contiguous) {
System.arraycopy(contiguous.array(), contiguous.offset(), target, offset, source.length());
return;
}
for (int i = 0; i < source.length(); i++) target[offset + i] = source.byteAt(i);
}
}
@@ -0,0 +1,85 @@
package dev.relism.flash.http2.hpack;
import dev.relism.flash.bytes.ArrayBackedByteView;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.fpr.core.ByteView;
/**
* Reusable per-stream storage for decoded header fields. Copying at the decoder boundary makes a
* stream independent of later HPACK dynamic-table eviction on the connection thread.
*/
public final class HpackHeaderBlock implements HeaderSink {
private final byte[] arena;
private final int[] nameOffsets;
private final int[] nameLengths;
private final int[] valueOffsets;
private final int[] valueLengths;
private final boolean[] neverIndexed;
private int arenaEnd;
private int count;
public HpackHeaderBlock() {
this(Http2Limits.MAX_HEADER_LIST_SIZE, Http2Limits.MAX_HEADER_LIST_SIZE / 32 + 1);
}
HpackHeaderBlock(int arenaCapacity, int fieldCapacity) {
arena = new byte[arenaCapacity];
nameOffsets = new int[fieldCapacity];
nameLengths = new int[fieldCapacity];
valueOffsets = new int[fieldCapacity];
valueLengths = new int[fieldCapacity];
neverIndexed = new boolean[fieldCapacity];
}
public void reset() {
arenaEnd = 0;
count = 0;
}
public int count() {
return count;
}
public boolean neverIndexed(int index) {
checkIndex(index);
return neverIndexed[index];
}
public void get(int index, PooledSlice name, PooledSlice value) {
checkIndex(index);
name.reset(arena, nameOffsets[index], nameLengths[index]);
value.reset(arena, valueOffsets[index], valueLengths[index]);
}
@Override
public void accept(ByteView name, ByteView value, boolean sensitive) {
int bytes = name.length() + value.length();
if (count >= nameOffsets.length || bytes > arena.length - arenaEnd) {
throw new IllegalStateException("decoded header block exceeds its configured storage");
}
nameOffsets[count] = arenaEnd;
nameLengths[count] = name.length();
copy(name, arenaEnd);
arenaEnd += name.length();
valueOffsets[count] = arenaEnd;
valueLengths[count] = value.length();
copy(value, arenaEnd);
arenaEnd += value.length();
neverIndexed[count] = sensitive;
count++;
}
private void copy(ByteView source, int destination) {
if (source instanceof ArrayBackedByteView contiguous) {
System.arraycopy(
contiguous.array(), contiguous.offset(), arena, destination, source.length());
return;
}
for (int i = 0; i < source.length(); i++) arena[destination + i] = source.byteAt(i);
}
private void checkIndex(int index) {
if (index < 0 || index >= count) throw new IndexOutOfBoundsException(index);
}
}
@@ -0,0 +1,169 @@
package dev.relism.flash.http2.hpack;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
/** The immutable 61-entry HPACK static table defined by RFC 7541 Appendix A. */
public final class HpackStaticTable {
public static final int LENGTH = 61;
private static final byte[][] NAMES = new byte[LENGTH + 1][];
private static final byte[][] VALUES = new byte[LENGTH + 1][];
private static final int[] NAME_INDEX = new int[128];
private static final int[] PAIR_INDEX = new int[128];
static {
add(1, ":authority", "");
add(2, ":method", "GET");
add(3, ":method", "POST");
add(4, ":path", "/");
add(5, ":path", "/index.html");
add(6, ":scheme", "http");
add(7, ":scheme", "https");
add(8, ":status", "200");
add(9, ":status", "204");
add(10, ":status", "206");
add(11, ":status", "304");
add(12, ":status", "400");
add(13, ":status", "404");
add(14, ":status", "500");
add(15, "accept-charset", "");
add(16, "accept-encoding", "gzip, deflate");
add(17, "accept-language", "");
add(18, "accept-ranges", "");
add(19, "accept", "");
add(20, "access-control-allow-origin", "");
add(21, "age", "");
add(22, "allow", "");
add(23, "authorization", "");
add(24, "cache-control", "");
add(25, "content-disposition", "");
add(26, "content-encoding", "");
add(27, "content-language", "");
add(28, "content-length", "");
add(29, "content-location", "");
add(30, "content-range", "");
add(31, "content-type", "");
add(32, "cookie", "");
add(33, "date", "");
add(34, "etag", "");
add(35, "expect", "");
add(36, "expires", "");
add(37, "from", "");
add(38, "host", "");
add(39, "if-match", "");
add(40, "if-modified-since", "");
add(41, "if-none-match", "");
add(42, "if-range", "");
add(43, "if-unmodified-since", "");
add(44, "last-modified", "");
add(45, "link", "");
add(46, "location", "");
add(47, "max-forwards", "");
add(48, "proxy-authenticate", "");
add(49, "proxy-authorization", "");
add(50, "range", "");
add(51, "referer", "");
add(52, "refresh", "");
add(53, "retry-after", "");
add(54, "server", "");
add(55, "set-cookie", "");
add(56, "strict-transport-security", "");
add(57, "transfer-encoding", "");
add(58, "user-agent", "");
add(59, "vary", "");
add(60, "via", "");
add(61, "www-authenticate", "");
for (int i = LENGTH; i >= 1; i--) {
put(NAME_INDEX, hash(NAMES[i]), i, false);
put(PAIR_INDEX, hashPair(NAMES[i], VALUES[i]), i, true);
}
}
private HpackStaticTable() {}
private static void add(int index, String name, String value) {
NAMES[index] = name.getBytes(StandardCharsets.US_ASCII);
VALUES[index] = value.getBytes(StandardCharsets.US_ASCII);
}
public static byte[] name(int index) {
checkIndex(index);
return NAMES[index];
}
public static byte[] value(int index) {
checkIndex(index);
return VALUES[index];
}
public static int findName(ByteView name) {
int slot = hash(name) & (NAME_INDEX.length - 1);
while (NAME_INDEX[slot] != 0) {
int index = NAME_INDEX[slot];
if (equals(name, NAMES[index])) return index;
slot = (slot + 1) & (NAME_INDEX.length - 1);
}
return 0;
}
public static int findPair(ByteView name, ByteView value) {
int slot = hashPair(name, value) & (PAIR_INDEX.length - 1);
while (PAIR_INDEX[slot] != 0) {
int index = PAIR_INDEX[slot];
if (equals(name, NAMES[index]) && equals(value, VALUES[index])) return index;
slot = (slot + 1) & (PAIR_INDEX.length - 1);
}
return 0;
}
private static void put(int[] table, int hash, int index, boolean pair) {
int slot = hash & (table.length - 1);
while (table[slot] != 0
&& !(equals(NAMES[index], NAMES[table[slot]])
&& (!pair || equals(VALUES[index], VALUES[table[slot]])))) {
slot = (slot + 1) & (table.length - 1);
}
table[slot] = index;
}
private static int hash(ByteView value) {
int hash = 0x811C9DC5;
for (int i = 0; i < value.length(); i++) hash = (hash ^ (value.byteAt(i) & 0xff)) * 0x01000193;
return hash;
}
private static int hash(byte[] value) {
int hash = 0x811C9DC5;
for (byte b : value) hash = (hash ^ (b & 0xff)) * 0x01000193;
return hash;
}
private static int hashPair(ByteView name, ByteView value) {
int hash = hash(name);
for (int i = 0; i < value.length(); i++) hash = (hash ^ (value.byteAt(i) & 0xff)) * 0x01000193;
return hash;
}
private static int hashPair(byte[] name, byte[] value) {
int hash = hash(name);
for (byte b : value) hash = (hash ^ (b & 0xff)) * 0x01000193;
return hash;
}
private static boolean equals(ByteView view, byte[] bytes) {
if (view.length() != bytes.length) return false;
for (int i = 0; i < bytes.length; i++) if (view.byteAt(i) != bytes[i]) return false;
return true;
}
private static boolean equals(byte[] left, byte[] right) {
return java.util.Arrays.equals(left, right);
}
private static void checkIndex(int index) {
if (index < 1 || index > LENGTH)
throw new IndexOutOfBoundsException("HPACK static index " + index);
}
}
@@ -0,0 +1,40 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class ContinuationAssemblerTest {
@Test
void assemblesContiguousBlock() {
ContinuationAssembler assembler = new ContinuationAssembler(16);
assembler.begin(3, "abc".getBytes(StandardCharsets.US_ASCII), 0, 3, false);
assembler.continuation(3, "def".getBytes(StandardCharsets.US_ASCII), 0, 3, true);
assertTrue(assembler.isComplete());
assertFalse(assembler.isActive());
assertEquals(
"abcdef", new String(assembler.buffer(), 0, assembler.length(), StandardCharsets.US_ASCII));
}
@Test
void rejectsInterleavingWrongStreamAndOversizedBlocks() {
ContinuationAssembler assembler = new ContinuationAssembler(4);
assembler.begin(1, new byte[] {1}, 0, 1, false);
assertThrows(Http2Exception.class, () -> assembler.begin(3, new byte[0], 0, 0, true));
assertThrows(Http2Exception.class, () -> assembler.continuation(3, new byte[0], 0, 0, true));
assertThrows(Http2Exception.class, () -> assembler.continuation(1, new byte[4], 0, 4, true));
}
@Test
void boundsContinuationCount() {
ContinuationAssembler assembler = new ContinuationAssembler(32);
assembler.begin(1, new byte[0], 0, 0, false);
for (int i = 0; i < Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK; i++) {
assembler.continuation(1, new byte[0], 0, 0, false);
}
assertThrows(Http2Exception.class, () -> assembler.continuation(1, new byte[0], 0, 0, false));
}
}
@@ -0,0 +1,39 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.fail;
import dev.relism.flash.http2.Http2Exception;
import org.junit.jupiter.api.Test;
class HpackDecoderFuzzTest {
private static final int CASES = 10_000_000;
private static final HeaderSink DISCARD = (name, value, never) -> {};
@Test
void tenMillionRandomBlocksOnlyProduceTypedRejections() {
HpackDecoder decoder = new HpackDecoder(256, 1024);
byte[] input = new byte[64];
long state = 0x7541_9113_C0DEL;
for (int iteration = 0; iteration < CASES; iteration++) {
state = next(state);
int length = (int) state & 63;
for (int i = 0; i < length; i++) {
state = next(state);
input[i] = (byte) state;
}
try {
decoder.decode(input, 0, length, DISCARD);
} catch (Http2Exception | HeaderListSizeException expected) {
// Typed protocol rejection.
} catch (Throwable unexpected) {
fail("unexpected failure at iteration " + iteration + ", length " + length, unexpected);
}
}
}
private static long next(long value) {
value ^= value << 13;
value ^= value >>> 7;
return value ^ (value << 17);
}
}
@@ -0,0 +1,68 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.Http2Exception;
import org.junit.jupiter.api.Test;
class HpackDecoderSecurityTest {
private static final HeaderSink DISCARD = (name, value, never) -> {};
@Test
void rejectsZeroAndOutOfRangeIndices() {
HpackDecoder decoder = new HpackDecoder();
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0x80}, 0, 1, DISCARD));
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0xff, 0}, 0, 2, DISCARD));
}
@Test
void rejectsLateAndOversizedTableUpdates() {
HpackDecoder decoder = new HpackDecoder(128, 1024);
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0x82, 0x20}, 0, 2, DISCARD));
ByteWriter update = new ByteWriter(8);
HpackIntegers.encode(update, 0x20, 5, 129);
assertThrows(
Http2Exception.class, () -> decoder.decode(update.array(), 0, update.length(), DISCARD));
}
@Test
void headerListLimitIsReportedOnlyAfterDynamicStateIsUpdated() {
HpackDecoder decoder = new HpackDecoder(256, 40);
byte[] block = java.util.HexFormat.of().parseHex("40016101624001630164");
HeaderListSizeException error =
assertThrows(
HeaderListSizeException.class, () -> decoder.decode(block, 0, block.length, DISCARD));
assertTrue(error.decodedSize() > 40);
assertEquals(2, decoder.dynamicTable().count());
}
@Test
void malformedStringsAndIntegerBombsAreCompressionErrors() {
HpackDecoder decoder = new HpackDecoder();
assertThrows(
Http2Exception.class, () -> decoder.decode(new byte[] {0x40, 0x01}, 0, 2, DISCARD));
byte[] bomb = {0x3f, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0x80};
assertThrows(Http2Exception.class, () -> decoder.decode(bomb, 0, bomb.length, DISCARD));
}
@Test
void indexedDynamicNameSurvivesEvictionDuringInsertion() {
HpackDecoder decoder = new HpackDecoder(48, 1024);
byte[] first = java.util.HexFormat.of().parseHex("4001610d31323334353637383930313233");
decoder.decode(first, 0, first.length, DISCARD);
// Dynamic index 62 supplies the name "a". Adding the new value evicts the referenced entry.
byte[] second = java.util.HexFormat.of().parseHex("7e0d6162636465666768696a6b6c6d");
decoder.decode(second, 0, second.length, DISCARD);
dev.relism.flash.bytes.PooledSlice name = new dev.relism.flash.bytes.PooledSlice();
dev.relism.flash.bytes.PooledSlice value = new dev.relism.flash.bytes.PooledSlice();
decoder.dynamicTable().get(1, name, value);
assertEquals('a', name.byteAt(0));
}
}
@@ -0,0 +1,162 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.fpr.core.ByteView;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HexFormat;
import java.util.List;
import org.junit.jupiter.api.Test;
class HpackDecoderTest {
private static final HexFormat HEX = HexFormat.of();
private static final class CollectingSink implements HeaderSink {
final List<String> fields = new ArrayList<>();
final List<Boolean> neverIndexed = new ArrayList<>();
@Override
public void accept(ByteView name, ByteView value, boolean never) {
fields.add(text(name) + ": " + text(value));
neverIndexed.add(never);
}
}
@Test
void appendixC2IndependentRepresentations() {
HpackDecoder decoder = new HpackDecoder();
CollectingSink sink = decode(decoder, "400a637573746f6d2d6b65790d637573746f6d2d686561646572");
assertEquals(List.of("custom-key: custom-header"), sink.fields);
assertDynamic(decoder, 1, "custom-key", "custom-header", 55);
decoder = new HpackDecoder();
sink = decode(decoder, "040c2f73616d706c652f70617468");
assertEquals(List.of(":path: /sample/path"), sink.fields);
assertEquals(0, decoder.dynamicTable().count());
sink = decode(decoder, "100870617373776f726406736563726574");
assertEquals(List.of("password: secret"), sink.fields);
assertEquals(List.of(true), sink.neverIndexed);
assertEquals(0, decoder.dynamicTable().count());
sink = decode(decoder, "82");
assertEquals(List.of(":method: GET"), sink.fields);
}
@Test
void appendixC3RequestsWithoutHuffman() {
verifyRequestSequence(
"828684410f7777772e6578616d706c652e636f6d",
"828684be58086e6f2d6361636865",
"828785bf400a637573746f6d2d6b65790c637573746f6d2d76616c7565");
}
@Test
void appendixC4RequestsWithHuffman() {
verifyRequestSequence(
"828684418cf1e3c2e5f23a6ba0ab90f4ff",
"828684be5886a8eb10649cbf",
"828785bf408825a849e95ba97d7f8925a849e95bb8e8b4bf");
}
@Test
void appendixC5ResponsesWithoutHuffman() {
verifyResponseSequence(
"4803333032580770726976617465611d4d6f6e2c203231204f637420323031332032303a31333a323120474d546e1768747470733a2f2f7777772e6578616d706c652e636f6d",
"4803333037c1c0bf",
"88c1611d4d6f6e2c203231204f637420323031332032303a31333a323220474d54c05a04677a69707738666f6f3d4153444a4b48514b425a584f5157454f50495541585157454f49553b206d61782d6167653d333630303b2076657273696f6e3d31");
}
@Test
void appendixC6ResponsesWithHuffman() {
verifyResponseSequence(
"488264025885aec3771a4b6196d07abe941054d444a8200595040b8166e082a62d1bff6e919d29ad171863c78f0b97c8e9ae82ae43d3",
"4883640effc1c0bf",
"88c16196d07abe941054d444a8200595040b8166e084a62d1bffc05a839bd9ab77ad94e7821dd7f2e6c7b335dfdfcd5b3960d5af27087f3672c1ab270fb5291f9587316065c003ed4ee5b1063d5007");
}
private static void verifyRequestSequence(String first, String second, String third) {
HpackDecoder decoder = new HpackDecoder();
assertEquals(
List.of(":method: GET", ":scheme: http", ":path: /", ":authority: www.example.com"),
decode(decoder, first).fields);
assertDynamic(decoder, 1, ":authority", "www.example.com", 57);
assertEquals(
List.of(
":method: GET",
":scheme: http",
":path: /",
":authority: www.example.com",
"cache-control: no-cache"),
decode(decoder, second).fields);
assertDynamic(decoder, 1, "cache-control", "no-cache", 110);
assertDynamic(decoder, 2, ":authority", "www.example.com", 110);
assertEquals(
List.of(
":method: GET",
":scheme: https",
":path: /index.html",
":authority: www.example.com",
"custom-key: custom-value"),
decode(decoder, third).fields);
assertDynamic(decoder, 1, "custom-key", "custom-value", 164);
assertDynamic(decoder, 2, "cache-control", "no-cache", 164);
assertDynamic(decoder, 3, ":authority", "www.example.com", 164);
}
private static void verifyResponseSequence(String first, String second, String third) {
HpackDecoder decoder = new HpackDecoder(256, 32_768);
assertEquals(responseFields("302", "21"), decode(decoder, first).fields);
assertDynamic(decoder, 1, "location", "https://www.example.com", 222);
assertDynamic(decoder, 4, ":status", "302", 222);
assertEquals(responseFields("307", "21"), decode(decoder, second).fields);
assertDynamic(decoder, 1, ":status", "307", 222);
assertDynamic(decoder, 4, "cache-control", "private", 222);
List<String> expected = new ArrayList<>(responseFields("200", "22"));
expected.add("content-encoding: gzip");
expected.add("set-cookie: foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1");
assertEquals(expected, decode(decoder, third).fields);
assertEquals(3, decoder.dynamicTable().count());
assertDynamic(
decoder, 1, "set-cookie", "foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1", 215);
assertDynamic(decoder, 2, "content-encoding", "gzip", 215);
assertDynamic(decoder, 3, "date", "Mon, 21 Oct 2013 20:13:22 GMT", 215);
}
private static List<String> responseFields(String status, String second) {
return List.of(
":status: " + status,
"cache-control: private",
"date: Mon, 21 Oct 2013 20:13:" + second + " GMT",
"location: https://www.example.com");
}
private static CollectingSink decode(HpackDecoder decoder, String hex) {
CollectingSink sink = new CollectingSink();
byte[] block = HEX.parseHex(hex);
decoder.decode(block, 0, block.length, sink);
return sink;
}
private static void assertDynamic(
HpackDecoder decoder, int index, String expectedName, String expectedValue, int size) {
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
decoder.dynamicTable().get(index, name, value);
assertEquals(expectedName, text(name));
assertEquals(expectedValue, text(value));
assertEquals(size, decoder.dynamicTable().size());
}
private static String text(ByteView value) {
byte[] bytes = new byte[value.length()];
for (int i = 0; i < bytes.length; i++) bytes[i] = value.byteAt(i);
return new String(bytes, StandardCharsets.US_ASCII);
}
}
@@ -0,0 +1,75 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http2.Http2Exception;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class HpackDynamicTableTest {
private static PooledSlice view(String text) {
byte[] bytes = text.getBytes(StandardCharsets.US_ASCII);
PooledSlice result = new PooledSlice();
result.reset(bytes, 0, bytes.length);
return result;
}
private static String text(PooledSlice value) {
return new String(value.array(), value.offset(), value.length(), StandardCharsets.US_ASCII);
}
@Test
void newestEntryHasLowestDynamicIndex() {
HpackDynamicTable table = new HpackDynamicTable(256);
table.add(view("a"), view("one"));
table.add(view("b"), view("two"));
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
table.get(1, name, value);
assertEquals("b", text(name));
assertEquals("two", text(value));
table.get(2, name, value);
assertEquals("a", text(name));
}
@Test
void evictsOldestEntriesByRfcSize() {
HpackDynamicTable table = new HpackDynamicTable(70);
table.add(view("a"), view("1")); // 34
table.add(view("b"), view("2")); // 34
table.add(view("c"), view("3")); // evicts a
assertEquals(2, table.count());
PooledSlice name = new PooledSlice();
table.get(2, name, new PooledSlice());
assertEquals("b", text(name));
}
@Test
void oversizedEntryClearsTableWithoutInsertion() {
HpackDynamicTable table = new HpackDynamicTable(40);
table.add(view("a"), view("1"));
table.add(view("long-name"), view("long-value"));
assertEquals(0, table.count());
assertEquals(0, table.size());
}
@Test
void sizeUpdateCannotExceedAdvertisedMaximum() {
HpackDynamicTable table = new HpackDynamicTable(128);
assertThrows(Http2Exception.class, () -> table.setMaximumSize(129));
table.setMaximumSize(0);
assertEquals(0, table.count());
}
@Test
void compactionPreservesLiveEntries() {
HpackDynamicTable table = new HpackDynamicTable(96);
for (int i = 0; i < 30; i++) table.add(view("name" + i), view("v" + i));
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
table.get(1, name, value);
assertEquals("name29", text(name));
assertEquals("v29", text(value));
}
}
@@ -0,0 +1,87 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import java.nio.charset.StandardCharsets;
import java.util.HexFormat;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import org.junit.jupiter.api.Test;
class HpackEvictionRaceTest {
@Test
void perStreamCopySurvivesConcurrentDynamicTableEviction() throws Exception {
HpackDecoder decoder = new HpackDecoder(64, 1024);
HpackHeaderBlock stream = new HpackHeaderBlock(1024, 16);
byte[] first = HexFormat.of().parseHex("40046e616d650b66697273742d76616c7565");
decoder.decode(first, 0, first.length, stream);
assertField(stream, 0, "name", "first-value");
byte[] replacement =
HexFormat.of().parseHex("400a6f746865722d6e616d650c7365636f6e642d76616c7565");
CountDownLatch start = new CountDownLatch(1);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
@SuppressWarnings("unchecked")
Future<Void>[] readers = new Future[8];
for (int reader = 0; reader < readers.length; reader++) {
readers[reader] =
executor.submit(
() -> {
start.await();
for (int i = 0; i < 10_000; i++) {
assertField(stream, 0, "name", "first-value");
}
return null;
});
}
start.countDown();
for (int i = 0; i < 10_000; i++) {
decoder.decode(replacement, 0, replacement.length, (n, v, x) -> {});
}
for (Future<Void> reader : readers) reader.get();
}
assertField(stream, 0, "name", "first-value");
}
@Test
void directDynamicTableViewDemonstratesTheEvictionHazard() {
HpackDynamicTable table = new HpackDynamicTable(64);
PooledSlice firstName = view("name");
PooledSlice firstValue = view("first-value");
table.add(firstName, firstValue);
PooledSlice borrowedName = new PooledSlice();
PooledSlice borrowedValue = new PooledSlice();
table.get(1, borrowedName, borrowedValue);
String before = text(borrowedValue);
table.add(view("other-name"), view("second-value"));
table.add(view("other-name"), view("second-value"));
table.add(view("other-name"), view("second-value"));
assertNotEquals(before, text(borrowedValue));
}
private static void assertField(
HpackHeaderBlock block, int index, String expectedName, String expectedValue) {
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
block.get(index, name, value);
assertEquals(expectedName, text(name));
assertEquals(expectedValue, text(value));
}
private static PooledSlice view(String text) {
byte[] bytes = text.getBytes(StandardCharsets.US_ASCII);
PooledSlice view = new PooledSlice();
view.reset(bytes, 0, bytes.length);
return view;
}
private static String text(PooledSlice value) {
return new String(value.array(), value.offset(), value.length(), StandardCharsets.US_ASCII);
}
}
@@ -0,0 +1,42 @@
package dev.relism.flash.http2.hpack;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.PooledSlice;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class HpackStaticTableTest {
private static PooledSlice view(String value) {
byte[] bytes = value.getBytes(StandardCharsets.US_ASCII);
PooledSlice view = new PooledSlice();
view.reset(bytes, 0, bytes.length);
return view;
}
@Test
void containsAllRfcEntriesAndUsesOneBasedIndices() {
assertEquals(61, HpackStaticTable.LENGTH);
assertArrayEquals(":authority".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.name(1));
assertArrayEquals(
"gzip, deflate".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.value(16));
assertArrayEquals(
"www-authenticate".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.name(61));
assertThrows(IndexOutOfBoundsException.class, () -> HpackStaticTable.name(0));
assertThrows(IndexOutOfBoundsException.class, () -> HpackStaticTable.value(62));
}
@Test
void findNameReturnsLowestIndexForRepeatedNames() {
assertEquals(2, HpackStaticTable.findName(view(":method")));
assertEquals(8, HpackStaticTable.findName(view(":status")));
assertEquals(0, HpackStaticTable.findName(view("missing")));
}
@Test
void findPairMatchesExactBytes() {
assertEquals(2, HpackStaticTable.findPair(view(":method"), view("GET")));
assertEquals(14, HpackStaticTable.findPair(view(":status"), view("500")));
assertEquals(0, HpackStaticTable.findPair(view(":method"), view("get")));
}
}