feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.8–14.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.
|
||||
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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
-1
@@ -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
-1
@@ -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, 7–9, and 14–15 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
-2
@@ -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
-4
@@ -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.
|
||||
*/
|
||||
+7
-14
@@ -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 +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
-1
@@ -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
-2
@@ -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
-3
@@ -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),
|
||||
+4
-6
@@ -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
-2
@@ -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.
|
||||
+3
-5
@@ -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;
|
||||
+4
-9
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.h2.frame;
|
||||
package dev.relism.flash.http2.frame;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
+3
-4
@@ -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
-1
@@ -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() {
|
||||
|
||||
-3
@@ -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;
|
||||
|
||||
+1
-5
@@ -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
-2
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.h2;
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.h2;
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.h2;
|
||||
package dev.relism.flash.http2;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
+4
-4
@@ -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));
|
||||
}
|
||||
}
|
||||
+2
-3
@@ -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
|
||||
+5
-5
@@ -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
-2
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
package dev.relism.flash.h2.frame;
|
||||
package dev.relism.flash.http2.frame;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
+3
-3
@@ -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() {
|
||||
|
||||
-1
@@ -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}",
|
||||
|
||||
-2
@@ -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.
|
||||
*/
|
||||
|
||||
-1
@@ -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
Reference in New Issue
Block a user