Compare commits

24 Commits
Author SHA1 Message Date
Relism 2078dea54f Merge pull request 'feat(core): HTTP/2 support, correctness fixes, and doc reorganization' (#10) from feature/core/http2 into master
Publish Maven packages / publish (push) Failing after 53s
Reviewed-on: #10
2026-08-14 18:20:30 +00:00
Zakaria El Orche a0dda8e47a refactor(core): remove out-of-scope HTTP/2 client/proxy, reorganize docs, refresh README
HttpProxy and Http2Client (719 LOC) shipped a reverse-proxy adapter and outbound HTTP/2
client from flash core with zero callers anywhere in the server itself — only each
other and their own tests. An HTTP/1.1+2 server framework has no business bundling an
outbound client; that capability belongs in its own flash-extensions/flash-ext-*
module if/when it's needed. Removed, along with the now-dead src/bench load driver
that depended on Http2Client (no replacement client written here — flagged as
follow-up work, not silently dropped).

docs/http2/ had accumulated core, cross-protocol documentation alongside genuine
HTTP/2-protocol internals: HTTP1-HARDENING, TRANSPORT, MESSAGE-MODEL,
TRAILERS-AND-STREAMING and BYTES all describe machinery HTTP/1.1 and HTTP/2 share, not
HTTP/2 specifically. Moved to a new docs/core/, leaving docs/http2/ to the protocol
layers, wire internals and operational docs that are actually HTTP/2-specific.
CLEARTEXT-AND-PROXY.md renamed to CLEARTEXT.md and its now-removed upstream-client
section cut, matching the source removal above.

README.md: removed the "HTTP/2 upstream proxy" section (documented the deleted
HttpProxy/Http2Client), the flash-bench module row and build command (not a module
that exists in this repo), and fixed every doc link to the new docs/core/ paths.
Added the new FlashConfiguration.maxConnections field to the configuration reference.

src/bench/ (a load-test harness distinct from the JMH suite, not wired into any Maven
profile or CI) is committed here for the first time.
2026-08-14 18:13:03 +00:00
Zakaria El Orche cf16be08c0 fix(core): fix HTTP/2 rate-limiter false positives, connection-flood OOM, and a streaming-body leak
Targeted stress testing under this branch's HTTP/2 work surfaced four independent
production bugs, each verified with a before/after load test and a regression test:

- Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL (400/10s) rejected legitimate
  high-concurrency HTTP/2 clients as if they were CVE-2023-44487 rapid-reset abuse —
  h2load's default pattern alone triggered 40-92% request failure. Raised to 100,000,
  matching MAX_STREAMS_PER_CONNECTION's existing lifetime budget; the RST_STREAM-rate
  counter remains the precise defence against the actual attack signature.

- Flash had no connection-admission control anywhere: AcceptLoop accepted every TCP
  connection unconditionally, so a connection flood (h2load -c 400) ran the JVM out of
  heap and crashed with OutOfMemoryError, killing even unrelated daemon threads.
  TransportLimits.defaultMaxConnections() auto-scales a cap from Runtime.maxMemory();
  ConnectionRunner.accept() enforces it before any per-connection state (TLS handshake
  included) is created. Verified surviving 42x the admission limit under both cleartext
  and TLS load with bounded RSS.

- Http1ResponseWriter never closed a handler's streaming response body on a write
  failure (e.g. the client disconnecting mid-transfer) — only on a clean EOF. A handler
  whose stream releases a held resource (a pooled backend connection, for a reverse
  proxy) from close() leaks it under any real amount of client disconnects. Now closed
  on every exit path, matching InputStream#close()'s own idempotency contract.

- Http2StreamState.transition() called the enum's values() every state transition;
  values() clones a fresh array on every call. Cached once, removing ~10.76% of
  allocations measured live under load.

695 -> 698 tests (three new regression tests), all passing.
2026-08-14 18:12:46 +00:00
Zakaria El Orche 825bdfc942 docs(core): document HTTP/2 operation and architecture 2026-08-13 21:39:37 +00:00
Zakaria El Orche 3679eed74a feat(core): add HTTP/2 performance gates 2026-08-13 21:29:21 +00:00
Zakaria El Orche 6386264a1e test(core): add HTTP/2 compliance suite 2026-08-13 20:51:35 +00:00
Zakaria El Orche f3011ffdf6 feat(core): add WebSocket over HTTP/2 2026-08-13 20:18:05 +00:00
Zakaria El Orche 3c1eb0d0df feat(core): add HTTP/2 cleartext proxy support 2026-08-13 20:00:59 +00:00
Zakaria El Orche 5755ef77fe feat(core): harden HTTP/2 abuse resistance 2026-08-13 19:40:52 +00:00
Zakaria El Orche ee90ac44ff feat(core): add HTTP trailers and push streaming 2026-08-13 19:23:26 +00:00
Zakaria El Orche 8d5340a0b4 feat(core): add HTTP/2 flow-controlled bodies 2026-08-13 19:00:19 +00:00
Zakaria El Orche c96d51f7ea feat(core): add HTTP/2 stream dispatch 2026-08-13 18:33:04 +00:00
Zakaria El Orche 9391f80f76 feat(core): add HTTP/2 response path 2026-08-13 18:04:22 +00:00
Zakaria El Orche cfa192e689 feat(core): add HTTP/2 connection state machine 2026-08-13 17:49:20 +00:00
Zakaria El Orche 95c33e7bf2 feat(core): add HPACK decoder 2026-08-13 17:17:29 +00:00
Zakaria El Orche f47f53c355 feat(core): add HPACK coding primitives 2026-08-13 16:48:47 +00:00
Zakaria El Orche 885c450f6b refactor(core): unify HTTP protocol package boundaries 2026-08-13 16:24:23 +00:00
Zakaria El OrcheandClaude Sonnet 5 d882ea255c feat(core): HTTP/2 Phase 6 — Request/Response model refactor
Pools Request/RequestBody/RequestLine/Response per connection (EX-20..EX-24),
following the same reset()/dev-mode-guard idiom Http1HeaderMap already used.
HeaderMap splits into HeaderView (interface) + Http1HeaderMap (impl, DEC-22).
Response gains byte-level structured headers, PreEncodedHeader, and
ResponseSerializer as the single source of truth for a response's header
sequence, consumed by Http1ResponseWriter's single-bulk-write rewrite (EX-27).
ByteTemplate gets O(1) slot lookup plus a buffer-writing overload (EX-28).
Multipart audited: three resource-exhaustion gaps found and fixed — unbounded
buffered part size, part count, and per-part header parsing (EX-38..EX-40) —
and boundary length confirmed already bounded (EX-41).

Re-measuring RequestPipelineBenchmark after the pooling work surfaced one more
per-request allocation underneath it (RequestParser building fresh
RequestByteViews every call) and, while checking the phase's own DoD text, an
unbounded Response.header(...) loop hazard neither had a limit — both fixed
(EX-42, EX-43). The h1 zero-alloc contract now holds: parseAndRoute measures
0.008 B/op (JMH noise floor), down from Phase 4's 120.008 B/op (DEC-20, DEC-23).

MESSAGE-MODEL.md records the pooling model; README gains an "Object lifetime"
section documenting the do-not-retain-past-the-handler contract. 503/503 tests
green.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 15:26:08 +00:00
Zakaria El OrcheandClaude Sonnet 5 0e1bbed42c feat(core): HTTP/2 Phase 5 — frame layer
Implements HTTP/2 frame reading, validation, and writing: FrameType (the
10 RFC 9113 types + per-type validation descriptor), FrameFlags (with
the deliberate END_STREAM/ACK bit collision documented), FrameHeader (a
flyweight, never allocated per frame), Http2FrameReader (length-prefixed
reader over BufferedByteSource, mirroring RequestParser's buffer/
compaction discipline), FrameValidator (table-driven, specific RFC error
code per violation -- not a uniform code per type), Padding (RFC 9113
6.1/6.2), and FrameWriteBuffer (beginFrame/endFrame length back-patching
over Phase 4's ByteWriter).

All 10 frame types round-trip correctly; every RFC-mandated rejection
has its own test asserting the specific error code; the reader is
fuzz-tested against 10,000,000 random inputs (~14s). The zero-alloc
contract is measured, not asserted: reading + validating + consuming a
frame is 0.002 B/op, writing one is ~10^-4 B/op -- both indistinguishable
from zero (DEC-21).

Found and fixed EX-37 while writing Http2FrameReaderTest: BufferedByteSource's
deadline mechanism (EX-07's actual fix) NPE'd against a null socket, which
every isolated unit test in this codebase uses -- it had zero dedicated
test coverage of its own. Fixed to treat a null socket as "no OS-level
timeout to bound" rather than a misuse, and given BufferedByteSourceTest,
which did not exist before.

449/449 tests green, both with and without -Pjmh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:25:12 +00:00
Zakaria El OrcheandClaude Sonnet 5 704a00a551 feat(core): HTTP/2 Phase 4 — byte-layer foundations
Builds dev.relism.flash.bytes: ByteScan (scanning/comparison/hashing,
scalar + SWAR, property-tested against each other on every boundary and
20,000 random fuzz trials each), ArrayBackedByteView/SegmentedByteView
capability hierarchy, PooledSlice/SlicePool, ByteWriter, Pairs.

Cashes in the allocation and scanning wins the existing code left on the
table: EX-04 (word-at-a-time router matching, verified directly against
fpr-core's own ByteCompare), EX-05 (pooled views replacing per-call
anonymous ByteView allocations in HeaderMap/QueryParams/PathParams),
EX-09 (HeaderMap index built once per reset() instead of rescanning per
lookup), EX-19 (reusable PathParams on the router's per-connection
scratch), EX-25/EX-26 (single-allocation String construction), EX-33
(SWAR header-terminator scan in RequestParser).

Also closes EX-06's router half, missing from this phase's own EX-item
list in the plan (same class of omission DEC-12 recorded for Phase 1):
FastPathRouterImpl/FastPathWsRouterImpl's ThreadLocals (unbounded under
one-virtual-thread-per-connection) are replaced by an opaque,
caller-owned per-connection scratch object (AbstractRouter#newScratch),
not by extending ConnectionScratch as its own Javadoc originally assumed
-- that would have created transport's first dependency on routing in
the reverse direction. Full rationale in DEC-19.

Every optimization is measured, not asserted (DEC-20): SWAR scan 35.4%
faster than scalar, kept; EX-04's word-path 32.1% faster than
byte-at-a-time at the mechanism level, kept for its real future
consumers even though today's router doesn't yet route through it
(MethodPathByteView stays deliberately non-array-backed, per the plan's
own text). Router matching itself is ~0 B/op including parametric
routes. The full h1 pipeline is not literally 0 B/op yet -- 120 B/op is
Request/RequestBody/RequestLine construction, honestly attributed to
Phase 6's explicit scope rather than hidden.

395/395 tests green, both with and without -Pjmh.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:07:29 +00:00
Zakaria El OrcheandClaude Sonnet 5 2bf261e4e2 feat(core): HTTP/2 Phase 3 — serialized frame writer (GO/NO-GO gate)
Implements the connection-level serialized frame writer per the plan's
go/no-go gate: tryLock() fast path with an intrusive Vyukov-style MPSC
fallback under contention, ReentrantLock throughout (never synchronized),
and a scan-based write-timeout reaper.

All four gate criteria met and measured: N=1 0 B/op and 42.6 ns overhead
(<=50 ns budget); N=64 65.5% throughput retention (>=60%) and 11.8-14.2 us
p999 (<1 ms); no carrier pinning; stress test 10,000/10,000 green across
1000 iterations x 5 concurrency levels x 2 scheduler configs. Compared
against plain-lock and dedicated-thread designs with real benchmark
numbers, not assertion. Full methodology and results in WRITER.md, DEC-09.

Also fixes a real regression found while resuming this work: the JMH
benchmark broke plain `mvn test` (no -Pjmh) because it lived in
src/test/java, which Surefire's test discovery loads regardless of
whether a class is ultimately selected as a test. Moved to a dedicated
src/jmh/java source root registered only under the jmh profile
(build-helper-maven-plugin), per DEC-17.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 14:05:01 +00:00
Zakaria El OrcheandClaude Sonnet 5 a315e1df8b feat(core): HTTP/2 Phase 2 — transport decomposition
Breaks HttpServer (563 lines, eleven responsibilities) into named,
single-purpose components and introduces the ConnectionProtocol seam
HTTP/2 plugs into starting Phase 8, per
flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 2.

New packages:
- dev.relism.flash.transport: TransportFactory (composition root, EX-34),
  ListenerBinder, BoundListener, TransportTuning, AcceptLoop,
  ConnectionRunner (per-connection setup/teardown), ConnectionProtocol
  (the h1/h2 seam), ConnectionContext, ConnectionScratch + ScratchPool
  (EX-06), ServerLifecycle (implements ServerHandle; start/stop/graceful
  shutdown, EX-32).
- dev.relism.flash.http1: Http1Connection (the keep-alive request loop,
  implements ConnectionProtocol), Http1ResponseWriter, Http1KeepAlive
  (the shared Connection-header token-list scanner, EX-13).
- dev.relism.flash.websocket additions: WebSocketUpgrade (detection +
  handshake), WebSocketLoop (session loop), WebSocketProtocolException.

Existing-code defects fixed (EX-nn):
- EX-01: WebSocketSession's two blocking-write sites use ReentrantLock
  instead of synchronized (out) -- a virtual thread blocking inside
  synchronized pins its carrier platform thread on Java 21.
- EX-06: HttpServer's three ThreadLocals (SHA1, LONG_BUF,
  STREAM_RELAY_BUFFER) replaced by ConnectionScratch, pooled via
  ScratchPool instead of one-per-virtual-thread (i.e. one-per-connection)
  growth. The router's ThreadLocals are deliberately deferred to Phase 4
  per this EX item's own phasing -- see DEC-15 for the plan-wording fix.
- EX-11: WebSocketSession.readFrame's extended-length and mask-key bytes
  are now read in a single bounded readFully instead of one at a time.
- EX-12: full RFC 6455 frame validation -- continuation-frame
  reassembly, mandatory masking-direction enforcement, opcode
  validation, control-frame constraints (not fragmented, <=125 bytes),
  and WebSocketProtocolException carrying the correct close code (1002
  protocol error, 1009 message too big).
- EX-13: Connection header token-list scanning shared between the
  keep-alive decision and the WebSocket upgrade check.
- EX-14: HEAD responses report Content-Length but write no body.
- EX-15: Content-Type omitted when empty; Content-Length and the body
  omitted entirely for 204/304/1xx responses.
- EX-16: Date header (dev.relism.flash.http.DateHeader), refreshed once
  per second by a shared daemon thread; FlashConfiguration.sendDate.
- EX-32: two-stage graceful shutdown -- stop accepting, force
  Connection: close on the response an in-flight handler is still
  producing (re-checked after the handler runs, not just before
  dispatch, so a shutdown beginning mid-handler is still honoured),
  drain up to shutdownDrainTimeoutMs, then force-close.
- EX-34: ServerHandle.create delegates to TransportFactory instead of
  constructing HttpServer directly.

Two plan corrections recorded: DEC-15 (Phase 2's "no ThreadLocal
anywhere" DoD line contradicted EX-06's own multi-phase assignment --
corrected to match the registry) and DEC-16 (no separate
WebSocketFrameCodec class this phase; the EX-11/EX-12 fixes stay inside
WebSocketSession, which is one cohesive state machine under R6's own
carve-out -- revisit at Phase 15 if RFC 8441 needs the decoupling for
real).

HttpServer.java deleted.

311/311 tests green (flash module), run three times for stability of
the wall-clock-based timeout/shutdown tests. Whole-repo build green.
h1 benchmark regression check remains unverified in the plan's DoD (no
JMH harness until Phase 3, same caveat as Phase 1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 12:03:44 +00:00
Zakaria El OrcheandClaude Sonnet 5 5a2aaf5a07 feat(core): HTTP/2 Phase 1 — HTTP/1.1 hardening and protocol negotiation
Fixes the request-smuggling and resource-exhaustion debt in the existing
HTTP/1.1 parser, and adds the ALPN/h2c-preface negotiation seam so a
connection's protocol is decided once, before any request is parsed, per
flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 1.

Existing-code defects fixed (EX-nn):
- EX-02: reject Content-Length + Transfer-Encoding together (RFC 9112 6.1
  CL.TE/TE.CL smuggling), and conflicting duplicate Content-Length values.
- EX-03: strict, overflow-safe Content-Length parsing, replacing a parser
  that silently skipped non-digit bytes ("5abc" -> 5, "-1" -> 1).
- EX-07: header-read / idle-keep-alive / body-read timeouts enforced by an
  absolute deadline (dev.relism.flash.transport.BufferedByteSource), not
  merely Socket#setSoTimeout, which never trips against a peer trickling
  one byte per read within the window.
- EX-08: header count / name length / value length / request-line length
  bounds (Http1Limits), 431 on violation.
- EX-10: ChunkedInputStream now reads through BufferedByteSource instead
  of the raw unbuffered socket stream, and the header-parser's read-ahead
  bytes are handed over via a zero-copy prependOnce() instead of a
  SequenceInputStream/ByteArrayInputStream pair.
- EX-17: HttpStatus's status-code bound is computed from values() instead
  of a hand-maintained constant that silently threw
  ArrayIndexOutOfBoundsException when a code above it was added; added
  421, 431, 505, 507, 511 and others HTTP/2 and this hardening need.
- EX-18: bare-CR desync and obsolete line folding rejected.
- EX-30: the TLS handshake is forced explicitly, under a timeout, before
  any protocol decision -- SSLSocket#getApplicationProtocol() returned
  null until the handshake had run, and nothing previously forced it.
- EX-31: TLS 1.2 cipher suites on the RFC 9113 Appendix A blocklist are
  filtered out of a listener's enabled set whenever it offers h2 via ALPN.
- EX-35 (found in this phase): Transfer-Encoding values listing multiple
  codings ("gzip, chunked") were silently treated as not chunked at all,
  corrupting the message boundary -- only the whole value was compared.
- EX-36 (found in this phase): a header line with no ':' was silently
  skipped instead of rejected.

New:
- dev.relism.flash.transport.BufferedByteSource: the single buffered,
  deadline-aware, peekable view over a connection's inbound bytes.
- dev.relism.flash.transport.ProtocolNegotiator/NegotiatedProtocol: ALPN
  and h2c prior-knowledge detection. In this phase an H2 result is always
  closed cleanly -- there is no Http2Connection to hand off to until
  Phase 8. FlashConfiguration.http2Enabled gates the h2c preface peek.
- dev.relism.flash.exceptions.MalformedRequestException: a typed,
  status-carrying rejection distinct from HttpException, caught at the
  parse site so a malformed request never reaches the handler chain or
  the user's exception handler, and the connection is always closed.

Two small plan-document corrections recorded as DEC-12 (Phase 1's Files
list omitted BufferedByteSource.java and MalformedRequestException.java;
the request-line-length check description pointed at the wrong offset).
DEC-13/DEC-14 record the deadline and exception-hierarchy designs.

277/277 tests green (flash module), run twice for stability of the new
wall-clock-based HttpServerTimeoutTest cases. Whole-repo build green.
h1 benchmark regression check is left unverified in the plan's DoD: no
JMH harness exists yet (Phase 3 deliverable).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 11:40:03 +00:00
Zakaria El OrcheandClaude Sonnet 5 db6e4a4d0c feat(core): HTTP/2 Phase 0 — groundwork (limits, error model, decision log)
Establishes the package layout, limits/error model and decision-log
convention that every later HTTP/2 phase depends on, per
flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 0.

- dev.relism.flash.h2: package-info (architecture overview), Http2ErrorCode
  (the 14 RFC 9113 §7 codes with precomputed 4-byte wire encodings),
  Http2Exception (connection error -> GOAWAY) and Http2StreamException
  (stream error -> RST_STREAM), neither extending IOException, both with
  stack-trace capture disabled on the hot rejection path.
- Http2Limits: every bound Phase 0 requires (concurrent streams, frame
  size, header list size, CONTINUATION/reset/settings/ping rate bounds,
  flow-control windows, HPACK table size/string length, assembly and idle
  timeouts), each documented with the attack or RFC clause it addresses.
- dev.relism.flash.http.Http1Limits: the h1 bounds needed by EX-03 (strict
  Content-Length) and EX-08 (header count/size limits).
- flash/docs/http2/DECISIONS.md seeded with DEC-01..DEC-11 (the ten
  decisions implied by the plan itself, plus DEC-11 recording that commits
  keep scope `core` rather than adding `h2` to AGENTS.md).
- flash/docs/http2/IMPLEMENTATION-PLAN.md: added the Progress Ledger
  (tracks phase status across sessions) and checked off Phase 0's DoD.

19 new tests, full flash module suite green (226/226).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-13 10:59:49 +00:00
273 changed files with 27134 additions and 1677 deletions
+33 -1
View File
@@ -32,8 +32,40 @@ jobs:
server-username: MAVEN_USERNAME
server-password: MAVEN_PASSWORD
- name: Install h2spec 2.6.0
run: |
curl --fail --location --silent --show-error \
--output /tmp/h2spec.tar.gz \
https://github.com/summerwind/h2spec/releases/download/v2.6.0/h2spec_linux_amd64.tar.gz
echo "157ee0de702e01ad40e752dbf074b366027e550c8e7504f9450da2809e279318 /tmp/h2spec.tar.gz" \
| sha256sum --check
tar --extract --gzip --file /tmp/h2spec.tar.gz --directory /tmp
- name: Install nghttp client
run: |
sudo apt-get update
sudo apt-get install --yes nghttp2-client
- name: Install grpcurl 1.9.3
run: |
curl --fail --location --silent --show-error \
--output /tmp/grpcurl.tgz \
https://github.com/fullstorydev/grpcurl/releases/download/v1.9.3/grpcurl_1.9.3_linux_x86_64.tar.gz
echo "a926b62a85787ccf73ef8736b3ae554f1242e39d92bb8767a79d6dd23b11d1d5 /tmp/grpcurl.tgz" \
| sha256sum --check
tar --extract --gzip --file /tmp/grpcurl.tgz --directory /tmp grpcurl
- name: Build and test
run: mvn -B --settings .github/settings.xml clean verify
run: >-
mvn -B --settings .github/settings.xml
-Dh2spec.executable=/tmp/h2spec
-Dcurl.executable=/usr/bin/curl
-Dnghttp.executable=/usr/bin/nghttp
-Dgrpcurl.executable=/tmp/grpcurl
-Djdk.tracePinnedThreads=full
-Pjmh
-Dflash.performance.gates=true
clean verify
env:
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
+152 -19
View File
@@ -1,12 +1,13 @@
# Flash
A high-performance HTTP/1.1 server library for Java 21, built around virtual threads and a zero-allocation FSM router.
A high-performance HTTP/1.1 and HTTP/2 server library for Java 21, built around virtual threads,
a zero-allocation FSM router, bounded protocol state, and one shared request/response API.
## Modules
| Module | Description |
|---|---|
| `flash` | Core server library — router, request parser, HTTP I/O transport |
| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model |
| `flash-extensions/flash-ext-jackson` | Jackson JSON integration |
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
@@ -14,7 +15,6 @@ A high-performance HTTP/1.1 server library for Java 21, built around virtual thr
| `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives |
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
## Requirements
@@ -58,7 +58,7 @@ app.post("/echo", (req, res) -> {
});
app.get("/users/{id}", (req, res) -> {
String id = req.pathParam("id");
String id = req.param("id");
return "user:" + id;
});
```
@@ -167,13 +167,64 @@ app.onException((ex, req, res) -> {
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) |
| `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) |
| `wsFrameBufferSize` | `65536` | Per-connection WebSocket read buffer (bytes) |
| `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/core/HTTP1-HARDENING.md). |
| `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. |
| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. |
| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. |
| `maxConnections` | auto (~heap/10MB) | Maximum concurrent connections across all listeners before new ones are closed immediately at accept time, before any per-connection state (TLS handshake included) is created. Auto-scales from `Runtime.maxMemory()`; set explicitly for a known deployment size, or `0` to disable. |
| `http2Enabled` | `false` | Whether TLS listeners advertise HTTP/2 through ALPN. |
| `http2CleartextEnabled` | `false` | Whether plaintext listeners accept HTTP/2 prior knowledge (h2c). Independent from TLS HTTP/2. |
| `h2HuffmanDynamicValues` | `false` | HPACK-Huffman encode runtime response values. Constants remain pre-encoded; the measured default avoids an extra encode pass. |
| `h2MaxResetStreamsPerInterval` | `200` | Rapid Reset budget per rolling interval. |
| `h2MaxStreamsCreatedPerInterval` | `400` | New-stream budget per rolling interval. |
| `h2AbuseRateIntervalMs` | `10000` | Rolling interval for the two operator-tunable rate limits above. |
| `h2MaxStreamsPerConnection` | `100000` | Total stream budget; `0` disables it. |
| `h2MaxBytesPerConnection` | `0` | Optional total wire-byte budget; `0` disables it. |
| `h2MaxConnectionLifetimeMs` | `0` | Optional connection lifetime; `0` disables it. |
| `h2StreamIdleTimeoutMs` | `60000` | Inactive open-stream deadline. |
| `sendDate` | `true` | Add an RFC 9110 `Date` field to responses; disable when an upstream proxy supplies it. |
## Protocols
Routes, middleware, `Request`, `Response`, bodies, trailers, streaming and WebSockets use the same
API on HTTP/1.1 and HTTP/2. Protocol selection happens once per connection:
- On TLS listeners, enable `http2Enabled`; Flash advertises `h2` and `http/1.1` through ALPN and
uses the protocol selected by the client. Existing HTTP/1.1 clients continue to work.
- On plaintext listeners, enable `http2CleartextEnabled` to accept the HTTP/2 prior-knowledge
preface on the same port as HTTP/1.1. Clients that do not send that exact preface are parsed as
HTTP/1.1.
- With both switches left at their default `false`, Flash behaves as an HTTP/1.1 server.
After enabling the appropriate switch, application routes need no protocol-specific code. TLS
still requires the normal certificate configuration shown below.
Flash deliberately does not implement HTTP/2 server push, RFC 7540 dependency-tree priority
scheduling, or the obsolete HTTP/1.1 `Upgrade: h2c` transition. Server push has no application API,
RFC 9113 deprecated the old priority scheme, and cleartext HTTP/2 uses prior knowledge instead.
See the [HTTP/2 compliance record](flash/docs/http2/COMPLIANCE.md) for exact coverage.
## WebSockets over HTTP/2
The same `ws(path, handler)` route serves WebSockets over HTTP/1.1 and HTTP/2. When HTTP/2 is
enabled, Flash advertises RFC 8441 extended CONNECT support and carries WebSocket frames inside
flow-controlled DATA frames. No alternate handler, route, or session API is required:
```java
app.ws("/live", handler);
```
HTTP/1.1 clients use the ordinary `101 Switching Protocols` upgrade. HTTP/2 clients use an
extended CONNECT and receive status `200`; Flash applies the same RFC 6455 framing, masking,
fragmentation, close, and callback behavior on both transports. Client support for negotiating
WebSockets over HTTP/2 varies, so clients without RFC 8441 support continue to use HTTP/1.1.
## TLS
HTTPS and WSS are a transport-layer concern only: once a listener is bound, the accepted
`Socket` is either plain or an `SSLSocket` indistinguishably from `HttpServer`'s point of view
onward — the request parser, router, and WebSocket upgrade never branch on it. WSS is therefore
not a separate feature; it's a WebSocket upgrade running over whatever transport it was handed.
HTTPS and WSS are a transport-layer concern only. Once a listener is bound, the accepted socket
is plain or TLS; the selected HTTP connection implementation then performs either the HTTP/1.1
upgrade or the HTTP/2 extended CONNECT. WSS does not require a separate route or handler API.
### Quick start
@@ -254,23 +305,108 @@ that got the request this far has already completed, never a forced handshake.
`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the
upgrading `Request` — no separate TLS state is tracked for WS.
## Object lifetime
`Request` and `Response` are **pooled per connection**, not allocated per request: one instance is
created per connection and repositioned (`reset()`) over each new request/response in turn — the
same idiom Java NIO buffers use, applied to the whole request/response model
(`flash/docs/core/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1
request/response cycle 0 B/op.
**Do not retain a `Request` or `Response` past the handler that received it.** A reference kept in
a field, a captured closure, a `CompletableFuture` continuation, or a background thread and read
*after* the handler returns will observe whatever the *next* request on that connection
repositioned the same instance to — not the request you thought you had:
```java
// WRONG — captures `req`, reads it after the handler has returned
app.get("/slow", (req, res) -> {
CompletableFuture.runAsync(() -> log(req.header("X-Trace-Id"))); // may log the NEXT request's header
return "ok";
});
```
Copy out whatever you need before returning or handing work off asynchronously — every accessor
that returns a `String` (`header`, `param`, `query`, `path`, …) gives you an independent heap copy
that's safe to keep as long as you like:
```java
app.get("/slow", (req, res) -> {
String traceId = req.header("X-Trace-Id"); // copy now, safe to retain
CompletableFuture.runAsync(() -> log(traceId));
return "ok";
});
```
Run with `-Dflash.env=dev` and a use-after-return access throws `IllegalStateException` immediately
at the offending call site instead of silently reading the wrong request's data — turn this on in
tests and local development. It's a no-op in production beyond a single `boolean` field read.
`req.body()`/`RequestBody` follows the same rule — materialise (`.bytes()`) or fully consume
(`.stream()`) it inside the handler; don't stash the `RequestBody` itself for later.
### Reusable response headers
Use `PreEncodedHeader` for a constant header sent by many responses. It stores the name and value
once and remains valid on both HTTP versions:
```java
private static final PreEncodedHeader NO_STORE =
new PreEncodedHeader("cache-control", "no-store");
app.get("/health", (req, res) -> res.header(NO_STORE).body("ok"));
```
`Response.header(byte[])` accepts a complete CRLF-terminated HTTP/1 field line and is therefore
HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared
application and middleware code.
### Trailers and push streaming
Request trailers become available after the body reaches EOF:
```java
byte[] payload = req.body().bytes();
String status = req.trailers().first("grpc-status");
```
For a producer-driven response, `Response.streaming` provides a blocking `ResponseStream`. Its
bounded buffer and HTTP/2 flow-control windows apply backpressure directly to the producer's
virtual thread:
```java
return res.streaming(stream -> {
try {
stream.write(payload, 0, payload.length);
stream.trailer("result", "complete");
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
});
```
The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HEADERS on
HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a
future `flash-ext-grpc` extension.
## Architecture
```
ServerSocket.accept()
RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
→ RequestHandler.handle() # user handler; return value sets body
→ Request.drain() # consume unread body for keep-alive
→ HttpServer writes response # status line, headers, then fixed or chunked body
→ loop or close socket # based on Connection header
TransportFactory.create() # binds every listener, wires the connection runner
AcceptLoop # one per listener × accept thread; hands sockets off
ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
├─ Http1Connection.run() # request parser, router, handler, h1 response writer
└─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
→ RequestHandler.handle() # the same protocol-neutral request/response API
```
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`). Java 21 required.
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
- **`ConnectionProtocol` seam** — HTTP/1.1 and HTTP/2 are peers behind this interface, selected once per connection by `ProtocolNegotiator`; routing and application models are shared.
## Build & test
@@ -283,7 +419,4 @@ mvn test
# Run a single test class
mvn test -pl flash -Dtest=RequestParserTest
# Run the benchmark demo server
java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar
```
+177
View File
@@ -0,0 +1,177 @@
# The byte layer
Audience: contributors. This is the design record for `dev.relism.flash.bytes` — the
protocol-neutral byte primitives both HTTP/1.1 and HTTP/2 build on — and for the Phase 4
allocation/scanning fixes (`EX-04`, `EX-05`, `EX-09`, `EX-19`, `EX-25`, `EX-26`, `EX-33`, plus
`EX-06`'s router half) that consume them.
## Why this exists
Before Phase 4, byte-scanning and case-insensitive comparison logic was duplicated, slightly
differently, in `RequestParser`, `HeaderMap`, and `Http1KeepAlive`; `fpr-core`'s word-at-a-time
router-matching fast path (`ByteCompare`) was wired up but never actually enabled anywhere
(`EX-04` — every `ByteView` implementation returned `supportsLong() == false`); and four call
sites allocated a fresh view, array, or `String` per call on paths a realistic middleware chain
hits 610 times per request. `dev.relism.flash.bytes` is the single home these fixes converge on,
so no later phase (HPACK, the frame layer) has to invent its own scanning primitives.
## Package layout
```
dev.relism.flash.bytes
├── ByteScan static scanning/comparison/hashing utilities, scalar + SWAR
├── ArrayBackedByteView capability interface: a ByteView backed by one contiguous byte[]
├── SegmentedByteView the deliberate non-array-backed case (K discontiguous segments)
├── PooledSlice reusable ArrayBackedByteView, the EX-05 fix
├── SlicePool a small fixed-size ring of PooledSlice
├── ByteWriter index-based writer into a growable byte[] scratch buffer
└── Pairs the (hi<<32)|lo allocation-free pair-return idiom, named
```
## The `ByteView` capability hierarchy
```
ByteView (fpr-core)
├── ArrayBackedByteView capability: array() + offset()
│ ├── FastPathViews.RequestByteView RequestParser's request-line/header slices
│ ├── FastPathViews.SocketByteView a bare byte[] (e.g. a WebSocket payload)
│ ├── FastPathViews.StringByteView a String's UTF-8 bytes
│ └── PooledSlice the EX-05 reusable, pool-issued slice
└── (bare ByteView, not array-backed)
├── SegmentedByteView K discontiguous segments (general-purpose; HPACK stays contiguous)
└── FastPathViews.MethodPathByteView method bytes + another ByteView, composed
```
Code holding a bare `ByteView` and wanting the fast path when the concrete instance happens to
be array-backed does `instanceof ArrayBackedByteView` and falls back to the byte-at-a-time path
otherwise — see `ArrayBackedByteView`'s own Javadoc. This is used throughout Phase 4's fixes:
`Request.path()`, `PathParams.get()` (`EX-25`), and `QueryParams.decode`'s clean-value fast path
(`EX-26`) all take this shape.
## `EX-04`: the `supportsLong()`/`longAt()` contract
`fpr-core`'s `ByteCompare` (decompiled from `fpr-core-1.1.1`, since no source jar is published)
reads its comparison word via
`MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN)` and only takes the
word-at-a-time branch when the caller passed `useLong = true`, comparing the result bit-for-bit
against whatever `ByteView#longAt` returns. The contract this imposes on any `longAt`
implementation:
- Return the same value `LONG_VIEW.get(array, pos)` would, for the identical 8 bytes — meaning
**little-endian**, fixed, regardless of the host's native byte order (unlike `ByteScan`'s own
SWAR internals, which use `ByteOrder.nativeOrder()` for speed — see below for why that's a
different, safe choice in a different context).
- The caller (`ByteCompare`) never calls `longAt(i)` without first establishing `i + 8 <=
length()` — so `longAt` implementations do not re-check this themselves (a defensive check
would be dead code on every real call path).
`FastPathViews.RequestByteView`/`SocketByteView`/`StringByteView` implement this;
`MethodPathByteView` (composite, no single backing array) and `SegmentedByteView` (genuinely
discontiguous) both stay at the inherited `false` default — a word-at-a-time read is not merely
unimplemented for these, it is structurally unsound (a read could straddle two sources).
**Verified against `fpr-core` directly** (`FastPathViewsLongAtTest`), not merely by reading
bytecode: `ByteCompare.equals`/`indexOf` called with `useLong=true` and `useLong=false` are
asserted to agree on identical content, on content diverging at every position across an
8+-byte range (word-interior, word-boundary, and scalar-tail cases), and end-to-end through a
real compiled `fpr-core` router with literal route segments ≥ 8 bytes — including a near-miss
route differing only in its last byte, to catch exactly the kind of bounds/endianness bug that
would otherwise silently mis-route a request (the failure mode `EX-04`'s registry entry calls out
by name as the worst possible one here).
## `ByteScan`'s SWAR technique
Both `ByteScan.indexOf` (single byte) and `ByteScan.indexOfCrLfCrLf` (the `\r\n\r\n` header
terminator, `EX-33`) use the classic "does this word contain byte `b`" bit trick: XOR the 8-byte
word against `b` broadcast into every lane, then test for any zero lane with
`(v - 0x0101...01) & ~v & 0x8080...80`. `indexOfCrLfCrLf` uses this as a pre-filter to find a
candidate `CR` byte 8 at a time, then a cheap scalar 3-byte check verifies the full 4-byte match
at each candidate — so a scan touches every byte once per 8-byte stride in the common
no-CR-yet case, rather than once per byte.
This reads the word via `ByteOrder.nativeOrder()`, not a fixed order — safe here (unlike
`EX-04`'s `longAt`) because nothing compares this word against an independently-decoded one;
byte-equality detection itself (finding *that* a matching lane exists) is indifferent to lane
order, and position extraction (`laneIndexOf`) branches on the actual native order once, at
class-init time, to convert a matching bit back into the correct array index either way.
Every SWAR method has a scalar counterpart (`indexOfScalar`, `indexOfCrLfCrLfScalar`) used as
the correctness oracle: `ByteScanTest` property-tests SWAR against scalar at every length 0256
and every match position (including unaligned starts and matches at the very last valid byte),
and `ByteScanFuzzTest` throws 20 000 fully-random trials at each, per the plan's task 1. All
green — see the class's own Javadoc for the full technique writeup.
## `Http1HeaderMap`'s index
Originally, every `Http1HeaderMap` lookup (`first`, `all`, `view`, `valueEqualsIgnoreCase`)
rescanned the entire header section from scratch — O(n·m) for a realistic middleware chain
performing 610 lookups per request. `RequestParser` now populates the index while it validates
each header line; direct `Http1HeaderMap.reset()` callers scan the section exactly once. It records
per-header `(nameOffset, nameLength, valueOffset, valueLength)` and a case-insensitive
32-bit FNV-1a hash of the name (`ByteScan.hashNameIgnoreCaseAscii`) into `int[]` arrays grown
(never shrunk) to the connection's high-water mark, capped by `Http1Limits.MAX_HEADER_COUNT`
(asserted, not silently truncated — the parser rejects a request that would exceed it).
Every lookup then compares the caller's own hash (`ByteScan.hashNameIgnoreCaseAscii(String)`,
computed once) against the index's hashes before ever falling back to a full case-insensitive
name comparison. Production therefore performs one combined validation/index pass rather than
one parse-time pass plus one rescan per lookup. `forEach` uses the same index rather than keeping
an independent scanner.
## `EX-05`: pooled slices
`Http1HeaderMap.view`, `QueryParams.view`, and `PathParams.view` used to allocate a fresh anonymous
`ByteView` (plus its capturing instance) on every call. Each now draws from a small
(`VIEW_POOL_SIZE = 4`) `SlicePool` of reusable `PooledSlice` instances instead. The lifetime
contract, restated on each method: **a returned view stays valid until either the request ends,
or the same `view()` method is called `VIEW_POOL_SIZE` more times on the same instance —
whichever comes first** — at which point the ring silently repositions the same object over
different bytes. This is a real, demonstrated hazard, not a hypothetical one:
`SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice` and the analogous tests in
`Http1HeaderMapIndexTest`, `QueryParamsFastPathTest`, and `PathParamsTest` all show a 5th call
returning the exact same object instance the 1st call did, now aliased to different content.
`QueryParams` and `PathParams`'s pools are created **lazily**, on the first actual `view()` call
— not eagerly in the constructor — because both classes are otherwise-cheap objects created per
request (or, for `PathParams`'s `FastPathRouterImpl`-owned reusable instance, once per
connection) regardless of whether `view()` is ever invoked; an eager pool would add
`VIEW_POOL_SIZE` allocations to every such object whether or not it needed them; `Http1HeaderMap`'s
pool, by contrast, is unconditionally useful (every request's map handles headers) and is
constructed eagerly for simplicity.
**Two documented, deliberately-kept exceptions to "no `new ByteView()` remains"**: `QueryParams.view`
and `PathParams.view` each retain a fallback anonymous `ByteView` for the case where their
backing source is *not* `ArrayBackedByteView` — structurally unreachable on the real request path
today (`RequestParser` only ever constructs array-backed views), kept because both constructors
are `public` and could in principle be called with an arbitrary `ByteView`. A silent, correct,
allocating fallback was judged preferable to either crashing on a technically-valid input or
deleting a case that only test code could exercise. `Http1HeaderMap.view` has no such fallback
— it is always buffer-backed by construction.
## `EX-19`/`EX-06` (router half): the `FastPathRouterImpl` scratch
`FastPathRouterImpl.RouteScratch` (created once per connection via `AbstractRouter#newScratch`,
replacing the `ThreadLocal<MatchResult>`/`ThreadLocal<MethodPathByteView>` pair, as an opaque
caller-owned object rather than an extension of `ConnectionScratch`) also owns the reusable
path-param arrays and a single long-lived
`PathParams` instance, grown (via `ensureParamCapacity`, doubling) to the largest param count any
route on that connection has ever matched, and repositioned (`PathParams#reset`) rather than
reallocated on every match. `PathParams` gained a second, count-explicit constructor and a public
`reset(ByteView, int)` specifically for this: the reusable arrays can be larger than a given
request's actual param count, so `count` must be tracked independently of `names.length`.
## `EX-25`/`EX-26`: single-allocation `String` construction
`Request.path()`, `PathParams.get()`, and (for the common "no `%`/`+` in the value" case)
`QueryParams.decode` now build their result `String` directly from the backing array via
`new String(array, offset, length, UTF_8)` when the source is `ArrayBackedByteView`, instead of a
byte-at-a-time copy into a scratch `byte[]` followed by a second allocation for the `String`
itself. `QueryParams.decode` scans the value once for `%`/`+` first; only a value that actually
needs percent-decoding pays for the scratch-buffer path — verified to produce byte-identical
output to the always-decode path it bypasses, across clean values, `+`-only, `%XX`-only, invalid
escapes, and mixed queries (`QueryParamsFastPathTest`).
## Performance measurement
`EX-04` (the router's word-at-a-time path) and `EX-33` (the SWAR header-end scan) both carried an
explicit "measure, and keep only if it doesn't cost" requirement. Both were measured together
with the phase's overall zero-allocation contract in one JMH pass, and both were kept.
+92
View File
@@ -0,0 +1,92 @@
# HTTP/1.1 hardening
Audience: operators. This is the document to read when a `400`/`413`/`414`/`431`/`501` shows up
in the logs and it isn't obvious why. Every rejection rule Flash's HTTP/1.1 parser enforces is
listed here with its RFC citation and the status it produces. Contributor-level detail (why each
check is implemented the way it is, the exact code paths) lives in the Javadoc of
`RequestParser`, `ChunkedInputStream`, and `dev.relism.flash.exceptions.MalformedRequestException`.
Every rejection in this document has one thing in common: **the connection is always closed
afterwards, never kept alive.** A rejected request is exactly the situation a smuggling attack
needs a reusable connection for, so none of these rejections offer one — see
`MalformedRequestException`'s Javadoc.
## Request-smuggling defenses (RFC 9112 §6.1)
| Rule | Status | Detail |
|---|---|---|
| `Content-Length` and `Transfer-Encoding` both present | `400` | The canonical CL.TE/TE.CL smuggling vector. Rejected regardless of which header appears first. |
| Multiple `Content-Length` lines with **differing** values | `400` | Identical repeated values are tolerated (RFC 9110 §8.6 permits treating them as one). |
| `Transfer-Encoding` whose **final** coding is not `chunked` | `501` | Flash implements only `chunked`; anything else (`gzip` alone, or `chunked, gzip` — chunked must be *last*) is unsupported. |
## Strict `Content-Length` parsing (RFC 9110 §8.6)
| Input | Status |
|---|---|
| Empty value | `400` |
| Any non-digit byte (including a leading `+` or `-`) | `400` |
| More than 19 digits | `400` |
| Value overflows `Long.MAX_VALUE` | `400` |
| Value exceeds `Http1Limits.MAX_CONTENT_LENGTH` (4 GiB by default) | `413` |
The previous parser silently skipped non-digit characters (`"5abc"` parsed as `5`; `"-1"` parsed
as `1`) instead of rejecting them — this is the fix.
## Header and request-line limits (`Http1Limits`)
| Limit | Default | Status when exceeded |
|---|---|---|
| `MAX_HEADER_COUNT` | 100 | `431 Request Header Fields Too Large` |
| `MAX_HEADER_NAME_LENGTH` | 256 B | `431` |
| `MAX_HEADER_VALUE_LENGTH` | 8192 B | `431` |
| `MAX_REQUEST_LINE_LENGTH` | 8192 B | `431` |
| Header block exceeds `maxHeaderBufferSize` (or the connection ends before it completes) | configurable, default 64 KiB | `431` |
## Line-terminator and header-syntax correctness (RFC 9112 §5)
| Rule | Status |
|---|---|
| A `\r` not immediately followed by `\n` (bare CR) | `400` — a known desynchronization/smuggling surface |
| A header line beginning with whitespace (obsolete line folding, RFC 9112 §5.2) | `400` |
| A header name containing a byte outside RFC 9110 §5.6.2's `tchar` set | `400` |
| A header line with no `:` | `400` |
## Chunked transfer safety (RFC 9112 §7.1, `Http1Limits`)
| Limit | Default | Status when exceeded |
|---|---|---|
| `MAX_CHUNK_SIZE` | 16 MiB | `413` |
| Chunk-size line longer than 16 hex digits | — | `400` |
| `MAX_CHUNK_EXT_LENGTH` (the optional `;name=value` after a chunk size) | 256 B | `400` |
| `MAX_CHUNKS_PER_BODY` | 100 000 | `413` |
| `MAX_TRAILER_COUNT` | 50 | `431` |
| A chunk's data not followed by `\r\n`, or a malformed chunk-size/trailer terminator | — | `400` |
Trailers are consumed within the bounds above and exposed through `Request.trailers()` on both
HTTP/1.1 and HTTP/2.
## Timeouts (`FlashConfiguration`)
| Setting | Default | Covers |
|---|---|---|
| `idleKeepAliveTimeoutMs` | 60 000 | How long a keep-alive connection may sit idle waiting for its next request. |
| `headerReadTimeoutMs` | 10 000 | Once the first byte of a request arrives, how long the full header block may take. |
| `bodyReadTimeoutMs` | 30 000 | How long reading the body (by the handler, or the automatic post-response drain) may take. |
| `shutdownDrainTimeoutMs` | 15 000 | How long graceful shutdown waits for in-flight requests before force-closing. |
These are enforced by an **absolute deadline**, not merely `Socket.setSoTimeout`. A per-read
socket timeout alone never trips against a peer that sends one byte just often enough to keep
each individual read alive (the classic slowloris shape) — see
`dev.relism.flash.transport.BufferedByteSource`'s Javadoc for how the absolute deadline is
implemented on top of the JDK's per-read-only timeout API.
## TLS (RFC 9113 §9.2.2, applies once a listener offers `h2` over ALPN)
- The TLS handshake is forced explicitly (not left to the JDK's lazy on-first-read trigger)
before any protocol decision is made, and is bounded by `headerReadTimeoutMs`.
- When a listener's `TlsConfig.applicationProtocols` includes `"h2"`, the enabled TLS 1.2 cipher
suite list is filtered against the RFC 9113 Appendix A blocklist
(`TlsConfig.TLS12_H2_BLOCKED_CIPHERS`, ~280 entries). TLS 1.3 is never affected — none of its
cipher suites are on that list.
- `FlashConfiguration.http2Enabled` advertises `h2` on TLS listeners. The independent
`http2CleartextEnabled` switch accepts the h2c prior-knowledge preface on plaintext listeners.
+191
View File
@@ -0,0 +1,191 @@
# The message model
Audience: contributors. This is the design record for `dev.relism.flash.models`'s shared
request/response model: what is pooled, what that pooling means for callers, and how HTTP/1.1 and
HTTP/2 retain the same public contract.
## Why this exists
Through Phase 5, `Request`, `RequestLine`, `RequestBody`, and `Response` were all allocated fresh
per request — `DEC-20` measured this at 120.008 B/op for parse+route alone, and traced 100% of it
to these four objects. Phase 6 pools all of them, following the same "one instance per connection,
repositioned via `reset()`, never reallocated" idiom `Http1HeaderMap` and `RequestLine` already
established in earlier phases. This document is the single place that idiom's contract — and the
hazards of misusing it — is written down for the whole model, instead of being re-derived from
each class's own Javadoc.
## What is pooled, and by whom
```
RequestParser (one per connection)
├── Http1HeaderMap headerMap — reset() per request
├── RequestLine requestLine — reset() per request
├── Request request — reset() per request (via Request.forParsed)
├── RequestBody requestBody — reset() per request
├── RequestByteView pathView — reset() per request (EX-42)
├── RequestByteView queryView — reset() per request, only when present (EX-42)
└── RequestByteView protocolView — reset() per request (EX-42)
Http1Connection (one per connection)
└── Response pooledResponse — reset() per request (unless a handler returns its own Response)
FastPathRouterImpl.RouteScratch (one per connection, via AbstractRouter#newScratch)
└── PathParams pathParams — reset() per matched request (see BYTES.md, EX-19)
```
Every one of these follows the same three rules:
1. **One instance per connection**, created once (in `RequestParser`'s or `Http1Connection`'s
constructor, or in `newScratch()`), never re-allocated for the connection's lifetime except a
backing array growing to a new high-water mark (e.g. `RequestParser.buffer` doubling, or
`RouteScratch.ensureParamCapacity`).
2. **`reset(...)` repositions, it does not allocate** — the method that transitions the instance
from "describes request N" to "describes request N+1".
3. **Do not retain past the handler.** A reference captured in a closure, a `CompletableFuture`
continuation, or a background thread and read after the handler returns will observe whatever
the *next* request repositioned the instance to — silently, unless the dev-mode guard below
catches it.
## The dev-mode use-after-recycle guard (`Request`, `Response`)
`Request` and `Response` — the two objects most likely to be captured by user code — additionally
track an `active` flag, set `true` by `reset()` and `false` by `recycle()` (called by
`Http1Connection` once the handler and `drain()` have finished). Every public accessor calls
`checkActive()` first:
```java
private void checkActive() {
if (poisoningEnabled && !active) {
throw new IllegalStateException("... do not retain a Request past the handler ...");
}
}
```
`poisoningEnabled` defaults to `Flash.DEV` (`-Dflash.env=dev`), so this is a zero-cost `static
final`-guarded branch in production and a loud, precise `IllegalStateException` — thrown at the
exact misusing call site — in development. Since `Flash.DEV` is itself `static final` (fixed at
JVM startup) and therefore not something a single test can toggle, both classes expose a
package-private `setPoisoningEnabledForTesting(boolean)` hook purely so
`RequestRecycleGuardTest`/`ResponseRecycleGuardTest` can exercise the dev-mode branch without a
fragile reflective override of a `static final` field — production code never touches it.
`RequestBody`, `RequestLine`, `Http1HeaderMap`, and `PathParams` do **not** carry this guard: they
are reached only through `Request`/`Response` (or, for `PathParams`, through `Request.param`),
so `Request`/`Response`'s own guard already catches a stale read before it would reach these.
## `RequestBody`: two read modes, one reused bounded stream
`RequestBody.stream()` and `.bytes()` are mutually exclusive per request (calling both is
undefined). `EX-23`/`EX-24` (Phase 6) replaced two allocation sources in the streaming path:
- `stream()` used to build a fresh `SequenceInputStream` + `ByteArrayInputStream` + anonymous
bounded `InputStream` on every call. It now repositions one persistent
`BoundedBufferedInputStream` (a private inner class) via `reset(preBuf, preBufOff, preBufLen,
socketRemaining)` — the same object is returned every time, just pointed at different bytes.
- `drain()`'s chunked-body path used to call `InputStream.transferTo`, whose default
implementation allocates a fresh 8 KiB `byte[]` on every call. It now drains through a lazily
created (only if a chunked body is ever actually drained), persistent `drainBuffer`.
`RequestBody.of(byte[])`/`.empty()` remain as freestanding, unpooled factories for test/manual
construction (mirroring `Request`'s own manual constructor) — production's only pooled instance is
the one `RequestParser` owns.
## `Response`: byte-level headers, one write, `ResponseSerializer` as the source of truth
Before Phase 6, `Response.header(String, String)` stored headers as `List<byte[]>` — one `String`
concatenation and one `byte[]` allocation per call. `Response` now stores structured headers in a
`ByteWriter`-backed name/value region plus parallel `int[]` quads (`nameOff, nameLen, valOff,
valLen`), written via `ByteWriter.writeAscii` — zero-allocation on a warm connection. A second,
separate store (`List<byte[]>`) still holds the legacy `header(byte[])` raw-line entries; a tagged
sequence (`headerTags`/`headerRefs`) interleaves the two stores back into declaration order when
serialized, so mixing `header(String,String)` and `header(byte[])` calls on the same response still
produces headers in the order they were added.
`PreEncodedHeader` precomputes a header's name and value ASCII bytes once (for example, a constant
response header set at boot). Preserving the boundary lets HTTP/1.1 render a field line and HTTP/2
encode the same pair through HPACK without a second public header type.
`ResponseSerializer.forEachField(Response, FieldConsumer)` is the **one source of truth for what
headers a response has** — it enumerates `Content-Type` (if set) plus every structured custom
header, in order, and is the only place that knowledge lives. `Http1ResponseWriter` renders that
sequence as `Name: Value\r\n` lines; `Http2ResponseWriter` renders the same sequence as HPACK.
Deliberately excluded: `Content-Length`/`Connection`/`Date` (connection framing, not response
object properties — and HTTP/2 has no `Connection` header at all, RFC 9113 §8.2.2) and raw
`header(byte[])` entries (no recoverable name/value structure to hand the h2 encoder).
`Http1ResponseWriter` (`EX-27`) serializes the entire response head — status line, `Content-Type`,
`Date`, every custom header, `Content-Length`/`Connection` — into
`ConnectionScratch.responseHead` (a reused `ByteWriter`) and issues **one** `OutputStream.write`
call for the head plus any body at or below `Http1Limits.INLINE_BODY_THRESHOLD` (8 KiB), instead
of roughly ten small writes. A larger body is written in a second `write` call right after — folding
it into the head buffer first would cost an extra full-body `memcpy` the syscall reduction does not
pay for. Streaming/chunked bodies write the head, then relay their own bytes as they arrive, by
definition too large or unbounded to fold into one buffer up front.
`Response.header(...)` (any overload) is bounded by `Http1Limits.MAX_RESPONSE_HEADER_BYTES`/
`MAX_RESPONSE_HEADER_COUNT` (`EX-43`) — unlike every other `Http1Limits` constant, this guards
against a bug in the *caller* (a handler looping over an unbounded collection while building
headers) rather than a hostile peer: since `Response` is now pooled per connection, an unbounded
`headerRegion` would otherwise grow for the rest of the connection's lifetime, never shrinking
back down between requests. Both checks throw `IllegalStateException`, not
`MalformedRequestException` — this is an application-code misuse, not a wire-input rejection.
## `HeaderView` / `Http1HeaderMap` (`DEC-22`)
`HeaderMap` split into `HeaderView` (the protocol-neutral read contract: `first`, `all`, `view`,
`valueEqualsIgnoreCase`, `contains`, `count`, `forEach`) and `Http1HeaderMap` (the existing
byte-buffer-backed implementation, kept in `dev.relism.flash.models` rather than moved to
`dev.relism.flash.http1`: `RequestParser` (root package) owns and constructs it, and
`http1`→root already exists via `Http1Connection`, so moving it to `http1` would create a
`models``http1` package cycle). `RequestLine.headers` is typed as the
interface; `Http2HeaderMap` is the HPACK-backed second implementation without requiring a
`Request` or `RequestLine` API split.
## `ByteTemplate` (`EX-28`)
Off the h1 request/response hot path (used only by `ErrorPages`, on 404/500), but in scope because
it was a clean instance of the "precompute at boot" category the phase's own text calls out.
`render(String...)` used a nested loop — for every key-value pair, scan every slot — to find
matching placeholders, and a repeated placeholder name (`{{var}} == {{var}}`) meant a naive
name→single-index map would be wrong. Fixed by mapping each slot name to the (usually
one-element) array of every slot index using that name, built once at construction. A new
`renderInto(byte[], int, String...)` overload writes into a caller-supplied buffer and returns the
length written, for future callers with a reusable scratch buffer available; `render(String...)`
keeps its allocating signature for compatibility.
## `Multipart` (`EX-29`, and `EX-38``EX-41`)
Audited per the plan's mandatory rules for any file over 300 lines. Findings and fixes: an eagerly
buffered part body (text fields, and — during a full `parts()`/`parts(String)` scan — file bodies
too) had no size bound (`EX-38`, fixed with a bounded read capped by
`Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE`); the part count was unbounded (`EX-39`, capped by
`Http1Limits.MAX_MULTIPART_PARTS`); per-part header parsing had neither a header-count nor a
line-length bound (`EX-40`, capped by `Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT`/
`MAX_MULTIPART_HEADER_LINE_LENGTH`); the multipart boundary's length was checked and found to
already be bounded transitively, via `Http1Limits.MAX_HEADER_VALUE_LENGTH` on the `Content-Type`
header it comes from (`EX-41`, a non-finding, recorded so "checked, found fine" isn't mistaken for
"wasn't checked"). None of these bounds apply to `Part.materialize()` on a streaming file part
returned by `Multipart.file()` — that call is documented as an explicit, opt-in heap allocation the
caller chooses to pay for, the same way `RequestBody.bytes()` is.
## `EX-42`: the last per-request allocation, found by re-measuring
Pooling `Request`/`RequestBody`/`RequestLine`/`Response` dropped `RequestPipelineBenchmark`'s
`parseAndRoute` from 120.008 B/op to 48.008 B/op — real progress, but not the 0 B/op the phase's
own DoD text requires. Reading `RequestParser.parse` turned up three `new
FastPathViews.RequestByteView(...)` allocations (path, query when present, protocol) on every
call — pre-existing since at least Phase 4, invisible until the larger `Request`/`RequestBody`/
`RequestLine` cost sitting on top of them was removed. Fixed the same way as everything else in
this document: `RequestByteView` gained a `reset(byte[], int, int)`; `RequestParser` now owns one
pooled instance per role. `parseAndRoute` measures 0.008 B/op after the fix — JMH's noise floor,
effectively 0.
## The zero-alloc contract, closed
> A complete h1 request/response cycle on a warm connection — parse, route with path params, read
> three headers, set two response headers, write a 200 with a `byte[]` body — must be 0 B/op.
`RequestPipelineBenchmark.parseAndRoute` (parse + route with a parametric match, no header/param
access) measures 0 B/op. `parseRouteAndExtractThreeFields` (the same, plus one path param and two
header reads) measures 184.009 B/op — entirely the `String` allocations the contract's own text
exempts ("except for the user-facing `String`s the handler explicitly asks for").
+10
View File
@@ -0,0 +1,10 @@
# Flash core
The parts of Flash shared by every protocol it speaks — HTTP/1.1 and HTTP/2 alike. Protocol-specific
internals (frames, HPACK, stream state) live in [`../http2/`](../http2/README.md).
- [HTTP/1.1 hardening](HTTP1-HARDENING.md) — message-boundary rules, timeouts and negotiation.
- [Transport](TRANSPORT.md) — listeners, connection ownership, TLS and virtual threads.
- [Message model](MESSAGE-MODEL.md) — shared request/response objects and their lifetime contract.
- [Trailers and streaming](TRAILERS-AND-STREAMING.md) — the public cross-protocol APIs.
- [Byte primitives](BYTES.md) — reusable views, scanning and bounded slice lifetimes.
+36
View File
@@ -0,0 +1,36 @@
# Trailers and streaming
Flash exposes the same request and response model on HTTP/1.1 and HTTP/2. Request trailers are
available through `Request.trailers()` after the body has reached EOF. Calling it earlier throws
`IllegalStateException`; this prevents handlers from observing an incomplete trailer section.
HTTP/1.1 reads trailers from the final chunk, while HTTP/2 decodes the trailing HEADERS block in
the connection's existing HPACK context.
Response trailers are added with `Response.trailer(name, value)` or a `PreEncodedHeader`. HTTP/1.1
uses chunked framing and writes the fields after the zero chunk. HTTP/2 writes a trailing HEADERS
block with `END_STREAM`; the final DATA frame deliberately does not carry `END_STREAM`.
`Response.streaming(producer)` is the push alternative to `stream(InputStream, length)` and
`chunked(InputStream)`. Its `ResponseStream` is a bounded blocking bridge. A producer runs on a
virtual thread and blocks when the protocol writer or the HTTP/2 flow-control windows cannot make
progress. This keeps backpressure explicit without callbacks or reactive types:
```java
return response.type("application/grpc").streaming(stream -> {
try {
for (byte[] message : messages) stream.write(message, 0, message.length);
stream.trailer("grpc-status", "0");
} catch (IOException failure) {
throw new UncheckedIOException(failure);
}
});
```
The transport supports the primitives required by gRPC, but the core does not provide protobuf
codecs, generated stubs, service descriptors, or a gRPC service API. Those belong in a future
`flash-ext-grpc` module. `GrpcInteropTest` verifies the boundary with the external `grpcurl` client
and a hand-written wire-format handler.
CONNECT requests follow RFC 9113 request pseudo-header rules: `:authority` is required and
`:scheme`/`:path` are forbidden. Their DATA remains subject to the ordinary request limits,
timeouts and two-level flow control.
+148
View File
@@ -0,0 +1,148 @@
# Transport architecture
Audience: contributors. This document describes the shared listener and connection layer behind
the HTTP/1.1 and HTTP/2 implementations.
## Why this exists
The original `HttpServer` (563 lines) did bind, accept, virtual-thread dispatch, WebSocket
upgrade detection, WebSocket handshake, the WebSocket session loop, keep-alive detection, HTTP
response serialization, chunked encoding, hex encoding, and decimal encoding — eleven reasons to
change in one class (R6). It also held three `ThreadLocal`s that meant "one per connection" under
virtual threads, not "one per core" (`EX-06`), and used `synchronized` around blocking socket
writes in `WebSocketSession`, which pins a virtual thread's carrier on Java 21 (`EX-01`).
The current design replaces it with named, single-responsibility components and a
`ConnectionProtocol` seam implemented by both wire protocols.
## Package layout
```
dev.relism.flash.transport
├── TransportFactory composes everything below; ServerHandle.create()'s implementation (EX-34)
├── ListenerBinder FlashConfiguration.Listener -> bound ServerSocket
├── BoundListener record: the bound socket + whether it is TLS
├── TransportTuning accept-thread count / backlog / socket buffer size constants
├── AcceptLoop one listener's accept loop body
├── ConnectionRunner per-connection setup/teardown: TLS handshake, protocol negotiation,
│ dispatch to a ConnectionProtocol, guaranteed cleanup
├── ConnectionProtocol the h1/h2 seam: void run(ConnectionContext)
├── ConnectionContext everything a ConnectionProtocol needs, bundled (record)
├── ConnectionScratch per-connection reusable buffers (EX-06's fix)
├── ScratchPool a bounded cache of ConnectionScratch instances
├── ServerLifecycle implements ServerHandle: start/startAndBlock/stop, graceful shutdown (EX-32)
├── BufferedByteSource the buffered, deadline-aware, peekable inbound-byte source (Phase 1, EX-10)
└── ProtocolNegotiator/NegotiatedProtocol ALPN + h2c preface detection (Phase 1)
dev.relism.flash.http1
├── Http1Connection implements ConnectionProtocol: the h1 keep-alive request loop
├── Http1ResponseWriter serializes a Response as an HTTP/1.1 message
└── Http1KeepAlive keep-alive decision + the shared Connection-header token scanner (EX-13)
dev.relism.flash.websocket (existing package, extended)
├── WebSocketUpgrade upgrade detection + handshake response
├── WebSocketLoop the session read/dispatch loop
├── WebSocketSession per-connection WS I/O (frame codec + send API), EX-01/EX-11/EX-12
└── WebSocketProtocolException RFC 6455 violation, carries the correct close code
```
## The connection lifecycle
```
TransportFactory.create(configuration, router, wsRouter)
binds every configured listener (ListenerBinder)
builds one ConnectionRunner (shared virtual-thread executor, ScratchPool, both protocols)
returns a ServerLifecycle (implements ServerHandle)
ServerLifecycle.start()
for each listener, spawns TransportTuning.ACCEPT_THREADS platform threads
each runs AcceptLoop.run(listener, runner, this::isStopped)
AcceptLoop.run(...)
loop: listener.socket().accept() -> runner.accept(socket, stopped)
ConnectionRunner.accept(socket, stopped)
submits to the virtual-thread executor -> handle(socket, stopped)
ConnectionRunner.handle(socket, stopped)
activeSockets.add(socket); scratch = scratchPool.acquire()
try:
configure TCP_NODELAY / send buffer size
if SSLSocket: force startHandshake() under headerReadTimeoutMs (EX-30)
wrap streams: BufferedByteSource in, buffered OutputStream out, raw OutputStream rawOut
negotiated = negotiateProtocol(socket, in) # ALPN or h2c preface
build ConnectionContext
dispatch to http1Protocol.run(ctx) or http2Protocol.run(ctx)
finally:
activeSockets.remove(socket); scratchPool.release(scratch)
```
`Http1Connection.run(ConnectionContext)` is where HTTP/1.1 semantics actually live: the
keep-alive loop, the idle/header/body deadline transitions (Phase 1), the `MalformedRequestException`
rejection path, the WebSocket upgrade handoff, and the response write.
## `ConnectionScratch` and `ScratchPool` (`EX-06`)
`ThreadLocal` is the right idiom when "one per thread" means "one per core" — a bounded
platform-thread pool. Flash runs one **virtual** thread per connection
(`Executors.newVirtualThreadPerTaskExecutor()`), so a `ThreadLocal` there means one per
*connection*, with no upper bound: at 100 000 concurrent connections, an 8 KB relay buffer alone
would be ~800 MB that a bounded pool would otherwise cap.
`ConnectionScratch` is therefore an explicit, plain object (decimal-encoding buffer, streaming
relay buffer, the WebSocket-handshake `MessageDigest`) acquired from a `ScratchPool` at
connection start and released at connection end. The pool is a bounded *cache*, not a
leak-free arena: above its bound (`min(availableProcessors * 64, 4096)` by default), a released
scratch is simply dropped for the garbage collector rather than queued, so an unusually large
burst of connections cannot grow it without limit.
The routers use an explicit per-connection scratch passed through `AbstractRouter.route`; neither
`FastPathRouterImpl` nor `FastPathWsRouterImpl` retains connection state in a `ThreadLocal`.
## The `ConnectionProtocol` seam (R1 / `DEC-02`)
```java
public interface ConnectionProtocol {
void run(ConnectionContext ctx) throws IOException;
}
```
`ConnectionRunner` decides h1 vs h2 exactly once, immediately after ALPN/preface detection, and
dispatches to `Http1Connection` or `Http2Connection`. Neither implementation is aware the other
exists — `dev.relism.flash.http1` and `dev.relism.flash.http2` do not import each other, enforced
by `PackageBoundaryTest`.
## Graceful shutdown (`EX-32`)
Two stages, driven by `ServerLifecycle.stop()`:
1. **Stop accepting.** Every listener socket is closed immediately; `stopped` flips to `true`.
2. **Drain, then force-close.** `Http1Connection`'s request loop checks `ctx.stopped()` twice:
once before waiting for the next request (exits immediately if already stopped, rather than
waiting out the idle-keep-alive timeout), and again right before writing the *current*
response — forcing `Connection: close` on it even if the response's own `Connection` header
logic would have said keep-alive, and even if shutdown began *while the handler was running*
(the common case). `ServerLifecycle.stop()` polls `activeSockets` for up to
`shutdownDrainTimeoutMs`, then force-closes whatever remains and shuts down the executor.
HTTP/2 shutdown sends the two-stage `GOAWAY` sequence from RFC 9113 §6.8 before the lifecycle's
drain deadline force-closes remaining sockets.
## What changed for WebSocket (`EX-01`, `EX-11`, `EX-12`, `EX-13`)
- **`EX-01`**: `WebSocketSession`'s two blocking-write sites (`close`, `writeFrame`) now
serialize on a `ReentrantLock` instead of `synchronized (out)` — a virtual thread blocking
inside `synchronized` pins its carrier platform thread on Java 21 (JEP 491, which removes
this, is JDK 24+). `ReentrantLock` unmounts the blocked virtual thread instead.
- **`EX-11`**: `readFrame` used to read the extended-length and mask-key bytes one at a time.
It now reads that whole variable-length remainder in a single bounded `readFully` into the
existing `hdrScratch` array, then decodes with shifts.
- **`EX-12`**: `readFrame` now reassembles continuation frames into one logical message (bounded
by the same buffer a single frame already had), enforces the masking direction RFC 6455 §5.1
requires for this session's role, validates the opcode against the RFC's defined set, enforces
control-frame constraints (not fragmented, ≤125 bytes), and reports violations via
`WebSocketProtocolException` carrying the correct close code (1002 protocol error, 1009
message too big) for `WebSocketLoop` to send before closing.
- **`EX-13`**: the `Connection` header is a comma-separated token list, not a single value —
`Http1KeepAlive.tokenListContains` is the one scanner both the keep-alive decision and
`WebSocketUpgrade`'s `Connection: Upgrade` check use, so they cannot drift apart again.
+44
View File
@@ -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.2711,022.120` ns for Phase 0 and `973.8581,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.
+29
View File
@@ -0,0 +1,29 @@
# HTTP/2 cleartext
TLS HTTP/2 and cleartext HTTP/2 have independent rollout controls:
- `http2Enabled` advertises `h2` through TLS ALPN.
- `http2CleartextEnabled` accepts the HTTP/2 prior-knowledge preface on plaintext listeners.
Both default to `false`. Cleartext support follows RFC 9113 prior knowledge. The obsolete
HTTP/1.1 `Upgrade: h2c` transition is intentionally unsupported.
## Header conversion
`HopByHopHeaders` is the single policy used at connection boundaries. It removes fields named by
`Connection`, the standard hop-by-hop set, HTTP/2-forbidden fields and pseudo-fields. `TE` is
forwarded only as `trailers` when the target is HTTP/2. Tests execute the same policy for all four
HTTP/1.1 and HTTP/2 source/target combinations.
## Authority and 421
On TLS HTTP/2 connections, Flash checks `:authority` against the selected certificate's DNS/IP
subject alternative names. An authority outside that served set receives `421 Misdirected
Request`, allowing a coalescing client to retry on a different connection. Exact names and
single-label wildcards are supported; h2c has no certificate identity and is unaffected.
An outbound HTTP/2 client and reverse-proxy adapter (`Http2Client`, `HttpProxy`) were built
against this cleartext support but had no caller anywhere in `flash` core — an HTTP/1.1+2 server
framework has no business shipping an outbound client. That code has been removed; if a
reverse-proxy capability is needed later, it belongs in its own `flash-extensions/flash-ext-*`
module, not in core.
+84
View File
@@ -0,0 +1,84 @@
# HTTP/2 compliance
This document records the repeatable protocol gate for Flash's HTTP/2 server. The automated
matrix runs from Maven; external tools are selected through system properties so local builds
without them skip only the corresponding interoperability adapter. CI installs and enables every
command-line client listed below.
## h2spec
Validated on 2026-08-13 with h2spec 2.6.0.
| Listener | Cases | Failures | Skips |
|---|---:|---:|---:|
| TLS with ALPN `h2` | 146 | 0 | 0 |
| Cleartext prior knowledge on the mixed HTTP/1.1 + HTTP/2 port | 145 | 0 | 0 |
`H2SpecComplianceTest` parses h2spec's JUnit XML and fails on a failure, error, or skipped case.
The cleartext selection omits only `http2/3.5/2`, which sends a complete invalid HTTP/2 preface.
That case assumes a dedicated HTTP/2 endpoint. Flash deliberately has one cleartext port that
selects HTTP/2 only when the 24-byte prior-knowledge preface matches; any other initial bytes are
HTTP/1.1 input. RFC 9113 section 3.3 defines the exact preface as the cleartext protocol selector,
while section 3.4's `PROTOCOL_ERROR` applies after an endpoint is operating as HTTP/2. The HTTP/2
state machine itself does return `GOAWAY(PROTOCOL_ERROR)` for a complete invalid preface, covered
byte-for-byte by `invalid-preface.hex`. Excluding the mixed-port negotiation case therefore does
not waive an HTTP/2 state-machine requirement.
## Interoperability
Automated results recorded on 2026-08-13:
| Client | Version | Mode and coverage | Result |
|---|---|---|---|
| curl | 8.5.0, libnghttp2 1.59.0 | TLS and h2c; GET, POST, 2 MiB upload/download | pass |
| Java `HttpClient` | Temurin 21.0.11+10 | TLS; GET, POST, large bodies and multiplexing | pass |
| nghttp | nghttp2 1.59.0 | TLS and h2c; verbose SETTINGS/HEADERS/DATA trace, POST and 2 MiB download | pass |
| grpcurl | 1.9.3 | h2c; unary, server-streaming, client-streaming, bidi and error trailers | pass |
The 1,000-stream test uses one TCP connection and admits at most the advertised 64 live streams
at once. This tests 1,000 multiplexed stream lifecycles without contradicting
`SETTINGS_MAX_CONCURRENT_STREAMS` or weakening the production memory bound.
Chrome and Firefox are a release smoke test rather than a CI dependency. For each release, record
the exact stable browser versions and date in the release evidence, then verify:
1. Load a TLS route and confirm `h2` in the browser network protocol column.
2. Exercise GET, POST, a large upload and a large streamed download.
3. Open the same registered WebSocket route over HTTP/1.1 and RFC 8441, exchange a fragmented
message larger than one flow-control window, and close from each side once.
4. Confirm no certificate, console, failed-request, or retry-to-HTTP/1.1 warnings.
This manual row is intentionally not represented as an automated pass: browser release testing
must record the browsers actually shipped at release time rather than a stale development image.
## Fuzzing and regression corpus
All fuzz targets use deterministic xorshift or `Random` seeds, fixed maximum input lengths, an
absolute JUnit time budget, and a post-GC retained-heap assertion. Untyped runtime failures fail
the test immediately. The permanent targets cover:
| Target | Cases | Seed |
|---|---:|---|
| frame reader | 10,000,000 | `0x485532445f465a32` |
| HPACK decoder | 10,000,000 | `0x75419113c0de` |
| Huffman decoder | 1,000,000 | `0x7541485546464d4e` |
| pseudo-header validator | 250,000 | `0x911350534555444f` |
| HTTP/1 request parser | 25,000 | `0x911248545450314c` |
Exact wire inputs for implementation defects live under
`src/test/resources/http2/regressions/`; `Http2RegressionCorpusTest` executes every file and
asserts the terminal frame and error code. The nightly `Http2SoakTest` defaults to ten minutes of
GET, POST, streaming DATA, reset and PING traffic, with retained-heap assertions. A short run can
be requested with `-Dflash.http2.soak=true -Dflash.http2.soak.seconds=10`.
## Deliberately absent features
- HTTP/2 server push is not exposed. A client cannot send `PUSH_PROMISE` to a server (RFC 9113
section 6.6); receiving one is a connection error. Flash does not originate push.
- RFC 7540 dependency-tree priority scheduling is not implemented. RFC 9113 section 5.3.2
deprecates the scheme; PRIORITY frames are validated and ignored as required.
- `Upgrade: h2c` is not implemented. RFC 9113 section 3.1 removed the HTTP/1.1 upgrade mechanism;
cleartext support uses section 3.3 prior knowledge.
These omissions do not create alternate request/response APIs: HTTP/1.1 and HTTP/2 remain peers
behind the transport protocol boundary.
+70
View File
@@ -0,0 +1,70 @@
# HTTP/2 connection control
`Http2Connection` owns only connection-level protocol state. It verifies the preface, drives the
frame reader, dispatches control frames and performs shutdown. HPACK fragment extraction and decode
live in `Http2HeaderBlockDecoder`; socket serialization remains exclusively in
`Http2FrameWriter`. Stream dispatch and application handlers are separate layers.
Each accepted HTTP/2 socket receives a new `Http2Connection`. Sharing the stateless
`Http1Connection` implementation is safe, but sharing an HTTP/2 instance would leak dynamic HPACK,
SETTINGS, flow-control and GOAWAY state between peers.
## Demultiplexing invariant
The demux thread never invokes application work. It reads and validates frames, updates bounded
connection state, and enqueues or directly writes control frames. A registered handler cannot delay
SETTINGS or PING processing. The connection reader polls at a short interval so server shutdown is
observed promptly, while `Http2FrameReader` retains one non-renewable absolute deadline for a
partially received frame; polling therefore does not weaken slow-frame protection.
## Settings
| Identifier | Default | Validation and handling |
|---|---:|---|
| `HEADER_TABLE_SIZE` | 4096 | Unsigned 32-bit; locally capped |
| `ENABLE_PUSH` | 1 | Only 0 or 1; Flash advertises 0 |
| `MAX_CONCURRENT_STREAMS` | unlimited | Unsigned 32-bit |
| `INITIAL_WINDOW_SIZE` | 65535 | At most 2^31-1 |
| `MAX_FRAME_SIZE` | 16384 | 16384 through 16777215 |
| `MAX_HEADER_LIST_SIZE` | unlimited | Unsigned 32-bit |
Unknown identifiers are ignored. A payload is validated as a transaction before values are
committed. The initial-window delta is handed to the stream table as one operation: negative stream
windows are valid, but any result above 2^31-1 rejects the complete update with
`FLOW_CONTROL_ERROR`. Every non-ACK SETTINGS frame receives an empty ACK; locally sent settings are
bounded and have an acknowledgement deadline.
## Priority control writes
`Http2FrameWriter` has one priority MPSC lane in front of its ordinary stream-data lane. PING and
SETTINGS acknowledgements, RST_STREAM and GOAWAY use reusable control intents from the connection
scratch. They can overtake queued DATA but never split or interrupt a socket write already in
progress. Both PING and SETTINGS response queues are bounded.
## Shutdown
Graceful shutdown follows the two-stage protocol:
1. Send GOAWAY with last-stream-id 2^31-1 and `NO_ERROR`.
2. Send a connection PING and wait for its matching ACK, establishing a round trip.
3. Send a second GOAWAY with the real last processed stream id, then close after current work.
A connection error instead sends one GOAWAY with the precise error code, the real last processed
stream id and a bounded diagnostic string. A preface mismatch closes silently because the peer has
not established a valid HTTP/2 connection.
## Verification
The reusable control lifecycle (preface, SETTINGS/ACK, PING/PONG, WINDOW_UPDATE and received
GOAWAY) measures 974.263 ns/op and 0.008 B/op on JDK 21.0.11; the allocation figure is the JMH GC
profiler noise floor with no collections. `curl 8.5.0` using h2c prior knowledge completed the
handshake and observed both clean GOAWAY stages. It exits with code 56 because this phase
deliberately sends no response HEADERS or DATA; those arrive with the response and stream phases.
h2spec 2.6.0 passes 28 of the 35 selected section 3, 4, 6.5, 6.7, 6.8 and 6.9 cases, including all
connection-owned SETTINGS validation, PING, GOAWAY, frame-format, HPACK interleaving and
connection-window cases. Six failures require response HEADERS/DATA or per-stream flow control and
remain assigned to the response, stream and DATA phases. The seventh is h2spec's expectation of a
GOAWAY after an invalid preface; Flash intentionally closes without writing because no valid HTTP/2
connection exists yet, as permitted by RFC 7540 §3.5 and required by this implementation's preface
contract.
+48
View File
@@ -0,0 +1,48 @@
# HTTP/2 bodies and flow control
HTTP/2 applies flow control independently to the connection and to every stream. Flash advertises
a 1 MiB receive window at both levels and sends WINDOW_UPDATE only after the application has
consumed at least half a window. A DATA frame decrements both windows by its complete payload
length, including the pad-length byte and padding; only its unpadded data reaches the handler.
## Request bodies
Known bodies up to 64 KiB remain in one reusable contiguous stream buffer. Their handler is
dispatched at END_STREAM, and `RequestBody.bytes()` performs the only allocation: the byte array
returned to application code. For a 1,024-byte body JMH reports exactly 1,040 B/op, the array plus
its object header, with no framework allocation around it.
Larger or unknown-length bodies dispatch after request headers. DATA is copied out of the frame
reader into a connection-owned pool of 64 reusable 16 KiB buffers. Small adjacent frames coalesce
inside a buffer, so the pool is bounded by bytes rather than frame count. The existing
`RequestBody.stream()` blocks only the handler's virtual thread when data is absent. Buffers return
to the pool as reads consume them, and that consumption reopens both receive windows. If a handler
does not read its body, the normal post-handler drain performs the same bounded consumption.
The connection window and pool both cover exactly 1 MiB, so the peer can never hold more credit
than the server can store before backpressure takes effect. Per-stream accepted body bytes remain
bounded by `MAX_REQUEST_BODY_SIZE`. Declared content length is parsed without a String and checked
against the unpadded DATA total at END_STREAM.
## Responses
Fixed byte arrays, known-length streams and unknown-length streams all use one resumable
`Http2ResponseWriter`. It emits DATA frames no larger than the peer's frame limit, the available
connection window, the available stream window and the reusable 16 KiB relay buffer. A
WINDOW_UPDATE schedules the stream on the shared virtual-thread executor; application streams are
never read by the demultiplexer.
`Response.chunked(InputStream)` means unknown-length streaming at the application API. HTTP/2 has
no chunked transfer coding, so Flash emits ordinary DATA followed by END_STREAM and never sends a
`transfer-encoding` field. `Response.stream(InputStream, length)` emits `content-length` and fails
the stream if the source ends before that length.
## Verification
- A real Java HTTP/2 client uploads and downloads 100 MiB over TLS; both directions are validated
byte-for-byte without materializing the test payload.
- A synthetic 100 MiB response proves serialized scratch storage stays below 64 KiB.
- h2spec sections 5, 6.1, 6.9 and 8: 50 passed, one h2spec-skipped case, zero failures.
- Clean Maven build with JMH sources: 633 tests, no failures.
- JMH request streaming: 159.408 ns/op, 0.001 B/op, no GC.
- JMH response streaming frame: 219.090 ns/op, 0.002 B/op, no GC.
+147
View File
@@ -0,0 +1,147 @@
# The frame layer
Audience: contributors. This is the design record for `dev.relism.flash.http2.frame`'s frame
reading, validation, and writing — the 9-byte header and payload boundary, with no connection
semantics, no streams, and no HPACK above it.
## Why this is simpler than the h1 parser
HTTP/1.1 request parsing must scan for `\r\n\r\n` (`RequestParser`, `ByteScan.indexOfCrLfCrLf`)
because nothing in the h1 wire format states the header block's length up front. HTTP/2 states
every frame's payload length in the first three bytes of its 9-byte header — nothing is ever
scanned for. `Http2FrameReader` is a length-prefixed reader and nothing more: read 9 bytes,
decode the length, ensure that many more bytes are available, done.
## The wire format
```
+-----------------------------------------------+
| Length (24) |
+---------------+---------------+---------------+
| Type (8) | Flags (8) |
+-+-------------+---------------+-------------------------------+
|R| Stream Identifier (31) |
+=+=============================================================+
| Frame Payload (0...) ...
+---------------------------------------------------------------+
```
`R` (RFC 9113 §4.1) is reserved and MUST be ignored on receipt — `FrameHeader.reset` masks it
out of `streamId()` once, so no caller has to remember to.
## Package layout
```
dev.relism.flash.http2.frame
├── FrameType the 10 known types + per-type validation descriptor (min/max length, stream-id rule)
├── FrameFlags END_STREAM/ACK/END_HEADERS/PADDED/PRIORITY bit constants + predicates
├── FrameHeader flyweight over a read buffer: length/type/flags/streamId/payloadOffset
├── Http2FrameReader length-prefixed reader, RequestParser's buffer/compaction discipline
├── FrameValidator table-driven per-type RFC validation, specific error code per rule
├── Padding RFC 9113 §6.1/§6.2 pad-length byte + trailing padding, DATA/HEADERS
├── FrameWriteBuffer beginFrame()/endFrame() length back-patching over a ByteWriter
├── Http2FrameWriter the connection's single serialized writer
├── WriteIntent caller-owned serialized frame batch
└── IntrusiveMpscQueue allocation-free contended-write queue
```
## The validation table
Every rule below is enforced by `FrameValidator.validate(FrameHeader, insideHeaderBlock)`, in
this order: unknown-type handling, `SETTINGS`' modulus-6 special case, the generic
min/max length bounds, the `MAX_FRAME_SIZE_LOCAL` ceiling, the stream-id rule, then
`PUSH_PROMISE`'s always-reject rule.
| Type | Code | Length | Stream id | Notes / RFC |
|---|---|---|---|---|
| DATA | 0x0 | 0..MAX_FRAME_SIZE | required (≠0) | §6.1. Padding via `Padding.unpad`. |
| HEADERS | 0x1 | 0..MAX_FRAME_SIZE | required (≠0) | §6.2. Padding and optional PRIORITY fields are parsed before HPACK. |
| PRIORITY | 0x2 | exactly 5 | required (≠0) | §6.3. Deprecated (§5.3.2) — parsed, discarded, never acted on. |
| RST_STREAM | 0x3 | exactly 4 | required (≠0) | §6.4. The 4 bytes are the error code. |
| SETTINGS | 0x4 | multiple of 6 | forbidden (=0) | §6.5. Modulus checked before the generic bounds. |
| PUSH_PROMISE | 0x5 | ≥4 | required (≠0) | §6.6. Always `PROTOCOL_ERROR` from a client — never sent by Flash. |
| PING | 0x6 | exactly 8 | forbidden (=0) | §6.7. Opaque 8-byte payload, echoed on ACK. |
| GOAWAY | 0x7 | ≥8 | forbidden (=0) | §6.8. Last-stream-id (4) + error code (4) + optional debug data. |
| WINDOW_UPDATE | 0x8 | exactly 4 | either | §6.9. 0 = connection window, ≠0 = one stream's window. |
| CONTINUATION | 0x9 | 0..MAX_FRAME_SIZE | required (≠0) | §6.10. Continues a header block; see the flood guard below. |
| *(unrecognised)* | >0x9 | — | — | §4.1: ignored outside a header block, `PROTOCOL_ERROR` inside one (§6.10). |
**The error code is not uniform per type** — a `SETTINGS` frame with a bad length is
`FRAME_SIZE_ERROR`; the same frame with a non-zero stream id is `PROTOCOL_ERROR`. Every violation
in the table above carries its own RFC citation and the specific code that citation mandates;
`FrameValidatorTest` has one test per row asserting the exact code, not merely "an exception".
## Ignore vs. reject policy
RFC 9113 §4.1 makes unknown frame types part of the protocol's extension mechanism: an endpoint
that does not recognise a type MUST read and discard its payload, never reject the connection for
it. `FrameType.fromCode` returns `null` for anything above `CONTINUATION` (0x9); `FrameHeader`
still exposes the raw `typeCode()` for logging even when `type()` is `null`.
The one exception (§6.10): if an unrecognised-type frame arrives **between** a HEADERS/
PUSH_PROMISE frame that lacked `END_HEADERS` and the CONTINUATION that eventually sets it, the
HPACK decoder's state has nowhere to put that frame's bytes without desynchronizing — so this one
case *is* a `PROTOCOL_ERROR`, tracked by `FrameValidator.validate`'s `insideHeaderBlock`
parameter (owned and threaded through by the connection loop, which is the only caller
that knows whether a header block is currently open).
`PRIORITY` frames are a different kind of "ignore": they are a recognised, well-formed type that
Flash chooses not to act on (RFC 9113 §5.3.2 deprecates priority signalling and permits an
implementation to disregard it) — they are still fully parsed and validated like any other frame,
just never influence scheduling. `PUSH_PROMISE` is the opposite: recognised, but **always**
rejected when received (Flash advertises `SETTINGS_ENABLE_PUSH=0` and never sends one itself), so
receiving one at all can only mean the peer has the client/server roles backwards.
## Buffer discipline and the frame-size defence
`Http2FrameReader` never grows its buffer to accommodate a declared length before checking that
length against `Http2Limits.MAX_FRAME_SIZE_LOCAL` — the check happens first, so a hostile 16 MB
declared length is rejected at the cost of reading 9 bytes, not at the cost of a 16 MB
allocation. This mirrors `RequestParser`'s own `EX-08` discipline (bound the request line before
trusting it) applied to the frame layer's own attack surface.
The buffer itself follows `RequestParser`'s compact-before-grow policy: unconsumed bytes slide to
offset 0 when there is room to do so without growing, and growth only happens when compaction
alone cannot make room — bounded, because the reader's own length check already rejected
anything that would require growing past `9 + MAX_FRAME_SIZE_LOCAL`.
## Padding
`Padding.unpad` locates the actual data range within a `PADDED` frame's payload: 1 byte of
pad-length, then data, then that many padding bytes (whose contents carry no meaning — they exist
only to obscure payload size from network observers). A pad length greater than or equal to the
whole payload length is `PROTOCOL_ERROR` (RFC 9113 §6.1), checked before any arithmetic that
could otherwise underflow. Flow-control accounting for padded DATA frames (RFC 9113 §6.9.1: the
*whole* payload counts against the window, not just the data) is applied by
`Http2FlowController`; `Padding` only locates the data range.
## Writing: `FrameWriteBuffer`'s back-patching
A frame's length is rarely known before its payload is serialized (an HPACK-encoded header block,
in particular, has no cheap way to be measured in advance). `FrameWriteBuffer.beginFrame` writes
a 9-byte header with a placeholder length; the caller writes the payload directly through the
same `ByteWriter`; `endFrame` computes the actual length from how far the writer has advanced and
rewrites the three length bytes in place. This is *why* `Http2FrameWriter` serializes a
complete buffer before ever taking the connection lock, rather than streaming bytes as they are
produced — streaming would need the length upfront, which back-patching deliberately avoids
needing.
## Buffered-source deadline regression
`BufferedByteSource`'s deadline mechanism (`EX-07`'s actual fix) turned out to have zero dedicated
unit tests and an unconditional `socket.setSoTimeout(...)` call that NPE'd against the `null`
socket every isolated unit test in this codebase uses. Found while writing
`Http2FrameReaderTest`, fixed, and given its own regression suite (`BufferedByteSourceTest`).
## Testing
- `Http2FrameReaderTest` — round-trips every frame type, boundary lengths (0, 1, 16383, 16384,
16385), a frame split across three socket reads, a frame exactly filling the initial buffer,
multiple sequential frames, clean-EOF-vs-mid-frame-EOF, and reserved-bit masking.
- `FrameValidatorTest` — one test per RFC-mandated rejection above, asserting the specific
`Http2ErrorCode`.
- `Http2FrameReaderFuzzTest` — 10 000 000 random-length (064 byte), random-content inputs; only
`Http2Exception`, `EOFException`, or `SocketTimeoutException` may escape. Green, ~14s.
- `PaddingTest` — every boundary of the pad-length arithmetic, including the exact
`padLength == payloadLength - 1` (maximum valid) and `padLength >= payloadLength` (rejected)
cases.
+82
View File
@@ -0,0 +1,82 @@
# HPACK decoder
This document records the implementation constraints of Flash's RFC 7541 decoder. It is
contributor documentation, not application API documentation.
## Representation model
`HpackDecoder` accepts all RFC 7541 field representations: indexed fields, literals with
incremental indexing, literals without indexing, never-indexed literals, and dynamic-table size
updates. Prefix integers are bounded against overflow, Huffman padding and EOS are validated, and
decoded string lengths are checked while bytes are produced.
The static table is stored as 61 immutable name/value byte pairs. Encoder-oriented reverse lookup
uses fixed open-addressed integer tables built during class initialization; lookup never converts
header bytes to `String` and never calls `HashMap` on the hot path.
The dynamic table owns a bounded byte arena and a ring of primitive entry descriptors. Entry size
is `name length + value length + 32`, and eviction is oldest-first as required by RFC 7541 §4.1.
When the arena tail is too short, live entries are compacted into a contiguous prefix. This avoids
segmented views in every consumer and keeps indexed fields cheap to copy.
## Ownership and eviction safety
Views emitted by `HeaderSink` are callback-scoped. Production decoding targets a reusable
`HpackHeaderBlock` owned by the stream, which copies each name and value into its own arena.
The copy is required for correctness. Consider stream A referencing a dynamic-table entry while
its handler is running. The connection thread can then decode stream B, evict that entry, and
reuse its bytes. If stream A retained the dynamic-table view, its headers would silently change.
Per-stream storage removes that race without reference counting or synchronization.
The precise copy model is:
- HTTP/1.1 copies nothing per request but scans header bytes in the connection buffer.
- HTTP/2 copies decoded request headers into stream-owned storage because multiplexed handlers
outlive subsequent HPACK mutations.
- Novel incrementally-indexed fields are also copied once into the connection's dynamic table.
`HpackEvictionRaceTest` contains both the unsafe borrowed-view demonstration and the stable
stream-owned result.
## Header-list rejection
The decoder counts RFC header-list size cumulatively. Once the configured limit is crossed it
stops emitting fields, but continues parsing the entire block and applying dynamic-table updates.
Only after the block ends does it throw `HeaderListSizeException`. The stream layer can reject the
request while the connection's compression state remains synchronized.
## CONTINUATION assembly
`ContinuationAssembler` copies HEADERS and CONTINUATION fragments into one bounded connection
buffer. It rejects interleaving, stream-id changes, excessive continuation count, and blocks that
exceed the configured capacity. `SegmentedByteView` is intentionally not used here: RFC 9113 §6.10
requires a contiguous, non-interleaved continuation sequence, and one bounded copy makes the HPACK
decoder and all downstream views simpler.
## Verification
- RFC 7541 Appendix C.1C.6 vectors, including dynamic-table state after every sequence.
- Invalid integer, Huffman, index, size-update, and header-list inputs.
- Ten million deterministic random blocks; only typed protocol rejections may escape.
- JMH `-prof gc`: `decodeStaticRequest` measured 102.725 ns/op and 0.001 B/op on JDK 21.0.11. The
latter is the profiler's sampling noise floor; no garbage collections occurred.
## Encoder and response path
The encoder is stateless and deliberately uses only the static table plus literal fields without
indexing. It emits a dynamic-table-size update of zero at the start of the connection's first
response block. This avoids mutable compression state shared by concurrent streams; the trade-off
is a few more wire bytes for repeated custom response fields.
Status and known content-type fields are HPACK-encoded during class initialization. The cached Date
header refreshes both its HTTP/1 and HPACK forms once per second. Runtime values are raw literals by
default; `FlashConfiguration.h2HuffmanDynamicValues` enables Huffman coding when deployment-specific
measurements justify its CPU/wire-size trade-off.
`Http2ResponseWriter` is reusable per stream. It lowercases field names, removes forbidden
connection-specific fields, enforces the peer's header-list bound, keeps HEADERS and CONTINUATION
frames in one write intent, and appends a small fixed DATA body when flow-control permits.
JMH `-prof gc` measured the representative response path at 174.309 ns/op and 0.001 B/op on JDK
21.0.11, with no garbage collections. The reported allocation is the profiler noise floor.
+99
View File
@@ -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.
+43
View File
@@ -0,0 +1,43 @@
# HTTP/2 in Flash
Flash treats HTTP/1.1 and HTTP/2 as peer transports behind one connection boundary. TLS ALPN or
the cleartext prior-knowledge preface selects a protocol once; both paths then feed the same
router, `Request`, `Response`, handler, trailer, streaming and WebSocket APIs. HTTP/2 adds a
bounded frame decoder, HPACK codec, stream state machine, two-level flow control and one serialized
writer per connection. Application code does not branch on the wire protocol.
The implementation is deliberately layered:
```text
listener / TLS
-> protocol negotiation
-> HTTP/1.1 parser ---------+
-> HTTP/2 frames + HPACK ----+-> shared request model -> router -> handler
shared response model
<- HTTP/1.1 serializer -----+
<- HTTP/2 stream writer ----+
```
This page covers the HTTP/2-specific layers only. The transport, message model, and byte
primitives shared with HTTP/1.1 live in [`../core/`](../core/README.md).
## Protocol layers
- [Connection](CONNECTION.md) and [streams](STREAMS.md) — HTTP/2 connection and stream state.
- [Flow control](FLOW-CONTROL.md) — request backpressure and streamed responses.
- [Cleartext](CLEARTEXT.md) — prior knowledge and the 421 misdirected-request rule.
- [WebSockets](WEBSOCKET.md) — RFC 8441 extended CONNECT using the existing WebSocket API.
## Wire internals
- [Serialized writer](WRITER.md) — the single-owner output path and contention model.
- [Frames](FRAMES.md) — frame parsing, validation and error scope.
- [HPACK](HPACK.md) — integer/Huffman coding and static/dynamic table ownership.
## Operate and verify
- [Security](SECURITY.md) — every HTTP/2 limit, default and abuse control.
- [Troubleshooting](TROUBLESHOOTING.md) — GOAWAY/RST_STREAM diagnosis and protocol tracing.
- [Compliance](COMPLIANCE.md) — h2spec, interoperability, fuzzing and deliberate omissions.
- [Performance](PERFORMANCE.md) and [CI baselines](BASELINES.md) — measurements and regression
gates, including the comparison with nghttpd.
+42
View File
@@ -0,0 +1,42 @@
# HTTP/2 security controls
HTTP/2 multiplexing lets one connection create disproportionate parser, stream and response work.
Flash therefore combines structural bounds, flow-control bounds and rate bounds. Rate counters use
two fixed half-window buckets, allocate nothing per frame and need no timer thread.
JMH on JDK 21 measures one rate-counter increment at 38.083 ns/op and approximately
`10^-4 B/op` (allocation noise floor, no GC).
| Limit | Default | Defence / tuning guidance |
|---|---:|---|
| `MAX_CONCURRENT_STREAMS` | 64 | Bounds simultaneously retained stream state. |
| `MAX_STREAMS_CREATED_PER_INTERVAL` | 400 / 10 s | Companion to Rapid Reset; tune with `h2MaxStreamsCreatedPerInterval`. |
| `MAX_RESET_STREAMS_PER_INTERVAL` | 200 / 10 s | CVE-2023-44487 Rapid Reset; tune with `h2MaxResetStreamsPerInterval`. |
| `MAX_CONTINUATION_FRAMES_PER_BLOCK` | 8 | CVE-2024-27316 CONTINUATION flood. |
| `MAX_HEADER_LIST_SIZE` | 32 KiB | Stops HPACK expansion before fields reach stream storage. |
| `MAX_HPACK_STRING_LENGTH` | 8 KiB | Bounds one decoded literal, including Huffman expansion. |
| `MAX_SETTINGS_PER_INTERVAL` | 100 / 10 s | Bounds mandatory SETTINGS acknowledgements. |
| `MAX_PINGS_PER_INTERVAL` | 200 / 10 s | Bounds mandatory PING acknowledgements. |
| `MAX_USELESS_FRAMES_PER_INTERVAL` | 10,000 / 10 s | Aggregate CPU bound for PRIORITY, WINDOW_UPDATE, empty DATA and unknown frames. |
| `MAX_SETTINGS_ACK_QUEUE_DEPTH` | 64 | Bounds queued SETTINGS control writes. |
| `MAX_PING_QUEUE_DEPTH` | 64 | Bounds queued PING control writes. |
| `MAX_EMPTY_DATA_FRAMES_PER_STREAM` | 1,000 | Stops DATA work that spends no flow-control credit. |
| `INITIAL_WINDOW_SIZE_LOCAL` | 1 MiB | Matches the bounded DATA pool; consumption, not receipt, returns credit. |
| `MAX_REQUEST_BODY_SIZE` | 100 MiB | Hard per-stream request body bound. |
| `HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS` | 10 s | Absolute HEADERS-to-END_HEADERS deadline. |
| `STREAM_IDLE_TIMEOUT_MS` | 60 s | Cancels retained inactive streams; tune with `h2StreamIdleTimeoutMs`. |
| `FRAME_READ_TIMEOUT_MS` | 20 s | Absolute partial-frame deadline. |
| `WRITE_TIMEOUT_MS` | 30 s | Interrupts a socket writer blocked by a peer that stopped reading. |
| `MAX_STREAMS_PER_CONNECTION` | 100,000 | Optional connection churn budget; zero disables, tune with `h2MaxStreamsPerConnection`. |
| `MAX_BYTES_PER_CONNECTION` | disabled | Optional wire-byte budget; tune with `h2MaxBytesPerConnection`. |
| `MAX_CONNECTION_LIFETIME_MS` | disabled | Optional lifetime rotation; tune with `h2MaxConnectionLifetimeMs`. |
`h2AbuseRateIntervalMs` changes the rolling interval for reset and stream-creation operator
limits. Breaching a connection-wide rate or budget produces GOAWAY `ENHANCE_YOUR_CALM`; malformed
stream-local messages use the RFC-defined stream error. The ordinary write queue is bounded by the
64 live streams and their single-in-flight response intent; control writes use the fixed scratch
slots above, so a slow reader cannot create an unbounded application queue.
The security suite covers Rapid Reset, stream churn, SETTINGS/PING/non-progress floods, 100,000
CONTINUATION frames, HPACK expansion, malformed names/pseudo-fields, configurable resource budgets,
header assembly deadlines and idle-stream cancellation. HTTP/1 request/header/body security tests
remain in the full suite and the shared public message model uses the same bounds on both paths.
+48
View File
@@ -0,0 +1,48 @@
# HTTP/2 streams and request dispatch
Each connection owns a fixed-capacity `Http2StreamTable`. Client stream identifiers are validated
as odd, non-zero and strictly increasing before a stream object is acquired. The table uses
primitive open addressing and a bounded object free list; it never grows beyond the advertised 64
concurrent streams. An excess request receives `REFUSED_STREAM`, allowing the peer to retry it.
## State model
`Http2StreamState` represents `IDLE`, `OPEN`, `HALF_CLOSED_REMOTE`, `HALF_CLOSED_LOCAL` and
`CLOSED`. A class-initialized table maps every receive/send event to either its next state or the
correct stream error. Frames racing with a recently closed stream follow RFC 9113 §5.1 rather than
being rejected uniformly.
## Header and request model
Decoded HPACK fields are copied into storage owned by the stream. Before dispatch,
`PseudoHeaders` enforces ordering, uniqueness, required request pseudo-fields, lowercase regular
names, connection-specific-field rejection, the `te: trailers` exception and host/authority
consistency. Pseudo-fields are not exposed as regular headers; `:authority` is also visible as
`host` so existing middleware sees the same authority through HTTP/1.1 and HTTP/2.
The stream assembles the existing protocol-neutral `Request`, `RequestLine`, `RequestBody` and
`HeaderView` models. Path/query splitting, routing, middleware, not-found handling and exception
handling therefore use the same code as HTTP/1.1. `FastPathRouterImpl` is unchanged.
## Dispatch and ownership
The connection thread decodes and validates frames only. Completed bodyless streams are queued in
a fixed array while more frame bytes are already buffered, then submitted to the server's shared
virtual-thread executor before the demultiplexer waits for the network again. This preserves burst
admission semantics without adding a dispatch timer or blocking the connection thread.
The stream owns its pooled request, response, body, decoded-header arena and response writer.
Normal response completion releases it through the serialized writer callback. RST_STREAM marks a
queued or running stream cancelled and defers release to that sole owner; setup, routing and handler
failures send an appropriate stream reset and release in the failure path. A 100,000-cycle test
proves stable pool counts, and an immediate request/reset/request regression test covers reuse
while dispatch is pending.
## Verification
- The complete clean Maven/JMH suite is recorded in `COMPLIANCE.md` and `PERFORMANCE.md`.
- h2spec sections 5 and 8 pass after request DATA byte accounting landed.
- Java `HttpClient` negotiates HTTP/2 over TLS and runs an existing parameterized route unchanged.
- curl prior-knowledge h2c receives a valid `200` response and body.
- The pooled lifecycle (HPACK decode, request assembly, response write and release) remains a
zero-GC CI gate; current percentile and allocation baselines live in `BASELINES.md`.
+73
View File
@@ -0,0 +1,73 @@
# HTTP/2 troubleshooting
## Confirm which protocol was selected
For TLS, the client and server must both offer `h2` through ALPN. Enable
`FlashConfiguration.http2Enabled`, use a certificate valid for the requested hostname, then check
with `curl --http2 -v https://host/path` or `nghttp -nv https://host/path`. The trace must report
ALPN `h2`; a successful HTTP/1.1 response usually means HTTP/2 was not enabled or the client did
not offer it.
For plaintext, enable `http2CleartextEnabled` and use prior knowledge:
```bash
curl --http2-prior-knowledge -v http://host:port/path
nghttp -nv http://host:port/path
```
Flash does not support `Upgrade: h2c`. A client configured for Upgrade rather than prior knowledge
will remain on HTTP/1.1.
## Read GOAWAY and RST_STREAM
GOAWAY terminates or drains a connection; `last_stream_id` identifies the highest client stream
the server may have processed. A client may retry a stream above that id only when its own request
semantics make retry safe. RST_STREAM affects one stream and leaves the connection usable.
| Error | What it usually means | What to check |
|---|---|---|
| `NO_ERROR` | Graceful shutdown or connection rotation. | Server lifecycle and configured connection lifetime. |
| `PROTOCOL_ERROR` | Invalid preface, pseudo-header ordering, stream state or frame semantics. | A verbose frame trace and the first rejected stream. |
| `INTERNAL_ERROR` | Handler, response production or I/O failed unexpectedly. | The server exception immediately preceding stream cancellation. |
| `FLOW_CONTROL_ERROR` | A window overflow or DATA exceeded available credit. | Client flow-control implementation and SETTINGS deltas. |
| `SETTINGS_TIMEOUT` | The peer did not complete required SETTINGS progress. | Network stalls or a non-compliant peer. |
| `STREAM_CLOSED` | A frame targeted a stream whose remote side or whole lifecycle was closed. | Late DATA/HEADERS and duplicate terminal frames. |
| `FRAME_SIZE_ERROR` | A frame length violated its type or the negotiated maximum. | The nine-byte frame header and peer frame-size configuration. |
| `REFUSED_STREAM` | Live or pending-output capacity was temporarily exhausted. | Client concurrency versus the advertised maximum; retry only when safe. |
| `CANCEL` | The request, handler or streamed response was cancelled. | Client cancellation and application producer logs. |
| `COMPRESSION_ERROR` | HPACK integer, Huffman, index or table update was invalid. | Header-block bytes and whether an intermediary rewrote them. |
| `CONNECT_ERROR` | A CONNECT tunnel failed. | Upstream tunnel or extended-CONNECT negotiation. |
| `ENHANCE_YOUR_CALM` | A configured abuse, rate, header, body or queue bound was exceeded. | [Security controls](SECURITY.md) and traffic rate before increasing a limit. |
| `INADEQUATE_SECURITY` | TLS does not meet HTTP/2 requirements. | TLS version, cipher suite and ALPN configuration. |
| `HTTP_1_1_REQUIRED` | The peer should retry using HTTP/1.1. | Protocol policy and intermediary compatibility. |
Flash caps GOAWAY debug data, and clients must not depend on it being present. The numeric error
code and last stream id are the reliable diagnostic fields.
## Capture a frame trace
Flash does not log every frame in production: frame logs leak header and traffic metadata and add
work to the hottest connection loop. Reproduce against a verbose client instead:
```bash
nghttp -nv https://host/path
curl --http2 -v https://host/path
```
`nghttp -nv` prints SETTINGS, HEADERS, DATA, WINDOW_UPDATE, RST_STREAM and GOAWAY in wire order. For
a server-side-only failure, capture the connection with an approved packet tool; TLS traffic must
be decrypted in a controlled environment. Never attach production header blocks or payloads to a
ticket without redacting credentials and personal data.
## Common misconfiguration patterns
1. **HTTP/2 switch disabled.** `http2Enabled` controls TLS ALPN and
`http2CleartextEnabled` controls prior knowledge independently.
2. **Wrong cleartext mode.** The client sends `Upgrade: h2c`; Flash expects the RFC 9113 prior-
knowledge preface on the shared plaintext listener.
3. **ALPN or certificate mismatch.** A custom `TlsConfig.ofContext` omits `h2`, or hostname
verification rejects the certificate before HTTP/2 starts. Inspect the TLS handshake first.
If a connection closes under load rather than at startup, compare the observed rate and retained
stream count with [the security defaults](SECURITY.md), especially reset/stream creation budgets,
the 64 concurrent-stream setting, header assembly time and stream idle time.
+52
View File
@@ -0,0 +1,52 @@
# WebSockets over HTTP/2
Flash implements RFC 8441 extended CONNECT alongside the existing HTTP/1.1 WebSocket upgrade.
Both transports resolve the same `ws(path, handler)` registration through `AbstractWsRouter` and
run the same `WebSocketSession`, frame parser, handler callbacks, and close lifecycle.
## Protocol negotiation
Every HTTP/2 server connection advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL` (`0x8`) with value
`1`. A WebSocket request uses this pseudo-header shape:
```text
:method CONNECT
:protocol websocket
:scheme https # or http
:authority example.com
:path /live
```
The normal HTTP/1.1 upgrade fields (`Connection`, `Upgrade`, `Sec-WebSocket-Key`, and
`Sec-WebSocket-Accept`) are neither required nor permitted on this path. A matched route receives
status `200`; a missing route receives `404`.
## Shared application behavior
At the router boundary, an extended CONNECT for `websocket` is represented as a GET so the
existing WebSocket router can be reused without a second registration table or protocol-specific
handler API. The wire validator retains the original CONNECT semantics and rejects malformed
pseudo-header combinations before dispatch.
Request DATA is exposed through the existing streaming `RequestBody`. WebSocket output passes
through the common push-style `ResponseStream`, so HTTP/2 stream and connection flow-control
windows apply without changing the WebSocket codec. Messages may cross any number of DATA-frame
boundaries; those boundaries are invisible to RFC 6455 framing. Client-to-server masking remains
mandatory and is validated by the same frame parser used for HTTP/1.1.
## Lifecycle and backpressure
Response HEADERS are sent before the push producer is allowed to wait for request DATA. This is
required for a full-duplex protocol: waiting for the first WebSocket frame before publishing the
successful CONNECT response would deadlock compliant clients. Subsequent response batches block
behind the bounded response bridge and resume when HTTP/2 flow-control credit becomes available.
Handler failures from `onOpen` or `onMessage` are reported through `onError`; `onClose` is invoked
once and the transport is released even if the close callback itself fails.
## Verification
`WebSocketOverH2Test` exercises the extended CONNECT exchange, fragmented text, masking, graceful
close, and a binary message larger than the initial one-mebibyte stream window.
`WebSocketParityTest` sends the same message through one route and handler over HTTP/1.1 and
HTTP/2 and compares the result byte for byte.
+275
View File
@@ -0,0 +1,275 @@
# The serialized frame writer
Audience: contributors. This is the design record and benchmark evidence for
`dev.relism.flash.http2.frame.Http2FrameWriter`, the one component every HTTP/2 write in this
codebase passes through. It was the central architectural risk: frames, HPACK and flow control are
table-driven, but multiplexed streams require concurrent producers to share one socket without
interleaving bytes or pinning carrier threads. The measured gate is recorded below.
## The problem, precisely
Under HTTP/1.1, one virtual thread owns one connection's socket for the request/response it is
currently serving; there is never a second writer. Under HTTP/2, N streams share one connection
and their frames must interleave on the wire, so every write must pass through a serialization
point that plain HTTP/1.1 never needed. A lock taken naively per frame — `synchronized` or an
uncontended `ReentrantLock.lock()` — costs more per write than every allocation this codebase has
ever saved elsewhere (`EX-04` through `EX-29`), because it sits on the one path every response,
of either protocol width, eventually goes through.
## The design, three layers
**Layer 1 — serialize outside the lock.** By the time `Http2FrameWriter.write(WriteIntent)` is
called, the caller (a stream, or a connection-level singleton such as a precompiled SETTINGS ACK)
has already built its complete frame — header, HPACK block, payload — into a buffer it owns. The
writer never serializes anything; it holds the lock only for the duration of one bulk
`sink.write(buffer, offset, length)` call, never for a sequence of small writes. This is why
`EX-27` (collapsing `HttpServer.writeResponse`'s ~10 small writes into one) is a prerequisite for
HTTP/1.1 too; `Http1ResponseWriter` now follows the same bulk-write discipline.
**Layer 2 — `ReentrantLock`, never `synchronized`.** On Java 21, a virtual thread that blocks
inside a `synchronized` block pins its carrier platform thread (JEP 491, which removes this,
only lands in JDK 24+). Blocking on a `ReentrantLock` unmounts the virtual thread instead. This
is the same fix `EX-01` applies to `WebSocketSession`, generalized to the connection writer where
it matters far more (N streams instead of one WebSocket session). `ReentrantLock` is load-bearing
for a second reason `synchronized` cannot offer: `tryLock()`.
**Layer 3 — `tryLock()` fast path, intrusive MPSC fallback.** The overwhelmingly common instant,
even on a genuinely multiplexed connection, has exactly one stream wanting to write: a browser
calling one API endpoint, a gRPC unary call. `tryLock()` on an uncontended lock is one successful
CAS; the calling thread writes inline and releases — no handoff, no queue touched, no allocation,
no context switch. Only when `tryLock()` fails — genuine contention, genuine multiplexing — does
the intent get published through `IntrusiveMpscQueue` (one more CAS, still zero allocation: the
`WriteIntent` itself is the queue node, via `mpscNext()`/`setMpscNext`) for the current lock
holder to drain.
```
happy path (1 active writer): tryLock → sink.write → unlock ≈ 1 CAS
contended (N active writers): tryLock fails → CAS enqueue → return
current holder drains the queue before unlocking
```
### Why the fast path checks `queue.hasWork()`, not just `tryLock()`
Found by this phase's own stress test at N=64/256 — exactly the class of bug R10 exists to catch
before it ships, not after. Writing an intent immediately, ahead of anything already queued, is
only safe when nothing is already queued. Without the `hasWork()` guard:
1. Producer P calls `write(a)`, then `write(b)`. Both contend (someone else holds the lock) and
both get queued — fire-and-forget from P's point of view.
2. The current holder is *about* to drain them but has not yet done so.
3. P's very next call, `write(c)`, finds the lock free (the holder released it between P's calls)
and — without the guard — would write `c` directly, landing it on the wire *before* `a` and
`b`, which are still sitting in the queue.
`write()` therefore checks `!queue.hasWork() && lock.tryLock()` before taking the direct path:
"bypass the queue" only happens when the queue is observed genuinely empty, i.e. everything any
producer has ever offered has already been written. `hasWork()` never false-negatives (it would
only ever wrongly report work that isn't there, which just costs an extra harmless `tryLock()`
attempt), so this preserves per-producer ordering without adding a false rejection of the fast
path.
## Lost-wakeup avoidance
The classic hazard for a design like this: a producer offers its intent to the queue at the exact
moment the current lock holder has just found the queue empty and is about to unlock. Without
care, the item is stranded — offered, but nobody left to drain it, and the producer already
returned believing the write is in flight.
```
Producer P Holder H (currently draining, about to unlock)
─────────── ──────────────────────────────────────────────
next = queue.poll() // null: queue looks empty
queue.offer(intent) ← races here →
if (lock.tryLock()) lock.unlock()
drive(null) // P's own second chance: if P wins the tryLock() race
// immediately after H's unlock(), P itself becomes the new
// holder and drains — including its own just-offered intent.
```
Two cooperating mechanisms close this, and both are required — neither alone is sufficient:
1. **The producer's own second chance.** After a failed `tryLock()`, `write()` offers the intent
*then* immediately attempts `tryLock()` again. If H has already unlocked by this point, P wins
the second `tryLock()` and drains the queue itself (`drive(null)` — draining whatever is
queued, which necessarily includes the intent P just offered, since `offer()` had
already completed).
2. **The holder's re-check-after-unlock loop**, in `drive()`: after `unlock()`, re-read
`queue.hasWork()`. If non-empty, attempt `tryLock()` again and drain, then unlock and re-check
once more — looping, because this recheck cycle can itself race the same way a first pass can.
If a second `tryLock()` in this loop fails, some *other* thread now holds the lock, and by the
same argument that other holder's own re-check-after-unlock covers the item once it releases.
The correctness argument for why together these are sufficient is a happens-before chain through
the queue's `AtomicReference` (`IntrusiveMpscQueue.head`, a `getAndSet` per `offer`) and the
lock's own acquire/release ordering: every `offer()` happens-before some subsequent `poll()` that
observes it (directly, or via the momentary-`null` self-correcting race documented on
`IntrusiveMpscQueue` itself — see its class Javadoc), and every thread that successfully offers
either (a) is itself about to attempt `tryLock()` and, on success, drains everything including its
own offer, or (b) fails that `tryLock()`, meaning some other thread holds the lock *at that
instant* and that thread's own unlock will trigger its own re-check-after-unlock loop. There is no
interleaving in which an offered intent is neither drained by its own producer nor covered by some
other thread's re-check loop.
A frame's bytes are never interleaved with another frame's bytes: every write of one intent is a
single `sink.write` call issued while holding the lock, and the lock is not released between a
`WriteIntent`'s bytes — proven directly by `Http2FrameWriterStressTest`, which reassembles
producer/sequence/marker-tagged frames from the sink's output and fails loudly on any torn,
duplicated, reordered, or lost frame.
## Write timeout
A blocking write is unavoidable when the kernel send buffer is full and the peer is not reading —
whoever holds the lock is blocked in the syscall, holding up every other stream on the connection.
This is bounded by `Http2Limits.WRITE_TIMEOUT_MS` (30 s), enforced by a single shared daemon
thread (`Http2FrameWriter.WriteTimeoutReaper`) rather than `Socket#setSoTimeout`, which bounds
reads, not writes.
The reaper deliberately does **not** ask each write to record a `System.nanoTime()` deadline — an
early revision did, and this phase's own N=1 benchmark measured that single `nanoTime()` call
(plus the extra `volatile` field it required) costing enough to put per-write overhead over the
50 ns-over-baseline gate budget. Instead, the reaper scans every registered writer every
`SCAN_INTERVAL_MS` (50 ms) and counts *consecutive* scans a writer has been observed still blocked
(`writingThread` non-null); a writer blocked for more than `WRITE_TIMEOUT_MS / SCAN_INTERVAL_MS`
consecutive scans is interrupted. This trades a little precision — up to one scan interval of
slop, already inherent to any background-reaper design — for removing all per-write timing cost
from the path this document's gate criteria are strictest about.
## Benchmark methodology
`flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java` (a JMH source root
registered only under the `jmh` Maven profile, not `src/test/java`) compares four harnesses at
`threads` ∈ {1, 2, 4, 8, 16, 64}:
- `trylock_mpsc` — the shipped `Http2FrameWriter` design.
- `plain_lock` — every write blocks on `ReentrantLock.lock()` unconditionally (candidate (a)).
- `dedicated_thread` — every write hands off to one dedicated platform thread via the same
`IntrusiveMpscQueue`, parked/unparked, never busy-polled (candidate (c)).
- `raw_unsynchronized` — no coordination at all; not a candidate (concurrent writers would tear
each other's frames), included only to answer "what does a write cost with zero coordination",
which the N=1 gate criterion is defined relative to.
Each JMH "operation" is a full burst: `threads` virtual producer threads each write 4 000 frames
of 512 bytes into a `CountingSink` that discards the bytes but atomically counts completed writes;
`runBurst` blocks until the count reaches the expected total, so the timed interval always covers
real completion, not mere submission (`write()` can return once an intent is merely *queued* on
the contended path — timing only "how long until every `write()` call returned" would flatter
whichever design most aggressively defers work). `@Threads` was not usable here: it requires a
compile-time constant, not a value swept via `@Param`, and JMH's own thread pool is platform
threads, not the virtual threads under test.
**Two honest caveats, stated plainly rather than glossed over (R3):**
1. **The sink is an in-memory counter, not a real socket.** "p999 latency ... on loopback" in the
plan's gate wording implies real socket I/O; this harness measures writer-lock-contention
latency in isolation from network variance, which is the right isolation for judging *this
component*, but it means the recorded p999 numbers below are a lower bound on what a real
loopback socket would show, not a direct stand-in for it. Frame size used is 512 B, not the
plan's illustrative 1 KB — chosen to keep the burst's own array allocation small relative to
JVM defaults; the writer's cost model does not depend on frame size (it copies nothing; see
`WriteIntent`'s Javadoc), so this does not affect the gate conclusions.
2. **One JMH "op" is a whole burst (4 000 writes), not one write**, because `@OperationsPerInvocation`
requires a compile-time constant and cannot vary with the `threads` `@Param`. Every burst also
pays fixed harness costs common to *all four* designs equally: one `ExecutorService` (a
virtual-thread-per-task executor) created and torn down, one `Future[]` array, one
`long[threads][4000]` latency-sample array, and one fresh `BenchIntent` object allocated per
write (matching the stress test's own pattern, not the writer's actual production contract —
a real stream is long-lived and reuses itself as its own `WriteIntent`). Because this cost is
identical across designs, **absolute** `gc.alloc.rate.norm` numbers below are dominated by this
shared harness cost (~33 443 B/op), not by the design under test; the number that actually
answers the "0 B/op" gate criterion is the **differential** between a design and the
`raw_unsynchronized` baseline, which isolates exactly the bytes that design itself adds.
## Results
All runs: JDK 21.0.11 (Temurin), this development sandbox, JMH 1.37, `-Fork` per run noted below.
Raw JMH output is not reproduced in full here; the numbers below are the reported means with
their 99.9% CI half-widths.
### N=1 — throughput and allocation (`-f 4 -wi 5 -w 1s -i 12 -r 2s`, throughput; separately
`-f 2 -wi 3 -i 8`, `-prof gc`)
| design | ops/s (bursts/s) | derived ns/write | gc.alloc.rate.norm (B/op, per burst) |
|---|---|---|---|
| `trylock_mpsc` | 2007.930 ± 60.623 | 124.5 ns | 33 449.253 ± 13.383 |
| `raw_unsynchronized` | 3052.036 ± 52.515 | 81.9 ns | 33 443.349 ± 1.572 |
- **Overhead vs. raw unsynchronized:** 124.5 81.9 = **42.6 ns** (point estimate). Worst case
within the 99.9% CI (slowest plausible `trylock_mpsc`, fastest plausible baseline):
**47.9 ns**. Both are under the **50 ns** gate budget.
- **Allocation delta:** 33 449.253 33 443.349 = **5.9 B per 4 000-write burst** ≈ **0.0015 B per
write** — within `trylock_mpsc`'s own ±13.383 error band, i.e. not distinguishable from zero.
Consistent with the design: the fast path is `queue.hasWork()` (a volatile read) plus
`ReentrantLock.tryLock()`/`unlock()` (well-known non-allocating on the JDK's implementation)
plus one bulk `sink.write`. **Gate criterion: 0 B/op — PASS.**
### N=64 — throughput retention and tail latency (`-f 2 -wi 3 -w 1s -i 5 -r 1s`)
| design | N=1 writes/s (per-thread) | N=64 writes/s (aggregate) | retention | p999 @ N=64 |
|---|---|---|---|---|
| `trylock_mpsc` (shipped) | 8 721 148 | 5 712 640 | **65.5 %** | **11.814.2 µs** |
| `plain_lock` (candidate a) | 10 469 956 | 6 082 048 | 58.1 % | 16271952 µs |
| `dedicated_thread` (candidate c) | 2 673 964 | 5 718 528 | 213.8 %† | 1.56.7 µs |
| `raw_unsynchronized` (unsafe baseline) | 11 353 924 | 20 764 160 | n/a | n/a |
`dedicated_thread`'s N=1 baseline is itself poor (every uncontended write still pays a full
park/unpark handoff to the dedicated thread — there is no fast path for the "only one writer"
case at all), so a >100% "retention" number reflects a bad denominator, not superlinear scaling.
It is reported for completeness, not as a pass/fail signal — the gate criterion is defined
relative to `trylock_mpsc`'s own N=1 baseline, which is the design that shipped.
- **`trylock_mpsc` throughput retention:** 65.5 % ≥ the required 60 %. **PASS.**
- **`trylock_mpsc` p999 latency:** 11.814.2 µs, far under the 1 ms budget. **PASS.**
- (Not gate-relevant, but part of why (b) was chosen over (a) and (c), per the plan's task 6:
`plain_lock` blows past the 1 ms p999 budget by ~1000× under load — unfair blocking causes tail
pile-up exactly as expected from a design with no fast path and no fairness guarantee.
`dedicated_thread` has the best tail latency of the three but a **~3.3×** throughput penalty at
N=1, because *every* write, even genuinely uncontended ones, pays a full thread handoff. Neither
alternative is a better shipped default than `trylock_mpsc`.)
### Stress test — correctness under concurrency, 1000 iterations per N
Run via an ad hoc reflective driver invoking `Http2FrameWriterStressTest`'s private `runStress`
method directly (the shipped test class runs reduced counts for a fast default `mvn test`; this
is the full gate verification described in that class's own Javadoc), for `N` ∈ {1, 2, 8, 64,
256}, 1000 iterations each:
| Scheduler | n=1 | n=2 | n=8 | n=64 | n=256 | Total wall time |
|---|---|---|---|---|---|---|
| default parallelism | 0 failures | 0 failures | 0 failures | 0 failures | 0 failures | ≈ 9.1 s |
| `-Djdk.virtualThreadScheduler.parallelism=1` | 0 failures | 0 failures | 0 failures | 0 failures | 0 failures | ≈ 8.9 s |
Every byte of every frame arrived, correctly ordered per-producer, with no tearing, duplication,
or loss, in both configurations — **10 000 total stress runs, 0 failures.**
**Carrier pinning:** the `parallelism=1` run above was additionally run under
`-Djdk.tracePinnedThreads=full`, which prints a stack trace to stderr for any virtual thread found
blocked while pinning its carrier. Zero output — **no pinning observed**, consistent with the
design's exclusive use of `ReentrantLock` (never `synchronized`) on every path that can block.
## Gate criteria — final tally
| # | Criterion | Result | Verdict |
|---|---|---|---|
| 1 | N=1: 0 B/op | 0.0015 B/write differential vs. baseline, within noise | **PASS** |
| 1 | N=1: ≤50 ns overhead vs. raw unsynchronized | 42.6 ns point estimate, ≤47.9 ns worst-case CI | **PASS** |
| 2 | N=64: throughput ≥60% of N=1 per-thread rate | 65.5 % | **PASS** |
| 2 | N=64: p999 <1 ms (512 B frame, in-memory sink) | 11.814.2 µs | **PASS** |
| 3 | No carrier pinning under `-Djdk.tracePinnedThreads=full` | none observed | **PASS** |
| 4 | Stress test green at every N, 1000 iterations, incl. parallelism=1 | 10 000/10 000 | **PASS** |
**All four gate criteria are met.** `Http2FrameWriter` ships as designed: a `tryLock()` fast path
with an intrusive MPSC fallback.
## What this design costs vs. what it saves
The honest framing (per R3, extended from HPACK's own to the writer): the writer's happy path
costs one uncontended CAS (`ReentrantLock.tryLock()`) plus a volatile read (`queue.hasWork()`)
plus the write syscall itself — on the order of tens of nanoseconds, measured above at ~42.6 ns
over a raw unsynchronized write. What it buys is the only thing that makes HTTP/2 multiplexing
possible on a codebase built around "one thread owns the socket": N concurrent streams can write
frames to the same connection without a naive per-frame lock (which the `plain_lock` comparison
above shows costs ~1000× more in tail latency once real contention appears), and without
committing every connection to a dedicated writer thread's per-write handoff cost (which the
`dedicated_thread` comparison shows costs ~3.3× throughput at the N=1 case that dominates real
traffic). Forty-two nanoseconds is a price worth paying once, on the one path that gates
multiplexed HTTP/2 correctness at all.
+78
View File
@@ -37,4 +37,82 @@
</dependency>
</dependencies>
<!--
Phase 3 (flash/docs/http2/IMPLEMENTATION-PLAN.md): the JMH benchmark gate for the h2
serialized frame writer. Not bound to the default build — activate explicitly with
`-Pjmh`. Benchmarks live in src/jmh/java, a source root distinct from src/test/java
(a `jmh` profile on this module, per the plan's own suggestion, rather than a new
flash-bench submodule — recorded as DEC-09), specifically so that `mvn test` with no
profile never even *compiles* them: src/jmh/java is registered as a test-source root
only inside this profile (build-helper-maven-plugin's add-test-source), and the JMH
dependencies it imports are likewise profile-scoped. An earlier revision put the
benchmark directly in src/test/java, relying on Surefire's JUnit filtering (JMH classes
carry no JUnit annotations) to skip it at *run* time — but Surefire's test discovery
loads every compiled test class regardless, so a plain `mvn test` without `-Pjmh` failed
the whole module at test-compile with "package org.openjdk.jmh.annotations does not
exist", since jmh-core is not on the classpath outside this profile. The separate source
root fixes that at the root: with the profile inactive, the benchmark source is not on
any compiler's input at all. Run: `mvn -Pjmh -pl flash test-compile` then
`java -cp ... org.openjdk.jmh.Main` (see FrameWriterBenchmark's own Javadoc for the full
classpath incantation).
-->
<profiles>
<profile>
<id>jmh</id>
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>${build.helper.plugin.version}</version>
<executions>
<execution>
<id>add-jmh-source</id>
<phase>generate-test-sources</phase>
<goals>
<goal>add-test-source</goal>
</goals>
<configuration>
<sources>
<source>src/jmh/java</source>
</sources>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>${lombok.version}</version>
</path>
<path>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
</plugins>
</build>
</profile>
</profiles>
</project>
@@ -0,0 +1,69 @@
package dev.relism.flash.bench;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import java.net.ServerSocket;
import java.net.URI;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* Real-server, real-network throughput and latency benchmark: boots one live Flash server on
* loopback exposing {@code GET /hello}, then drives it end to end — real sockets, real accept
* loop, real routing and response serialization — with independent HTTP clients across protocols
* and concurrency levels. This is not a component-scoped JMH microbenchmark; it is the same shape
* of measurement a tool like {@code h2load} or {@code wrk} gives any other server.
*
* <p>Never wired into the build or CI — run manually with: {@code mvn -pl flash -Pbench
* exec:java}. Override scenario length with {@code -Dflash.bench.warmupSeconds} / {@code
* -Dflash.bench.measureSeconds} (defaults: 2 / 5).
*/
public final class BenchmarkMain {
private static final int[] CONCURRENCY_LEVELS = {1, 8, 32, 128};
public static void main(String[] args) throws Exception {
Duration warmup = seconds("flash.bench.warmupSeconds", 2);
Duration measurement = seconds("flash.bench.measureSeconds", 5);
int port = freePort();
FlashApp app =
FlashApp.create(
FlashConfiguration.builder()
.host("127.0.0.1")
.port(port)
.http2CleartextEnabled(true)
.build());
app.get("/hello", (request, response) -> "hello");
app.start();
try {
URI target = URI.create("http://127.0.0.1:" + port + "/hello");
Report.print(runAllScenarios(target, warmup, measurement));
} finally {
app.stop().join();
}
}
private static List<LoadResult> runAllScenarios(URI target, Duration warmup, Duration measurement)
throws InterruptedException {
List<LoadResult> results = new ArrayList<>();
for (int concurrency : CONCURRENCY_LEVELS) {
results.add(
new Http1Driver()
.run("http/1.1 c=" + concurrency, target, concurrency, warmup, measurement));
}
return results;
}
private static Duration seconds(String property, int fallback) {
return Duration.ofSeconds(Long.getLong(property, fallback));
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -0,0 +1,37 @@
package dev.relism.flash.bench;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
/**
* HTTP/1.1 keep-alive load driver backed by the JDK's own {@link HttpClient} — an independent
* client implementation, not Flash's own code, measuring the server end to end.
*/
final class Http1Driver implements LoadDriver {
@Override
public LoadResult run(
String scenarioLabel, URI target, int concurrency, Duration warmup, Duration measurement)
throws InterruptedException {
HttpRequest request = HttpRequest.newBuilder(target).timeout(Duration.ofSeconds(5)).GET().build();
return LoadRunner.execute(
scenarioLabel,
concurrency,
warmup,
measurement,
() -> {
// One HttpClient per worker: its own connection pool, reused keep-alive across requests.
HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build();
return () -> {
HttpResponse<Void> response =
client.send(request, HttpResponse.BodyHandlers.discarding());
if (response.statusCode() != 200) {
throw new IllegalStateException("status " + response.statusCode());
}
};
});
}
}
@@ -0,0 +1,22 @@
package dev.relism.flash.bench;
import java.util.Arrays;
/** One worker's latency samples, in nanoseconds. Grows without boxing on the request loop. */
final class LatencyRecorder {
private long[] samples = new long[1024];
private int count;
void record(long nanos) {
if (count == samples.length) samples = Arrays.copyOf(samples, samples.length * 2);
samples[count++] = nanos;
}
int count() {
return count;
}
long[] toArray() {
return Arrays.copyOf(samples, count);
}
}
@@ -0,0 +1,11 @@
package dev.relism.flash.bench;
import java.net.URI;
import java.time.Duration;
/** Runs one scenario (a protocol at a fixed concurrency) against a live target and returns its stats. */
interface LoadDriver {
LoadResult run(
String scenarioLabel, URI target, int concurrency, Duration warmup, Duration measurement)
throws InterruptedException;
}
@@ -0,0 +1,17 @@
package dev.relism.flash.bench;
/** One scenario's outcome: throughput and latency distribution over the measured phase only. */
record LoadResult(
String scenario,
long requests,
long errors,
double seconds,
double meanLatencyMicros,
double p50Micros,
double p99Micros,
double p999Micros) {
double requestsPerSecond() {
return seconds == 0 ? 0 : requests / seconds;
}
}
@@ -0,0 +1,69 @@
package dev.relism.flash.bench;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.LongAdder;
/**
* Drives a fixed number of concurrent virtual-thread workers against one {@link WorkerFactory},
* each worker looping its own {@link WorkUnit#request()} until a wall-clock deadline. A discarded
* warmup phase runs first so JIT warmup and connection setup don't skew the measured phase.
*/
final class LoadRunner {
private LoadRunner() {}
static LoadResult execute(
String scenarioLabel,
int concurrency,
Duration warmup,
Duration measurement,
WorkerFactory factory)
throws InterruptedException {
runUntil(concurrency, System.nanoTime() + warmup.toNanos(), factory, null, null);
LongAdder errors = new LongAdder();
List<LatencyRecorder> perWorker = new ArrayList<>(concurrency);
for (int i = 0; i < concurrency; i++) perWorker.add(new LatencyRecorder());
long measureStart = System.nanoTime();
runUntil(concurrency, measureStart + measurement.toNanos(), factory, errors, perWorker);
return Stats.summarize(scenarioLabel, perWorker, errors.sum(), System.nanoTime() - measureStart);
}
private static void runUntil(
int concurrency,
long deadlineNanos,
WorkerFactory factory,
LongAdder errors,
List<LatencyRecorder> perWorker)
throws InterruptedException {
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < concurrency; i++) {
LatencyRecorder recorder = perWorker == null ? null : perWorker.get(i);
pool.execute(() -> worker(deadlineNanos, factory, errors, recorder));
}
}
}
private static void worker(
long deadlineNanos, WorkerFactory factory, LongAdder errors, LatencyRecorder recorder) {
try (WorkUnit unit = factory.create()) {
while (System.nanoTime() < deadlineNanos) {
long start = System.nanoTime();
try {
unit.request();
if (recorder != null) recorder.record(System.nanoTime() - start);
} catch (Exception requestFailure) {
if (errors != null) errors.increment();
}
}
} catch (Exception setupFailure) {
if (errors != null) errors.increment();
}
}
}
@@ -0,0 +1,27 @@
package dev.relism.flash.bench;
import java.util.List;
/** Prints results as a fixed-width table on stdout — no file output, this is a manual tool. */
final class Report {
private Report() {}
static void print(List<LoadResult> results) {
System.out.printf(
"%-16s %10s %8s %12s %10s %10s %10s %10s%n",
"scenario", "requests", "errors", "req/s", "mean(us)", "p50(us)", "p99(us)", "p999(us)");
for (LoadResult result : results) {
System.out.printf(
"%-16s %10d %8d %12.1f %10.1f %10.1f %10.1f %10.1f%n",
result.scenario(),
result.requests(),
result.errors(),
result.requestsPerSecond(),
result.meanLatencyMicros(),
result.p50Micros(),
result.p99Micros(),
result.p999Micros());
}
}
}
@@ -0,0 +1,52 @@
package dev.relism.flash.bench;
import java.util.Arrays;
import java.util.List;
/** Merges every worker's samples and reduces them to one {@link LoadResult}. */
final class Stats {
private Stats() {}
static LoadResult summarize(
String scenarioLabel, List<LatencyRecorder> perWorker, long errors, long elapsedNanos) {
int total = 0;
for (LatencyRecorder recorder : perWorker) total += recorder.count();
long[] merged = new long[total];
int offset = 0;
for (LatencyRecorder recorder : perWorker) {
long[] samples = recorder.toArray();
System.arraycopy(samples, 0, merged, offset, samples.length);
offset += samples.length;
}
Arrays.sort(merged);
return new LoadResult(
scenarioLabel,
merged.length,
errors,
elapsedNanos / 1_000_000_000.0,
microsOf(mean(merged)),
microsOf(percentile(merged, 0.50)),
microsOf(percentile(merged, 0.99)),
microsOf(percentile(merged, 0.999)));
}
private static double mean(long[] sorted) {
if (sorted.length == 0) return 0;
long sum = 0;
for (long value : sorted) sum += value;
return (double) sum / sorted.length;
}
private static long percentile(long[] sorted, double fraction) {
if (sorted.length == 0) return 0;
int index = (int) Math.min(sorted.length - 1, Math.floor(fraction * sorted.length));
return sorted[index];
}
private static double microsOf(double nanos) {
return nanos / 1000.0;
}
}
@@ -0,0 +1,9 @@
package dev.relism.flash.bench;
/** One worker's request loop body. {@link #close()} releases whatever {@link WorkerFactory} opened. */
interface WorkUnit extends AutoCloseable {
void request() throws Exception;
@Override
default void close() throws Exception {}
}
@@ -0,0 +1,7 @@
package dev.relism.flash.bench;
/** Builds one worker's {@link WorkUnit} — its own connection/client, isolated per virtual thread. */
@FunctionalInterface
interface WorkerFactory {
WorkUnit create() throws Exception;
}
@@ -0,0 +1,123 @@
package dev.relism.flash;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.flash.routing.Middleware;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
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.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/**
* The h1 zero-alloc contract: "an h1 {@code GET /users/{id}} request that reads three headers and
* one path param must be 0 B/op end to end except for the user-facing {@code String}s the handler
* explicitly asks for." This benchmark measures the actual number with {@code -prof gc}. Before
* model and view pooling, {@code parseAndRoute} measured 120.008 B/op. It now measures at JMH's
* allocation noise floor. The two methods below isolate the parser/router path from the unavoidable
* cost of explicit {@code String} reads by comparing a route with no header or parameter access
* against one that reads a path parameter and two headers.
*
* <p>Uses a hand-rolled repeating {@link InputStream} (never allocates, cycles the same request
* bytes indefinitely) rather than a fresh {@code ByteArrayInputStream}/{@code BufferedByteSource}
* per invocation, so the timed path matches production exactly: one {@link BufferedByteSource}
* created once per connection and reused across every request, per {@code Http1Connection}'s own
* shape — not recreated per benchmark iteration, which would contaminate the measurement with
* harness allocation unrelated to the parser/router/model code under test (the same lesson {@code
* WRITER.md} documents for {@code FrameWriterBenchmark}'s own harness).
*/
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class RequestPipelineBenchmark {
/**
* Cycles a fixed byte[] indefinitely — simulates an infinite pipelined keep-alive stream of
* identical requests without allocating anything per read.
*/
private static final class RepeatingByteStream extends InputStream {
private final byte[] template;
private int pos;
RepeatingByteStream(byte[] template) {
this.template = template;
}
@Override
public int read() {
byte b = template[pos];
pos = (pos + 1) % template.length;
return b & 0xFF;
}
@Override
public int read(byte[] dst, int off, int len) {
for (int i = 0; i < len; i++) {
dst[off + i] = template[pos];
pos = (pos + 1) % template.length;
}
return len;
}
}
private RequestParser parser;
private BufferedByteSource in;
private FastPathRouterImpl router;
private Object routeScratch;
@Setup(Level.Trial)
public void setup() {
String req =
"GET /users/12345 HTTP/1.1\r\n"
+ "Host: api.example.com\r\n"
+ "Accept: application/json\r\n"
+ "Authorization: Bearer abcdefghijklmnopqrstuvwxyz\r\n"
+ "\r\n";
byte[] template = req.getBytes(StandardCharsets.US_ASCII);
in = new BufferedByteSource(new RepeatingByteStream(template), null);
parser = new RequestParser(64 * 1024);
router = new FastPathRouterImpl();
RequestHandler handler = new SimpleHandler((r, res) -> "ok");
router.doRegister(HttpMethod.GET, "/users/{id}", handler, new Middleware[0]);
router.compile();
routeScratch = router.newScratch();
}
/** Parse and route without requesting user-facing header or parameter strings. */
@Benchmark
public RequestHandler parseAndRoute() throws IOException {
Request request = parser.parse(in);
request.drain();
return router.route(request, routeScratch);
}
/** Parse, route, and read one path parameter and two headers as strings. */
@Benchmark
public Object parseRouteAndExtractThreeFields() throws IOException {
Request request = parser.parse(in);
RequestHandler handler = router.route(request, routeScratch);
String id = request.param("id");
String host = request.header("Host");
String auth = request.header("Authorization");
request.drain();
return id.length() + host.length() + auth.length() + (handler != null ? 1 : 0);
}
}
@@ -0,0 +1,62 @@
package dev.relism.flash.bytes;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
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.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/**
* Compares {@link ByteScan#indexOfCrLfCrLf} (SWAR) against {@link ByteScan#indexOfCrLfCrLfScalar}
* on a realistic HTTP/1.1 request header block. It lives in this package to reach the
* package-private scalar reference method without widening that method's visibility solely for
* measurement.
*
* <p>Run: {@code mvn -Pjmh -pl flash test-compile} then {@code java -cp
* flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath
* -Dmdep.outputFile=/dev/stdout -q) org.openjdk.jmh.Main ByteScanBenchmark}.
*/
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class ByteScanBenchmark {
/** A realistic request: request line + 7 headers + terminator, ~330 bytes. */
private byte[] requestBuf;
@Setup(Level.Trial)
public void setup() {
String req =
"GET /users/12345?sort=desc&limit=20 HTTP/1.1\r\n"
+ "Host: api.example.com\r\n"
+ "User-Agent: Mozilla/5.0 (compatible; FlashBench/1.0)\r\n"
+ "Accept: application/json\r\n"
+ "Accept-Encoding: gzip, deflate, br\r\n"
+ "Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123456789\r\n"
+ "Cookie: session=xyz123abc; theme=dark; lang=en-US\r\n"
+ "Connection: keep-alive\r\n"
+ "\r\n";
requestBuf = req.getBytes(StandardCharsets.US_ASCII);
}
@Benchmark
public int headerEndScan_swar() {
return ByteScan.indexOfCrLfCrLf(requestBuf, 0, requestBuf.length);
}
@Benchmark
public int headerEndScan_scalar() {
return ByteScan.indexOfCrLfCrLfScalar(requestBuf, 0, requestBuf.length);
}
}
@@ -0,0 +1,131 @@
package dev.relism.flash.http2;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameWriteBuffer;
import dev.relism.flash.http2.frame.Http2FrameReader;
import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
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.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
import org.openjdk.jmh.annotations.Warmup;
/** Measures the allocation-free control-frame lifecycle after connection objects are prepared. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class Http2ConnectionBenchmark {
private static final java.util.function.BooleanSupplier RUNNING = () -> false;
private byte[] wire;
private Http2Connection connection;
private BufferedByteSource input;
private Http2FrameReader reader;
private Http2FrameWriter writer;
private CountingSink sink;
private ResettableInputStream stream;
@Setup(Level.Trial)
public void buildWire() {
ByteWriter bytes = new ByteWriter(128);
bytes.writeBytes("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
FrameWriteBuffer frames = new FrameWriteBuffer(bytes);
frames.beginFrame(FrameType.SETTINGS, 0, 0);
frames.endFrame();
frames.beginFrame(FrameType.SETTINGS, FrameFlags.ACK, 0);
frames.endFrame();
frames.beginFrame(FrameType.PING, 0, 0);
bytes.writeAscii("12345678");
frames.endFrame();
frames.beginFrame(FrameType.WINDOW_UPDATE, 0, 0);
bytes.writeUInt31(1);
frames.endFrame();
frames.beginFrame(FrameType.GOAWAY, 0, 0);
bytes.writeUInt31(0);
bytes.writeUInt32(Http2ErrorCode.NO_ERROR.code());
frames.endFrame();
wire = new byte[bytes.length()];
System.arraycopy(bytes.array(), 0, wire, 0, wire.length);
setupConnection();
}
private void setupConnection() {
connection = new Http2Connection();
stream = new ResettableInputStream(wire);
input = new BufferedByteSource(stream, null);
reader = new Http2FrameReader(input);
sink = new CountingSink();
writer = new Http2FrameWriter(sink, 5_000);
}
@Setup(Level.Invocation)
public void resetConnection() {
stream.reset();
connection.reset();
sink.bytes = 0;
}
@TearDown(Level.Trial)
public void closeWriter() {
writer.close();
}
@Benchmark
public int controlLifecycle() throws Exception {
connection.runPrepared(input, reader, writer, RUNNING);
return sink.bytes;
}
private static final class ResettableInputStream extends InputStream {
private final byte[] bytes;
private int position;
private ResettableInputStream(byte[] bytes) {
this.bytes = bytes;
}
@Override
public void reset() {
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;
}
}
private static final class CountingSink implements Http2FrameWriter.Sink {
private int bytes;
@Override
public void write(byte[] buffer, int offset, int length) {
bytes += length;
}
}
}
@@ -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);
}
@@ -0,0 +1,27 @@
package dev.relism.flash.http2;
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;
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
@Fork(2)
public class RollingWindowCounterBenchmark {
private final RollingWindowCounter counter = new RollingWindowCounter(10_000);
@Benchmark
public boolean increment() {
return counter.incrementExceeded(Integer.MAX_VALUE);
}
}
@@ -0,0 +1,111 @@
package dev.relism.flash.http2.frame;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.IOException;
import java.io.InputStream;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
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.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/**
* Measures allocation and latency for reading, validating and discarding a frame and for writing a
* frame header. Allocation is measured with {@code -prof gc}, not inferred from inspection.
*
* <p>Uses the same hand-rolled repeating {@link InputStream} technique {@code
* RequestPipelineBenchmark} established: one {@link BufferedByteSource}/ {@link Http2FrameReader}
* pair created once per trial and reused across every invocation, matching how a real connection's
* demux loop owns exactly one of each for its whole lifetime, rather than paying for harness-side
* (re)construction inside the timed path.
*/
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class FrameLayerBenchmark {
private static final class RepeatingByteStream extends InputStream {
private final byte[] template;
private int pos;
RepeatingByteStream(byte[] template) {
this.template = template;
}
@Override
public int read() {
byte b = template[pos];
pos = (pos + 1) % template.length;
return b & 0xFF;
}
@Override
public int read(byte[] dst, int off, int len) {
for (int i = 0; i < len; i++) {
dst[off + i] = template[pos];
pos = (pos + 1) % template.length;
}
return len;
}
}
// ── Read + validate ──────────────────────────────────────────────────────
private Http2FrameReader reader;
@Setup(Level.Trial)
public void setupReader() {
FrameWriteBuffer out = new FrameWriteBuffer(new ByteWriter(64));
out.beginFrame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1);
byte[] payload = new byte[48];
for (int i = 0; i < payload.length; i++) payload[i] = (byte) i;
out.writer().writeBytes(payload);
out.endFrame();
byte[] template = new byte[out.writer().length()];
System.arraycopy(out.writer().array(), 0, template, 0, template.length);
BufferedByteSource src = new BufferedByteSource(new RepeatingByteStream(template), null);
reader = new Http2FrameReader(src);
}
@Benchmark
public int readValidateAndDiscard() throws IOException {
FrameHeader header = reader.readFrame();
FrameValidator.validate(header, false);
int checksum = header.buffer()[header.payloadOffset()];
reader.consumeFrame();
return checksum;
}
// ── Write ────────────────────────────────────────────────────────────────
private FrameWriteBuffer writeBuffer;
private byte[] writePayload;
@Setup(Level.Trial)
public void setupWriter() {
writeBuffer = new FrameWriteBuffer(new ByteWriter(64));
writePayload = new byte[48];
for (int i = 0; i < writePayload.length; i++) writePayload[i] = (byte) i;
}
@Benchmark
public int writeFrame() {
writeBuffer.writer().reset();
writeBuffer.beginFrame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1);
writeBuffer.writer().writeBytes(writePayload);
writeBuffer.endFrame();
return writeBuffer.writer().length();
}
}
@@ -0,0 +1,353 @@
package dev.relism.flash.http2.frame;
import java.util.Arrays;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.locks.LockSupport;
import java.util.concurrent.locks.ReentrantLock;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
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.TearDown;
import org.openjdk.jmh.annotations.Warmup;
/**
* Compares three writer designs at N ∈ {1, 2, 4, 8, 16, 64} concurrent <b>virtual-thread</b>
* writers:
*
* <ul>
* <li>{@code trylock_mpsc} — the design that ships as {@link Http2FrameWriter}: {@code tryLock()}
* fast path, intrusive MPSC fallback.
* <li>{@code plain_lock} — every write blocks on {@code ReentrantLock#lock()}, unconditionally.
* <li>{@code dedicated_thread} — every write hands off to a single dedicated platform thread via
* the same {@link IntrusiveMpscQueue}, parked/unparked (never a busy poll).
* </ul>
*
* <h3>Why this benchmark drives its own concurrency instead of JMH's {@code @Threads}</h3>
*
* {@code @Threads} requires a compile-time constant, not a {@code @Param}-swept value, and JMH's
* thread pool is platform threads, not virtual threads — the exact scheduling behaviour under test.
* Each {@code @Benchmark} invocation therefore spawns {@link #threads} virtual threads itself, has
* them race a fixed burst of writes to a counting no-op sink, and reports the burst's wall-clock
* rate; JMH still owns fork/warmup/measurement-iteration control and (via {@code -prof gc}) the
* zero-allocation verification.
*
* <h3>Why {@code runBurst} waits on a write counter, not just thread completion</h3>
*
* {@code write()} does not mean "already on the wire" for every design: the shipped design's
* contended path, and the dedicated-thread design's handoff, can both return once the frame is
* merely *queued*. Timing only "how long until every producer's {@code write()} call returned"
* would therefore measure submission speed, not completion speed, and would flatter exactly the
* designs that most aggressively defer work — the opposite of a fair comparison. Every harness here
* writes through {@link CountingSink} and {@link #runBurst} waits for its counter to reach the
* expected total before returning, so the timed interval always covers real completion.
*
* <p>Per-write latency percentiles are computed by hand from {@code System.nanoTime()} samples
* collected during the burst (JMH's own {@code Mode.SampleTime} does not fit a custom-concurrency
* benchmark method) and printed once per (design, threads) combination — see {@code WRITER.md} for
* the recorded results and the gate decision.
*
* <p>Run: {@code mvn -Pjmh -pl flash test-compile} then {@code java -cp
* flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath
* -Dmdep.outputFile=/dev/stdout -q) org.openjdk.jmh.Main FrameWriterBenchmark -prof gc}.
*/
@State(Scope.Benchmark)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.SECONDS)
@Fork(1)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class FrameWriterBenchmark {
private static final int FRAMES_PER_THREAD = 4000;
private static final int FRAME_SIZE = 512;
@Param({"trylock_mpsc", "plain_lock", "dedicated_thread", "raw_unsynchronized"})
public String design;
@Param({"1", "2", "4", "8", "16", "64"})
public int threads;
private DesignHarness harness;
private byte[] payload;
@Setup(Level.Trial)
public void setup() {
payload = new byte[FRAME_SIZE];
harness =
switch (design) {
case "trylock_mpsc" -> new TryLockMpscHarness();
case "plain_lock" -> new PlainLockHarness();
case "dedicated_thread" -> new DedicatedThreadHarness();
case "raw_unsynchronized" -> new RawUnsynchronizedHarness();
default -> throw new IllegalStateException("unknown design: " + design);
};
}
@TearDown(Level.Trial)
public void teardown() {
harness.shutdown();
}
/**
* One "operation" here is a full burst: {@link #threads} virtual threads each writing {@link
* #FRAMES_PER_THREAD} frames. Reported ops/sec must be multiplied by {@code threads *
* FRAMES_PER_THREAD} to get frames/sec — done during result analysis, not via
* {@code @OperationsPerInvocation} (which requires a compile-time constant and cannot vary with
* the {@code threads} @Param).
*/
@Benchmark
public void burst() throws Exception {
harness.runBurst(threads, FRAMES_PER_THREAD, payload);
}
// ── Harness abstraction and the three designs under comparison ─────────────
private interface DesignHarness {
void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception;
void shutdown();
}
/**
* Discards everything (isolating the writer designs from real socket variance) but counts every
* completed write, so callers can wait for true completion rather than mere submission — see the
* class Javadoc.
*/
private static final class CountingSink implements Http2FrameWriter.Sink {
final AtomicLong count = new AtomicLong();
@Override
public void write(byte[] buf, int off, int len) {
count.incrementAndGet();
}
}
private static final class BenchIntent implements WriteIntent {
final byte[] buf;
WriteIntent next;
BenchIntent(byte[] buf) {
this.buf = buf;
}
@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> {
void accept(T t) throws Exception;
}
/**
* Spawns {@code threadCount} virtual threads, has each write {@code framesPerThread} fresh {@link
* BenchIntent}s (one per write — matches production usage, where a stream's scratch buffer holds
* exactly one in-flight frame at a time), records per-write latency samples, then blocks until
* {@code sink}'s counter reflects every one of them actually written.
*/
private static void race(
int threadCount, int framesPerThread, CountingSink sink, ThrowingConsumer<WriteIntent> write)
throws Exception {
long target = sink.count.get() + (long) threadCount * framesPerThread;
byte[] payload = new byte[FRAME_SIZE];
long[][] samplesByThread = new long[threadCount][framesPerThread];
try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) {
Future<?>[] futures = new Future<?>[threadCount];
for (int t = 0; t < threadCount; t++) {
int idx = t;
futures[t] =
exec.submit(
() -> {
long[] samples = samplesByThread[idx];
for (int i = 0; i < framesPerThread; i++) {
BenchIntent intent = new BenchIntent(payload);
long start = System.nanoTime();
try {
write.accept(intent);
} catch (Exception e) {
throw new RuntimeException(e);
}
samples[i] = System.nanoTime() - start;
}
});
}
for (Future<?> f : futures) f.get();
}
while (sink.count.get() < target) {
Thread.onSpinWait();
}
LatencyReport.recordAndMaybePrint(samplesByThread);
}
/**
* Prints p50/p99/p999 to stdout once per thread-count actually exercised, from the first burst
* observed for it — cheap, and avoids flooding the JMH log with one line per measurement
* iteration.
*/
private static final class LatencyReport {
private static final Set<String> PRINTED = ConcurrentHashMap.newKeySet();
static void recordAndMaybePrint(long[][] samplesByThread) {
String key = samplesByThread.length + "t";
if (!PRINTED.add(key)) return;
int total = 0;
for (long[] s : samplesByThread) total += s.length;
long[] all = new long[total];
int pos = 0;
for (long[] s : samplesByThread) {
System.arraycopy(s, 0, all, pos, s.length);
pos += s.length;
}
Arrays.sort(all);
long p50 = all[(int) (all.length * 0.50)];
long p99 = all[(int) (all.length * 0.99)];
long p999 = all[Math.min(all.length - 1, (int) (all.length * 0.999))];
System.out.printf(
"[latency threads=%d] p50=%.1fus p99=%.1fus p999=%.1fus (n=%d)%n",
samplesByThread.length, p50 / 1000.0, p99 / 1000.0, p999 / 1000.0, all.length);
}
}
// ── Baseline: no synchronization at all ─────────────────────────────────────
// Not a candidate design (concurrent writers would tear each other's frames) — exists
// purely to establish "what a write costs with zero coordination overhead" for the N=1
// gate criterion ("per-frame overhead versus a raw unsynchronized write is within 50 ns").
// At N=1 there genuinely is no concurrent writer, so the missing safety is moot there.
private static final class RawUnsynchronizedHarness implements DesignHarness {
private final CountingSink sink = new CountingSink();
@Override
public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception {
race(
threads,
framesPerThread,
sink,
intent -> sink.write(intent.buffer(), intent.offset(), intent.length()));
}
@Override
public void shutdown() {}
}
// ── Design (a): plain lock ──────────────────────────────────────────────────
private static final class PlainLockHarness implements DesignHarness {
private final CountingSink sink = new CountingSink();
private final ReentrantLock lock = new ReentrantLock();
@Override
public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception {
race(
threads,
framesPerThread,
sink,
intent -> {
lock.lock();
try {
sink.write(intent.buffer(), intent.offset(), intent.length());
} finally {
lock.unlock();
}
});
}
@Override
public void shutdown() {}
}
// ── Design (b): tryLock + intrusive MPSC — the shipped design ──────────────
private static final class TryLockMpscHarness implements DesignHarness {
private final CountingSink sink = new CountingSink();
private final Http2FrameWriter writer = new Http2FrameWriter(sink, 30_000);
@Override
public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception {
race(threads, framesPerThread, sink, writer::write);
}
@Override
public void shutdown() {
writer.close();
}
}
// ── Design (c): always hand off to one dedicated writer thread ─────────────
private static final class DedicatedThreadHarness implements DesignHarness {
private final CountingSink sink = new CountingSink();
private final IntrusiveMpscQueue queue = new IntrusiveMpscQueue();
private final Thread writerThread;
private volatile boolean running = true;
DedicatedThreadHarness() {
this.writerThread = Thread.ofPlatform().name("bench-dedicated-writer").start(this::loop);
}
private void loop() {
while (running) {
WriteIntent intent = queue.poll();
if (intent == null) {
LockSupport.park();
continue;
}
sink.write(intent.buffer(), intent.offset(), intent.length());
}
}
@Override
public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception {
race(
threads,
framesPerThread,
sink,
intent -> {
queue.offer(intent);
LockSupport.unpark(writerThread);
});
}
@Override
public void shutdown() {
running = false;
writerThread.interrupt();
}
}
}
@@ -0,0 +1,62 @@
package dev.relism.flash.http2.hpack;
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.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};
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
public int decodeStaticRequest() {
headers.reset();
decoder.decode(block, 0, block.length, headers);
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();
}
}
@@ -0,0 +1,122 @@
package dev.relism.flash.http2.message;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.RequestBody;
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.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 3)
@Measurement(iterations = 5)
@Fork(2)
@State(Scope.Thread)
public class Http2BodyBenchmark {
private static final Http2RequestBody.ConsumptionListener NOOP = bytes -> {};
private final byte[] payload = new byte[1024];
private final byte[] streamingPayload = new byte[1024 * 1024];
private final byte[] target = new byte[1024];
private DataBufferPool pool;
private Http2RequestBody source;
private RequestBody body;
private Response response;
private Http2ResponseWriter responseWriter;
private ResettableInputStream responseSource;
private ResettableInputStream largeResponseSource;
@Setup(Level.Trial)
public void setup() throws IOException {
pool = new DataBufferPool(16_384, 1);
source = new Http2RequestBody(pool);
body = new RequestBody();
response = new Response(200, ContentType.BINARY);
responseWriter = new Http2ResponseWriter();
responseSource = new ResettableInputStream(payload);
largeResponseSource = new ResettableInputStream(streamingPayload);
source.begin(-1, false, NOOP);
source.offer(1, payload, 0, payload.length, payload.length);
source.finish(1);
source.read(target);
}
@Benchmark
public byte[] inlineBytes() {
source.begin(payload.length, true, NOOP);
source.offer(1, payload, 0, payload.length, payload.length);
source.finish(1);
body.reset(source, payload.length, null, 0, 0);
return body.bytes();
}
@Benchmark
public int streamingRead() throws IOException {
source.begin(-1, false, NOOP);
source.offer(1, payload, 0, payload.length, payload.length);
source.finish(1);
return source.read(target, 0, target.length);
}
@Benchmark
public int streamingResponseFrame() throws IOException {
responseSource.rewind();
response.reset(200, ContentType.BINARY).stream(responseSource, payload.length);
responseWriter.startFlowControlled(
response, 1, false, false, true, false, false, 16_384, 32_768, 16_384);
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 final byte[] source;
private int position;
ResettableInputStream(byte[] source) {
this.source = source;
}
void rewind() {
position = 0;
}
@Override
public int read() {
return position == source.length ? -1 : source[position++] & 0xff;
}
@Override
public int read(byte[] target, int offset, int length) {
if (position == source.length) return -1;
int count = Math.min(length, source.length - position);
System.arraycopy(source, position, target, offset, count);
position += count;
return count;
}
}
}
@@ -0,0 +1,44 @@
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.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.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/** Measures the steady-state allocation cost of a representative fixed HTTP/2 response. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class Http2ResponseWriterBenchmark {
private Http2ResponseWriter writer;
private Response response;
@Setup
public void setup() {
writer = new Http2ResponseWriter();
response =
new Response(200, "hello", ContentType.JSON)
.header(new PreEncodedHeader("cache-control", "no-store"))
.header(new PreEncodedHeader("x-trace", "abc123"));
writer.prepare(response, 1, false, true, true, false, false, 16_384, 16_384, 65_535);
}
@Benchmark
public int encodeResponse() {
writer.prepare(response, 1, false, true, true, false, false, 16_384, 16_384, 65_535);
return writer.length();
}
}
@@ -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;
}
}
@@ -0,0 +1,108 @@
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 dev.relism.flash.models.Response;
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.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/** Measures the pooled HPACK-decode, request-assembly and fixed-response stream lifecycle. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class Http2StreamBenchmark {
private static final byte[] BODY = "pong".getBytes(StandardCharsets.US_ASCII);
private static final byte[] POST_BODY = new byte[1024];
private Http2StreamTable streams;
private HpackDecoder decoder;
private byte[] requestBlock;
private int requestLength;
private byte[] postBlock;
private int postLength;
@Setup
public void setup() {
streams = new Http2StreamTable(1);
decoder = new HpackDecoder();
ByteWriter block = new ByteWriter(64);
HpackEncoder.writeIndexed(block, 2);
HpackEncoder.writeIndexed(block, 7);
HpackEncoder.writeLiteralWithNameIndex(
block, 4, "/ping".getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeLiteralWithNameIndex(
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
requestBlock = block.array();
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();
postOneKiB();
}
@Benchmark
public int lifecycle() {
Http2Stream stream = streams.acquire(1);
decoder.decode(requestBlock, 0, requestLength, stream.headerBlock());
stream.assembleRequest(null, null);
Response response = stream.resetResponse().body(BODY);
stream
.responseWriter()
.prepare(response, 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;
}
/** 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;
}
}
@@ -0,0 +1,119 @@
package dev.relism.flash.routing.routers.fastpathrouter;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.SimpleHandler;
import dev.relism.fpr.core.internal.runtime.ByteCompare;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
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.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Warmup;
/**
* Two related but distinct measurements of router and byte-comparison performance.
*
* <p><b>{@code router_*}</b> exercises the shipped {@link FastPathRouterImpl#route} end to end,
* including lazy-compiled route-table lookup, {@link FastPathRouterImpl.RouteScratch} reuse and
* path-parameter extraction.
*
* <p><b>{@code byteCompare_*}</b> directly compares {@code ByteCompare.equals} with its
* word-at-a-time path enabled and disabled over representative array-backed content. This is
* separate because router matching uses the composite, non-array-backed {@link
* FastPathViews.MethodPathByteView}, so the router measurements cannot expose the word path's
* effect.
*/
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class FastPathRouterBenchmark {
// ── router_*: the real, shipped router, end to end ──────────────────────
private FastPathRouterImpl router;
private Object scratch;
private Request staticRequest;
private Request paramRequest;
@Setup(Level.Trial)
public void setupRouter() {
router = new FastPathRouterImpl();
RequestHandler h = new SimpleHandler((req, res) -> "ok");
router.doRegister(HttpMethod.GET, "/health", h, new dev.relism.flash.routing.Middleware[0]);
router.doRegister(HttpMethod.GET, "/users/{id}", h, new dev.relism.flash.routing.Middleware[0]);
router.doRegister(
HttpMethod.GET,
"/users/{id}/posts/{postId}",
h,
new dev.relism.flash.routing.Middleware[0]);
router.doRegister(HttpMethod.POST, "/users", h, new dev.relism.flash.routing.Middleware[0]);
router.doRegister(
HttpMethod.GET,
"/api/v1/products/{category}/{id}",
h,
new dev.relism.flash.routing.Middleware[0]);
router.compile();
scratch = router.newScratch();
staticRequest = mockRequest(HttpMethod.GET, "/health");
paramRequest = mockRequest(HttpMethod.GET, "/users/12345/posts/67890");
}
@Benchmark
public RequestHandler router_staticRoute() {
return router.route(staticRequest, scratch);
}
@Benchmark
public RequestHandler router_parametricRoute() {
return router.route(paramRequest, scratch);
}
private static Request mockRequest(HttpMethod method, String path) {
byte[] bytes = path.getBytes(StandardCharsets.UTF_8);
FastPathViews.RequestByteView pathView =
new FastPathViews.RequestByteView(bytes, 0, bytes.length);
dev.relism.flash.models.RequestLine line =
new dev.relism.flash.models.RequestLine(
method,
pathView,
null,
new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8),
new dev.relism.flash.models.Http1HeaderMap());
return new Request(line, new byte[0]);
}
// ── byteCompare_*: word-at-a-time comparison in isolation ──────────────
private FastPathViews.RequestByteView cmpView;
private byte[] cmpOther;
@Setup(Level.Trial)
public void setupByteCompare() {
byte[] content = "/api/v1/products/electronics/00012345".getBytes(StandardCharsets.US_ASCII);
cmpView = new FastPathViews.RequestByteView(content, 0, content.length);
cmpOther = content.clone();
}
@Benchmark
public boolean byteCompare_longPath() {
return ByteCompare.equals(cmpView, 0, cmpOther, 0, cmpOther.length, true);
}
@Benchmark
public boolean byteCompare_byteAtATime() {
return ByteCompare.equals(cmpView, 0, cmpOther, 0, cmpOther.length, false);
}
}
@@ -1,24 +1,49 @@
package dev.relism.flash;
import java.io.ByteArrayInputStream;
import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.models.BodyCompletion;
import dev.relism.flash.models.MutableHeaderMap;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.IOException;
import java.io.InputStream;
import java.io.SequenceInputStream;
/**
* De-chunking {@link InputStream} for HTTP/1.1 {@code Transfer-Encoding: chunked} request bodies.
* Handles pre-buffered bytes from the header read-ahead, chunk framing, and trailer consumption.
* Returns -1 at end of the final chunk; the underlying socket is left positioned for the next request.
*
* instead of the raw, unbuffered socket stream. Chunk-size digits, the trailing CRLF after each
* chunk, and trailer lines are all read one byte at a time by design (the framing is
* byte-oriented) — that used to mean one {@code read(2)} syscall per byte on the raw socket;
* against {@link BufferedByteSource} it is a read from an already-filled in-memory buffer.
* The header-parser's read-ahead bytes are handed to {@code src} via
* {@link BufferedByteSource#prependOnce}, replacing the {@code SequenceInputStream}/
* {@code ByteArrayInputStream} pair the previous implementation allocated per chunked request.
*/
final class ChunkedInputStream extends InputStream {
private final InputStream src;
final class ChunkedInputStream extends InputStream implements BodyCompletion {
private final BufferedByteSource src;
private int chunkRemaining = 0;
private boolean done = false;
private int chunksSeen = 0;
private final MutableHeaderMap trailers;
private final byte[] trailerLine = new byte[Http1Limits.MAX_HEADER_VALUE_LENGTH];
ChunkedInputStream(InputStream socket, byte[] preBuf, int preBufOff, int preBufLen) {
src = preBufLen > 0
? new SequenceInputStream(new ByteArrayInputStream(preBuf, preBufOff, preBufLen), socket)
: socket;
ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen,
MutableHeaderMap trailers) {
this.src = src;
this.trailers = trailers;
if (preBufLen > 0) src.prependOnce(preBuf, preBufOff, preBufLen);
}
ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen) {
this(src, preBuf, preBufOff, preBufLen, new MutableHeaderMap());
}
@Override
public boolean fullyRead() {
return done;
}
@Override
@@ -29,7 +54,7 @@ final class ChunkedInputStream extends InputStream {
if (chunkRemaining == 0) { consumeTrailers(); done = true; return -1; }
}
int b = src.read();
if (b >= 0 && --chunkRemaining == 0) { src.read(); src.read(); } // consume trailing \r\n
if (b >= 0 && --chunkRemaining == 0) consumeChunkTerminator();
return b;
}
@@ -43,30 +68,154 @@ final class ChunkedInputStream extends InputStream {
int n = src.read(buf, off, Math.min(len, chunkRemaining));
if (n > 0) {
chunkRemaining -= n;
if (chunkRemaining == 0) { src.read(); src.read(); } // consume trailing \r\n
if (chunkRemaining == 0) consumeChunkTerminator();
}
return n;
}
/** Validates and consumes the CRLF that terminates every chunk's data (RFC 9112 §7.1.1). */
private void consumeChunkTerminator() throws IOException {
int cr = src.read();
int lf = src.read();
if (cr != '\r' || lf != '\n') {
throw new MalformedRequestException(400, "Malformed chunk terminator");
}
}
/**
* Reads one chunk-size line: hex digits, an optional {@code ;}-prefixed chunk-extension
* (discarded — RFC 9112 §7.1.1 permits ignoring extensions this server does not recognise),
* then CRLF. Bounded per {@code Http1Limits} against: more than 16 hex digits (a chunk size
* cannot legitimately need more — {@code Long.MAX_VALUE} is 16 hex digits), a size above
* {@link Http1Limits#MAX_CHUNK_SIZE}, an extension longer than
* {@link Http1Limits#MAX_CHUNK_EXT_LENGTH}, and more than
* {@link Http1Limits#MAX_CHUNKS_PER_BODY} chunks per body — all defences against a peer
* that is technically well-formed but deliberately expensive to parse.
*/
private int readChunkSize() throws IOException {
if (++chunksSeen > Http1Limits.MAX_CHUNKS_PER_BODY) {
throw new MalformedRequestException(413, "Too many chunks");
}
long size = 0;
int b;
while ((b = src.read()) != -1) {
if (b >= '0' && b <= '9') size = (size << 4) | (b - '0');
else if (b >= 'a' && b <= 'f') size = (size << 4) | (b - 'a' + 10);
else if (b >= 'A' && b <= 'F') size = (size << 4) | (b - 'A' + 10);
else { while ((b = src.read()) != -1 && b != '\n'); break; } // ext or \r\n
if (size > Integer.MAX_VALUE) throw new IOException("Chunk size exceeds 2 GB limit");
int digits = 0;
int b = src.read();
while (isHexDigit(b)) {
if (++digits > 16) throw new MalformedRequestException(400, "Chunk size line too long");
size = (size << 4) | hexValue(b);
if (size > Http1Limits.MAX_CHUNK_SIZE) {
throw new MalformedRequestException(413, "Chunk size exceeds configured maximum");
}
b = src.read();
}
if (digits == 0) throw new MalformedRequestException(400, "Malformed chunk size");
int extLen = 0;
while (b != -1 && b != '\r') {
if (++extLen > Http1Limits.MAX_CHUNK_EXT_LENGTH) {
throw new MalformedRequestException(400, "Chunk extension too long");
}
b = src.read();
}
if (b != '\r' || src.read() != '\n') {
throw new MalformedRequestException(400, "Malformed chunk size line terminator");
}
return (int) size;
}
// Reads and discards trailer headers until the empty line that terminates the chunked body.
private static boolean isHexDigit(int b) {
return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F');
}
private static int hexValue(int b) {
if (b <= '9') return b - '0';
if (b <= 'F') return b - 'A' + 10;
return b - 'a' + 10;
}
/**
* Reads and discards trailer headers until the empty line that terminates the chunked body
* (RFC 9112 §7.1.2). Bounded by {@link Http1Limits#MAX_TRAILER_COUNT} and
* {@link Http1Limits#MAX_HEADER_VALUE_LENGTH} — without a bound, a peer could follow the
* final chunk with an unbounded trailer section purely to waste CPU discarding it. Trailers
* ({@code Request.trailers()}).
*/
private void consumeTrailers() throws IOException {
int trailerCount = 0;
while (true) {
int b = src.read();
if (b == -1 || b == '\r') { src.read(); return; } // empty line — done
while ((b = src.read()) != -1 && b != '\n'); // skip non-empty trailer line
if (b == -1) throw new MalformedRequestException(400, "Truncated trailer section");
if (b == '\r') {
if (src.read() != '\n') {
throw new MalformedRequestException(400, "Malformed trailer section terminator");
}
return; // empty line — trailer section done
}
if (++trailerCount > Http1Limits.MAX_TRAILER_COUNT) {
throw new MalformedRequestException(431, "Too many trailers");
}
int lineLen = 0;
trailerLine[lineLen++] = (byte) b;
while ((b = src.read()) != -1 && b != '\n') {
if (lineLen == trailerLine.length) {
throw new MalformedRequestException(431, "Trailer line too long");
}
trailerLine[lineLen++] = (byte) b;
}
if (b != '\n' || lineLen == 0 || trailerLine[lineLen - 1] != '\r') {
throw new MalformedRequestException(400, "Malformed trailer line");
}
addTrailer(lineLen - 1);
}
}
private void addTrailer(int lineLength) throws MalformedRequestException {
int colon = -1;
for (int i = 0; i < lineLength; i++) {
if (trailerLine[i] == ':') { colon = i; break; }
}
if (colon <= 0) throw new MalformedRequestException(400, "Malformed trailer field");
for (int i = 0; i < colon; i++) {
int c = trailerLine[i] & 0xff;
if (!isToken(c)) {
throw new MalformedRequestException(400, "Invalid trailer field name");
}
}
int valueStart = colon + 1;
while (valueStart < lineLength
&& (trailerLine[valueStart] == ' ' || trailerLine[valueStart] == '\t')) valueStart++;
int valueEnd = lineLength;
while (valueEnd > valueStart
&& (trailerLine[valueEnd - 1] == ' ' || trailerLine[valueEnd - 1] == '\t')) valueEnd--;
if (forbidden(trailerLine, colon)) {
throw new MalformedRequestException(400, "Forbidden trailer field");
}
trailers.add(trailerLine, 0, colon, trailerLine, valueStart, valueEnd - valueStart);
}
private static boolean isToken(int c) {
return (c >= '0' && c <= '9')
|| (c >= 'A' && c <= 'Z')
|| (c >= 'a' && c <= 'z')
|| c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\''
|| c == '*' || c == '+' || c == '-' || c == '.' || c == '^' || c == '_'
|| c == '`' || c == '|' || c == '~';
}
private static boolean forbidden(byte[] name, int length) {
return asciiEquals(name, length, "content-length")
|| asciiEquals(name, length, "transfer-encoding")
|| asciiEquals(name, length, "host")
|| asciiEquals(name, length, "trailer");
}
private static boolean asciiEquals(byte[] bytes, int length, String expected) {
if (length != expected.length()) return false;
for (int i = 0; i < length; i++) {
int c = bytes[i] & 0xff;
if (c >= 'A' && c <= 'Z') c += 32;
if (c != expected.charAt(i)) return false;
}
return true;
}
}
@@ -1,564 +0,0 @@
package dev.relism.flash;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.tls.TlsConfig;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketSession;
import dev.relism.fpr.core.ByteView;
import lombok.extern.slf4j.Slf4j;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLSocket;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* Pure I/O transport layer. Owns one {@link ServerSocket} per configured listener (plain or
* TLS), the virtual-thread executor, and the keep-alive accept loop. Routing is delegated to
* HTTP and WS routers — identically, regardless of which listener accepted the connection.
*
* <p>TLS is a transport-level concern only: once a {@link BoundListener} is bound, an accepted
* {@link Socket} is either plain or an {@code SSLSocket} indistinguishably from here on —
* {@link #process} never branches on it. This is also why WSS needs no separate code path from
* WS: the WebSocket upgrade happens over whatever transport {@link #process} was handed.
*
* <h3>Allocation model</h3>
* <ul>
* <li>{@code LONG_BUF} (20 bytes) and {@code STREAM_RELAY_BUFFER} (8 KB, for a streaming
* {@link Response} body — see {@link #writeStreamingBody}) are the only {@link ThreadLocal}s
* kept here. Both are per-connection, not per-request: one virtual thread runs a
* connection's whole keep-alive request loop (see {@link #process}), so a handler that
* streams a large response on every request allocates its relay buffer once per
* connection, not once per request.</li>
* <li>WS handshake SHA-1: {@link ThreadLocal}&lt;{@link MessageDigest}&gt; — one per
* accept thread (there are now {@code ACCEPT_THREADS} of them, not one).</li>
* </ul>
*/
@Slf4j
class HttpServer implements ServerHandle {
// ── Tuning constants ──────────────────────────────────────────────────────
/**
* Number of platform threads competing on {@code serverSocket.accept()}.
* Rule of thumb: number of available CPU cores, capped at 8.
* More than this rarely helps — accept is cheap; the bottleneck is usually
* the virtual-thread executor dispatching the connection handler.
*/
private static final int ACCEPT_THREADS = Math.min(Runtime.getRuntime().availableProcessors(), 8);
/**
* TCP listen backlog. The kernel holds up to this many fully-established
* (SYN+ACK sent, ACK received) connections waiting for accept().
* 4096 is safe on Linux; /proc/sys/net/core/somaxconn must be >= this value,
* or the kernel silently caps it. Raise somaxconn if needed:
* sysctl -w net.core.somaxconn=4096
*/
private static final int ACCEPT_BACKLOG = 4096;
/**
* Socket send/receive buffer sizes. Matched to the WS frame read buffer
* ({@link FlashConfiguration#getWsFrameBufferSize()}) so the kernel never
* needs to fragment a full frame into multiple TCP segments on the receive
* side, and never blocks a write waiting for the send buffer to drain.
*
* Linux default is 87380 bytes (rmem) / 16384 bytes (wmem). We raise both
* to 256 KB — a good fit for up to ~250 KB WS frames with no partial reads.
*/
private static final int SOCKET_BUF_SIZE = 256 * 1024;
// ── Instance fields ───────────────────────────────────────────────────────
private final FlashConfiguration configuration;
private final List<BoundListener> boundListeners;
private final AbstractRouter router;
private final AbstractWsRouter wsRouter;
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
private volatile boolean stopped = false;
/** Latch that reaches 0 when all accept threads, across all listeners, have exited. */
private final CountDownLatch acceptLatch;
/** One bound listener socket (plain or TLS) plus whether it is TLS, for logging only. */
private record BoundListener(ServerSocket socket, boolean secure) {}
// ── Static byte constants (written once, read-only on hot path) ──────────
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
private static final byte[] WS_HANDSHAKE_PREFIX =
("HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: ")
.getBytes(StandardCharsets.ISO_8859_1);
private static final byte[] WS_HANDSHAKE_SUFFIX =
"\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1);
private static final byte[] WS_REJECT_400 =
"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.getBytes(StandardCharsets.ISO_8859_1);
private static final byte[] WS_GUID_BYTES =
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1);
private static final ThreadLocal<MessageDigest> SHA1 =
ThreadLocal.withInitial(() -> {
try { return MessageDigest.getInstance("SHA-1"); }
catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); }
});
private static final ThreadLocal<byte[]> LONG_BUF = ThreadLocal.withInitial(() -> new byte[20]);
/**
* Relay buffer for copying a streaming {@link Response} body to the client — shared by
* {@link #writeStreamingBody}'s non-chunked path and {@link #writeChunked}, so both draw
* from the same reused array instead of each allocating its own {@code byte[8192]} (the
* non-chunked path previously relied on {@link InputStream#transferTo}, which allocates
* internally on every call). Sized to match the pre-existing behavior this replaces, not
* newly tuned — not exposed as a {@link FlashConfiguration} tunable since nothing here
* needed one before.
*/
private static final int STREAM_RELAY_BUFFER_SIZE = 8192;
private static final ThreadLocal<byte[]> STREAM_RELAY_BUFFER =
ThreadLocal.withInitial(() -> new byte[STREAM_RELAY_BUFFER_SIZE]);
private static final int SHA1_LEN = 20;
private static final int WS_ACCEPT_LEN = 28;
// ── Constructor ───────────────────────────────────────────────────────────
HttpServer(FlashConfiguration configuration, AbstractRouter router, AbstractWsRouter wsRouter) throws IOException {
this.configuration = configuration;
this.router = router;
this.wsRouter = wsRouter;
List<FlashConfiguration.Listener> specs = configuration.getListeners().isEmpty()
? List.of(new FlashConfiguration.Listener(
configuration.getPort(), configuration.getHost(), configuration.getTls()))
: configuration.getListeners();
List<BoundListener> bound = new ArrayList<>(specs.size());
for (FlashConfiguration.Listener spec : specs) bound.add(bind(spec));
this.boundListeners = List.copyOf(bound);
this.acceptLatch = new CountDownLatch(ACCEPT_THREADS * boundListeners.size());
for (BoundListener bl : boundListeners) {
log.info("HttpServer bound on {}:{} (tls={}, backlog={}, acceptThreads={})",
bl.socket().getInetAddress(), bl.socket().getLocalPort(), bl.secure(),
ACCEPT_BACKLOG, ACCEPT_THREADS);
}
}
/**
* Binds one listener. A TLS listener gets its {@link ServerSocket} from
* {@link TlsConfig#serverSocketFactory()} instead of {@code new ServerSocket()}, and its
* protocol/client-auth parameters from {@link TlsConfig#applyTo} — reuse-address, receive
* buffer size, backlog and the bind call itself are identical either way. TLS only changes
* which bytes come out of {@code accept()}; it never changes how the accept loop, or
* anything downstream of it, treats them.
*/
private static BoundListener bind(FlashConfiguration.Listener spec) throws IOException {
TlsConfig tls = spec.tls();
ServerSocket socket = tls != null ? tls.serverSocketFactory().createServerSocket() : new ServerSocket();
// setReuseAddress(true) must be called BEFORE bind().
socket.setReuseAddress(true);
socket.setReceiveBufferSize(SOCKET_BUF_SIZE);
if (tls != null) tls.applyTo((SSLServerSocket) socket);
InetSocketAddress addr = spec.host() != null
? new InetSocketAddress(spec.host(), spec.port())
: new InetSocketAddress(spec.port());
socket.bind(addr, ACCEPT_BACKLOG);
return new BoundListener(socket, tls != null);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
public void start() {
for (int li = 0; li < boundListeners.size(); li++) {
BoundListener listener = boundListeners.get(li);
for (int i = 0; i < ACCEPT_THREADS; i++) {
Thread.ofPlatform()
.name("flash-accept-" + li + "-" + i)
.daemon(false)
.start(() -> acceptLoop(listener));
}
}
}
@Override
public void startAndBlock() {
start();
try { acceptLatch.await(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
/**
* Single accept loop body — runs on each of the {@code ACCEPT_THREADS}
* platform threads bound to one {@code listener}. All threads for that listener block on
* the same {@link ServerSocket}; the JVM ensures only one wakes per incoming connection
* (no thundering herd). Other listeners' accept threads are entirely independent.
*/
private void acceptLoop(BoundListener listener) {
try {
while (!stopped) {
try {
process(listener.socket().accept());
} catch (IOException e) {
if (!stopped) log.error("Accept error", e);
}
}
} finally {
acceptLatch.countDown();
}
}
@Override
public CompletableFuture<Void> stop() {
return CompletableFuture.runAsync(() -> {
stopped = true;
for (BoundListener bl : boundListeners) {
try { bl.socket().close(); } catch (IOException e) { log.error("Error closing server socket", e); }
}
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
executorService.shutdown();
try {
if (!executorService.awaitTermination(30, TimeUnit.SECONDS))
executorService.shutdownNow();
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
});
}
// ── Hot-path ──────────────────────────────────────────────────────────────
private void process(Socket socket) {
try {
executorService.submit(() -> {
activeSockets.add(socket);
try (socket;
InputStream in = socket.getInputStream();
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
// TCP_NODELAY: disable Nagle's algorithm.
// Small WS frames (< MSS) are sent immediately rather than
// waiting up to 200 ms for more data to coalesce. Latency
// drops significantly at the cost of slightly more TCP segments
// under sustained bulk transfer — acceptable for interactive WS.
socket.setTcpNoDelay(true);
socket.setSendBufferSize(SOCKET_BUF_SIZE);
// rawOut is the unbuffered socket stream — passed to WebSocketSession
// directly. WS writes are already bulk (header + payload in two calls);
// with TCP_NODELAY the kernel ships them without Nagle delay, so no
// userspace buffer is needed and no flush() is required per frame.
// HTTP responses continue to use the BufferedOutputStream (out) because
// writeResponse() does many small individual writes that benefit from
// userspace coalescing before a single syscall.
OutputStream rawOut = socket.getOutputStream();
RequestParser parser = new RequestParser(
configuration.getMaxHeaderBufferSize(),
(InetSocketAddress) socket.getRemoteSocketAddress(),
socket instanceof SSLSocket sslSocket ? sslSocket : null);
while (!stopped) {
Request request = parser.parse(in);
if (request == null) break;
if (request.method() == HttpMethod.GET && isWebSocketUpgrade(request)) {
WebSocketHandler wsHandler = wsRouter.route(request);
if (wsHandler == null) {
out.write(WS_REJECT_400);
out.flush();
break;
}
// Flush buffered HTTP bytes (the 101 response) before WebSocketSession
// takes over rawOut — otherwise the handshake reply stays stuck in
// the BufferedOutputStream buffer and the client never sees it.
performHandshake(out, request);
out.flush();
request.drain();
WebSocketSession session = new WebSocketSession(
in, rawOut, configuration.getWsFrameBufferSize(), request, false);
runWsLoop(session, wsHandler);
return;
}
boolean keepAlive = isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN);
RequestHandler handler = router.route(request);
if (handler == null) handler = router.getNotFoundHandler();
try {
Object result = handler.handle(request, response);
if (result instanceof Response r) response = r;
else if (result != null) response.setBody(result);
} catch (Exception ex) {
Object result = router.getExceptionHandler().handle(ex, request, response);
if (result instanceof Response r) response = r;
else if (result != null) response.setBody(result);
}
writeResponse(out, response, keepAlive);
request.drain();
if (!keepAlive) break;
}
} catch (IOException e) {
if (!stopped) {
if (e instanceof java.net.SocketException)
log.debug("Connection closed: {}", e.getMessage());
else
log.error("I/O error handling request", e);
}
} catch (Exception e) {
// Anything not an IOException here means a collaborator misbehaved on the TLS
// handshake path — most likely a custom TlsConfig#ofContext KeyManager/
// TrustManager throwing (e.g. a failed DB lookup or on-demand cert issuance).
// That failure is isolated to this one virtual thread/connection: the
// try-with-resources above still closes the socket, the finally below still
// runs, and the accept loop (a different thread entirely) never sees this.
if (!stopped) log.error("Unexpected error handling connection", e);
} finally {
activeSockets.remove(socket);
}
});
} catch (RejectedExecutionException ignored) {
try { socket.close(); } catch (IOException e) { log.debug("Error closing socket on shutdown", e); }
}
}
// ── WebSocket upgrade detection (zero-alloc) ──────────────────────────────
private static boolean isWebSocketUpgrade(Request request) {
ByteView upgrade = request.getRequestLine().getHeaders().view("Upgrade");
if (upgrade == null) return false;
if (!tokenEqualsIgnoreCase(upgrade, 0, upgrade.length(), "websocket")) return false;
return connectionContainsUpgrade(request);
}
private static boolean connectionContainsUpgrade(Request request) {
ByteView conn = request.getRequestLine().getHeaders().view("Connection");
if (conn == null) return false;
int len = conn.length(), i = 0;
while (i < len) {
while (i < len && conn.byteAt(i) == ' ') i++;
int start = i;
while (i < len && conn.byteAt(i) != ',') i++;
if (tokenEqualsIgnoreCase(conn, start, i, "upgrade")) return true;
i++;
}
return false;
}
private static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) {
int tlen = token.length();
int wlen = end - start;
while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--;
if (wlen != tlen) return false;
for (int i = 0; i < tlen; i++) {
byte b = view.byteAt(start + i);
if (b >= 'A' && b <= 'Z') b += 32;
if (b != (byte) token.charAt(i)) return false;
}
return true;
}
// ── WebSocket handshake ───────────────────────────────────────────────────
private void performHandshake(OutputStream out, Request request) throws IOException {
ByteView keyView = request.getRequestLine().getHeaders().view("Sec-WebSocket-Key");
if (keyView == null) throw new IOException("Missing Sec-WebSocket-Key header");
MessageDigest sha1 = SHA1.get();
sha1.reset();
for (int i = 0, len = keyView.length(); i < len; i++) sha1.update(keyView.byteAt(i));
sha1.update(WS_GUID_BYTES);
byte[] accept = Base64.getEncoder().encode(sha1.digest());
out.write(WS_HANDSHAKE_PREFIX);
out.write(accept);
out.write(WS_HANDSHAKE_SUFFIX);
out.flush();
}
// ── WebSocket session loop ────────────────────────────────────────────────
private void runWsLoop(WebSocketSession session, WebSocketHandler handler) {
handler.onOpen(session);
WebSocketFrame frame = new WebSocketFrame();
try {
while (session.isOpen()) {
if (!session.readFrame(frame)) break;
switch (frame.opcode()) {
case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY
-> handler.onMessage(session, frame);
case WebSocketFrame.OP_CLOSE
-> session.closeFromPeer(frame);
case WebSocketFrame.OP_PING
-> session.sendPong(frame);
case WebSocketFrame.OP_PONG -> { /* heartbeat ack, no-op */ }
}
}
} catch (IOException e) {
handler.onError(session, e);
} finally {
handler.onClose(session, session.closeCode());
session.forceClose();
}
}
// ── HTTP keep-alive detection ─────────────────────────────────────────────
private static boolean isKeepAlive(Request request) {
if (request.headerEquals("Connection", "close")) return false;
ByteView protocol = request.getRequestLine().getProtocol();
int plen = protocol.length();
if (plen == 8) {
byte minor = protocol.byteAt(7);
if (minor == '1') return true;
if (minor == '0') return request.headerEquals("Connection", "keep-alive");
}
log.debug("Unrecognised protocol '{}', treating as close", protocol);
return false;
}
// ── Response serialisation ────────────────────────────────────────────────
private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException {
out.write(HTTP_1_1);
byte[] statusBytes = response.getStatusBytes();
if (statusBytes != null) out.write(statusBytes);
else writeStatusPhrase(out, response.getStatusCode());
out.write(CRLF);
out.write(CONTENT_TYPE);
out.write(response.getContentType());
out.write(CRLF);
response.writeHeaders(out);
if (response.isStreaming()) {
writeStreamingBody(out, response, keepAlive);
} else {
byte[] body = response.getBody();
out.write(CONTENT_LENGTH);
writeLong(out, body != null ? body.length : 0);
out.write(CRLF);
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
if (body != null) out.write(body);
}
out.flush();
}
private static void writeStreamingBody(OutputStream out, Response response, boolean keepAlive) throws IOException {
if (!response.isChunked()) {
out.write(CONTENT_LENGTH);
writeLong(out, response.getStreamLength());
out.write(CRLF);
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
relay(response.getStream(), out);
} else {
out.write(TRANSFER_CHUNKED);
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
writeChunked(out, response.getStream());
}
}
/**
* Copies {@code in} to {@code out} until EOF, same contract as {@link InputStream#transferTo}
* — but via {@link #STREAM_RELAY_BUFFER} instead of a fresh {@code byte[]} per call, which is
* what {@code transferTo}'s own (JDK-internal) implementation would otherwise allocate on
* every streamed response.
*/
private static void relay(InputStream in, OutputStream out) throws IOException {
byte[] buf = STREAM_RELAY_BUFFER.get();
int n;
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
}
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
byte[] phrase = HttpStatus.bytesForCode(statusCode);
if (phrase != null) out.write(phrase);
else { writeLong(out, statusCode); out.write(UNKNOWN_STATUS_SUFFIX); }
}
private static void writeLong(OutputStream out, long value) throws IOException {
if (value == 0) { out.write('0'); return; }
byte[] buf = LONG_BUF.get();
int pos = buf.length;
boolean neg = value < 0;
if (neg) value = -value;
do { buf[--pos] = (byte) ('0' + value % 10); value /= 10; } while (value > 0);
if (neg) buf[--pos] = '-';
out.write(buf, pos, buf.length - pos);
}
private static void writeChunked(OutputStream out, InputStream stream) throws IOException {
byte[] buf = STREAM_RELAY_BUFFER.get();
int n;
while ((n = stream.read(buf)) > 0) {
writeHex(out, n);
out.write(CRLF);
out.write(buf, 0, n);
out.write(CRLF);
}
out.write(FINAL_CHUNK);
}
private static void writeHex(OutputStream out, int value) throws IOException {
int shift = 28;
boolean leading = true;
while (shift >= 0) {
int digit = (value >>> shift) & 0xF;
if (digit != 0 || !leading) {
leading = false;
out.write(digit < 10 ? '0' + digit : 'a' + digit - 10);
}
shift -= 4;
}
if (leading) out.write('0');
}
}
@@ -1,17 +1,22 @@
package dev.relism.flash;
import dev.relism.flash.bytes.ByteScan;
import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.HeaderMap;
import dev.relism.flash.models.Http1HeaderMap;
import dev.relism.flash.models.MutableHeaderMap;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestBody;
import dev.relism.flash.models.RequestLine;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
import dev.relism.flash.transport.BufferedByteSource;
import lombok.extern.slf4j.Slf4j;
import javax.net.ssl.SSLSocket;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.util.Arrays;
@@ -35,6 +40,15 @@ import java.util.Arrays;
* to the <em>next</em> request. They are snapshotted at the top of {@link #parse}
* and reset to {@code 0/0} before any work begins, so an exception thrown mid-parse
* leaves the fields clean rather than pointing at stale data from a previous request.
*
* Anything wrong with the request itself — smuggling-relevant ambiguity, an over-limit
* header, a malformed byte where the grammar forbids one — is reported as a
* {@link MalformedRequestException} carrying the exact status the caller must respond with.
* This is distinct from {@link IOException}, which still means "the socket failed" (EOF,
* reset, timeout). The caller ({@code HttpServer.process}) must always close the connection
* after a {@link MalformedRequestException}, never keep it alive — RFC 9112 §6.1's rationale
* for rejecting {@code Content-Length} + {@code Transfer-Encoding} outright is exactly that a
* kept-alive connection after a disputed request boundary is what a smuggling attack needs.
*/
@Slf4j
public class RequestParser {
@@ -43,7 +57,17 @@ public class RequestParser {
private final int maxHeaderBufferSize;
private final InetSocketAddress remoteAddress;
private final SSLSocket sslSocket;
private final HeaderMap headerMap = new HeaderMap();
private final Http1HeaderMap headerMap = new Http1HeaderMap();
private final MutableHeaderMap trailerMap = new MutableHeaderMap();
// request — same idiom as headerMap above.
private final RequestLine requestLine = new RequestLine();
private final Request request = new Request();
private final RequestBody requestBody = new RequestBody();
// RequestLine/Response themselves. queryView is only reset and used when a query string is
// actually present; RequestLine.getQuery() must keep returning null otherwise (see reset()).
private final FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(null, 0, 0);
private final FastPathViews.RequestByteView queryView = new FastPathViews.RequestByteView(null, 0, 0);
private final FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(null, 0, 0);
private byte[] buffer;
// Unconsumed bytes belonging to the NEXT request.
@@ -65,6 +89,19 @@ public class RequestParser {
this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
}
/**
* Whether bytes from a previous {@link #parse} call are already buffered and ready to be
* consumed by the next call without reading anything further from the source — the HTTP
* pipelining case. The caller (the connection loop) uses this to decide whether it is safe
* to skip waiting for "the next request has started arriving": if bytes are already
* buffered, the next request has, by definition, already started (and may even be
* complete), so an idle-timeout wait on the underlying source would wait for bytes that
* were never going to arrive there — they are already here.
*/
public boolean hasBufferedBytes() {
return bufLen > 0;
}
/**
* Parses the next HTTP request from {@code in}.
*
@@ -75,9 +112,12 @@ public class RequestParser {
* the same parser instance is reused after an error.
*
* @return the parsed {@link Request}, or {@code null} on clean EOF.
* @throws IOException on malformed headers or I/O failure.
* @throws MalformedRequestException if the request violates the HTTP/1.1 grammar or a
* configured safety limit — carries the exact status to respond with.
* @throws IOException on genuine I/O failure (socket reset, timeout).
*/
public Request parse(InputStream in) throws IOException {
public Request parse(BufferedByteSource in) throws IOException {
trailerMap.reset();
// Snapshot leftover bytes from the previous request, then reset immediately.
// Any exception thrown below leaves bufBase/bufLen at 0 — safe state.
int base = bufBase;
@@ -85,7 +125,7 @@ public class RequestParser {
bufBase = 0;
bufLen = 0;
int headerEndIdx = totalRead > 0 ? findEndOfHeader(buffer, base, base + totalRead) : -1;
int headerEndIdx = totalRead > 0 ? ByteScan.indexOfCrLfCrLf(buffer, base, base + totalRead) : -1;
while (headerEndIdx == -1) {
if (base + totalRead == buffer.length) {
if (base > 0) {
@@ -94,7 +134,8 @@ public class RequestParser {
System.arraycopy(buffer, base, buffer, 0, totalRead);
base = 0;
} else if (buffer.length >= maxHeaderBufferSize) {
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
throw new MalformedRequestException(431,
"Request headers exceed " + maxHeaderBufferSize + " bytes");
} else {
buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize));
}
@@ -103,65 +144,133 @@ public class RequestParser {
if (n <= 0) break;
int prevTotal = totalRead;
totalRead += n;
headerEndIdx = findEndOfHeader(buffer, base + Math.max(0, prevTotal - 3), base + totalRead);
headerEndIdx = ByteScan.indexOfCrLfCrLf(buffer, base + Math.max(0, prevTotal - 3), base + totalRead);
}
if (totalRead <= 0) return null;
if (headerEndIdx == -1) {
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
throw new MalformedRequestException(431,
"Request headers exceed " + maxHeaderBufferSize + " bytes");
}
// ── Request line ─────────────────────────────────────────────────────
int methodEnd = find(buffer, base, headerEndIdx, (byte) ' ');
if (methodEnd == -1) throw new IOException("Invalid request line (method)");
int methodEnd = ByteScan.indexOf(buffer, base, headerEndIdx, (byte) ' ');
if (methodEnd == -1) throw new MalformedRequestException(400, "Invalid request line (method)");
if (methodEnd == base) throw new MalformedRequestException(400, "Missing HTTP method");
HttpMethod method = HttpMethod.fromBytes(buffer, base, methodEnd - base);
if (method == null) throw new IOException("Unsupported HTTP method");
if (method == null) throw new MalformedRequestException(501, "Unsupported HTTP method");
int pathStart = methodEnd + 1;
int pathEnd = find(buffer, pathStart, headerEndIdx, (byte) ' ');
if (pathEnd == -1) throw new IOException("Invalid request line (path)");
int pathEnd = ByteScan.indexOf(buffer, pathStart, headerEndIdx, (byte) ' ');
if (pathEnd == -1) throw new MalformedRequestException(400, "Invalid request line (path)");
int queryMark = find(buffer, pathStart, pathEnd, (byte) '?');
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
FastPathViews.RequestByteView queryView = queryMark != -1
? new FastPathViews.RequestByteView(buffer, queryMark + 1, pathEnd - queryMark - 1)
: null;
int queryMark = ByteScan.indexOf(buffer, pathStart, pathEnd, (byte) '?');
pathView.reset(buffer, pathStart, queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
if (queryMark != -1) queryView.reset(buffer, queryMark + 1, pathEnd - queryMark - 1);
int protocolStart = pathEnd + 1;
int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r');
if (protocolEnd == -1) throw new IOException("Invalid request line (protocol)");
int protocolEnd = ByteScan.indexOf(buffer, protocolStart, headerEndIdx, (byte) '\r');
if (protocolEnd == -1) throw new MalformedRequestException(400, "Invalid request line (protocol)");
FastPathViews.RequestByteView protocolView =
new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
// from the overall header-block size, so an oversized request line gets its own,
// specific rejection rather than being folded into the generic "headers too large" case.
if (protocolEnd - base > Http1Limits.MAX_REQUEST_LINE_LENGTH) {
throw new MalformedRequestException(431, "Request line exceeds " + Http1Limits.MAX_REQUEST_LINE_LENGTH + " bytes");
}
protocolView.reset(buffer, protocolStart, protocolEnd - protocolStart);
// ── Headers ──────────────────────────────────────────────────────────
int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
int sectionStart = ByteScan.indexOf(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
int current = sectionStart;
long contentLength = 0;
boolean isChunked = false;
long contentLength = -1;
boolean contentLengthSeen = false;
boolean transferEncodingSeen = false;
boolean transferEncodingChunked = false;
int headerCount = 0;
headerMap.beginParsed(buffer, sectionStart, headerEndIdx);
while (current < headerEndIdx) {
int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r');
// deprecates line folding and treating a folded continuation as part of the
// previous header's value is a known request-smuggling vector.
byte first = buffer[current];
if (first == ' ' || first == '\t') {
throw new MalformedRequestException(400, "Obsolete line folding is not supported");
}
int lineEnd = ByteScan.indexOf(buffer, current, headerEndIdx + 1, (byte) '\r');
if (lineEnd == -1 || lineEnd == current) break;
int colon = find(buffer, current, lineEnd, (byte) ':');
if (colon != -1) {
int valueStart = colon + 1;
while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++;
// advancing past two bytes — a bare '\r' not followed by '\n' desynchronizes the
// parse and is a known bare-CR smuggling surface. Safe to read lineEnd+1: lineEnd
// is at most headerEndIdx, and findEndOfHeader already guaranteed 4 readable bytes
// (\r\n\r\n) starting at headerEndIdx.
if (buffer[lineEnd + 1] != '\n') {
throw new MalformedRequestException(400, "Malformed line terminator (bare CR)");
}
if (equalsIgnoreCase(buffer, current, colon, "content-length")) {
contentLength = parseLong(buffer, valueStart, lineEnd);
} else if (equalsIgnoreCase(buffer, current, colon, "transfer-encoding")) {
isChunked = equalsIgnoreCase(buffer, valueStart, lineEnd, "chunked");
if (++headerCount > Http1Limits.MAX_HEADER_COUNT) {
throw new MalformedRequestException(431, "Too many headers");
}
int colon = ByteScan.indexOf(buffer, current, lineEnd, (byte) ':');
if (colon == -1) {
throw new MalformedRequestException(400, "Header line missing ':'");
}
if (colon - current > Http1Limits.MAX_HEADER_NAME_LENGTH) {
throw new MalformedRequestException(431, "Header name exceeds " + Http1Limits.MAX_HEADER_NAME_LENGTH + " bytes");
}
for (int i = current; i < colon; i++) {
if (!ByteScan.isTChar(buffer[i])) {
throw new MalformedRequestException(400, "Invalid header name character");
}
}
int valueStart = colon + 1;
while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++;
if (lineEnd - valueStart > Http1Limits.MAX_HEADER_VALUE_LENGTH) {
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")) {
// parseLong, which silently accepted "5abc" as 5 and "-1" as 1.
long parsed = parseContentLengthStrict(buffer, valueStart, lineEnd);
// Multiple Content-Length lines with differing values is itself a smuggling
// permits a recipient to treat that as one value).
if (contentLengthSeen && parsed != contentLength) {
throw new MalformedRequestException(400, "Conflicting Content-Length values");
}
contentLength = parsed;
contentLengthSeen = true;
} else if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "transfer-encoding")) {
transferEncodingSeen = true;
// "chunked", so "gzip, chunked" — valid per RFC 9112 §6.1, where chunked need
// only be the FINAL coding — was silently treated as not chunked at all,
// corrupting the message boundary. Fixed by inspecting only the last token.
transferEncodingChunked = isFinalCodingChunked(buffer, valueStart, lineEnd);
}
current = lineEnd + 2;
}
headerMap.reset(buffer, sectionStart, headerEndIdx);
// MUST be treated as an error by an origin server — this is the canonical CL.TE/TE.CL
// smuggling vector. Checked once both headers are known, regardless of the order they
// appeared in, so ordering games cannot bypass it.
if (contentLengthSeen && transferEncodingSeen) {
throw new MalformedRequestException(400, "Content-Length and Transfer-Encoding both present");
}
boolean isChunked;
if (transferEncodingSeen) {
if (!transferEncodingChunked) {
throw new MalformedRequestException(501, "Unsupported Transfer-Encoding");
}
isChunked = true;
} else {
isChunked = false;
if (!contentLengthSeen) contentLength = 0;
}
// ── Body / pipelining accounting ─────────────────────────────────────
@@ -181,50 +290,69 @@ public class RequestParser {
preBufLen = (int) contentLength;
}
RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap);
requestLine.reset(method, pathView, queryMark != -1 ? queryView : null, protocolView, headerMap);
// Javadoc) -- reset() repositions it for the fixed-length/empty case (contentLength == 0
// is handled by the same call: preBufLen is already forced to 0 for it above) or the
// chunked case, never reallocated.
if (isChunked) {
return Request.forParsed(requestLine,
new ChunkedInputStream(in, buffer, bodyStart, preBufLen),
-1L, null, 0, 0, remoteAddress, sslSocket);
requestBody.reset(
new ChunkedInputStream(in, buffer, bodyStart, preBufLen, trailerMap),
-1L, null, 0, 0);
} else {
requestBody.reset(in, contentLength, buffer, bodyStart, preBufLen);
}
return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress, sslSocket);
Request parsed = Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
parsed.setTrailers(trailerMap);
return parsed;
}
// ── Buffer scanning utilities (hot path — keep branch-free where possible) ──
private static int findEndOfHeader(byte[] buf, int from, int len) {
for (int i = from; i <= len - 4; i++) {
if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n')
return i;
}
return -1;
}
private static int find(byte[] buf, int start, int end, byte target) {
for (int i = start; i < end; i++) {
if (buf[i] == target) return i;
}
return -1;
}
private static boolean equalsIgnoreCase(byte[] buf, int start, int end, String target) {
/**
* value, any non-digit byte (including a leading {@code +}/{@code -}, which are not
* digits), more than 19 digits (the longest possible {@code Long.MAX_VALUE}), arithmetic
* overflow past {@code Long.MAX_VALUE}, and a value above
* {@link Http1Limits#MAX_CONTENT_LENGTH}. The pre-existing {@code parseLong} silently
* skipped any non-digit character instead of rejecting it — {@code "5abc"} parsed as
* {@code 5} and {@code "-1"} parsed as {@code 1}.
*/
private static long parseContentLengthStrict(byte[] buf, int start, int end) throws MalformedRequestException {
int len = end - start;
if (len != target.length()) return false;
for (int i = 0; i < len; i++) {
byte b = buf[start + i];
if (b >= 'A' && b <= 'Z') b += 32;
if (b != (byte) target.charAt(i)) return false;
}
return true;
}
private static long parseLong(byte[] buf, int start, int end) {
if (len == 0) throw new MalformedRequestException(400, "Empty Content-Length value");
if (len > 19) throw new MalformedRequestException(400, "Content-Length value too long");
long value = 0;
for (int i = start; i < end; i++) {
byte c = buf[i];
if (c >= '0' && c <= '9') value = value * 10 + (c - '0');
if (c < '0' || c > '9') {
throw new MalformedRequestException(400, "Malformed Content-Length value");
}
int digit = c - '0';
if (value > (Long.MAX_VALUE - digit) / 10) {
throw new MalformedRequestException(400, "Content-Length overflow");
}
value = value * 10 + digit;
}
if (value > Http1Limits.MAX_CONTENT_LENGTH) {
throw new MalformedRequestException(413, "Content-Length exceeds configured maximum");
}
return value;
}
/**
* RFC 9112 §6.1: when {@code Transfer-Encoding} lists multiple codings
* ({@code "gzip, chunked"}), {@code chunked} MUST be the final one for the message to be
* self-delimiting. Returns whether the last comma-separated token in {@code [start, end)}
* is exactly {@code "chunked"} (case-insensitive), ignoring surrounding whitespace around
* misclassified any multi-coding value as non-chunked.
*/
private static boolean isFinalCodingChunked(byte[] buf, int start, int end) {
int e = end;
while (e > start && (buf[e - 1] == ' ' || buf[e - 1] == '\t')) e--;
int lastComma = start - 1;
for (int i = start; i < e; i++) {
if (buf[i] == ',') lastComma = i;
}
int tokenStart = lastComma + 1;
while (tokenStart < e && (buf[tokenStart] == ' ' || buf[tokenStart] == '\t')) tokenStart++;
return ByteScan.equalsIgnoreCaseAscii(buf, tokenStart, e, "chunked");
}
}
@@ -4,6 +4,7 @@ import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.transport.TransportFactory;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
@@ -11,7 +12,7 @@ import java.util.concurrent.CompletableFuture;
/**
* Public handle to the underlying HTTP transport. Returned by {@link #create}
* so that {@link FlashApp} can start and stop the server
* without holding a direct reference to the package-private {@link HttpServer}.
* without holding a direct reference to the transport's internal composition
*/
public interface ServerHandle {
@@ -30,6 +31,6 @@ public interface ServerHandle {
static ServerHandle create(FlashConfiguration config,
AbstractRouter httpRouter,
AbstractWsRouter wsRouter) throws IOException {
return new HttpServer(config, httpRouter, wsRouter);
return TransportFactory.create(config, httpRouter, wsRouter);
}
}
@@ -1,7 +1,9 @@
package dev.relism.flash.api.multipart;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.models.Request;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
@@ -41,6 +43,7 @@ import java.util.*;
* <p><b>Thread safety:</b> not thread-safe; one instance per request.
*/
public final class Multipart {
private int partCount;
private static final int BUF_CAP = 8192;
@@ -156,6 +159,11 @@ public final class Multipart {
Map<String, String> headers = readPartHeaders();
if (headers == null) { done = true; return null; }
// unbounded growth of `scanned` and unbounded cumulative header-parsing work.
if (++partCount > Http1Limits.MAX_MULTIPART_PARTS) {
throw new IOException("multipart body exceeds max part count (" + Http1Limits.MAX_MULTIPART_PARTS + ")");
}
String disp = headers.get("content-disposition");
String name = extractParam(disp, "name");
String filename = extractParam(disp, "filename");
@@ -168,8 +176,9 @@ public final class Multipart {
// File part — expose streaming body; not cached (stream is consumed once)
p = Part.streaming(name, filename, ct, active);
} else {
// Text part, or full-scan path: buffer body now
byte[] body = active.readAllBytes();
// InputStream.readAllBytes() — an unbounded field/file body would otherwise let a
// hostile peer force an arbitrarily large single heap allocation.
byte[] body = readBoundedBody(active);
active = null;
p = Part.buffered(name, filename, ct, body);
scanned.add(p);
@@ -177,6 +186,27 @@ public final class Multipart {
return p;
}
/**
* Reads {@code in} to EOF into a {@code byte[]}, bounded by
* {@link Http1Limits#MAX_MULTIPART_BUFFERED_PART_SIZE} — see that constant's Javadoc for why
* this bound is necessary even though the overall request body already has one.
*/
private static byte[] readBoundedBody(InputStream in) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream(BUF_CAP);
byte[] chunk = new byte[BUF_CAP];
long total = 0;
int n;
while ((n = in.read(chunk)) > 0) {
total += n;
if (total > Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE) {
throw new IOException("multipart part body exceeds max buffered size ("
+ Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + " bytes)");
}
out.write(chunk, 0, n);
}
return out.toByteArray();
}
// -------------------------------------------------------------------------
// PartBodyStream — inner class sharing the window buffer
// -------------------------------------------------------------------------
@@ -266,9 +296,15 @@ public final class Multipart {
private Map<String, String> readPartHeaders() throws IOException {
Map<String, String> map = new HashMap<>();
int count = 0;
while (true) {
String line = readLine();
if (line == null || line.isEmpty()) break;
// header lines before the blank line that ends a part's header block.
if (++count > Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT) {
throw new IOException("multipart part exceeds max header count ("
+ Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT + ")");
}
int colon = line.indexOf(':');
if (colon > 0)
map.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT),
@@ -289,6 +325,7 @@ public final class Multipart {
sb.append(new String(win, wPos, i - wPos, StandardCharsets.UTF_8));
int consumed = i - wPos + 2;
wPos += consumed; wLen -= consumed;
checkHeaderLineLength(sb.length());
return sb.toString();
}
}
@@ -298,14 +335,26 @@ public final class Multipart {
sb.append(new String(win, wPos, append, StandardCharsets.UTF_8));
wPos += append; wLen -= append;
}
// growing for as long as it keeps streaming bytes — the multipart-header analogue of
// RequestParser's Http1Limits.MAX_HEADER_VALUE_LENGTH check, which does not apply
// here since these header lines live inside the body, not the top-level HTTP headers.
checkHeaderLineLength(sb.length());
if (srcEof && wLen > 0) {
sb.append(new String(win, wPos, wLen, StandardCharsets.UTF_8));
wPos += wLen; wLen = 0;
checkHeaderLineLength(sb.length());
return sb.toString();
}
}
}
private static void checkHeaderLineLength(int length) throws IOException {
if (length > Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH) {
throw new IOException("multipart header line exceeds "
+ Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH + " bytes");
}
}
// -------------------------------------------------------------------------
// Utilities
// -------------------------------------------------------------------------
@@ -0,0 +1,36 @@
package dev.relism.flash.bytes;
import dev.relism.fpr.core.ByteView;
/**
* Capability interface for a {@link ByteView} that is a contiguous slice of a single backing
* {@code byte[]} — as opposed to a {@link SegmentedByteView}, which spans several arrays and
* cannot expose a single {@code (array, offset)} pair.
*
* <p>Every array-backed view in this codebase implements this: {@code RequestByteView},
* {@code SocketByteView}, {@code StringByteView} (all in
* {@code dev.relism.flash.routing.routers.fastpathrouter.FastPathViews}), and {@link PooledSlice}.
* {@code MethodPathByteView} deliberately does not — it is a composite of a {@code byte[]}
* (method) and another {@link ByteView} (path), so it has no single backing array.
*
* <h3>What this enables</h3>
* Anywhere code holds a plain {@link ByteView} and wants the fast path when the concrete
* instance happens to be array-backed, an {@code instanceof ArrayBackedByteView} check unlocks:
* <ul>
* <li>Single-allocation {@code String} construction —
* {@code new String(view.array(), view.offset(), view.length(), UTF_8)} instead of a
* byte-at-a-time copy into a scratch {@code byte[]} followed by a second allocation for
* <li>A single {@code System.arraycopy} instead of a manual loop wherever a view's bytes need
* to be copied.</li>
* </ul>
* Code that only has a bare {@link ByteView} (e.g. because it received one across the
* {@link SegmentedByteView} boundary) keeps the
* byte-at-a-time fallback — this interface is an opportunistic fast path, never a requirement.
*/
public interface ArrayBackedByteView extends ByteView {
/** The backing array. Bytes {@code [offset(), offset() + length())} belong to this view. */
byte[] array();
/** Offset of this view's first byte within {@link #array()}. */
int offset();
}
@@ -0,0 +1,324 @@
package dev.relism.flash.bytes;
import dev.relism.fpr.core.ByteView;
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
import java.nio.ByteOrder;
/**
* The single home for protocol-neutral byte scanning: single-byte search, the four-byte
* {@code \r\n\r\n} header-terminator search (SWAR-accelerated), case-insensitive comparison,
* comma-separated token-list scanning ({@code Connection: a, b, c}), RFC 9110 {@code tchar}
* validation, and the case-insensitive header-name hash {@link dev.relism.flash.models.Http1HeaderMap}'s
*
* <p>Every method here is {@code static} and allocates nothing. Every SWAR method has a plain
* scalar counterpart ({@code *Scalar}) that exists for two reasons: it is what the tests use as
* the correctness oracle (property-tested against the SWAR version on randomized inputs — see
* {@code ByteScanTest}/{@code ByteScanFuzzTest}), and it is the documented fallback if a future
* measurement ever shows the SWAR path is not worth its complexity on some path (none has been
*
* <h3>The SWAR technique used throughout</h3>
* Both {@link #indexOf} and {@link #indexOfCrLfCrLf} use the classic "does this word contain
* byte {@code b}" bit trick (Bit Twiddling Hacks, "Determine if a word has a byte equal to n"):
* XOR the 8-byte word against {@code b} broadcast into every lane (turning matching lanes to
* {@code 0x00}), then test for any zero lane with
* {@code (v - 0x0101010101010101L) & ~v & 0x8080808080808080L} — non-zero exactly when some lane
* was {@code 0x00} before the subtraction, i.e. some original lane equalled {@code b}. This finds
* *that a* matching lane exists in one word-sized read plus a handful of ALU ops, touching every
* byte only once per 8-byte stride in the common (no-match-yet) case, instead of once per byte.
*
* <p>Reading the word uses {@link MethodHandles#byteArrayViewVarHandle} with
* {@link ByteOrder#nativeOrder()} — deliberately native rather than a fixed order (contrast
* {@code fpr-core}'s {@code ByteCompare}, which fixes {@code LITTLE_ENDIAN} because it compares
* two independently-read words for bit-exact equality and so needs a byte order both reads
* agree on; nothing here compares across two separately-decoded words, so the fastest order for
* the host CPU is free to use). Byte-equality detection itself (finding that a matching lane
* exists in the mask) does not depend on which order was used to assemble the word — XOR and the
* haszero test are lane-wise operations, indifferent to how lanes map to memory offsets.
* <b>Position extraction does depend on it</b>: converting "which bit of the 64-bit mask is set"
* back into "which array index did that byte come from" requires knowing whether array byte 0
* became the long's least-significant byte (little-endian) or most-significant byte
* (big-endian) — {@link #laneIndexOf} branches on {@link #NATIVE_IS_LITTLE} once, at class-init
* time, precisely to get this right on either host.
*/
public final class ByteScan {
private ByteScan() {}
private static final ByteOrder NATIVE_ORDER = ByteOrder.nativeOrder();
private static final boolean NATIVE_IS_LITTLE = NATIVE_ORDER == ByteOrder.LITTLE_ENDIAN;
private static final VarHandle LONG_VIEW =
MethodHandles.byteArrayViewVarHandle(long[].class, NATIVE_ORDER);
private static final long LANE_LSB = 0x0101010101010101L;
private static final long LANE_MSB = 0x8080808080808080L;
// ── tchar (RFC 9110 §5.6.2) ──────────────────────────────────────────────
/**
* RFC 9110 §5.6.2 {@code tchar} set, table-driven so validation is a single array read per
* only the ASCII range a valid header-name character can ever occupy is populated.
*/
private static final boolean[] TCHAR = new boolean[128];
static {
for (byte b : "!#$%&'*+-.^_`|~".getBytes(java.nio.charset.StandardCharsets.US_ASCII)) {
TCHAR[b] = true;
}
for (char c = '0'; c <= '9'; c++) TCHAR[c] = true;
for (char c = 'A'; c <= 'Z'; c++) TCHAR[c] = true;
for (char c = 'a'; c <= 'z'; c++) TCHAR[c] = true;
}
/** Whether {@code b} is a valid RFC 9110 §5.6.2 {@code tchar} (a legal header-name byte). */
public static boolean isTChar(byte b) {
return b >= 0 && b < 128 && TCHAR[b];
}
// ── Single-byte search ───────────────────────────────────────────────────
/**
* Index of the first occurrence of {@code target} in {@code buf[from, to)}, or {@code -1}.
* SWAR-accelerated: touches 8 bytes per word while no match has been found, falling back to
* a byte-at-a-time tail once fewer than 8 bytes remain.
*/
public static int indexOf(byte[] buf, int from, int to, byte target) {
long broadcast = (target & 0xFFL) * LANE_LSB;
int i = from;
while (i + 8 <= to) {
long word = (long) LONG_VIEW.get(buf, i);
long masked = hasZeroLane(word ^ broadcast);
if (masked != 0) {
return i + laneIndexOf(masked);
}
i += 8;
}
for (; i < to; i++) {
if (buf[i] == target) return i;
}
return -1;
}
/** Plain byte-at-a-time reference implementation of {@link #indexOf} — the test oracle. */
static int indexOfScalar(byte[] buf, int from, int to, byte target) {
for (int i = from; i < to; i++) {
if (buf[i] == target) return i;
}
return -1;
}
// ── \r\n\r\n header terminator search ────────────────────────────────────
private static final byte CR = '\r', LF = '\n';
/**
* Index of the first {@code "\r\n\r\n"} in {@code buf[from, to)}, or {@code -1}. SWAR
* pre-filter (find a candidate {@code CR} byte 8 at a time) plus a cheap scalar 3-byte
* verify at each candidate — see the class Javadoc for the technique and
*/
public static int indexOfCrLfCrLf(byte[] buf, int from, int to) {
int limit = to - 4; // last index at which a 4-byte match can start
int i = from;
while (i + 8 <= to) {
long word = (long) LONG_VIEW.get(buf, i);
long masked = hasZeroLane(word ^ CR_BROADCAST);
if (masked == 0) {
i += 8;
continue;
}
int crPos = i + laneIndexOf(masked);
if (crPos > limit) {
// Nearest CR candidate in this word can't fit a full match before `to`; no CR
// exists before it in [i, crPos) (laneIndexOf always finds the lowest-address
// match first), so nothing in [i, crPos) can match either — the scalar tail
// below, bounded by `limit`, correctly finds nothing without re-deriving that.
break;
}
if (buf[crPos + 1] == LF && buf[crPos + 2] == CR && buf[crPos + 3] == LF) {
return crPos;
}
i = crPos + 1;
}
for (; i <= limit; i++) {
if (buf[i] == CR && buf[i + 1] == LF && buf[i + 2] == CR && buf[i + 3] == LF) {
return i;
}
}
return -1;
}
private static final long CR_BROADCAST = (CR & 0xFFL) * LANE_LSB;
/** Plain byte-at-a-time reference implementation of {@link #indexOfCrLfCrLf} — the test oracle. */
static int indexOfCrLfCrLfScalar(byte[] buf, int from, int to) {
for (int i = from; i <= to - 4; i++) {
if (buf[i] == CR && buf[i + 1] == LF && buf[i + 2] == CR && buf[i + 3] == LF) {
return i;
}
}
return -1;
}
/** "Determine if a word has a byte equal to n" (Bit Twiddling Hacks), applied to {@code xored}. */
private static long hasZeroLane(long xored) {
return (xored - LANE_LSB) & ~xored & LANE_MSB;
}
/** Converts a {@link #hasZeroLane} result into the array-index offset of its lowest matching lane. */
private static int laneIndexOf(long masked) {
return NATIVE_IS_LITTLE
? Long.numberOfTrailingZeros(masked) >>> 3
: 7 - (Long.numberOfLeadingZeros(masked) >>> 3);
}
// ── Case-insensitive comparison ──────────────────────────────────────────
private static byte foldAsciiUpper(byte b) {
return (b >= 'A' && b <= 'Z') ? (byte) (b + 32) : b;
}
/** Case-insensitive (ASCII) equality of {@code buf[start, end)} against {@code target}. */
public static boolean equalsIgnoreCaseAscii(byte[] buf, int start, int end, String target) {
int len = end - start;
if (len != target.length()) return false;
for (int i = 0; i < len; i++) {
if (foldAsciiUpper(buf[start + i]) != foldAsciiUpper((byte) target.charAt(i))) return false;
}
return true;
}
/** Case-insensitive (ASCII) equality of two byte-array ranges. */
public static boolean equalsIgnoreCaseAscii(byte[] a, int aStart, int aLen, byte[] b, int bStart, int bLen) {
if (aLen != bLen) return false;
for (int i = 0; i < aLen; i++) {
if (foldAsciiUpper(a[aStart + i]) != foldAsciiUpper(b[bStart + i])) return false;
}
return true;
}
/** Case-insensitive (ASCII) equality of {@code view[start, end)} against {@code target}. */
public static boolean equalsIgnoreCase(ByteView view, int start, int end, String target) {
int len = end - start;
if (len != target.length()) return false;
for (int i = 0; i < len; i++) {
if (foldAsciiUpper(view.byteAt(start + i)) != foldAsciiUpper((byte) target.charAt(i))) return false;
}
return true;
}
// ── Comma-separated token lists (e.g. `Connection: keep-alive, Upgrade`) ────
/**
* Whether the comma-separated, OWS-tolerant token list {@code view} contains {@code token}
* (case-insensitive). The shared scanner behind both {@code Http1KeepAlive.isKeepAlive} and
* drift apart the way a whole-value {@code equals} check once did.
*/
public static boolean tokenListContains(ByteView view, String token) {
int len = view.length(), i = 0;
while (i < len) {
while (i < len && view.byteAt(i) == ' ') i++;
int start = i;
while (i < len && view.byteAt(i) != ',') i++;
if (tokenEqualsIgnoreCase(view, start, i, token)) return true;
i++;
}
return false;
}
/** Case-insensitive compare of {@code view[start, end)}, trimming trailing spaces, against {@code token}. */
public static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) {
int wlen = end - start;
while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--;
return equalsIgnoreCase(view, start, start + wlen, token);
}
/**
* Case-insensitive (ASCII fold) 32-bit FNV-1a hash of {@code buf[start, start + len)}. Used
* by {@link dev.relism.flash.models.Http1HeaderMap}'s per-request index to compare a cheap hash
* before falling back to a full case-insensitive {@code memcmp}-equivalent
* ({@link #equalsIgnoreCaseAscii}) — two header names that differ anywhere hash differently
* with overwhelming probability, so the common "not the header I'm looking for" case resolves
* in one hash compare instead of a byte-by-byte scan.
*/
public static int hashNameIgnoreCaseAscii(byte[] buf, int start, int len) {
int hash = 0x811C9DC5; // FNV-1a 32-bit offset basis
for (int i = 0; i < len; i++) {
hash ^= (foldAsciiUpper(buf[start + i]) & 0xFF);
hash *= 0x01000193; // FNV-1a 32-bit prime
}
return hash;
}
/**
* Same hash as {@link #hashNameIgnoreCaseAscii(byte[], int, int)}, computed directly from a
* lookup-key {@code String} (e.g. {@code "Content-Type"}) instead of already-scanned bytes —
* the two must agree bit-for-bit on equivalent ASCII content for
* {@link dev.relism.flash.models.Http1HeaderMap}'s index (hash the request-declared bytes once at
* {@code reset()}; hash the caller's lookup key once per {@code first()}/{@code all()} call;
* compare the two cheap hashes before ever touching a full case-insensitive comparison).
*/
public static int hashNameIgnoreCaseAscii(String name) {
int hash = 0x811C9DC5;
int len = name.length();
for (int i = 0; i < len; i++) {
hash ^= (foldAsciiUpper((byte) name.charAt(i)) & 0xFF);
hash *= 0x01000193;
}
return hash;
}
// ── Decimal / hex parsing ────────────────────────────────────────────────
/** Sentinel returned by {@link #parseDecimalStrict} on any malformed or out-of-range input. */
public static final long PARSE_INVALID = -1L;
/**
* Strict, overflow-safe unsigned decimal parse of {@code buf[start, end)}: rejects an empty
* range, any non-{@code '0'..'9'} byte, more than 19 digits, and arithmetic overflow past
* {@link Long#MAX_VALUE}. Returns {@link #PARSE_INVALID} rather than throwing — the same
* shape {@code RequestParser}'s own {@code Content-Length} parser already hand-rolls (kept
* separate there since it also needs to throw a specific, differently-worded
* {@code MalformedRequestException} per failure mode); this is the general-purpose version
* for callers (HPACK integer decoding, frame-length fields) that just need a valid/invalid
* signal.
*/
public static long parseDecimalStrict(byte[] buf, int start, int end) {
int len = end - start;
if (len == 0 || len > 19) return PARSE_INVALID;
long value = 0;
for (int i = start; i < end; i++) {
byte c = buf[i];
if (c < '0' || c > '9') return PARSE_INVALID;
int digit = c - '0';
if (value > (Long.MAX_VALUE - digit) / 10) return PARSE_INVALID;
value = value * 10 + digit;
}
return value;
}
/**
* Parses up to {@code maxDigits} hex digits (ASCII, either case) from {@code buf[start, end)}
* as an unsigned value. Returns {@link #PARSE_INVALID} if the range is empty, contains a
* non-hex-digit byte, or would need more than {@code maxDigits} digits to represent (the
* caller's bound against, e.g., a chunk-size line with an implausible number of digits).
*/
public static long parseHexStrict(byte[] buf, int start, int end, int maxDigits) {
int len = end - start;
if (len == 0 || len > maxDigits) return PARSE_INVALID;
long value = 0;
for (int i = start; i < end; i++) {
int digit = hexDigit(buf[i]);
if (digit < 0) return PARSE_INVALID;
value = (value << 4) | digit;
}
return value;
}
private static int hexDigit(byte b) {
if (b >= '0' && b <= '9') return b - '0';
if (b >= 'a' && b <= 'f') return b - 'a' + 10;
if (b >= 'A' && b <= 'F') return b - 'A' + 10;
return -1;
}
}
@@ -0,0 +1,162 @@
package dev.relism.flash.bytes;
import java.nio.charset.StandardCharsets;
/**
* Index-based writer into a growable {@code byte[]} scratch buffer. Every {@code write*} method
* bounds-checks and grows the backing array only when the write would not otherwise fit —
* on an already-warm buffer (the steady-state case: the buffer has already grown to the
* connection's high-water mark), no method here allocates.
*
* Callers build a complete message in a {@code ByteWriter}-backed scratch buffer and then issue
* one bulk {@code write(buffer, 0, length())}. The same writer is shared by HTTP/1.1 and HTTP/2.
*
* <h3>Lifetime and thread-safety contract</h3>
* Not thread-safe — exactly one writer at a time, matching every other per-connection scratch
* object in this codebase ({@code ConnectionScratch}, {@code Http1HeaderMap}). {@link #reset()}
* repositions this writer to the start of its backing array for the next message; the backing
* array itself is never shrunk back down, only grown — the same amortized-to-zero-allocation
* growth policy {@code RequestParser}'s read buffer already uses.
*/
public final class ByteWriter {
private byte[] buf;
private final byte[] digits = new byte[20];
private int len;
public ByteWriter(int initialCapacity) {
this.buf = new byte[Math.max(initialCapacity, 16)];
}
/** Repositions this writer to the start of its buffer, ready for the next message. */
public void reset() {
len = 0;
}
/** The backing buffer. Valid content is {@code [0, length())} — never assume {@code buf.length == length()}. */
public byte[] array() {
return buf;
}
/** How many bytes have been written since the last {@link #reset()}. */
public int length() {
return len;
}
private void ensure(int additional) {
int needed = len + additional;
if (needed <= buf.length) return;
int grown = buf.length * 2;
while (grown < needed) grown *= 2;
byte[] next = new byte[grown];
System.arraycopy(buf, 0, next, 0, len);
buf = next;
}
public void writeByte(byte b) {
ensure(1);
buf[len++] = b;
}
public void writeBytes(byte[] src) {
writeBytes(src, 0, src.length);
}
public void writeBytes(byte[] src, int off, int srcLen) {
ensure(srcLen);
System.arraycopy(src, off, buf, len, srcLen);
len += srcLen;
}
/**
* Writes {@code value}'s ASCII decimal digits (no sign — callers write {@code '-'} via
* {@link #writeByte} first if needed). {@code value} must be non-negative.
*/
public void writeDecimal(long value) {
if (value < 0) throw new IllegalArgumentException("writeDecimal requires a non-negative value: " + value);
if (value == 0) {
writeByte((byte) '0');
return;
}
// Digits emerge least-significant-first. The reusable field holds every possible long
// representation, so decimal rendering does not allocate on a warm writer.
int n = 0;
long v = value;
while (v > 0) {
digits[n++] = (byte) ('0' + (v % 10));
v /= 10;
}
ensure(n);
for (int i = n - 1; i >= 0; i--) buf[len++] = digits[i];
}
private static final byte[] HEX_DIGITS = "0123456789abcdef".getBytes(StandardCharsets.US_ASCII);
/** Writes {@code value}'s lowercase hex digits, no leading zeros (except for {@code value == 0}, which writes {@code "0"}). */
public void writeHex(int value) {
if (value == 0) {
writeByte((byte) '0');
return;
}
int n = 0;
int v = value;
while (v != 0) {
digits[n++] = HEX_DIGITS[v & 0xF];
v >>>= 4;
}
ensure(n);
for (int i = n - 1; i >= 0; i--) buf[len++] = digits[i];
}
/** Writes {@code s}'s ASCII bytes, lower-cased. {@code s} must be ASCII-only. */
public void writeAsciiLower(String s) {
int n = s.length();
ensure(n);
for (int i = 0; i < n; i++) {
char c = s.charAt(i);
if (c >= 'A' && c <= 'Z') c += 32;
buf[len++] = (byte) c;
}
}
/**
* Writes {@code s}'s ASCII bytes, case preserved. {@code s} must be ASCII-only. Unlike
* {@code new String(...).getBytes(UTF_8)}, writes each character directly into this buffer
* and avoids an intermediate {@code byte[]}.
*/
public void writeAscii(String s) {
int n = s.length();
ensure(n);
for (int i = 0; i < n; i++) {
buf[len++] = (byte) s.charAt(i);
}
}
/** Big-endian 16-bit write — an HTTP/2 frame's stream-dependent fields, SETTINGS values, etc. */
public void writeUInt16(int value) {
ensure(2);
buf[len++] = (byte) (value >>> 8);
buf[len++] = (byte) value;
}
/** Big-endian 24-bit write — an HTTP/2 frame header's length field. */
public void writeUInt24(int value) {
ensure(3);
buf[len++] = (byte) (value >>> 16);
buf[len++] = (byte) (value >>> 8);
buf[len++] = (byte) value;
}
/** Big-endian 31-bit write (top bit always 0) — an HTTP/2 stream identifier. */
public void writeUInt31(int value) {
writeUInt32(value & 0x7FFFFFFF);
}
/** Big-endian 32-bit write — an HTTP/2 window-size increment, SETTINGS value, etc. */
public void writeUInt32(int value) {
ensure(4);
buf[len++] = (byte) (value >>> 24);
buf[len++] = (byte) (value >>> 16);
buf[len++] = (byte) (value >>> 8);
buf[len++] = (byte) value;
}
}
@@ -0,0 +1,42 @@
package dev.relism.flash.bytes;
/**
* The allocation-free idiom for returning two {@code int}s from a method without an object:
* pack both into one {@code long}, unpack at the call site. Already used, hand-rolled, in four
* places ({@code Http1HeaderMap.findFirst}, {@code QueryParams.findFirst}, and others) before this
* class existed — this is the single named home for the shifts so they are not duplicated (and
* potentially inconsistently duplicated — e.g. one copy masking with {@code 0xFFFFFFFFL} and
* another forgetting to) five times over.
*
* <h3>Why this works</h3>
* A {@code long} is 64 bits; each packed {@code int} is 32. {@link #pack} left-shifts the high
* half into the top 32 bits and OR's the low half into the bottom 32. {@link #lo} must mask with
* {@code 0xFFFFFFFFL} rather than simply cast to {@code int} after no mask, because a right-shift
* of a negative {@code long} sign-extends — the mask discards everything above bit 31 before the
* narrowing cast happens implicitly. {@link #hi} needs no mask: a right-shift by 32 already
* leaves only the original high bits in the low 32 positions of the result.
*
* <h3>Encoding convention used across this codebase</h3>
* Every {@code findFirst}-shaped method in this codebase packs {@code (start << 32) | length},
* i.e. {@code hi() == start} and {@code lo() == length}. {@code -1L} is the shared "not found"
* sentinel (a valid {@code (start, length)} pair can never be negative, since both halves are
* non-negative offsets/lengths).
*/
public final class Pairs {
private Pairs() {}
/** Packs two {@code int}s into one {@code long}: {@code hi} in the upper 32 bits, {@code lo} in the lower 32. */
public static long pack(int hi, int lo) {
return ((long) hi << 32) | (lo & 0xFFFFFFFFL);
}
/** Extracts the upper 32 bits packed by {@link #pack}. */
public static int hi(long packed) {
return (int) (packed >> 32);
}
/** Extracts the lower 32 bits packed by {@link #pack}. */
public static int lo(long packed) {
return (int) (packed & 0xFFFFFFFFL);
}
}
@@ -0,0 +1,52 @@
package dev.relism.flash.bytes;
/**
* per-call {@code new ByteView() { ... }} anonymous-class allocation that used to live in
* {@code Http1HeaderMap.view}, {@code QueryParams.view}, and {@code PathParams.view}: instead of
* allocating a fresh view object (plus its capturing instance) on every call, a small
* {@link SlicePool} of these hands out an existing instance, repositioned in place.
*
* <h3>Lifetime contract</h3>
* A {@code PooledSlice} handed out by {@link SlicePool#acquire} is valid only until the pool
* wraps around and reuses the same slot — see {@link SlicePool}'s own Javadoc for the exact
* "valid until the Nth subsequent acquire, or end of request" rule the owning class (e.g.
* {@code Http1HeaderMap}) documents precisely for its own {@code view()} method. Never retain a
* {@code PooledSlice} past that window, for the same reason the old anonymous view could not be
* retained past the handler: the bytes (and, here, additionally the slice object itself) are
* about to be repositioned out from under a stale reference.
*/
public final class PooledSlice implements ArrayBackedByteView {
private byte[] array;
private int offset;
private int length;
/** Repositions this slice over {@code array[offset, offset + length)}. Zero allocation. */
public void reset(byte[] array, int offset, int length) {
this.array = array;
this.offset = offset;
this.length = length;
}
@Override
public byte[] array() {
return array;
}
@Override
public int offset() {
return offset;
}
@Override
public int length() {
return length;
}
@Override
public byte byteAt(int index) {
if (index < 0 || index >= length) {
throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + length);
}
return array[offset + index];
}
}
@@ -0,0 +1,80 @@
package dev.relism.flash.bytes;
import dev.relism.fpr.core.ByteView;
/**
* A {@link ByteView} over up to {@code K} discontiguous {@code byte[]} segments, presented as one
* logical byte sequence. Exists for the one case in this codebase where a "single contiguous
* slice of one buffer" model (every other {@link ByteView} implementation) does not hold: an
* HPACK header block whose encoding spans more than one {@code CONTINUATION} frame (RFC 9113
* §6.10), where each frame's payload lives in its own connection-buffer region.
*
* <h3>Deliberately not array-backed</h3>
* This does not implement {@link ArrayBackedByteView} — there is no single {@code (array,
* offset)} pair that describes it — and {@link #supportsLong()} returns {@code false}
* time path is only sound for a genuinely contiguous backing array; see
* {@code FastPathViews.MethodPathByteView} for the other deliberately-segmented view in this
* codebase, which makes the same choice for the same reason).
*
* <h3>Reusable, not allocated per block</h3>
* {@link #reset} repositions this view over a new set of segments without allocating — the same
* idiom {@link PooledSlice} uses for the contiguous case. The {@code segments}/{@code offsets}/
* {@code lengths} arrays passed to {@link #reset} are retained by reference, not copied; the
* caller owns their lifetime (typically the connection's HPACK scratch, sized to
* {@code Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK}).
*
* <h3>Cost model</h3>
* {@link #byteAt} walks the segment table to find which segment an index falls in — O(segments),
* not O(1) — because this view exists precisely for the rare, deliberately-bounded case
* (at most {@code MAX_CONTINUATION_FRAMES_PER_BLOCK} segments); optimizing it further would add
* complexity for a path that, by construction, is never hot.
*/
public final class SegmentedByteView implements ByteView {
private byte[][] segments;
private int[] offsets;
private int[] lengths;
private int count;
private int totalLength;
/**
* Repositions this view over {@code segments[0..count)}, where segment {@code i} contributes
* bytes {@code segments[i][offsets[i], offsets[i] + lengths[i])}. Zero allocation: the three
* arrays are retained by reference.
*/
public void reset(byte[][] segments, int[] offsets, int[] lengths, int count) {
this.segments = segments;
this.offsets = offsets;
this.lengths = lengths;
this.count = count;
int total = 0;
for (int i = 0; i < count; i++) total += lengths[i];
this.totalLength = total;
}
@Override
public int length() {
return totalLength;
}
@Override
public byte byteAt(int index) {
if (index < 0 || index >= totalLength) {
throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + totalLength);
}
int remaining = index;
for (int i = 0; i < count; i++) {
int len = lengths[i];
if (remaining < len) {
return segments[i][offsets[i] + remaining];
}
remaining -= len;
}
throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + totalLength);
}
/** Always {@code false} — see the class Javadoc for why a cross-segment word read is unsound. */
@Override
public boolean supportsLong() {
return false;
}
}
@@ -0,0 +1,51 @@
package dev.relism.flash.bytes;
/**
* A small, fixed-size ring of {@link PooledSlice} instances — one per {@code ConnectionScratch}-
* {@code Http1HeaderMap.view}, {@code QueryParams.view}, {@code PathParams.view}).
*
* <h3>Why a ring, not a single reused slice</h3>
* A single reused slice (the shape {@code Http1HeaderMap.forEach} already uses for its two
* {@code nameSlice}/{@code valueSlice} fields) is correct only when the caller is guaranteed to
* finish with one slice before the next is produced — true for a single {@code forEach} callback
* invocation, false for {@code view()}: a handler might reasonably call
* {@code headers.view("A")} and {@code headers.view("B")} and want to compare both. A ring of
* {@code size} slices lets up to {@code size} calls' results stay simultaneously valid.
*
* <h3>Lifetime contract</h3>
* A slice returned by {@link #acquire} is valid until either the request ends, or {@link #acquire}
* is called {@code size} more times on the same pool (at which point the ring has wrapped around
* and repositioned that same slot for a new caller) — whichever comes first. This must be
* restated precisely on every method that hands out a slice from a pool (see
* {@code Http1HeaderMap.view}'s Javadoc for the canonical wording); it is a real, testable hazard, not
* a hypothetical one — see {@code SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice} for
* a demonstration.
*/
public final class SlicePool {
private final PooledSlice[] slices;
private int next = 0;
/** A ring of {@code size} reusable slices. {@code size} must be at least 1. */
public SlicePool(int size) {
if (size < 1) throw new IllegalArgumentException("SlicePool size must be at least 1: " + size);
slices = new PooledSlice[size];
for (int i = 0; i < size; i++) slices[i] = new PooledSlice();
}
/** How many slices this pool cycles through before a caller's slice is reused. */
public int size() {
return slices.length;
}
/**
* Returns the next slice in the ring, repositioned over {@code array[offset, offset + length)}.
* Zero allocation — the returned instance already existed.
*/
public PooledSlice acquire(byte[] array, int offset, int length) {
PooledSlice slice = slices[next];
next++;
if (next == slices.length) next = 0;
slice.reset(array, offset, length);
return slice;
}
}
@@ -0,0 +1,22 @@
package dev.relism.flash.exceptions;
/**
* Thrown by the HTTP/1.1 parser when a request violates a protocol rule that must be rejected
* outright — most importantly the request-smuggling defenses of RFC 9112 §6.1 (see
*
* <p>Distinct from {@link HttpException}, which a <em>handler</em> throws to describe an
* application-level failure and which is routed through the user's configured exception
* handler ({@code AbstractRouter.getExceptionHandler()}). A malformed request never reaches a
* handler, or middleware, or the user's exception handler at all: it is rejected by the
* transport itself, with a fixed, minimal, non-customizable response, and the connection is
* always closed afterwards — never kept alive. Keeping a connection alive after a rejected
* request is exactly the situation a smuggling attempt exploits (a rejected first request
* hiding a crafted second one in the same TCP stream), so the transport never offers that
* choice to user code.
*/
public class MalformedRequestException extends HttpException {
public MalformedRequestException(int status, String message) {
super(status, message);
}
}
@@ -7,7 +7,7 @@ import java.util.List;
/**
* Inspects a handler class at registration time and returns zero or more
* {@link Middleware middlewares} to inject automatically.
* {@link MiddlewareNode middleware nodes} to inject automatically.
*
* <p>Processors are called once per register call, before
* the handler is compiled into the router. Returning an empty list is always
@@ -1,13 +1,11 @@
package dev.relism.flash.extension;
import dev.relism.flash.tls.TlsConfig;
import java.util.List;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List;
/**
* Configuration for a {@link FlashApp} instance.
*
@@ -39,27 +37,128 @@ import java.util.List;
@Builder
public class FlashConfiguration {
int port;
String host;
int port;
String host;
/** TLS for the single default listener ({@link #port}/{@link #host}). Ignored if {@link #listeners} is non-empty. */
TlsConfig tls;
/**
* TLS for the single default listener ({@link #port}/{@link #host}). Ignored if {@link
* #listeners} is non-empty.
*/
TlsConfig tls;
/** One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link #host}/{@link #tls}. */
@Singular
List<Listener> listeners;
/**
* One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link
* #host}/{@link #tls}.
*/
@Singular List<Listener> listeners;
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
@Builder.Default
int maxHeaderBufferSize = 64 * 1024;
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
@Builder.Default int maxHeaderBufferSize = 64 * 1024;
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
@Builder.Default
int wsFrameBufferSize = 64 * 1024;
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
@Builder.Default int wsFrameBufferSize = 64 * 1024;
/** One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS. */
public record Listener(int port, String host, TlsConfig tls) {
public Listener(int port) { this(port, null, null); }
public Listener(int port, TlsConfig tls) { this(port, null, tls); }
/**
* Maximum time, in milliseconds, allowed for a request's headers to be fully read once the first
* byte of it has arrived. Bounds the classic slowloris attack: a peer that trickles one header
* byte every few seconds forever. Enforced by an absolute deadline (see {@code
* dev.relism.flash.transport.BufferedByteSource}), not merely a per-read socket timeout — a
* per-read timeout alone never trips as long as each individual read succeeds within the window,
* no matter how long the overall header block takes. Default: 10 000
*/
@Builder.Default int headerReadTimeoutMs = 10_000;
/**
* Maximum time, in milliseconds, a keep-alive connection may sit idle waiting for its next
* request before being closed. More generous than {@link #headerReadTimeoutMs} because an idle
* keep-alive connection is normal, expected behaviour, not an attack in progress — the tighter
* bound applies only once bytes have actually started arriving. Default: 60 000
*/
@Builder.Default int idleKeepAliveTimeoutMs = 60_000;
/**
* Maximum time, in milliseconds, a request's body may take to be fully read (by the handler or by
* the automatic drain after it returns) once headers are parsed. Default: 30 000
*/
@Builder.Default int bodyReadTimeoutMs = 30_000;
/**
* Maximum time, in milliseconds, {@link dev.relism.flash.ServerHandle#stop()} waits for in-flight
* requests to finish after it stops accepting new connections, before force-
*/
@Builder.Default int shutdownDrainTimeoutMs = 15_000;
/**
* Maximum connections admitted across all listeners before new connections are closed
* immediately at accept time, before any per-connection state (TLS handshake, protocol
* negotiation, HPACK tables, buffers) is set up. Defaults to an auto-scaled budget based on the
* JVM's max heap ({@link dev.relism.flash.transport.TransportLimits#defaultMaxConnections()}),
* so a connection flood cannot exhaust the heap out of the box. Set explicitly if you know your
* deployment's real capacity, or to {@code 0} to disable the check entirely (unlimited).
*/
@Builder.Default int maxConnections =
dev.relism.flash.transport.TransportLimits.defaultMaxConnections();
/** Whether TLS listeners advertise HTTP/2 through ALPN. */
@Builder.Default boolean http2Enabled = false;
/**
* Whether plaintext listeners accept the HTTP/2 prior-knowledge preface. This is independent
* from TLS HTTP/2 and deliberately disabled by default.
*/
@Builder.Default boolean http2CleartextEnabled = false;
/**
* Whether runtime-generated HTTP/2 header values use HPACK Huffman coding. Constants are always
* compressed once at startup; leaving this disabled avoids a per-byte encode pass on responses.
*/
@Builder.Default boolean h2HuffmanDynamicValues = false;
/** Maximum peer RST_STREAM frames per rolling interval. */
@Builder.Default int h2MaxResetStreamsPerInterval =
dev.relism.flash.http2.Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL;
/** Maximum peer-created streams per rolling interval. */
@Builder.Default int h2MaxStreamsCreatedPerInterval =
dev.relism.flash.http2.Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL;
/** Rolling interval used by HTTP/2 abuse-rate counters. */
@Builder.Default long h2AbuseRateIntervalMs =
dev.relism.flash.http2.Http2Limits.RESET_RATE_INTERVAL_MS;
/** Maximum total streams served by one HTTP/2 connection; zero disables the budget. */
@Builder.Default long h2MaxStreamsPerConnection =
dev.relism.flash.http2.Http2Limits.MAX_STREAMS_PER_CONNECTION;
/** Maximum wire bytes read by one HTTP/2 connection; zero disables the budget. */
@Builder.Default long h2MaxBytesPerConnection =
dev.relism.flash.http2.Http2Limits.MAX_BYTES_PER_CONNECTION;
/** Maximum HTTP/2 connection lifetime in milliseconds; zero disables the budget. */
@Builder.Default long h2MaxConnectionLifetimeMs =
dev.relism.flash.http2.Http2Limits.MAX_CONNECTION_LIFETIME_MS;
/** Maximum inactivity time for an open HTTP/2 stream. */
@Builder.Default long h2StreamIdleTimeoutMs =
dev.relism.flash.http2.Http2Limits.STREAM_IDLE_TIMEOUT_MS;
/**
* Whether every response includes a {@code Date} header (RFC 9110 §6.6.1). Default {@code true};
* set {@code false} if Flash sits behind a reverse proxy that already adds one, to skip the
* (already cheap — see {@code dev.relism.flash.http.DateHeader}) write.
*/
@Builder.Default boolean sendDate = true;
/**
* One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS.
*/
public record Listener(int port, String host, TlsConfig tls) {
public Listener(int port) {
this(port, null, null);
}
public Listener(int port, TlsConfig tls) {
this(port, null, tls);
}
}
}
@@ -2,8 +2,9 @@ package dev.relism.flash.extension;
import dev.relism.flash.exceptions.InitializationException;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.routing.Ws;
import dev.relism.flash.routing.Route;
import dev.relism.flash.routing.Routes;
import dev.relism.flash.routing.Ws;
import dev.relism.flash.websocket.WebSocketEndpoint;
import java.io.File;
@@ -1,65 +1,88 @@
package dev.relism.flash.http;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.hpack.HpackEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import lombok.Getter;
import java.nio.charset.StandardCharsets;
/**
* Pre-compiled byte representations of common HTTP {@code Content-Type} values.
* {@link #getBytes()} returns the pre-computed array directly, never allocates.
* Pre-compiled byte representations of common HTTP {@code Content-Type} values. {@code getBytes()}
* returns the pre-computed array directly, never allocates.
*/
@Getter
public enum ContentType {
NONE(""),
NONE (""),
// Text
TEXT_PLAIN("text/plain"),
TEXT_HTML("text/html"),
TEXT_CSS("text/css"),
TEXT_JAVASCRIPT("text/javascript"),
TEXT_XML("text/xml"),
TEXT_CSV("text/csv"),
TEXT_MARKDOWN("text/markdown"),
TEXT_EVENT_STREAM("text/event-stream"),
// Text
TEXT_PLAIN ("text/plain"),
TEXT_HTML ("text/html"),
TEXT_CSS ("text/css"),
TEXT_JAVASCRIPT ("text/javascript"),
TEXT_XML ("text/xml"),
TEXT_CSV ("text/csv"),
TEXT_MARKDOWN ("text/markdown"),
TEXT_EVENT_STREAM ("text/event-stream"),
// Application
JSON("application/json"),
XML("application/xml"),
BINARY("application/octet-stream"),
PDF("application/pdf"),
ZIP("application/zip"),
GZIP("application/gzip"),
FORM_URLENCODED("application/x-www-form-urlencoded"),
MULTIPART_FORM("multipart/form-data"),
GRAPHQL("application/graphql"),
NDJSON("application/x-ndjson"),
MSGPACK("application/msgpack"),
CBOR("application/cbor"),
LD_JSON("application/ld+json"),
// Application
JSON ("application/json"),
XML ("application/xml"),
BINARY ("application/octet-stream"),
PDF ("application/pdf"),
ZIP ("application/zip"),
GZIP ("application/gzip"),
FORM_URLENCODED ("application/x-www-form-urlencoded"),
MULTIPART_FORM ("multipart/form-data"),
GRAPHQL ("application/graphql"),
NDJSON ("application/x-ndjson"),
MSGPACK ("application/msgpack"),
CBOR ("application/cbor"),
LD_JSON ("application/ld+json"),
// Image
IMAGE_PNG("image/png"),
IMAGE_JPEG("image/jpeg"),
IMAGE_GIF("image/gif"),
IMAGE_WEBP("image/webp"),
IMAGE_SVG("image/svg+xml"),
IMAGE_ICO("image/x-icon"),
IMAGE_AVIF("image/avif"),
// Image
IMAGE_PNG ("image/png"),
IMAGE_JPEG ("image/jpeg"),
IMAGE_GIF ("image/gif"),
IMAGE_WEBP ("image/webp"),
IMAGE_SVG ("image/svg+xml"),
IMAGE_ICO ("image/x-icon"),
IMAGE_AVIF ("image/avif"),
// Font
FONT_WOFF("font/woff"),
FONT_WOFF2("font/woff2"),
// Font
FONT_WOFF ("font/woff"),
FONT_WOFF2 ("font/woff2"),
// Audio / Video
AUDIO_MPEG("audio/mpeg"),
AUDIO_OGG("audio/ogg"),
VIDEO_MP4("video/mp4"),
VIDEO_WEBM("video/webm");
// Audio / Video
AUDIO_MPEG ("audio/mpeg"),
AUDIO_OGG ("audio/ogg"),
VIDEO_MP4 ("video/mp4"),
VIDEO_WEBM ("video/webm");
private final byte[] bytes;
private final byte[] hpackBytes;
private static final ContentType[] ALL = values();
private final byte[] bytes;
ContentType(String value) {
this.bytes = value.getBytes(StandardCharsets.UTF_8);
ContentType(String value) {
this.bytes = value.getBytes(StandardCharsets.UTF_8);
if (bytes.length == 0) {
this.hpackBytes = bytes;
} else {
ByteWriter out = new ByteWriter(32);
HpackEncoder.writeLiteralWithNameIndex(out, 31, bytes, true);
this.hpackBytes = Arrays.copyOf(out.array(), out.length());
}
}
/** Precompiled HPACK {@code content-type} field, or an empty array for {@link #NONE}. */
public byte[] getHpackBytes() {
return hpackBytes;
}
/** Finds the boot-time HPACK rendering for a response content-type byte array. */
public static byte[] hpackBytesFor(byte[] value) {
for (ContentType type : ALL) {
if (type.bytes == value || Arrays.equals(type.bytes, value)) return type.hpackBytes;
}
return null;
}
}
@@ -0,0 +1,77 @@
package dev.relism.flash.http;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.hpack.HpackEncoder;
import java.nio.charset.StandardCharsets;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.Locale;
/**
* A daemon refreshes both protocol renderings once per second. Response writers only perform one
* volatile read and copy already-encoded bytes into their output buffer.
*/
public final class DateHeader {
private DateHeader() {}
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US)
.withZone(ZoneOffset.UTC);
private record Snapshot(byte[] http1, byte[] hpack) {}
private static volatile Snapshot current = encode();
static {
Thread refresher =
new Thread(
() -> {
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
current = encode();
}
},
"flash-date-header");
refresher.setDaemon(true);
refresher.start();
}
private static Snapshot encode() {
byte[] value = format(ZonedDateTime.now(ZoneOffset.UTC)).getBytes(StandardCharsets.US_ASCII);
byte[] prefix = "Date: ".getBytes(StandardCharsets.US_ASCII);
byte[] http1 = new byte[prefix.length + value.length + 2];
System.arraycopy(prefix, 0, http1, 0, prefix.length);
System.arraycopy(value, 0, http1, prefix.length, value.length);
http1[http1.length - 2] = '\r';
http1[http1.length - 1] = '\n';
ByteWriter encoded = new ByteWriter(32);
HpackEncoder.writeLiteralWithNameIndex(encoded, 33, value, true);
return new Snapshot(http1, Arrays.copyOf(encoded.array(), encoded.length()));
}
static String format(ZonedDateTime time) {
return FORMATTER.format(time);
}
/**
* The current pre-encoded {@code "Date: ...\r\n"} field line, accurate to within one second.
* Never allocates the same array is returned until the next refresh.
*/
public static byte[] bytes() {
return current.http1;
}
/** Current precompiled HPACK {@code date} field. */
public static byte[] hpackBytes() {
return current.hpack;
}
}
@@ -0,0 +1,87 @@
package dev.relism.flash.http;
import dev.relism.flash.models.HeaderView;
import dev.relism.fpr.core.ByteView;
/** Shared proxy policy for fields that must not cross an HTTP connection boundary. */
public final class HopByHopHeaders {
public enum Protocol {
HTTP_1_1,
HTTP_2
}
private HopByHopHeaders() {}
/** Returns whether a field may be copied to a new downstream connection. */
public static boolean shouldForward(
HeaderView source,
ByteView name,
ByteView value,
Protocol sourceProtocol,
Protocol targetProtocol) {
if (name.length() == 0 || name.byteAt(0) == ':') return false;
if (is(name, "connection")
|| is(name, "keep-alive")
|| is(name, "proxy-connection")
|| is(name, "proxy-authenticate")
|| is(name, "proxy-authorization")
|| is(name, "trailer")
|| is(name, "transfer-encoding")
|| is(name, "upgrade")) {
return false;
}
if (isConnectionListed(source, name)) return false;
if (is(name, "te")) {
return targetProtocol == Protocol.HTTP_2 && isTrimmed(value, "trailers");
}
return true;
}
private static boolean isConnectionListed(HeaderView source, ByteView fieldName) {
for (String value : source.all("connection")) {
int start = 0;
while (start < value.length()) {
int comma = value.indexOf(',', start);
int end = comma < 0 ? value.length() : comma;
while (start < end && isWhitespace(value.charAt(start))) start++;
while (end > start && isWhitespace(value.charAt(end - 1))) end--;
if (equalsAsciiIgnoreCase(fieldName, value, start, end)) return true;
start = comma < 0 ? value.length() : comma + 1;
}
}
return false;
}
private static boolean is(ByteView bytes, String expected) {
return equalsAsciiIgnoreCase(bytes, expected, 0, expected.length());
}
private static boolean isTrimmed(ByteView bytes, String expected) {
int start = 0;
int end = bytes.length();
while (start < end && isWhitespace((char) bytes.byteAt(start))) start++;
while (end > start && isWhitespace((char) bytes.byteAt(end - 1))) end--;
if (end - start != expected.length()) return false;
for (int i = 0; i < expected.length(); i++) {
if (lower(bytes.byteAt(start + i) & 0xff) != lower(expected.charAt(i))) return false;
}
return true;
}
private static boolean equalsAsciiIgnoreCase(
ByteView bytes, String expected, int expectedStart, int expectedEnd) {
if (bytes.length() != expectedEnd - expectedStart) return false;
for (int i = 0; i < bytes.length(); i++) {
if (lower(bytes.byteAt(i) & 0xff) != lower(expected.charAt(expectedStart + i))) return false;
}
return true;
}
private static int lower(int value) {
return value >= 'A' && value <= 'Z' ? value + ('a' - 'A') : value;
}
private static boolean isWhitespace(char value) {
return value == ' ' || value == '\t';
}
}
@@ -0,0 +1,156 @@
package dev.relism.flash.http;
/**
* Bounds the HTTP/1.1 parser ({@code RequestParser}, {@code ChunkedInputStream}) enforces
* against a peer's input, in one place.
*
* against a named constant here never against an ad-hoc literal, and never by letting the
* underlying buffer throw on overrun. Each field's Javadoc names the specific attack it bounds.
*
* Compare {@code dev.relism.flash.http2.Http2Limits}, the HTTP/2 equivalent.
*/
public final class Http1Limits {
private Http1Limits() {
}
/**
* The largest {@code Content-Length} value accepted, in bytes. RFC 9112 places no upper
* bound on the header's numeric value, but an unbounded value from a hostile peer is a
* resource-exhaustion vector for any code path that pre-sizes a buffer from it. Requests
* declaring a length above this are rejected with {@code 413 Payload Too Large} before any
* body byte is read.
*
* <p>4 GiB generous enough for legitimate large uploads (Flash is a general-purpose
* server, not an API-only framework with a tiny default), while still bounding a hostile
* peer to a finite, known-in-advance number rather than the effectively unbounded
* {@code Long.MAX_VALUE} the parser accepted before this limit existed. Comfortably above
* {@code Integer.MAX_VALUE} (~2.1 billion) so legitimate very-large declared lengths are
*/
public static final long MAX_CONTENT_LENGTH = 4L * 1024 * 1024 * 1024;
/**
* Maximum number of header lines accepted in a single request. Without this bound, a
* request with tens of thousands of one-byte headers passes the total header-block size
* check ({@code maxHeaderBufferSize}) while still forcing every subsequent
* {@code Http1HeaderMap} lookup to scan all of them turning a small request into quadratic CPU
*/
public static final int MAX_HEADER_COUNT = 100;
/**
* Maximum length, in bytes, of a single header field name. RFC 9110 §5.1 places no formal
* limit; this bound exists purely to cap per-header memory and scan cost.
*/
public static final int MAX_HEADER_NAME_LENGTH = 256;
/**
* Maximum length, in bytes, of a single header field value. Bounds per-header memory and
* scan cost the same way {@link #MAX_HEADER_NAME_LENGTH} bounds the name.
*/
public static final int MAX_HEADER_VALUE_LENGTH = 8_192;
/**
* Maximum length, in bytes, of the request line ({@code METHOD SP target SP version}).
* Tracked separately from the overall header-buffer size so an oversized request line is
* rejected with a specific, correct status ({@code 414 URI Too Long}) rather than folded
* into the generic header-block-too-large case.
*/
public static final int MAX_REQUEST_LINE_LENGTH = 8_192;
/**
* Maximum size, in bytes, of a single {@code Transfer-Encoding: chunked} chunk.
* {@code ChunkedInputStream.readChunkSize} previously accepted any value up to 2 GiB before
* rejecting it; a hostile peer can advertise a huge chunk size and then trickle bytes,
* forcing the connection to stay open far longer than any legitimate chunk would need
* (bounded separately by {@code bodyReadTimeoutMs}, but this limit catches the size claim
* itself before that timeout would).
*/
public static final long MAX_CHUNK_SIZE = 16L * 1024 * 1024;
/**
* Maximum length, in bytes, of the chunk-extension section (the optional
* {@code ;name=value} data after a chunk size and before its CRLF, RFC 9112 §7.1.1). Flash
* does not interpret chunk extensions; without a bound, a peer could send an arbitrarily
* long extension on every chunk purely to waste CPU discarding it.
*/
public static final int MAX_CHUNK_EXT_LENGTH = 256;
/**
* Maximum number of chunks accepted in a single request body. Without this bound, a peer
* can send an unbounded number of minimal (or zero-length) chunks, each cheap individually
* but collectively forcing unbounded per-chunk framing work a "death by a thousand
* chunks" variant of a slow-body attack.
*/
public static final int MAX_CHUNKS_PER_BODY = 100_000;
/**
* Maximum number of trailer header lines accepted after the final chunk of a chunked body
* (RFC 9112 §7.1.2). Bounded for the same reason {@link #MAX_HEADER_COUNT} bounds the
* regular header section; trailer values are separately bounded by
* {@link #MAX_HEADER_VALUE_LENGTH}.
*/
public static final int MAX_TRAILER_COUNT = 50;
/**
* buffer as the response head (status line + headers) and written with it in a single
* {@code OutputStream.write} call; larger bodies are written in a second {@code write} right
* after the head, since copying a large body into the head buffer first would cost more
* (an extra full-body memcpy) than the syscall it saves. 8 KiB matches this codebase's
* other "one socket-buffer's worth" constants ({@code ConnectionScratch.RELAY_BUFFER_SIZE},
* {@code BufferedByteSource.DEFAULT_BUFFER_SIZE}) rather than introducing an uncalibrated
*/
public static final int INLINE_BODY_THRESHOLD = 8192;
/**
* {@code multipart/form-data} body. Without this bound, a peer can send an unbounded number
* of minimal parts each cheap individually but forcing unbounded growth of the parser's
* {@code scanned} list and unbounded per-part header-parsing work, the multipart analogue of
* {@link #MAX_CHUNKS_PER_BODY}.
*/
public static final int MAX_MULTIPART_PARTS = 1_000;
/**
* {@code Content-Type}, ) accepted per multipart part. Real clients send at most two or
* three; without a bound a peer could send an effectively unlimited number before the blank
* line that ends a part's header block, forcing unbounded {@code HashMap} growth per part.
*/
public static final int MAX_MULTIPART_PART_HEADER_COUNT = 20;
/**
* header block. {@code Multipart.readLine} otherwise has no bound of its own to fall back
* on unlike the top-level HTTP headers (bounded by {@link #MAX_HEADER_VALUE_LENGTH} in
* {@code RequestParser}), a line here with no {@code \r\n} would grow its {@code StringBuilder}
* without limit for as long as the peer keeps streaming bytes.
*/
public static final int MAX_MULTIPART_HEADER_LINE_LENGTH = 8_192;
/**
* buffers eagerly into a {@code byte[]} text fields (always buffered) and, during a full
* {@code parts()}/{@code parts(String)} scan, file bodies too. {@link #MAX_CONTENT_LENGTH}
* bounds the whole request body, but at 4 GiB (and effectively unbounded for a chunked body,
* see {@link #MAX_CHUNKS_PER_BODY} × {@link #MAX_CHUNK_SIZE}) it does nothing to stop a
* single part from exhausting the heap on its own this is the bound that actually protects
* {@code ByteArrayOutputStream}-style eager buffering. Deliberately does not apply to
* {@code Part.materialize()} on a streaming file part returned by {@code Multipart.file()}
* that call is documented as an explicit, opt-in heap allocation the caller chooses to pay for.
*/
public static final long MAX_MULTIPART_BUFFERED_PART_SIZE = 10L * 1024 * 1024;
/**
* Maximum combined size, in bytes, of every response header's name + value bytes
* ({@code Response.header(...)}'s growable {@code headerRegion}). Unlike every other bound in
* this class, this one guards against a bug in <em>Flash's own caller</em> rather than a
* hostile peer a handler that calls {@code header(...)} in an unbounded loop (e.g. echoing
* an unbounded collection into headers) would otherwise grow this connection's scratch region
* without limit for the rest of its lifetime, since it is never shrunk back down between
*/
public static final int MAX_RESPONSE_HEADER_BYTES = 65_536;
/**
* Maximum number of {@code Response.header(...)} calls (any overload) accepted on a single
* response. Same rationale as {@link #MAX_RESPONSE_HEADER_BYTES}: bounds the response-side
* analogue of {@link #MAX_HEADER_COUNT}, since an unbounded call count grows the header index
* arrays even if each individual header is small.
*/
public static final int MAX_RESPONSE_HEADER_COUNT = 1_000;
}
@@ -1,101 +1,164 @@
package dev.relism.flash.http;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.hpack.HpackEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;
/**
* Pre-compiled byte representations of standard HTTP status lines.
* Uses a direct-access array for O(1) lookup with zero allocation.
* Pre-compiled byte representations of standard HTTP status lines. Uses a direct-access array for
* O(1) lookup with zero allocation.
*/
public enum HttpStatus {
// 1xx
CONTINUE (100, "Continue"),
SWITCHING_PROTOCOLS (101, "Switching Protocols"),
// 1xx
CONTINUE(100, "Continue"),
SWITCHING_PROTOCOLS(101, "Switching Protocols"),
// 2xx
OK (200, "OK"),
CREATED (201, "Created"),
ACCEPTED (202, "Accepted"),
NO_CONTENT (204, "No Content"),
PARTIAL_CONTENT (206, "Partial Content"),
// 2xx
OK(200, "OK"),
CREATED(201, "Created"),
ACCEPTED(202, "Accepted"),
NO_CONTENT(204, "No Content"),
PARTIAL_CONTENT(206, "Partial Content"),
// 3xx
MOVED_PERMANENTLY (301, "Moved Permanently"),
FOUND (302, "Found"),
NOT_MODIFIED (304, "Not Modified"),
TEMPORARY_REDIRECT (307, "Temporary Redirect"),
PERMANENT_REDIRECT (308, "Permanent Redirect"),
// 3xx
MOVED_PERMANENTLY(301, "Moved Permanently"),
FOUND(302, "Found"),
NOT_MODIFIED(304, "Not Modified"),
TEMPORARY_REDIRECT(307, "Temporary Redirect"),
PERMANENT_REDIRECT(308, "Permanent Redirect"),
// 4xx
BAD_REQUEST (400, "Bad Request"),
UNAUTHORIZED (401, "Unauthorized"),
FORBIDDEN (403, "Forbidden"),
NOT_FOUND (404, "Not Found"),
METHOD_NOT_ALLOWED (405, "Method Not Allowed"),
NOT_ACCEPTABLE (406, "Not Acceptable"),
CONFLICT (409, "Conflict"),
GONE (410, "Gone"),
LENGTH_REQUIRED (411, "Length Required"),
PAYLOAD_TOO_LARGE (413, "Payload Too Large"),
URI_TOO_LONG (414, "URI Too Long"),
UNSUPPORTED_MEDIA_TYPE (415, "Unsupported Media Type"),
UNPROCESSABLE_ENTITY (422, "Unprocessable Entity"),
TOO_MANY_REQUESTS (429, "Too Many Requests"),
// 4xx
BAD_REQUEST(400, "Bad Request"),
UNAUTHORIZED(401, "Unauthorized"),
FORBIDDEN(403, "Forbidden"),
NOT_FOUND(404, "Not Found"),
METHOD_NOT_ALLOWED(405, "Method Not Allowed"),
NOT_ACCEPTABLE(406, "Not Acceptable"),
CONFLICT(409, "Conflict"),
GONE(410, "Gone"),
LENGTH_REQUIRED(411, "Length Required"),
PRECONDITION_FAILED(412, "Precondition Failed"),
PAYLOAD_TOO_LARGE(413, "Payload Too Large"),
URI_TOO_LONG(414, "URI Too Long"),
UNSUPPORTED_MEDIA_TYPE(415, "Unsupported Media Type"),
RANGE_NOT_SATISFIABLE(416, "Range Not Satisfiable"),
EXPECTATION_FAILED(417, "Expectation Failed"),
MISDIRECTED_REQUEST(421, "Misdirected Request"),
UNPROCESSABLE_ENTITY(422, "Unprocessable Entity"),
TOO_MANY_REQUESTS(429, "Too Many Requests"),
REQUEST_HEADER_FIELDS_TOO_LARGE(431, "Request Header Fields Too Large"),
// 5xx
INTERNAL_SERVER_ERROR (500, "Internal Server Error"),
NOT_IMPLEMENTED (501, "Not Implemented"),
BAD_GATEWAY (502, "Bad Gateway"),
SERVICE_UNAVAILABLE (503, "Service Unavailable"),
GATEWAY_TIMEOUT (504, "Gateway Timeout");
// 5xx
INTERNAL_SERVER_ERROR(500, "Internal Server Error"),
NOT_IMPLEMENTED(501, "Not Implemented"),
BAD_GATEWAY(502, "Bad Gateway"),
SERVICE_UNAVAILABLE(503, "Service Unavailable"),
GATEWAY_TIMEOUT(504, "Gateway Timeout"),
HTTP_VERSION_NOT_SUPPORTED(505, "HTTP Version Not Supported"),
INSUFFICIENT_STORAGE(507, "Insufficient Storage"),
NETWORK_AUTHENTICATION_REQUIRED(511, "Network Authentication Required");
private static final int MAX_STATUS_CODE = 504;
private static final byte[][] INDEX = new byte[MAX_STATUS_CODE + 1][];
private static final String[] REASONS = new String[MAX_STATUS_CODE + 1];
// ArrayIndexOutOfBoundsException from this static initializer the moment any constant
// above it (421, 431, 505, 507, 511 several of which HTTP/2 needs, see MISDIRECTED_REQUEST
// and REQUEST_HEADER_FIELDS_TOO_LARGE above) was added. Computed from values() instead, so
// adding a status code can never silently break class loading again.
private static final int MAX_STATUS_CODE;
private static final byte[][] INDEX;
private static final byte[][] HPACK_INDEX;
private static final String[] REASONS;
static {
for (HttpStatus s : values()) {
INDEX[s.code] = s.bytes;
REASONS[s.code] = s.reason;
}
static {
int max = 0;
for (HttpStatus s : values()) max = Math.max(max, s.code);
MAX_STATUS_CODE = max;
INDEX = new byte[MAX_STATUS_CODE + 1][];
HPACK_INDEX = new byte[MAX_STATUS_CODE + 1][];
REASONS = new String[MAX_STATUS_CODE + 1];
for (HttpStatus s : values()) {
INDEX[s.code] = s.bytes;
HPACK_INDEX[s.code] = s.hpackBytes;
REASONS[s.code] = s.reason;
}
}
private final int code;
private final String reason;
private final byte[] bytes;
private final int code;
private final String reason;
private final byte[] bytes;
private final byte[] hpackBytes;
HttpStatus(int code, String reason) {
this.code = code;
this.reason = reason;
this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8);
HttpStatus(int code, String reason) {
this.code = code;
this.reason = reason;
this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8);
this.hpackBytes = encodeHpack(code);
}
/** Numeric status code (e.g. {@code 200}). */
public int code() {
return code;
}
/** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */
public byte[] bytes() {
return bytes;
}
/** Precompiled HPACK representation of {@code :status}. */
public byte[] hpackBytes() {
return hpackBytes;
}
/** Reason phrase (e.g. {@code "OK"}). */
public String reason() {
return reason;
}
/**
* Returns pre-compiled status bytes for the given code. Access is O(1) and generates zero
* garbage.
*/
public static byte[] bytesForCode(int code) {
if (code >= 0 && code <= MAX_STATUS_CODE) {
return INDEX[code];
}
return null;
}
/** Numeric status code (e.g. {@code 200}). */
public int code() { return code; }
/** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */
public byte[] bytes() { return bytes; }
/** Reason phrase (e.g. {@code "OK"}). */
public String reason() { return reason; }
/**
* Returns pre-compiled status bytes for the given code.
* Access is O(1) and generates zero garbage.
*/
public static byte[] bytesForCode(int code) {
if (code >= 0 && code <= MAX_STATUS_CODE) {
return INDEX[code];
}
return null;
/** Returns reason phrase for the given code, or null if unknown. */
public static String reasonForCode(int code) {
if (code >= 0 && code <= MAX_STATUS_CODE) {
return REASONS[code];
}
return null;
}
/** Returns reason phrase for the given code, or null if unknown. */
public static String reasonForCode(int code) {
if (code >= 0 && code <= MAX_STATUS_CODE) {
return REASONS[code];
}
return null;
/** Returns the precompiled HPACK status field for a known code, or {@code null}. */
public static byte[] hpackBytesForCode(int code) {
return code >= 0 && code <= MAX_STATUS_CODE ? HPACK_INDEX[code] : null;
}
private static byte[] encodeHpack(int code) {
int staticIndex =
switch (code) {
case 200 -> 8;
case 204 -> 9;
case 206 -> 10;
case 304 -> 11;
case 400 -> 12;
case 404 -> 13;
case 500 -> 14;
default -> 0;
};
ByteWriter out = new ByteWriter(8);
if (staticIndex != 0) {
HpackEncoder.writeIndexed(out, staticIndex);
} else {
byte[] value = {
(byte) ('0' + code / 100), (byte) ('0' + code / 10 % 10), (byte) ('0' + code % 10)
};
HpackEncoder.writeLiteralWithNameIndex(out, 8, value, true);
}
return java.util.Arrays.copyOf(out.array(), out.length());
}
}
@@ -0,0 +1,141 @@
package dev.relism.flash.http1;
import dev.relism.flash.RequestParser;
import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.transport.BufferedByteSource;
import dev.relism.flash.transport.ConnectionContext;
import dev.relism.flash.transport.ConnectionProtocol;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketLoop;
import dev.relism.flash.websocket.WebSocketSession;
import dev.relism.flash.websocket.WebSocketUpgrade;
import java.io.IOException;
import java.io.OutputStream;
import java.net.SocketTimeoutException;
/**
* The HTTP/1.1 keep-alive request loop: parse route handle respond, repeated until the
* connection closes. Sole responsibility: drive that loop for one connection; parsing lives in
* {@link RequestParser}, serialization in {@link Http1ResponseWriter}, and the WebSocket upgrade
* path hands off to {@link WebSocketUpgrade}/{@link WebSocketLoop} entirely once a connection
* upgrades, this class has nothing further to do with it.
*/
public final class Http1Connection implements ConnectionProtocol {
@Override
public void run(ConnectionContext ctx) throws IOException {
RequestParser parser = new RequestParser(
ctx.configuration().getMaxHeaderBufferSize(),
ctx.remoteAddress(),
ctx.sslSocket());
BufferedByteSource in = ctx.in();
OutputStream out = ctx.out();
byte[] idleProbe = new byte[1];
// reused across every request on this connection see AbstractRouter#newScratch.
Object routeScratch = ctx.router().newScratch();
Object wsRouteScratch = ctx.wsRouter().newScratch();
Response pooledResponse = new Response(200, ContentType.TEXT_PLAIN);
while (!ctx.stopped().getAsBoolean()) {
// idle-keep-alive timeout sitting idle between keep-alive requests is normal, not
// an attack. Skipped when the parser already has bytes buffered from a previous
// read (HTTP pipelining): the next request has, by definition, already started, so
// waiting on the *source* for a fresh byte would wait for something that already
// arrived and is sitting in the parser's own buffer.
if (!parser.hasBufferedBytes()) {
in.setDeadline(System.nanoTime() + ctx.configuration().getIdleKeepAliveTimeoutMs() * 1_000_000L);
int firstByteSeen;
try {
firstByteSeen = in.peek(idleProbe, 0, 1);
} catch (SocketTimeoutException e) {
break; // idle timeout nothing pending; close quietly, like EOF
}
if (firstByteSeen <= 0) break; // clean EOF
}
// Bytes have started arriving: tighten to the slowloris-specific bound for the rest
// of the header block.
in.setDeadline(System.nanoTime() + ctx.configuration().getHeaderReadTimeoutMs() * 1_000_000L);
Request request;
try {
request = parser.parse(in);
} catch (MalformedRequestException e) {
// through a handler or the user's exception handler and the connection is
// always closed afterwards, never kept alive.
Response rejection = new Response(e.status(), e.getMessage(), ContentType.TEXT_PLAIN);
Http1ResponseWriter.writeResponse(out, rejection, null, false, ctx.configuration().isSendDate(), ctx.scratch());
break;
} catch (SocketTimeoutException e) {
break; // header-read deadline exceeded close
}
if (request == null) break;
if (request.method() == HttpMethod.GET && WebSocketUpgrade.isWebSocketUpgrade(request)) {
in.clearDeadline(); // the WS session loop is long-lived; it paces itself
WebSocketHandler wsHandler = ctx.wsRouter().route(request, wsRouteScratch);
if (wsHandler == null) {
out.write(WebSocketUpgrade.REJECT_400);
out.flush();
break;
}
// Flush buffered HTTP bytes (the 101 response) before WebSocketSession takes
// over rawOut otherwise the handshake reply stays stuck in the buffered
// stream and the client never sees it.
WebSocketUpgrade.performHandshake(out, request, ctx.scratch());
out.flush();
request.drain();
WebSocketSession session = new WebSocketSession(
in, ctx.rawOut(), ctx.configuration().getWsFrameBufferSize(), request, false);
WebSocketLoop.run(session, wsHandler);
return;
}
// Headers are fully read; the body (if any) may still be pending whether the
// handler consumes it or the automatic drain() below does, bound it by the same
// deadline.
in.setDeadline(System.nanoTime() + ctx.configuration().getBodyReadTimeoutMs() * 1_000_000L);
boolean keepAlive = Http1KeepAlive.isKeepAlive(request);
Response response = pooledResponse.reset(200, ContentType.TEXT_PLAIN);
RequestHandler handler = ctx.router().route(request, routeScratch);
if (handler == null) handler = ctx.router().getNotFoundHandler();
try {
Object result = handler.handle(request, response);
if (result instanceof Response r) response = r;
else if (result != null) response.setBody(result);
} catch (Exception ex) {
Object result = ctx.router().getExceptionHandler().handle(ex, request, response);
if (result instanceof Response r) response = r;
else if (result != null) response.setBody(result);
}
// this handler was running (the common case: draining connections mid-request) must
// still force this response to Connection: close, not whatever was decided before
// the handler ran.
boolean actuallyKeepAlive = keepAlive && !ctx.stopped().getAsBoolean();
Http1ResponseWriter.writeResponse(out, response, request.method(), actuallyKeepAlive,
ctx.configuration().isSendDate(), ctx.scratch());
request.drain();
// dropped, if the connection closes) poison them in dev mode so any reference the
// handler improperly retained (a captured field, an async callback) fails loudly on
// its next access instead of silently reading whatever comes next. Only the pooled
// Response is recycled: if the handler returned a different instance, that object was
// never pooled in the first place and owes nothing back to this connection.
request.recycle();
if (response == pooledResponse) pooledResponse.recycle();
in.clearDeadline();
if (!actuallyKeepAlive) break;
}
}
}
@@ -0,0 +1,70 @@
package dev.relism.flash.http1;
import dev.relism.flash.models.Request;
import dev.relism.fpr.core.ByteView;
/**
* HTTP/1.1 keep-alive decision (RFC 9110 §7.6.1) and the shared {@code Connection} header
* token-list scanner both it and WebSocket upgrade detection need.
*
* (e.g. {@code "Connection: keep-alive, Upgrade"}), not a single value a whole-value compare
* against {@code "close"} misses exactly that case. {@link #tokenListContains} is the one
* scanner both this class's {@link #isKeepAlive} and {@code WebSocketUpgrade}'s
* {@code Connection: Upgrade} check use, so the two can never drift apart again.
*/
public final class Http1KeepAlive {
private Http1KeepAlive() {
}
/**
* Whether the connection should remain open after this response. HTTP/1.1 defaults to
* keep-alive unless {@code Connection} lists {@code close}; HTTP/1.0 defaults to close
* unless it lists {@code keep-alive}.
*/
public static boolean isKeepAlive(Request request) {
if (connectionContainsToken(request, "close")) return false;
ByteView protocol = request.getRequestLine().getProtocol();
int plen = protocol.length();
if (plen == 8) {
byte minor = protocol.byteAt(7);
if (minor == '1') return true;
if (minor == '0') return connectionContainsToken(request, "keep-alive");
}
return false;
}
/** Whether the request's {@code Connection} header lists {@code token} (case-insensitive). */
public static boolean connectionContainsToken(Request request, String token) {
ByteView conn = request.getRequestLine().getHeaders().view("Connection");
if (conn == null) return false;
return tokenListContains(conn, token);
}
/** Scans a comma-separated token list for {@code token} (case-insensitive, OWS-tolerant). */
public static boolean tokenListContains(ByteView view, String token) {
int len = view.length(), i = 0;
while (i < len) {
while (i < len && view.byteAt(i) == ' ') i++;
int start = i;
while (i < len && view.byteAt(i) != ',') i++;
if (tokenEqualsIgnoreCase(view, start, i, token)) return true;
i++;
}
return false;
}
/** Case-insensitive compare of {@code view[start, end)}, trimming trailing spaces, against {@code token}. */
public static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) {
int tlen = token.length();
int wlen = end - start;
while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--;
if (wlen != tlen) return false;
for (int i = 0; i < tlen; i++) {
byte b = view.byteAt(start + i);
if (b >= 'A' && b <= 'Z') b += 32;
if (b != (byte) token.charAt(i)) return false;
}
return true;
}
}
@@ -0,0 +1,238 @@
package dev.relism.flash.http1;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http.DateHeader;
import dev.relism.flash.http.Http1Limits;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.Response;
import dev.relism.flash.transport.ConnectionScratch;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
/**
* Serializes a {@link Response} as an HTTP/1.1 message. Sole responsibility: response
* serialization routing, handler dispatch, and the request loop live in
* {@link Http1Connection}.
*
* The status line, {@code Content-Type}, {@code Date}, every custom header, and
* {@code Content-Length}/{@code Connection} are all serialized into
* {@link ConnectionScratch#responseHead} (a reused {@link ByteWriter}) before a single
* {@code OutputStream.write} call not one small {@code write} per field, and no
* {@link java.io.BufferedOutputStream} coalescing them at the stream layer (this class removes
* the need for one entirely on the h1 response path). A body at or below
* {@link Http1Limits#INLINE_BODY_THRESHOLD} is copied into the same scratch buffer and goes out
* in that same syscall; a larger body is written separately right after, since copying it into
* the head buffer first would cost an extra full-body memcpy the syscall it saves does not pay
* for. Streaming/chunked bodies write the head, then relay their own bytes as they arrive by
* definition unknown or too large to fold into one buffer up front.
*/
public final class Http1ResponseWriter {
private Http1ResponseWriter() {
}
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
/**
* Writes {@code response} to {@code out} as a complete HTTP/1.1 message.
*
* @param method the request method {@code null} is treated as "not HEAD" (used for
* parser-rejection responses, which never reach a handler and so have no
* associated method)
* @param sendDate whether to include the {@code Date} header ({@code FlashConfiguration#isSendDate()})
*/
public static void writeResponse(OutputStream out, Response response, HttpMethod method,
boolean keepAlive, boolean sendDate, ConnectionScratch scratch) throws IOException {
int statusCode = response.getStatusCode();
// RFC 9110 §8.6/§15: 204, 304 and all 1xx responses MUST NOT carry Content-Length or a
// still reports the Content-Length GET would have, but never writes body bytes.
boolean noContentAllowed = statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200);
boolean suppressBody = noContentAllowed || method == HttpMethod.HEAD;
ByteWriter head = scratch.responseHead;
head.reset();
head.writeBytes(HTTP_1_1);
byte[] statusBytes = response.getStatusBytes();
if (statusBytes != null) head.writeBytes(statusBytes);
else writeStatusPhrase(head, statusCode);
head.writeBytes(CRLF);
// "Content-Type: \r\n" a header with no value. Skip the line entirely instead.
byte[] contentType = response.getContentType();
if (contentType != null && contentType.length > 0) {
head.writeBytes(CONTENT_TYPE);
head.writeBytes(contentType);
head.writeBytes(CRLF);
}
// one write into the scratch, never a per-response format call.
if (sendDate) head.writeBytes(DateHeader.bytes());
response.writeHeadersInto(head);
if (response.hasTrailers()) {
writeTrailerBody(out, head, response, keepAlive, suppressBody, scratch);
} else if (response.isStreaming()) {
writeStreamingBody(out, head, response, keepAlive, noContentAllowed, suppressBody, scratch);
} else {
byte[] body = response.getBody();
int len = body != null ? body.length : 0;
if (!noContentAllowed) {
head.writeBytes(CONTENT_LENGTH);
head.writeDecimal(len);
head.writeBytes(CRLF);
}
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
head.writeBytes(CRLF);
// the body itself.
boolean writeBody = body != null && !suppressBody;
if (writeBody && len <= Http1Limits.INLINE_BODY_THRESHOLD) {
// one syscall.
head.writeBytes(body);
out.write(head.array(), 0, head.length());
} else {
out.write(head.array(), 0, head.length());
if (writeBody) out.write(body);
}
}
out.flush();
}
private static void writeTrailerBody(OutputStream out, ByteWriter head, Response response,
boolean keepAlive, boolean suppressBody,
ConnectionScratch scratch) throws IOException {
head.writeBytes(TRANSFER_CHUNKED);
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
head.writeBytes(CRLF);
out.write(head.array(), 0, head.length());
if (suppressBody) return;
if (response.isStreaming()) {
writeChunkedAndClose(out, response, scratch);
} else {
byte[] body = response.getBody();
if (body != null && body.length != 0) {
writeHex(out, body.length);
out.write(CRLF);
out.write(body);
out.write(CRLF);
}
writeFinalChunk(out, response);
}
}
private static void writeStreamingBody(OutputStream out, ByteWriter head, Response response, boolean keepAlive,
boolean noContentAllowed, boolean suppressBody,
ConnectionScratch scratch) throws IOException {
if (!response.isChunked()) {
if (!noContentAllowed) {
head.writeBytes(CONTENT_LENGTH);
head.writeDecimal(response.getStreamLength());
head.writeBytes(CRLF);
}
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
head.writeBytes(CRLF);
out.write(head.array(), 0, head.length());
if (!suppressBody) relayAndClose(response.getStream(), out, scratch);
} else {
head.writeBytes(TRANSFER_CHUNKED);
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
head.writeBytes(CRLF);
out.write(head.array(), 0, head.length());
// A HEAD response still declares the Transfer-Encoding GET would have used (RFC
// 9110 §9.3.2) but writes zero body bytes not even the final-chunk marker, since
// there is no chunk framing at all for a message with no body.
if (!suppressBody) writeChunkedAndClose(out, response, scratch);
}
}
/**
* Closes the handler's stream on every exit clean EOF or a write failure partway through
* (e.g. the client disconnected mid-transfer). Without this, a handler whose stream only
* releases a held resource (a pooled backend connection, say) from {@code close()} not from
* observing EOF on a {@code read()} that a downstream write failure means it never reaches
* leaks that resource for as long as the JVM takes to finalize it. A well-behaved stream's
* {@code close()} must already be idempotent (Java's own contract for {@link InputStream}), so
* this costs nothing extra on the ordinary clean-EOF path.
*/
private static void relayAndClose(InputStream in, OutputStream out, ConnectionScratch scratch)
throws IOException {
try {
relay(in, out, scratch);
} finally {
in.close();
}
}
private static void writeChunkedAndClose(OutputStream out, Response response, ConnectionScratch scratch)
throws IOException {
try {
writeChunked(out, response.getStream(), response, scratch);
} finally {
response.getStream().close();
}
}
/**
* Copies {@code in} to {@code out} until EOF, via {@link ConnectionScratch#relayBuffer}
* instead of a fresh {@code byte[]} per call.
*/
private static void relay(InputStream in, OutputStream out, ConnectionScratch scratch) throws IOException {
byte[] buf = scratch.relayBuffer;
int n;
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
}
private static void writeStatusPhrase(ByteWriter head, int statusCode) {
byte[] phrase = HttpStatus.bytesForCode(statusCode);
if (phrase != null) head.writeBytes(phrase);
else { head.writeDecimal(statusCode); head.writeBytes(UNKNOWN_STATUS_SUFFIX); }
}
private static void writeChunked(OutputStream out, InputStream stream, Response response,
ConnectionScratch scratch) throws IOException {
byte[] buf = scratch.relayBuffer;
int n;
while ((n = stream.read(buf)) > 0) {
writeHex(out, n);
out.write(CRLF);
out.write(buf, 0, n);
out.write(CRLF);
}
if (response.hasTrailers()) writeFinalChunk(out, response);
else out.write(FINAL_CHUNK);
}
private static void writeFinalChunk(OutputStream out, Response response) throws IOException {
out.write('0');
out.write(CRLF);
response.writeTrailers(out);
out.write(CRLF);
}
private static void writeHex(OutputStream out, int value) throws IOException {
int shift = 28;
boolean leading = true;
while (shift >= 0) {
int digit = (value >>> shift) & 0xF;
if (digit != 0 || !leading) {
leading = false;
out.write(digit < 10 ? '0' + digit : 'a' + digit - 10);
}
shift -= 4;
}
if (leading) out.write('0');
}
}
@@ -0,0 +1,114 @@
package dev.relism.flash.http2;
import dev.relism.flash.extension.FlashConfiguration;
/** Enforces per-connection HTTP/2 rate limits and lifetime budgets. */
final class Http2AbuseGuard {
private RollingWindowCounter resetRate;
private RollingWindowCounter streamCreationRate;
private RollingWindowCounter settingsRate;
private RollingWindowCounter pingRate;
private RollingWindowCounter uselessFrameRate;
private int maxResetRate;
private int maxStreamCreationRate;
private long maxStreams;
private long maxBytes;
private long maxLifetimeNanos;
private long startedNanos;
private long wireBytes;
private long streams;
Http2AbuseGuard() {
configure(FlashConfiguration.builder().build());
}
void configure(FlashConfiguration configuration) {
long interval = configuration.getH2AbuseRateIntervalMs();
if (interval < 2) throw new IllegalArgumentException("h2AbuseRateIntervalMs must be at least 2");
resetRate = new RollingWindowCounter(interval);
streamCreationRate = new RollingWindowCounter(interval);
settingsRate = new RollingWindowCounter(interval);
pingRate = new RollingWindowCounter(interval);
uselessFrameRate = new RollingWindowCounter(interval);
maxResetRate =
positive(
configuration.getH2MaxResetStreamsPerInterval(),
"h2MaxResetStreamsPerInterval");
maxStreamCreationRate =
positive(
configuration.getH2MaxStreamsCreatedPerInterval(),
"h2MaxStreamsCreatedPerInterval");
maxStreams =
nonNegative(configuration.getH2MaxStreamsPerConnection(), "h2MaxStreamsPerConnection");
maxBytes =
nonNegative(configuration.getH2MaxBytesPerConnection(), "h2MaxBytesPerConnection");
long lifetime =
nonNegative(
configuration.getH2MaxConnectionLifetimeMs(), "h2MaxConnectionLifetimeMs");
maxLifetimeNanos = toNanos(lifetime);
}
void start() {
startedNanos = System.nanoTime();
}
void receivedFrame(int payloadLength) {
wireBytes += 9L + payloadLength;
checkBudgets();
}
void streamCreated() {
if (streamCreationRate.incrementExceeded(maxStreamCreationRate)) {
calm("stream creation rate");
}
streams++;
if (maxStreams > 0 && streams > maxStreams) calm("connection stream budget");
}
void resetReceived() {
if (resetRate.incrementExceeded(maxResetRate)) calm("RST_STREAM rate");
}
void settingsReceived() {
if (settingsRate.incrementExceeded(Http2Limits.MAX_SETTINGS_PER_INTERVAL)) {
calm("SETTINGS rate");
}
}
void pingReceived() {
if (pingRate.incrementExceeded(Http2Limits.MAX_PINGS_PER_INTERVAL)) calm("PING rate");
}
void uselessFrameReceived() {
if (uselessFrameRate.incrementExceeded(Http2Limits.MAX_USELESS_FRAMES_PER_INTERVAL)) {
calm("non-progress frame rate");
}
}
void checkBudgets() {
if (maxBytes > 0 && wireBytes > maxBytes) calm("connection byte budget");
if (maxLifetimeNanos > 0 && System.nanoTime() - startedNanos > maxLifetimeNanos) {
calm("connection lifetime budget");
}
}
private static int positive(int value, String name) {
if (value <= 0) throw new IllegalArgumentException(name + " must be positive");
return value;
}
private static long nonNegative(long value, String name) {
if (value < 0) throw new IllegalArgumentException(name + " must not be negative");
return value;
}
private static long toNanos(long milliseconds) {
return milliseconds > Long.MAX_VALUE / 1_000_000L
? Long.MAX_VALUE
: milliseconds * 1_000_000L;
}
private static void calm(String reason) {
throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, reason + " exceeded");
}
}
@@ -0,0 +1,54 @@
package dev.relism.flash.http2;
import java.security.cert.Certificate;
import java.security.cert.CertificateParsingException;
import java.security.cert.X509Certificate;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
import javax.net.ssl.SSLSession;
/** Validates a coalesced request authority against the certificate selected for its connection. */
final class Http2Authority {
private Http2Authority() {}
static boolean isServed(String authority, SSLSession session) {
if (session == null || authority == null) return true;
String host = host(authority);
try {
Certificate[] certificates = session.getLocalCertificates();
if (certificates == null || certificates.length == 0
|| !(certificates[0] instanceof X509Certificate certificate)) {
return true;
}
Collection<List<?>> names = certificate.getSubjectAlternativeNames();
if (names == null) return true;
for (List<?> name : names) {
int type = (Integer) name.get(0);
if ((type == 2 || type == 7) && matches(host, name.get(1).toString())) return true;
}
return false;
} catch (CertificateParsingException failure) {
return true;
}
}
static boolean matches(String authority, String certificateName) {
String host = host(authority).toLowerCase(Locale.ROOT);
String name = certificateName.toLowerCase(Locale.ROOT);
if (!name.startsWith("*.")) return host.equals(name);
String suffix = name.substring(1);
if (!host.endsWith(suffix)) return false;
int prefixLength = host.length() - suffix.length();
return prefixLength > 0 && host.indexOf('.') == prefixLength;
}
private static String host(String authority) {
if (authority.startsWith("[")) {
int closing = authority.indexOf(']');
return closing < 0 ? authority : authority.substring(1, closing);
}
int colon = authority.lastIndexOf(':');
return colon > 0 && authority.indexOf(':') == colon ? authority.substring(0, colon) : authority;
}
}
@@ -0,0 +1,660 @@
package dev.relism.flash.http2;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http2.Http2ConnectionScratch.ControlIntent;
import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameHeader;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameValidator;
import dev.relism.flash.http2.frame.Http2FrameReader;
import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.http2.frame.Padding;
import dev.relism.flash.http2.hpack.HeaderSink;
import dev.relism.flash.http2.message.DataBufferPool;
import dev.relism.flash.http2.stream.Http2FlowController;
import dev.relism.flash.http2.stream.Http2Stream;
import dev.relism.flash.http2.stream.Http2StreamState;
import dev.relism.flash.http2.stream.Http2StreamTable;
import dev.relism.flash.transport.BufferedByteSource;
import dev.relism.flash.transport.ConnectionContext;
import dev.relism.flash.transport.ConnectionProtocol;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.util.function.BooleanSupplier;
import lombok.extern.slf4j.Slf4j;
/**
* Owns one HTTP/2 connection's demultiplexing and connection-level protocol state. The demux loop
* never invokes application code and never waits for a handler or body consumer; stream dispatch is
* handed to independent virtual threads by the stream layer.
*/
@Slf4j
public final class Http2Connection implements ConnectionProtocol {
private static final byte[] SHUTDOWN_PING = {
(byte) 0x46, (byte) 0x4c, (byte) 0x41, (byte) 0x53,
(byte) 0x48, (byte) 0x47, (byte) 0x4f, (byte) 0x21
};
private final Http2Settings peerSettings = new Http2Settings();
private final Http2ConnectionScratch scratch = new Http2ConnectionScratch();
private final Http2Settings.StreamWindowUpdater streamWindows;
private final long settingsAckTimeoutMs;
private final Http2HeaderBlockDecoder headerBlocks = new Http2HeaderBlockDecoder();
private final DataBufferPool dataBuffers =
new DataBufferPool(Http2Limits.MAX_FRAME_SIZE_LOCAL, Http2Limits.DATA_BUFFER_POOL_SIZE);
private final Http2StreamTable streams =
new Http2StreamTable(Http2Limits.MAX_CONCURRENT_STREAMS, dataBuffers);
private static final HeaderSink DISCARD_HEADERS = (name, value, never) -> {};
private Http2FlowController flowController;
private int outstandingLocalSettings;
private long oldestSettingsSentNanos;
private int lastProcessedStreamId;
private int peerLastStreamId = Integer.MAX_VALUE;
private int peerErrorCode;
private boolean peerGoAway;
private boolean gracefulStarted;
private boolean gracefulFinished;
private int highestClientStreamId;
private Http2Stream pendingHeaderStream;
private boolean refusingHeaderStream;
private boolean pendingTrailers;
private Http2StreamDispatcher streamDispatcher;
private final Http2Stream[] dispatchQueue = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
private int dispatchCount;
private final Http2AbuseGuard abuse = new Http2AbuseGuard();
private long streamIdleTimeoutNanos = Http2Limits.STREAM_IDLE_TIMEOUT_MS * 1_000_000L;
private final Http2Stream[] idleSweep = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
public Http2Connection() {
this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
}
public Http2Connection(Http2Settings.StreamWindowUpdater streamWindows) {
this(streamWindows, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
}
Http2Connection(Http2Settings.StreamWindowUpdater streamWindows, long settingsAckTimeoutMs) {
this.streamWindows =
delta -> {
try {
if (flowController == null) streams.adjustAllSendWindows(delta);
else flowController.applyInitialWindowDelta(streams, delta);
} catch (IllegalStateException overflow) {
throw Http2Exception.FLOW_CONTROL_ERROR;
}
streamWindows.applyInitialWindowDelta(delta);
};
this.settingsAckTimeoutMs = settingsAckTimeoutMs;
}
@Override
public void run(ConnectionContext ctx) throws IOException {
configure(ctx.configuration());
Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write);
flowController =
new Http2FlowController(
(streamId, increment) -> sendWindowUpdate(writer, streamId, increment));
streamDispatcher =
new Http2StreamDispatcher(
ctx,
writer,
peerSettings,
streams,
flowController,
(streamId, error) -> sendRstStream(writer, streamId, error));
try {
run(ctx.in(), writer, ctx.stopped());
} finally {
writer.close();
}
}
void run(BufferedByteSource input, Http2FrameWriter writer, BooleanSupplier stopped)
throws IOException {
if (flowController == null) {
flowController =
new Http2FlowController(
(streamId, increment) -> sendWindowUpdate(writer, streamId, increment));
}
Http2FrameReader reader = new Http2FrameReader(input);
runPrepared(input, reader, writer, stopped);
}
/** Runs with connection collaborators that were allocated during connection setup. */
void runPrepared(
BufferedByteSource input,
Http2FrameReader reader,
Http2FrameWriter writer,
BooleanSupplier stopped)
throws IOException {
PrefaceResult preface = verifyPreface(input);
if (preface == PrefaceResult.TRUNCATED) return;
if (preface == PrefaceResult.INVALID) {
sendGoAway(writer, 0, Http2ErrorCode.PROTOCOL_ERROR, "invalid client preface");
writer.drain();
return;
}
abuse.start();
sendConstant(writer, Http2Preface.serverSettings());
sendConstant(writer, Http2Preface.initialConnectionWindow());
outstandingLocalSettings = 1;
oldestSettingsSentNanos = System.nanoTime();
boolean firstFrame = true;
try {
while (!gracefulFinished && !peerGoAway) {
abuse.checkBudgets();
closeIdleStreams(writer);
if (stopped.getAsBoolean() && !gracefulStarted) startGracefulShutdown(writer);
FrameHeader frame;
try {
frame = reader.readFrame(Math.min(100, nextReadTimeoutMs()));
} catch (SocketTimeoutException timeout) {
checkSettingsTimeout();
headerBlocks.checkTimeout();
if (stopped.getAsBoolean() && !gracefulStarted) {
startGracefulShutdown(writer);
continue;
}
if (reader.frameDeadlineExpired()) throw timeout;
continue;
}
if (frame == null) break;
abuse.receivedFrame(frame.length());
try {
headerBlocks.checkTimeout();
FrameValidator.validate(frame, headerBlocks.insideHeaderBlock());
if (headerBlocks.insideHeaderBlock() && frame.type() != FrameType.CONTINUATION) {
throw Http2Exception.PROTOCOL_ERROR;
}
if (firstFrame && frame.type() != FrameType.SETTINGS) {
throw Http2Exception.PROTOCOL_ERROR;
}
if (firstFrame && FrameFlags.isAck(frame.flags()) && frame.length() == 0) {
throw Http2Exception.PROTOCOL_ERROR;
}
firstFrame = false;
dispatch(frame, writer);
} catch (Http2StreamException streamError) {
sendRstStream(writer, streamError);
closeStreamAfterError(streamError.streamId());
} finally {
reader.consumeFrame();
}
writer.drain();
if (dispatchCount > 0 && !reader.hasBufferedInput()) dispatchPendingStreams();
checkSettingsTimeout();
}
} catch (Http2Exception connectionError) {
sendGoAway(
writer, lastProcessedStreamId, connectionError.errorCode(), connectionError.getMessage());
} catch (IOException io) {
throw io;
} catch (RuntimeException unexpected) {
log.error("Unexpected failure in HTTP/2 demux loop", unexpected);
sendGoAway(writer, lastProcessedStreamId, Http2ErrorCode.INTERNAL_ERROR, "internal error");
}
}
private PrefaceResult verifyPreface(BufferedByteSource input) throws IOException {
byte[] preface = scratch.prefaceBuffer();
int read = 0;
input.setDeadline(System.nanoTime() + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L);
try {
while (read < preface.length) {
int n = input.read(preface, read, preface.length - read);
if (n < 0) return PrefaceResult.TRUNCATED;
read += n;
}
return Http2Preface.matchesClientPreface(preface)
? PrefaceResult.MATCHED
: PrefaceResult.INVALID;
} finally {
input.clearDeadline();
}
}
private enum PrefaceResult {
MATCHED,
INVALID,
TRUNCATED
}
private void dispatch(FrameHeader frame, Http2FrameWriter writer) throws IOException {
FrameType type = frame.type();
if (type == null) {
abuse.uselessFrameReceived();
return;
}
switch (type) {
case SETTINGS -> receiveSettings(frame, writer);
case PING -> receivePing(frame, writer);
case WINDOW_UPDATE -> receiveWindowUpdate(frame);
case GOAWAY -> receiveGoAway(frame);
case HEADERS -> receiveHeaders(frame, writer);
case CONTINUATION -> receiveContinuation(frame, writer);
case DATA -> receiveData(frame);
case RST_STREAM -> receiveRstStream(frame);
case PRIORITY -> receivePriority(frame);
default -> {}
}
}
private void receiveHeaders(FrameHeader frame, Http2FrameWriter writer) throws IOException {
int streamId = frame.streamId();
if ((streamId & 1) == 0) throw Http2Exception.PROTOCOL_ERROR;
Http2Stream existing = streams.get(streamId);
if (existing != null) {
existing.touch();
if (existing.state() == Http2StreamState.HALF_CLOSED_REMOTE) {
throw new Http2StreamException(
streamId, Http2ErrorCode.STREAM_CLOSED, "stream is half-closed remotely");
}
if (!FrameFlags.isEndStream(frame.flags())) {
throw new Http2StreamException(
streamId, Http2ErrorCode.PROTOCOL_ERROR, "trailers require END_STREAM");
}
pendingHeaderStream = existing;
pendingTrailers = true;
existing.trailerBlock().reset();
if (headerBlocks.accept(frame, existing.trailerBlock())) completeHeaders(writer, streamId);
return;
}
if (streamId <= highestClientStreamId) {
int closedKind = streams.closedKind(streamId);
if (closedKind == Http2StreamTable.CLOSED_NORMALLY) {
throw Http2Exception.of(Http2ErrorCode.STREAM_CLOSED, "frame on a closed stream");
}
if (closedKind == Http2StreamTable.CLOSED_BY_RESET) {
throw new Http2StreamException(
streamId, Http2ErrorCode.STREAM_CLOSED, "stream was reset");
}
throw Http2Exception.PROTOCOL_ERROR;
}
abuse.streamCreated();
highestClientStreamId = streamId;
pendingTrailers = false;
pendingHeaderStream = streams.acquire(streamId);
refusingHeaderStream = pendingHeaderStream == null;
if (pendingHeaderStream != null) {
flowController.initializeStreamSendWindow(
pendingHeaderStream, peerSettings.initialWindowSize());
}
HeaderSink sink =
refusingHeaderStream
? DISCARD_HEADERS
: (pendingTrailers
? pendingHeaderStream.trailerBlock()
: pendingHeaderStream.headerBlock());
if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId());
}
private void receiveContinuation(FrameHeader frame, Http2FrameWriter writer) throws IOException {
if (pendingHeaderStream == null && !refusingHeaderStream) {
throw Http2Exception.PROTOCOL_ERROR;
}
HeaderSink sink =
refusingHeaderStream
? DISCARD_HEADERS
: (pendingTrailers
? pendingHeaderStream.trailerBlock()
: pendingHeaderStream.headerBlock());
if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId());
}
private void completeHeaders(Http2FrameWriter writer, int streamId) throws IOException {
try {
if (refusingHeaderStream) {
sendRstStream(writer, streamId, Http2ErrorCode.REFUSED_STREAM);
} else {
Http2Stream stream = pendingHeaderStream;
boolean dispatch;
if (pendingTrailers) {
stream.validateTrailers();
stream.finishRequestBody();
stream.transition(Http2StreamState.Event.RECV_HEADERS_ES);
dispatch = !stream.dispatched();
} else {
if (streamDispatcher != null) stream.validateHeaders();
dispatch = stream.prepareRequestBody(flowController, headerBlocks.endStream());
stream.transition(
headerBlocks.endStream()
? Http2StreamState.Event.RECV_HEADERS_ES
: Http2StreamState.Event.RECV_HEADERS);
lastProcessedStreamId = Math.max(lastProcessedStreamId, streamId);
}
if (streamDispatcher == null && !pendingTrailers) {
streams.retire(stream, streamId);
if (!gracefulStarted) startGracefulShutdown(writer);
} else if (dispatch) {
enqueueDispatch(stream);
} else if (stream.responseStarted() && stream.responseWriter().finished()
&& !stream.responseInFlight() && stream.state() == Http2StreamState.CLOSED) {
streams.retire(stream, streamId);
}
}
} finally {
pendingHeaderStream = null;
refusingHeaderStream = false;
pendingTrailers = false;
}
}
private void receivePriority(FrameHeader frame) {
abuse.uselessFrameReceived();
int dependency = readUInt31(frame.buffer(), frame.payloadOffset());
if (dependency == frame.streamId()) {
throw new Http2StreamException(
frame.streamId(), Http2ErrorCode.PROTOCOL_ERROR, "stream depends on itself");
}
}
private void receiveData(FrameHeader frame) {
if (frame.length() == 0) abuse.uselessFrameReceived();
flowController.receiveConnectionBytes(frame.length());
Http2Stream stream = streams.get(frame.streamId());
if (stream == null) {
discardConnectionBytes(frame.length());
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
throw new Http2StreamException(
frame.streamId(), Http2ErrorCode.STREAM_CLOSED, "stream is closed");
}
boolean bodyAccepted = false;
try {
stream.touch();
stream.transition(
FrameFlags.isEndStream(frame.flags())
? Http2StreamState.Event.RECV_DATA_ES
: Http2StreamState.Event.RECV_DATA);
flowController.receiveStreamBytes(stream, frame.length());
long unpadded =
Padding.unpad(
frame.buffer(),
frame.payloadOffset(),
frame.length(),
FrameFlags.isPadded(frame.flags()));
int dataOffset = Pairs.hi(unpadded);
int dataLength = Pairs.lo(unpadded);
if (frame.length() == 0) {
if (stream.incrementEmptyDataFrames()
> Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM) {
throw new Http2StreamException(
frame.streamId(), Http2ErrorCode.ENHANCE_YOUR_CALM, "empty DATA frame limit exceeded");
}
} else {
stream.resetEmptyDataFrames();
}
stream.receiveData(frame.buffer(), dataOffset, dataLength, frame.length());
bodyAccepted = true;
if (FrameFlags.isEndStream(frame.flags())) {
stream.finishRequestBody();
if (streamDispatcher != null && !stream.dispatched()) enqueueDispatch(stream);
else if (stream.responseStarted() && stream.responseWriter().finished()
&& !stream.responseInFlight()
&& stream.state() == Http2StreamState.CLOSED) {
streams.retire(stream, frame.streamId());
}
}
} catch (RuntimeException failure) {
if (!bodyAccepted) discardConnectionBytes(frame.length());
throw failure;
}
}
private void receiveRstStream(FrameHeader frame) {
abuse.resetReceived();
Http2Stream stream = streams.get(frame.streamId());
if (stream == null) {
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
return;
}
boolean releaseDeferred =
stream.dispatched() || stream.state() == Http2StreamState.HALF_CLOSED_REMOTE;
stream.transition(Http2StreamState.Event.RECV_RST);
if (!streams.removeIfSame(stream, frame.streamId())) return;
streams.rememberReset(frame.streamId());
if (releaseDeferred) {
stream.cancel();
if (stream.responseStarted() && !stream.responseInFlight()) streams.release(stream);
} else {
streams.release(stream);
}
}
private void enqueueDispatch(Http2Stream stream) {
if (dispatchCount == dispatchQueue.length) {
throw new Http2StreamException(
stream.id(), Http2ErrorCode.REFUSED_STREAM, "dispatch queue is full");
}
stream.markDispatched();
dispatchQueue[dispatchCount++] = stream;
}
private void dispatchPendingStreams() {
int count = dispatchCount;
dispatchCount = 0;
for (int i = 0; i < count; i++) {
Http2Stream stream = dispatchQueue[i];
dispatchQueue[i] = null;
streamDispatcher.dispatch(stream);
}
}
private void receiveSettings(FrameHeader frame, Http2FrameWriter writer) throws IOException {
boolean ack = FrameFlags.isAck(frame.flags());
if (ack) {
if (frame.length() != 0) throw Http2Exception.FRAME_SIZE_ERROR;
if (outstandingLocalSettings == 0) throw Http2Exception.PROTOCOL_ERROR;
outstandingLocalSettings--;
if (outstandingLocalSettings == 0) oldestSettingsSentNanos = 0;
return;
}
abuse.settingsReceived();
peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), streamWindows);
sendConstant(writer, Http2Preface.settingsAck());
}
private void receivePing(FrameHeader frame, Http2FrameWriter writer) throws IOException {
if (FrameFlags.isAck(frame.flags())) {
if (gracefulStarted && matches(frame.buffer(), frame.payloadOffset(), SHUTDOWN_PING)) {
sendGoAway(writer, lastProcessedStreamId, Http2ErrorCode.NO_ERROR, "shutdown complete");
gracefulFinished = true;
}
return;
}
abuse.pingReceived();
ControlIntent pong = scratch.acquire(ControlKind.PING);
pong.frame(FrameType.PING, FrameFlags.ACK, 0, frame.buffer(), frame.payloadOffset(), 8);
writer.writePriority(pong);
}
private void receiveWindowUpdate(FrameHeader frame) {
abuse.uselessFrameReceived();
int increment = readUInt31(frame.buffer(), frame.payloadOffset());
if (increment == 0) {
if (frame.streamId() == 0) throw Http2Exception.PROTOCOL_ERROR;
throw new Http2StreamException(
frame.streamId(), Http2ErrorCode.PROTOCOL_ERROR, "zero window increment");
}
if (frame.streamId() != 0) {
Http2Stream stream = streams.get(frame.streamId());
if (stream == null) {
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
return;
}
try {
stream.touch();
flowController.increaseStreamSendWindow(stream, increment);
} catch (IllegalStateException overflow) {
throw new Http2StreamException(
frame.streamId(), Http2ErrorCode.FLOW_CONTROL_ERROR, "stream window overflow");
}
if (streamDispatcher != null) streamDispatcher.streamWindowUpdated(stream);
return;
}
flowController.increaseConnectionSendWindow(increment);
if (streamDispatcher != null) streamDispatcher.connectionWindowUpdated();
}
private void receiveGoAway(FrameHeader frame) {
peerLastStreamId = readUInt31(frame.buffer(), frame.payloadOffset());
peerErrorCode = readInt(frame.buffer(), frame.payloadOffset() + 4);
peerGoAway = true;
}
void configure(FlashConfiguration configuration) {
abuse.configure(configuration);
long idle = configuration.getH2StreamIdleTimeoutMs();
if (idle <= 0) throw new IllegalArgumentException("h2StreamIdleTimeoutMs must be positive");
streamIdleTimeoutNanos = idle > Long.MAX_VALUE / 1_000_000L
? Long.MAX_VALUE : idle * 1_000_000L;
}
private void closeIdleStreams(Http2FrameWriter writer) throws IOException {
int count = streams.copyValues(idleSweep);
long now = System.nanoTime();
for (int i = 0; i < count; i++) {
Http2Stream stream = idleSweep[i];
idleSweep[i] = null;
if (!stream.idleExpired(now, streamIdleTimeoutNanos)) continue;
int streamId = stream.id();
if (!streams.removeIfSame(stream, streamId)) continue;
streams.rememberReset(streamId);
sendRstStream(writer, streamId, Http2ErrorCode.CANCEL);
if (stream.dispatched()) stream.cancel();
else streams.release(stream);
}
}
private void startGracefulShutdown(Http2FrameWriter writer) throws IOException {
gracefulStarted = true;
sendGoAway(writer, Integer.MAX_VALUE, Http2ErrorCode.NO_ERROR, "server shutting down");
ControlIntent ping = scratch.acquire(ControlKind.PING);
ping.frame(FrameType.PING, 0, 0, SHUTDOWN_PING, 0, SHUTDOWN_PING.length);
writer.writePriority(ping);
}
private void sendRstStream(Http2FrameWriter writer, Http2StreamException error)
throws IOException {
ControlIntent rst = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
rst.frame(FrameType.RST_STREAM, 0, error.streamId(), error.errorCode().bytes(), 0, 4);
writer.writePriority(rst);
}
private void sendRstStream(Http2FrameWriter writer, int streamId, Http2ErrorCode error)
throws IOException {
ControlIntent rst = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
rst.frame(FrameType.RST_STREAM, 0, streamId, error.bytes(), 0, 4);
writer.writePriority(rst);
}
private void sendWindowUpdate(Http2FrameWriter writer, int streamId, int increment)
throws IOException {
ControlIntent update = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
update.windowUpdate(streamId, increment);
writer.writePriority(update);
}
private void discardConnectionBytes(int bytes) {
try {
flowController.discarded(bytes);
} catch (IOException failure) {
throw new IllegalStateException("failed to restore connection flow-control window", failure);
}
}
private void closeStreamAfterError(int streamId) {
Http2Stream stream = streams.get(streamId);
if (stream == null) return;
if (!streams.removeIfSame(stream, streamId)) return;
streams.rememberReset(streamId);
if (stream.dispatched()) stream.cancel();
else streams.release(stream);
if (pendingHeaderStream == stream) pendingHeaderStream = null;
}
private void sendGoAway(
Http2FrameWriter writer, int lastStreamId, Http2ErrorCode error, String debug)
throws IOException {
ControlIntent goAway = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
goAway.goAway(lastStreamId, error, debug == null ? "" : debug);
writer.writePriority(goAway);
}
private void sendConstant(Http2FrameWriter writer, byte[] bytes) throws IOException {
ControlIntent intent = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
intent.copy(bytes);
writer.writePriority(intent);
}
private long nextReadTimeoutMs() {
if (outstandingLocalSettings == 0) return Http2Limits.FRAME_READ_TIMEOUT_MS;
long elapsed = System.nanoTime() - oldestSettingsSentNanos;
long remainingNanos = settingsAckTimeoutMs * 1_000_000L - elapsed;
if (remainingNanos <= 0) throw Http2Exception.SETTINGS_TIMEOUT;
long remainingMs = Math.max(1, (remainingNanos + 999_999L) / 1_000_000L);
return Math.min(Http2Limits.FRAME_READ_TIMEOUT_MS, remainingMs);
}
private void checkSettingsTimeout() {
if (outstandingLocalSettings != 0
&& System.nanoTime() - oldestSettingsSentNanos >= settingsAckTimeoutMs * 1_000_000L) {
throw Http2Exception.SETTINGS_TIMEOUT;
}
}
private static boolean matches(byte[] buf, int off, byte[] expected) {
int different = 0;
for (int i = 0; i < expected.length; i++) different |= buf[off + i] ^ expected[i];
return different == 0;
}
private static int readUInt31(byte[] buf, int off) {
return readInt(buf, off) & 0x7FFF_FFFF;
}
private static int readInt(byte[] buf, int off) {
return ((buf[off] & 0xFF) << 24)
| ((buf[off + 1] & 0xFF) << 16)
| ((buf[off + 2] & 0xFF) << 8)
| (buf[off + 3] & 0xFF);
}
public Http2Settings peerSettings() {
return peerSettings;
}
public long connectionSendWindow() {
return flowController == null ? 65_535 : flowController.connectionSendWindow();
}
public int peerLastStreamId() {
return peerLastStreamId;
}
public int peerErrorCode() {
return peerErrorCode;
}
void reset() {
peerSettings.reset();
flowController = null;
outstandingLocalSettings = 0;
oldestSettingsSentNanos = 0;
lastProcessedStreamId = 0;
peerLastStreamId = Integer.MAX_VALUE;
peerErrorCode = 0;
peerGoAway = false;
gracefulStarted = false;
gracefulFinished = false;
highestClientStreamId = 0;
pendingHeaderStream = null;
refusingHeaderStream = false;
dispatchCount = 0;
}
}
@@ -0,0 +1,179 @@
package dev.relism.flash.http2;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.WriteIntent;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/** Reusable control-frame storage owned by one HTTP/2 connection. */
final class Http2ConnectionScratch {
private static final int CONTROL_SLOT_COUNT =
Http2Limits.MAX_PING_QUEUE_DEPTH + Http2Limits.MAX_SETTINGS_ACK_QUEUE_DEPTH + 8;
private static final int CONTROL_FRAME_CAPACITY = 256;
private final ControlIntent[] controls = new ControlIntent[CONTROL_SLOT_COUNT];
private final AtomicInteger pingResponses = new AtomicInteger();
private final AtomicInteger settingsAcks = new AtomicInteger();
private final byte[] preface = new byte[Http2Preface.clientPrefaceLength()];
Http2ConnectionScratch() {
for (int i = 0; i < controls.length; i++) {
controls[i] = new ControlIntent(this, CONTROL_FRAME_CAPACITY);
}
}
byte[] prefaceBuffer() {
return preface;
}
ControlIntent acquire(ControlKind kind) {
AtomicInteger counter = counter(kind);
int limit = limit(kind);
int queued = counter.incrementAndGet();
if (queued > limit) {
counter.decrementAndGet();
throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, kind + " queue exhausted");
}
for (ControlIntent intent : controls) {
if (intent.claim(kind)) return intent;
}
counter.decrementAndGet();
throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, "control-frame queue exhausted");
}
private void release(ControlIntent intent) {
counter(intent.kind).decrementAndGet();
intent.release();
}
private AtomicInteger counter(ControlKind kind) {
return kind == ControlKind.PING ? pingResponses : settingsAcks;
}
private static int limit(ControlKind kind) {
return kind == ControlKind.PING
? Http2Limits.MAX_PING_QUEUE_DEPTH
: Http2Limits.MAX_SETTINGS_ACK_QUEUE_DEPTH;
}
enum ControlKind {
PING,
SETTINGS_OR_OTHER
}
static final class ControlIntent implements WriteIntent {
private final Http2ConnectionScratch owner;
private final byte[] buffer;
private final AtomicBoolean claimed = new AtomicBoolean();
private volatile WriteIntent next;
private ControlKind kind;
private int length;
private ControlIntent(Http2ConnectionScratch owner, int capacity) {
this.owner = owner;
this.buffer = new byte[capacity];
}
private boolean claim(ControlKind kind) {
if (!claimed.compareAndSet(false, true)) return false;
this.kind = kind;
this.length = 0;
this.next = null;
return true;
}
void copy(byte[] source) {
System.arraycopy(source, 0, buffer, 0, source.length);
length = source.length;
}
void frame(FrameType type, int flags, int streamId, byte[] payload, int off, int len) {
if (9 + len > buffer.length) {
throw new IllegalArgumentException("control frame exceeds scratch capacity");
}
buffer[0] = (byte) (len >>> 16);
buffer[1] = (byte) (len >>> 8);
buffer[2] = (byte) len;
buffer[3] = (byte) type.code();
buffer[4] = (byte) flags;
writeUInt31(buffer, 5, streamId);
System.arraycopy(payload, off, buffer, 9, len);
length = 9 + len;
}
void goAway(int lastStreamId, Http2ErrorCode error, String debug) {
int debugLength =
Math.min(
debug.length(),
Math.min(Http2Limits.MAX_GOAWAY_DEBUG_DATA_LENGTH, buffer.length - 17));
int payloadLength = 8 + debugLength;
buffer[0] = 0;
buffer[1] = 0;
buffer[2] = (byte) payloadLength;
buffer[3] = (byte) FrameType.GOAWAY.code();
buffer[4] = 0;
writeUInt31(buffer, 5, 0);
writeUInt31(buffer, 9, lastStreamId);
writeUInt32(buffer, 13, error.code());
for (int i = 0; i < debugLength; i++) buffer[17 + i] = (byte) debug.charAt(i);
length = 17 + debugLength;
}
void windowUpdate(int streamId, int increment) {
buffer[0] = 0;
buffer[1] = 0;
buffer[2] = 4;
buffer[3] = (byte) FrameType.WINDOW_UPDATE.code();
buffer[4] = 0;
writeUInt31(buffer, 5, streamId);
writeUInt31(buffer, 9, increment);
length = 13;
}
private static void writeUInt31(byte[] target, int off, int value) {
writeUInt32(target, off, value & 0x7FFF_FFFF);
}
private static void writeUInt32(byte[] target, int off, int value) {
target[off] = (byte) (value >>> 24);
target[off + 1] = (byte) (value >>> 16);
target[off + 2] = (byte) (value >>> 8);
target[off + 3] = (byte) value;
}
@Override
public byte[] buffer() {
return buffer;
}
@Override
public int offset() {
return 0;
}
@Override
public int length() {
return length;
}
@Override
public WriteIntent mpscNext() {
return next;
}
@Override
public void setMpscNext(WriteIntent next) {
this.next = next;
}
@Override
public void completed() {
owner.release(this);
}
private void release() {
next = null;
claimed.set(false);
}
}
}
@@ -0,0 +1,94 @@
package dev.relism.flash.http2;
/**
* The 14 HTTP/2 error codes defined by RFC 9113 §7.
*
* <p>Each constant carries its 4-byte big-endian wire encoding, precomputed once at class
* load (RFC 9113 §6.4 {@code RST_STREAM} and §6.8 {@code GOAWAY} both carry the error code as
* a raw 32-bit field there is no framing around it to build). Callers write
*
* <p>{@code Http2ErrorCode} is used to reject a peer <em>and</em> to interpret what a peer
* sends us: {@link #fromCode(int)} decodes a received 32-bit value. RFC 9113 does not reserve
* unknown codes for future use in a way that requires us to accept them silently as one of the
* 14 an endpoint that receives an error code it does not recognise treats it as
* {@code INTERNAL_ERROR}-equivalent for logging purposes; {@link #fromCode(int)} returns
* {@code null} for that case and callers log the raw integer rather than guessing a mapping.
*/
public enum Http2ErrorCode {
/** Graceful shutdown or successful completion; not an error. RFC 9113 §7. */
NO_ERROR(0x00),
/** The peer violated the protocol in a way not covered by a more specific code. */
PROTOCOL_ERROR(0x01),
/** Unexpected internal condition on our side (e.g. an uncaught exception on the demux loop). */
INTERNAL_ERROR(0x02),
/** A flow-control window was violated: overflow past 2^31-1, or a peer exceeded its window. */
FLOW_CONTROL_ERROR(0x03),
/** The peer did not acknowledge our SETTINGS within {@code SETTINGS_ACK_TIMEOUT_MS}. */
SETTINGS_TIMEOUT(0x04),
/** A frame was received for a stream that is already closed. */
STREAM_CLOSED(0x05),
/** A frame's length did not match what its type requires (RFC 9113 §4.2, per-type rules). */
FRAME_SIZE_ERROR(0x06),
/** The stream was refused before any processing; safe for the client to retry elsewhere. */
REFUSED_STREAM(0x07),
/** Used by clients to cancel a stream; Flash never sends it, only receives it. */
CANCEL(0x08),
/** An HPACK decoding failure. Terminates the connection because the dynamic table state is lost. */
COMPRESSION_ERROR(0x09),
/** A CONNECT-tunnelled stream failed. */
CONNECT_ERROR(0x0a),
/** The peer is generating excessive load (rate-limit rejection: Rapid Reset, PING/SETTINGS floods). */
ENHANCE_YOUR_CALM(0x0b),
/** The negotiated TLS parameters fall below RFC 9113 §9.2's minimum security requirements. */
INADEQUATE_SECURITY(0x0c),
/** Defined by RFC 9113 for HTTP/1.1-only resources; Flash serves everything over h2, so unused. */
HTTP_1_1_REQUIRED(0x0d);
private static final Http2ErrorCode[] BY_CODE = new Http2ErrorCode[values().length];
static {
for (Http2ErrorCode c : values()) {
BY_CODE[c.code] = c;
}
}
private final int code;
private final byte[] bytes;
Http2ErrorCode(int code) {
this.code = code;
this.bytes = new byte[]{
(byte) (code >>> 24),
(byte) (code >>> 16),
(byte) (code >>> 8),
(byte) code
};
}
/** The numeric error code as it appears on the wire. */
public int code() {
return code;
}
/**
* The pre-encoded 4-byte big-endian wire form. Safe to write directly into a
* {@code RST_STREAM} or {@code GOAWAY} payload with a single {@code System.arraycopy}
* never allocated or formatted per use.
*/
public byte[] bytes() {
return bytes;
}
/**
* Decodes a 32-bit error code received from a peer. Returns {@code null} for a value
* outside the 14 defined codes; the caller should log the raw integer rather than assume
* a mapping, since RFC 9113 permits future extension codes we do not yet know about.
*/
public static Http2ErrorCode fromCode(int code) {
if (code >= 0 && code < BY_CODE.length) {
return BY_CODE[code];
}
return null;
}
}
@@ -0,0 +1,70 @@
package dev.relism.flash.http2;
/**
* A <b>connection-level</b> HTTP/2 error. Thrown anywhere a peer's frame, HPACK block, or
* SETTINGS value violates the protocol in a way that leaves the connection's state (the HPACK
* dynamic table, a flow-control window, the stream table) unrecoverable.
*
* single site: it sends {@code GOAWAY} with {@link #errorCode()} and closes the connection.
* Compare {@link Http2StreamException}, whose scope is one stream and which results in
* {@code RST_STREAM} while the connection survives.
*
* <p>Deliberately does <b>not</b> extend {@link java.io.IOException}: the connection loop
* distinguishes a protocol violation (a decision Flash made about the peer's bytes) from a
* socket failure (the peer went away) by catching these as unrelated types. Conflating them
* would make it possible to accidentally treat a hostile peer's malformed frame as a harmless
* disconnect, or vice versa.
*
* <h3>Why stack traces are disabled</h3>
* This exception is thrown on the connection's hot rejection path a single malformed byte
* from a hostile or buggy peer can trigger it, and under a scripted attack that can happen many
* times per second across many connections. JVM stack trace capture ({@code fillInStackTrace})
* is by far the most expensive part of constructing a {@code Throwable}, and it buys nothing
* here: the call site is exactly where {@code errorCode()} says it is, and the debug message
* already names the specific violation. The 4-argument {@link RuntimeException} constructor
* disables both suppression and stack-trace writing.
*
* <h3>Preallocated singletons</h3>
* For the common, message-less rejections (frame validation failures, HPACK structural errors)
* this class exposes shared singleton instances. Reusing one instance across threads and across
* many throws is safe <em>only because</em> the instance carries no per-throw mutable state and
* writable-stack-trace is disabled nothing about a throw mutates the exception object.
*/
public final class Http2Exception extends RuntimeException {
private final Http2ErrorCode errorCode;
private Http2Exception(Http2ErrorCode errorCode, String message) {
super(message, null, false, false);
this.errorCode = errorCode;
}
/** The RFC 9113 §7 error code to send in the {@code GOAWAY} frame. */
public Http2ErrorCode errorCode() {
return errorCode;
}
/**
* Builds a connection error carrying a caller-supplied debug message. Allocates a new
* message carries information specific to this occurrence (e.g. the offending stream id or
* a decoded value); use one of the preallocated singletons below when it does not.
*/
public static Http2Exception of(Http2ErrorCode code, String message) {
return new Http2Exception(code, message);
}
// Preallocated, message-less singletons for the hot rejection paths
public static final Http2Exception PROTOCOL_ERROR =
new Http2Exception(Http2ErrorCode.PROTOCOL_ERROR, "protocol error");
public static final Http2Exception FRAME_SIZE_ERROR =
new Http2Exception(Http2ErrorCode.FRAME_SIZE_ERROR, "frame size error");
public static final Http2Exception FLOW_CONTROL_ERROR =
new Http2Exception(Http2ErrorCode.FLOW_CONTROL_ERROR, "flow control error");
public static final Http2Exception COMPRESSION_ERROR =
new Http2Exception(Http2ErrorCode.COMPRESSION_ERROR, "compression error");
public static final Http2Exception INTERNAL_ERROR =
new Http2Exception(Http2ErrorCode.INTERNAL_ERROR, "internal error");
public static final Http2Exception SETTINGS_TIMEOUT =
new Http2Exception(Http2ErrorCode.SETTINGS_TIMEOUT, "settings ack timeout");
}
@@ -0,0 +1,115 @@
package dev.relism.flash.http2;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameHeader;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.Padding;
import dev.relism.flash.http2.hpack.ContinuationAssembler;
import dev.relism.flash.http2.hpack.HeaderListSizeException;
import dev.relism.flash.http2.hpack.HeaderSink;
import dev.relism.flash.http2.hpack.HpackDecoder;
/** Composes frame fragment extraction, CONTINUATION assembly and HPACK decoding. */
final class Http2HeaderBlockDecoder {
private static final int PRIORITY_FIELDS_LENGTH = 5;
private final ContinuationAssembler assembler = new ContinuationAssembler();
private final HpackDecoder decoder = new HpackDecoder();
private final long assemblyTimeoutNanos;
private boolean endStream;
private long assemblyStartedNanos;
Http2HeaderBlockDecoder() {
this(Http2Limits.HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS);
}
Http2HeaderBlockDecoder(long assemblyTimeoutMillis) {
if (assemblyTimeoutMillis <= 0) throw new IllegalArgumentException("non-positive timeout");
assemblyTimeoutNanos = assemblyTimeoutMillis * 1_000_000L;
}
boolean insideHeaderBlock() {
return assembler.isActive();
}
/** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */
boolean accept(FrameHeader frame, HeaderSink sink) {
checkTimeout();
if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) {
throw Http2Exception.PROTOCOL_ERROR;
}
if (frame.type() == FrameType.HEADERS) {
begin(frame);
} else if (frame.type() == FrameType.CONTINUATION) {
assembler.continuation(
frame.streamId(),
frame.buffer(),
frame.payloadOffset(),
frame.length(),
FrameFlags.isEndHeaders(frame.flags()));
} else {
return false;
}
if (!assembler.isComplete()) return false;
try {
decoder.decode(assembler.buffer(), 0, assembler.length(), sink);
} catch (HeaderListSizeException tooLarge) {
int streamId = assembler.streamId();
assembler.reset();
throw new Http2StreamException(
streamId, Http2ErrorCode.ENHANCE_YOUR_CALM, tooLarge.getMessage());
}
assembler.reset();
assemblyStartedNanos = 0;
return true;
}
void checkTimeout() {
if (assembler.isActive()
&& System.nanoTime() - assemblyStartedNanos >= assemblyTimeoutNanos) {
assembler.reset();
assemblyStartedNanos = 0;
throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM,
"header block assembly timeout");
}
}
boolean endStream() {
return endStream;
}
private void begin(FrameHeader frame) {
assemblyStartedNanos = System.nanoTime();
endStream = FrameFlags.isEndStream(frame.flags());
long unpadded =
Padding.unpad(
frame.buffer(),
frame.payloadOffset(),
frame.length(),
FrameFlags.isPadded(frame.flags()));
int fragmentOffset = Pairs.hi(unpadded);
int fragmentLength = Pairs.lo(unpadded);
if (FrameFlags.hasPriority(frame.flags())) {
if (fragmentLength < PRIORITY_FIELDS_LENGTH) throw Http2Exception.FRAME_SIZE_ERROR;
int dependency =
((frame.buffer()[fragmentOffset] & 0x7f) << 24)
| ((frame.buffer()[fragmentOffset + 1] & 0xff) << 16)
| ((frame.buffer()[fragmentOffset + 2] & 0xff) << 8)
| (frame.buffer()[fragmentOffset + 3] & 0xff);
if (dependency == frame.streamId()) {
throw new Http2StreamException(
frame.streamId(), Http2ErrorCode.PROTOCOL_ERROR, "stream depends on itself");
}
fragmentOffset += PRIORITY_FIELDS_LENGTH;
fragmentLength -= PRIORITY_FIELDS_LENGTH;
}
assembler.begin(
frame.streamId(),
frame.buffer(),
fragmentOffset,
fragmentLength,
FrameFlags.isEndHeaders(frame.flags()));
}
}
@@ -0,0 +1,219 @@
package dev.relism.flash.http2;
/**
* Every bound the HTTP/2 implementation enforces against a peer's input, in one place.
*
* <p>Every wire-derived length, index, count, or size is checked against a named constant here
* never against an ad-hoc literal, and never by letting the underlying array or buffer throw on
* overrun. Each field's Javadoc names the specific attack or resource it bounds and, where one
* exists, the CVE.
*
* <p>These are compile-time defaults, not runtime configuration. A limit becomes configurable only
* when the operational need and its safe range are established.
*
* <p>Each field is introduced with the feature that enforces it; this class contains no unused
* placeholders.
*/
public final class Http2Limits {
private Http2Limits() {}
/**
* Maximum number of streams a single connection may have open concurrently. Advertised to the
* peer as {@code SETTINGS_MAX_CONCURRENT_STREAMS}. Bounds per-connection memory (each open stream
* owns a per-stream HPACK arena and request/response state) against a peer that simply opens
* streams and never closes them.
*/
public static final int MAX_CONCURRENT_STREAMS = 64;
/**
* The largest frame payload we accept without the peer first raising it via our own {@code
* SETTINGS_MAX_FRAME_SIZE}. RFC 9113 §4.2 fixes the protocol default at 16384 and requires any
* advertised value to stay within {@code 16384..16777215}. Bounds the memory a single frame read
* can force us to hold.
*/
public static final int MAX_FRAME_SIZE_LOCAL = 16_384;
/**
* Maximum total size (name + value + 32 per RFC 7541 §4.1's accounting, summed over every header)
* of a decoded header list. Advertised as {@code SETTINGS_MAX_HEADER_LIST_SIZE} (RFC 9113
* §6.5.2). This is the primary defence against an HPACK bomb: a small compressed block that
* references dynamic-table entries to expand into an enormous header list.
*/
public static final int MAX_HEADER_LIST_SIZE = 32_768;
/**
* Maximum number of CONTINUATION frames accepted for a single header block before the connection
* is torn down. Defence against CVE-2024-27316 (the "HTTP/2 CONTINUATION Flood"): a peer that
* never sets {@code END_HEADERS} can otherwise force unbounded decode/reassembly work per header
* block.
*/
public static final int MAX_CONTINUATION_FRAMES_PER_BLOCK = 8;
/**
* Maximum number of {@code RST_STREAM} frames accepted from the peer within {@link
* #RESET_RATE_INTERVAL_MS}. Defence against CVE-2023-44487 ("HTTP/2 Rapid Reset"): opening a
* stream and immediately resetting it does not count against {@link #MAX_CONCURRENT_STREAMS}, so
* without a rate bound a peer can force unbounded per-stream setup/teardown work at effectively
* unlimited concurrency.
*/
public static final int MAX_RESET_STREAMS_PER_INTERVAL = 200;
/**
* The rolling window (milliseconds) over which {@link #MAX_RESET_STREAMS_PER_INTERVAL} is
* measured.
*/
public static final long RESET_RATE_INTERVAL_MS = 10_000;
/**
* Maximum number of new streams accepted from the peer within {@link #RESET_RATE_INTERVAL_MS}. A
* companion bound to {@link #MAX_RESET_STREAMS_PER_INTERVAL}: Rapid Reset defences that only
* count resets can still be bypassed by a peer that creates streams fast enough that the reset
* counter never saturates within any single window boundary.
*
* <p>Matches {@link #MAX_STREAMS_PER_CONNECTION}'s lifetime budget by design: a connection may
* not create more streams in one rolling burst window than it is ever allowed to create in its
* whole lifetime. An earlier value of 400 (40/s) measured the RST_STREAM flood attack this bound
* exists for, but also rejected ordinary high-concurrency multiplexed clients well below the
* throughput a hardened server is expected to sustain h2load's default light-load pattern (10
* connections, 10 concurrent streams each) alone drives multiple thousands of legitimate stream
* creations per connection per second on a fast peer, which 400/10s cannot distinguish from
* abuse. The RST_STREAM-rate counter above measures the actual CVE-2023-44487 signature (resets,
* not creates); this bound only needs to catch a peer creating streams fast enough to dodge that
* counter, which a much higher ceiling still does.
*/
public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 100_000;
/** Maximum SETTINGS frames accepted within one abuse-rate interval. */
public static final int MAX_SETTINGS_PER_INTERVAL = 100;
/** Maximum non-acknowledgement PING frames accepted within one abuse-rate interval. */
public static final int MAX_PINGS_PER_INTERVAL = 200;
/**
* Aggregate bound for frames that consume parsing work without carrying application data:
* PRIORITY, WINDOW_UPDATE, empty DATA and unknown extension frames.
*/
public static final int MAX_USELESS_FRAMES_PER_INTERVAL = 10_000;
/** Default total-stream budget for one connection; zero disables the budget. */
public static final long MAX_STREAMS_PER_CONNECTION = 100_000;
/** Default wire-byte budget for one connection; zero disables the budget. */
public static final long MAX_BYTES_PER_CONNECTION = 0;
/** Default connection lifetime budget in milliseconds; zero disables the budget. */
public static final long MAX_CONNECTION_LIFETIME_MS = 0;
/**
* Maximum number of SETTINGS parameter entries accepted in a single SETTINGS frame. A SETTINGS
* frame is already bounded in byte length by {@link #MAX_FRAME_SIZE_LOCAL} (each entry is 6
* bytes), but an explicit entry-count bound keeps the per-entry validation loop itself cheap to
* reason about and gives a distinct, loud rejection reason.
*/
public static final int MAX_SETTINGS_ENTRIES_PER_FRAME = 64;
/** Maximum number of locally-sent SETTINGS frames awaiting acknowledgement. */
public static final int MAX_OUTSTANDING_LOCAL_SETTINGS = 8;
/** Maximum time allowed for the peer to acknowledge a locally-sent SETTINGS frame. */
public static final long SETTINGS_ACK_TIMEOUT_MS = 10_000;
/**
* Maximum number of SETTINGS acknowledgements waiting behind a blocked socket writer. This
* prevents a peer from turning a stream of empty SETTINGS frames into an unbounded queue of
* mandatory responses.
*/
public static final int MAX_SETTINGS_ACK_QUEUE_DEPTH = 64;
/** Maximum diagnostic bytes included in an outbound GOAWAY frame. */
public static final int MAX_GOAWAY_DEBUG_DATA_LENGTH = 128;
/**
* Maximum number of outstanding (unanswered) PING responses queued for the writer. A PING flood
* forces a PONG per PING; without a bound, a peer that reads its own responses slowly can make us
* buffer unbounded PONG frames.
*/
public static final int MAX_PING_QUEUE_DEPTH = 64;
/**
* Maximum number of zero-length DATA frames accepted per stream. Zero-length DATA consumes no
* flow-control window, so window accounting does not bound it without this limit a peer can
* force unbounded per-frame dispatch/validation CPU work at zero cost to itself.
*/
public static final int MAX_EMPTY_DATA_FRAMES_PER_STREAM = 1_000;
/** Largest request body retained contiguously before dispatching its handler. */
public static final int INLINE_BODY_THRESHOLD = 64 * 1024;
/** Hard limit for request body bytes accepted on one stream. */
public static final int MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024;
/** Number of frame-sized buffers available to streaming request bodies on one connection. */
public static final int DATA_BUFFER_POOL_SIZE = 64;
/**
* The value of {@code SETTINGS_INITIAL_WINDOW_SIZE} Flash advertises for every new stream:
* deliberately large (1 MiB, versus the RFC default of 65535) so that a normal-sized
*/
public static final int INITIAL_WINDOW_SIZE_LOCAL = 1_048_576;
/**
* The connection-level flow-control window Flash advertises. Sized above {@link
* #INITIAL_WINDOW_SIZE_LOCAL} so a single active stream is never bottlenecked by the connection
* window before its own stream window, but well below {@code MAX_CONCURRENT_STREAMS *
* INITIAL_WINDOW_SIZE_LOCAL} real traffic is never all streams simultaneously saturating their
* windows, and sizing for that worst case would commit 100 MiB of receive window to every
* connection regardless of load.
*/
public static final int CONNECTION_WINDOW_SIZE_LOCAL = 1_048_576;
/**
* The HPACK dynamic table size Flash's decoder honours, in bytes of RFC 7541 §4.1 accounting. RFC
* 7541's protocol default. The encoder never uses a dynamic table at all
*/
public static final int HPACK_DYNAMIC_TABLE_SIZE_LOCAL = 4_096;
/**
* Maximum length, in decoded bytes, of a single HPACK string literal. Applied during Huffman
* decode as bytes are produced, not to the encoded length a Huffman string can expand by
* roughly 8/5, so bounding only the encoded length would let a compact input still decode past
* this limit.
*/
public static final int MAX_HPACK_STRING_LENGTH = 8_192;
/**
* Maximum time, in milliseconds, allowed between a HEADERS frame's arrival and the header block's
* completion (its {@code END_HEADERS} flag, possibly after CONTINUATION frames). A peer that
* starts a header block and then stalls indefinitely would otherwise hold the per-stream arena
* and the connection's HPACK assembly buffer forever.
*/
public static final long HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS = 10_000;
/**
* Maximum time, in milliseconds, a stream may remain open with no frame activity in either
* direction. Bounds resource pinning by a peer that opens a stream and then goes silent without
* closing it the h2 equivalent of the h1 slowloris defence in {@code
* FlashConfiguration.idleKeepAliveTimeoutMs}.
*/
public static final long STREAM_IDLE_TIMEOUT_MS = 60_000;
/**
* Maximum time, in milliseconds, {@code Http2FrameWriter} may spend blocked inside a single
* socket write. A blocking write is unavoidable when the kernel send buffer is full and the peer
* is not reading (that peer holds the connection's single writer lock for the duration see
* {@code WRITER.md}), but it must not be unbounded: a peer that simply stops reading would
* otherwise let a single stalled connection wedge the writer forever. Enforced via a background
* reaper interrupting the blocked thread past the deadline, not {@code Socket#setSoTimeout}
* that option bounds reads, not writes.
*/
public static final long WRITE_TIMEOUT_MS = 30_000;
/**
* Maximum time, in milliseconds, {@code Http2FrameReader} may wait for a single frame's
* header and payload to fully arrive. Bounds the same slowloris-shaped hazard:
* without it, a peer that sends 9 header bytes and then never sends the declared payload
* would hold this connection's frame reader waiting forever.
*/
public static final long FRAME_READ_TIMEOUT_MS = 20_000;
}
@@ -0,0 +1,87 @@
package dev.relism.flash.http2;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameWriteBuffer;
import java.nio.charset.StandardCharsets;
/** Byte-exact client preface and immutable server startup frames, compiled once at class load. */
public final class Http2Preface {
private static final byte[] CLIENT_PREFACE =
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
private static final byte[] SERVER_SETTINGS = buildServerSettings();
private static final byte[] SETTINGS_ACK = frame(FrameType.SETTINGS, FrameFlags.ACK, 0, 0);
private static final byte[] INITIAL_CONNECTION_WINDOW = buildInitialConnectionWindow();
private Http2Preface() {}
/** Immutable client connection preface bytes. Callers must not modify the returned array. */
public static byte[] clientPreface() {
return CLIENT_PREFACE;
}
static int clientPrefaceLength() {
return CLIENT_PREFACE.length;
}
static boolean matchesClientPreface(byte[] candidate) {
if (candidate.length != CLIENT_PREFACE.length) return false;
int different = 0;
for (int i = 0; i < CLIENT_PREFACE.length; i++) {
different |= candidate[i] ^ CLIENT_PREFACE[i];
}
return different == 0;
}
static byte[] serverSettings() {
return SERVER_SETTINGS;
}
static byte[] settingsAck() {
return SETTINGS_ACK;
}
static byte[] initialConnectionWindow() {
return INITIAL_CONNECTION_WINDOW;
}
private static byte[] buildServerSettings() {
ByteWriter bytes = new ByteWriter(64);
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
frame.beginFrame(FrameType.SETTINGS, 0, 0);
setting(bytes, Http2Settings.HEADER_TABLE_SIZE, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL);
setting(bytes, Http2Settings.ENABLE_PUSH, 0);
setting(bytes, Http2Settings.MAX_CONCURRENT_STREAMS, Http2Limits.MAX_CONCURRENT_STREAMS);
setting(bytes, Http2Settings.INITIAL_WINDOW_SIZE, Http2Limits.INITIAL_WINDOW_SIZE_LOCAL);
setting(bytes, Http2Settings.MAX_HEADER_LIST_SIZE, Http2Limits.MAX_HEADER_LIST_SIZE);
setting(bytes, Http2Settings.ENABLE_CONNECT_PROTOCOL, 1);
frame.endFrame();
return copy(bytes);
}
private static byte[] buildInitialConnectionWindow() {
int increment = Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL - 65_535;
return frame(FrameType.WINDOW_UPDATE, 0, 0, increment);
}
private static byte[] frame(FrameType type, int flags, int streamId, int payload) {
ByteWriter bytes = new ByteWriter(16);
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
frame.beginFrame(type, flags, streamId);
if (type == FrameType.WINDOW_UPDATE) bytes.writeUInt31(payload);
frame.endFrame();
return copy(bytes);
}
private static void setting(ByteWriter bytes, int id, int value) {
bytes.writeUInt16(id);
bytes.writeUInt32(value);
}
private static byte[] copy(ByteWriter bytes) {
byte[] result = new byte[bytes.length()];
System.arraycopy(bytes.array(), 0, result, 0, result.length);
return result;
}
}
@@ -0,0 +1,149 @@
package dev.relism.flash.http2;
/**
* The peer's HTTP/2 SETTINGS state. A received payload is validated completely before any value is
* applied, so a malformed parameter cannot leave a partially-updated connection.
*/
public final class Http2Settings {
public static final int HEADER_TABLE_SIZE = 0x1;
public static final int ENABLE_PUSH = 0x2;
public static final int MAX_CONCURRENT_STREAMS = 0x3;
public static final int INITIAL_WINDOW_SIZE = 0x4;
public static final int MAX_FRAME_SIZE = 0x5;
public static final int MAX_HEADER_LIST_SIZE = 0x6;
public static final int ENABLE_CONNECT_PROTOCOL = 0x8;
public static final int DEFAULT_HEADER_TABLE_SIZE = 4_096;
public static final int DEFAULT_INITIAL_WINDOW_SIZE = 65_535;
public static final int DEFAULT_MAX_FRAME_SIZE = 16_384;
/**
* Applies an INITIAL_WINDOW_SIZE delta to every open stream. Implementations must validate all
* resulting windows before changing any of them; negative results are valid, while a result above
* {@link Integer#MAX_VALUE} is a connection FLOW_CONTROL_ERROR.
*/
@FunctionalInterface
public interface StreamWindowUpdater {
void applyInitialWindowDelta(int delta);
}
private int headerTableSize = DEFAULT_HEADER_TABLE_SIZE;
private boolean pushEnabled = true;
private long maxConcurrentStreams = 0xFFFF_FFFFL;
private int initialWindowSize = DEFAULT_INITIAL_WINDOW_SIZE;
private int maxFrameSize = DEFAULT_MAX_FRAME_SIZE;
private long maxHeaderListSize = 0xFFFF_FFFFL;
/** Validates and applies one SETTINGS payload. Unknown identifiers are ignored. */
public void apply(byte[] payload, int off, int len, StreamWindowUpdater streamWindows) {
if (len % 6 != 0) throw Http2Exception.FRAME_SIZE_ERROR;
int entries = len / 6;
if (entries > Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME) {
throw Http2Exception.of(
Http2ErrorCode.ENHANCE_YOUR_CALM, "too many SETTINGS entries: " + entries);
}
checkRange(payload, off, len);
int nextHeaderTableSize = headerTableSize;
boolean nextPushEnabled = pushEnabled;
long nextMaxConcurrentStreams = maxConcurrentStreams;
int nextInitialWindowSize = initialWindowSize;
int nextMaxFrameSize = maxFrameSize;
long nextMaxHeaderListSize = maxHeaderListSize;
for (int pos = off; pos < off + len; pos += 6) {
int id = readUInt16(payload, pos);
long value = readUInt32(payload, pos + 2);
validate(id, value);
switch (id) {
case HEADER_TABLE_SIZE ->
nextHeaderTableSize = (int) Math.min(value, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL);
case ENABLE_PUSH -> nextPushEnabled = value == 1;
case MAX_CONCURRENT_STREAMS -> nextMaxConcurrentStreams = value;
case INITIAL_WINDOW_SIZE -> nextInitialWindowSize = (int) value;
case MAX_FRAME_SIZE -> nextMaxFrameSize = (int) value;
case MAX_HEADER_LIST_SIZE -> nextMaxHeaderListSize = value;
default -> {
// RFC 9113 §6.5.2: ignore unknown settings.
}
}
}
streamWindows.applyInitialWindowDelta(nextInitialWindowSize - initialWindowSize);
headerTableSize = nextHeaderTableSize;
pushEnabled = nextPushEnabled;
maxConcurrentStreams = nextMaxConcurrentStreams;
initialWindowSize = nextInitialWindowSize;
maxFrameSize = nextMaxFrameSize;
maxHeaderListSize = nextMaxHeaderListSize;
}
private static void validate(int id, long value) {
switch (id) {
case ENABLE_PUSH, ENABLE_CONNECT_PROTOCOL -> {
if (value > 1) throw Http2Exception.PROTOCOL_ERROR;
}
case INITIAL_WINDOW_SIZE -> {
if (value > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR;
}
case MAX_FRAME_SIZE -> {
if (value < 16_384 || value > 16_777_215) {
throw Http2Exception.PROTOCOL_ERROR;
}
}
default -> {
// HEADER_TABLE_SIZE, MAX_CONCURRENT_STREAMS and MAX_HEADER_LIST_SIZE accept
// every unsigned 32-bit value. Unknown identifiers are ignored by the RFC.
}
}
}
private static void checkRange(byte[] payload, int off, int len) {
if (off < 0 || len < 0 || off > payload.length - len) {
throw new IndexOutOfBoundsException("invalid SETTINGS payload range");
}
}
private static int readUInt16(byte[] buf, int off) {
return ((buf[off] & 0xFF) << 8) | (buf[off + 1] & 0xFF);
}
private static long readUInt32(byte[] buf, int off) {
return ((long) (buf[off] & 0xFF) << 24)
| ((long) (buf[off + 1] & 0xFF) << 16)
| ((long) (buf[off + 2] & 0xFF) << 8)
| (buf[off + 3] & 0xFFL);
}
public int headerTableSize() {
return headerTableSize;
}
public boolean pushEnabled() {
return pushEnabled;
}
public long maxConcurrentStreams() {
return maxConcurrentStreams;
}
public int initialWindowSize() {
return initialWindowSize;
}
public int maxFrameSize() {
return maxFrameSize;
}
public long maxHeaderListSize() {
return maxHeaderListSize;
}
void reset() {
headerTableSize = DEFAULT_HEADER_TABLE_SIZE;
pushEnabled = true;
maxConcurrentStreams = 0xFFFF_FFFFL;
initialWindowSize = DEFAULT_INITIAL_WINDOW_SIZE;
maxFrameSize = DEFAULT_MAX_FRAME_SIZE;
maxHeaderListSize = 0xFFFF_FFFFL;
}
}
@@ -0,0 +1,305 @@
package dev.relism.flash.http2;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.http2.message.Http2ResponseWriter;
import dev.relism.flash.http2.stream.Http2FlowController;
import dev.relism.flash.http2.stream.Http2Stream;
import dev.relism.flash.http2.stream.Http2StreamState;
import dev.relism.flash.http2.stream.Http2StreamTable;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.models.ResponseStreamOutputStream;
import dev.relism.flash.transport.ConnectionContext;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketLoop;
import dev.relism.flash.websocket.WebSocketSession;
import java.io.IOException;
import java.util.concurrent.RejectedExecutionException;
import lombok.extern.slf4j.Slf4j;
/** Dispatches completed request streams without blocking the connection demultiplexer. */
@Slf4j
final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
@FunctionalInterface
interface FailureSink {
void fail(int streamId, Http2ErrorCode errorCode) throws IOException;
}
private final ConnectionContext context;
private final Http2FrameWriter frameWriter;
private final Http2Settings peerSettings;
private final Http2StreamTable streams;
private final Http2FlowController flowController;
private final FailureSink failures;
private final Http2Stream[] resumeScratch =
new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
private volatile boolean firstResponse = true;
Http2StreamDispatcher(
ConnectionContext context,
Http2FrameWriter frameWriter,
Http2Settings peerSettings,
Http2StreamTable streams,
Http2FlowController flowController,
FailureSink failures) {
this.context = context;
this.frameWriter = frameWriter;
this.peerSettings = peerSettings;
this.streams = streams;
this.flowController = flowController;
this.failures = failures;
}
void streamWindowUpdated(Http2Stream stream) {
scheduleResume(stream);
}
void connectionWindowUpdated() {
int count = streams.copyValues(resumeScratch);
for (int i = 0; i < count; i++) {
Http2Stream stream = resumeScratch[i];
resumeScratch[i] = null;
scheduleResume(stream);
}
}
private void scheduleResume(Http2Stream stream) {
if (!stream.responseStarted() || stream.cancelled()) return;
if (!stream.beginResponseBatch()) return;
stream.markResumeTask();
try {
context.executor().execute(stream);
} catch (RejectedExecutionException rejected) {
stream.endResponseBatch();
failAndRelease(stream, Http2ErrorCode.REFUSED_STREAM, rejected);
}
}
void dispatch(Http2Stream stream) {
if (stream.cancelled()) {
streams.release(stream);
return;
}
stream.markDispatched();
stream.responseSink(this);
try {
context.executor().execute(stream);
} catch (RejectedExecutionException rejected) {
failAndRelease(stream, Http2ErrorCode.REFUSED_STREAM, rejected);
}
}
@Override
public void handleRequest(Http2Stream stream) {
handle(stream);
}
private void handle(Http2Stream stream) {
stream.touch();
if (stream.cancelled()) {
streams.release(stream);
return;
}
try {
Request request = stream.assembleRequest(context.remoteAddress(), context.sslSocket());
Response pooled = stream.resetResponse();
Response response = pooled;
if (!Http2Authority.isServed(request.header("host"), request.sslSession())) {
response.status(HttpStatus.MISDIRECTED_REQUEST);
if (stream.websocketConnect()) response.type(ContentType.NONE).streaming(output -> {});
} else if (stream.websocketConnect()) {
WebSocketHandler handler =
context.wsRouter().route(request, stream.wsRouteScratch(context.wsRouter()));
response.type(ContentType.NONE);
if (handler == null) {
response.status(HttpStatus.NOT_FOUND).streaming(output -> {});
} else {
response.streaming(
output ->
WebSocketLoop.run(
new WebSocketSession(
request.body().stream(),
new ResponseStreamOutputStream(output),
context.configuration().getWsFrameBufferSize(),
request,
false),
handler));
}
} else {
Object routeScratch = stream.routeScratch(context.router());
RequestHandler handler = context.router().route(request, routeScratch);
if (handler == null) handler = context.router().getNotFoundHandler();
try {
Object result = handler.handle(request, response);
if (result instanceof Response returned) response = returned;
else if (result != null) response.setBody(result);
} catch (Exception handlerFailure) {
Object result =
context.router().getExceptionHandler().handle(handlerFailure, request, response);
if (result instanceof Response returned) response = returned;
else if (result != null) response.setBody(result);
}
}
boolean pushStreaming = response.isPushStreaming();
if (!pushStreaming) request.drain();
Http2ResponseWriter responseWriter = stream.responseWriter();
if (stream.cancelled()) {
request.recycle();
if (response == pooled) pooled.recycle();
streams.release(stream);
return;
}
boolean headRequest = request.method() == HttpMethod.HEAD;
int reserved;
int used;
synchronized (this) {
boolean tableUpdate = firstResponse;
reserved = flowController.reserveSend(stream, peerSettings.maxFrameSize());
used = 0;
try {
used =
responseWriter.startFlowControlled(
response,
stream.id(),
headRequest,
context.configuration().isSendDate(),
true,
context.configuration().isH2HuffmanDynamicValues(),
tableUpdate,
peerSettings.maxFrameSize(),
peerSettings.maxHeaderListSize(),
reserved);
} finally {
flowController.refundSend(stream, reserved - used);
}
firstResponse = false;
}
if (!pushStreaming) request.recycle();
stream.markResponseStarted();
applyBatchTransition(stream, responseWriter);
if (!stream.beginResponseBatch()) {
throw new IllegalStateException("response batch already in flight");
}
detachFinalBatch(stream, responseWriter);
frameWriter.write(responseWriter);
} catch (Exception failure) {
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure);
}
}
private void tryResumeResponse(Http2Stream stream) {
stream.touch();
if (stream.cancelled()) {
stream.endResponseBatch();
streams.release(stream);
return;
}
Http2ResponseWriter responseWriter = stream.responseWriter();
int streamId = stream.id();
if (responseWriter.finished()) {
stream.endResponseBatch();
if (stream.state() == Http2StreamState.CLOSED) {
streams.retire(stream, streamId);
}
return;
}
int reserved = flowController.reserveSend(stream, peerSettings.maxFrameSize());
if (reserved == 0) {
stream.endResponseBatch();
return;
}
try {
int used = 0;
try {
used = responseWriter.resume(peerSettings.maxFrameSize(), reserved);
} finally {
flowController.refundSend(stream, reserved - used);
}
applyBatchTransition(stream, responseWriter);
detachFinalBatch(stream, responseWriter);
frameWriter.write(responseWriter);
} catch (Exception failure) {
stream.endResponseBatch();
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure);
}
}
@Override
public void resumeResponse(Http2Stream stream) {
tryResumeResponse(stream);
}
private static void applyBatchTransition(
Http2Stream stream, Http2ResponseWriter responseWriter) {
if (responseWriter.headersInBatch()) {
if (responseWriter.finished() && responseWriter.dataBytesInBatch() == 0
&& !responseWriter.trailerHeadersInBatch()) {
stream.transition(Http2StreamState.Event.SEND_HEADERS_ES);
return;
}
stream.transition(Http2StreamState.Event.SEND_HEADERS);
}
if (responseWriter.dataBytesInBatch() != 0 || responseWriter.endStreamInBatch()) {
if (responseWriter.dataBytesInBatch() != 0) {
stream.transition(
responseWriter.endStreamInBatch() && !responseWriter.trailerHeadersInBatch()
? Http2StreamState.Event.SEND_DATA_ES
: Http2StreamState.Event.SEND_DATA);
}
if (responseWriter.trailerHeadersInBatch()) {
stream.transition(Http2StreamState.Event.SEND_HEADERS_ES);
} else if (responseWriter.dataBytesInBatch() == 0 && responseWriter.endStreamInBatch()) {
stream.transition(Http2StreamState.Event.SEND_DATA_ES);
}
}
}
@Override
public void responseBatchCompleted(Http2Stream stream) {
stream.touch();
stream.endResponseBatch();
int streamId = stream.id();
if (streamId == 0) return;
if (stream.cancelled()) {
streams.remove(stream.id());
streams.release(stream);
return;
}
if (stream.responseWriter().finished()) {
if (stream.state() == Http2StreamState.CLOSED) {
if (!streams.retire(stream, streamId)) streams.release(stream);
}
return;
}
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) {
int streamId = stream.id();
if (!streams.removeIfSame(stream, streamId) && stream.id() != streamId) return;
if (cause != null) log.error("HTTP/2 stream {} failed", streamId, cause);
try {
stream.cancel();
} catch (RuntimeException cancellationFailure) {
log.debug("Failed to cancel HTTP/2 stream {} cleanly", streamId, cancellationFailure);
}
try {
failures.fail(streamId, error);
} catch (IOException writeFailure) {
log.debug("Failed to write RST_STREAM for {}", streamId, writeFailure);
} finally {
streams.release(stream);
}
}
}
@@ -0,0 +1,43 @@
package dev.relism.flash.http2;
/**
* A <b>stream-level</b> HTTP/2 error, scoped to one stream id. Results in an {@code RST_STREAM}
* frame for {@link #streamId()} with {@link #errorCode()}; the connection and every other
* stream on it are unaffected. Compare {@link Http2Exception}, whose scope is the whole
* connection.
*
* <p>Deliberately does <b>not</b> extend {@link java.io.IOException}, for the same reason as
* {@link Http2Exception}: the connection loop must be able to distinguish "we decided to reject
* this stream" from "the socket failed" by catching unrelated exception types.
*
* <h3>Why this allocates, unlike {@code Http2Exception}'s singletons</h3>
* Every instance carries a distinct {@link #streamId()}, so it cannot be a shared singleton the
* exempts error paths. The scenario where this matters most a peer opening and resetting
* thousands of streams per second (the Rapid Reset pattern, CVE-2023-44487) is bounded by
* that can force RST_STREAM generation fast enough for GC pressure to matter has already
* tripped {@code Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL} and the connection is being torn
* down anyway.
*
* <p>Stack trace capture is disabled for the same cost reason as {@link Http2Exception}.
*/
public final class Http2StreamException extends RuntimeException {
private final Http2ErrorCode errorCode;
private final int streamId;
public Http2StreamException(int streamId, Http2ErrorCode errorCode, String message) {
super(message, null, false, false);
this.streamId = streamId;
this.errorCode = errorCode;
}
/** The id of the stream this error terminates. */
public int streamId() {
return streamId;
}
/** The RFC 9113 §7 error code to send in the {@code RST_STREAM} frame. */
public Http2ErrorCode errorCode() {
return errorCode;
}
}
@@ -0,0 +1,31 @@
package dev.relism.flash.http2;
/** Allocation-free two-bucket rolling rate counter owned by one connection thread. */
final class RollingWindowCounter {
private final long bucketNanos;
private long currentBucket;
private int currentCount;
private int previousCount;
RollingWindowCounter(long intervalMillis) {
if (intervalMillis < 2) throw new IllegalArgumentException("interval must be at least 2 ms");
bucketNanos = intervalMillis * 1_000_000L / 2;
}
boolean incrementExceeded(int limit) {
return incrementExceeded(limit, System.nanoTime());
}
boolean incrementExceeded(int limit, long nowNanos) {
long bucket = nowNanos / bucketNanos;
if (currentBucket == 0) {
currentBucket = bucket;
} else if (bucket != currentBucket) {
previousCount = bucket == currentBucket + 1 ? currentCount : 0;
currentCount = 0;
currentBucket = bucket;
}
currentCount++;
return currentCount + previousCount > limit;
}
}
@@ -0,0 +1,39 @@
package dev.relism.flash.http2.frame;
/**
* The frame-header flag bits (RFC 9113 §6), as bitwise constants plus predicate helpers.
*
* <h3>The deliberate collision</h3>
* Bit {@code 0x1} means different things on different frame types: {@link #END_STREAM} on
* {@code DATA}/{@code HEADERS}, {@link #ACK} on {@code SETTINGS}/{@code PING}. They are the same
* bit position because the RFC defines flags per-type, not globally reusing the numeric value
* is intentional on the wire, not a naming accident here. **Never call {@link #isEndStream} on a
* SETTINGS/PING frame's flags, or {@link #isAck} on a DATA/HEADERS frame's** each predicate is
* named for the one frame type family it is valid to call it on; mixing them up silently
* misreads an unrelated bit rather than throwing, because the bit pattern is, by construction,
* identical.
*
* <p>RFC 9113 §4.1: flag bits not defined for a frame's type MUST be ignored on receipt and MUST
* NOT be set when sending. This class only ever tests bits it defines for the type the caller is
* working with; undefined bits are never inspected.
*/
public final class FrameFlags {
private FrameFlags() {}
/** DATA/HEADERS: no more frames will be sent for this stream in this direction. */
public static final int END_STREAM = 0x1;
/** SETTINGS/PING: this frame acknowledges the peer's own frame, rather than proposing new values. */
public static final int ACK = 0x1;
/** HEADERS/PUSH_PROMISE/CONTINUATION: the header block is complete — no CONTINUATION follows. */
public static final int END_HEADERS = 0x4;
/** DATA/HEADERS/PUSH_PROMISE: a pad-length byte and trailing padding are present — see {@link Padding}. */
public static final int PADDED = 0x8;
/** HEADERS: deprecated stream-dependency/weight fields are present (RFC 9113 §5.3.2 — parsed and discarded). */
public static final int PRIORITY = 0x20;
public static boolean isEndStream(int flags) { return (flags & END_STREAM) != 0; }
public static boolean isAck(int flags) { return (flags & ACK) != 0; }
public static boolean isEndHeaders(int flags) { return (flags & END_HEADERS) != 0; }
public static boolean isPadded(int flags) { return (flags & PADDED) != 0; }
public static boolean hasPriority(int flags) { return (flags & PRIORITY) != 0; }
}
@@ -0,0 +1,83 @@
package dev.relism.flash.http2.frame;
/**
* A <b>flyweight</b> over one frame's 9-byte header plus its payload location, both still living
* in {@link Http2FrameReader}'s own read buffer. One instance per connection, {@link #reset}
* in place by every {@link Http2FrameReader#readFrame()} call never allocated per frame
* (mirrors the existing {@code WebSocketFrame} reuse idiom in {@code dev.relism.flash.websocket}).
*
* <h3>Lifetime contract</h3>
* Valid only until the next {@link Http2FrameReader#readFrame()}/{@code consumeFrame()} call on
* the same reader same "do not retain past the handler" rule the rest of this codebase's
* buffer-backed flyweights (`Http1HeaderMap`, `WebSocketFrame`) already document. The payload bytes
* are also transient: whatever layer needs to retain a DATA frame's payload past this window
*
* <h3>Reserved bit and unknown types</h3>
* {@link #streamId()} has already had the wire's reserved high bit (RFC 9113 §4.1: "R: A
* reserved 1-bit field... The semantics of this bit are undefined, and the bit MUST be ignored
* when receiving") masked off during {@link #reset} — callers never see it and never need to
* mask it themselves. {@link #type()} is {@code null} for a type code {@link FrameType} does not
* recognise (i.e. {@code typeCode() > FrameType.maxKnown()}); per RFC 9113 §4.1 such frames must
* be ignored, not rejected {@link #typeCode()} remains available so the caller can still log
* or count it before skipping the payload.
*/
public final class FrameHeader {
private byte[] buf;
private int length;
private int typeCode;
private FrameType type;
private int flags;
private int streamId;
private int payloadOffset;
/** Called by {@link Http2FrameReader} only, once the full 9-byte header is available at {@code buf[off]}. */
void reset(byte[] buf, int off) {
this.buf = buf;
int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF;
this.length = (b0 << 16) | (b1 << 8) | b2;
this.typeCode = buf[off + 3] & 0xFF;
this.type = FrameType.fromCode(typeCode);
this.flags = buf[off + 4] & 0xFF;
// RFC 9113 §4.1: the top bit of byte 5 is reserved and MUST be ignored on receipt
// masked here, once, rather than requiring every caller to remember to.
int b5 = buf[off + 5] & 0x7F;
int b6 = buf[off + 6] & 0xFF, b7 = buf[off + 7] & 0xFF, b8 = buf[off + 8] & 0xFF;
this.streamId = (b5 << 24) | (b6 << 16) | (b7 << 8) | b8;
this.payloadOffset = off + 9;
}
/** Payload length in bytes, as declared by the frame header (0..2^24-1 before any limit check). */
public int length() {
return length;
}
/** The raw wire type byte, valid even when {@link #type()} is {@code null} (an unrecognised type). */
public int typeCode() {
return typeCode;
}
/** The recognised frame type, or {@code null} if {@link #typeCode()} is not one of RFC 9113's 10. */
public FrameType type() {
return type;
}
/** The raw flags byte — interpret via {@link FrameFlags}, which is type-specific. */
public int flags() {
return flags;
}
/** Stream identifier, reserved bit already masked. {@code 0} means "the connection itself". */
public int streamId() {
return streamId;
}
/** The backing buffer — see the class Javadoc's lifetime contract before retaining a reference. */
public byte[] buffer() {
return buf;
}
/** Offset of the first payload byte within {@link #buffer()}. Payload spans {@code [payloadOffset(), payloadOffset() + length())}. */
public int payloadOffset() {
return payloadOffset;
}
}
@@ -0,0 +1,113 @@
package dev.relism.flash.http2.frame;
/**
* The 10 HTTP/2 frame types (RFC 9113 §6), plus the shared per-type validation rules {@link
* FrameValidator} enforces. Values above {@code 0x9} are not assigned a constant here RFC 9113
* §4.1 requires unknown types to be silently ignored (read and discard the payload), which {@link
* Http2FrameReader}'s caller implements by checking {@code type > FrameType.maxKnown()} rather than
* by this enum growing an {@code UNKNOWN} member (an {@code UNKNOWN} constant would misleadingly
* suggest "a recognised category of unrecognised frame", when the correct handling is simply "not
* this table, skip it").
*
* <p>Each constant carries the RFC-mandated payload length bounds, whether a zero stream id is
* required/forbidden/either, and whether the frame counts toward the CONTINUATION-flood guard
* (bounded by {@code Http2Limits#MAX_CONTINUATION_FRAMES_PER_BLOCK}) see {@link FrameValidator}
* for how these are applied and the specific RFC citation per rule.
*/
public enum FrameType {
/** RFC 9113 §6.1. Stream body bytes. Stream id required. Length: 0..MAX_FRAME_SIZE. */
DATA(0x0, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
/** RFC 9113 §6.2. Header block fragment (HPACK). Stream id required. */
HEADERS(0x1, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
PRIORITY(0x2, 5, 5, StreamIdRule.REQUIRED),
/** RFC 9113 §6.4. Stream-level error. Exactly 4 bytes (the error code). Stream id required. */
RST_STREAM(0x3, 4, 4, StreamIdRule.REQUIRED),
/**
* RFC 9113 §6.5. Connection-level parameters. Length must be a multiple of 6. Stream id must be
* 0.
*/
SETTINGS(0x4, 0, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
/**
* RFC 9113 §6.6. Never sent (Flash advertises {@code SETTINGS_ENABLE_PUSH=0}); receiving one from
* a client is a protocol error.
*/
PUSH_PROMISE(0x5, 4, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
/**
* RFC 9113 §6.7. Connection liveness / RTT probe. Exactly 8 bytes of opaque data. Stream id must
* be 0.
*/
PING(0x6, 8, 8, StreamIdRule.FORBIDDEN),
/**
* RFC 9113 §6.8. Connection shutdown notice. At least 8 bytes (last-stream-id + error code).
* Stream id must be 0.
*/
GOAWAY(0x7, 8, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
/**
* RFC 9113 §6.9. Flow-control window increment. Exactly 4 bytes. Stream id may be either (0 =
* connection window).
*/
WINDOW_UPDATE(0x8, 4, 4, StreamIdRule.EITHER),
/**
* RFC 9113 §6.10. Continuation of a header block that did not fit one HEADERS/PUSH_PROMISE frame.
* Stream id required.
*/
CONTINUATION(0x9, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED);
/** Whether a frame type requires stream id 0, requires it non-zero, or permits either. */
public enum StreamIdRule {
REQUIRED,
FORBIDDEN,
EITHER
}
private static final FrameType[] BY_CODE = new FrameType[values().length];
static {
for (FrameType t : values()) {
BY_CODE[t.code] = t;
}
}
private final int code;
private final int minLength;
private final int maxLength;
private final StreamIdRule streamIdRule;
FrameType(int code, int minLength, int maxLength, StreamIdRule streamIdRule) {
this.code = code;
this.minLength = minLength;
this.maxLength = maxLength;
this.streamIdRule = streamIdRule;
}
public int code() {
return code;
}
public int minLength() {
return minLength;
}
public int maxLength() {
return maxLength;
}
public StreamIdRule streamIdRule() {
return streamIdRule;
}
/**
* The highest type code this enum recognises anything above must be ignored per RFC 9113 §4.1.
*/
public static int maxKnown() {
return CONTINUATION.code;
}
/**
* Looks up the constant for a wire type byte, or {@code null} if it is an unrecognised
* (to-be-ignored) type.
*/
public static FrameType fromCode(int code) {
return (code >= 0 && code < BY_CODE.length) ? BY_CODE[code] : null;
}
}
@@ -0,0 +1,88 @@
package dev.relism.flash.http2.frame;
import dev.relism.flash.http2.Http2ErrorCode;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
/**
* Table-driven RFC 9113 per-frame-type validation: length bounds, the stream-id
* required/forbidden/either rule, and the two special-cased structural rules ({@code SETTINGS}'
* multiple-of-6 length, {@code PUSH_PROMISE} always rejected from a client) that do not fit a
* class is the code that reads it.
*
* <p><b>The error code is not uniform</b> read the RFC per violation, not just per type. A
* {@code SETTINGS} frame with a bad length is {@code FRAME_SIZE_ERROR}; the same frame with a
* non-zero stream id is {@code PROTOCOL_ERROR}. This class throws the specific code each
* violation's own RFC citation requires, not a single blanket code per type.
*/
public final class FrameValidator {
private FrameValidator() {}
/**
* Validates {@code header} against RFC 9113's rules for its type.
*
* @param insideHeaderBlock whether this frame arrived between a HEADERS/PUSH_PROMISE frame
* lacking {@code END_HEADERS} and its terminating CONTINUATION
* changes the handling of an unrecognised type (§6.10: a
* {@code PROTOCOL_ERROR}, not the usual silent ignore, since an
* in-progress header block cannot tolerate an interloper frame of
* any kind without desynchronizing HPACK's stateful decode)
* @throws Http2Exception on any RFC violation, with the specific error code the violated
* rule mandates
*/
public static void validate(FrameHeader header, boolean insideHeaderBlock) {
FrameType type = header.type();
if (type == null) {
// RFC 9113 §4.1: unknown frame types MUST be ignored except inside an in-progress
// header block (§6.10), where anything other than CONTINUATION desynchronizes HPACK.
if (insideHeaderBlock) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR,
"unrecognised frame type " + header.typeCode() + " received inside a header block");
}
return;
}
int length = header.length();
// RFC 9113 §6.5: a SETTINGS frame's length MUST be a multiple of 6 (each entry is a
// 2-byte identifier + 4-byte value). Checked before the generic bounds below, since the
// generic table only expresses a min/max range, not a modulus.
if (type == FrameType.SETTINGS && length % 6 != 0) {
throw Http2Exception.FRAME_SIZE_ERROR;
}
if (length < type.minLength() || length > type.maxLength()) {
throw Http2Exception.FRAME_SIZE_ERROR;
}
// Redundant with Http2FrameReader's own pre-allocation check for frames it read itself,
// but this method must also be correct for a FrameHeader built any other way (tests,
// and in later phases frames reassembled from multiple reads), so the bound is
// re-asserted here rather than trusted from the caller.
if (length > Http2Limits.MAX_FRAME_SIZE_LOCAL) {
throw Http2Exception.FRAME_SIZE_ERROR;
}
int streamId = header.streamId();
switch (type.streamIdRule()) {
case REQUIRED -> {
if (streamId == 0) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, type + " requires a non-zero stream id");
}
}
case FORBIDDEN -> {
if (streamId != 0) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, type + " must have stream id 0, got " + streamId);
}
}
case EITHER -> { /* WINDOW_UPDATE: 0 (connection window) or non-zero (stream window) both valid */ }
}
// (Flash advertises SETTINGS_ENABLE_PUSH=0 and never sends one); receiving one at all
// means the peer believes it is talking to a client, which is always a protocol error.
if (type == FrameType.PUSH_PROMISE) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, "PUSH_PROMISE received from a client");
}
}
}
@@ -0,0 +1,75 @@
package dev.relism.flash.http2.frame;
import dev.relism.flash.bytes.ByteWriter;
/**
* Serializes HTTP/2 frames into a {@link ByteWriter} scratch buffer with the standard
* length-back-patching technique: {@link #beginFrame} writes a 9-byte header with a placeholder
* length, the caller writes the payload directly through {@link #writer()} (the same
* {@link ByteWriter}), and {@link #endFrame} rewrites the length once it is known the payload
* size is rarely known before it is serialized (an HPACK-encoded header block, in particular,
* has no cheap way to be measured in advance).
*
* issues one bulk {@code write}, rather than streaming bytes as they are produced: streaming
* would require knowing the length <em>before</em> the first byte goes out, which back-patching
* deliberately avoids needing.
*
* <h3>Usage</h3>
* <pre>{@code
* FrameWriteBuffer out = new FrameWriteBuffer(new ByteWriter(4096));
* out.beginFrame(FrameType.SETTINGS, 0, 0);
* out.writer().writeUInt16(SETTINGS_MAX_CONCURRENT_STREAMS);
* out.writer().writeUInt32(100);
* out.endFrame();
* // out.writer().array()[0, out.writer().length()) now holds one complete, correctly-lengthed frame
* }</pre>
*
* <h3>Multiple frames, one buffer</h3>
* {@link #beginFrame}/{@link #endFrame} pairs may be repeated on the same instance without a
* {@link ByteWriter#reset()} between them each pair appends one more complete frame after
* whatever was already written, which is exactly what {@link Http2FrameWriter#write} wants for a
* single bulk write covering several frames (e.g. HEADERS followed immediately by its first
* DATA frame).
*
* <h3>Thread-safety</h3>
* Not thread-safe exactly one writer at a time, the same convention every other per-connection
* scratch object in this codebase follows.
*/
public final class FrameWriteBuffer {
private final ByteWriter writer;
private int headerStart = -1;
public FrameWriteBuffer(ByteWriter writer) {
this.writer = writer;
}
/** The underlying {@link ByteWriter} — write the frame's payload directly through this between {@link #beginFrame} and {@link #endFrame}. */
public ByteWriter writer() {
return writer;
}
/** Writes a 9-byte frame header with a placeholder length, to be filled in by {@link #endFrame}. */
public void beginFrame(FrameType type, int flags, int streamId) {
if (headerStart != -1) {
throw new IllegalStateException("beginFrame() called again before the previous frame's endFrame()");
}
headerStart = writer.length();
writer.writeUInt24(0); // length placeholder
writer.writeByte((byte) type.code());
writer.writeByte((byte) flags);
writer.writeUInt31(streamId);
}
/** Back-patches the length field written by {@link #beginFrame} now that the payload's size is known. */
public void endFrame() {
if (headerStart == -1) {
throw new IllegalStateException("endFrame() called without a matching beginFrame()");
}
int payloadLength = writer.length() - (headerStart + 9);
byte[] buf = writer.array();
buf[headerStart] = (byte) (payloadLength >>> 16);
buf[headerStart + 1] = (byte) (payloadLength >>> 8);
buf[headerStart + 2] = (byte) payloadLength;
headerStart = -1;
}
}
@@ -0,0 +1,160 @@
package dev.relism.flash.http2.frame;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.EOFException;
import java.io.IOException;
import java.util.Arrays;
/**
* Reads length-prefixed HTTP/2 frames from one connection's {@link BufferedByteSource}. Simpler
* than {@code RequestParser} by construction: HTTP/2 frames declare their length up front (the
* 9-byte header), so nothing is ever scanned for {@code Http2FrameReader} only ever needs to know
* "do I have N bytes yet", never "where does this end".
*
* <h3>Buffer discipline</h3>
*
* One growable {@code byte[]} per connection, reused across every frame the same
* compact-before-grow discipline {@code RequestParser}'s own buffer uses. A frame's declared length
* is checked against {@link Http2Limits#MAX_FRAME_SIZE_LOCAL} <em>before</em> the buffer
* length-check, not after an allocation already paid for it.
*
* <h3>Usage</h3>
*
* <pre>{@code
* FrameHeader header = reader.readFrame();
* if (header == null) { /* clean EOF between frames connection closing *\/ }
* // ... process header.buffer()[header.payloadOffset(), +header.length()) ...
* reader.consumeFrame(); // MUST be called before the next readFrame()
* }</pre>
*
* <h3>Thread-safety</h3>
*
* Not thread-safe exactly one virtual thread (the connection's demux loop) ever calls this, the
* same invariant every other per-connection reader in this codebase assumes.
*/
public final class Http2FrameReader {
private static final int FRAME_HEADER_SIZE = 9;
private static final int INITIAL_BUFFER_SIZE = 16 * 1024;
private final BufferedByteSource in;
private final FrameHeader header = new FrameHeader();
private byte[] buffer;
private int base; // offset of the first unconsumed byte
private int totalRead; // count of valid unconsumed bytes at [base, base + totalRead)
private long frameDeadlineNanos;
public Http2FrameReader(BufferedByteSource in) {
this(in, INITIAL_BUFFER_SIZE);
}
public Http2FrameReader(BufferedByteSource in, int initialBufferSize) {
this.in = in;
this.buffer = new byte[Math.max(initialBufferSize, FRAME_HEADER_SIZE)];
}
/**
* Reads the next frame's header and payload, bounded by {@link
* Http2Limits#FRAME_READ_TIMEOUT_MS}, and returns the reused {@link FrameHeader} flyweight
* positioned over it or {@code null} on a clean EOF between frames (the peer closed the
* connection while nothing was in flight; not an error).
*
* <p>The caller MUST call {@link #consumeFrame()} exactly once after processing this frame (or
* deciding to discard it) and before calling this method again.
*
* @throws Http2Exception if the declared length exceeds {@link Http2Limits#MAX_FRAME_SIZE_LOCAL}
* @throws EOFException if the connection closes after a frame has already started arriving
* @throws java.net.SocketTimeoutException if {@link Http2Limits#FRAME_READ_TIMEOUT_MS} elapses
*/
public FrameHeader readFrame() throws IOException {
return readFrame(Http2Limits.FRAME_READ_TIMEOUT_MS);
}
/** Reads one frame using a caller-supplied upper bound for this frame's absolute deadline. */
public FrameHeader readFrame(long timeoutMs) throws IOException {
if (timeoutMs <= 0) throw new IllegalArgumentException("timeoutMs must be positive");
long now = System.nanoTime();
if (frameDeadlineNanos == 0) {
frameDeadlineNanos = now + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L;
}
in.setDeadline(Math.min(frameDeadlineNanos, now + timeoutMs * 1_000_000L));
try {
if (!ensureAvailable(FRAME_HEADER_SIZE)) {
frameDeadlineNanos = 0;
return null; // clean EOF: nothing buffered yet, peer closed between frames
}
int declaredLength = decodeLength(buffer, base);
// never causes an oversized allocation, only a rejection.
if (declaredLength > Http2Limits.MAX_FRAME_SIZE_LOCAL) {
throw Http2Exception.FRAME_SIZE_ERROR;
}
ensureAvailable(FRAME_HEADER_SIZE + declaredLength);
header.reset(buffer, base);
return header;
} catch (java.net.SocketTimeoutException timeout) {
if (totalRead == 0) frameDeadlineNanos = 0;
throw timeout;
} finally {
in.clearDeadline();
}
}
/** Advances past the frame last returned by {@link #readFrame()}. Zero-copy, zero-allocation. */
public void consumeFrame() {
int consumed = FRAME_HEADER_SIZE + header.length();
base += consumed;
totalRead -= consumed;
if (totalRead == 0) {
base = 0; // nothing buffered reset to the front rather than drifting forever
}
frameDeadlineNanos = 0;
}
/** Whether a partially received frame exhausted its non-renewable absolute deadline. */
public boolean frameDeadlineExpired() {
return totalRead != 0 && System.nanoTime() >= frameDeadlineNanos;
}
/** Whether another frame may be consumed immediately without waiting for network input. */
public boolean hasBufferedInput() {
return totalRead != 0 || in.available() != 0;
}
private static int decodeLength(byte[] buf, int off) {
int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF;
return (b0 << 16) | (b1 << 8) | b2;
}
/**
* Ensures at least {@code need} bytes are available starting at {@link #base}, growing or
* compacting the buffer as necessary. Returns {@code false} only for a clean EOF with nothing at
* all buffered yet (the between-frames case); an EOF after any bytes of the current frame have
* already arrived is a genuine truncation and throws.
*/
private boolean ensureAvailable(int need) throws IOException {
while (totalRead < need) {
if (base + need > buffer.length) {
if (base > 0) {
// Compact: slide unconsumed bytes to the front frees room without growing.
System.arraycopy(buffer, base, buffer, 0, totalRead);
base = 0;
} else {
// need <= 9 + MAX_FRAME_SIZE_LOCAL always, by readFrame()'s own check before
// the payload-sized call grow exactly enough, never unbounded.
int grown = buffer.length;
while (grown < need) grown *= 2;
buffer = Arrays.copyOf(buffer, grown);
}
}
int n = in.read(buffer, base + totalRead, buffer.length - base - totalRead);
if (n < 0) {
if (totalRead == 0) return false;
throw new EOFException(
"connection closed mid-frame (" + totalRead + "/" + need + " bytes read)");
}
totalRead += n;
}
return true;
}
}
@@ -0,0 +1,283 @@
package dev.relism.flash.http2.frame;
import dev.relism.flash.http2.Http2Limits;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.ReentrantLock;
/**
* The one component every HTTP/2 write in this codebase passes through connection frames and
* stream frames alike (both are just {@link WriteIntent}s). Its entire job is serializing
* concurrent access to one connection's socket write side as cheaply as physically possible,
* because under multiplexing every stream on a connection shares that one socket.
*
* <h2>The design, three layers</h2>
*
* <p><b>Layer 1 serialize outside the lock.</b> By the time {@link #write} is called, the caller
* has already built its complete frame into a buffer it owns (see {@link WriteIntent}). This writer
* never serializes anything; it only ever issues one bulk {@code sink.write(buffer, offset,
* length)} call while holding the lock never many small writes, which would turn "hold the lock"
* into "hold the lock across a serialization pass."
*
* <p><b>Layer 2 {@link ReentrantLock}, never {@code synchronized}.</b> On Java 21, a virtual
* thread blocking inside {@code synchronized} pins its carrier platform thread; blocking on a
* {@link ReentrantLock} unmounts it instead (JEP 491, which removes the {@code synchronized} {@code
* ReentrantLock} is also load-bearing here for a second reason {@code synchronized} cannot offer:
* {@link ReentrantLock#tryLock()}.
*
* <p><b>Layer 3 {@code tryLock()} fast path, intrusive MPSC fallback.</b> The overwhelmingly
* common case, even on a genuinely multiplexed connection, is exactly one stream wanting to write
* at a given instant. {@code tryLock()} on an uncontended lock is one successful CAS; the calling
* thread writes inline and releases no handoff, no queue touched, no allocation, no context
* switch. Only when {@code tryLock()} fails (genuine contention) does the intent get published
* through {@link IntrusiveMpscQueue} (one more CAS, still zero allocation the intent itself is
* the queue node) for the current lock holder to drain.
*
* <h3>Lost-wakeup avoidance</h3>
*
* The classic hazard: a producer offers its intent to the queue at the exact moment the current
* holder has just found the queue empty and is about to unlock the item would be stranded with
* nobody left to drain it. This is closed by two cooperating checks, and the correctness argument
* for why together they are sufficient is a happens-before chain through the queue's {@code
* AtomicReference} and the lock's own acquire/release ordering (recorded in full in {@code
* WRITER.md}, since it is exactly the kind of reasoning a future reader must be able to re-derive,
* not just trust):
*
* <pre>
* write(intent):
* if tryLock() succeeds: // 1 CAS, the fast path
* drive(intent) // write intent directly, then drain the queue, then unlock
* else:
* queue.offer(intent) // 1 CAS, zero allocation
* if tryLock() succeeds: // the producer's own second chance
* drive(null) // drain whatever is queued, including our own intent
*
* drive(firstIntentOrNull):
* write firstIntentOrNull if present, then poll-and-write until the queue is empty
* unlock()
* while queue.hasWork(): // the re-check-after-unlock that closes the race
* if !tryLock(): break // someone else is now responsible; their own recheck covers us
* poll-and-write until empty
* unlock()
* </pre>
*
* A frame's bytes are never interleaved with another frame's bytes: every write of one intent is a
* single {@code sink.write} call issued while holding the lock, and the lock is not released
* between a {@code WriteIntent}'s bytes.
*
* <h3>Write timeout</h3>
*
* A blocking write is unavoidable when the kernel send buffer is full and the peer is not reading
* whoever holds the lock is blocked in the syscall, holding up every other stream on the
* connection. This is bounded by {@link Http2Limits#WRITE_TIMEOUT_MS}, enforced by a shared
* background reaper ({@link WriteTimeoutReaper}) that interrupts the blocked thread past the
* deadline {@code Socket#setSoTimeout} bounds reads, not writes, so it cannot be used here.
* connection setup), so arming/disarming the deadline for each individual write is two {@code
* volatile} field writes, not an allocation.
*/
public final class Http2FrameWriter {
/**
* What a frame's serialized bytes are ultimately written to. Kept minimal and separate from
* {@code java.io.OutputStream} so this class is testable without a real socket.
*/
public interface Sink {
void write(byte[] buf, int off, int len) throws IOException;
}
private final Sink sink;
private final long writeTimeoutMs;
private final ReentrantLock lock = new ReentrantLock();
private final IntrusiveMpscQueue priorityQueue = new IntrusiveMpscQueue();
private final IntrusiveMpscQueue queue = new IntrusiveMpscQueue();
// Set only for the duration of an in-flight sink.write() call; see WriteTimeoutReaper. A
// single volatile write to arm, one to disarm no timestamp is recorded here (see the
// reaper's own Javadoc for why: a per-write System.nanoTime() call measurably missed the
// N=1 gate's 50 ns overhead budget when this was first benchmarked, recorded in WRITER.md).
private volatile Thread writingThread;
public Http2FrameWriter(Sink sink) {
this(sink, Http2Limits.WRITE_TIMEOUT_MS);
}
public Http2FrameWriter(Sink sink, long writeTimeoutMs) {
this.sink = sink;
this.writeTimeoutMs = writeTimeoutMs;
WriteTimeoutReaper.register(this);
}
/**
* Serializes and writes one frame. Returns when the bytes are in the socket buffer or safely
* queued behind another writer. Never blocks on another stream's I/O while holding the lock for
* longer than that stream's own single bulk write.
*
* <p><b>Why the fast path is gated on {@code !queue.hasWork()}, not just {@code tryLock()}</b>
* Writing {@code intent} immediately, before anything already queued, is only safe when nothing
* is already queued. Without the {@code hasWork()} check, this sequence is possible and
* violates same-producer ordering, which the stress test asserts: a producer's {@code write(a)}
* then {@code write(b)} contends and both get queued (fire-and-forget); the current holder is
* about to drain them but has not yet; that producer's very next call, {@code write(c)}, finds
* the lock free (the holder released it between the producer's calls) and would otherwise write
* {@code c} directly landing on the wire before {@code a} and {@code b}, which are still
* sitting in the queue. Checking {@code hasWork()} first means "bypass the queue" only happens
* when the queue is observed genuinely empty, i.e. everything previously offered by any
* producer has already been written; see {@code WRITER.md} for the full argument.
*/
public void write(WriteIntent intent) throws IOException {
if (!priorityQueue.hasWork() && !queue.hasWork() && lock.tryLock()) {
drive(intent);
} else {
queue.offer(intent);
if (lock.tryLock()) {
drive(null);
}
}
}
/**
* Writes a connection-control frame ahead of queued stream data. An already executing socket
* write is never interrupted, but once it completes the priority queue is drained before the
* ordinary queue. This is used for PING acknowledgements, SETTINGS acknowledgements, GOAWAY and
* RST_STREAM.
*/
public void writePriority(WriteIntent intent) throws IOException {
priorityQueue.offer(intent);
if (lock.tryLock()) {
drive(null);
}
}
/**
* Flushes any queued intents. Called by the demux loop when it has nothing left to read a no-op
* on the (overwhelmingly common) fast path where nothing is queued.
*/
public void drain() throws IOException {
if (!priorityQueue.hasWork() && !queue.hasWork()) return;
if (lock.tryLock()) {
drive(null);
}
}
/**
* Deregisters this writer from the write-timeout reaper. Call once, when the connection closes.
*/
public void close() {
WriteTimeoutReaper.unregister(this);
}
private void drive(WriteIntent firstIntentOrNull) throws IOException {
try {
if (firstIntentOrNull != null) writeDirect(firstIntentOrNull);
drainQueues();
} finally {
lock.unlock();
}
// Lost-wakeup fix: re-check after unlocking, looping because this cycle itself can race
// the same way see the class Javadoc for the correctness argument.
while (priorityQueue.hasWork() || queue.hasWork()) {
if (!lock.tryLock()) break;
try {
drainQueues();
} finally {
lock.unlock();
}
}
}
private void drainQueues() throws IOException {
WriteIntent next;
while (true) {
while ((next = priorityQueue.poll()) != null) {
writeDirect(next);
}
next = queue.poll();
if (next == null) return;
writeDirect(next);
}
}
private void writeDirect(WriteIntent intent) throws IOException {
writingThread = Thread.currentThread();
try {
sink.write(intent.buffer(), intent.offset(), intent.length());
} catch (IOException e) {
if (Thread.interrupted()) {
InterruptedIOException timeout =
new InterruptedIOException("HTTP/2 write timed out after ~" + writeTimeoutMs + " ms");
timeout.initCause(e);
throw timeout;
}
throw e;
} finally {
writingThread = null;
Thread.interrupted(); // clear a stray interrupt flag defensively before returning control
intent.completed();
}
}
/**
* A single shared daemon thread scanning every registered {@link Http2FrameWriter} for a blocking
* write that has overrun {@link Http2Limits#WRITE_TIMEOUT_MS}. One thread for the whole process
* (like {@code DateHeader}'s refresher), not one per connection registration per-write one.
*
* <p>Deliberately does <em>not</em> ask each write to record a {@code System.nanoTime()} {@code
* nanoTime()} call (plus the extra volatile field it required) costing enough to miss the N=1
* gate's 50 ns-over-baseline budget (recorded in {@code WRITER.md}). Instead, the reaper counts
* <em>consecutive scans</em> a given writer has been observed still blocked ({@link
* #writingThread} non-null); a writer blocked for more than {@code WRITE_TIMEOUT_MS /
* SCAN_INTERVAL_MS} consecutive scans is interrupted. This trades a little precision (up to one
* scan interval of slop already inherent to any background-reaper design) for removing all
* per-write timing cost.
*/
static final class WriteTimeoutReaper {
private static final long SCAN_INTERVAL_MS = 50;
private static final Set<Http2FrameWriter> ACTIVE = ConcurrentHashMap.newKeySet();
// Touched only by the single reaper thread -- no synchronization needed.
private static final java.util.Map<Http2FrameWriter, Integer> BLOCKED_SCAN_COUNTS =
new java.util.IdentityHashMap<>();
static {
Thread reaper =
new Thread(
() -> {
while (true) {
try {
Thread.sleep(SCAN_INTERVAL_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
for (Http2FrameWriter writer : ACTIVE) {
Thread t = writer.writingThread;
if (t == null) {
BLOCKED_SCAN_COUNTS.remove(writer);
continue;
}
int scans = BLOCKED_SCAN_COUNTS.merge(writer, 1, Integer::sum);
long thresholdScans = Math.max(1, writer.writeTimeoutMs / SCAN_INTERVAL_MS);
if (scans >= thresholdScans) {
t.interrupt();
BLOCKED_SCAN_COUNTS.remove(writer);
}
}
}
},
"flash-http2-write-timeout-reaper");
reaper.setDaemon(true);
reaper.start();
}
private WriteTimeoutReaper() {}
static void register(Http2FrameWriter writer) {
ACTIVE.add(writer);
}
static void unregister(Http2FrameWriter writer) {
ACTIVE.remove(writer);
}
}
}
@@ -0,0 +1,101 @@
package dev.relism.flash.http2.frame;
import java.util.concurrent.atomic.AtomicReference;
/**
* A Vyukov-style intrusive multi-producer, single-consumer queue of {@link WriteIntent}s.
* "Intrusive" means the queued object <em>is</em> the node {@link WriteIntent#mpscNext()} /
* {@link WriteIntent#setMpscNext} supply the linkage so {@link #offer} allocates nothing: one
* {@link AtomicReference#getAndSet} CAS and that is the entire cost.
*
* <h3>Only {@link Http2FrameWriter} calls {@link #poll()}</h3>
* This queue is safe for any number of concurrent {@link #offer} callers, but {@link #poll()}
* must only ever be called by the single thread currently holding the writer's lock exactly
* the invariant {@code Http2FrameWriter} maintains (it never calls {@code poll()} without
* holding the lock). Calling {@code poll()} from two threads concurrently is undefined.
*
* <h3>The stub node and the "inconsistent" result</h3>
* The queue always contains at least one node a private, singleton {@code stub} which lets
* {@link #offer} and {@link #poll} both proceed without ever observing a literal {@code null}
* head. A subtlety of this algorithm (documented here because it surprises readers unfamiliar
* with it, and it is the reason {@code Http2FrameWriter}'s drain loop is itself a loop, not a
* single pass): {@link #poll()} can return {@code null} even when {@link #offer} has completed
* and is "logically" enqueued, if that producer's {@code getAndSet} (which publishes the new
* tail pointer) has completed but its following {@code setMpscNext} (which links the *previous*
* tail to it) has not yet landed. This is a momentary, self-correcting race the next
* {@code poll()} call (even from the same thread, immediately after) will see it never a
* permanent loss. {@code Http2FrameWriter}'s lost-wakeup-avoidance protocol (see its Javadoc)
* already retries in exactly the way this requires.
*/
final class IntrusiveMpscQueue {
/**
* Sentinel node that is never returned by {@link #poll()} and never appears anywhere except
* internally. Its own {@code mpscNext} field is the only piece of mutable state on it.
*/
private static final class Stub implements WriteIntent {
private volatile WriteIntent next;
@Override public byte[] buffer() { throw new UnsupportedOperationException("stub node"); }
@Override public int offset() { throw new UnsupportedOperationException("stub node"); }
@Override public int length() { throw new UnsupportedOperationException("stub node"); }
@Override public WriteIntent mpscNext() { return next; }
@Override public void setMpscNext(WriteIntent next) { this.next = next; }
}
private final Stub stub = new Stub();
private final AtomicReference<WriteIntent> head = new AtomicReference<>(stub);
private WriteIntent tail = stub; // consumer-only; never touched by offer()
/** Enqueues {@code node}. Safe from any number of concurrent threads. Zero allocation. */
void offer(WriteIntent node) {
node.setMpscNext(null);
WriteIntent prev = head.getAndSet(node);
prev.setMpscNext(node);
}
/**
* Dequeues the next intent, or {@code null} if the queue is empty <em>or</em> a producer is
* momentarily mid-{@link #offer} see the class Javadoc. Single-consumer only.
*/
WriteIntent poll() {
WriteIntent t = tail;
WriteIntent next = t.mpscNext();
if (t == stub) {
if (next == null) {
return null; // genuinely empty
}
tail = next;
t = next;
next = t.mpscNext();
}
if (next != null) {
tail = next;
return t;
}
WriteIntent h = head.get();
if (t != h) {
return null; // producer mid-offer; momentary, retry later
}
// t is the last real node and head hasn't moved past it: park the stub here so the
// next poll() (once a future offer() lands) has somewhere to advance from, then check
// whether t already gained a follower while we were doing this.
offer(stub);
next = t.mpscNext();
if (next != null) {
tail = next;
return t;
}
return null;
}
/** Cheap, conservative "might there be work" check never a false negative, may be a false
* positive (harmless: the caller just attempts a {@code tryLock()} that finds nothing). */
boolean hasWork() {
return head.get() != tail;
}
}
@@ -0,0 +1,66 @@
package dev.relism.flash.http2.frame;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.http2.Http2ErrorCode;
import dev.relism.flash.http2.Http2Exception;
/**
* RFC 9113 §6.1 (DATA) / §6.2 (HEADERS) padding. When {@link FrameFlags#PADDED} is set, a
* frame's payload is laid out as: 1 pad-length byte, then the actual data (or header-block
* fragment), then that many padding bytes (RFC 9113 gives no meaning to the padding bytes
* themselves they exist only to obscure payload size from network observers).
*
* <p>Padding is <b>not optional to support</b>: any client may send it on DATA or HEADERS
* regardless of whether the server ever sends padded frames itself.
*
* <h3>Flow control (forward note, not implemented here)</h3>
* RFC 9113 §6.9.1: padding bytes count against the DATA flow-control window even though they
* carry no data the <em>whole</em> frame payload (pad-length byte + data + padding) is what a
* #dataLength(long)}. This class only locates the data range within the payload; it performs no
* flow-control accounting itself.
*/
public final class Padding {
private Padding() {}
/**
* Locates the actual data range within a payload that may or may not be padded. When
* {@code padded} is {@code false}, returns the whole payload unchanged (zero-cost no
* padding byte to read, no arithmetic beyond the pack). When {@code true}, reads the
* pad-length byte at {@code buf[payloadOffset]}, validates it, and returns the data range
* that follows it.
*
* @return {@code Pairs.pack(dataOffset, dataLength)} unpack with {@link Pairs#hi}/{@link Pairs#lo}
* @throws Http2Exception ({@code PROTOCOL_ERROR}) if {@code padded} is set but
* {@code payloadLength == 0} (no room for the pad-length byte itself), or if the
* claimed pad length is greater than or equal to the whole payload length (RFC 9113
* §6.1: "If the length of the padding is the length of the frame payload or
* greater, the recipient MUST treat this as a connection error")
*/
public static long unpad(byte[] buf, int payloadOffset, int payloadLength, boolean padded) {
if (!padded) {
return Pairs.pack(payloadOffset, payloadLength);
}
if (payloadLength == 0) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR,
"PADDED flag set but the frame has no payload for the pad-length byte");
}
int padLength = buf[payloadOffset] & 0xFF;
if (padLength >= payloadLength) {
throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR,
"pad length " + padLength + " >= frame payload length " + payloadLength);
}
int dataOffset = payloadOffset + 1;
int dataLength = payloadLength - 1 - padLength;
return Pairs.pack(dataOffset, dataLength);
}
/** Extracts the data offset from a value returned by {@link #unpad}. */
public static int dataOffset(long unpadded) {
return Pairs.hi(unpadded);
}
/** Extracts the data length from a value returned by {@link #unpad}. */
public static int dataLength(long unpadded) {
return Pairs.lo(unpadded);
}
}
@@ -0,0 +1,49 @@
package dev.relism.flash.http2.frame;
/**
* "Serialize yourself, then hand me the finished bytes." The interface a stream (and, eventually,
* connection-level singletons the precompiled SETTINGS ACK, PING ACK, GOAWAY, WINDOW_UPDATE
* frames) implements to write through {@link Http2FrameWriter}.
*
* <h3>Layer 1 serialize outside the lock</h3>
*
* By the time {@link Http2FrameWriter#write} is called, the implementation has already built its
* complete output (frame header + HPACK block + payload, or whatever the frame needs) into a buffer
* it owns a per-stream scratch buffer, reused across writes, never allocated per call. {@link
* #buffer()}/{@link #offset()}/{@link #length()} just describe where that already-finished output
* lives. {@code Http2FrameWriter} never serializes anything itself; it only ever issues one bulk
* {@code write(buffer, offset, length)} while holding the connection's write lock see {@code
* WRITER.md} for why that distinction is the entire point of this design (the lock must never be
* held across serialization work, only across the syscall).
*
* <h3>Intrusive queue linkage</h3>
*
* {@link #mpscNext()}/{@link #setMpscNext} are not part of the writer's public contract they
* exist so a {@code WriteIntent} can double as an {@link IntrusiveMpscQueue} node with zero extra
* allocation when the writer is contended. Implementations provide simple field storage; nothing
* about the field is meaningful outside {@link IntrusiveMpscQueue}.
*/
public interface WriteIntent {
/** The buffer holding this intent's already-serialized bytes. */
byte[] buffer();
/** Offset of the first byte to write, within {@link #buffer()}. */
int offset();
/** Number of bytes to write, starting at {@link #offset()}. */
int length();
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
WriteIntent mpscNext();
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
void setMpscNext(WriteIntent next);
/**
* Called exactly once after this intent leaves the writer, whether the socket write succeeded or
* failed. Pooled control-frame intents use this hook to return their slot to the owning
* connection without allocating a completion object.
*/
default void completed() {}
}
@@ -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);
}
}

Some files were not shown because too many files have changed in this diff Show More