feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10
@@ -62,6 +62,9 @@ jobs:
|
|||||||
-Dcurl.executable=/usr/bin/curl
|
-Dcurl.executable=/usr/bin/curl
|
||||||
-Dnghttp.executable=/usr/bin/nghttp
|
-Dnghttp.executable=/usr/bin/nghttp
|
||||||
-Dgrpcurl.executable=/tmp/grpcurl
|
-Dgrpcurl.executable=/tmp/grpcurl
|
||||||
|
-Djdk.tracePinnedThreads=full
|
||||||
|
-Pjmh
|
||||||
|
-Dflash.performance.gates=true
|
||||||
clean verify
|
clean verify
|
||||||
env:
|
env:
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# HTTP performance baselines
|
||||||
|
|
||||||
|
These numbers are regression controls, not cross-machine promises. They were measured on
|
||||||
|
2026-08-13 under Linux 6.12/KVM, six exposed cores of an AMD Ryzen 7 1700X, Temurin 21.0.11 and
|
||||||
|
JMH 1.37. CI uses short independent forks for allocation and sample latency so the sampling
|
||||||
|
harness does not contaminate `gc.alloc.rate.norm`.
|
||||||
|
|
||||||
|
## Gated hot paths
|
||||||
|
|
||||||
|
| Benchmark | B/op | p50 ns | p99 ns | p999 ns | CI p99 ceiling ns |
|
||||||
|
|---|---:|---:|---:|---:|---:|
|
||||||
|
| h1 parse and route | 0.022 | 540 | 33,472 | 60,822 | 45,000 |
|
||||||
|
| h2 pooled stream lifecycle | 0.010 | 530 | 2,138 | 37,724 | 2,900 |
|
||||||
|
| h2 response encoding | 0.004 | 210 | 993 | 14,626 | 1,350 |
|
||||||
|
| HPACK browser-request decode | 0.015 | 730 | 5,245 | 27,577 | 7,100 |
|
||||||
|
| HPACK typical-response encode | 0.003 | 180 | 620 | 12,025 | 850 |
|
||||||
|
| frame read/validate/discard | 0.006 | 70 | 1,999 | 90,508 | 2,700 |
|
||||||
|
|
||||||
|
The sub-byte allocation values occur with no collection and are JMH/GC-profiler rate
|
||||||
|
normalization noise. The CI allocation ceiling is 0.05 B/op. A benchmark exceeding it fails; a
|
||||||
|
baseline or ceiling change requires an explicit edit and justification here.
|
||||||
|
|
||||||
|
The table records the higher percentile observed across three consecutive controlled runs; this is
|
||||||
|
important because short sample-mode runs on the shared KVM host showed visible scheduler noise.
|
||||||
|
The p999 values expose those tails but are recorded rather than gated. The p99 ceilings are the
|
||||||
|
worst observed p99 plus about 35% headroom.
|
||||||
|
|
||||||
|
## HTTP/1 historical comparison
|
||||||
|
|
||||||
|
The plan required a pre-Phase-1 number, but no benchmark was committed at that point. Phase 17
|
||||||
|
reconstructed the current `RequestPipelineBenchmark.parseAndRoute` fixture against Phase 0 commit
|
||||||
|
`db6e4a4` in a detached worktree and ran both revisions on the same host and JVM:
|
||||||
|
|
||||||
|
| Revision | ns/op | B/op |
|
||||||
|
|---|---:|---:|
|
||||||
|
| Phase 0 (`db6e4a4`) | 976.195 ± 45.924 | 224.007 |
|
||||||
|
| Phase 17 | 1,024.602 ± 50.744 | 0.007 |
|
||||||
|
|
||||||
|
The hardened parser's mean is 5.0% higher and removes effectively all 224 B/op. The 99.9%
|
||||||
|
confidence intervals overlap (`930.271–1,022.120` ns for Phase 0 and `973.858–1,075.345` ns for
|
||||||
|
Phase 17), so this run does not establish a statistically significant latency regression. This is
|
||||||
|
an honest reconstruction, not a claim that an absent historical run existed. Phase 17 recovered
|
||||||
|
about 4.5% by having `RequestParser` populate `Http1HeaderMap`'s zero-copy index during the same
|
||||||
|
validated header pass instead of rescanning every line; all security checks remain in that path.
|
||||||
@@ -1100,3 +1100,45 @@ a second h2-only cleartext listener solely to satisfy a tool assumption.
|
|||||||
against that listener too.
|
against that listener too.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## DEC-35 — Separate live-stream admission from final-write ownership
|
||||||
|
|
||||||
|
**Context.** A stream becomes closed on the wire before the asynchronous serialized writer calls
|
||||||
|
back for its final batch. Counting that object as live rejects legal replacement streams; pooling
|
||||||
|
it before the callback lets the next stream mutate memory still referenced by the writer.
|
||||||
|
|
||||||
|
**Decision.** Detach a wire-closed stream from the primitive live table immediately before its
|
||||||
|
final batch is submitted, but retain the stream object until write completion. Bound the combined
|
||||||
|
live and detached population to twice `MAX_CONCURRENT_STREAMS`; output congestion therefore
|
||||||
|
remains bounded and eventually applies `REFUSED_STREAM` backpressure rather than growing memory.
|
||||||
|
|
||||||
|
**Consequence.** The peer can use all advertised live-stream slots while final writes drain, and
|
||||||
|
the callback always owns the correct object generation. The closed-stream tombstone is recorded
|
||||||
|
at detach time, so protocol error classification is unchanged.
|
||||||
|
|
||||||
|
**Revisit when.** If production traces show the two-generation object bound rejecting healthy
|
||||||
|
traffic, measure writer-drain latency first; increasing the bound without evidence would only hide
|
||||||
|
output backpressure.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## DEC-36 — Performance gates distinguish profiler noise, latency sampling, and load results
|
||||||
|
|
||||||
|
**Context.** JMH's sampling mode allocates bookkeeping records, so combining `Mode.SampleTime`
|
||||||
|
with `GCProfiler` falsely reports allocations on otherwise allocation-free operations. End-to-end
|
||||||
|
h2load results also show that Flash does not outperform the reference server, so the plan's
|
||||||
|
"unmatched" wording cannot honestly become a product claim.
|
||||||
|
|
||||||
|
**Decision.** Run two independent forked CI passes over the same six hot paths: average-time plus
|
||||||
|
`GCProfiler` for allocation, and sample-time without the allocation profiler for p50/p99/p999.
|
||||||
|
Treat up to 0.05 B/op with zero observed collections as the profiler's measurement floor. Gate
|
||||||
|
p99 with documented per-benchmark ceilings and keep h2load comparative results informational.
|
||||||
|
|
||||||
|
**Consequence.** CI detects real allocation and latency regressions without measuring its own
|
||||||
|
sampling machinery. Performance documentation reports Flash and nghttpd numbers directly and
|
||||||
|
makes no "unmatched" claim.
|
||||||
|
|
||||||
|
**Revisit when.** Recalibrate baselines deliberately on a controlled CI runner, or replace the
|
||||||
|
noise floor if a profiler can distinguish harness allocation from benchmark allocation exactly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
|
|||||||
| 14 — h2c prior knowledge + proxy support | done | `feature/core/http2` | Independent TLS/h2c gates, pooled proxy-oriented h2 client with TLS ALPN and h2c, bidirectional h1/h2 trailer relay, shared four-direction hop-by-hop policy and certificate-backed 421 handling complete. Real grpcurl h2c interop passes. 670/670 tests green from a clean `-Pjmh` build. |
|
| 14 — h2c prior knowledge + proxy support | done | `feature/core/http2` | Independent TLS/h2c gates, pooled proxy-oriented h2 client with TLS ALPN and h2c, bidirectional h1/h2 trailer relay, shared four-direction hop-by-hop policy and certificate-backed 421 handling complete. Real grpcurl h2c interop passes. 670/670 tests green from a clean `-Pjmh` build. |
|
||||||
| 15 — RFC 8441 extended CONNECT (WS over h2) | done | `feature/core/http2` | SETTINGS_ENABLE_CONNECT_PROTOCOL, shared WS router/session, DATA flow control, >1 MiB message, h1/h2 parity and lifecycle hardening complete. EX-52/53 fixed; DEC-32 recorded. 675/675 tests green from a clean `-Pjmh` build; real grpcurl interop remains green. |
|
| 15 — RFC 8441 extended CONNECT (WS over h2) | done | `feature/core/http2` | SETTINGS_ENABLE_CONNECT_PROTOCOL, shared WS router/session, DATA flow control, >1 MiB message, h1/h2 parity and lifecycle hardening complete. EX-52/53 fixed; DEC-32 recorded. 675/675 tests green from a clean `-Pjmh` build; real grpcurl interop remains green. |
|
||||||
| 16 — Compliance test suite | done | `feature/core/http2` | h2spec 2.6.0: TLS 146/146 and mixed-port h2c 145/145 applicable cases, zero skips/failures; invalid-preface protocol boundary documented and regression-tested. Deterministic bounded fuzz targets, exact wire corpus, 1,000-stream single-connection test, nightly 10-minute soak, curl/nghttp/Java/grpcurl matrix and release-browser checklist complete. EX-54–56 fixed; DEC-33/34 recorded. Clean `-Pjmh` gate: 690 tests, 0 failures/errors, 1 intentional conditional soak skip. |
|
| 16 — Compliance test suite | done | `feature/core/http2` | h2spec 2.6.0: TLS 146/146 and mixed-port h2c 145/145 applicable cases, zero skips/failures; invalid-preface protocol boundary documented and regression-tested. Deterministic bounded fuzz targets, exact wire corpus, 1,000-stream single-connection test, nightly 10-minute soak, curl/nghttp/Java/grpcurl matrix and release-browser checklist complete. EX-54–56 fixed; DEC-33/34 recorded. Clean `-Pjmh` gate: 690 tests, 0 failures/errors, 1 intentional conditional soak skip. |
|
||||||
| 17 — Benchmarks, allocation gates, tuning | not started | — | — |
|
| 17 — Benchmarks, allocation gates, tuning | done | `feature/core/http2` | Forked JMH allocation and true sampled-p99 gates wired into CI; h1/h2/frame/HPACK/body/multiplexing/writer coverage complete. Reconstructed Phase-0 h1 baseline: 1,024.602 ns now vs 976.195 ns then with overlapping 99.9% CIs, and 0.007 vs 224.007 B/op. h2load matrix against nghttpd recorded honestly (no unmatched claim); tuning and async-profiler CPU/allocation/lock pass documented. EX-57/58 and DEC-35/36 recorded. Clean pinned-thread build: 694 tests, zero failures/errors, eight intentional conditional skips. |
|
||||||
| 18 — Documentation | not started | — | — |
|
| 18 — Documentation | not started | — | — |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -855,6 +855,25 @@ connection `PROTOCOL_ERROR`. **Fix**: preface verification now distinguishes mat
|
|||||||
and invalid input; invalid input sends GOAWAY. The exact 24 bytes are in the regression corpus.
|
and invalid input; invalid input sends GOAWAY. The exact 24 bytes are in the regression corpus.
|
||||||
**Phase**: 16.
|
**Phase**: 16.
|
||||||
|
|
||||||
|
### EX-57 — Wire-closed streams occupied the live concurrency table until their final write callback
|
||||||
|
|
||||||
|
Found by the Phase 17 h2load matrix at the advertised 64-stream concurrency. A response stream
|
||||||
|
could be closed in protocol state while its final immutable write batch was still owned by the
|
||||||
|
serialized writer. Keeping that object in the live table made a legal replacement stream receive
|
||||||
|
`REFUSED_STREAM`; recycling it immediately would instead corrupt the pending write callback.
|
||||||
|
**Fix**: detach a closed stream from live lookup before submitting its final batch, retain bounded
|
||||||
|
object ownership until the callback, and cap live plus detached objects at twice the advertised
|
||||||
|
live capacity. The regression test fills a one-entry table, detaches its final generation, admits
|
||||||
|
the next stream, and proves both objects return to the pool. **Phase**: 17.
|
||||||
|
|
||||||
|
### EX-58 — The upstream HTTP/2 client left Nagle enabled on synchronous exchanges
|
||||||
|
|
||||||
|
Found while building the Phase 17 end-to-end benchmark. The proxy-oriented client sends small
|
||||||
|
request and control frames and then synchronously waits for the response; with Nagle enabled this
|
||||||
|
interacted with delayed ACKs and added roughly 40 ms to a local exchange. **Fix**: configure
|
||||||
|
`TCP_NODELAY` on both cleartext and TLS sockets before protocol exchange. A socket-option
|
||||||
|
regression test covers the shared configuration method. **Phase**: 17.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
# PART III — The phases
|
# PART III — The phases
|
||||||
@@ -3122,11 +3141,14 @@ decisions and the rejected ones. Every claim in the project's marketing about pe
|
|||||||
be traceable to a number in this file.
|
be traceable to a number in this file.
|
||||||
|
|
||||||
### DoD
|
### DoD
|
||||||
- [ ] Allocation gates green in CI and wired to fail the build.
|
- [x] Allocation gates green in CI and wired to fail the build.
|
||||||
- [ ] Latency baselines recorded.
|
- [x] Latency baselines recorded in `BASELINES.md`; CI reads JMH's actual `p0.99` secondary
|
||||||
- [ ] h1 performance is not worse than the pre-Phase-1 baseline.
|
result, not iteration-mean statistics.
|
||||||
- [ ] No carrier pinning anywhere.
|
- [x] h1 performance is not statistically worse than the reconstructed pre-Phase-1 baseline:
|
||||||
- [ ] `flash/docs/http2/PERFORMANCE.md` complete with the comparison against a reference server.
|
the 99.9% confidence intervals overlap, while allocation falls from 224.007 to 0.007 B/op.
|
||||||
|
- [x] No carrier pinning anywhere — full 694-test clean run with
|
||||||
|
`-Djdk.tracePinnedThreads=full`, zero pinning events.
|
||||||
|
- [x] `flash/docs/http2/PERFORMANCE.md` complete with the comparison against nghttpd.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# HTTP/2 performance
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
Measurements were taken on 2026-08-13 under Linux 6.12/KVM with six exposed AMD Ryzen 7 1700X
|
||||||
|
cores, Temurin 21.0.11, JMH 1.37 and nghttp2 1.59.0. JMH component benchmarks use prepared,
|
||||||
|
reusable protocol state and forked JVMs. `h2load` exercises the real cleartext server on loopback;
|
||||||
|
Flash and nghttpd run on the same host in alternating order. Results are snapshots, not promises
|
||||||
|
for different hardware.
|
||||||
|
|
||||||
|
No "unmatched throughput" claim is supported. nghttpd is normally faster in this matrix; Flash's
|
||||||
|
numbers include framework routing, request-model assembly and handler dispatch that the static
|
||||||
|
reference server does not.
|
||||||
|
|
||||||
|
## Component results
|
||||||
|
|
||||||
|
The CI-controlled allocation and percentile numbers are in `BASELINES.md`. Additional average
|
||||||
|
time measurements from the same run were:
|
||||||
|
|
||||||
|
| Scenario | Result |
|
||||||
|
|---|---:|
|
||||||
|
| h2 responses across 1 live stream | 74.405 ns |
|
||||||
|
| h2 responses across 8 live streams | 705.368 ns |
|
||||||
|
| h2 responses across 64 live streams | 5,591.368 ns |
|
||||||
|
| h2 responses across 256 live streams | 28,961.681 ns |
|
||||||
|
| h2 POST lifecycle with 1 KiB DATA | 741.031 ns, 0.005 B/op |
|
||||||
|
| 1 MiB streaming response | 78,681.815 ns |
|
||||||
|
|
||||||
|
The multiplexing benchmark reports one complete response-encoding pass across all live streams,
|
||||||
|
not per-stream time. `Http2BodyBenchmark` separately covers the 1 KiB request-body shape and the
|
||||||
|
1 MiB response shape. `FrameWriterBenchmark` retains the Phase 3 contention matrix and its
|
||||||
|
per-write latency distribution.
|
||||||
|
|
||||||
|
## End-to-end h2load comparison
|
||||||
|
|
||||||
|
Each row uses at least 1,000 requests. Requested stream concurrency is capped first by Flash's
|
||||||
|
advertised 64-stream setting and then to 4,096 aggregate active streams so the 1,000-connection
|
||||||
|
rows remain bounded. Both requested and effective values are shown.
|
||||||
|
|
||||||
|
| Connections | Requested/effective streams | Flash req/s | nghttpd req/s |
|
||||||
|
|---:|---:|---:|---:|
|
||||||
|
| 1 | 1 / 1 | 2,136.18 | 12,786.42 |
|
||||||
|
| 1 | 10 / 10 | 18,396.56 | 83,521.26 |
|
||||||
|
| 1 | 100 / 64 | 17,039.55 | 66,746.76 |
|
||||||
|
| 10 | 1 / 1 | 11,247.08 | 37,838.66 |
|
||||||
|
| 10 | 10 / 10 | 25,055.12 | 104,964.84 |
|
||||||
|
| 10 | 100 / 64 | 3,878.28 | 67,303.81 |
|
||||||
|
| 100 | 1 / 1 | 5,517.94 | 42,319.09 |
|
||||||
|
| 100 | 10 / 10 | 1,818.52 | 26,732.25 |
|
||||||
|
| 100 | 100 / 40 | 7,042.85 | 136,585.90 |
|
||||||
|
| 1,000 | 1 / 1 | 1,393.17 | 3,877.62 |
|
||||||
|
| 1,000 | 10 / 4 | 10,374.83 | 40,976.05 |
|
||||||
|
| 1,000 | 100 / 4 | 49,622.10 | 85,344.40 |
|
||||||
|
|
||||||
|
The matrix found a correctness issue before it produced these final numbers: closed streams still
|
||||||
|
occupied live admission slots while their final write callback was pending. The bounded detach
|
||||||
|
fix is recorded as EX-57 and covered by regression tests.
|
||||||
|
|
||||||
|
## Tuning decisions
|
||||||
|
|
||||||
|
| Knob | Measurement | Decision |
|
||||||
|
|---|---|---|
|
||||||
|
| 16 KiB / 64 KiB / 1 MiB response frame | 1 MiB stream: 104,071 / 97,996 / 98,769 ns in the non-Huffman sweep | Keep 16 KiB. The roughly 6% gain at 64 KiB does not justify 4x per-connection buffer exposure on this noisy host. |
|
||||||
|
| 1 MiB initial receive window | 100 MiB Phase 11 transfer and the load matrix complete without flow stalls | Keep; it matches bounded receive capacity and changing it independently would not isolate a throughput claim. |
|
||||||
|
| half-window WINDOW_UPDATE hysteresis | 1 MiB streaming and 100 MiB transfer complete with steady pooled reads | Keep; no per-frame update traffic and no demonstrated reason to weaken backpressure. |
|
||||||
|
| 64 KiB inline body | 1 KiB inline materialization is one 1,040 B allocation; streaming steady state is ≈0 B/op | Keep the explicit one-array small-body tradeoff and stream larger bodies. |
|
||||||
|
| 64 × 16 KiB DATA buffers | 1 MiB streaming is 78,682 ns with ≈0 B/op; h2load stays bounded | Keep; larger chunks did not produce a clear win beyond the frame-size sweep. |
|
||||||
|
| `ScratchPool` bound | 64 objects per exposed CPU, capped at 4,096; full 1,000-connection matrix completes | Keep the capacity bound; it affects retained burst memory, not steady-state request instructions. |
|
||||||
|
| word-at-a-time route compare | 14.289 ns versus 22.313 ns bytewise, 36.0% faster | Keep. |
|
||||||
|
| SWAR header-end scan | 89.919 ns versus 128.460 ns scalar, 30.0% faster | Keep. |
|
||||||
|
| `SlicePool` size 4 | Header/path/query view benchmarks remain allocation-free | Keep; size changes lifetime capacity, not lookup work, and four simultaneous borrowed views match the documented contract. |
|
||||||
|
| runtime-value Huffman | representative response headers: 375.761 ns versus 180.129 ns at 16 KiB | Keep disabled by default; this header set is 109% slower to encode. |
|
||||||
|
|
||||||
|
The frame-size/Huffman factorial produced counterintuitive variation in the body-only rows, so it
|
||||||
|
was not used to claim a Huffman body effect: Huffman only prepares headers. This is treated as
|
||||||
|
host noise rather than reverse-engineered into a preferred result.
|
||||||
|
|
||||||
|
## Profiling
|
||||||
|
|
||||||
|
async-profiler 4.4 was run against the representative browser HPACK decode. The top CPU leaves
|
||||||
|
were `Huffman.decode` (72.67%), `HpackHeaderBlock.accept` (7.00%), `HpackDecoder.decode` (5.00%),
|
||||||
|
JVM byte-array copy (4.00%), `HpackHeaderBlock.copy` (4.00%), `HpackStaticTable.name` (2.00%),
|
||||||
|
`PooledSlice.reset` (1.00%), `PooledSlice.array` (0.67%), JVM byte-arraycopy (0.67%), and
|
||||||
|
`HpackDecoder.decodeString` (0.67%). Each belongs to decoding, bounded arena ownership, or the
|
||||||
|
copy that makes header lifetime independent of dynamic-table eviction; none is incidental
|
||||||
|
locking or logging.
|
||||||
|
|
||||||
|
The allocation profile produced no samples on the gated decode path. The realistic eight-writer
|
||||||
|
lock profile produced no sampled contended locks; the writer benchmark measured 185.605 bursts/s,
|
||||||
|
p50 1.2 µs, p99 5.3 µs and p999 41.6 µs. CPU, allocation and lock artifacts were generated under
|
||||||
|
`/tmp/phase17-*` and are intentionally not committed.
|
||||||
|
|
||||||
|
CI runs the complete suite with `-Djdk.tracePinnedThreads=full`. The forked allocation and p99
|
||||||
|
gates run only under the Maven `jmh` profile; the h2load comparison remains informational and
|
||||||
|
conditional because cross-runner throughput is not a stable correctness gate.
|
||||||
|
|
||||||
|
The reconstructed Phase-0 HTTP/1 comparison is documented in `BASELINES.md`. Its confidence
|
||||||
|
interval overlaps the Phase-17 result, while normalized allocation falls from 224.007 B/op to
|
||||||
|
0.007 B/op.
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.Map;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||||
|
import org.openjdk.jmh.annotations.Mode;
|
||||||
|
import org.openjdk.jmh.profile.GCProfiler;
|
||||||
|
import org.openjdk.jmh.results.Result;
|
||||||
|
import org.openjdk.jmh.results.RunResult;
|
||||||
|
import org.openjdk.jmh.runner.Runner;
|
||||||
|
import org.openjdk.jmh.runner.options.ChainedOptionsBuilder;
|
||||||
|
import org.openjdk.jmh.runner.options.Options;
|
||||||
|
import org.openjdk.jmh.runner.options.OptionsBuilder;
|
||||||
|
import org.openjdk.jmh.runner.options.TimeValue;
|
||||||
|
|
||||||
|
/** Short, forked JMH gates used by CI; full publication runs retain each benchmark's annotations. */
|
||||||
|
@EnabledIfSystemProperty(named = "flash.performance.gates", matches = "true")
|
||||||
|
class PerformanceGateTest {
|
||||||
|
private static final double ALLOCATION_NOISE_FLOOR = 0.05;
|
||||||
|
private static final String INCLUDE =
|
||||||
|
"(RequestPipelineBenchmark.parseAndRoute"
|
||||||
|
+ "|Http2StreamBenchmark.lifecycle"
|
||||||
|
+ "|Http2ResponseWriterBenchmark.encodeResponse"
|
||||||
|
+ "|HpackDecoderBenchmark.decodeTypicalBrowserRequest"
|
||||||
|
+ "|HpackEncoderBenchmark.encodeTypicalResponse"
|
||||||
|
+ "|FrameLayerBenchmark.readValidateAndDiscard)";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void allocationAndLatencyBaselinesHold() throws Exception {
|
||||||
|
Collection<RunResult> allocationResults = new Runner(allocationOptions()).run();
|
||||||
|
assertFalse(allocationResults.isEmpty(), "JMH did not discover the allocation gates");
|
||||||
|
for (RunResult run : allocationResults) {
|
||||||
|
String benchmark = shortName(run.getParams().getBenchmark());
|
||||||
|
Result<?> allocation = run.getSecondaryResults().get("gc.alloc.rate.norm");
|
||||||
|
assertTrue(allocation != null, "missing allocation measurement for " + benchmark);
|
||||||
|
assertTrue(
|
||||||
|
allocation.getScore() <= ALLOCATION_NOISE_FLOOR,
|
||||||
|
() -> benchmark + " allocated " + allocation.getScore() + " B/op");
|
||||||
|
}
|
||||||
|
|
||||||
|
Collection<RunResult> latencyResults = new Runner(latencyOptions()).run();
|
||||||
|
assertFalse(latencyResults.isEmpty(), "JMH did not discover the latency gates");
|
||||||
|
for (RunResult run : latencyResults) {
|
||||||
|
String benchmark = shortName(run.getParams().getBenchmark());
|
||||||
|
Double maximumNanos = MAXIMUM_P99_NANOS.get(benchmark);
|
||||||
|
assertTrue(maximumNanos != null, "missing latency baseline for " + benchmark);
|
||||||
|
Result<?> p99 = run.getSecondaryResults().get("p0.99");
|
||||||
|
assertTrue(p99 != null, "missing p99 measurement for " + benchmark);
|
||||||
|
double score = p99.getScore();
|
||||||
|
assertTrue(
|
||||||
|
score <= maximumNanos,
|
||||||
|
() -> benchmark + " p99 regressed to " + score + " ns/op; gate is " + maximumNanos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Options allocationOptions() {
|
||||||
|
return commonOptions()
|
||||||
|
.mode(Mode.AverageTime)
|
||||||
|
.addProfiler(GCProfiler.class)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Options latencyOptions() {
|
||||||
|
return commonOptions().mode(Mode.SampleTime).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ChainedOptionsBuilder commonOptions() {
|
||||||
|
return new OptionsBuilder()
|
||||||
|
.include(INCLUDE)
|
||||||
|
.warmupIterations(2)
|
||||||
|
.warmupTime(TimeValue.milliseconds(250))
|
||||||
|
.measurementIterations(3)
|
||||||
|
.measurementTime(TimeValue.milliseconds(350))
|
||||||
|
.forks(1)
|
||||||
|
.shouldFailOnError(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String shortName(String benchmark) {
|
||||||
|
return benchmark.substring(benchmark.lastIndexOf('.') + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filled from the controlled baseline run documented in BASELINES.md, with 35% CI headroom.
|
||||||
|
private static final Map<String, Double> MAXIMUM_P99_NANOS =
|
||||||
|
Map.of(
|
||||||
|
"parseAndRoute", 45_000.0,
|
||||||
|
"lifecycle", 2_900.0,
|
||||||
|
"encodeResponse", 1_350.0,
|
||||||
|
"decodeTypicalBrowserRequest", 7_100.0,
|
||||||
|
"encodeTypicalResponse", 850.0,
|
||||||
|
"readValidateAndDiscard", 2_700.0);
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package dev.relism.flash.http2.hpack;
|
package dev.relism.flash.http2.hpack;
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import org.openjdk.jmh.annotations.Level;
|
||||||
|
import org.openjdk.jmh.annotations.Setup;
|
||||||
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;
|
||||||
@@ -22,6 +26,25 @@ public class HpackDecoderBenchmark {
|
|||||||
private final HpackDecoder decoder = new HpackDecoder();
|
private final HpackDecoder decoder = new HpackDecoder();
|
||||||
private final HpackHeaderBlock headers = new HpackHeaderBlock();
|
private final HpackHeaderBlock headers = new HpackHeaderBlock();
|
||||||
private final byte[] block = {(byte) 0x82, (byte) 0x87, (byte) 0x84, (byte) 0x88};
|
private final byte[] block = {(byte) 0x82, (byte) 0x87, (byte) 0x84, (byte) 0x88};
|
||||||
|
private byte[] browserBlock;
|
||||||
|
|
||||||
|
@Setup(Level.Trial)
|
||||||
|
public void setupTypicalBlock() {
|
||||||
|
ByteWriter encoded = new ByteWriter(256);
|
||||||
|
HpackEncoder.writeIndexed(encoded, 2);
|
||||||
|
HpackEncoder.writeIndexed(encoded, 7);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
encoded, 4, "/products?category=books".getBytes(StandardCharsets.US_ASCII), true);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
encoded, 1, "shop.example.com".getBytes(StandardCharsets.US_ASCII), true);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
encoded, 19, "text/html,application/xhtml+xml".getBytes(StandardCharsets.US_ASCII), true);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
encoded, 16, "gzip, deflate".getBytes(StandardCharsets.US_ASCII), true);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
encoded, 55, "Mozilla/5.0 benchmark".getBytes(StandardCharsets.US_ASCII), true);
|
||||||
|
browserBlock = java.util.Arrays.copyOf(encoded.array(), encoded.length());
|
||||||
|
}
|
||||||
|
|
||||||
@Benchmark
|
@Benchmark
|
||||||
public int decodeStaticRequest() {
|
public int decodeStaticRequest() {
|
||||||
@@ -29,4 +52,11 @@ public class HpackDecoderBenchmark {
|
|||||||
decoder.decode(block, 0, block.length, headers);
|
decoder.decode(block, 0, block.length, headers);
|
||||||
return headers.count();
|
return headers.count();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public int decodeTypicalBrowserRequest() {
|
||||||
|
headers.reset();
|
||||||
|
decoder.decode(browserBlock, 0, browserBlock.length, headers);
|
||||||
|
return headers.count();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package dev.relism.flash.http2.hpack;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.openjdk.jmh.annotations.Benchmark;
|
||||||
|
import org.openjdk.jmh.annotations.BenchmarkMode;
|
||||||
|
import org.openjdk.jmh.annotations.Fork;
|
||||||
|
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 a representative stateless response header block. */
|
||||||
|
@State(Scope.Thread)
|
||||||
|
@BenchmarkMode(Mode.AverageTime)
|
||||||
|
@OutputTimeUnit(TimeUnit.NANOSECONDS)
|
||||||
|
@Fork(2)
|
||||||
|
@Warmup(iterations = 3, time = 1)
|
||||||
|
@Measurement(iterations = 5, time = 1)
|
||||||
|
public class HpackEncoderBenchmark {
|
||||||
|
private static final byte[] CONTENT_LENGTH = "1024".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
private static final byte[] CONTENT_TYPE = "application/json".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
private static final byte[] CACHE_CONTROL = "no-cache".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
private static final byte[] ETAG_NAME = "etag".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
private static final byte[] ETAG = "\"abc123\"".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
private static final byte[] SERVER = "Flash".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
private final ByteWriter output = new ByteWriter(128);
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public int encodeTypicalResponse() {
|
||||||
|
output.reset();
|
||||||
|
HpackEncoder.writeIndexed(output, 8);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(output, 31, CONTENT_TYPE, true);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(output, 28, CONTENT_LENGTH, false);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(output, 24, CACHE_CONTROL, true);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(output, 51, SERVER, true);
|
||||||
|
HpackEncoder.writeLiteral(output, ETAG_NAME, ETAG);
|
||||||
|
return output.length();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ public class Http2BodyBenchmark {
|
|||||||
private static final Http2RequestBody.ConsumptionListener NOOP = bytes -> {};
|
private static final Http2RequestBody.ConsumptionListener NOOP = bytes -> {};
|
||||||
|
|
||||||
private final byte[] payload = new byte[1024];
|
private final byte[] payload = new byte[1024];
|
||||||
|
private final byte[] streamingPayload = new byte[1024 * 1024];
|
||||||
private final byte[] target = new byte[1024];
|
private final byte[] target = new byte[1024];
|
||||||
private DataBufferPool pool;
|
private DataBufferPool pool;
|
||||||
private Http2RequestBody source;
|
private Http2RequestBody source;
|
||||||
@@ -35,6 +36,7 @@ public class Http2BodyBenchmark {
|
|||||||
private Response response;
|
private Response response;
|
||||||
private Http2ResponseWriter responseWriter;
|
private Http2ResponseWriter responseWriter;
|
||||||
private ResettableInputStream responseSource;
|
private ResettableInputStream responseSource;
|
||||||
|
private ResettableInputStream largeResponseSource;
|
||||||
|
|
||||||
@Setup(Level.Trial)
|
@Setup(Level.Trial)
|
||||||
public void setup() throws IOException {
|
public void setup() throws IOException {
|
||||||
@@ -44,6 +46,7 @@ public class Http2BodyBenchmark {
|
|||||||
response = new Response(200, ContentType.BINARY);
|
response = new Response(200, ContentType.BINARY);
|
||||||
responseWriter = new Http2ResponseWriter();
|
responseWriter = new Http2ResponseWriter();
|
||||||
responseSource = new ResettableInputStream(payload);
|
responseSource = new ResettableInputStream(payload);
|
||||||
|
largeResponseSource = new ResettableInputStream(streamingPayload);
|
||||||
source.begin(-1, false, NOOP);
|
source.begin(-1, false, NOOP);
|
||||||
source.offer(1, payload, 0, payload.length, payload.length);
|
source.offer(1, payload, 0, payload.length, payload.length);
|
||||||
source.finish(1);
|
source.finish(1);
|
||||||
@@ -76,6 +79,20 @@ public class Http2BodyBenchmark {
|
|||||||
return responseWriter.length();
|
return responseWriter.length();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public int streamingResponseOneMiB() throws IOException {
|
||||||
|
largeResponseSource.rewind();
|
||||||
|
response.reset(200, ContentType.BINARY).stream(largeResponseSource, streamingPayload.length);
|
||||||
|
responseWriter.startFlowControlled(
|
||||||
|
response, 1, false, false, true, false, false, 16_384, 32_768, 16_384);
|
||||||
|
int wireBytes = responseWriter.length();
|
||||||
|
while (!responseWriter.finished()) {
|
||||||
|
responseWriter.resume(16_384, 16_384);
|
||||||
|
wireBytes += responseWriter.length();
|
||||||
|
}
|
||||||
|
return wireBytes;
|
||||||
|
}
|
||||||
|
|
||||||
private static final class ResettableInputStream extends InputStream {
|
private static final class ResettableInputStream extends InputStream {
|
||||||
private final byte[] source;
|
private final byte[] source;
|
||||||
private int position;
|
private int position;
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package dev.relism.flash.http2.message;
|
||||||
|
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.models.PreEncodedHeader;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.openjdk.jmh.annotations.Benchmark;
|
||||||
|
import org.openjdk.jmh.annotations.BenchmarkMode;
|
||||||
|
import org.openjdk.jmh.annotations.Fork;
|
||||||
|
import org.openjdk.jmh.annotations.Level;
|
||||||
|
import org.openjdk.jmh.annotations.Measurement;
|
||||||
|
import org.openjdk.jmh.annotations.Mode;
|
||||||
|
import org.openjdk.jmh.annotations.OutputTimeUnit;
|
||||||
|
import org.openjdk.jmh.annotations.Param;
|
||||||
|
import org.openjdk.jmh.annotations.Scope;
|
||||||
|
import org.openjdk.jmh.annotations.Setup;
|
||||||
|
import org.openjdk.jmh.annotations.State;
|
||||||
|
import org.openjdk.jmh.annotations.Warmup;
|
||||||
|
|
||||||
|
/** Factorial measurements for the response knobs considered during tuning. */
|
||||||
|
@State(Scope.Thread)
|
||||||
|
@BenchmarkMode(Mode.AverageTime)
|
||||||
|
@OutputTimeUnit(TimeUnit.NANOSECONDS)
|
||||||
|
@Fork(2)
|
||||||
|
@Warmup(iterations = 3, time = 1)
|
||||||
|
@Measurement(iterations = 5, time = 1)
|
||||||
|
public class Http2TuningBenchmark {
|
||||||
|
@Param({"false", "true"})
|
||||||
|
public boolean huffmanDynamicValues;
|
||||||
|
|
||||||
|
@Param({"16384", "65536", "1048576"})
|
||||||
|
public int maxFrameSize;
|
||||||
|
|
||||||
|
private final byte[] largeBody = new byte[1024 * 1024];
|
||||||
|
private Http2ResponseWriter writer;
|
||||||
|
private Response response;
|
||||||
|
private Response streamingResponse;
|
||||||
|
private ResettableInputStream source;
|
||||||
|
|
||||||
|
@Setup(Level.Trial)
|
||||||
|
public void setup() {
|
||||||
|
writer = new Http2ResponseWriter();
|
||||||
|
response =
|
||||||
|
new Response(200, "hello", ContentType.JSON)
|
||||||
|
.header(new PreEncodedHeader("cache-control", "private, max-age=60"))
|
||||||
|
.header(new PreEncodedHeader("x-request-id", "d7bca219-6dd4-4ef0-a881-f21931e249c7"));
|
||||||
|
source = new ResettableInputStream(largeBody);
|
||||||
|
streamingResponse = new Response(200, ContentType.BINARY).stream(source, largeBody.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public int encodeResponseHeaders() {
|
||||||
|
writer.prepare(
|
||||||
|
response,
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
huffmanDynamicValues,
|
||||||
|
false,
|
||||||
|
maxFrameSize,
|
||||||
|
32_768,
|
||||||
|
65_535);
|
||||||
|
return writer.length();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public int streamOneMiB() throws IOException {
|
||||||
|
source.rewind();
|
||||||
|
writer.startFlowControlled(
|
||||||
|
streamingResponse,
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
huffmanDynamicValues,
|
||||||
|
false,
|
||||||
|
maxFrameSize,
|
||||||
|
32_768,
|
||||||
|
maxFrameSize);
|
||||||
|
int wireBytes = writer.length();
|
||||||
|
while (!writer.finished()) {
|
||||||
|
writer.resume(maxFrameSize, maxFrameSize);
|
||||||
|
wireBytes += writer.length();
|
||||||
|
}
|
||||||
|
return wireBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ResettableInputStream extends InputStream {
|
||||||
|
private final byte[] bytes;
|
||||||
|
private int position;
|
||||||
|
|
||||||
|
private ResettableInputStream(byte[] bytes) {
|
||||||
|
this.bytes = bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rewind() {
|
||||||
|
position = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() {
|
||||||
|
return position == bytes.length ? -1 : bytes[position++] & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read(byte[] target, int offset, int length) {
|
||||||
|
if (position == bytes.length) return -1;
|
||||||
|
int count = Math.min(length, bytes.length - position);
|
||||||
|
System.arraycopy(bytes, position, target, offset, count);
|
||||||
|
position += count;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package dev.relism.flash.http2.stream;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackDecoder;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackEncoder;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.openjdk.jmh.annotations.Benchmark;
|
||||||
|
import org.openjdk.jmh.annotations.BenchmarkMode;
|
||||||
|
import org.openjdk.jmh.annotations.Fork;
|
||||||
|
import org.openjdk.jmh.annotations.Level;
|
||||||
|
import org.openjdk.jmh.annotations.Measurement;
|
||||||
|
import org.openjdk.jmh.annotations.Mode;
|
||||||
|
import org.openjdk.jmh.annotations.OutputTimeUnit;
|
||||||
|
import org.openjdk.jmh.annotations.Param;
|
||||||
|
import org.openjdk.jmh.annotations.Scope;
|
||||||
|
import org.openjdk.jmh.annotations.Setup;
|
||||||
|
import org.openjdk.jmh.annotations.State;
|
||||||
|
import org.openjdk.jmh.annotations.Warmup;
|
||||||
|
|
||||||
|
/** Measures one response pass across a connection with N simultaneously live request streams. */
|
||||||
|
@State(Scope.Thread)
|
||||||
|
@BenchmarkMode(Mode.AverageTime)
|
||||||
|
@OutputTimeUnit(TimeUnit.NANOSECONDS)
|
||||||
|
@Fork(2)
|
||||||
|
@Warmup(iterations = 3, time = 1)
|
||||||
|
@Measurement(iterations = 5, time = 1)
|
||||||
|
public class Http2MultiplexingBenchmark {
|
||||||
|
private static final byte[] BODY = "ok".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
|
||||||
|
@Param({"1", "8", "64", "256"})
|
||||||
|
public int liveStreams;
|
||||||
|
|
||||||
|
private Http2Stream[] streams;
|
||||||
|
|
||||||
|
@Setup(Level.Trial)
|
||||||
|
public void setup() {
|
||||||
|
Http2StreamTable table = new Http2StreamTable(liveStreams);
|
||||||
|
streams = new Http2Stream[liveStreams];
|
||||||
|
ByteWriter encoded = new ByteWriter(64);
|
||||||
|
HpackEncoder.writeIndexed(encoded, 2);
|
||||||
|
HpackEncoder.writeIndexed(encoded, 7);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
encoded, 4, "/get".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
encoded, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
HpackDecoder decoder = new HpackDecoder();
|
||||||
|
for (int i = 0; i < streams.length; i++) {
|
||||||
|
Http2Stream stream = table.acquire(i * 2 + 1);
|
||||||
|
decoder.decode(encoded.array(), 0, encoded.length(), stream.headerBlock());
|
||||||
|
stream.assembleRequest(null, null);
|
||||||
|
streams[i] = stream;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Benchmark
|
||||||
|
public int encodeAllLiveStreamResponses() {
|
||||||
|
int wireBytes = 0;
|
||||||
|
for (Http2Stream stream : streams) {
|
||||||
|
stream
|
||||||
|
.responseWriter()
|
||||||
|
.prepare(
|
||||||
|
stream.resetResponse().body(BODY),
|
||||||
|
stream.id(),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
16_384,
|
||||||
|
32_768,
|
||||||
|
65_535);
|
||||||
|
wireBytes += stream.responseWriter().length();
|
||||||
|
}
|
||||||
|
return wireBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,11 +26,14 @@ import org.openjdk.jmh.annotations.Warmup;
|
|||||||
@Measurement(iterations = 5, time = 1)
|
@Measurement(iterations = 5, time = 1)
|
||||||
public class Http2StreamBenchmark {
|
public class Http2StreamBenchmark {
|
||||||
private static final byte[] BODY = "pong".getBytes(StandardCharsets.US_ASCII);
|
private static final byte[] BODY = "pong".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
private static final byte[] POST_BODY = new byte[1024];
|
||||||
|
|
||||||
private Http2StreamTable streams;
|
private Http2StreamTable streams;
|
||||||
private HpackDecoder decoder;
|
private HpackDecoder decoder;
|
||||||
private byte[] requestBlock;
|
private byte[] requestBlock;
|
||||||
private int requestLength;
|
private int requestLength;
|
||||||
|
private byte[] postBlock;
|
||||||
|
private int postLength;
|
||||||
|
|
||||||
@Setup
|
@Setup
|
||||||
public void setup() {
|
public void setup() {
|
||||||
@@ -45,7 +48,19 @@ public class Http2StreamBenchmark {
|
|||||||
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
requestBlock = block.array();
|
requestBlock = block.array();
|
||||||
requestLength = block.length();
|
requestLength = block.length();
|
||||||
|
ByteWriter post = new ByteWriter(96);
|
||||||
|
HpackEncoder.writeIndexed(post, 3);
|
||||||
|
HpackEncoder.writeIndexed(post, 7);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
post, 4, "/ping".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
post, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
post, 28, "1024".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
postBlock = post.array();
|
||||||
|
postLength = post.length();
|
||||||
lifecycle();
|
lifecycle();
|
||||||
|
postOneKiB();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Benchmark
|
@Benchmark
|
||||||
@@ -62,4 +77,32 @@ public class Http2StreamBenchmark {
|
|||||||
streams.release(stream);
|
streams.release(stream);
|
||||||
return bytes;
|
return bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Unary request shape: HPACK decode, one 1 KiB DATA payload, assembly and fixed response. */
|
||||||
|
@Benchmark
|
||||||
|
public int postOneKiB() {
|
||||||
|
Http2Stream stream = streams.acquire(1);
|
||||||
|
decoder.decode(postBlock, 0, postLength, stream.headerBlock());
|
||||||
|
stream.prepareRequestBody(null, false);
|
||||||
|
stream.receiveData(POST_BODY, 0, POST_BODY.length, POST_BODY.length);
|
||||||
|
stream.finishRequestBody();
|
||||||
|
stream.assembleRequest(null, null);
|
||||||
|
stream
|
||||||
|
.responseWriter()
|
||||||
|
.prepare(
|
||||||
|
stream.resetResponse().body(BODY),
|
||||||
|
1,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
16_384,
|
||||||
|
32_768,
|
||||||
|
65_535);
|
||||||
|
int bytes = stream.responseWriter().length();
|
||||||
|
streams.remove(1);
|
||||||
|
streams.release(stream);
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,7 @@ public class RequestParser {
|
|||||||
boolean transferEncodingSeen = false;
|
boolean transferEncodingSeen = false;
|
||||||
boolean transferEncodingChunked = false;
|
boolean transferEncodingChunked = false;
|
||||||
int headerCount = 0;
|
int headerCount = 0;
|
||||||
|
headerMap.beginParsed(buffer, sectionStart, headerEndIdx);
|
||||||
|
|
||||||
while (current < headerEndIdx) {
|
while (current < headerEndIdx) {
|
||||||
// deprecates line folding and treating a folded continuation as part of the
|
// deprecates line folding and treating a folded continuation as part of the
|
||||||
@@ -232,6 +233,7 @@ public class RequestParser {
|
|||||||
if (lineEnd - valueStart > Http1Limits.MAX_HEADER_VALUE_LENGTH) {
|
if (lineEnd - valueStart > Http1Limits.MAX_HEADER_VALUE_LENGTH) {
|
||||||
throw new MalformedRequestException(431, "Header value exceeds " + Http1Limits.MAX_HEADER_VALUE_LENGTH + " bytes");
|
throw new MalformedRequestException(431, "Header value exceeds " + Http1Limits.MAX_HEADER_VALUE_LENGTH + " bytes");
|
||||||
}
|
}
|
||||||
|
headerMap.addParsed(current, colon - current, valueStart, lineEnd - valueStart);
|
||||||
|
|
||||||
if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "content-length")) {
|
if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "content-length")) {
|
||||||
// parseLong, which silently accepted "5abc" as 5 and "-1" as 1.
|
// parseLong, which silently accepted "5abc" as 5 and "-1" as 1.
|
||||||
@@ -270,8 +272,6 @@ public class RequestParser {
|
|||||||
if (!contentLengthSeen) contentLength = 0;
|
if (!contentLengthSeen) contentLength = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
headerMap.reset(buffer, sectionStart, headerEndIdx);
|
|
||||||
|
|
||||||
// ── Body / pipelining accounting ─────────────────────────────────────
|
// ── Body / pipelining accounting ─────────────────────────────────────
|
||||||
|
|
||||||
int bodyStart = headerEndIdx + 4;
|
int bodyStart = headerEndIdx + 4;
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
|
|||||||
if (!stream.beginResponseBatch()) {
|
if (!stream.beginResponseBatch()) {
|
||||||
throw new IllegalStateException("response batch already in flight");
|
throw new IllegalStateException("response batch already in flight");
|
||||||
}
|
}
|
||||||
|
detachFinalBatch(stream, responseWriter);
|
||||||
frameWriter.write(responseWriter);
|
frameWriter.write(responseWriter);
|
||||||
} catch (Exception failure) {
|
} catch (Exception failure) {
|
||||||
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure);
|
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure);
|
||||||
@@ -220,6 +221,7 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
|
|||||||
flowController.refundSend(stream, reserved - used);
|
flowController.refundSend(stream, reserved - used);
|
||||||
}
|
}
|
||||||
applyBatchTransition(stream, responseWriter);
|
applyBatchTransition(stream, responseWriter);
|
||||||
|
detachFinalBatch(stream, responseWriter);
|
||||||
frameWriter.write(responseWriter);
|
frameWriter.write(responseWriter);
|
||||||
} catch (Exception failure) {
|
} catch (Exception failure) {
|
||||||
stream.endResponseBatch();
|
stream.endResponseBatch();
|
||||||
@@ -270,16 +272,22 @@ final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
|
|||||||
}
|
}
|
||||||
if (stream.responseWriter().finished()) {
|
if (stream.responseWriter().finished()) {
|
||||||
if (stream.state() == Http2StreamState.CLOSED) {
|
if (stream.state() == Http2StreamState.CLOSED) {
|
||||||
streams.retire(stream, streamId);
|
if (!streams.retire(stream, streamId)) streams.release(stream);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
scheduleResume(stream);
|
scheduleResume(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void detachFinalBatch(Http2Stream stream, Http2ResponseWriter writer) {
|
||||||
|
if (writer.finished() && stream.state() == Http2StreamState.CLOSED) {
|
||||||
|
streams.detach(stream, stream.id());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) {
|
private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) {
|
||||||
int streamId = stream.id();
|
int streamId = stream.id();
|
||||||
if (!streams.removeIfSame(stream, streamId)) return;
|
if (!streams.removeIfSame(stream, streamId) && stream.id() != streamId) return;
|
||||||
if (cause != null) log.error("HTTP/2 stream {} failed", streamId, cause);
|
if (cause != null) log.error("HTTP/2 stream {} failed", streamId, cause);
|
||||||
try {
|
try {
|
||||||
stream.cancel();
|
stream.cancel();
|
||||||
|
|||||||
@@ -463,6 +463,7 @@ public final class Http2Client implements Closeable {
|
|||||||
if (!origin.secure) {
|
if (!origin.secure) {
|
||||||
Socket socket = new Socket();
|
Socket socket = new Socket();
|
||||||
socket.connect(new InetSocketAddress(origin.host, origin.port), CONNECT_TIMEOUT_MS);
|
socket.connect(new InetSocketAddress(origin.host, origin.port), CONNECT_TIMEOUT_MS);
|
||||||
|
configureLowLatency(socket);
|
||||||
return socket;
|
return socket;
|
||||||
}
|
}
|
||||||
SSLContext context;
|
SSLContext context;
|
||||||
@@ -473,6 +474,7 @@ public final class Http2Client implements Closeable {
|
|||||||
}
|
}
|
||||||
SSLSocket socket =
|
SSLSocket socket =
|
||||||
(SSLSocket) context.getSocketFactory().createSocket(origin.host, origin.port);
|
(SSLSocket) context.getSocketFactory().createSocket(origin.host, origin.port);
|
||||||
|
configureLowLatency(socket);
|
||||||
SSLParameters parameters = socket.getSSLParameters();
|
SSLParameters parameters = socket.getSSLParameters();
|
||||||
parameters.setApplicationProtocols(new String[] {"h2"});
|
parameters.setApplicationProtocols(new String[] {"h2"});
|
||||||
parameters.setEndpointIdentificationAlgorithm("HTTPS");
|
parameters.setEndpointIdentificationAlgorithm("HTTPS");
|
||||||
@@ -486,6 +488,10 @@ public final class Http2Client implements Closeable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void configureLowLatency(Socket socket) throws IOException {
|
||||||
|
socket.setTcpNoDelay(true);
|
||||||
|
}
|
||||||
|
|
||||||
private static final class Exchange {
|
private static final class Exchange {
|
||||||
private final int streamId;
|
private final int streamId;
|
||||||
private final MutableHeaderMap headers = new MutableHeaderMap();
|
private final MutableHeaderMap headers = new MutableHeaderMap();
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ public final class Http2StreamTable {
|
|||||||
private final Http2Stream[] values;
|
private final Http2Stream[] values;
|
||||||
private final int mask;
|
private final int mask;
|
||||||
private final int maxEntries;
|
private final int maxEntries;
|
||||||
|
private final int maxObjects;
|
||||||
private final int[] closedIds;
|
private final int[] closedIds;
|
||||||
private final byte[] closedKinds;
|
private final byte[] closedKinds;
|
||||||
private int size;
|
private int size;
|
||||||
@@ -42,6 +43,7 @@ public final class Http2StreamTable {
|
|||||||
values = new Http2Stream[capacity];
|
values = new Http2Stream[capacity];
|
||||||
mask = capacity - 1;
|
mask = capacity - 1;
|
||||||
this.maxEntries = maxEntries;
|
this.maxEntries = maxEntries;
|
||||||
|
maxObjects = maxEntries * 2;
|
||||||
this.dataBuffers = dataBuffers;
|
this.dataBuffers = dataBuffers;
|
||||||
closedIds = new int[maxEntries * 2];
|
closedIds = new int[maxEntries * 2];
|
||||||
closedKinds = new byte[closedIds.length];
|
closedKinds = new byte[closedIds.length];
|
||||||
@@ -69,7 +71,7 @@ public final class Http2StreamTable {
|
|||||||
free = stream.poolNext;
|
free = stream.poolNext;
|
||||||
stream.poolNext = null;
|
stream.poolNext = null;
|
||||||
} else {
|
} else {
|
||||||
if (created == maxEntries) return null;
|
if (created == maxObjects) return null;
|
||||||
stream = new Http2Stream(dataBuffers);
|
stream = new Http2Stream(dataBuffers);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
@@ -123,6 +125,13 @@ public final class Http2StreamTable {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Removes a wire-closed stream from live concurrency while retaining its in-flight buffer. */
|
||||||
|
public synchronized boolean detach(Http2Stream stream, int streamId) {
|
||||||
|
if (!removeIfSame(stream, streamId)) return false;
|
||||||
|
rememberClosed(streamId, CLOSED_NORMALLY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized void rememberReset(int streamId) {
|
public synchronized void rememberReset(int streamId) {
|
||||||
rememberClosed(streamId, CLOSED_BY_RESET);
|
rememberClosed(streamId, CLOSED_BY_RESET);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,12 +77,33 @@ public class Http1HeaderMap implements HeaderView {
|
|||||||
private Slice valueSlice;
|
private Slice valueSlice;
|
||||||
|
|
||||||
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
|
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
|
||||||
this.buffer = buffer;
|
beginParsed(buffer, sectionStart, sectionEnd);
|
||||||
this.sectionStart = sectionStart;
|
|
||||||
this.sectionEnd = sectionEnd;
|
|
||||||
buildIndex();
|
buildIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts an index populated by the request parser while it validates the same header lines.
|
||||||
|
* This avoids rescanning a validated section solely to recover offsets already known there.
|
||||||
|
*/
|
||||||
|
public void beginParsed(byte[] buffer, int sectionStart, int sectionEnd) {
|
||||||
|
this.buffer = buffer;
|
||||||
|
this.sectionStart = sectionStart;
|
||||||
|
this.sectionEnd = sectionEnd;
|
||||||
|
headerCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds one already-validated header to the current zero-copy index. */
|
||||||
|
public void addParsed(int nameOffset, int nameLength, int valueOffset, int valueLength) {
|
||||||
|
ensureIndexCapacity(headerCount + 1);
|
||||||
|
nameOffsets[headerCount] = nameOffset;
|
||||||
|
nameLengths[headerCount] = nameLength;
|
||||||
|
valueOffsets[headerCount] = valueOffset;
|
||||||
|
valueLengths[headerCount] = valueLength;
|
||||||
|
nameHashes[headerCount] =
|
||||||
|
ByteScan.hashNameIgnoreCaseAscii(buffer, nameOffset, nameLength);
|
||||||
|
headerCount++;
|
||||||
|
}
|
||||||
|
|
||||||
private void buildIndex() {
|
private void buildIndex() {
|
||||||
headerCount = 0;
|
headerCount = 0;
|
||||||
if (buffer == null) return;
|
if (buffer == null) return;
|
||||||
@@ -92,13 +113,7 @@ public class Http1HeaderMap implements HeaderView {
|
|||||||
int colon = findColon(i, lineEnd);
|
int colon = findColon(i, lineEnd);
|
||||||
if (colon != -1) {
|
if (colon != -1) {
|
||||||
int vs = skipSpaces(colon + 1, lineEnd);
|
int vs = skipSpaces(colon + 1, lineEnd);
|
||||||
ensureIndexCapacity(headerCount + 1);
|
addParsed(i, colon - i, vs, lineEnd - vs);
|
||||||
nameOffsets[headerCount] = i;
|
|
||||||
nameLengths[headerCount] = colon - i;
|
|
||||||
valueOffsets[headerCount] = vs;
|
|
||||||
valueLengths[headerCount] = lineEnd - vs;
|
|
||||||
nameHashes[headerCount] = ByteScan.hashNameIgnoreCaseAscii(buffer, i, colon - i);
|
|
||||||
headerCount++;
|
|
||||||
}
|
}
|
||||||
i = lineEnd + 2;
|
i = lineEnd + 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
@Tag("benchmark")
|
||||||
|
@EnabledIfSystemProperty(named = "h2load.executable", matches = ".+")
|
||||||
|
@EnabledIfSystemProperty(named = "nghttpd.executable", matches = ".+")
|
||||||
|
class H2LoadMeasurementTest {
|
||||||
|
private static final int[] CONNECTIONS = {1, 10, 100, 1_000};
|
||||||
|
private static final int[] STREAMS = {1, 10, 100};
|
||||||
|
private static final Pattern RATE = Pattern.compile("([0-9.]+) req/s");
|
||||||
|
private static final Pattern REQUESTS =
|
||||||
|
Pattern.compile("requests: (\\d+) total, .*? (\\d+) succeeded, (\\d+) failed");
|
||||||
|
|
||||||
|
private FlashApp app;
|
||||||
|
private Process reference;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stop() {
|
||||||
|
if (app != null) app.stop().join();
|
||||||
|
if (reference != null) reference.destroyForcibly();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void measureFlashAndNghttpdMatrix(@TempDir Path directory) throws Exception {
|
||||||
|
int flashPort = freePort();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.port(flashPort)
|
||||||
|
.http2CleartextEnabled(true)
|
||||||
|
.h2MaxStreamsCreatedPerInterval(Integer.MAX_VALUE)
|
||||||
|
.h2MaxStreamsPerConnection(0)
|
||||||
|
.build());
|
||||||
|
app.get("/index.html", (request, response) -> "flash-load");
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
int referencePort = freePort();
|
||||||
|
Files.writeString(directory.resolve("index.html"), "flash-load");
|
||||||
|
ProcessBuilder server =
|
||||||
|
new ProcessBuilder(
|
||||||
|
System.getProperty("nghttpd.executable"),
|
||||||
|
"--no-tls",
|
||||||
|
"--max-concurrent-streams=128",
|
||||||
|
"-d",
|
||||||
|
directory.toString(),
|
||||||
|
Integer.toString(referencePort));
|
||||||
|
applyLibraryPath(server);
|
||||||
|
reference = server.redirectErrorStream(true).start();
|
||||||
|
Thread.sleep(200);
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"implementation,connections,requested_streams,effective_streams,requests,requests_per_second");
|
||||||
|
for (int connections : CONNECTIONS) {
|
||||||
|
for (int streams : STREAMS) {
|
||||||
|
int requests = Math.max(1_000, connections * streams);
|
||||||
|
int effectiveStreams =
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(
|
||||||
|
Math.min(streams, Http2Limits.MAX_CONCURRENT_STREAMS),
|
||||||
|
4_096 / connections));
|
||||||
|
measure("flash", flashPort, connections, streams, effectiveStreams, requests);
|
||||||
|
measure("nghttpd", referencePort, connections, streams, effectiveStreams, requests);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void measure(
|
||||||
|
String implementation,
|
||||||
|
int port,
|
||||||
|
int connections,
|
||||||
|
int requestedStreams,
|
||||||
|
int effectiveStreams,
|
||||||
|
int requests)
|
||||||
|
throws Exception {
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add(System.getProperty("h2load.executable"));
|
||||||
|
command.add("-n");
|
||||||
|
command.add(Integer.toString(requests));
|
||||||
|
command.add("-c");
|
||||||
|
command.add(Integer.toString(connections));
|
||||||
|
command.add("-m");
|
||||||
|
command.add(Integer.toString(effectiveStreams));
|
||||||
|
command.add("-t");
|
||||||
|
command.add(Integer.toString(Math.min(8, connections)));
|
||||||
|
command.add("http://127.0.0.1:" + port + "/index.html");
|
||||||
|
ProcessBuilder builder = new ProcessBuilder(command).redirectErrorStream(true);
|
||||||
|
applyLibraryPath(builder);
|
||||||
|
Process process = builder.start();
|
||||||
|
assertTrue(process.waitFor(Duration.ofMinutes(2).toMillis(), TimeUnit.MILLISECONDS));
|
||||||
|
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
assertEquals(0, process.exitValue(), output);
|
||||||
|
Matcher requestsResult = REQUESTS.matcher(output);
|
||||||
|
assertTrue(requestsResult.find(), output);
|
||||||
|
assertEquals(requests, Integer.parseInt(requestsResult.group(1)), output);
|
||||||
|
assertEquals(requests, Integer.parseInt(requestsResult.group(2)), output);
|
||||||
|
assertEquals(0, Integer.parseInt(requestsResult.group(3)), output);
|
||||||
|
Matcher rate = RATE.matcher(output);
|
||||||
|
assertTrue(rate.find(), output);
|
||||||
|
System.out.printf(
|
||||||
|
"%s,%d,%d,%d,%d,%s%n",
|
||||||
|
implementation,
|
||||||
|
connections,
|
||||||
|
requestedStreams,
|
||||||
|
effectiveStreams,
|
||||||
|
requests,
|
||||||
|
rate.group(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyLibraryPath(ProcessBuilder builder) {
|
||||||
|
String path = System.getProperty("nghttp.library.path");
|
||||||
|
if (path != null) builder.environment().put("LD_LIBRARY_PATH", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int freePort() throws Exception {
|
||||||
|
try (ServerSocket socket = new ServerSocket(0)) {
|
||||||
|
return socket.getLocalPort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package dev.relism.flash.http2.client;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
import dev.relism.flash.extension.FlashApp;
|
import dev.relism.flash.extension.FlashApp;
|
||||||
import dev.relism.flash.extension.FlashConfiguration;
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
@@ -10,6 +12,7 @@ import dev.relism.flash.models.MutableHeaderMap;
|
|||||||
import dev.relism.flash.tls.TestKeystores;
|
import dev.relism.flash.tls.TestKeystores;
|
||||||
import dev.relism.flash.tls.TlsConfig;
|
import dev.relism.flash.tls.TlsConfig;
|
||||||
import java.net.ServerSocket;
|
import java.net.ServerSocket;
|
||||||
|
import java.net.Socket;
|
||||||
import java.net.URI;
|
import java.net.URI;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
@@ -101,6 +104,15 @@ class Http2ClientTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void configuresConnectionsForRequestResponseLatency() throws Exception {
|
||||||
|
try (Socket socket = new Socket()) {
|
||||||
|
assertFalse(socket.getTcpNoDelay());
|
||||||
|
Http2Client.configureLowLatency(socket);
|
||||||
|
assertTrue(socket.getTcpNoDelay());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static MutableHeaderMap fields(String name, String value) {
|
private static MutableHeaderMap fields(String name, String value) {
|
||||||
MutableHeaderMap headers = new MutableHeaderMap();
|
MutableHeaderMap headers = new MutableHeaderMap();
|
||||||
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package dev.relism.flash.http2.stream;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotSame;
|
||||||
import static org.junit.jupiter.api.Assertions.assertSame;
|
import static org.junit.jupiter.api.Assertions.assertSame;
|
||||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
@@ -52,4 +54,20 @@ class Http2StreamTableTest {
|
|||||||
assertEquals(Http2StreamTable.CLOSED_BY_RESET, table.closedKind(3));
|
assertEquals(Http2StreamTable.CLOSED_BY_RESET, table.closedKind(3));
|
||||||
assertEquals(Http2StreamTable.CLOSED_UNKNOWN, table.closedKind(5));
|
assertEquals(Http2StreamTable.CLOSED_UNKNOWN, table.closedKind(5));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void detachedFinalWriteDoesNotConsumeLiveStreamCapacity() {
|
||||||
|
Http2StreamTable table = new Http2StreamTable(1);
|
||||||
|
Http2Stream first = table.acquire(1);
|
||||||
|
|
||||||
|
assertTrue(table.detach(first, 1));
|
||||||
|
Http2Stream second = table.acquire(3);
|
||||||
|
assertNotNull(second);
|
||||||
|
assertNotSame(first, second);
|
||||||
|
|
||||||
|
table.release(first);
|
||||||
|
assertTrue(table.retire(second, 3));
|
||||||
|
assertEquals(2, table.createdCount());
|
||||||
|
assertEquals(2, table.freeCount());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user