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