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
107 changed files with 122 additions and 484 deletions
Showing only changes of commit 885c450f6b - Show all commits
+5 -5
View File
@@ -14,7 +14,7 @@ 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
## DEC-01 — HTTP/2 lives in `flash` core, package `dev.relism.flash.http2`, 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`.
@@ -53,7 +53,7 @@ existing HTTP/1.1 code paths.
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
`dev.relism.flash.http2` 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.
@@ -318,7 +318,7 @@ identified. Not anticipated.
**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,
the file paths touched (`dev.relism.flash.http2/**`, `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.
@@ -484,7 +484,7 @@ instead of a speculative one.
## DEC-17 — `FrameWriterBenchmark` lives in `src/jmh/java`, a source root registered only inside the `jmh` profile, not in `src/test/java`
**Context.** The Phase 3 JMH benchmark (`FrameWriterBenchmark`) was first placed directly in
`src/test/java/dev/relism/flash/h2/frame/`, on the theory recorded in `flash/pom.xml`'s comment
`src/test/java/dev/relism/flash/http2/frame/`, on the theory recorded in `flash/pom.xml`'s comment
at the time: since the class carries only `@Benchmark`/JMH annotations and no JUnit annotations,
Surefire's JUnit-Jupiter engine would simply not select it as a test, so a plain `mvn test` (no
`-Pjmh`) would harmlessly ignore it. Verifying this assumption (`mvn -pl flash -am clean
@@ -512,7 +512,7 @@ catch, just in the build graph rather than the source graph.
profile's `<build>`. With the profile inactive, the file is not handed to the compiler at
all, under any goal — not `test-compile`, not IDE indexing driven by the effective POM.
This is also what the plan itself already suggested (Phase 3's Files list: `flash/src/jmh/
java/dev/relism/flash/h2/FrameWriterBenchmark.java (or a flash-bench submodule...)`) — the
java/dev/relism/flash/http2/FrameWriterBenchmark.java (or a flash-bench submodule...)`) — the
prior session's placement in `src/test/java` was itself a deviation from the plan's own
suggested layout, not a considered alternative.
3. A separate `flash-bench` submodule, depending on `flash` and always pulling in JMH. The
+2 -2
View File
@@ -1,6 +1,6 @@
# The Frame Layer (Phase 5)
Audience: contributors. This is the design record for `dev.relism.flash.h2.frame`'s frame
Audience: contributors. This is the design record for `dev.relism.flash.http2.frame`'s frame
reading, validation, and writing — the 9-byte header and payload boundary, with no connection
semantics, no streams, and no HPACK above it.
@@ -32,7 +32,7 @@ out of `streamId()` once, so no caller has to remember to.
## Package layout
```
dev.relism.flash.h2.frame
dev.relism.flash.http2.frame
├── FrameType the 10 known types + per-type validation descriptor (min/max length, stream-id rule)
├── FrameFlags END_STREAM/ACK/END_HEADERS/PADDED/PRIORITY bit constants + predicates
├── FrameHeader flyweight over a read buffer: length/type/flags/streamId/payloadOffset
+22 -30
View File
@@ -1,10 +1,10 @@
# Flash — HTTP/2 Implementation Plan
> **Status**: design document, not yet implemented.
> **Status**: working implementation ledger; it is not product documentation or an API contract.
> **Target branch**: `feature/core/http2`
> **Target module**: `flash` (core). HTTP/2 is a transport concern and must live where
> `HttpServer` lives; it cannot be an extension.
> **Target package root**: `dev.relism.flash.h2`
> **Target package root**: `dev.relism.flash.http2`
> **Java baseline**: 21 (`maven.compiler.source/target=21` in the root `pom.xml`). Every
> decision in this document assumes Java 21 semantics, in particular that
> **`synchronized` pins the carrier thread of a virtual thread** (JEP 491, which removes
@@ -61,7 +61,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
| Phase | Status | Branch/PR | Notes |
|---|---|---|---|
| 0 — Groundwork | done | `feature/core/http2` | Package skeleton, `Http2Limits`, `Http1Limits`, `Http2ErrorCode`, `Http2Exception`/`Http2StreamException`, `DECISIONS.md` (`DEC-01``DEC-11`), `package-info.java`. 226/226 tests green. |
| 0 — Groundwork | done | `feature/core/http2` | Package skeleton, `Http2Limits`, `Http1Limits`, `Http2ErrorCode`, `Http2Exception`/`Http2StreamException`, and `DECISIONS.md`. 226/226 tests green. |
| 1 — HTTP/1.1 hardening + ALPN/preface | done | `feature/core/http2` | EX-02/03/07/08/10/17/18/30/31 fixed; EX-35/36 found+fixed. `BufferedByteSource`, `ProtocolNegotiator`, `MalformedRequestException` added (plan corrected, DEC-12). 277/277 tests green (run twice). h1 benchmark check deferred — no JMH harness until Phase 3 (documented in DoD). |
| 2 — Transport decomposition | done | `feature/core/http2` | `HttpServer.java` deleted; `transport`/`http1` packages + WS extraction (EX-01/06/11/12/13/14/15/16/32/34) done. Router `ThreadLocal` (EX-06 router half) deliberately deferred to Phase 4 per DEC-15. 311/311 tests green (run 3×). h1 benchmark check deferred — no JMH harness until Phase 3. |
| 3 — Serialized frame writer (GO/NO-GO gate) | done | `feature/core/http2` | `Http2FrameWriter`/`WriteIntent`/`IntrusiveMpscQueue` + `Http2FrameWriterTest`/`Http2FrameWriterStressTest` + `FrameWriterBenchmark` (JMH, `-Pjmh`, `src/jmh/java` — moved there from `src/test/java` after it broke default `mvn test`; see `DEC-17`). All 4 gate criteria met: N=1 0 B/op & 42.6 ns overhead (≤50 ns budget); N=64 65.5% throughput retention (≥60%) & 11.814.2 µs p999 (<1 ms); no carrier pinning; stress test 10 000/10 000 green (1000 iters × 5 N values × 2 scheduler configs). Full numbers in `WRITER.md`, `DEC-09`. 321/321 non-JMH tests green. |
@@ -782,11 +782,10 @@ prevents three different naming schemes for the same idea.
### Files created
```
flash/src/main/java/dev/relism/flash/h2/package-info.java
flash/src/main/java/dev/relism/flash/h2/Http2Limits.java
flash/src/main/java/dev/relism/flash/h2/Http2ErrorCode.java
flash/src/main/java/dev/relism/flash/h2/Http2Exception.java
flash/src/main/java/dev/relism/flash/h2/Http2StreamException.java
flash/src/main/java/dev/relism/flash/http2/Http2Limits.java
flash/src/main/java/dev/relism/flash/http2/Http2ErrorCode.java
flash/src/main/java/dev/relism/flash/http2/Http2Exception.java
flash/src/main/java/dev/relism/flash/http2/Http2StreamException.java
flash/src/main/java/dev/relism/flash/http/Http1Limits.java
flash/docs/http2/IMPLEMENTATION-PLAN.md (this file)
flash/docs/http2/DECISIONS.md (decision log, see below)
@@ -795,8 +794,7 @@ flash/docs/http2/DECISIONS.md (decision log, see below)
### Package layout (final; later phases fill it in)
```
dev.relism.flash.h2
├── package-info.java module-level Javadoc: the whole architecture in one page
dev.relism.flash.http2
├── Http2Limits.java every bound, every default, each with its attack rationale
├── Http2ErrorCode.java the 14 RFC 9113 §7 codes, with pre-encoded 4-byte forms
├── Http2Exception.java connection error → GOAWAY
@@ -867,11 +865,7 @@ dev.relism.flash.bytes (new, Phase 4 — protocol-neutral byte ut
(`DEC-01` … `DEC-08`, listed in Part VI). Every subsequent non-obvious choice appends an
entry: context, options, decision, consequence. This is how the next agent understands why
the encoder has no dynamic table.
2. Write `dev/relism/flash/h2/package-info.java` containing the one-page architecture
description: the demux loop, the virtual-thread-per-stream model, the writer discipline, the
arena strategy, and the explicit list of what Flash does not implement (server push,
priority scheduling) with the RFC citation permitting it.
3. Write `Http2ErrorCode` as an enum of the 14 RFC 9113 §7 codes with `code()` and a
2. Write `Http2ErrorCode` as an enum of the 14 RFC 9113 §7 codes with `code()` and a
**pre-encoded 4-byte big-endian `byte[]`** per constant (used in RST_STREAM and GOAWAY
payloads without formatting).
4. Write `Http2Limits` with every bound this plan will need. Each field gets a Javadoc naming
@@ -906,7 +900,7 @@ positive and internally consistent, e.g. `MAX_FRAME_SIZE_LOCAL` within RFC bound
16384..16777215).
### Docs
`flash/docs/http2/DECISIONS.md` created. `package-info.java` written.
`flash/docs/http2/DECISIONS.md` created.
### DoD
- [x] Package skeleton compiles (empty classes are acceptable only for classes whose phase has
@@ -1241,7 +1235,7 @@ nothing but stops syscalling per byte. No new steady-state allocation is introdu
immediately.
- [x] `PackageBoundaryTest` — a source-scan architecture test (decision recorded in the test's
own Javadoc: no ArchUnit dependency yet, and one import check per package pair does not
need one): `dev.relism.flash.http1` must not import `dev.relism.flash.h2` and vice versa.
need one): `dev.relism.flash.http1` must not import `dev.relism.flash.http2` and vice versa.
### Docs
- `README.md` architecture section (lines 257-274) rewritten to reflect the new component
@@ -1337,16 +1331,16 @@ race documented in the Javadoc, and verified by a dedicated stress test.
### Files
Created:
- `flash/src/main/java/dev/relism/flash/h2/frame/Http2FrameWriter.java`
- `flash/src/main/java/dev/relism/flash/h2/frame/WriteIntent.java` — the interface a stream
- `flash/src/main/java/dev/relism/flash/http2/frame/Http2FrameWriter.java`
- `flash/src/main/java/dev/relism/flash/http2/frame/WriteIntent.java` — the interface a stream
implements to describe "serialize yourself into this buffer". Implemented by `Http2Stream`
and by connection-level singletons (SETTINGS ACK, PING ACK, GOAWAY, WINDOW_UPDATE) so that
connection frames use the same path as stream frames — one writer, no exceptions.
- `flash/src/main/java/dev/relism/flash/h2/frame/IntrusiveMpscQueue.java` — the Vyukov queue,
- `flash/src/main/java/dev/relism/flash/http2/frame/IntrusiveMpscQueue.java` — the Vyukov queue,
operating on a `Node` interface that `Http2Stream` implements.
- `flash/src/test/java/dev/relism/flash/h2/frame/Http2FrameWriterTest.java`
- `flash/src/test/java/dev/relism/flash/h2/frame/Http2FrameWriterStressTest.java`
- `flash/src/jmh/java/dev/relism/flash/h2/FrameWriterBenchmark.java` (or a `flash-bench`
- `flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameWriterTest.java`
- `flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameWriterStressTest.java`
- `flash/src/jmh/java/dev/relism/flash/http2/FrameWriterBenchmark.java` (or a `flash-bench`
submodule — decide and record in `DECISIONS.md`; a `jmh` profile on the `flash` module is
simplest and avoids a new artifact).
@@ -2801,7 +2795,7 @@ speak h2 as a **client** so Pathway can proxy.
the same frame reader/writer, the same HPACK codec (the encoder now needs `:method`,
`:scheme`, `:authority`, `:path` — all static-table entries), the same stream machine with
the roles inverted. New: connection pooling, `:status` handling, and response assembly.
Keep it in `dev.relism.flash.h2.client` and keep it honest about scope: it exists to serve
Keep it in `dev.relism.flash.http2.client` and keep it honest about scope: it exists to serve
the proxy use case, not to be a general-purpose HTTP client.
4. **Trailer relay.** A proxy must forward trailers in both directions, and must forward them
*as trailers*, not fold them into headers. Getting this wrong is the single most common
@@ -2918,7 +2912,7 @@ defines the h2 mechanism.
- A soak test: 10 minutes of sustained mixed traffic (GET, POST, streaming, RST, PING) with
heap and pool-size assertions at the end. Tagged for nightly, not per-PR.
6. **Regression corpus.** Every bug found during implementation gets a test with the exact
frame bytes that triggered it, checked in under `src/test/resources/h2/regressions/`.
frame bytes that triggered it, checked in under `src/test/resources/http2/regressions/`.
### Docs
`flash/docs/http2/COMPLIANCE.md` — the `h2spec` result table, the interop matrix with versions, the
@@ -3044,8 +3038,7 @@ be traceable to a number in this file.
orientation for someone opening the package for the first time.
**Javadoc:**
- Every public type in `dev.relism.flash.h2` and the new `transport`/`http1`/`bytes` packages.
- `package-info.java` for each new package.
- Every public type in `dev.relism.flash.http2` and the new `transport`/`http1`/`bytes` packages.
- The release workflow publishes Javadoc to GitHub Pages (`release.yml`); verify the new
packages render correctly and that no `@link` is broken.
@@ -3080,7 +3073,7 @@ be traceable to a number in this file.
| Concurrency | 1000 streams, stress, leak, pinning | `*ConcurrencyTest`, `*LeakTest` |
| Allocation | 0 B/op gates | JMH `-prof gc` in CI |
| Performance | Throughput and latency baselines | JMH + `h2load` |
| Regression | Every bug ever found, by its exact bytes | `src/test/resources/h2/regressions/` |
| Regression | Every bug ever found, by its exact bytes | `src/test/resources/http2/regressions/` |
## Rules
@@ -3141,7 +3134,7 @@ an entry in the same format: **Context / Options / Decision / Consequence / Revi
| Id | Decision | One-line rationale |
|---|---|---|
| `DEC-01` | HTTP/2 lives in `flash` core, package `dev.relism.flash.h2`, not an extension | The protocol branch must sit where the transport sits; `HttpServer` is package-private |
| `DEC-01` | HTTP/2 lives in `flash` core, package `dev.relism.flash.http2`, not an extension | The protocol branch must sit where the transport sits; `HttpServer` is package-private |
| `DEC-02` | h1 and h2 are peers behind a `ConnectionProtocol` seam, never flags in shared code | `R1`; protects h1 performance and both implementations' readability |
| `DEC-03` | `ReentrantLock` everywhere, never `synchronized` around blocking I/O | Java 21 pins carriers on `synchronized`; JEP 491 is JDK 24+ |
| `DEC-04` | The HPACK **encoder** uses the static table only; no dynamic table | Removes all shared mutable state from the write path, at a cost of a few bytes on the wire |
@@ -3267,4 +3260,3 @@ pressure and it is the one the project owner asked for most explicitly:
> description. Do not open a TODO, do not defer it, and do not work around it.
>
> The registry in Part II came from reading the codebase once. It is a floor, not a ceiling.
+1 -1
View File
@@ -114,7 +114,7 @@ public interface ConnectionProtocol {
`ConnectionRunner` decides h1 vs h2 exactly once, immediately after ALPN/preface detection, and
dispatches. Today only `Http1Connection` exists; an `H2` negotiation result is closed cleanly
(there is no `Http2Connection` to hand off to until Phase 8). Neither implementation is aware
the other exists — `dev.relism.flash.http1` and `dev.relism.flash.h2` do not import each other,
the other exists — `dev.relism.flash.http1` and `dev.relism.flash.http2` do not import each other,
enforced by `PackageBoundaryTest`.
## Graceful shutdown (`EX-32`)
+2 -2
View File
@@ -1,7 +1,7 @@
# The Serialized Frame Writer (Phase 3 — GO/NO-GO gate)
Audience: contributors. This is the design record and benchmark evidence for
`dev.relism.flash.h2.frame.Http2FrameWriter`, the one component every HTTP/2 write in this
`dev.relism.flash.http2.frame.Http2FrameWriter`, the one component every HTTP/2 write in this
codebase passes through. Phase 3 of `IMPLEMENTATION-PLAN.md` treats this component as the
single genuinely novel architectural risk in the whole project — everything downstream (frames,
HPACK, flow control) is table-driven work with known cost, but nothing in Flash today
@@ -139,7 +139,7 @@ from the path this document's gate criteria are strictest about.
## Benchmark methodology
`flash/src/jmh/java/dev/relism/flash/h2/frame/FrameWriterBenchmark.java` (a JMH source root
`flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java` (a JMH source root
registered only under the `jmh` Maven profile — see `DECISIONS.md`, `DEC-17`, for why it does not
live in `src/test/java`) compares four harnesses at `threads` ∈ {1, 2, 4, 8, 16, 64}:
@@ -1,4 +1,4 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.transport.BufferedByteSource;
@@ -1,4 +1,4 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
@@ -12,7 +12,6 @@ import java.io.InputStream;
* Handles pre-buffered bytes from the header read-ahead, chunk framing, and trailer consumption.
* Returns -1 at end of the final chunk; the underlying socket is left positioned for the next request.
*
* <p><b>{@code EX-10}:</b> reads through the connection's shared {@link BufferedByteSource}
* instead of the raw, unbuffered socket stream. Chunk-size digits, the trailing CRLF after each
* chunk, and trailer lines are all read one byte at a time by design (the framing is
* byte-oriented) — that used to mean one {@code read(2)} syscall per byte on the raw socket;
@@ -124,7 +123,6 @@ final class ChunkedInputStream extends InputStream {
* (RFC 9112 §7.1.2). Bounded by {@link Http1Limits#MAX_TRAILER_COUNT} and
* {@link Http1Limits#MAX_HEADER_VALUE_LENGTH} — without a bound, a peer could follow the
* final chunk with an unbounded trailer section purely to waste CPU discarding it. Trailers
* are discarded, not exposed to the handler; exposing them is Phase 12 scope
* ({@code Request.trailers()}).
*/
private void consumeTrailers() throws IOException {
@@ -40,7 +40,6 @@ import java.util.Arrays;
* and reset to {@code 0/0} before any work begins, so an exception thrown mid-parse
* leaves the fields clean rather than pointing at stale data from a previous request.
*
* <h3>Rejection model (RFC 9112 §6.1, {@code EX-02}/{@code EX-03}/{@code EX-08}/{@code EX-18})</h3>
* Anything wrong with the request itself — smuggling-relevant ambiguity, an over-limit
* header, a malformed byte where the grammar forbids one — is reported as a
* {@link MalformedRequestException} carrying the exact status the caller must respond with.
@@ -58,13 +57,10 @@ public class RequestParser {
private final InetSocketAddress remoteAddress;
private final SSLSocket sslSocket;
private final Http1HeaderMap headerMap = new Http1HeaderMap();
// EX-22: one Request/RequestLine per connection, repositioned (never reallocated) per
// request — same idiom as headerMap above.
private final RequestLine requestLine = new RequestLine();
private final Request request = new Request();
private final RequestBody requestBody = new RequestBody();
// EX-42: one pooled RequestByteView per role, repositioned (never reallocated) per request —
// closes the last per-request allocation left after EX-20..EX-24 pooled Request/RequestBody/
// RequestLine/Response themselves. queryView is only reset and used when a query string is
// actually present; RequestLine.getQuery() must keep returning null otherwise (see reset()).
private final FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(null, 0, 0);
@@ -174,7 +170,6 @@ public class RequestParser {
int protocolEnd = ByteScan.indexOf(buffer, protocolStart, headerEndIdx, (byte) '\r');
if (protocolEnd == -1) throw new MalformedRequestException(400, "Invalid request line (protocol)");
// EX-08: the request line itself (method SP target SP version) is bounded separately
// from the overall header-block size, so an oversized request line gets its own,
// specific rejection rather than being folded into the generic "headers too large" case.
if (protocolEnd - base > Http1Limits.MAX_REQUEST_LINE_LENGTH) {
@@ -194,7 +189,6 @@ public class RequestParser {
int headerCount = 0;
while (current < headerEndIdx) {
// EX-18 (obs-fold): a header line MUST NOT begin with whitespace. RFC 9112 §5.2
// deprecates line folding and treating a folded continuation as part of the
// previous header's value is a known request-smuggling vector.
byte first = buffer[current];
@@ -205,7 +199,6 @@ public class RequestParser {
int lineEnd = ByteScan.indexOf(buffer, current, headerEndIdx + 1, (byte) '\r');
if (lineEnd == -1 || lineEnd == current) break;
// EX-18: verify the '\r' is immediately followed by '\n' instead of blindly
// advancing past two bytes — a bare '\r' not followed by '\n' desynchronizes the
// parse and is a known bare-CR smuggling surface. Safe to read lineEnd+1: lineEnd
// is at most headerEndIdx, and findEndOfHeader already guaranteed 4 readable bytes
@@ -238,11 +231,9 @@ public class RequestParser {
}
if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "content-length")) {
// EX-03: strict, overflow-safe parsing — replaces the old digit-skipping
// parseLong, which silently accepted "5abc" as 5 and "-1" as 1.
long parsed = parseContentLengthStrict(buffer, valueStart, lineEnd);
// Multiple Content-Length lines with differing values is itself a smuggling
// primitive (EX-02); identical repeated values are tolerated (RFC 9110 §8.6
// permits a recipient to treat that as one value).
if (contentLengthSeen && parsed != contentLength) {
throw new MalformedRequestException(400, "Conflicting Content-Length values");
@@ -251,8 +242,6 @@ public class RequestParser {
contentLengthSeen = true;
} else if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "transfer-encoding")) {
transferEncodingSeen = true;
// Correctness fix found while implementing EX-02 in this exact code path
// (registered as EX-35): the old check required the WHOLE value to equal
// "chunked", so "gzip, chunked" — valid per RFC 9112 §6.1, where chunked need
// only be the FINAL coding — was silently treated as not chunked at all,
// corrupting the message boundary. Fixed by inspecting only the last token.
@@ -261,7 +250,6 @@ public class RequestParser {
current = lineEnd + 2;
}
// EX-02 (RFC 9112 §6.1): a request with both Content-Length and Transfer-Encoding
// MUST be treated as an error by an origin server — this is the canonical CL.TE/TE.CL
// smuggling vector. Checked once both headers are known, regardless of the order they
// appeared in, so ordering games cannot bypass it.
@@ -301,7 +289,6 @@ public class RequestParser {
requestLine.reset(method, pathView, queryMark != -1 ? queryView : null, protocolView, headerMap);
// EX-22: requestBody is this connection's single pooled instance (see its own class
// Javadoc) -- reset() repositions it for the fixed-length/empty case (contentLength == 0
// is handled by the same call: preBufLen is already forced to 0 for it above) or the
// chunked case, never reallocated.
@@ -314,7 +301,6 @@ public class RequestParser {
}
/**
* Strict, overflow-safe {@code Content-Length} parsing ({@code EX-03}). Rejects: an empty
* value, any non-digit byte (including a leading {@code +}/{@code -}, which are not
* digits), more than 19 digits (the longest possible {@code Long.MAX_VALUE}), arithmetic
* overflow past {@code Long.MAX_VALUE}, and a value above
@@ -349,7 +335,6 @@ public class RequestParser {
* ({@code "gzip, chunked"}), {@code chunked} MUST be the final one for the message to be
* self-delimiting. Returns whether the last comma-separated token in {@code [start, end)}
* is exactly {@code "chunked"} (case-insensitive), ignoring surrounding whitespace around
* that token. Registered as {@code EX-35}: the previous whole-value comparison silently
* misclassified any multi-coding value as non-chunked.
*/
private static boolean isFinalCodingChunked(byte[] buf, int start, int end) {
@@ -13,7 +13,6 @@ import java.util.concurrent.CompletableFuture;
* Public handle to the underlying HTTP transport. Returned by {@link #create}
* so that {@link FlashApp} can start and stop the server
* without holding a direct reference to the transport's internal composition
* ({@link TransportFactory}, {@code EX-34}).
*/
public interface ServerHandle {
@@ -57,7 +57,6 @@ public final class Multipart {
private final List<Part> scanned = new ArrayList<>();
private PartBodyStream active = null; // open file stream; must be drained before next scan
private int partCount = 0; // EX-29: bounds Http1Limits.MAX_MULTIPART_PARTS
// -------------------------------------------------------------------------
// Factory
@@ -159,7 +158,6 @@ public final class Multipart {
Map<String, String> headers = readPartHeaders();
if (headers == null) { done = true; return null; }
// EX-29: without this bound, a peer sending an unbounded number of minimal parts forces
// unbounded growth of `scanned` and unbounded cumulative header-parsing work.
if (++partCount > Http1Limits.MAX_MULTIPART_PARTS) {
throw new IOException("multipart body exceeds max part count (" + Http1Limits.MAX_MULTIPART_PARTS + ")");
@@ -177,7 +175,6 @@ public final class Multipart {
// File part — expose streaming body; not cached (stream is consumed once)
p = Part.streaming(name, filename, ct, active);
} else {
// Text part, or full-scan path: buffer body now. EX-29: bounded, not
// InputStream.readAllBytes() — an unbounded field/file body would otherwise let a
// hostile peer force an arbitrarily large single heap allocation.
byte[] body = readBoundedBody(active);
@@ -302,7 +299,6 @@ public final class Multipart {
while (true) {
String line = readLine();
if (line == null || line.isEmpty()) break;
// EX-29: without this bound a peer can send an effectively unlimited number of
// header lines before the blank line that ends a part's header block.
if (++count > Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT) {
throw new IOException("multipart part exceeds max header count ("
@@ -338,7 +334,6 @@ public final class Multipart {
sb.append(new String(win, wPos, append, StandardCharsets.UTF_8));
wPos += append; wLen -= append;
}
// EX-29: without this bound, a peer that never sends \r\n keeps this StringBuilder
// growing for as long as it keeps streaming bytes — the multipart-header analogue of
// RequestParser's Http1Limits.MAX_HEADER_VALUE_LENGTH check, which does not apply
// here since these header lines live inside the body, not the top-level HTTP headers.
@@ -20,7 +20,6 @@ import dev.relism.fpr.core.ByteView;
* <li>Single-allocation {@code String} construction —
* {@code new String(view.array(), view.offset(), view.length(), UTF_8)} instead of a
* byte-at-a-time copy into a scratch {@code byte[]} followed by a second allocation for
* the {@code String} itself ({@code EX-25}).</li>
* <li>A single {@code System.arraycopy} instead of a manual loop wherever a view's bytes need
* to be copied.</li>
* </ul>
@@ -11,15 +11,12 @@ import java.nio.ByteOrder;
* {@code \r\n\r\n} header-terminator search (SWAR-accelerated), case-insensitive comparison,
* comma-separated token-list scanning ({@code Connection: a, b, c}), RFC 9110 {@code tchar}
* validation, and the case-insensitive header-name hash {@link dev.relism.flash.models.Http1HeaderMap}'s
* index uses ({@code EX-09}).
*
* <p>Every method here is {@code static} and allocates nothing. Every SWAR method has a plain
* scalar counterpart ({@code *Scalar}) that exists for two reasons: it is what the tests use as
* the correctness oracle (property-tested against the SWAR version on randomized inputs — see
* {@code ByteScanTest}/{@code ByteScanFuzzTest}), and it is the documented fallback if a future
* measurement ever shows the SWAR path is not worth its complexity on some path (none has been
* found not worth it so far — see {@code DECISIONS.md} for the one path that {@em was}
* measured and kept, {@code EX-33}).
*
* <h3>The SWAR technique used throughout</h3>
* Both {@link #indexOf} and {@link #indexOfCrLfCrLf} use the classic "does this word contain
@@ -60,7 +57,6 @@ public final class ByteScan {
/**
* RFC 9110 §5.6.2 {@code tchar} set, table-driven so validation is a single array read per
* byte (R4/R5) rather than a chain of range comparisons. Indexed directly by byte value;
* only the ASCII range a valid header-name character can ever occupy is populated.
*/
private static final boolean[] TCHAR = new boolean[128];
@@ -119,7 +115,6 @@ public final class ByteScan {
* Index of the first {@code "\r\n\r\n"} in {@code buf[from, to)}, or {@code -1}. SWAR
* pre-filter (find a candidate {@code CR} byte 8 at a time) plus a cheap scalar 3-byte
* verify at each candidate — see the class Javadoc for the technique and
* {@code RequestParser}, {@code EX-33}, for why this replaced a fully byte-at-a-time scan.
*/
public static int indexOfCrLfCrLf(byte[] buf, int from, int to) {
int limit = to - 4; // last index at which a 4-byte match can start
@@ -216,7 +211,6 @@ public final class ByteScan {
/**
* Whether the comma-separated, OWS-tolerant token list {@code view} contains {@code token}
* (case-insensitive). The shared scanner behind both {@code Http1KeepAlive.isKeepAlive} and
* the {@code Connection: Upgrade} check ({@code EX-13}) — a single home so the two can never
* drift apart the way a whole-value {@code equals} check once did.
*/
public static boolean tokenListContains(ByteView view, String token) {
@@ -238,7 +232,6 @@ public final class ByteScan {
return equalsIgnoreCase(view, start, start + wlen, token);
}
// ── Header-name hash (EX-09) ─────────────────────────────────────────────
/**
* Case-insensitive (ASCII fold) 32-bit FNV-1a hash of {@code buf[start, start + len)}. Used
@@ -8,12 +8,8 @@ import java.nio.charset.StandardCharsets;
* on an already-warm buffer (the steady-state case: the buffer has already grown to the
* connection's high-water mark), no method here allocates.
*
* <p>This is the infrastructure {@code EX-27} (Phase 6, collapsing {@code Http1ResponseWriter}'s
* ~10 small writes into one) and the Phase 5 frame layer serialize into: build a complete
* message into a {@code ByteWriter}-backed scratch buffer, then issue one bulk
* {@code write(buffer, 0, length())} — the same "serialize outside the lock, one bulk write"
* discipline {@link dev.relism.flash.h2.frame.Http2FrameWriter} already established for the h2
* writer (see its Javadoc's "Layer 1"), extended to the byte layer both protocols share.
* Callers build a complete message in a {@code ByteWriter}-backed scratch buffer and then issue
* one bulk {@code write(buffer, 0, length())}. The same writer is shared by HTTP/1.1 and HTTP/2.
*
* <h3>Lifetime and thread-safety contract</h3>
* Not thread-safe — exactly one writer at a time, matching every other per-connection scratch
@@ -24,6 +20,7 @@ import java.nio.charset.StandardCharsets;
*/
public final class ByteWriter {
private byte[] buf;
private final byte[] digits = new byte[20];
private int len;
public ByteWriter(int initialCapacity) {
@@ -80,9 +77,8 @@ public final class ByteWriter {
writeByte((byte) '0');
return;
}
// Digits emerge least-significant-first; stage them in a small fixed buffer (at most 20
// digits for any long) and copy in reverse — avoids a second pass to compute digit count.
byte[] digits = new byte[20];
// Digits emerge least-significant-first. The reusable field holds every possible long
// representation, so decimal rendering does not allocate on a warm writer.
int n = 0;
long v = value;
while (v > 0) {
@@ -101,7 +97,6 @@ public final class ByteWriter {
writeByte((byte) '0');
return;
}
byte[] digits = new byte[8];
int n = 0;
int v = value;
while (v != 0) {
@@ -125,10 +120,8 @@ public final class ByteWriter {
/**
* Writes {@code s}'s ASCII bytes, case preserved. {@code s} must be ASCII-only. Unlike
* {@code new String(...).getBytes(UTF_8)}, writes each character directly into this
* writer's buffer — no intermediate {@code byte[]} ({@code EX-20}: this is what lets
* {@code Response.header(String, String)} avoid the {@code StringBuilder}+concat+
* {@code getBytes} allocation chain it used to pay per call).
* {@code new String(...).getBytes(UTF_8)}, writes each character directly into this buffer
* and avoids an intermediate {@code byte[]}.
*/
public void writeAscii(String s) {
int n = s.length();
@@ -1,7 +1,6 @@
package dev.relism.flash.bytes;
/**
* A mutable, reusable {@link ArrayBackedByteView} — the {@code EX-05} fix. Replaces the
* per-call {@code new ByteView() { ... }} anonymous-class allocation that used to live in
* {@code Http1HeaderMap.view}, {@code QueryParams.view}, and {@code PathParams.view}: instead of
* allocating a fresh view object (plus its capturing instance) on every call, a small
@@ -12,7 +12,6 @@ import dev.relism.fpr.core.ByteView;
* <h3>Deliberately not array-backed</h3>
* This does not implement {@link ArrayBackedByteView} — there is no single {@code (array,
* offset)} pair that describes it — and {@link #supportsLong()} returns {@code false}
* unconditionally rather than attempting a cross-segment 8-byte read ({@code EX-04}'s word-at-a-
* time path is only sound for a genuinely contiguous backing array; see
* {@code FastPathViews.MethodPathByteView} for the other deliberately-segmented view in this
* codebase, which makes the same choice for the same reason).
@@ -2,7 +2,6 @@ package dev.relism.flash.bytes;
/**
* A small, fixed-size ring of {@link PooledSlice} instances — one per {@code ConnectionScratch}-
* held call site that used to allocate a fresh {@code ByteView} per call ({@code EX-05}:
* {@code Http1HeaderMap.view}, {@code QueryParams.view}, {@code PathParams.view}).
*
* <h3>Why a ring, not a single reused slice</h3>
@@ -3,8 +3,6 @@ package dev.relism.flash.exceptions;
/**
* Thrown by the HTTP/1.1 parser when a request violates a protocol rule that must be rejected
* outright — most importantly the request-smuggling defenses of RFC 9112 §6.1 (see
* {@code EX-02}/{@code EX-03} in {@code flash/docs/http2/IMPLEMENTATION-PLAN.md}) and the hard
* safety limits in {@code Http1Limits} (see {@code EX-08}).
*
* <p>Distinct from {@link HttpException}, which a <em>handler</em> throws to describe an
* application-level failure and which is routed through the user's configured exception
@@ -64,7 +64,6 @@ public class FlashConfiguration {
* (see {@code dev.relism.flash.transport.BufferedByteSource}), not merely a per-read socket
* timeout a per-read timeout alone never trips as long as each individual read succeeds
* within the window, no matter how long the overall header block takes. Default: 10 000
* ({@code EX-07}).
*/
@Builder.Default
int headerReadTimeoutMs = 10_000;
@@ -74,7 +73,6 @@ public class FlashConfiguration {
* request before being closed. More generous than {@link #headerReadTimeoutMs} because an
* idle keep-alive connection is normal, expected behaviour, not an attack in progress the
* tighter bound applies only once bytes have actually started arriving. Default: 60 000
* ({@code EX-07}).
*/
@Builder.Default
int idleKeepAliveTimeoutMs = 60_000;
@@ -82,7 +80,6 @@ public class FlashConfiguration {
/**
* Maximum time, in milliseconds, a request's body may take to be fully read (by the handler
* or by the automatic drain after it returns) once headers are parsed. Default: 30 000
* ({@code EX-07}).
*/
@Builder.Default
int bodyReadTimeoutMs = 30_000;
@@ -90,15 +87,12 @@ public class FlashConfiguration {
/**
* Maximum time, in milliseconds, {@link dev.relism.flash.ServerHandle#stop()} waits for
* in-flight requests to finish after it stops accepting new connections, before force-
* closing whatever remains. Default: 15 000 ({@code EX-32} the graceful two-stage
* shutdown this bounds is wired up starting Phase 2).
*/
@Builder.Default
int shutdownDrainTimeoutMs = 15_000;
/**
* Whether this server will ever negotiate HTTP/2. Default {@code false}: until the h2
* connection state machine exists (Phase 8) there is nothing to negotiate into, so this
* flag currently only gates the h2c cleartext-preface detection
* ({@code dev.relism.flash.transport.ProtocolNegotiator}) skipping it entirely keeps
* plaintext connections byte-for-byte identical to pre-HTTP/2 Flash when left at its
@@ -1,73 +0,0 @@
/**
* 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;
@@ -6,14 +6,11 @@ import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
/**
* {@code EX-16}: RFC 9110 §6.6.1 an origin server with a clock SHOULD send {@code Date}.
* Flash never emitted it. Rather than formatting a timestamp on every response, a single
* daemon thread refreshes a pre-encoded {@code "Date: ...\r\n"} field line once per second into
* a {@code volatile byte[]}; {@code Http1ResponseWriter} writes it with one
* {@code OutputStream#write(byte[])} the cost per response is one volatile read and one
* write, never a format call (R4).
*
* <p>The parallel HPACK-encoded rendering for HTTP/2 responses is added in Phase 9.
*/
public final class DateHeader {
@@ -4,14 +4,10 @@ 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.
* Compare {@code dev.relism.flash.http2.Http2Limits}, the HTTP/2 equivalent.
*/
public final class Http1Limits {
@@ -30,7 +26,6 @@ public final class Http1Limits {
* peer to a finite, known-in-advance number rather than the effectively unbounded
* {@code Long.MAX_VALUE} the parser accepted before this limit existed. Comfortably above
* {@code Integer.MAX_VALUE} (~2.1 billion) so legitimate very-large declared lengths are
* not confused with the int-overflow bug this same fix (EX-03) also closes.
*/
public static final long MAX_CONTENT_LENGTH = 4L * 1024 * 1024 * 1024;
@@ -39,7 +34,6 @@ public final class Http1Limits {
* request with tens of thousands of one-byte headers passes the total header-block size
* check ({@code maxHeaderBufferSize}) while still forcing every subsequent
* {@code Http1HeaderMap} lookup to scan all of them turning a small request into quadratic CPU
* work per middleware that reads a header ({@code EX-08}, {@code EX-09}).
*/
public static final int MAX_HEADER_COUNT = 100;
@@ -98,19 +92,16 @@ public final class Http1Limits {
public static final int MAX_TRAILER_COUNT = 50;
/**
* {@code EX-27}: response bodies at or below this size are copied into the same scratch
* buffer as the response head (status line + headers) and written with it in a single
* {@code OutputStream.write} call; larger bodies are written in a second {@code write} right
* after the head, since copying a large body into the head buffer first would cost more
* (an extra full-body memcpy) than the syscall it saves. 8 KiB matches this codebase's
* other "one socket-buffer's worth" constants ({@code ConnectionScratch.RELAY_BUFFER_SIZE},
* {@code BufferedByteSource.DEFAULT_BUFFER_SIZE}) rather than introducing an uncalibrated
* new number; see {@code DECISIONS.md} for the measurement that confirmed this default.
*/
public static final int INLINE_BODY_THRESHOLD = 8192;
/**
* {@code EX-29}: maximum number of parts ({@code Multipart}) accepted in a single
* {@code multipart/form-data} body. Without this bound, a peer can send an unbounded number
* of minimal parts each cheap individually but forcing unbounded growth of the parser's
* {@code scanned} list and unbounded per-part header-parsing work, the multipart analogue of
@@ -119,7 +110,6 @@ public final class Http1Limits {
public static final int MAX_MULTIPART_PARTS = 1_000;
/**
* {@code EX-29}: maximum number of header lines ({@code Content-Disposition},
* {@code Content-Type}, ) accepted per multipart part. Real clients send at most two or
* three; without a bound a peer could send an effectively unlimited number before the blank
* line that ends a part's header block, forcing unbounded {@code HashMap} growth per part.
@@ -127,7 +117,6 @@ public final class Http1Limits {
public static final int MAX_MULTIPART_PART_HEADER_COUNT = 20;
/**
* {@code EX-29}: maximum length, in bytes, of a single header line within a multipart part's
* header block. {@code Multipart.readLine} otherwise has no bound of its own to fall back
* on unlike the top-level HTTP headers (bounded by {@link #MAX_HEADER_VALUE_LENGTH} in
* {@code RequestParser}), a line here with no {@code \r\n} would grow its {@code StringBuilder}
@@ -136,7 +125,6 @@ public final class Http1Limits {
public static final int MAX_MULTIPART_HEADER_LINE_LENGTH = 8_192;
/**
* {@code EX-29}: maximum size, in bytes, of a single multipart part body that {@code Multipart}
* buffers eagerly into a {@code byte[]} text fields (always buffered) and, during a full
* {@code parts()}/{@code parts(String)} scan, file bodies too. {@link #MAX_CONTENT_LENGTH}
* bounds the whole request body, but at 4 GiB (and effectively unbounded for a chunked body,
@@ -155,7 +143,6 @@ public final class Http1Limits {
* hostile peer a handler that calls {@code header(...)} in an unbounded loop (e.g. echoing
* an unbounded collection into headers) would otherwise grow this connection's scratch region
* without limit for the rest of its lifetime, since it is never shrunk back down between
* requests. Phase 6's zero-alloc DoD names this bound explicitly.
*/
public static final int MAX_RESPONSE_HEADER_BYTES = 65_536;
@@ -58,7 +58,6 @@ public enum HttpStatus {
INSUFFICIENT_STORAGE (507, "Insufficient Storage"),
NETWORK_AUTHENTICATION_REQUIRED (511, "Network Authentication Required");
// EX-17: the bound used to be the hand-maintained constant 504, which silently threw
// ArrayIndexOutOfBoundsException from this static initializer the moment any constant
// above it (421, 431, 505, 507, 511 several of which HTTP/2 needs, see MISDIRECTED_REQUEST
// and REQUEST_HEADER_FIELDS_TOO_LARGE above) was added. Computed from values() instead, so
@@ -39,16 +39,13 @@ public final class Http1Connection implements ConnectionProtocol {
OutputStream out = ctx.out();
byte[] idleProbe = new byte[1];
// EX-06 (router half): created once per connection, exactly like `parser` above, and
// reused across every request on this connection see AbstractRouter#newScratch.
Object routeScratch = ctx.router().newScratch();
Object wsRouteScratch = ctx.wsRouter().newScratch();
// EX-21: one Response per connection, repositioned (never reallocated) per request.
Response pooledResponse = new Response(200, ContentType.TEXT_PLAIN);
while (!ctx.stopped().getAsBoolean()) {
// EX-07: wait for the next request to begin, bounded by the generous
// idle-keep-alive timeout sitting idle between keep-alive requests is normal, not
// an attack. Skipped when the parser already has bytes buffered from a previous
// read (HTTP pipelining): the next request has, by definition, already started, so
@@ -72,7 +69,6 @@ public final class Http1Connection implements ConnectionProtocol {
try {
request = parser.parse(in);
} catch (MalformedRequestException e) {
// EX-02/03/08/18: a fixed, minimal, non-customizable rejection never routed
// through a handler or the user's exception handler and the connection is
// always closed afterwards, never kept alive.
Response rejection = new Response(e.status(), e.getMessage(), ContentType.TEXT_PLAIN);
@@ -124,7 +120,6 @@ public final class Http1Connection implements ConnectionProtocol {
else if (result != null) response.setBody(result);
}
// EX-32: re-checked here, not just before dispatch a shutdown that begins while
// this handler was running (the common case: draining connections mid-request) must
// still force this response to Connection: close, not whatever was decided before
// the handler ran.
@@ -132,7 +127,6 @@ public final class Http1Connection implements ConnectionProtocol {
Http1ResponseWriter.writeResponse(out, response, request.method(), actuallyKeepAlive,
ctx.configuration().isSendDate(), ctx.scratch());
request.drain();
// EX-22/EX-21: these instances are about to be repositioned over the next request (or
// dropped, if the connection closes) poison them in dev mode so any reference the
// handler improperly retained (a captured field, an async callback) fails loudly on
// its next access instead of silently reading whatever comes next. Only the pooled
@@ -7,7 +7,6 @@ import dev.relism.fpr.core.ByteView;
* HTTP/1.1 keep-alive decision (RFC 9110 §7.6.1) and the shared {@code Connection} header
* token-list scanner both it and WebSocket upgrade detection need.
*
* <p>{@code EX-13}: {@code Connection} is a comma-separated token list
* (e.g. {@code "Connection: keep-alive, Upgrade"}), not a single value a whole-value compare
* against {@code "close"} misses exactly that case. {@link #tokenListContains} is the one
* scanner both this class's {@link #isKeepAlive} and {@code WebSocketUpgrade}'s
@@ -18,7 +18,6 @@ import java.nio.charset.StandardCharsets;
* serialization routing, handler dispatch, and the request loop live in
* {@link Http1Connection}.
*
* <h3>{@code EX-27}: one bulk write, not ~10 small ones</h3>
* The status line, {@code Content-Type}, {@code Date}, every custom header, and
* {@code Content-Length}/{@code Connection} are all serialized into
* {@link ConnectionScratch#responseHead} (a reused {@link ByteWriter}) before a single
@@ -58,7 +57,6 @@ public final class Http1ResponseWriter {
boolean keepAlive, boolean sendDate, ConnectionScratch scratch) throws IOException {
int statusCode = response.getStatusCode();
// RFC 9110 §8.6/§15: 204, 304 and all 1xx responses MUST NOT carry Content-Length or a
// body at all not "an empty one", none (EX-15). A HEAD response (RFC 9110 §9.3.2)
// still reports the Content-Length GET would have, but never writes body bytes.
boolean noContentAllowed = statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200);
boolean suppressBody = noContentAllowed || method == HttpMethod.HEAD;
@@ -71,7 +69,6 @@ public final class Http1ResponseWriter {
else writeStatusPhrase(head, statusCode);
head.writeBytes(CRLF);
// EX-15: a Content-Type of ContentType.NONE (empty byte[]) used to still emit the line
// "Content-Type: \r\n" a header with no value. Skip the line entirely instead.
byte[] contentType = response.getContentType();
if (contentType != null && contentType.length > 0) {
@@ -80,7 +77,6 @@ public final class Http1ResponseWriter {
head.writeBytes(CRLF);
}
// EX-16: precomputed once per second by a shared daemon thread one volatile read,
// one write into the scratch, never a per-response format call.
if (sendDate) head.writeBytes(DateHeader.bytes());
@@ -99,11 +95,9 @@ public final class Http1ResponseWriter {
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
head.writeBytes(CRLF);
// EX-14: HEAD reports the Content-Length GET would have (above) but never writes
// the body itself.
boolean writeBody = body != null && !suppressBody;
if (writeBody && len <= Http1Limits.INLINE_BODY_THRESHOLD) {
// EX-27: small body folded into the same scratch buffer head + body leave in
// one syscall.
head.writeBytes(body);
out.write(head.array(), 0, head.length());
@@ -1,4 +1,4 @@
package dev.relism.flash.h2;
package dev.relism.flash.http2;
/**
* The 14 HTTP/2 error codes defined by RFC 9113 §7.
@@ -6,7 +6,6 @@ package dev.relism.flash.h2;
* <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
@@ -1,11 +1,10 @@
package dev.relism.flash.h2;
package dev.relism.flash.http2;
/**
* A <b>connection-level</b> HTTP/2 error. Thrown anywhere a peer's frame, HPACK block, or
* SETTINGS value violates the protocol in a way that leaves the connection's state (the HPACK
* dynamic table, a flow-control window, the stream table) unrecoverable.
*
* <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.
@@ -47,8 +46,6 @@ public final class Http2Exception extends RuntimeException {
/**
* 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.
*/
@@ -1,22 +1,18 @@
package dev.relism.flash.h2;
package dev.relism.flash.http2;
/**
* 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
* Every wire-derived length, index, count, or size is checked against a named constant here
* never against an ad-hoc literal, and never by letting the
* underlying array or buffer throw on overrun. Each field's Javadoc names the specific attack
* or resource it bounds and, where one exists, the CVE.
*
* <p>These are compile-time defaults, not runtime configuration. 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>These are compile-time defaults, not runtime configuration. A limit becomes configurable
* only when the operational need and its safe range are established.
*
* <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.
* <p>Each field is introduced with the feature that enforces it; this class contains no unused
* placeholders.
*/
public final class Http2Limits {
@@ -101,7 +97,6 @@ public final class Http2Limits {
/**
* 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;
@@ -118,7 +113,6 @@ public final class Http2Limits {
/**
* 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;
@@ -160,7 +154,6 @@ public final class Http2Limits {
/**
* Maximum time, in milliseconds, {@code Http2FrameReader} may wait for a single frame's
* header and payload to fully arrive. Bounds the same slowloris-shaped hazard {@code
* BufferedByteSource}'s deadline mechanism already defends h1 against ({@code EX-07}):
* without it, a peer that sends 9 header bytes and then never sends the declared payload
* would hold this connection's frame reader waiting forever.
*/
@@ -1,4 +1,4 @@
package dev.relism.flash.h2;
package dev.relism.flash.http2;
/**
* A <b>stream-level</b> HTTP/2 error, scoped to one stream id. Results in an {@code RST_STREAM}
@@ -12,11 +12,8 @@ package dev.relism.flash.h2;
*
* <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.
@@ -1,4 +1,4 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
/**
* The frame-header flag bits (RFC 9113 §6), as bitwise constants plus predicate helpers.
@@ -1,4 +1,4 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
/**
* A <b>flyweight</b> over one frame's 9-byte header plus its payload location, both still living
@@ -11,7 +11,6 @@ package dev.relism.flash.h2.frame;
* the same reader same "do not retain past the handler" rule the rest of this codebase's
* buffer-backed flyweights (`Http1HeaderMap`, `WebSocketFrame`) already document. The payload bytes
* are also transient: whatever layer needs to retain a DATA frame's payload past this window
* must copy it out (R3 the connection read buffer is shared, single-threaded, and reused).
*
* <h3>Reserved bit and unknown types</h3>
* {@link #streamId()} has already had the wire's reserved high bit (RFC 9113 §4.1: "R: A
@@ -1,4 +1,4 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
/**
* The 10 HTTP/2 frame types (RFC 9113 §6), plus the shared per-type validation rules
@@ -9,7 +9,6 @@ package dev.relism.flash.h2.frame;
* {@code UNKNOWN} constant would misleadingly suggest "a recognised category of unrecognised
* frame", when the correct handling is simply "not this table, skip it").
*
* <h3>Per-type validation, table-driven (R4)</h3>
* Each constant carries the RFC-mandated payload length bounds, whether a zero stream id is
* required/forbidden/either, and whether the frame counts toward the CONTINUATION-flood guard
* ({@code EX}-style defence, {@code Http2Limits#MAX_CONTINUATION_FRAMES_PER_BLOCK}) see
@@ -20,7 +19,6 @@ public enum FrameType {
DATA(0x0, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
/** RFC 9113 §6.2. Header block fragment (HPACK). Stream id required. */
HEADERS(0x1, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
/** RFC 9113 §6.3. Deprecated priority signal — parsed and discarded, never acted on (DEC, Phase 5 task 7). */
PRIORITY(0x2, 5, 5, StreamIdRule.REQUIRED),
/** RFC 9113 §6.4. Stream-level error. Exactly 4 bytes (the error code). Stream id required. */
RST_STREAM(0x3, 4, 4, StreamIdRule.REQUIRED),
@@ -1,14 +1,13 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import dev.relism.flash.h2.Http2ErrorCode;
import dev.relism.flash.h2.Http2Exception;
import dev.relism.flash.h2.Http2Limits;
import dev.relism.flash.http2.Http2ErrorCode;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
/**
* Table-driven RFC 9113 per-frame-type validation: length bounds, the stream-id
* required/forbidden/either rule, and the two special-cased structural rules ({@code SETTINGS}'
* multiple-of-6 length, {@code PUSH_PROMISE} always rejected from a client) that do not fit a
* generic min/max/stream-id table. Table itself lives on {@link FrameType}'s constants (R4); this
* class is the code that reads it.
*
* <p><b>The error code is not uniform</b> read the RFC per violation, not just per type. A
@@ -80,7 +79,6 @@ public final class FrameValidator {
case EITHER -> { /* WINDOW_UPDATE: 0 (connection window) or non-zero (stream window) both valid */ }
}
// RFC 9113 §8.4 / this codebase's DEC-10: PUSH_PROMISE is a server-to-client-only frame
// (Flash advertises SETTINGS_ENABLE_PUSH=0 and never sends one); receiving one at all
// means the peer believes it is talking to a client, which is always a protocol error.
if (type == FrameType.PUSH_PROMISE) {
@@ -1,4 +1,4 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import dev.relism.flash.bytes.ByteWriter;
@@ -10,7 +10,6 @@ import dev.relism.flash.bytes.ByteWriter;
* size is rarely known before it is serialized (an HPACK-encoded header block, in particular,
* has no cheap way to be measured in advance).
*
* <p>This is the reason {@link Http2FrameWriter} (Phase 3) serializes a complete buffer and
* issues one bulk {@code write}, rather than streaming bytes as they are produced: streaming
* would require knowing the length <em>before</em> the first byte goes out, which back-patching
* deliberately avoids needing.
@@ -1,7 +1,7 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import dev.relism.flash.h2.Http2Exception;
import dev.relism.flash.h2.Http2Limits;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.EOFException;
@@ -18,7 +18,6 @@ import java.util.Arrays;
* One growable {@code byte[]} per connection, reused across every frame the same
* compact-before-grow discipline {@code RequestParser}'s own buffer uses. A frame's declared
* length is checked against {@link Http2Limits#MAX_FRAME_SIZE_LOCAL} <em>before</em> the buffer
* is ever grown to accommodate it (R8): a hostile 16 MB declared length is rejected at the
* length-check, not after an allocation already paid for it.
*
* <h3>Usage</h3>
@@ -72,7 +71,6 @@ public final class Http2FrameReader {
return null; // clean EOF: nothing buffered yet, peer closed between frames
}
int declaredLength = decodeLength(buffer, base);
// R8: checked BEFORE any further buffer growth or read a hostile declared length
// never causes an oversized allocation, only a rejection.
if (declaredLength > Http2Limits.MAX_FRAME_SIZE_LOCAL) {
throw Http2Exception.FRAME_SIZE_ERROR;
@@ -1,6 +1,6 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import dev.relism.flash.h2.Http2Limits;
import dev.relism.flash.http2.Http2Limits;
import java.io.IOException;
import java.io.InterruptedIOException;
@@ -25,7 +25,6 @@ import java.util.concurrent.locks.ReentrantLock;
* <p><b>Layer 2 {@link ReentrantLock}, never {@code synchronized}.</b> On Java 21, a virtual
* thread blocking inside {@code synchronized} pins its carrier platform thread; blocking on a
* {@link ReentrantLock} unmounts it instead (JEP 491, which removes the {@code synchronized}
* pinning behaviour, only lands in JDK 24+ see {@code EX-01}, {@code DEC-03}).
* {@code ReentrantLock} is also load-bearing here for a second reason {@code synchronized}
* cannot offer: {@link ReentrantLock#tryLock()}.
*
@@ -72,7 +71,6 @@ import java.util.concurrent.locks.ReentrantLock;
* the connection. This is bounded by {@link Http2Limits#WRITE_TIMEOUT_MS}, enforced by a shared
* background reaper ({@link WriteTimeoutReaper}) that interrupts the blocked thread past the
* deadline {@code Socket#setSoTimeout} bounds reads, not writes, so it cannot be used here.
* Registration happens once per writer (connection-setup cost, not per write R2 exempts
* connection setup), so arming/disarming the deadline for each individual write is two
* {@code volatile} field writes, not an allocation.
*/
@@ -111,8 +109,7 @@ public final class Http2FrameWriter {
* the lock for longer than that stream's own single bulk write.
*
* <p><b>Why the fast path is gated on {@code !queue.hasWork()}, not just {@code tryLock()}</b>
* (found by this phase's own stress test, at N=64/256 exactly the kind of bug R10 exists
* to catch): writing {@code intent} immediately, before anything already queued, is only
* Writing {@code intent} immediately, before anything already queued, is only
* safe when nothing is already queued. Without the {@code hasWork()} check, this sequence
* is possible and violates same-producer ordering, which the stress test asserts: a
* producer's {@code write(a)} then {@code write(b)} contends and both get queued
@@ -197,11 +194,9 @@ public final class Http2FrameWriter {
* A single shared daemon thread scanning every registered {@link Http2FrameWriter} for a
* blocking write that has overrun {@link Http2Limits#WRITE_TIMEOUT_MS}. One thread for the
* whole process (like {@code DateHeader}'s refresher), not one per connection registration
* is the only per-connection cost, and it is a connection-setup-time cost (R2-exempt), not a
* per-write one.
*
* <p>Deliberately does <em>not</em> ask each write to record a {@code System.nanoTime()}
* deadline an earlier version did, and Phase 3's own benchmark measured that single
* {@code nanoTime()} call (plus the extra volatile field it required) costing enough to miss
* the N=1 gate's 50 ns-over-baseline budget (recorded in {@code WRITER.md}). Instead, the
* reaper counts <em>consecutive scans</em> a given writer has been observed still blocked
@@ -239,7 +234,7 @@ public final class Http2FrameWriter {
}
}
}
}, "flash-h2-write-timeout-reaper");
}, "flash-http2-write-timeout-reaper");
reaper.setDaemon(true);
reaper.start();
}
@@ -1,4 +1,4 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import java.util.concurrent.atomic.AtomicReference;
@@ -1,8 +1,8 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.h2.Http2ErrorCode;
import dev.relism.flash.h2.Http2Exception;
import dev.relism.flash.http2.Http2ErrorCode;
import dev.relism.flash.http2.Http2Exception;
/**
* RFC 9113 §6.1 (DATA) / §6.2 (HEADERS) padding. When {@link FrameFlags#PADDED} is set, a
@@ -16,7 +16,6 @@ import dev.relism.flash.h2.Http2Exception;
* <h3>Flow control (forward note, not implemented here)</h3>
* RFC 9113 §6.9.1: padding bytes count against the DATA flow-control window even though they
* carry no data the <em>whole</em> frame payload (pad-length byte + data + padding) is what a
* future Phase 11 flow controller must subtract from the window, not just {@link
* #dataLength(long)}. This class only locates the data range within the payload; it performs no
* flow-control accounting itself.
*/
@@ -1,4 +1,4 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
/**
* "Serialize yourself, then hand me the finished bytes." The interface a stream (and,
@@ -5,19 +5,15 @@ import dev.relism.fpr.core.ByteView;
import java.util.List;
/**
* The read-side contract every header container implements, protocol-neutral: {@link
* Http1HeaderMap} backs it with an HTTP/1.1 byte-buffer range today; a Phase 10
* {@code Http2HeaderMap} will back it with HPACK-decoded (name, value) pairs. Neither concrete
* shape leaks into this interface there is no {@code reset(byte[], int, int)} here, since that
* signature only makes sense for a byte-range-backed implementation.
* The read-side, protocol-neutral contract every header container implements. HTTP/1.1 uses a
* byte-range-backed implementation; HTTP/2 uses HPACK-decoded name/value pairs. Neither concrete
* representation leaks into this interface.
*
* <p>{@link RequestLine#getHeaders()} is typed as this interface (not a concrete class), which
* is what lets Phase 10 hand a {@link Request} an HPACK-backed header container without touching
* a single line of {@code Request}'s own code the entire point of this phase's refactor (R1:
* h1 and h2 are peers behind a shared abstraction, never one forking the other).
* <p>{@link RequestLine#getHeaders()} exposes this interface rather than a protocol-specific
* implementation so request handling remains independent of the transport protocol.
*
* <h3>Lifetime contract</h3>
* Every implementation lives on the connection (h1) or the stream (h2), not per-request, and is
* Every implementation lives on the connection (HTTP/1.1) or the stream (HTTP/2), not per-request, and is
* repositioned in place between requests never retain an instance past the handler that
* received it. {@code String} values returned by {@link #first}/{@link #all} are safe to retain
* (independent heap copies); {@link ByteView}s returned by {@link #view} and passed to {@link
@@ -22,8 +22,6 @@ import java.util.List;
* instance per connection) lives in the root {@code dev.relism.flash} package, and {@code http1}
* already depends on root (via {@code Http1Connection}'s use of {@code RequestParser}) placing
* this class in {@code http1} would require root to import back from {@code http1}, the exact
* kind of package cycle {@code DEC-19} already found and avoided once in this codebase. See
* {@code DECISIONS.md}, {@code DEC-22}, for the full reasoning; this note exists so a future
* reader does not "fix" the location back to what the plan's Files list originally suggested.
*
* <h3>Lifetime contract read carefully</h3>
@@ -45,7 +43,6 @@ import java.util.List;
* {@code byte[]} before leaving the synchronous handler scope.</li>
* </ol>
*
* <h3>{@code EX-09}: an index built once per {@link #reset}, not rescanned per lookup</h3>
* {@link #reset} scans the header section exactly once and records, per header, its name/value
* byte offsets and a case-insensitive 32-bit hash of the name into {@code int[]} arrays grown
* (never shrunk) to this connection's high-water mark. Every lookup method
@@ -62,7 +59,6 @@ public class Http1HeaderMap implements HeaderView {
private int sectionStart;
private int sectionEnd;
// EX-09 index grown (never shrunk) to this connection's high-water mark, rebuilt in place
// by every reset() call. Entry i's name is buffer[nameOffsets[i], nameOffsets[i]+nameLengths[i]),
// its value is buffer[valueOffsets[i], valueOffsets[i]+valueLengths[i]).
private int headerCount;
@@ -72,7 +68,6 @@ public class Http1HeaderMap implements HeaderView {
private int[] valueLengths = new int[INITIAL_INDEX_CAPACITY];
private int[] nameHashes = new int[INITIAL_INDEX_CAPACITY];
// EX-05: pooled, reused slices for view() see its own Javadoc for the reuse window.
private final SlicePool viewPool = new SlicePool(VIEW_POOL_SIZE);
// forEach's own pair, reused across every header of every call same idiom as viewPool,
@@ -81,7 +76,6 @@ public class Http1HeaderMap implements HeaderView {
private Slice nameSlice;
private Slice valueSlice;
/** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}, rebuilding the {@code EX-09} index. */
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
this.buffer = buffer;
this.sectionStart = sectionStart;
@@ -112,7 +106,6 @@ public class Http1HeaderMap implements HeaderView {
private void ensureIndexCapacity(int needed) {
if (needed <= nameOffsets.length) return;
// EX-08 (Http1Limits.MAX_HEADER_COUNT) already rejects any request with more headers
// than this before it ever reaches reset() this can only fire while growing toward
// that ceiling, never past it. Asserted, not silently truncated: an index that silently
// dropped headers past this point would be a correctness bug, not a capacity one.
@@ -203,7 +196,6 @@ public class Http1HeaderMap implements HeaderView {
/**
* Returns a zero-copy {@link ByteView} over the first value of {@code name}, or {@code null}.
*
* <h3>{@code EX-05}: pooled, not allocated per call</h3>
* The returned view is drawn from a small internal {@link SlicePool} rather than allocated
* fresh. It stays valid until either the request ends, or {@link #view} is called
* {@value #VIEW_POOL_SIZE} more times on this same {@code Http1HeaderMap} whichever comes
@@ -218,7 +210,6 @@ public class Http1HeaderMap implements HeaderView {
return viewPool.acquire(buffer, valueOffsets[i], valueLengths[i]);
}
/** Index into the {@code EX-09} arrays of the first header named {@code name}, or {@code -1}. */
private int indexOfHeader(String name) {
if (buffer == null) return -1;
int hash = ByteScan.hashNameIgnoreCaseAscii(name);
@@ -10,9 +10,7 @@ import java.nio.charset.StandardCharsets;
/**
* Path parameters captured during routing, stored as byte offsets into the path view.
* {@link #get} allocates a {@code String} on call (in one allocation when {@link #source} is
* {@link ArrayBackedByteView} {@code EX-25} two otherwise); {@link #view} is zero-copy.
*
* <h3>Reusable instances ({@code EX-19})</h3>
* The public constructor below builds a one-shot, fixed-size instance (used by
* {@code AbstractWsRouter} and by tests) {@code names.length} is taken as the exact param
* count. {@code FastPathRouterImpl}'s per-connection scratch instead owns a single long-lived
@@ -38,7 +36,6 @@ public class PathParams {
private final int[] lens;
private int count;
// EX-05: created lazily, only if view() is ever actually called.
private SlicePool viewPool;
public PathParams(ByteView source, String[] names, int[] starts, int[] lens) {
@@ -85,7 +82,6 @@ public class PathParams {
int i = indexOf(name);
if (i < 0) return null;
int start = starts[i], len = lens[i];
// EX-25: a single-copy String construction when the source is a contiguous array slice
// (always true for h1 today) instead of a byte-at-a-time copy into a scratch array
// followed by a second allocation for the String itself.
if (source instanceof ArrayBackedByteView abv) {
@@ -97,7 +93,6 @@ public class PathParams {
}
/**
* Returns a zero-copy view over path param {@code name}, or {@code null}. {@code EX-05}:
* drawn from a small internal {@link SlicePool} when {@link #source} is array-backed (always
* true for h1 today) same reuse-window contract as {@link Http1HeaderMap#view}. Falls back to a
* fresh (allocating) view otherwise never exercised on the real request path.
@@ -7,7 +7,6 @@ import java.util.Arrays;
* A header name/value pair pre-encoded once (typically at boot, as a {@code static final}
* constant) and reused across many responses via {@link Response#header(PreEncodedHeader)}.
*
* <h3>{@code EX-20}: why this exists alongside {@link Response#header(byte[])}</h3>
* The older {@code header(byte[])} overload takes an already-fully-rendered h1 field line
* (e.g. {@code "X-RateLimit-Limit: 100\r\n"}) fine for h1, but not valid HPACK: HPACK encodes
* a header as a compressed (name, value) pair, never as a literal CRLF-terminated line, so a
@@ -15,11 +14,10 @@ import java.util.Arrays;
* PreEncodedHeader} instead precomputes the {@code name}/{@code value} bytes <em>separately</em>
* (still once, still at boot) so either protocol's writer can render them in its own format
* {@link Response#header(byte[])} is kept, working, for h1-only callers, but is documented as
* ignored on a future h2 response path (there is no way to recover structured name/value data
* ignored on a future HTTP/2 response path (there is no way to recover structured name/value data
* from an opaque pre-rendered line); prefer this class for any header a handler wants to send on
* both protocols.
*
* <p>The HPACK-encoded rendering itself is Phase 9 scope (no HPACK encoder exists yet) this
* class stores the raw {@code name}/{@code value} bytes now, which is everything a future HPACK
* encoder needs to produce its own rendering from; it does not yet expose a precomputed HPACK
* byte form, since building one before HPACK exists would be speculative, untested API surface.
@@ -22,7 +22,6 @@ public class QueryParams {
private final ByteView raw;
// EX-05: created lazily, only if view() is ever actually called QueryParams itself is
// recreated per request (see Request#resolveQueryParams), so an eagerly-constructed pool
// would cost VIEW_POOL_SIZE allocations on every request that touches query params at all,
// even the (currently: every) request that never calls view().
@@ -40,7 +39,6 @@ public class QueryParams {
/**
* Returns a view over the first raw (not percent-decoded) value of {@code name}, or
* {@code null}. {@code EX-05}: drawn from a small internal {@link SlicePool} when
* {@link #raw} is array-backed (always true for h1 today) instead of allocated per call
* same reuse-window contract as {@link Http1HeaderMap#view}: valid until either the request ends
* or {@link #view} is called {@value #VIEW_POOL_SIZE} more times on this instance, whichever
@@ -116,7 +114,6 @@ public class QueryParams {
* {@code %XX} triplets are decoded to their byte values; {@code +} decodes as space.
* Invalid {@code %} sequences are passed through as-is.
*
* <p>{@code EX-26}: the overwhelmingly common query value contains neither {@code %} nor
* {@code +} scanned for first; when clean and {@link #raw} is array-backed, the
* {@code String} is built directly from the backing array in one allocation, skipping the
* scratch {@code byte[]} copy this method used to make unconditionally for every value.
@@ -29,7 +29,6 @@ import java.util.List;
* });
* }</pre>
*
* <h3>{@code EX-22}: pooled, not allocated per request</h3>
* A {@code Request} instance is owned by its connection (HTTP/1.1) or its stream (HTTP/2) and is
* recycled after the handler returns. <b>Do not retain it</b> the same instance is repositioned
* over the next request's data as soon as this one's handler returns. {@code equals}/
@@ -48,7 +47,6 @@ import java.util.List;
* loudly, and at the exact call site that misused it instead of silently reading whatever the
* next (or a completely different) request happened to reset this instance to. In production
* this check is a single {@code boolean} field read gated behind a {@code static final} flag the
* JIT treats as a trusted constant once the class is initialized see {@code DECISIONS.md} for
* the measured cost.
*/
public class Request {
@@ -65,7 +63,6 @@ public class Request {
private InetSocketAddress remoteAddress;
private SSLSocket sslSocket;
// EX-22 dev-mode poisoning guard: true from reset() until recycle() marks this instance
// unsafe to use further. Only consulted when poisoningEnabled is true (see checkActive()).
private boolean active;
@@ -137,7 +134,6 @@ public class Request {
/**
* Repositions {@code pooled} over a freshly-parsed request. {@code body} is already fully
* configured by the caller ({@code RequestParser}, which owns and resets its own pooled
* {@link RequestBody} for the fixed-length/chunked/empty cases see {@code EX-22}) this
* method's only job is wiring it, {@code requestLine}, and the connection identity fields
* into {@code pooled}.
*/
@@ -160,7 +156,6 @@ public class Request {
checkActive();
if (cachedPath != null) return cachedPath;
ByteView v = requestLine.getPath();
// EX-25: one allocation via a direct String(array, offset, length) construction when the
// view is a contiguous array slice (always true for h1 today), instead of a byte-at-a-time
// copy into a scratch array followed by a second allocation for the String itself.
if (v instanceof ArrayBackedByteView abv) {
@@ -10,7 +10,6 @@ import java.io.*;
* Safe to call multiple times; the second call returns the cached array. Throws for
* bodies larger than 2 GB.</li>
* <li>{@link #stream()} returns a bounded {@link InputStream} without upfront allocation.
* For fixed-length bodies this is a reused, repositioned view (see {@code EX-23} below)
* into the already-buffered header bytes stitched to the socket; for chunked bodies it is
* the raw {@link dev.relism.ChunkedInputStream} that de-chunks on the fly.</li>
* </ul>
@@ -21,13 +20,11 @@ import java.io.*;
* <p><b>Keep-alive:</b> unread body bytes are discarded by {@link Request#drain()} after the
* handler returns so the socket is correctly positioned for the next pipelined request.
*
* <h3>{@code EX-22}: pooled, not allocated per request</h3>
* One instance per connection (owned by {@code RequestParser}, repositioned via {@link #reset}
* for every request), the same treatment {@link Request}/{@link RequestLine} get. The {@link
* #of(byte[])} factory below remains for test/manual construction and returns a freestanding,
* unpooled instance exactly like {@link Request}'s own manual constructor.
*
* <h3>{@code EX-23}/{@code EX-24}: the reusable bounded stream and drain buffer</h3>
* {@link #stream()} used to allocate a {@link SequenceInputStream}, a {@link ByteArrayInputStream}
* and an anonymous bounded {@link InputStream} on every call. It now hands out one persistent
* {@link BoundedBufferedInputStream}, repositioned per request instead of reallocated.
@@ -45,10 +42,8 @@ public class RequestBody {
private byte[] resolved;
private long socketConsumed;
// EX-23: created once, repositioned per request via reset()'s call into boundedStream.reset(...).
private BoundedBufferedInputStream boundedStream;
// EX-24: created lazily on first chunked-body drain(), then reused for the life of the connection.
private byte[] drainBuffer;
/** Pooled instance, populated later via {@link #reset}. One per connection — see {@code RequestParser}. */
@@ -128,7 +123,6 @@ public class RequestBody {
* Returns a bounded {@link InputStream} over the body without upfront allocation.
*
* <p>For fixed-length bodies: a reused {@link BoundedBufferedInputStream} (see the class
* Javadoc, {@code EX-23}) serving any already-buffered header bytes followed by a bounded
* view of the socket stream zero allocation on a warm connection.
*
* <p>For chunked bodies: the raw {@link dev.relism.ChunkedInputStream} that de-chunks on
@@ -136,7 +130,6 @@ public class RequestBody {
* the next keep-alive request.
*
* <p>If {@link #bytes()} was called first, returns a fresh {@link java.io.ByteArrayInputStream}
* over the cached array a rare dual-access pattern, not the hot path {@code EX-23} targets.
*/
public InputStream stream() {
if (resolved != null) return new ByteArrayInputStream(resolved);
@@ -152,7 +145,6 @@ public class RequestBody {
void drain() {
if (isEmpty() || resolved != null) return;
if (contentLength < 0) {
// EX-24: InputStream.transferTo's default implementation allocates a fresh 8 KiB
// byte[] on every call replaced with a buffer this instance allocates once
// (lazily, only if a chunked body is ever actually drained) and reuses thereafter.
if (drainBuffer == null) drainBuffer = new byte[8192];
@@ -167,7 +159,6 @@ public class RequestBody {
}
/**
* {@code EX-23}: a reused, repositionable {@link InputStream} that serves bytes first from a
* caller-owned pre-buffered array, then from the socket, bounded overall to a fixed length
* replacing the {@code SequenceInputStream}+{@code ByteArrayInputStream}+anonymous-bounded-
* stream trio that used to be allocated fresh on every {@link #stream()} call. One instance
@@ -8,7 +8,6 @@ import dev.relism.flash.http.HttpMethod;
* and the header container. Internal reached via {@link Request#getRequestLine()}, not
* user-facing API.
*
* <h3>Pooled, like {@link Request} ({@code EX-22})</h3>
* One instance per connection, repositioned via {@link #reset} for every request rather than
* reallocated {@code RequestParser} owns it exactly the way it owns {@link Http1HeaderMap}.
* {@link #reset} is {@code public} rather than package-private matching
@@ -28,7 +28,6 @@ import java.util.List;
* return new Response(200, ContentType.TEXT_PLAIN).chunked(source);
* }</pre>
*
* <h3>{@code EX-21}: pooled, not allocated per request</h3>
* The connection driver (e.g. {@code Http1Connection}) owns one {@code Response} instance per
* connection, reset before every handler call rather than reallocated the same treatment
* {@link Request} gets (see its Javadoc for the full pooling/dev-mode-guard rationale, which
@@ -47,7 +46,6 @@ public class Response {
private boolean chunked;
private byte[] contentType;
// EX-20: custom headers stored as (name, value) byte pairs in one growable region, instead
// of a List<byte[]> of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder +
// char[] + String + getBytes() chain per header(String,String) call). Two backing stores,
// unified into one insertion-ordered sequence via headerTags/headerRefs, since a fully
@@ -63,7 +61,6 @@ public class Response {
private int[] headerRefs; // one entry per header(), in call order: index into the tag's store
private int headerCount; // total header() calls this response has recorded
// EX-21 dev-mode poisoning guard -- see Request's identical mechanism for the full rationale.
private boolean active = true;
private static volatile boolean poisoningEnabled = Flash.DEV;
@@ -207,7 +204,6 @@ public class Response {
}
/**
* Adds a response header. {@code EX-20}: writes {@code name}/{@code value} directly into a
* reused byte region (via {@link ByteWriter#writeAscii}) instead of building an intermediate
* {@code String} and re-encoding it zero allocation once the region has grown to this
* connection's high-water mark.
@@ -241,7 +237,7 @@ public class Response {
/**
* Adds a header from a {@link PreEncodedHeader} built once (typically at boot). Copies its
* precomputed {@code name}/{@code value} bytes into this response's region a memcpy, not a
* re-encode, and usable by a future h2 response path (unlike {@link #header(byte[])}) since
* re-encode, and usable by a future HTTP/2 response path (unlike {@link #header(byte[])}) since
* the name/value structure survives.
*/
public Response header(PreEncodedHeader preEncoded) {
@@ -277,7 +273,7 @@ public class Response {
*
* <p><b>h1-only</b>: a rendered {@code "Name: Value\r\n"} line carries no structured
* name/value data an HPACK encoder could use, so this header is not representable on a
* future h2 response path prefer {@link #header(PreEncodedHeader)} for anything that must
* future HTTP/2 response path prefer {@link #header(PreEncodedHeader)} for anything that must
* render correctly on both protocols. Kept for existing h1-only callers.
*/
public Response header(byte[] preEncoded) {
@@ -289,11 +285,7 @@ public class Response {
return this;
}
/**
* {@code EX-nn}: bounds the response-side analogue of the request header limits a handler
* that calls {@code header(...)} in an unbounded loop must not grow this connection's
* per-request scratch state without limit (Phase 6's zero-alloc DoD names this explicitly).
*/
/** Prevents an unbounded header loop from growing the connection's response scratch state. */
private void checkHeaderBudget() {
if (headerCount >= Http1Limits.MAX_RESPONSE_HEADER_COUNT) {
throw new IllegalStateException("response exceeds " + Http1Limits.MAX_RESPONSE_HEADER_COUNT
@@ -397,7 +389,6 @@ public class Response {
/**
* Writes every custom header directly into {@code head} (a scratch {@link ByteWriter}
* see {@code EX-27}), in call order. Zero-alloc when no headers are set or on a warm region.
* This is what {@code Http1ResponseWriter} uses; {@link #writeHeaders(OutputStream)} below
* (the {@code OutputStream} equivalent) exists for the streaming-body write paths that
* cannot fold their whole write into one scratch buffer.
@@ -5,8 +5,7 @@ import java.nio.charset.StandardCharsets;
/**
* The protocol-neutral enumeration of a {@link Response}'s header fields one source of truth
* consumed by every protocol's own writer, so {@code Content-Type}/custom-header logic is never
* duplicated (and cannot drift) between {@code Http1ResponseWriter} and a future h2 encoder
* (Phase 9). {@code Http1ResponseWriter} renders each field as {@code "Name: Value\r\n"}; the h2
* duplicated (and cannot drift) between {@code Http1ResponseWriter} and a future HTTP/2 encoder
* encoder will render the same fields via HPACK.
*
* <h3>Scope: response-object fields only, not connection framing</h3>
@@ -22,7 +21,7 @@ import java.nio.charset.StandardCharsets;
* recoverable (name, value) structure see that method's own Javadoc so it cannot appear in
* this enumeration. {@code Http1ResponseWriter} still renders it (via {@link
* Response#writeHeaders}, which handles both structured and raw entries, in the original call
* order); a future h2 writer will not be able to.
* order); a future HTTP/2 writer will not be able to.
*/
public final class ResponseSerializer {
private ResponseSerializer() {}
@@ -37,7 +36,6 @@ public final class ResponseSerializer {
/**
* Enumerates {@code response}'s fields in a fixed, deterministic order: {@code Content-Type}
* first (if set to a non-empty value {@code EX-15}: {@code ContentType.NONE} emits
* nothing, never an empty-valued header line), then every {@code header(String,String)}/
* {@code header(PreEncodedHeader)}-added field in call order. Zero allocation: every byte
* range handed to {@code consumer} is a slice of {@code response}'s own already-allocated
@@ -108,14 +108,12 @@ public abstract class AbstractRouter {
* same "create once per connection, reuse across requests" shape already used there for
* {@code RequestParser}.
*
* <p>{@code EX-06}'s router-half fix: a {@code ThreadLocal} here would mean "one per virtual
* thread", which under this codebase's one-virtual-thread-per-connection model is "one per
* connection with no upper bound and no pooling" — exactly the failure mode
* {@code ConnectionScratch} already exists to avoid for every other per-connection buffer.
* An explicit, caller-owned scratch object achieves the same per-connection reuse without
* that unbounded-growth risk, and without requiring {@code routing} to depend on
* {@code transport}'s {@code ConnectionScratch} type (this package has no such dependency
* today see {@code DECISIONS.md}, {@code DEC-19}, for why that boundary was kept rather
* than extending {@code ConnectionScratch} itself, which is what an earlier draft of this
* fix assumed).
*/
@@ -18,7 +18,6 @@ public abstract class AbstractWsRouter {
/**
* Creates a fresh per-connection scratch object for {@link #route}, or {@code null} if this
* router keeps no reusable per-connection state see {@link AbstractRouter#newScratch} for
* the full rationale ({@code EX-06}'s router-half fix), mirrored here for the WebSocket
* router.
*/
public Object newScratch() {
@@ -19,7 +19,6 @@ import java.util.Arrays;
* virtual {@code METHOD + path} byte sequence in a single pass; the per-connection
* {@link RouteScratch} ({@link #newScratch}) owns the reused {@link MatchResult},
* {@link FastPathViews.MethodPathByteView} and path-param arrays that would otherwise allocate
* (or, before {@code EX-06}'s router-half fix, sit in an unbounded {@code ThreadLocal}) on every
* request.
*/
public class FastPathRouterImpl extends AbstractRouter {
@@ -30,12 +29,10 @@ public class FastPathRouterImpl extends AbstractRouter {
public FastPathRouterImpl() {}
/**
* Per-connection reusable matching state {@code EX-06}'s router half and {@code EX-19}
* together. Created once per connection by {@link #newScratch} and threaded back into every
* {@link #route} call for that connection's lifetime (see {@link AbstractRouter#newScratch}
* for why this replaced the two {@code ThreadLocal}s this class used to hold).
*
* <p>{@code paramNames}/{@code paramStarts}/{@code paramLens} ({@code EX-19}) start small and
* grow (doubling, via {@link #ensureParamCapacity}) to the connection's high-water mark
* the number of path params the most param-heavy route matched on this connection ever
* needed and are never shrunk back down or reallocated once warm, the same amortized policy
@@ -14,7 +14,6 @@ import java.nio.charset.StandardCharsets;
public final class FastPathViews {
/**
* {@code EX-04}: {@code fpr-core}'s decompiled {@code ByteCompare} (its word-at-a-time
* router-matching fast path see {@code ByteCompare.equals}/{@code indexOf}) reads a
* comparison word via {@code MethodHandles.byteArrayViewVarHandle(long[].class,
* ByteOrder.LITTLE_ENDIAN)} and compares it bit-for-bit against whatever
@@ -35,14 +34,12 @@ public final class FastPathViews {
* {@link ByteView#longAt} implementation to. Caller-guaranteed contract (never asserted here
* {@code ByteCompare} itself never calls this without first checking {@code pos + 8 <=
* length}, so a defensive check here would be dead code on every real call path; see
* {@code EX-04}'s registry entry): {@code pos + 8 <= array.length}.
*/
private static long longAtLittleEndian(byte[] array, int pos) {
return (long) LONG_VIEW_LE.get(array, pos);
}
/**
* {@code EX-42}: not immutable {@link #reset} repositions an existing instance over new
* bounds instead of requiring a fresh allocation. {@code RequestParser} owns one pooled
* instance per role (path/query/protocol) per connection and calls {@link #reset} on it for
* every request, the same "do not retain past the handler" pooling contract every other
@@ -91,7 +88,6 @@ public final class FastPathViews {
return start;
}
/** {@code EX-04}: array-backed and contiguous — the word-at-a-time router path applies. */
@Override
public boolean supportsLong() {
return true;
@@ -110,9 +106,7 @@ public final class FastPathViews {
/**
* Mutable composite view: method bytes + path. Reused per connection, call {@link #reset}
* before use (see {@code FastPathRouterImpl}'s per-connection scratch, {@code EX-06}).
*
* <h3>{@code EX-04}: deliberately not array-backed, {@code supportsLong()} stays {@code false}</h3>
* Unlike every other view in this file, this one is a composite of two independent sources
* (a raw {@code byte[]} for the method, and another {@link ByteView} itself possibly
* array-backed for the path). There is no single backing array a word-at-a-time read could
@@ -173,7 +167,6 @@ public final class FastPathViews {
return 0;
}
/** {@code EX-04}: array-backed and contiguous — the word-at-a-time router path applies. */
@Override
public boolean supportsLong() {
return true;
@@ -212,7 +205,6 @@ public final class FastPathViews {
return 0;
}
/** {@code EX-04}: array-backed and contiguous — the word-at-a-time router path applies. */
@Override
public boolean supportsLong() {
return true;
@@ -12,9 +12,7 @@ import dev.relism.flash.websocket.WebSocketHandler;
/**
* WebSocket-upgrade counterpart of {@link FastPathRouterImpl} same {@code fpr-core} matching
* engine, same {@code EX-06} router-half fix (an explicit per-connection {@link RouteScratch}
* via {@link #newScratch} in place of the {@code ThreadLocal}s this class used to hold). Unlike
* {@link FastPathRouterImpl}, its path-param extraction is not covered by {@code EX-19} (that
* registry entry names {@code FastPathRouterImpl.route} specifically) and still allocates a
* fresh {@code PathParams} per matched, parametric WebSocket upgrade WebSocket upgrades are
* inherently rare relative to ordinary requests (one per connection, not one per message), so
@@ -26,9 +24,7 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter {
private volatile FastPathRouter<ByteView, WebSocketHandler> router;
private String[] cachedParamNames;
/** Per-connection reusable matching state see {@link FastPathRouterImpl.RouteScratch}'s
* javadoc for the full {@code EX-06} rationale; this router's scratch is smaller since
* {@code EX-19}'s path-param reuse does not apply here (see the class Javadoc). */
/** Per-connection reusable matching state. */
static final class RouteScratch {
final MatchResult<WebSocketHandler> matchResult = new MatchResult<>(32, 128);
final FastPathViews.MethodPathByteView combinedView = new FastPathViews.MethodPathByteView();
@@ -15,7 +15,6 @@ import java.util.Map;
* <p>
* Layout: seg[0] slot[0] seg[1] slot[1] seg[n-1] slot[n-1] seg[n]
*
* <h3>{@code EX-28}: slot lookup is O(1) per key-value pair, not O(slots)</h3>
* A slot name can appear more than once (e.g. {@code {{var}} == {{var}}}), so the map built at
* construction maps each name to the (usually single-element) array of every slot index using
* that name, instead of the nested "scan every slot for every pair" loop this used to do.
@@ -54,7 +54,6 @@ public final class TlsConfig {
private static final String[] SECURE_PROTOCOLS = { "TLSv1.3", "TLSv1.2" };
/**
* {@code EX-31}: RFC 9113 §9.2.2 requires that an HTTP/2 endpoint MUST NOT use any of these
* cipher suites over TLS 1.2 (the list is unchanged from RFC 7540 Appendix A, which 9113
* carries forward verbatim), and that it MUST support at least
* {@code TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}. TLS 1.3 is unaffected: none of its cipher
@@ -65,11 +64,9 @@ public final class TlsConfig {
* <p>Transcribed from Go's {@code golang.org/x/net/http2} {@code isBadCipher} table
* (BSD-licensed, itself an implementation of this exact RFC 9113 requirement, cross-checked
* against the IANA TLS Cipher Suite registry) rather than by hand from the RFC text, for the
* same reason Appendix D of {@code flash/docs/http2/IMPLEMENTATION-PLAN.md} insists the HPACK
* static table be transcribed from the RFC directly and verified: a transcription error in a
* ~280-entry list is easy to make and easy to miss, and here the failure mode is silently
* permitting a cipher suite RFC 9113 requires rejecting. Built once, in a static
* initializer (R4) never reconstructed per connection.
*/
private static final Set<String> TLS12_H2_BLOCKED_CIPHERS = Set.of(
"TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA",
@@ -435,7 +432,6 @@ public final class TlsConfig {
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true);
// EX-31: RFC 9113 §9.2.2 when this listener can negotiate h2, the enabled cipher
// suite list must exclude every suite on the TLS 1.2 blocklist. TLS 1.3 suites are
// never in that list (see TLS12_H2_BLOCKED_CIPHERS's Javadoc) so this only narrows
// which TLS 1.2 suites remain available; TLS 1.3 is unaffected either way.
@@ -452,7 +448,6 @@ public final class TlsConfig {
/**
* Whether this listener's configured ALPN protocol list ({@link #applicationProtocols})
* includes {@code "h2"}. Lets a caller (the connection runner, for logging; {@link #applyTo}
* itself, for {@code EX-31}'s cipher filtering) know a listener's h2 capability without
* duplicating the offered-protocols check.
*/
public boolean negotiatesH2() {
@@ -7,7 +7,6 @@ import java.net.SocketTimeoutException;
/**
* The single buffered view over one connection's inbound bytes, for the whole lifetime of the
* connection. Fixes {@code EX-10} (one syscall per byte in {@code ChunkedInputStream}) and
* gives {@link dev.relism.flash.transport.ProtocolNegotiator} a way to inspect the first bytes
* of a plaintext connection (the h2c preface) without consuming them.
*
@@ -30,7 +29,6 @@ import java.net.SocketTimeoutException;
* absolute {@link System#nanoTime()} deadline; every underlying socket read computes the
* remaining budget and hands exactly that to {@code setSoTimeout} before reading, so a
* {@link SocketTimeoutException} from an underlying read unambiguously means the deadline
* not just one read has been exceeded. This is what {@code EX-07} requires: "implement that
* deadline, do not rely on {@code setSoTimeout} alone."
*
* <h3>Thread-safety</h3>
@@ -92,7 +90,6 @@ public final class BufferedByteSource extends InputStream {
* ({@code SO_TIMEOUT = 0}). Must be called before any read the caller wants to be
* unbounded (e.g. handing the connection off to a long-lived WebSocket session loop).
*
* <p>{@code EX-37}: a {@code null} socket (the constructor accepts one every isolated unit
* test in this codebase that constructs a {@code BufferedByteSource} directly over a
* {@code ByteArrayInputStream} passes {@code null}, since there is no real connection to
* bound) is treated as "no OS-level timeout to clear", not an error only the deadline
@@ -255,7 +252,6 @@ public final class BufferedByteSource extends InputStream {
* before reading, so a {@link SocketTimeoutException} from {@code in.read} unambiguously
* means the deadline not merely one read has elapsed; see the class Javadoc.
*
* <p>{@code EX-37}: the expiry check above (throwing once {@code remainingNanos <= 0}) runs
* regardless of whether a real {@link Socket} is present; only the OS-level
* {@code setSoTimeout} call meaningless without a socket, and previously called
* unconditionally, which NPE'd the instant any deadline-bounded read ran against a
@@ -26,7 +26,6 @@ import java.util.function.BooleanSupplier;
* @param rawOut the unbuffered output stream for WebSocket, whose writes are already
* bulk (see {@code WebSocketSession})
* @param remoteAddress the client's address, or {@code null} if unavailable
* @param scratch this connection's reusable buffers ({@code EX-06})
* @param router the HTTP router
* @param wsRouter the WebSocket router
* @param configuration the server configuration (timeouts, limits, feature flags)
@@ -3,7 +3,6 @@ package dev.relism.flash.transport;
import java.io.IOException;
/**
* The h1/h2 seam R1 requires: the protocol decision is made once, immediately after
* ALPN/preface detection ({@link ConnectionRunner}), and dispatches to one implementation of
* this interface. After that point neither implementation knows the other exists.
*/
@@ -21,14 +21,12 @@ import java.util.function.BooleanSupplier;
/**
* Owns one connection's socket lifecycle from accept to close: configures socket options,
* forces the TLS handshake if applicable ({@code EX-30}), negotiates the protocol, and
* dispatches to the matching {@link ConnectionProtocol} guaranteeing cleanup (scratch
* release, active-socket tracking) regardless of how the protocol implementation exits.
*
* <p>Sole responsibility: connection setup/teardown. It contains no HTTP semantics at all
* those live entirely inside whichever {@link ConnectionProtocol} it dispatches to (today,
* always {@code Http1Connection}; an {@code H2} negotiation result is closed cleanly, since
* {@code Http2Connection} does not exist until Phase 8).
*/
@Slf4j
public final class ConnectionRunner {
@@ -77,7 +75,6 @@ public final class ConnectionRunner {
SSLSocket sslSocket = socket instanceof SSLSocket ssl ? ssl : null;
if (sslSocket != null) {
// EX-30: force the handshake explicitly, under a bounded timeout, before any
// protocol decision SSLSocket#getApplicationProtocol() (which
// ProtocolNegotiator relies on) returns null until the handshake has run.
socket.setSoTimeout(configuration.getHeaderReadTimeoutMs());
@@ -92,8 +89,7 @@ public final class ConnectionRunner {
BufferedByteSource in = new BufferedByteSource(socket.getInputStream(), socket);
NegotiatedProtocol negotiated = negotiateProtocol(socket, in);
if (negotiated == NegotiatedProtocol.H2) {
// No Http2Connection exists yet (lands in Phase 8) close cleanly rather than
if (negotiated == NegotiatedProtocol.HTTP_2) {
// attempt to speak a protocol this version cannot yet serve.
return;
}
@@ -6,7 +6,6 @@ import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* The {@code EX-06} fix. Owns every per-connection reusable buffer that used to live in a
* {@link ThreadLocal} on {@code HttpServer}: the decimal-formatting scratch, the streaming
* relay buffer, and the WebSocket-handshake {@link MessageDigest}.
*
@@ -25,10 +24,7 @@ import java.security.NoSuchAlgorithmException;
* pool when the connection closes. Never shared between two connections at once there is no
* synchronization here because none is needed.
*
* <p>{@code EX-06}'s router half (the {@code FastPathRouterImpl}/{@code FastPathWsRouterImpl}
* {@code ThreadLocal}s) is fixed in Phase 4, but deliberately <em>not</em> by extending this
* class: {@code routing} has no dependency on {@code transport} today, and folding the router's
* scratch fields in here would have created one see {@code DECISIONS.md}, {@code DEC-19}, for
* the opaque-per-connection-object mechanism ({@code AbstractRouter#newScratch}) used instead.
* This class gains HTTP/2 write/HPACK scratch in later phases, where {@code h2} already depends
* on {@code transport} and no such boundary concern applies.
@@ -45,7 +41,6 @@ public final class ConnectionScratch {
public final byte[] relayBuffer = new byte[RELAY_BUFFER_SIZE];
/**
* {@code EX-27}: the scratch {@code Http1ResponseWriter} serializes a whole response head
* (status line, {@code Content-Type}, {@code Date}, custom headers, {@code Content-Length}/
* {@code Connection}, and for small fixed bodies the body itself) into before issuing a
* single bulk {@code write()}, instead of ~10 small {@code OutputStream.write} calls.
@@ -2,9 +2,8 @@ package dev.relism.flash.transport;
/**
* The result of {@link ProtocolNegotiator#negotiate}: which protocol a connection will speak,
* decided once, immediately after ALPN or the h2c preface is inspected, per R1.
*/
public enum NegotiatedProtocol {
HTTP_1_1,
H2
HTTP_2
}
@@ -9,23 +9,21 @@ import java.util.Arrays;
/**
* Decides, once per connection and before any request is parsed, whether the connection speaks
* HTTP/1.1 or HTTP/2 the single seam R1 requires ("the protocol decision is made once,
* immediately after ALPN/preface detection").
*
* <p>Two independent signals, in order:
* <ol>
* <li><b>ALPN</b> (TLS connections). If the socket is an {@link SSLSocket} and the TLS
* handshake already resolved {@code "h2"} as the application protocol, this connection is
* {@link NegotiatedProtocol#H2}. Anything else negotiated {@code "http/1.1"}, no
* {@link NegotiatedProtocol#HTTP_2}. Anything else negotiated {@code "http/1.1"}, no
* protocol at all (a peer that doesn't speak ALPN), or an empty string is
* {@link NegotiatedProtocol#HTTP_1_1}. This costs nothing beyond a field read: ALPN is
* resolved during the handshake, which must already have completed (see
* {@code TlsConfig}'s Javadoc on why {@code startHandshake()} must be called explicitly
* before this method runs {@code EX-30}).</li>
* <li><b>h2c prior knowledge</b> (plaintext connections, RFC 9113 §3.4). The first 24 bytes of
* the connection are compared, without being consumed, against the client connection
* preface {@code "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"}. A match is
* {@link NegotiatedProtocol#H2}; anything else including a partial match followed by
* {@link NegotiatedProtocol#HTTP_2}; anything else including a partial match followed by
* EOF, or a preface look-alike that diverges partway through is
* {@link NegotiatedProtocol#HTTP_1_1}. This is why {@link BufferedByteSource#peek} exists:
* the bytes must remain available for {@code RequestParser} if they turn out not to be an
@@ -33,8 +31,7 @@ import java.util.Arrays;
* </ol>
*
* <p>This method reports the protocol accurately and unconditionally it does not consult
* {@code FlashConfiguration.http2Enabled}. Gating whether an {@link NegotiatedProtocol#H2}
* result is honoured (versus cleanly rejected, which is all Phase 1 can do there is no
* {@code FlashConfiguration.http2Enabled}. Gating whether an {@link NegotiatedProtocol#HTTP_2}
* {@code Http2Connection} yet) and whether the h2c peek is even attempted for plaintext
* connections are both the caller's responsibility, so that this class stays a pure,
* directly-testable detector (see {@code ProtocolNegotiatorTest}).
@@ -42,7 +39,6 @@ import java.util.Arrays;
public final class ProtocolNegotiator {
/**
* The HTTP/2 client connection preface (RFC 9113 §3.4) precompiled once (R4), never
* reconstructed per connection.
*/
private static final byte[] H2C_PREFACE =
@@ -54,13 +50,13 @@ public final class ProtocolNegotiator {
public static NegotiatedProtocol negotiate(Socket socket, BufferedByteSource source) throws IOException {
if (socket instanceof SSLSocket ssl) {
String applicationProtocol = ssl.getApplicationProtocol();
return "h2".equals(applicationProtocol) ? NegotiatedProtocol.H2 : NegotiatedProtocol.HTTP_1_1;
return "h2".equals(applicationProtocol) ? NegotiatedProtocol.HTTP_2 : NegotiatedProtocol.HTTP_1_1;
}
byte[] probe = new byte[H2C_PREFACE.length];
int n = source.peek(probe, 0, probe.length);
if (n == H2C_PREFACE.length && Arrays.equals(probe, H2C_PREFACE)) {
return NegotiatedProtocol.H2;
return NegotiatedProtocol.HTTP_2;
}
return NegotiatedProtocol.HTTP_1_1;
}
@@ -17,7 +17,6 @@ import java.util.concurrent.TimeUnit;
/**
* Owns the server's lifecycle: the accept threads (one per listener ×
* {@code TransportTuning.ACCEPT_THREADS}), the active-socket registry, and the two-stage
* graceful shutdown ({@code EX-32}) stop accepting, let in-flight connections drain up to
* {@code shutdownDrainTimeoutMs} (during which {@code Http1Connection} forces
* {@code Connection: close} on the next response once it observes {@link #isStopped()}), then
* force-close whatever remains.
@@ -88,7 +87,6 @@ public final class ServerLifecycle implements ServerHandle {
try { bl.socket().close(); } catch (IOException e) { log.error("Error closing server socket", e); }
}
// EX-32: give in-flight connections a chance to finish their current response and
// exit (Http1Connection forces Connection: close once it observes isStopped())
// before force-closing whatever is still open.
long deadlineNanos = System.nanoTime() + configuration.getShutdownDrainTimeoutMs() * 1_000_000L;
@@ -22,7 +22,6 @@ import java.util.concurrent.Executors;
* and the h1 protocol, and returns the {@link ServerHandle} implementation
* ({@link ServerLifecycle}) that {@link dev.relism.flash.ServerHandle#create} exposes publicly.
*
* <p>{@code EX-34}: this is the "composed transport rather than a god object" the registry
* asked for {@code ServerHandle.create} used to construct {@code HttpServer} directly, which
* no longer exists. Package-private-in-spirit (public only because {@code ServerHandle} lives
* in a different package and must call it) user code has no reason to call this directly.
@@ -4,7 +4,6 @@ import java.io.IOException;
/**
* Drives one {@link WebSocketSession}'s read loop until the session closes, dispatching frames
* to the user's {@link WebSocketHandler}. Extracted from {@code HttpServer} (Phase 2) its only
* responsibility is this loop; the handshake and upgrade detection live in
* {@link WebSocketUpgrade}.
*/
@@ -30,7 +29,6 @@ public final class WebSocketLoop {
}
}
} catch (WebSocketProtocolException e) {
// EX-12: tell the peer why, with the correct close code, before tearing down.
try { session.close(e.closeCode()); } catch (IOException ignored) { }
handler.onError(session, e);
} catch (IOException e) {
@@ -36,7 +36,6 @@ import java.util.concurrent.locks.ReentrantLock;
*
* <h3>Thread safety</h3>
* {@link #sendText}, {@link #send}, and {@link #close} are serialized on a
* {@link ReentrantLock} (never {@code synchronized} see {@code EX-01}: a virtual thread
* blocking inside {@code synchronized} pins its carrier platform thread on Java 21, and a
* blocking socket write is exactly the kind of call that can block. {@link ReentrantLock}
* unmounts the blocked virtual thread instead) and are safe to call from threads other than the
@@ -352,8 +351,7 @@ public final class WebSocketSession {
}
}
/** Bulk-reads {@code len} bytes into {@link #hdrScratch} starting at offset 0 the {@code
* EX-11} fix: the extended-length and mask-key bytes used to be read one at a time. */
/** Bulk-reads {@code len} bytes into {@link #hdrScratch} starting at offset 0. */
private void readFullyHeader(int len) throws IOException {
int remaining = len;
while (remaining > 0) {
@@ -13,7 +13,6 @@ import java.util.Base64;
/**
* WebSocket upgrade detection (RFC 6455 §4.2.1) and handshake response. Extracted from
* {@code HttpServer} (Phase 2) its only responsibility is deciding whether a request is an
* upgrade request and, if so, answering the {@code 101 Switching Protocols} handshake. The
* session loop itself lives in {@link WebSocketLoop}.
*/
@@ -40,7 +39,6 @@ public final class WebSocketUpgrade {
/**
* Whether {@code request} is a WebSocket upgrade request: {@code Upgrade: websocket} and a
* {@code Connection} header whose token list includes {@code upgrade} ({@code EX-13} the
* shared token-list scanner in {@link Http1KeepAlive} is what fixed the whole-value compare
* bug this check used to have too).
*/
@@ -127,7 +127,6 @@ class ChunkedInputStreamTest {
assertEquals("hi", new String(in.readAllBytes(), StandardCharsets.UTF_8));
}
// --- EX-10: no per-byte syscalls against the underlying stream ------------
/** Counts every {@code read} call that reaches the wrapped stream — i.e. every syscall. */
private static final class CountingInputStream extends ByteArrayInputStream {
@@ -159,7 +158,6 @@ class ChunkedInputStreamTest {
assertEquals(1, counting.reads);
}
// --- EX-02/09 chunk safety limits ------------------------------------------
@Test
void chunkSizeAboveLimit_rejected() {
@@ -17,7 +17,6 @@ import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-07}: a socket-level {@code SO_TIMEOUT} alone never trips against a peer that keeps
* trickling bytes slower than the timeout window each individual read still succeeds. These
* tests prove the absolute deadline in {@code dev.relism.flash.transport.BufferedByteSource}
* actually bounds the total time, not just each read.
@@ -142,7 +141,6 @@ class HttpServerTimeoutTest {
long start = System.nanoTime();
// A plain socket that never speaks TLS at all the server's explicit
// startHandshake() (EX-30) blocks waiting for a ClientHello that is never coming,
// and must be bounded by headerReadTimeoutMs rather than hanging forever. Whether the
// JSSE implementation sends a TLS alert record before closing or just closes outright
// is a JSSE implementation detail, not something this test should pin down the
@@ -13,8 +13,6 @@ import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* One test per rejection rule added in HTTP/2 plan Phase 1 (EX-02, EX-03, EX-08, EX-18, EX-35,
* EX-36), each asserting the specific status code {@link MalformedRequestException} carries
* not merely that some exception was thrown. {@code Http1Connection}/{@code ConnectionRunner} always closes the connection
* after any of these (never keep-alive); that behaviour is exercised at the integration level
* by {@code HttpServerTest}.
@@ -34,7 +32,6 @@ class RequestParserSecurityTest {
return assertThrows(MalformedRequestException.class, () -> parse(raw));
}
// --- EX-02: Content-Length + Transfer-Encoding smuggling -------------------
@Test
void contentLengthAndTransferEncodingBothPresent_rejected400() {
@@ -79,7 +76,6 @@ class RequestParserSecurityTest {
assertEquals(501, e.status());
}
// --- EX-03: strict Content-Length parsing -----------------------------------
@Test
void contentLength_nonDigitSuffix_rejected400() {
@@ -112,7 +108,6 @@ class RequestParserSecurityTest {
assertEquals(413, expect("POST / HTTP/1.1\nHost: h\nContent-Length: " + tooLarge + "\n\n").status());
}
// --- EX-08: header/request-line limits --------------------------------------
@Test
void tooManyHeaders_rejected431() {
@@ -140,7 +135,6 @@ class RequestParserSecurityTest {
assertEquals(431, expect("GET " + path + " HTTP/1.1\nHost: h\n\n").status());
}
// --- EX-18: bare CR / obs-fold -----------------------------------------------
@Test
void bareLfInsteadOfCrlf_headerLine_rejected() {
@@ -178,7 +172,6 @@ class RequestParserSecurityTest {
assertThrows(MalformedRequestException.class, () -> new RequestParser().parse(source(raw)));
}
// --- EX-36: header line missing ':' -------------------------------------------
@Test
void headerLineMissingColon_rejected400() {
@@ -56,7 +56,6 @@ class RequestParserTest {
assertEquals("2", r.query("page"));
}
// --- EX-42: pooled RequestByteViews (path/query/protocol) don't leak across requests ---
@Test
void samePooledParser_secondRequestWithoutQuery_doesNotLeakFirstRequestsQuery() throws IOException {
@@ -135,8 +134,6 @@ class RequestParserTest {
@Test
void missingHeaderTerminator_throwsMalformedRequestException() {
// Valid request line but stream ends before \r\n\r\n. Previously a generic IOException;
// now the same typed rejection EX-08's over-limit case uses, since both mean "the
// header block could never be completed within the allowed buffer" (EX-08).
byte[] raw = "GET / HTTP/1.1\r\nHost: localhost\r\n".getBytes(StandardCharsets.UTF_8);
assertThrows(MalformedRequestException.class, () -> new RequestParser().parse(source(raw)));
}
@@ -159,7 +156,6 @@ class RequestParserTest {
@Test
void headers_exceedingMaxBufferSize_throwsMalformedRequestException() {
// Feed more bytes than the configured cap with no \r\n\r\n : must throw 431 (EX-08).
int cap = 16 * 1024;
byte[] giant = new byte[cap + 1];
Arrays.fill(giant, (byte) 'A');
@@ -183,7 +179,6 @@ class RequestParserTest {
@Test
void transferEncoding_multiValueEndingInChunked_recognised() throws IOException {
// EX-35: "gzip, chunked" chunked need only be the FINAL coding (RFC 9112 §6.1). The
// old whole-value comparison misclassified this as not chunked at all.
String raw = "POST / HTTP/1.1\r\n" +
"Host: localhost\r\n" +
@@ -245,7 +245,6 @@ class MultipartTest {
}
// -------------------------------------------------------------------------
// EX-29: resource-exhaustion bounds
// -------------------------------------------------------------------------
@Test
@@ -10,31 +10,21 @@ import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.fail;
/**
* {@code R1}/{@code DEC-02}: HTTP/1.1 and HTTP/2 are peers behind the {@code ConnectionProtocol}
* seam, never coupled to each other directly. A lightweight source-scan rather than ArchUnit
* this project has no bytecode-analysis test dependency yet, and one import-statement check per
* package pair does not need one; record the choice here rather than in {@code DECISIONS.md}
* since it is this test's own implementation detail, not a design decision affecting shipped
* code.
*/
/** Ensures that the HTTP/1.1 and HTTP/2 implementations remain independent peers. */
class PackageBoundaryTest {
@Test
void http1DoesNotImportH2() throws IOException {
assertNoImportOfPackage("dev/relism/flash/http1", "dev.relism.flash.h2");
void http1DoesNotImportHttp2() throws IOException {
assertNoImportOfPackage("dev/relism/flash/http1", "dev.relism.flash.http2");
}
@Test
void h2DoesNotImportHttp1() throws IOException {
assertNoImportOfPackage("dev/relism/flash/h2", "dev.relism.flash.http1");
void http2DoesNotImportHttp1() throws IOException {
assertNoImportOfPackage("dev/relism/flash/http2", "dev.relism.flash.http1");
}
private static void assertNoImportOfPackage(String sourceDirRelative, String forbiddenImportPrefix) throws IOException {
Path root = findSourceRoot(sourceDirRelative);
// Neither package boundary can be meaningfully checked before both packages exist; once
// dev.relism.flash.h2 gains real classes (Phase 3+) this stops being a no-op for the
// h2-side test.
if (root == null) return;
try (Stream<Path> files = Files.walk(root)) {
@@ -45,7 +35,7 @@ class PackageBoundaryTest {
if (trimmed.startsWith("import " + forbiddenImportPrefix + ".")
|| trimmed.startsWith("import " + forbiddenImportPrefix + ";")) {
fail(file + " imports " + forbiddenImportPrefix
+ " violates the h1/h2 package boundary (R1/DEC-02): " + trimmed);
+ " and violates the HTTP protocol package boundary: " + trimmed);
}
}
}
@@ -9,7 +9,6 @@ import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
/**
* Randomized agreement testing for {@link ByteScan}'s SWAR methods against their scalar
* counterparts, per Phase 4's task 1 ("property-test SWAR against scalar on random inputs of
* every length 0..256 ... including unaligned starts"). {@link ByteScanTest} already covers
* every exact boundary deterministically; this class instead throws a large volume of fully
* random bytes and random sub-ranges at both implementations, on a fixed seed for reproducible
@@ -41,7 +41,6 @@ class HttpStatusTest {
assertNull(HttpStatus.reasonForCode(0));
}
// --- EX-17: bound computed from values(), not a hand-maintained constant -----
@Test
void statusesAboveThePreviousHandMaintainedBound_workCorrectly() {
@@ -26,7 +26,6 @@ class Http1ResponseWriterTest {
return out.toString(StandardCharsets.UTF_8);
}
// --- EX-14: HEAD ------------------------------------------------------------
@Test
void head_reportsContentLengthButWritesNoBody() throws IOException {
@@ -46,7 +45,6 @@ class Http1ResponseWriterTest {
assertTrue(raw.endsWith("hello world"), raw);
}
// --- EX-15: 204 / 304 / 1xx never carry Content-Length or a body ------------
@Test
void status204_omitsContentLengthAndBody() throws IOException {
@@ -85,7 +83,6 @@ class Http1ResponseWriterTest {
assertTrue(raw.contains("Content-Length: 1\r\n"), raw);
}
// --- EX-15: ContentType.NONE omits the Content-Type line entirely -----------
@Test
void contentTypeNone_omitsContentTypeLine() throws IOException {
@@ -101,7 +98,6 @@ class Http1ResponseWriterTest {
assertTrue(raw.contains("Content-Type: text/plain\r\n"), raw);
}
// --- EX-16: Date header -------------------------------------------------------
@Test
void sendDateTrue_includesDateHeader() throws IOException {
@@ -135,7 +131,6 @@ class Http1ResponseWriterTest {
assertTrue(raw.contains("Connection: close\r\n"), raw);
}
// --- EX-27: one bulk write for a small fixed body -----------------------------
/** Counts calls to {@code write(byte[], int, int)} — the only overload {@link Http1ResponseWriter} uses. */
private static final class CountingOutputStream extends java.io.OutputStream {
@@ -1,4 +1,4 @@
package dev.relism.flash.h2;
package dev.relism.flash.http2;
import org.junit.jupiter.api.Test;
@@ -53,7 +53,6 @@ class Http2ErrorCodeTest {
@Test
void bytesInstanceIsStablePerConstant() {
// Precomputed at class init (R4) must not be rebuilt per call.
assertSame(Http2ErrorCode.PROTOCOL_ERROR.bytes(), Http2ErrorCode.PROTOCOL_ERROR.bytes());
}
}
@@ -1,4 +1,4 @@
package dev.relism.flash.h2;
package dev.relism.flash.http2;
import org.junit.jupiter.api.Test;
@@ -1,4 +1,4 @@
package dev.relism.flash.h2;
package dev.relism.flash.http2;
import org.junit.jupiter.api.Test;
@@ -1,4 +1,4 @@
package dev.relism.flash.h2;
package dev.relism.flash.http2;
import org.junit.jupiter.api.Test;
@@ -1,7 +1,7 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import dev.relism.flash.h2.Http2ErrorCode;
import dev.relism.flash.h2.Http2Exception;
import dev.relism.flash.http2.Http2ErrorCode;
import dev.relism.flash.http2.Http2Exception;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@@ -175,7 +175,7 @@ class FrameValidatorTest {
@Test
void declaredLengthAboveMaxFrameSize_isFrameSizeError() {
FrameHeader h = headerOf(dev.relism.flash.h2.Http2Limits.MAX_FRAME_SIZE_LOCAL + 1, FrameType.DATA, 0, 1);
FrameHeader h = headerOf(dev.relism.flash.http2.Http2Limits.MAX_FRAME_SIZE_LOCAL + 1, FrameType.DATA, 0, 1);
assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false));
}
}
@@ -1,6 +1,6 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import dev.relism.flash.h2.Http2Exception;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.transport.BufferedByteSource;
import org.junit.jupiter.api.Test;
@@ -13,7 +13,6 @@ import java.util.Random;
import static org.junit.jupiter.api.Assertions.fail;
/**
* Phase 5's DoD: "Fuzz test green for 10 million random inputs." Throws fully random bytes at
* {@link Http2FrameReader} and asserts that only a typed, expected outcome ever results: a
* {@link Http2Exception} (a declared length exceeding {@code MAX_FRAME_SIZE_LOCAL} the
* overwhelmingly common outcome, since a random 24-bit length is astronomically likely to
@@ -1,7 +1,7 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.h2.Http2Exception;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.transport.BufferedByteSource;
import org.junit.jupiter.api.Test;
@@ -70,9 +70,9 @@ class Http2FrameReaderTest {
byte[] payload = new byte[len];
byte[] wire = buildFrame(FrameType.DATA, 0, 1, payload);
Http2FrameReader reader = new Http2FrameReader(sourceOf(wire));
if (len > dev.relism.flash.h2.Http2Limits.MAX_FRAME_SIZE_LOCAL) {
if (len > dev.relism.flash.http2.Http2Limits.MAX_FRAME_SIZE_LOCAL) {
Http2Exception ex = assertThrows(Http2Exception.class, reader::readFrame);
assertEquals(dev.relism.flash.h2.Http2ErrorCode.FRAME_SIZE_ERROR, ex.errorCode());
assertEquals(dev.relism.flash.http2.Http2ErrorCode.FRAME_SIZE_ERROR, ex.errorCode());
} else {
FrameHeader header = reader.readFrame();
assertNotNull(header);
@@ -151,7 +151,7 @@ class Http2FrameReaderTest {
out.beginFrame(FrameType.PING, 0, 0);
out.writer().writeBytes(new byte[]{1, 2, 3, 4, 5, 6, 7, 8});
out.endFrame();
out.beginFrame(FrameType.PING, dev.relism.flash.h2.frame.FrameFlags.ACK, 0);
out.beginFrame(FrameType.PING, dev.relism.flash.http2.frame.FrameFlags.ACK, 0);
out.writer().writeBytes(new byte[]{8, 7, 6, 5, 4, 3, 2, 1});
out.endFrame();
byte[] wire = new byte[w.length()];
@@ -1,4 +1,4 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import org.junit.jupiter.api.Test;
@@ -26,7 +26,6 @@ import static org.junit.jupiter.api.Assertions.*;
* full gate verification (1000 iterations per N, plus a
* {@code -Djdk.virtualThreadScheduler.parallelism=1} run to surface pinning/lost-wakeup bugs
* that only appear at parallelism 1) was run manually and is recorded, with its numbers, in
* {@code flash/docs/http2/WRITER.md} and {@code DECISIONS.md} (`DEC-09`).
*/
class Http2FrameWriterStressTest {
@@ -1,4 +1,4 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import org.junit.jupiter.api.Test;
@@ -1,7 +1,7 @@
package dev.relism.flash.h2.frame;
package dev.relism.flash.http2.frame;
import dev.relism.flash.h2.Http2ErrorCode;
import dev.relism.flash.h2.Http2Exception;
import dev.relism.flash.http2.Http2ErrorCode;
import dev.relism.flash.http2.Http2Exception;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
@@ -8,7 +8,6 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-09}: dedicated correctness coverage for {@link Http1HeaderMap}'s per-{@code reset()}
* index duplicate names, case variation, zero headers, and growth past the initial index
* capacity up to {@code Http1Limits.MAX_HEADER_COUNT}. {@link HeaderMapTest} already covers the
* ordinary lookup/forEach contract; this class targets the index machinery specifically.
@@ -104,7 +103,6 @@ class Http1HeaderMapIndexTest {
@Test
void allocation_indexArraysAreNotReallocatedOnceWarm() {
// The rigorous 0 B/op verification is the Phase 17 JMH gate (-prof gc); this is a
// unit-test-level structural guarantee that repeated first()/all()/view() lookups never
// re-trigger index growth (Arrays.copyOf inside ensureIndexCapacity) after the first
// reset() has already sized the arrays for this header count asserted by identity: the
@@ -124,7 +122,6 @@ class Http1HeaderMapIndexTest {
@Test
void view_poolWraparound_aliasesAnEarlierReturnedView() {
// EX-05's documented hazard, demonstrated through the actual public API: Http1HeaderMap's
// view() pool is sized 4 (VIEW_POOL_SIZE); a 5th call in the same request wraps around
// and silently repositions the object the 1st call returned.
dev.relism.fpr.core.ByteView v1 = null;
@@ -68,7 +68,6 @@ class PathParamsTest {
@Test
void view_poolWraparound_aliasesAnEarlierReturnedView() {
// EX-05's pooled path only engages when `source` is array-backed (ArrayBackedByteView)
// unlike of()'s plain inline ByteView (which exercises the non-pooled fallback, still
// correct but not the code path this test targets), use the same view type RequestParser
// actually produces.
@@ -9,10 +9,8 @@ import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-26}: the clean-value (no {@code %}/{@code +}) fast path in {@code QueryParams.decode}
* must produce byte-for-byte identical results to the percent-decoding slow path it bypasses
* verified here across clean values, values needing every kind of decoding, and the boundary
* between them. Also covers {@code EX-05}'s pooled {@code view()}.
*/
class QueryParamsFastPathTest {
@@ -63,7 +61,6 @@ class QueryParamsFastPathTest {
assertEquals("a b", qp.get("plussed"));
}
// EX-05: pooled view()
@Test
void view_returnsRawUndecodedBytes() {
@@ -162,7 +162,6 @@ class RequestBodyTest {
assertEquals(0, socket.available());
}
// --- EX-22/EX-23: pooled instance, repositioned via reset() --------------------
@Test
void reset_repositionsSamePooledInstance_overSuccessiveRequests() throws IOException {
@@ -187,7 +186,6 @@ class RequestBodyTest {
body.reset(new ByteArrayInputStream("two".getBytes(StandardCharsets.UTF_8)), 3, new byte[0], 0, 0);
InputStream stream2 = body.stream();
assertSame(stream1, stream2, "EX-23: stream() must reposition the one pooled BoundedBufferedInputStream, not allocate a new one per request");
assertEquals("two", new String(stream2.readAllBytes(), StandardCharsets.UTF_8));
}
@@ -9,13 +9,11 @@ import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-22}: {@link Request} is pooled <em>per connection</em> (one instance owned by
* {@code RequestParser}, repositioned via {@link Request#forParsed} for every request on that
* connection) not via a shared cross-connection pool. The plan's own safety-check wording
* ("connection A's {@code Authorization} header must never be visible on connection B") describes
* a threat model that does not structurally apply to this design: two different connections
* never share a {@code Request} instance at all (each owns its own {@code RequestParser}, hence
* its own {@code Request}) see {@code DECISIONS.md} for the pooling-granularity decision this
* follows from. The real, applicable threat this class actually tests: request <em>N+1</em> on
* the *same* keep-alive connection must never see stale data left over from request <em>N</em>,
* since those two requests genuinely do share one {@code Request} instance.
@@ -10,7 +10,6 @@ import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-22}'s dev-mode use-after-recycle guard. Exercises the poisoning check directly via
* {@code Request.setPoisoningEnabledForTesting} rather than the real {@code Flash.DEV} flag,
* which is a {@code static final boolean} fixed once at JVM startup and cannot be toggled by an
* individual test see that field's own comment in {@code Request.java}.
@@ -5,7 +5,6 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** {@code EX-21}: mirrors {@code RequestPoolingTest} for {@link Response}. */
class ResponsePoolingTest {
@Test
@@ -6,7 +6,6 @@ import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
/** {@code EX-21}'s dev-mode use-after-recycle guard — mirrors {@code RequestRecycleGuardTest}. */
class ResponseRecycleGuardTest {
@AfterEach
@@ -135,7 +135,6 @@ class ResponseTest {
assertTrue(new Response(200, new byte[0], ContentType.TEXT_PLAIN).getHeaders().isEmpty());
}
// --- EX-43: response header budget (Phase 6 zero-alloc DoD) ---
@Test
void header_exceedingMaxCount_throws() {
@@ -72,7 +72,6 @@ class FastPathRouterImplTest {
@Test
void route_reusesScratchAcrossManyRequests_includingGrowingParamCapacity() throws Exception {
// EX-19: the same scratch, reused across a mix of param counts, must keep matching
// correctly as its arrays grow past their initial size (8) and get reused afterward.
FastPathRouterImpl router = new FastPathRouterImpl();
router.doRegister(HttpMethod.GET, "/a/{p1}/{p2}/{p3}/{p4}/{p5}/{p6}/{p7}/{p8}/{p9}/{p10}",
@@ -12,12 +12,10 @@ import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-04}: verifies the {@code longAt()}/{@code supportsLong()} contract against
* {@code fpr-core}'s own word-at-a-time comparison code not merely against a hand-derived
* expectation, per the plan's explicit instruction to verify by testing against {@code fpr-core}
* directly rather than by reading its bytecode (bytecode-reading only informed which byte order
* to use; this test is the actual verification). A wrong endianness or a wrong bounds assumption
* here produces silently mis-routed requests, the worst possible failure mode ({@code EX-04}'s
* own registry entry) so this covers both the raw word-read contract and an end-to-end router
* match with the long path actually engaged.
*/
@@ -28,7 +28,6 @@ class FastPathViewsTest {
assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(10));
}
// --- EX-42: reset() repositions the same instance, zero allocation ---------
@Test
void requestByteView_reset_repositionsSameInstance() {

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