feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10
@@ -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, 7–9, and 14–15 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user