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

Merged
Relism merged 23 commits from feature/core/http2 into master 2026-08-14 18:20:30 +00:00
13 changed files with 3968 additions and 0 deletions
Showing only changes of commit db6e4a4d0c - Show all commits
+282
View File
@@ -0,0 +1,282 @@
# Flash HTTP/2 — Decision Log
This is the living record of every non-obvious choice made while implementing
`flash/docs/http2/IMPLEMENTATION-PLAN.md`. It is not a changelog of what was built — the git
history is that — it is a record of *why*, for choices that were not forced by the RFC and that
a future reader would otherwise have to re-derive or, worse, silently re-litigate.
Every entry: **Context / Options / Decision / Consequence / Revisit when**.
Seeded at Phase 0 with `DEC-01``DEC-10` (the decisions already implied by the plan itself, per
Appendix A). Every subsequent non-obvious choice appends a new entry with the next free number.
Numbers are never reused, even if a decision is later reversed — the reversal gets its own entry
that supersedes the earlier one and says so explicitly.
---
## DEC-01 — HTTP/2 lives in `flash` core, package `dev.relism.flash.h2`, not an extension
**Context.** Flash has an extension mechanism (`flash-ext-*` modules) for optional
functionality. HTTP/2 could in principle be shipped as `flash-ext-h2`.
**Options.**
1. Ship as an extension, loaded optionally.
2. Ship in `flash` core, alongside HTTP/1.1.
**Decision.** Core (option 2).
**Consequence.** The protocol decision (h1 vs h2) is made once, immediately after
ALPN/preface detection, inside the transport layer. `HttpServer` (and its Phase 2 replacement)
is package-private to `flash` core; an extension cannot hook into ALPN negotiation or the
accept loop without core exposing seams it does not otherwise need. HTTP/2 is a transport
concern in the same sense HTTP/1.1 is — it cannot be optional in the way, say, an OpenAPI
generator is.
**Revisit when.** Never, absent a restructuring of the extension mechanism itself to support
transport-level extensions (not currently planned).
---
## DEC-02 — h1 and h2 are peers behind a `ConnectionProtocol` seam, never flags in shared code
**Context.** The obvious shortcut is `if (isHttp2) { ... } else { ... }` scattered through the
existing HTTP/1.1 code paths.
**Options.**
1. Flag-branch inside shared code.
2. A `ConnectionProtocol` interface with two implementations (`Http1Connection`,
`Http2Connection`), selected once per connection.
**Decision.** Option 2 (R1).
**Consequence.** Shared code (byte scanning, the writer discipline, `Request`/`Response`) is
extracted upward into protocol-neutral components (`dev.relism.flash.bytes`,
`ResponseSerializer`), never pushed sideways with a protocol flag. This is enforced by an
architecture test (Phase 2) asserting `dev.relism.flash.http1` never references
`dev.relism.flash.h2` and vice versa. The cost is more up-front extraction work in Phase 2 and
Phase 6; the benefit is that h1 throughput cannot regress from an `if` that the JIT fails to
eliminate, and that either implementation can be read in isolation.
**Revisit when.** Never — this is a structural invariant, not a tunable.
---
## DEC-03 — `ReentrantLock` everywhere, never `synchronized` around blocking I/O
**Context.** Java 21 (this project's baseline) has virtual threads (JEP 444) but not JEP 491
(which removes `synchronized` carrier-pinning); JEP 491 lands in JDK 24. A virtual thread that
blocks inside a `synchronized` block pins its carrier platform thread for the duration of the
block, including any blocking I/O inside it.
**Options.**
1. Keep `synchronized` where it already exists (`WebSocketSession`, `EX-01`) and accept the
pinning risk.
2. Replace every `synchronized` block that can block on I/O with `java.util.concurrent.locks
.ReentrantLock`, which unmounts a blocked virtual thread instead of pinning its carrier.
**Decision.** Option 2, applied retroactively to the existing WebSocket code (Phase 2) and as a
standing rule for every future connection-writer path, most importantly `Http2FrameWriter`
(Phase 3).
**Consequence.** One virtual thread blocking on a slow write no longer starves the carrier pool
for every other connection scheduled onto that carrier. The cost is that `ReentrantLock` is
slightly more expensive than an uncontended `synchronized` monitor in the*platform-thread* case
— irrelevant here, since every request-serving thread in this codebase is virtual.
**Revisit when.** The project's Java baseline moves to JDK 24+ and JEP 491 is confirmed to
remove pinning for `synchronized`. Even then, `ReentrantLock`'s explicit `tryLock()` — which
`synchronized` cannot offer — is load-bearing for Phase 3's writer design, so this decision
would only partially reverse.
---
## DEC-04 — The HPACK **encoder** uses the static table only; no dynamic table
**Context.** RFC 7541's dynamic table is optional for an encoder (a decoder must always
support the peer using one; nothing requires the encoder to use one itself). Using it on the
encode side would save bytes on repeated headers (e.g. a constant `server` value) but requires
mutable, connection-shared state: an insertion changes indices for every subsequent encode on
that connection.
**Options.**
1. Encoder uses the dynamic table, saving bytes on repeated custom headers.
2. Encoder emits only Indexed (static) and Literal-Without-Indexing representations; no dynamic
table, no mutable encoder state.
**Decision.** Option 2.
**Consequence.** The write path — already the project's largest architectural risk (Phase 3) —
needs no shared-table lock and no invalidation protocol across concurrently-writing streams.
The cost is a few extra bytes per response for headers that do not already have a static-table
entry (i.e. everything except the ~30 header names RFC 7541 Appendix A knows about). The
encoder still honours the peer's `SETTINGS_HEADER_TABLE_SIZE` by sending a Dynamic Table Size
Update of 0 at the start of the first header block, declaring "I will never use this table" —
a correctness detail, not optional politeness (Phase 9 task 1).
**Revisit when.** Benchmark evidence (Phase 17) shows the extra wire bytes materially hurt
throughput or latency on a realistic workload — not before. A shared dynamic table is a
non-trivial correctness surface (see `DEC-06`'s discussion of the analogous decode-side hazard)
and should only be taken on with a measured reason.
---
## DEC-05 — Huffman-encode constants at boot; emit runtime values as raw literals
**Context.** HPACK lets the encoder Huffman-code any string at its option. Constants (status
lines, `content-type` values) are a closed, known set and can be Huffman-encoded once, at class
initialization, for free at runtime. Runtime-generated values (a dynamic `ETag`, a user-set
custom header) would need to be Huffman-encoded on every response.
**Options.**
1. Huffman-encode everything, including runtime values, on every write.
2. Huffman-encode only boot-time constants; emit runtime values as raw (uncompressed) literals.
**Decision.** Option 2, with `FlashConfiguration.h2HuffmanDynamicValues` (default `false`) so
option 1's cost/benefit can actually be measured on real traffic rather than argued about in
the abstract.
**Consequence.** The response write path's critical section has no per-byte Huffman encode
loop for the common case. The cost is a few extra bytes on the wire for runtime header values,
which HPACK's other mechanisms (indexing on the receive side, if the receiver chooses to use
its dynamic table) can still partially recover.
**Revisit when.** Phase 17 benchmarks the flag both ways on a representative response shape.
---
## DEC-06 — Decoded headers are copied into a **per-stream** arena, not referenced in the dynamic table
**Context.** A `ByteView` into the HPACK dynamic table's arena is valid only while its entry is
still live. Under HTTP/1.1 this is trivially safe (one thread, one request at a time). Under
HTTP/2, the demux thread can decode a second stream's HEADERS — evicting and overwriting
dynamic-table arena bytes — while a handler on a different virtual thread is still reading a
view produced by an earlier decode. This is a genuine, silent data race: it does not manifest
in any test that decodes one block at a time, only under real multiplexed load.
**Options.**
1. Reference dynamic-table entries directly from decoded `ByteView`s, and protect them with an
epoch or reference-count scheme so an entry cannot be evicted while still referenced.
2. Copy every decoded header (name and value) into an arena owned by the stream being
assembled, at decode time. One `~30`-byte-average `memcpy` per header; correctness by
construction, no cross-thread coordination.
**Decision.** Option 2.
**Consequence.** Header decode is not zero-copy relative to the dynamic table (R3's "honest
naming" clause applies: HTTP/2 copies each novel header once per connection and references it
by index thereafter — the per-stream arena copy is that one copy). In exchange, no handler can
ever observe a torn or evicted header value, and the demux thread never needs to coordinate
with a handler thread to decode the next block. Per-stream arenas are pooled (returned on
stream close) so this is zero allocation at steady state despite the copy.
**Revisit when.** Profiling (Phase 17) shows the per-header copy is a measurable cost on a
realistic HPACK-heavy workload. Even then, option 1's concurrent bookkeeping is a large
correctness surface to take on to avoid a small `memcpy`, and should not be revisited casually.
---
## DEC-07 — `:authority` is exposed to user code as both `:authority` and `host`
**Context.** HTTP/2 requests carry authority information in the `:authority` pseudo-header
(RFC 9113 §8.3.1), not a `Host` header — `host` may optionally also be present and, if so, must
match `:authority`, but is not required. Existing Flash middleware (and most middleware in the
wild) reads `Host` by convention, inherited from HTTP/1.1.
**Options.**
1. Expose only `:authority`, under whatever name the h2 header map uses for pseudo-headers.
Middleware written against `Host` silently breaks on h2.
2. Expose `:authority`'s value under both keys: the literal `:authority` and `host`.
**Decision.** Option 2.
**Consequence.** A single small duplication (one extra index entry into the same per-stream
arena bytes — no extra copy) buys behavioural parity for existing and future middleware that
reads `Host`, without requiring every middleware author to special-case h2. Documented in
`flash/docs/http2/STREAMS.md`.
**Revisit when.** Not planned to be revisited; this is a compatibility shim with negligible
cost, not a design compromise under pressure.
---
## DEC-08 — Flash ships HTTP/2, not a gRPC codec
**Context.** gRPC is one of the strongest motivations for HTTP/2 support (Pathway's upstream
use case), and it is tempting to let that motivation expand scope into shipping gRPC framing,
proto codecs, or a service-definition layer.
**Options.**
1. Ship a gRPC codec/framework alongside HTTP/2 transport support.
2. Ship HTTP/2 transport only; validate gRPC compatibility with an interop test, not a feature.
**Decision.** Option 2.
**Consequence.** Phase 12's `GrpcInteropTest` proves that the protocol features gRPC actually
needs — trailers, `content-type: application/grpc`, `te: trailers`, half-close, streaming — are
present and correct, using a real gRPC client against a hand-written Flash handler that speaks
the wire format directly. Flash does not gain a dependency on any gRPC/protobuf library, and
users who want a gRPC service framework build it on top of Flash rather than being handed one.
**Revisit when.** Not planned to be revisited; this is a scope boundary, not a temporary
limitation.
---
## DEC-09 — The chosen `Http2FrameWriter` design, with its benchmark numbers
**Status.** Not yet decided — this entry is a placeholder until Phase 3 runs its gate. Phase 3
benchmarks three candidate writer designs ((a) plain `ReentrantLock.lock()` per frame,
(b) `tryLock()` + intrusive MPSC, (c) a dedicated writer virtual thread fed by an MPSC queue)
against the numeric gate criteria in the plan (0 B/op and <50 ns overhead at N=1; ≥60% of the
N=1 per-thread aggregate throughput and <1 ms p999 at N=64; no carrier pinning). This entry is
filled in with the winning design and the raw numbers when Phase 3 completes, or with the
failure and the redesign taken if no candidate meets the gate.
**Revisit when.** N/A until Phase 3 lands.
---
## DEC-10 — `Upgrade: h2c` is deliberately **not** implemented
**Context.** RFC 7540 §3.2 (the original HTTP/2 RFC) defined an `Upgrade: h2c` mechanism to
move a plaintext HTTP/1.1 connection to HTTP/2 mid-connection. RFC 9113 (which obsoletes
RFC 7540) §3.1 removes this mechanism entirely from the current specification.
**Options.**
1. Implement `Upgrade: h2c` for compatibility with any client that still relies on it.
2. Do not implement it; support cleartext HTTP/2 only via prior knowledge (RFC 9113 §3.4).
**Decision.** Option 2.
**Consequence.** Every h2c client that matters for Flash's use case (gRPC, and every modern h2c
implementation) uses prior knowledge, not the upgrade dance, so nothing is lost in practice.
Recorded explicitly so a future contributor who notices `Upgrade: h2c` is unhandled does not
assume it was an oversight and add it back.
**Revisit when.** A concrete client that requires `Upgrade: h2c` and cannot be changed is
identified. Not anticipated.
---
## DEC-11 — Commit scope stays `core`; `h2` is not added to `AGENTS.md`'s allowed-scope list
**Context.** `AGENTS.md` (§Commit Messages) enumerates the allowed Conventional Commits scopes.
`h2` is not among them. R9 leaves the choice open: either add `h2` as a new scope via a
`docs:` commit, or use `core` and record the decision here.
**Options.**
1. Add `h2` as a new allowed scope, so h2-specific commits are distinguishable in history from
other core work at a glance.
2. Use the existing `core` scope for all HTTP/2 work.
**Decision.** Option 2.
**Consequence.** All HTTP/2 commits use `feat(core): ...` / `fix(core): ...` /
`refactor(core): ...`, consistent with the branch name (`feature/core/http2`) and with `DEC-01`
(HTTP/2 is core, not a separate concern). A reader can still find every h2-related commit via
the file paths touched (`dev.relism.flash.h2/**`, `flash/docs/http2/**`) or via the commit body,
which is no worse than a scope label and avoids growing the scope list for what is, by `DEC-01`,
not actually a separate module.
**Revisit when.** The `h2` package's commit volume makes `core` too coarse to navigate in
`git log` — not expected before Phase 10 at the earliest, if ever.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,95 @@
package dev.relism.flash.h2;
/**
* 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
* {@link #bytes()} directly into a frame payload; nothing is formatted at request time (R4).
*
* <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,73 @@
package dev.relism.flash.h2;
/**
* 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.
*
* <p>The connection demux loop ({@code Http2Connection}, Phase 8) catches this exception at a
* 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
* instance — acceptable per R2, since this exception always terminates the connection and
* R2 exempts error paths that terminate the connection. Use this overload whenever the
* 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,148 @@
package dev.relism.flash.h2;
/**
* Every bound the HTTP/2 implementation enforces against a peer's input, in one place.
*
* <p>Per R8, any code that reads a length, an index, a count, or a size off the wire checks it
* 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. The operationally-relevant
* subset is promoted to {@code FlashConfiguration} in Phase 13 task 10, once the whole surface
* has been exercised and it is clear which knobs operators actually need. Until then, changing
* a limit means changing this file.
*
* <p>This class is added to incrementally: later phases add fields as the feature that needs
* them lands (e.g. {@code WRITE_TIMEOUT_MS} in Phase 3, {@code FRAME_READ_TIMEOUT_MS} in
* Phase 5). Phase 0 seeds the set called out explicitly by its task list; nothing here is a
* forward-declared placeholder — every field is already used by the phase that introduces it.
*/
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 = 100;
/**
* 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.
*/
public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 400;
/**
* 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 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;
/**
* 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
* request or response body never blocks on a WINDOW_UPDATE round trip. See Phase 11 task 1.
*/
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 = 16 * 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
* (DEC-04), so this bound applies only to headers <em>we receive</em>.
*/
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;
}
@@ -0,0 +1,46 @@
package dev.relism.flash.h2;
/**
* 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
* way {@code Http2Exception}'s message-less constants are. This is still acceptable under R2:
* {@code RST_STREAM} generation is an error path, not the steady-state request path, and R2
* 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
* rate limits (Phase 13), not by making the rejection itself allocation-free; a hostile peer
* 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,73 @@
/**
* HTTP/2 (RFC 9113) and HPACK (RFC 7541), as a peer transport to HTTP/1.1 — not a special case
* bolted onto it. This package lives in {@code flash} core, not an extension, because the
* protocol decision is made at the transport layer, where {@code HttpServer}'s replacement
* lives (see {@code DEC-01} in {@code flash/docs/http2/DECISIONS.md}).
*
* <h2>Architecture in one page</h2>
*
* <h3>The demux loop</h3>
* One virtual thread per connection reads and dispatches frames
* ({@code Http2Connection}, Phase 8): read a 9-byte frame header, validate it against the
* per-type table ({@code FrameValidator}, Phase 5), dispatch by type. This loop <b>never blocks
* on application work</b> — a slow handler must never stall frame processing for other streams
* on the same connection, which is the entire point of multiplexing. The only things the demux
* thread itself does synchronously are protocol bookkeeping: SETTINGS/PING/WINDOW_UPDATE
* accounting, HPACK decode, and stream-table updates.
*
* <h3>Virtual-thread-per-stream dispatch</h3>
* Once a request's headers (and, for small bodies, its body) are fully assembled, the demux
* thread submits a task to the shared virtual-thread executor and returns immediately to
* reading frames. Routing, middleware, and the user's handler run on that stream's own virtual
* thread — identical to the HTTP/1.1 dispatch model, so a handler written for h1 works
* unmodified over h2 (verified in Phase 10).
*
* <h3>The writer discipline</h3>
* N stream threads share one socket. {@code Http2FrameWriter} (Phase 3) is the single
* serialization point: a stream serializes its complete frame (header + HPACK block + payload)
* into a reusable per-stream scratch buffer, then takes a connection-wide {@link
* java.util.concurrent.locks.ReentrantLock} — never {@code synchronized}, which pins a virtual
* thread's carrier on Java 21 (see {@code DEC-03}) — and issues one bulk write. The uncontended
* path costs one CAS ({@code tryLock()}); contention falls back to an intrusive, allocation-free
* MPSC queue rather than blocking every writer on the lock. This is the project's single
* largest architectural risk and is proven or falsified by Phase 3's benchmark gate before any
* frame-layer code is written.
*
* <h3>The arena strategy</h3>
* HPACK is stateful compression: header bytes that enter the dynamic table must outlive the
* connection read buffer, and Huffman-coded values must be decoded somewhere. Flash copies each
* decoded header into a <b>per-stream arena</b>, not a shared one ({@code DEC-06}). This is not
* the minimal-copy design — a refcounted shared dynamic table would copy less — but it is the
* only design that is correct by construction under concurrent multiplexing: the demux thread
* can decode another stream's HEADERS, evicting dynamic-table entries, while a handler on a
* different virtual thread is still reading a view into a previous decode. A per-stream arena
* makes that race impossible without any cross-thread coordination on the hot path. See
* {@code flash/docs/http2/HPACK.md} (Phase 7) for the worked example.
*
* <h2>What this package deliberately does not implement</h2>
* <ul>
* <li><b>Server push ({@code PUSH_PROMISE}).</b> Flash never sends it and rejects any
* {@code PUSH_PROMISE} received from a client as a connection error, since only servers may
* send it (RFC 9113 §8.4). Flash advertises {@code SETTINGS_ENABLE_PUSH = 0}. Justification:
* push is widely disabled by browsers and its cache-coherency benefits are better served by
* {@code 103 Early Hints} or resource hints, which do not require protocol-level state.</li>
* <li><b>Priority scheduling ({@code PRIORITY} frames, and the deprecated priority fields on
* {@code HEADERS}).</b> RFC 9113 §5.3.2 itself says endpoints "SHOULD ignore" priority
* signalling — it was deprecated in the same RFC that (re)defined HTTP/2. Flash parses and
* discards {@code PRIORITY} frames (they must still be consumed, not rejected) and never acts
* on the priority fields.</li>
* <li><b>{@code Upgrade: h2c}.</b> RFC 9113 §3.1 removed the HTTP/1.1 upgrade mechanism that
* RFC 7540 §3.2 defined. Cleartext HTTP/2 is reached only via prior knowledge (RFC 9113 §3.4),
* which is what every modern h2c client (notably gRPC) actually uses. See {@code DEC-10}.</li>
* </ul>
*
* <h2>Package layout</h2>
* This package is filled in incrementally, phase by phase — see
* {@code flash/docs/http2/IMPLEMENTATION-PLAN.md} Part III for the full schedule. As of Phase 0
* it contains only the error model ({@link dev.relism.flash.h2.Http2ErrorCode},
* {@link dev.relism.flash.h2.Http2Exception}, {@link dev.relism.flash.h2.Http2StreamException})
* and the limits registry ({@link dev.relism.flash.h2.Http2Limits}). Subpackages
* {@code frame}, {@code hpack}, {@code stream}, {@code message}, and {@code upgrade} are added
* by Phases 3, 5, 79, and 1415 respectively.
*/
package dev.relism.flash.h2;
@@ -0,0 +1,58 @@
package dev.relism.flash.http;
/**
* Bounds the HTTP/1.1 parser ({@code RequestParser}, {@code ChunkedInputStream}) enforces
* against a peer's input, in one place.
*
* <p>Per R8, any code that reads a length, an index, a count, or a size off the wire checks it
* 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.
*
* <p>Seeded in Phase 0 with the bounds required by {@code EX-03} (strict {@code Content-Length}
* parsing) and {@code EX-08} (header count/size limits); extended in Phase 1 with the chunked-
* transfer bounds ({@code EX-10}) and again in later phases as new h1 surfaces need a limit.
* Compare {@code dev.relism.flash.h2.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.
*/
public static final long MAX_CONTENT_LENGTH = 100L * 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 HeaderMap} lookup to scan all of them — turning a small request into quadratic CPU
* work per middleware that reads a header ({@code EX-08}, {@code EX-09}).
*/
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;
}
@@ -0,0 +1,59 @@
package dev.relism.flash.h2;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class Http2ErrorCodeTest {
@Test
void everyCodeRoundTripsThroughFromCode() {
for (Http2ErrorCode code : Http2ErrorCode.values()) {
assertSame(code, Http2ErrorCode.fromCode(code.code()));
}
}
@Test
void bytesAreFourByteBigEndian() {
for (Http2ErrorCode code : Http2ErrorCode.values()) {
assertEquals(4, code.bytes().length, code.name());
int decoded = ((code.bytes()[0] & 0xFF) << 24)
| ((code.bytes()[1] & 0xFF) << 16)
| ((code.bytes()[2] & 0xFF) << 8)
| (code.bytes()[3] & 0xFF);
assertEquals(code.code(), decoded, code.name());
}
}
@Test
void allFourteenRfc9113CodesArePresent() {
assertEquals(14, Http2ErrorCode.values().length);
assertEquals(0x00, Http2ErrorCode.NO_ERROR.code());
assertEquals(0x01, Http2ErrorCode.PROTOCOL_ERROR.code());
assertEquals(0x02, Http2ErrorCode.INTERNAL_ERROR.code());
assertEquals(0x03, Http2ErrorCode.FLOW_CONTROL_ERROR.code());
assertEquals(0x04, Http2ErrorCode.SETTINGS_TIMEOUT.code());
assertEquals(0x05, Http2ErrorCode.STREAM_CLOSED.code());
assertEquals(0x06, Http2ErrorCode.FRAME_SIZE_ERROR.code());
assertEquals(0x07, Http2ErrorCode.REFUSED_STREAM.code());
assertEquals(0x08, Http2ErrorCode.CANCEL.code());
assertEquals(0x09, Http2ErrorCode.COMPRESSION_ERROR.code());
assertEquals(0x0a, Http2ErrorCode.CONNECT_ERROR.code());
assertEquals(0x0b, Http2ErrorCode.ENHANCE_YOUR_CALM.code());
assertEquals(0x0c, Http2ErrorCode.INADEQUATE_SECURITY.code());
assertEquals(0x0d, Http2ErrorCode.HTTP_1_1_REQUIRED.code());
}
@Test
void unknownCodeReturnsNull() {
assertNull(Http2ErrorCode.fromCode(0x0e));
assertNull(Http2ErrorCode.fromCode(-1));
assertNull(Http2ErrorCode.fromCode(Integer.MAX_VALUE));
}
@Test
void bytesInstanceIsStablePerConstant() {
// Precomputed at class init (R4) — must not be rebuilt per call.
assertSame(Http2ErrorCode.PROTOCOL_ERROR.bytes(), Http2ErrorCode.PROTOCOL_ERROR.bytes());
}
}
@@ -0,0 +1,36 @@
package dev.relism.flash.h2;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class Http2ExceptionTest {
@Test
void ofCarriesTheGivenCodeAndMessage() {
Http2Exception e = Http2Exception.of(Http2ErrorCode.FLOW_CONTROL_ERROR, "window exceeded");
assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, e.errorCode());
assertEquals("window exceeded", e.getMessage());
}
@Test
void stackTraceCaptureIsDisabled() {
Http2Exception e = Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, "bad frame");
assertEquals(0, e.getStackTrace().length);
}
@Test
void singletonsCarryTheAdvertisedCode() {
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, Http2Exception.PROTOCOL_ERROR.errorCode());
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, Http2Exception.FRAME_SIZE_ERROR.errorCode());
assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, Http2Exception.FLOW_CONTROL_ERROR.errorCode());
assertEquals(Http2ErrorCode.COMPRESSION_ERROR, Http2Exception.COMPRESSION_ERROR.errorCode());
assertEquals(Http2ErrorCode.INTERNAL_ERROR, Http2Exception.INTERNAL_ERROR.errorCode());
assertEquals(Http2ErrorCode.SETTINGS_TIMEOUT, Http2Exception.SETTINGS_TIMEOUT.errorCode());
}
@Test
void doesNotExtendIoException() {
assertFalse(java.io.IOException.class.isAssignableFrom(Http2Exception.class));
}
}
@@ -0,0 +1,56 @@
package dev.relism.flash.h2;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class Http2LimitsTest {
@Test
void maxFrameSizeLocalWithinRfcBounds() {
// RFC 9113 §6.5.2 — SETTINGS_MAX_FRAME_SIZE must be within 16384..16777215.
assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL >= 16_384);
assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL <= 16_777_215);
}
@Test
void everyLimitIsPositive() {
assertTrue(Http2Limits.MAX_CONCURRENT_STREAMS > 0);
assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL > 0);
assertTrue(Http2Limits.MAX_HEADER_LIST_SIZE > 0);
assertTrue(Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK > 0);
assertTrue(Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL > 0);
assertTrue(Http2Limits.RESET_RATE_INTERVAL_MS > 0);
assertTrue(Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL > 0);
assertTrue(Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME > 0);
assertTrue(Http2Limits.MAX_PING_QUEUE_DEPTH > 0);
assertTrue(Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM > 0);
assertTrue(Http2Limits.INITIAL_WINDOW_SIZE_LOCAL > 0);
assertTrue(Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL > 0);
assertTrue(Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL > 0);
assertTrue(Http2Limits.MAX_HPACK_STRING_LENGTH > 0);
assertTrue(Http2Limits.HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS > 0);
assertTrue(Http2Limits.STREAM_IDLE_TIMEOUT_MS > 0);
}
@Test
void streamCreationBoundIsAtLeastTheResetBound() {
// A Rapid Reset defence that only counts resets can be bypassed by a peer that creates
// streams fast enough that the reset counter never saturates within a window boundary;
// the creation bound must be at least as tight.
assertTrue(Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL >= Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL);
}
@Test
void connectionWindowIsAtLeastAsLargeAsAStreamWindow() {
// Otherwise a single active stream would be bottlenecked by the connection window
// before it ever reaches its own (larger) per-stream window.
assertTrue(Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL >= Http2Limits.INITIAL_WINDOW_SIZE_LOCAL);
}
@Test
void hpackDynamicTableSizeMatchesRfcDefault() {
// RFC 7541 §4.1 default is 4096; nothing in this codebase should silently diverge.
assertEquals(4_096, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL);
}
}
@@ -0,0 +1,27 @@
package dev.relism.flash.h2;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class Http2StreamExceptionTest {
@Test
void carriesStreamIdAndErrorCode() {
Http2StreamException e = new Http2StreamException(7, Http2ErrorCode.STREAM_CLOSED, "closed");
assertEquals(7, e.streamId());
assertEquals(Http2ErrorCode.STREAM_CLOSED, e.errorCode());
assertEquals("closed", e.getMessage());
}
@Test
void stackTraceCaptureIsDisabled() {
Http2StreamException e = new Http2StreamException(3, Http2ErrorCode.CANCEL, "cancelled");
assertEquals(0, e.getStackTrace().length);
}
@Test
void doesNotExtendIoException() {
assertFalse(java.io.IOException.class.isAssignableFrom(Http2StreamException.class));
}
}
@@ -0,0 +1,24 @@
package dev.relism.flash.http;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class Http1LimitsTest {
@Test
void everyLimitIsPositive() {
assertTrue(Http1Limits.MAX_CONTENT_LENGTH > 0);
assertTrue(Http1Limits.MAX_HEADER_COUNT > 0);
assertTrue(Http1Limits.MAX_HEADER_NAME_LENGTH > 0);
assertTrue(Http1Limits.MAX_HEADER_VALUE_LENGTH > 0);
assertTrue(Http1Limits.MAX_REQUEST_LINE_LENGTH > 0);
}
@Test
void requestLineFitsInsideMaxHeaderValueOrderOfMagnitude() {
// Sanity: the request-line bound should not dwarf the total per-header bound to the
// point of being meaningless as a distinct limit.
assertTrue(Http1Limits.MAX_REQUEST_LINE_LENGTH <= Http1Limits.MAX_CONTENT_LENGTH);
}
}