diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d904df8..1206836 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,8 +32,40 @@ jobs: server-username: MAVEN_USERNAME server-password: MAVEN_PASSWORD + - name: Install h2spec 2.6.0 + run: | + curl --fail --location --silent --show-error \ + --output /tmp/h2spec.tar.gz \ + https://github.com/summerwind/h2spec/releases/download/v2.6.0/h2spec_linux_amd64.tar.gz + echo "157ee0de702e01ad40e752dbf074b366027e550c8e7504f9450da2809e279318 /tmp/h2spec.tar.gz" \ + | sha256sum --check + tar --extract --gzip --file /tmp/h2spec.tar.gz --directory /tmp + + - name: Install nghttp client + run: | + sudo apt-get update + sudo apt-get install --yes nghttp2-client + + - name: Install grpcurl 1.9.3 + run: | + curl --fail --location --silent --show-error \ + --output /tmp/grpcurl.tgz \ + https://github.com/fullstorydev/grpcurl/releases/download/v1.9.3/grpcurl_1.9.3_linux_x86_64.tar.gz + echo "a926b62a85787ccf73ef8736b3ae554f1242e39d92bb8767a79d6dd23b11d1d5 /tmp/grpcurl.tgz" \ + | sha256sum --check + tar --extract --gzip --file /tmp/grpcurl.tgz --directory /tmp grpcurl + - name: Build and test - run: mvn -B --settings .github/settings.xml clean verify + run: >- + mvn -B --settings .github/settings.xml + -Dh2spec.executable=/tmp/h2spec + -Dcurl.executable=/usr/bin/curl + -Dnghttp.executable=/usr/bin/nghttp + -Dgrpcurl.executable=/tmp/grpcurl + -Djdk.tracePinnedThreads=full + -Pjmh + -Dflash.performance.gates=true + clean verify env: MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }} MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }} diff --git a/README.md b/README.md index 721c3eb..bce2420 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,13 @@ # Flash -A high-performance HTTP/1.1 server library for Java 21, built around virtual threads and a zero-allocation FSM router. +A high-performance HTTP/1.1 and HTTP/2 server library for Java 21, built around virtual threads, +a zero-allocation FSM router, bounded protocol state, and one shared request/response API. ## Modules | Module | Description | |---|---| -| `flash` | Core server library — router, request parser, HTTP I/O transport | +| `flash` | Core server library — HTTP/1.1 and HTTP/2 transport, router, request/response model | | `flash-extensions/flash-ext-jackson` | Jackson JSON integration | | `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI | | `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow | @@ -14,7 +15,6 @@ A high-performance HTTP/1.1 server library for Java 21, built around virtual thr | `flash-extensions/flash-ext-view-core` | Minimal shared SSR runtime primitives | | `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension | | `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension | -| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) | ## Requirements @@ -58,7 +58,7 @@ app.post("/echo", (req, res) -> { }); app.get("/users/{id}", (req, res) -> { - String id = req.pathParam("id"); + String id = req.param("id"); return "user:" + id; }); ``` @@ -167,13 +167,64 @@ app.onException((ex, req, res) -> { | `tls` | `null` | TLS for the default listener — see [TLS](#tls) | | `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — see [TLS](#tls) | | `maxHeaderBufferSize` | `65536` | Max size of the header buffer (bytes) | +| `wsFrameBufferSize` | `65536` | Per-connection WebSocket read buffer (bytes) | +| `headerReadTimeoutMs` | `10000` | Once a request's first byte arrives, how long the full header block may take. Bounds slowloris-style attacks — see [`HTTP1-HARDENING.md`](flash/docs/core/HTTP1-HARDENING.md). | +| `idleKeepAliveTimeoutMs` | `60000` | How long a keep-alive connection may sit idle waiting for its next request. | +| `bodyReadTimeoutMs` | `30000` | How long reading a request body (handler or automatic drain) may take. | +| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. | +| `maxConnections` | auto (~heap/10MB) | Maximum concurrent connections across all listeners before new ones are closed immediately at accept time, before any per-connection state (TLS handshake included) is created. Auto-scales from `Runtime.maxMemory()`; set explicitly for a known deployment size, or `0` to disable. | +| `http2Enabled` | `false` | Whether TLS listeners advertise HTTP/2 through ALPN. | +| `http2CleartextEnabled` | `false` | Whether plaintext listeners accept HTTP/2 prior knowledge (h2c). Independent from TLS HTTP/2. | +| `h2HuffmanDynamicValues` | `false` | HPACK-Huffman encode runtime response values. Constants remain pre-encoded; the measured default avoids an extra encode pass. | +| `h2MaxResetStreamsPerInterval` | `200` | Rapid Reset budget per rolling interval. | +| `h2MaxStreamsCreatedPerInterval` | `400` | New-stream budget per rolling interval. | +| `h2AbuseRateIntervalMs` | `10000` | Rolling interval for the two operator-tunable rate limits above. | +| `h2MaxStreamsPerConnection` | `100000` | Total stream budget; `0` disables it. | +| `h2MaxBytesPerConnection` | `0` | Optional total wire-byte budget; `0` disables it. | +| `h2MaxConnectionLifetimeMs` | `0` | Optional connection lifetime; `0` disables it. | +| `h2StreamIdleTimeoutMs` | `60000` | Inactive open-stream deadline. | +| `sendDate` | `true` | Add an RFC 9110 `Date` field to responses; disable when an upstream proxy supplies it. | + +## Protocols + +Routes, middleware, `Request`, `Response`, bodies, trailers, streaming and WebSockets use the same +API on HTTP/1.1 and HTTP/2. Protocol selection happens once per connection: + +- On TLS listeners, enable `http2Enabled`; Flash advertises `h2` and `http/1.1` through ALPN and + uses the protocol selected by the client. Existing HTTP/1.1 clients continue to work. +- On plaintext listeners, enable `http2CleartextEnabled` to accept the HTTP/2 prior-knowledge + preface on the same port as HTTP/1.1. Clients that do not send that exact preface are parsed as + HTTP/1.1. +- With both switches left at their default `false`, Flash behaves as an HTTP/1.1 server. + +After enabling the appropriate switch, application routes need no protocol-specific code. TLS +still requires the normal certificate configuration shown below. + +Flash deliberately does not implement HTTP/2 server push, RFC 7540 dependency-tree priority +scheduling, or the obsolete HTTP/1.1 `Upgrade: h2c` transition. Server push has no application API, +RFC 9113 deprecated the old priority scheme, and cleartext HTTP/2 uses prior knowledge instead. +See the [HTTP/2 compliance record](flash/docs/http2/COMPLIANCE.md) for exact coverage. + +## WebSockets over HTTP/2 + +The same `ws(path, handler)` route serves WebSockets over HTTP/1.1 and HTTP/2. When HTTP/2 is +enabled, Flash advertises RFC 8441 extended CONNECT support and carries WebSocket frames inside +flow-controlled DATA frames. No alternate handler, route, or session API is required: + +```java +app.ws("/live", handler); +``` + +HTTP/1.1 clients use the ordinary `101 Switching Protocols` upgrade. HTTP/2 clients use an +extended CONNECT and receive status `200`; Flash applies the same RFC 6455 framing, masking, +fragmentation, close, and callback behavior on both transports. Client support for negotiating +WebSockets over HTTP/2 varies, so clients without RFC 8441 support continue to use HTTP/1.1. ## TLS -HTTPS and WSS are a transport-layer concern only: once a listener is bound, the accepted -`Socket` is either plain or an `SSLSocket` indistinguishably from `HttpServer`'s point of view -onward — the request parser, router, and WebSocket upgrade never branch on it. WSS is therefore -not a separate feature; it's a WebSocket upgrade running over whatever transport it was handed. +HTTPS and WSS are a transport-layer concern only. Once a listener is bound, the accepted socket +is plain or TLS; the selected HTTP connection implementation then performs either the HTTP/1.1 +upgrade or the HTTP/2 extended CONNECT. WSS does not require a separate route or handler API. ### Quick start @@ -254,23 +305,108 @@ that got the request this far has already completed, never a forced handshake. `WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the upgrading `Request` — no separate TLS state is tracked for WS. +## Object lifetime + +`Request` and `Response` are **pooled per connection**, not allocated per request: one instance is +created per connection and repositioned (`reset()`) over each new request/response in turn — the +same idiom Java NIO buffers use, applied to the whole request/response model +(`flash/docs/core/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1 +request/response cycle 0 B/op. + +**Do not retain a `Request` or `Response` past the handler that received it.** A reference kept in +a field, a captured closure, a `CompletableFuture` continuation, or a background thread and read +*after* the handler returns will observe whatever the *next* request on that connection +repositioned the same instance to — not the request you thought you had: + +```java +// WRONG — captures `req`, reads it after the handler has returned +app.get("/slow", (req, res) -> { + CompletableFuture.runAsync(() -> log(req.header("X-Trace-Id"))); // may log the NEXT request's header + return "ok"; +}); +``` + +Copy out whatever you need before returning or handing work off asynchronously — every accessor +that returns a `String` (`header`, `param`, `query`, `path`, …) gives you an independent heap copy +that's safe to keep as long as you like: + +```java +app.get("/slow", (req, res) -> { + String traceId = req.header("X-Trace-Id"); // copy now, safe to retain + CompletableFuture.runAsync(() -> log(traceId)); + return "ok"; +}); +``` + +Run with `-Dflash.env=dev` and a use-after-return access throws `IllegalStateException` immediately +at the offending call site instead of silently reading the wrong request's data — turn this on in +tests and local development. It's a no-op in production beyond a single `boolean` field read. + +`req.body()`/`RequestBody` follows the same rule — materialise (`.bytes()`) or fully consume +(`.stream()`) it inside the handler; don't stash the `RequestBody` itself for later. + +### Reusable response headers + +Use `PreEncodedHeader` for a constant header sent by many responses. It stores the name and value +once and remains valid on both HTTP versions: + +```java +private static final PreEncodedHeader NO_STORE = + new PreEncodedHeader("cache-control", "no-store"); + +app.get("/health", (req, res) -> res.header(NO_STORE).body("ok")); +``` + +`Response.header(byte[])` accepts a complete CRLF-terminated HTTP/1 field line and is therefore +HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared +application and middleware code. + +### Trailers and push streaming + +Request trailers become available after the body reaches EOF: + +```java +byte[] payload = req.body().bytes(); +String status = req.trailers().first("grpc-status"); +``` + +For a producer-driven response, `Response.streaming` provides a blocking `ResponseStream`. Its +bounded buffer and HTTP/2 flow-control windows apply backpressure directly to the producer's +virtual thread: + +```java +return res.streaming(stream -> { + try { + stream.write(payload, 0, payload.length); + stream.trailer("result", "complete"); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } +}); +``` + +The API renders as chunked data and trailers on HTTP/1.1, and DATA plus trailing HEADERS on +HTTP/2. Flash core supplies these transport primitives; a higher-level gRPC codec belongs in a +future `flash-ext-grpc` extension. + ## Architecture ``` -ServerSocket.accept() - → RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive - → GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl - → RequestHandler.handle() # user handler; return value sets body - → Request.drain() # consume unread body for keep-alive - → HttpServer writes response # status line, headers, then fixed or chunked body - → loop or close socket # based on Connection header +TransportFactory.create() # binds every listener, wires the connection runner + → AcceptLoop # one per listener × accept thread; hands sockets off + → ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation + → ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once + ├─ Http1Connection.run() # request parser, router, handler, h1 response writer + └─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control + → RequestHandler.handle() # the same protocol-neutral request/response API ``` -- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`). Java 21 required. +- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required. - **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation. - **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection. - **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported. - **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which. +- **`ConnectionProtocol` seam** — HTTP/1.1 and HTTP/2 are peers behind this interface, selected once per connection by `ProtocolNegotiator`; routing and application models are shared. ## Build & test @@ -283,7 +419,4 @@ mvn test # Run a single test class mvn test -pl flash -Dtest=RequestParserTest - -# Run the benchmark demo server -java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar ``` diff --git a/flash/docs/core/BYTES.md b/flash/docs/core/BYTES.md new file mode 100644 index 0000000..5325394 --- /dev/null +++ b/flash/docs/core/BYTES.md @@ -0,0 +1,177 @@ +# The byte layer + +Audience: contributors. This is the design record for `dev.relism.flash.bytes` — the +protocol-neutral byte primitives both HTTP/1.1 and HTTP/2 build on — and for the Phase 4 +allocation/scanning fixes (`EX-04`, `EX-05`, `EX-09`, `EX-19`, `EX-25`, `EX-26`, `EX-33`, plus +`EX-06`'s router half) that consume them. + +## Why this exists + +Before Phase 4, byte-scanning and case-insensitive comparison logic was duplicated, slightly +differently, in `RequestParser`, `HeaderMap`, and `Http1KeepAlive`; `fpr-core`'s word-at-a-time +router-matching fast path (`ByteCompare`) was wired up but never actually enabled anywhere +(`EX-04` — every `ByteView` implementation returned `supportsLong() == false`); and four call +sites allocated a fresh view, array, or `String` per call on paths a realistic middleware chain +hits 6–10 times per request. `dev.relism.flash.bytes` is the single home these fixes converge on, +so no later phase (HPACK, the frame layer) has to invent its own scanning primitives. + +## Package layout + +``` +dev.relism.flash.bytes +├── ByteScan static scanning/comparison/hashing utilities, scalar + SWAR +├── ArrayBackedByteView capability interface: a ByteView backed by one contiguous byte[] +├── SegmentedByteView the deliberate non-array-backed case (K discontiguous segments) +├── PooledSlice reusable ArrayBackedByteView, the EX-05 fix +├── SlicePool a small fixed-size ring of PooledSlice +├── ByteWriter index-based writer into a growable byte[] scratch buffer +└── Pairs the (hi<<32)|lo allocation-free pair-return idiom, named +``` + +## The `ByteView` capability hierarchy + +``` +ByteView (fpr-core) +├── ArrayBackedByteView capability: array() + offset() +│ ├── FastPathViews.RequestByteView RequestParser's request-line/header slices +│ ├── FastPathViews.SocketByteView a bare byte[] (e.g. a WebSocket payload) +│ ├── FastPathViews.StringByteView a String's UTF-8 bytes +│ └── PooledSlice the EX-05 reusable, pool-issued slice +└── (bare ByteView, not array-backed) + ├── SegmentedByteView K discontiguous segments (general-purpose; HPACK stays contiguous) + └── FastPathViews.MethodPathByteView method bytes + another ByteView, composed +``` + +Code holding a bare `ByteView` and wanting the fast path when the concrete instance happens to +be array-backed does `instanceof ArrayBackedByteView` and falls back to the byte-at-a-time path +otherwise — see `ArrayBackedByteView`'s own Javadoc. This is used throughout Phase 4's fixes: +`Request.path()`, `PathParams.get()` (`EX-25`), and `QueryParams.decode`'s clean-value fast path +(`EX-26`) all take this shape. + +## `EX-04`: the `supportsLong()`/`longAt()` contract + +`fpr-core`'s `ByteCompare` (decompiled from `fpr-core-1.1.1`, since no source jar is published) +reads its comparison word via +`MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN)` and only takes the +word-at-a-time branch when the caller passed `useLong = true`, comparing the result bit-for-bit +against whatever `ByteView#longAt` returns. The contract this imposes on any `longAt` +implementation: + +- Return the same value `LONG_VIEW.get(array, pos)` would, for the identical 8 bytes — meaning + **little-endian**, fixed, regardless of the host's native byte order (unlike `ByteScan`'s own + SWAR internals, which use `ByteOrder.nativeOrder()` for speed — see below for why that's a + different, safe choice in a different context). +- The caller (`ByteCompare`) never calls `longAt(i)` without first establishing `i + 8 <= + length()` — so `longAt` implementations do not re-check this themselves (a defensive check + would be dead code on every real call path). + +`FastPathViews.RequestByteView`/`SocketByteView`/`StringByteView` implement this; +`MethodPathByteView` (composite, no single backing array) and `SegmentedByteView` (genuinely +discontiguous) both stay at the inherited `false` default — a word-at-a-time read is not merely +unimplemented for these, it is structurally unsound (a read could straddle two sources). + +**Verified against `fpr-core` directly** (`FastPathViewsLongAtTest`), not merely by reading +bytecode: `ByteCompare.equals`/`indexOf` called with `useLong=true` and `useLong=false` are +asserted to agree on identical content, on content diverging at every position across an +8+-byte range (word-interior, word-boundary, and scalar-tail cases), and end-to-end through a +real compiled `fpr-core` router with literal route segments ≥ 8 bytes — including a near-miss +route differing only in its last byte, to catch exactly the kind of bounds/endianness bug that +would otherwise silently mis-route a request (the failure mode `EX-04`'s registry entry calls out +by name as the worst possible one here). + +## `ByteScan`'s SWAR technique + +Both `ByteScan.indexOf` (single byte) and `ByteScan.indexOfCrLfCrLf` (the `\r\n\r\n` header +terminator, `EX-33`) use the classic "does this word contain byte `b`" bit trick: XOR the 8-byte +word against `b` broadcast into every lane, then test for any zero lane with +`(v - 0x0101...01) & ~v & 0x8080...80`. `indexOfCrLfCrLf` uses this as a pre-filter to find a +candidate `CR` byte 8 at a time, then a cheap scalar 3-byte check verifies the full 4-byte match +at each candidate — so a scan touches every byte once per 8-byte stride in the common +no-CR-yet case, rather than once per byte. + +This reads the word via `ByteOrder.nativeOrder()`, not a fixed order — safe here (unlike +`EX-04`'s `longAt`) because nothing compares this word against an independently-decoded one; +byte-equality detection itself (finding *that* a matching lane exists) is indifferent to lane +order, and position extraction (`laneIndexOf`) branches on the actual native order once, at +class-init time, to convert a matching bit back into the correct array index either way. + +Every SWAR method has a scalar counterpart (`indexOfScalar`, `indexOfCrLfCrLfScalar`) used as +the correctness oracle: `ByteScanTest` property-tests SWAR against scalar at every length 0–256 +and every match position (including unaligned starts and matches at the very last valid byte), +and `ByteScanFuzzTest` throws 20 000 fully-random trials at each, per the plan's task 1. All +green — see the class's own Javadoc for the full technique writeup. + +## `Http1HeaderMap`'s index + +Originally, every `Http1HeaderMap` lookup (`first`, `all`, `view`, `valueEqualsIgnoreCase`) +rescanned the entire header section from scratch — O(n·m) for a realistic middleware chain +performing 6–10 lookups per request. `RequestParser` now populates the index while it validates +each header line; direct `Http1HeaderMap.reset()` callers scan the section exactly once. It records +per-header `(nameOffset, nameLength, valueOffset, valueLength)` and a case-insensitive +32-bit FNV-1a hash of the name (`ByteScan.hashNameIgnoreCaseAscii`) into `int[]` arrays grown +(never shrunk) to the connection's high-water mark, capped by `Http1Limits.MAX_HEADER_COUNT` +(asserted, not silently truncated — the parser rejects a request that would exceed it). +Every lookup then compares the caller's own hash (`ByteScan.hashNameIgnoreCaseAscii(String)`, +computed once) against the index's hashes before ever falling back to a full case-insensitive +name comparison. Production therefore performs one combined validation/index pass rather than +one parse-time pass plus one rescan per lookup. `forEach` uses the same index rather than keeping +an independent scanner. + +## `EX-05`: pooled slices + +`Http1HeaderMap.view`, `QueryParams.view`, and `PathParams.view` used to allocate a fresh anonymous +`ByteView` (plus its capturing instance) on every call. Each now draws from a small +(`VIEW_POOL_SIZE = 4`) `SlicePool` of reusable `PooledSlice` instances instead. The lifetime +contract, restated on each method: **a returned view stays valid until either the request ends, +or the same `view()` method is called `VIEW_POOL_SIZE` more times on the same instance — +whichever comes first** — at which point the ring silently repositions the same object over +different bytes. This is a real, demonstrated hazard, not a hypothetical one: +`SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice` and the analogous tests in +`Http1HeaderMapIndexTest`, `QueryParamsFastPathTest`, and `PathParamsTest` all show a 5th call +returning the exact same object instance the 1st call did, now aliased to different content. + +`QueryParams` and `PathParams`'s pools are created **lazily**, on the first actual `view()` call +— not eagerly in the constructor — because both classes are otherwise-cheap objects created per +request (or, for `PathParams`'s `FastPathRouterImpl`-owned reusable instance, once per +connection) regardless of whether `view()` is ever invoked; an eager pool would add +`VIEW_POOL_SIZE` allocations to every such object whether or not it needed them; `Http1HeaderMap`'s +pool, by contrast, is unconditionally useful (every request's map handles headers) and is +constructed eagerly for simplicity. + +**Two documented, deliberately-kept exceptions to "no `new ByteView()` remains"**: `QueryParams.view` +and `PathParams.view` each retain a fallback anonymous `ByteView` for the case where their +backing source is *not* `ArrayBackedByteView` — structurally unreachable on the real request path +today (`RequestParser` only ever constructs array-backed views), kept because both constructors +are `public` and could in principle be called with an arbitrary `ByteView`. A silent, correct, +allocating fallback was judged preferable to either crashing on a technically-valid input or +deleting a case that only test code could exercise. `Http1HeaderMap.view` has no such fallback +— it is always buffer-backed by construction. + +## `EX-19`/`EX-06` (router half): the `FastPathRouterImpl` scratch + +`FastPathRouterImpl.RouteScratch` (created once per connection via `AbstractRouter#newScratch`, +replacing the `ThreadLocal`/`ThreadLocal` pair, as an opaque +caller-owned object rather than an extension of `ConnectionScratch`) also owns the reusable +path-param arrays and a single long-lived +`PathParams` instance, grown (via `ensureParamCapacity`, doubling) to the largest param count any +route on that connection has ever matched, and repositioned (`PathParams#reset`) rather than +reallocated on every match. `PathParams` gained a second, count-explicit constructor and a public +`reset(ByteView, int)` specifically for this: the reusable arrays can be larger than a given +request's actual param count, so `count` must be tracked independently of `names.length`. + +## `EX-25`/`EX-26`: single-allocation `String` construction + +`Request.path()`, `PathParams.get()`, and (for the common "no `%`/`+` in the value" case) +`QueryParams.decode` now build their result `String` directly from the backing array via +`new String(array, offset, length, UTF_8)` when the source is `ArrayBackedByteView`, instead of a +byte-at-a-time copy into a scratch `byte[]` followed by a second allocation for the `String` +itself. `QueryParams.decode` scans the value once for `%`/`+` first; only a value that actually +needs percent-decoding pays for the scratch-buffer path — verified to produce byte-identical +output to the always-decode path it bypasses, across clean values, `+`-only, `%XX`-only, invalid +escapes, and mixed queries (`QueryParamsFastPathTest`). + +## Performance measurement + +`EX-04` (the router's word-at-a-time path) and `EX-33` (the SWAR header-end scan) both carried an +explicit "measure, and keep only if it doesn't cost" requirement. Both were measured together +with the phase's overall zero-allocation contract in one JMH pass, and both were kept. diff --git a/flash/docs/core/HTTP1-HARDENING.md b/flash/docs/core/HTTP1-HARDENING.md new file mode 100644 index 0000000..35332f3 --- /dev/null +++ b/flash/docs/core/HTTP1-HARDENING.md @@ -0,0 +1,92 @@ +# HTTP/1.1 hardening + +Audience: operators. This is the document to read when a `400`/`413`/`414`/`431`/`501` shows up +in the logs and it isn't obvious why. Every rejection rule Flash's HTTP/1.1 parser enforces is +listed here with its RFC citation and the status it produces. Contributor-level detail (why each +check is implemented the way it is, the exact code paths) lives in the Javadoc of +`RequestParser`, `ChunkedInputStream`, and `dev.relism.flash.exceptions.MalformedRequestException`. + +Every rejection in this document has one thing in common: **the connection is always closed +afterwards, never kept alive.** A rejected request is exactly the situation a smuggling attack +needs a reusable connection for, so none of these rejections offer one — see +`MalformedRequestException`'s Javadoc. + +## Request-smuggling defenses (RFC 9112 §6.1) + +| Rule | Status | Detail | +|---|---|---| +| `Content-Length` and `Transfer-Encoding` both present | `400` | The canonical CL.TE/TE.CL smuggling vector. Rejected regardless of which header appears first. | +| Multiple `Content-Length` lines with **differing** values | `400` | Identical repeated values are tolerated (RFC 9110 §8.6 permits treating them as one). | +| `Transfer-Encoding` whose **final** coding is not `chunked` | `501` | Flash implements only `chunked`; anything else (`gzip` alone, or `chunked, gzip` — chunked must be *last*) is unsupported. | + +## Strict `Content-Length` parsing (RFC 9110 §8.6) + +| Input | Status | +|---|---| +| Empty value | `400` | +| Any non-digit byte (including a leading `+` or `-`) | `400` | +| More than 19 digits | `400` | +| Value overflows `Long.MAX_VALUE` | `400` | +| Value exceeds `Http1Limits.MAX_CONTENT_LENGTH` (4 GiB by default) | `413` | + +The previous parser silently skipped non-digit characters (`"5abc"` parsed as `5`; `"-1"` parsed +as `1`) instead of rejecting them — this is the fix. + +## Header and request-line limits (`Http1Limits`) + +| Limit | Default | Status when exceeded | +|---|---|---| +| `MAX_HEADER_COUNT` | 100 | `431 Request Header Fields Too Large` | +| `MAX_HEADER_NAME_LENGTH` | 256 B | `431` | +| `MAX_HEADER_VALUE_LENGTH` | 8192 B | `431` | +| `MAX_REQUEST_LINE_LENGTH` | 8192 B | `431` | +| Header block exceeds `maxHeaderBufferSize` (or the connection ends before it completes) | configurable, default 64 KiB | `431` | + +## Line-terminator and header-syntax correctness (RFC 9112 §5) + +| Rule | Status | +|---|---| +| A `\r` not immediately followed by `\n` (bare CR) | `400` — a known desynchronization/smuggling surface | +| A header line beginning with whitespace (obsolete line folding, RFC 9112 §5.2) | `400` | +| A header name containing a byte outside RFC 9110 §5.6.2's `tchar` set | `400` | +| A header line with no `:` | `400` | + +## Chunked transfer safety (RFC 9112 §7.1, `Http1Limits`) + +| Limit | Default | Status when exceeded | +|---|---|---| +| `MAX_CHUNK_SIZE` | 16 MiB | `413` | +| Chunk-size line longer than 16 hex digits | — | `400` | +| `MAX_CHUNK_EXT_LENGTH` (the optional `;name=value` after a chunk size) | 256 B | `400` | +| `MAX_CHUNKS_PER_BODY` | 100 000 | `413` | +| `MAX_TRAILER_COUNT` | 50 | `431` | +| A chunk's data not followed by `\r\n`, or a malformed chunk-size/trailer terminator | — | `400` | + +Trailers are consumed within the bounds above and exposed through `Request.trailers()` on both +HTTP/1.1 and HTTP/2. + +## Timeouts (`FlashConfiguration`) + +| Setting | Default | Covers | +|---|---|---| +| `idleKeepAliveTimeoutMs` | 60 000 | How long a keep-alive connection may sit idle waiting for its next request. | +| `headerReadTimeoutMs` | 10 000 | Once the first byte of a request arrives, how long the full header block may take. | +| `bodyReadTimeoutMs` | 30 000 | How long reading the body (by the handler, or the automatic post-response drain) may take. | +| `shutdownDrainTimeoutMs` | 15 000 | How long graceful shutdown waits for in-flight requests before force-closing. | + +These are enforced by an **absolute deadline**, not merely `Socket.setSoTimeout`. A per-read +socket timeout alone never trips against a peer that sends one byte just often enough to keep +each individual read alive (the classic slowloris shape) — see +`dev.relism.flash.transport.BufferedByteSource`'s Javadoc for how the absolute deadline is +implemented on top of the JDK's per-read-only timeout API. + +## TLS (RFC 9113 §9.2.2, applies once a listener offers `h2` over ALPN) + +- The TLS handshake is forced explicitly (not left to the JDK's lazy on-first-read trigger) + before any protocol decision is made, and is bounded by `headerReadTimeoutMs`. +- When a listener's `TlsConfig.applicationProtocols` includes `"h2"`, the enabled TLS 1.2 cipher + suite list is filtered against the RFC 9113 Appendix A blocklist + (`TlsConfig.TLS12_H2_BLOCKED_CIPHERS`, ~280 entries). TLS 1.3 is never affected — none of its + cipher suites are on that list. +- `FlashConfiguration.http2Enabled` advertises `h2` on TLS listeners. The independent + `http2CleartextEnabled` switch accepts the h2c prior-knowledge preface on plaintext listeners. diff --git a/flash/docs/core/MESSAGE-MODEL.md b/flash/docs/core/MESSAGE-MODEL.md new file mode 100644 index 0000000..4436cee --- /dev/null +++ b/flash/docs/core/MESSAGE-MODEL.md @@ -0,0 +1,191 @@ +# The message model + +Audience: contributors. This is the design record for `dev.relism.flash.models`'s shared +request/response model: what is pooled, what that pooling means for callers, and how HTTP/1.1 and +HTTP/2 retain the same public contract. + +## Why this exists + +Through Phase 5, `Request`, `RequestLine`, `RequestBody`, and `Response` were all allocated fresh +per request — `DEC-20` measured this at 120.008 B/op for parse+route alone, and traced 100% of it +to these four objects. Phase 6 pools all of them, following the same "one instance per connection, +repositioned via `reset()`, never reallocated" idiom `Http1HeaderMap` and `RequestLine` already +established in earlier phases. This document is the single place that idiom's contract — and the +hazards of misusing it — is written down for the whole model, instead of being re-derived from +each class's own Javadoc. + +## What is pooled, and by whom + +``` +RequestParser (one per connection) +├── Http1HeaderMap headerMap — reset() per request +├── RequestLine requestLine — reset() per request +├── Request request — reset() per request (via Request.forParsed) +├── RequestBody requestBody — reset() per request +├── RequestByteView pathView — reset() per request (EX-42) +├── RequestByteView queryView — reset() per request, only when present (EX-42) +└── RequestByteView protocolView — reset() per request (EX-42) + +Http1Connection (one per connection) +└── Response pooledResponse — reset() per request (unless a handler returns its own Response) + +FastPathRouterImpl.RouteScratch (one per connection, via AbstractRouter#newScratch) +└── PathParams pathParams — reset() per matched request (see BYTES.md, EX-19) +``` + +Every one of these follows the same three rules: + +1. **One instance per connection**, created once (in `RequestParser`'s or `Http1Connection`'s + constructor, or in `newScratch()`), never re-allocated for the connection's lifetime except a + backing array growing to a new high-water mark (e.g. `RequestParser.buffer` doubling, or + `RouteScratch.ensureParamCapacity`). +2. **`reset(...)` repositions, it does not allocate** — the method that transitions the instance + from "describes request N" to "describes request N+1". +3. **Do not retain past the handler.** A reference captured in a closure, a `CompletableFuture` + continuation, or a background thread and read after the handler returns will observe whatever + the *next* request repositioned the instance to — silently, unless the dev-mode guard below + catches it. + +## The dev-mode use-after-recycle guard (`Request`, `Response`) + +`Request` and `Response` — the two objects most likely to be captured by user code — additionally +track an `active` flag, set `true` by `reset()` and `false` by `recycle()` (called by +`Http1Connection` once the handler and `drain()` have finished). Every public accessor calls +`checkActive()` first: + +```java +private void checkActive() { + if (poisoningEnabled && !active) { + throw new IllegalStateException("... do not retain a Request past the handler ..."); + } +} +``` + +`poisoningEnabled` defaults to `Flash.DEV` (`-Dflash.env=dev`), so this is a zero-cost `static +final`-guarded branch in production and a loud, precise `IllegalStateException` — thrown at the +exact misusing call site — in development. Since `Flash.DEV` is itself `static final` (fixed at +JVM startup) and therefore not something a single test can toggle, both classes expose a +package-private `setPoisoningEnabledForTesting(boolean)` hook purely so +`RequestRecycleGuardTest`/`ResponseRecycleGuardTest` can exercise the dev-mode branch without a +fragile reflective override of a `static final` field — production code never touches it. + +`RequestBody`, `RequestLine`, `Http1HeaderMap`, and `PathParams` do **not** carry this guard: they +are reached only through `Request`/`Response` (or, for `PathParams`, through `Request.param`), +so `Request`/`Response`'s own guard already catches a stale read before it would reach these. + +## `RequestBody`: two read modes, one reused bounded stream + +`RequestBody.stream()` and `.bytes()` are mutually exclusive per request (calling both is +undefined). `EX-23`/`EX-24` (Phase 6) replaced two allocation sources in the streaming path: + +- `stream()` used to build a fresh `SequenceInputStream` + `ByteArrayInputStream` + anonymous + bounded `InputStream` on every call. It now repositions one persistent + `BoundedBufferedInputStream` (a private inner class) via `reset(preBuf, preBufOff, preBufLen, + socketRemaining)` — the same object is returned every time, just pointed at different bytes. +- `drain()`'s chunked-body path used to call `InputStream.transferTo`, whose default + implementation allocates a fresh 8 KiB `byte[]` on every call. It now drains through a lazily + created (only if a chunked body is ever actually drained), persistent `drainBuffer`. + +`RequestBody.of(byte[])`/`.empty()` remain as freestanding, unpooled factories for test/manual +construction (mirroring `Request`'s own manual constructor) — production's only pooled instance is +the one `RequestParser` owns. + +## `Response`: byte-level headers, one write, `ResponseSerializer` as the source of truth + +Before Phase 6, `Response.header(String, String)` stored headers as `List` — one `String` +concatenation and one `byte[]` allocation per call. `Response` now stores structured headers in a +`ByteWriter`-backed name/value region plus parallel `int[]` quads (`nameOff, nameLen, valOff, +valLen`), written via `ByteWriter.writeAscii` — zero-allocation on a warm connection. A second, +separate store (`List`) still holds the legacy `header(byte[])` raw-line entries; a tagged +sequence (`headerTags`/`headerRefs`) interleaves the two stores back into declaration order when +serialized, so mixing `header(String,String)` and `header(byte[])` calls on the same response still +produces headers in the order they were added. + +`PreEncodedHeader` precomputes a header's name and value ASCII bytes once (for example, a constant +response header set at boot). Preserving the boundary lets HTTP/1.1 render a field line and HTTP/2 +encode the same pair through HPACK without a second public header type. + +`ResponseSerializer.forEachField(Response, FieldConsumer)` is the **one source of truth for what +headers a response has** — it enumerates `Content-Type` (if set) plus every structured custom +header, in order, and is the only place that knowledge lives. `Http1ResponseWriter` renders that +sequence as `Name: Value\r\n` lines; `Http2ResponseWriter` renders the same sequence as HPACK. +Deliberately excluded: `Content-Length`/`Connection`/`Date` (connection framing, not response +object properties — and HTTP/2 has no `Connection` header at all, RFC 9113 §8.2.2) and raw +`header(byte[])` entries (no recoverable name/value structure to hand the h2 encoder). + +`Http1ResponseWriter` (`EX-27`) serializes the entire response head — status line, `Content-Type`, +`Date`, every custom header, `Content-Length`/`Connection` — into +`ConnectionScratch.responseHead` (a reused `ByteWriter`) and issues **one** `OutputStream.write` +call for the head plus any body at or below `Http1Limits.INLINE_BODY_THRESHOLD` (8 KiB), instead +of roughly ten small writes. A larger body is written in a second `write` call right after — folding +it into the head buffer first would cost an extra full-body `memcpy` the syscall reduction does not +pay for. Streaming/chunked bodies write the head, then relay their own bytes as they arrive, by +definition too large or unbounded to fold into one buffer up front. + +`Response.header(...)` (any overload) is bounded by `Http1Limits.MAX_RESPONSE_HEADER_BYTES`/ +`MAX_RESPONSE_HEADER_COUNT` (`EX-43`) — unlike every other `Http1Limits` constant, this guards +against a bug in the *caller* (a handler looping over an unbounded collection while building +headers) rather than a hostile peer: since `Response` is now pooled per connection, an unbounded +`headerRegion` would otherwise grow for the rest of the connection's lifetime, never shrinking +back down between requests. Both checks throw `IllegalStateException`, not +`MalformedRequestException` — this is an application-code misuse, not a wire-input rejection. + +## `HeaderView` / `Http1HeaderMap` (`DEC-22`) + +`HeaderMap` split into `HeaderView` (the protocol-neutral read contract: `first`, `all`, `view`, +`valueEqualsIgnoreCase`, `contains`, `count`, `forEach`) and `Http1HeaderMap` (the existing +byte-buffer-backed implementation, kept in `dev.relism.flash.models` rather than moved to +`dev.relism.flash.http1`: `RequestParser` (root package) owns and constructs it, and +`http1`→root already exists via `Http1Connection`, so moving it to `http1` would create a +`models`↔`http1` package cycle). `RequestLine.headers` is typed as the +interface; `Http2HeaderMap` is the HPACK-backed second implementation without requiring a +`Request` or `RequestLine` API split. + +## `ByteTemplate` (`EX-28`) + +Off the h1 request/response hot path (used only by `ErrorPages`, on 404/500), but in scope because +it was a clean instance of the "precompute at boot" category the phase's own text calls out. +`render(String...)` used a nested loop — for every key-value pair, scan every slot — to find +matching placeholders, and a repeated placeholder name (`{{var}} == {{var}}`) meant a naive +name→single-index map would be wrong. Fixed by mapping each slot name to the (usually +one-element) array of every slot index using that name, built once at construction. A new +`renderInto(byte[], int, String...)` overload writes into a caller-supplied buffer and returns the +length written, for future callers with a reusable scratch buffer available; `render(String...)` +keeps its allocating signature for compatibility. + +## `Multipart` (`EX-29`, and `EX-38`–`EX-41`) + +Audited per the plan's mandatory rules for any file over 300 lines. Findings and fixes: an eagerly +buffered part body (text fields, and — during a full `parts()`/`parts(String)` scan — file bodies +too) had no size bound (`EX-38`, fixed with a bounded read capped by +`Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE`); the part count was unbounded (`EX-39`, capped by +`Http1Limits.MAX_MULTIPART_PARTS`); per-part header parsing had neither a header-count nor a +line-length bound (`EX-40`, capped by `Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT`/ +`MAX_MULTIPART_HEADER_LINE_LENGTH`); the multipart boundary's length was checked and found to +already be bounded transitively, via `Http1Limits.MAX_HEADER_VALUE_LENGTH` on the `Content-Type` +header it comes from (`EX-41`, a non-finding, recorded so "checked, found fine" isn't mistaken for +"wasn't checked"). None of these bounds apply to `Part.materialize()` on a streaming file part +returned by `Multipart.file()` — that call is documented as an explicit, opt-in heap allocation the +caller chooses to pay for, the same way `RequestBody.bytes()` is. + +## `EX-42`: the last per-request allocation, found by re-measuring + +Pooling `Request`/`RequestBody`/`RequestLine`/`Response` dropped `RequestPipelineBenchmark`'s +`parseAndRoute` from 120.008 B/op to 48.008 B/op — real progress, but not the 0 B/op the phase's +own DoD text requires. Reading `RequestParser.parse` turned up three `new +FastPathViews.RequestByteView(...)` allocations (path, query when present, protocol) on every +call — pre-existing since at least Phase 4, invisible until the larger `Request`/`RequestBody`/ +`RequestLine` cost sitting on top of them was removed. Fixed the same way as everything else in +this document: `RequestByteView` gained a `reset(byte[], int, int)`; `RequestParser` now owns one +pooled instance per role. `parseAndRoute` measures 0.008 B/op after the fix — JMH's noise floor, +effectively 0. + +## The zero-alloc contract, closed + +> A complete h1 request/response cycle on a warm connection — parse, route with path params, read +> three headers, set two response headers, write a 200 with a `byte[]` body — must be 0 B/op. + +`RequestPipelineBenchmark.parseAndRoute` (parse + route with a parametric match, no header/param +access) measures 0 B/op. `parseRouteAndExtractThreeFields` (the same, plus one path param and two +header reads) measures 184.009 B/op — entirely the `String` allocations the contract's own text +exempts ("except for the user-facing `String`s the handler explicitly asks for"). diff --git a/flash/docs/core/README.md b/flash/docs/core/README.md new file mode 100644 index 0000000..9e62071 --- /dev/null +++ b/flash/docs/core/README.md @@ -0,0 +1,10 @@ +# Flash core + +The parts of Flash shared by every protocol it speaks — HTTP/1.1 and HTTP/2 alike. Protocol-specific +internals (frames, HPACK, stream state) live in [`../http2/`](../http2/README.md). + +- [HTTP/1.1 hardening](HTTP1-HARDENING.md) — message-boundary rules, timeouts and negotiation. +- [Transport](TRANSPORT.md) — listeners, connection ownership, TLS and virtual threads. +- [Message model](MESSAGE-MODEL.md) — shared request/response objects and their lifetime contract. +- [Trailers and streaming](TRAILERS-AND-STREAMING.md) — the public cross-protocol APIs. +- [Byte primitives](BYTES.md) — reusable views, scanning and bounded slice lifetimes. diff --git a/flash/docs/core/TRAILERS-AND-STREAMING.md b/flash/docs/core/TRAILERS-AND-STREAMING.md new file mode 100644 index 0000000..2043f80 --- /dev/null +++ b/flash/docs/core/TRAILERS-AND-STREAMING.md @@ -0,0 +1,36 @@ +# Trailers and streaming + +Flash exposes the same request and response model on HTTP/1.1 and HTTP/2. Request trailers are +available through `Request.trailers()` after the body has reached EOF. Calling it earlier throws +`IllegalStateException`; this prevents handlers from observing an incomplete trailer section. +HTTP/1.1 reads trailers from the final chunk, while HTTP/2 decodes the trailing HEADERS block in +the connection's existing HPACK context. + +Response trailers are added with `Response.trailer(name, value)` or a `PreEncodedHeader`. HTTP/1.1 +uses chunked framing and writes the fields after the zero chunk. HTTP/2 writes a trailing HEADERS +block with `END_STREAM`; the final DATA frame deliberately does not carry `END_STREAM`. + +`Response.streaming(producer)` is the push alternative to `stream(InputStream, length)` and +`chunked(InputStream)`. Its `ResponseStream` is a bounded blocking bridge. A producer runs on a +virtual thread and blocks when the protocol writer or the HTTP/2 flow-control windows cannot make +progress. This keeps backpressure explicit without callbacks or reactive types: + +```java +return response.type("application/grpc").streaming(stream -> { + try { + for (byte[] message : messages) stream.write(message, 0, message.length); + stream.trailer("grpc-status", "0"); + } catch (IOException failure) { + throw new UncheckedIOException(failure); + } +}); +``` + +The transport supports the primitives required by gRPC, but the core does not provide protobuf +codecs, generated stubs, service descriptors, or a gRPC service API. Those belong in a future +`flash-ext-grpc` module. `GrpcInteropTest` verifies the boundary with the external `grpcurl` client +and a hand-written wire-format handler. + +CONNECT requests follow RFC 9113 request pseudo-header rules: `:authority` is required and +`:scheme`/`:path` are forbidden. Their DATA remains subject to the ordinary request limits, +timeouts and two-level flow control. diff --git a/flash/docs/core/TRANSPORT.md b/flash/docs/core/TRANSPORT.md new file mode 100644 index 0000000..90b4e90 --- /dev/null +++ b/flash/docs/core/TRANSPORT.md @@ -0,0 +1,148 @@ +# Transport architecture + +Audience: contributors. This document describes the shared listener and connection layer behind +the HTTP/1.1 and HTTP/2 implementations. + +## Why this exists + +The original `HttpServer` (563 lines) did bind, accept, virtual-thread dispatch, WebSocket +upgrade detection, WebSocket handshake, the WebSocket session loop, keep-alive detection, HTTP +response serialization, chunked encoding, hex encoding, and decimal encoding — eleven reasons to +change in one class (R6). It also held three `ThreadLocal`s that meant "one per connection" under +virtual threads, not "one per core" (`EX-06`), and used `synchronized` around blocking socket +writes in `WebSocketSession`, which pins a virtual thread's carrier on Java 21 (`EX-01`). + +The current design replaces it with named, single-responsibility components and a +`ConnectionProtocol` seam implemented by both wire protocols. + +## Package layout + +``` +dev.relism.flash.transport +├── TransportFactory composes everything below; ServerHandle.create()'s implementation (EX-34) +├── ListenerBinder FlashConfiguration.Listener -> bound ServerSocket +├── BoundListener record: the bound socket + whether it is TLS +├── TransportTuning accept-thread count / backlog / socket buffer size constants +├── AcceptLoop one listener's accept loop body +├── ConnectionRunner per-connection setup/teardown: TLS handshake, protocol negotiation, +│ dispatch to a ConnectionProtocol, guaranteed cleanup +├── ConnectionProtocol the h1/h2 seam: void run(ConnectionContext) +├── ConnectionContext everything a ConnectionProtocol needs, bundled (record) +├── ConnectionScratch per-connection reusable buffers (EX-06's fix) +├── ScratchPool a bounded cache of ConnectionScratch instances +├── ServerLifecycle implements ServerHandle: start/startAndBlock/stop, graceful shutdown (EX-32) +├── BufferedByteSource the buffered, deadline-aware, peekable inbound-byte source (Phase 1, EX-10) +└── ProtocolNegotiator/NegotiatedProtocol ALPN + h2c preface detection (Phase 1) + +dev.relism.flash.http1 +├── Http1Connection implements ConnectionProtocol: the h1 keep-alive request loop +├── Http1ResponseWriter serializes a Response as an HTTP/1.1 message +└── Http1KeepAlive keep-alive decision + the shared Connection-header token scanner (EX-13) + +dev.relism.flash.websocket (existing package, extended) +├── WebSocketUpgrade upgrade detection + handshake response +├── WebSocketLoop the session read/dispatch loop +├── WebSocketSession per-connection WS I/O (frame codec + send API), EX-01/EX-11/EX-12 +└── WebSocketProtocolException RFC 6455 violation, carries the correct close code +``` + +## The connection lifecycle + +``` +TransportFactory.create(configuration, router, wsRouter) + binds every configured listener (ListenerBinder) + builds one ConnectionRunner (shared virtual-thread executor, ScratchPool, both protocols) + returns a ServerLifecycle (implements ServerHandle) + +ServerLifecycle.start() + for each listener, spawns TransportTuning.ACCEPT_THREADS platform threads + each runs AcceptLoop.run(listener, runner, this::isStopped) + +AcceptLoop.run(...) + loop: listener.socket().accept() -> runner.accept(socket, stopped) + +ConnectionRunner.accept(socket, stopped) + submits to the virtual-thread executor -> handle(socket, stopped) + +ConnectionRunner.handle(socket, stopped) + activeSockets.add(socket); scratch = scratchPool.acquire() + try: + configure TCP_NODELAY / send buffer size + if SSLSocket: force startHandshake() under headerReadTimeoutMs (EX-30) + wrap streams: BufferedByteSource in, buffered OutputStream out, raw OutputStream rawOut + negotiated = negotiateProtocol(socket, in) # ALPN or h2c preface + build ConnectionContext + dispatch to http1Protocol.run(ctx) or http2Protocol.run(ctx) + finally: + activeSockets.remove(socket); scratchPool.release(scratch) +``` + +`Http1Connection.run(ConnectionContext)` is where HTTP/1.1 semantics actually live: the +keep-alive loop, the idle/header/body deadline transitions (Phase 1), the `MalformedRequestException` +rejection path, the WebSocket upgrade handoff, and the response write. + +## `ConnectionScratch` and `ScratchPool` (`EX-06`) + +`ThreadLocal` is the right idiom when "one per thread" means "one per core" — a bounded +platform-thread pool. Flash runs one **virtual** thread per connection +(`Executors.newVirtualThreadPerTaskExecutor()`), so a `ThreadLocal` there means one per +*connection*, with no upper bound: at 100 000 concurrent connections, an 8 KB relay buffer alone +would be ~800 MB that a bounded pool would otherwise cap. + +`ConnectionScratch` is therefore an explicit, plain object (decimal-encoding buffer, streaming +relay buffer, the WebSocket-handshake `MessageDigest`) acquired from a `ScratchPool` at +connection start and released at connection end. The pool is a bounded *cache*, not a +leak-free arena: above its bound (`min(availableProcessors * 64, 4096)` by default), a released +scratch is simply dropped for the garbage collector rather than queued, so an unusually large +burst of connections cannot grow it without limit. + +The routers use an explicit per-connection scratch passed through `AbstractRouter.route`; neither +`FastPathRouterImpl` nor `FastPathWsRouterImpl` retains connection state in a `ThreadLocal`. + +## The `ConnectionProtocol` seam (R1 / `DEC-02`) + +```java +public interface ConnectionProtocol { + void run(ConnectionContext ctx) throws IOException; +} +``` + +`ConnectionRunner` decides h1 vs h2 exactly once, immediately after ALPN/preface detection, and +dispatches to `Http1Connection` or `Http2Connection`. Neither implementation is aware the other +exists — `dev.relism.flash.http1` and `dev.relism.flash.http2` do not import each other, enforced +by `PackageBoundaryTest`. + +## Graceful shutdown (`EX-32`) + +Two stages, driven by `ServerLifecycle.stop()`: + +1. **Stop accepting.** Every listener socket is closed immediately; `stopped` flips to `true`. +2. **Drain, then force-close.** `Http1Connection`'s request loop checks `ctx.stopped()` twice: + once before waiting for the next request (exits immediately if already stopped, rather than + waiting out the idle-keep-alive timeout), and again right before writing the *current* + response — forcing `Connection: close` on it even if the response's own `Connection` header + logic would have said keep-alive, and even if shutdown began *while the handler was running* + (the common case). `ServerLifecycle.stop()` polls `activeSockets` for up to + `shutdownDrainTimeoutMs`, then force-closes whatever remains and shuts down the executor. + +HTTP/2 shutdown sends the two-stage `GOAWAY` sequence from RFC 9113 §6.8 before the lifecycle's +drain deadline force-closes remaining sockets. + +## What changed for WebSocket (`EX-01`, `EX-11`, `EX-12`, `EX-13`) + +- **`EX-01`**: `WebSocketSession`'s two blocking-write sites (`close`, `writeFrame`) now + serialize on a `ReentrantLock` instead of `synchronized (out)` — a virtual thread blocking + inside `synchronized` pins its carrier platform thread on Java 21 (JEP 491, which removes + this, is JDK 24+). `ReentrantLock` unmounts the blocked virtual thread instead. +- **`EX-11`**: `readFrame` used to read the extended-length and mask-key bytes one at a time. + It now reads that whole variable-length remainder in a single bounded `readFully` into the + existing `hdrScratch` array, then decodes with shifts. +- **`EX-12`**: `readFrame` now reassembles continuation frames into one logical message (bounded + by the same buffer a single frame already had), enforces the masking direction RFC 6455 §5.1 + requires for this session's role, validates the opcode against the RFC's defined set, enforces + control-frame constraints (not fragmented, ≤125 bytes), and reports violations via + `WebSocketProtocolException` carrying the correct close code (1002 protocol error, 1009 + message too big) for `WebSocketLoop` to send before closing. +- **`EX-13`**: the `Connection` header is a comma-separated token list, not a single value — + `Http1KeepAlive.tokenListContains` is the one scanner both the keep-alive decision and + `WebSocketUpgrade`'s `Connection: Upgrade` check use, so they cannot drift apart again. diff --git a/flash/docs/http2/BASELINES.md b/flash/docs/http2/BASELINES.md new file mode 100644 index 0000000..5f0a7ff --- /dev/null +++ b/flash/docs/http2/BASELINES.md @@ -0,0 +1,44 @@ +# HTTP performance baselines + +These numbers are regression controls, not cross-machine promises. They were measured on +2026-08-13 under Linux 6.12/KVM, six exposed cores of an AMD Ryzen 7 1700X, Temurin 21.0.11 and +JMH 1.37. CI uses short independent forks for allocation and sample latency so the sampling +harness does not contaminate `gc.alloc.rate.norm`. + +## Gated hot paths + +| Benchmark | B/op | p50 ns | p99 ns | p999 ns | CI p99 ceiling ns | +|---|---:|---:|---:|---:|---:| +| h1 parse and route | 0.022 | 540 | 33,472 | 60,822 | 45,000 | +| h2 pooled stream lifecycle | 0.010 | 530 | 2,138 | 37,724 | 2,900 | +| h2 response encoding | 0.004 | 210 | 993 | 14,626 | 1,350 | +| HPACK browser-request decode | 0.015 | 730 | 5,245 | 27,577 | 7,100 | +| HPACK typical-response encode | 0.003 | 180 | 620 | 12,025 | 850 | +| frame read/validate/discard | 0.006 | 70 | 1,999 | 90,508 | 2,700 | + +The sub-byte allocation values occur with no collection and are JMH/GC-profiler rate +normalization noise. The CI allocation ceiling is 0.05 B/op. A benchmark exceeding it fails; a +baseline or ceiling change requires an explicit edit and justification here. + +The table records the higher percentile observed across three consecutive controlled runs; this is +important because short sample-mode runs on the shared KVM host showed visible scheduler noise. +The p999 values expose those tails but are recorded rather than gated. The p99 ceilings are the +worst observed p99 plus about 35% headroom. + +## HTTP/1 historical comparison + +The plan required a pre-Phase-1 number, but no benchmark was committed at that point. Phase 17 +reconstructed the current `RequestPipelineBenchmark.parseAndRoute` fixture against Phase 0 commit +`db6e4a4` in a detached worktree and ran both revisions on the same host and JVM: + +| Revision | ns/op | B/op | +|---|---:|---:| +| Phase 0 (`db6e4a4`) | 976.195 ± 45.924 | 224.007 | +| Phase 17 | 1,024.602 ± 50.744 | 0.007 | + +The hardened parser's mean is 5.0% higher and removes effectively all 224 B/op. The 99.9% +confidence intervals overlap (`930.271–1,022.120` ns for Phase 0 and `973.858–1,075.345` ns for +Phase 17), so this run does not establish a statistically significant latency regression. This is +an honest reconstruction, not a claim that an absent historical run existed. Phase 17 recovered +about 4.5% by having `RequestParser` populate `Http1HeaderMap`'s zero-copy index during the same +validated header pass instead of rescanning every line; all security checks remain in that path. diff --git a/flash/docs/http2/CLEARTEXT.md b/flash/docs/http2/CLEARTEXT.md new file mode 100644 index 0000000..2ff8583 --- /dev/null +++ b/flash/docs/http2/CLEARTEXT.md @@ -0,0 +1,29 @@ +# HTTP/2 cleartext + +TLS HTTP/2 and cleartext HTTP/2 have independent rollout controls: + +- `http2Enabled` advertises `h2` through TLS ALPN. +- `http2CleartextEnabled` accepts the HTTP/2 prior-knowledge preface on plaintext listeners. + +Both default to `false`. Cleartext support follows RFC 9113 prior knowledge. The obsolete +HTTP/1.1 `Upgrade: h2c` transition is intentionally unsupported. + +## Header conversion + +`HopByHopHeaders` is the single policy used at connection boundaries. It removes fields named by +`Connection`, the standard hop-by-hop set, HTTP/2-forbidden fields and pseudo-fields. `TE` is +forwarded only as `trailers` when the target is HTTP/2. Tests execute the same policy for all four +HTTP/1.1 and HTTP/2 source/target combinations. + +## Authority and 421 + +On TLS HTTP/2 connections, Flash checks `:authority` against the selected certificate's DNS/IP +subject alternative names. An authority outside that served set receives `421 Misdirected +Request`, allowing a coalescing client to retry on a different connection. Exact names and +single-label wildcards are supported; h2c has no certificate identity and is unaffected. + +An outbound HTTP/2 client and reverse-proxy adapter (`Http2Client`, `HttpProxy`) were built +against this cleartext support but had no caller anywhere in `flash` core — an HTTP/1.1+2 server +framework has no business shipping an outbound client. That code has been removed; if a +reverse-proxy capability is needed later, it belongs in its own `flash-extensions/flash-ext-*` +module, not in core. diff --git a/flash/docs/http2/COMPLIANCE.md b/flash/docs/http2/COMPLIANCE.md new file mode 100644 index 0000000..6837b8a --- /dev/null +++ b/flash/docs/http2/COMPLIANCE.md @@ -0,0 +1,84 @@ +# HTTP/2 compliance + +This document records the repeatable protocol gate for Flash's HTTP/2 server. The automated +matrix runs from Maven; external tools are selected through system properties so local builds +without them skip only the corresponding interoperability adapter. CI installs and enables every +command-line client listed below. + +## h2spec + +Validated on 2026-08-13 with h2spec 2.6.0. + +| Listener | Cases | Failures | Skips | +|---|---:|---:|---:| +| TLS with ALPN `h2` | 146 | 0 | 0 | +| Cleartext prior knowledge on the mixed HTTP/1.1 + HTTP/2 port | 145 | 0 | 0 | + +`H2SpecComplianceTest` parses h2spec's JUnit XML and fails on a failure, error, or skipped case. +The cleartext selection omits only `http2/3.5/2`, which sends a complete invalid HTTP/2 preface. +That case assumes a dedicated HTTP/2 endpoint. Flash deliberately has one cleartext port that +selects HTTP/2 only when the 24-byte prior-knowledge preface matches; any other initial bytes are +HTTP/1.1 input. RFC 9113 section 3.3 defines the exact preface as the cleartext protocol selector, +while section 3.4's `PROTOCOL_ERROR` applies after an endpoint is operating as HTTP/2. The HTTP/2 +state machine itself does return `GOAWAY(PROTOCOL_ERROR)` for a complete invalid preface, covered +byte-for-byte by `invalid-preface.hex`. Excluding the mixed-port negotiation case therefore does +not waive an HTTP/2 state-machine requirement. + +## Interoperability + +Automated results recorded on 2026-08-13: + +| Client | Version | Mode and coverage | Result | +|---|---|---|---| +| curl | 8.5.0, libnghttp2 1.59.0 | TLS and h2c; GET, POST, 2 MiB upload/download | pass | +| Java `HttpClient` | Temurin 21.0.11+10 | TLS; GET, POST, large bodies and multiplexing | pass | +| nghttp | nghttp2 1.59.0 | TLS and h2c; verbose SETTINGS/HEADERS/DATA trace, POST and 2 MiB download | pass | +| grpcurl | 1.9.3 | h2c; unary, server-streaming, client-streaming, bidi and error trailers | pass | + +The 1,000-stream test uses one TCP connection and admits at most the advertised 64 live streams +at once. This tests 1,000 multiplexed stream lifecycles without contradicting +`SETTINGS_MAX_CONCURRENT_STREAMS` or weakening the production memory bound. + +Chrome and Firefox are a release smoke test rather than a CI dependency. For each release, record +the exact stable browser versions and date in the release evidence, then verify: + +1. Load a TLS route and confirm `h2` in the browser network protocol column. +2. Exercise GET, POST, a large upload and a large streamed download. +3. Open the same registered WebSocket route over HTTP/1.1 and RFC 8441, exchange a fragmented + message larger than one flow-control window, and close from each side once. +4. Confirm no certificate, console, failed-request, or retry-to-HTTP/1.1 warnings. + +This manual row is intentionally not represented as an automated pass: browser release testing +must record the browsers actually shipped at release time rather than a stale development image. + +## Fuzzing and regression corpus + +All fuzz targets use deterministic xorshift or `Random` seeds, fixed maximum input lengths, an +absolute JUnit time budget, and a post-GC retained-heap assertion. Untyped runtime failures fail +the test immediately. The permanent targets cover: + +| Target | Cases | Seed | +|---|---:|---| +| frame reader | 10,000,000 | `0x485532445f465a32` | +| HPACK decoder | 10,000,000 | `0x75419113c0de` | +| Huffman decoder | 1,000,000 | `0x7541485546464d4e` | +| pseudo-header validator | 250,000 | `0x911350534555444f` | +| HTTP/1 request parser | 25,000 | `0x911248545450314c` | + +Exact wire inputs for implementation defects live under +`src/test/resources/http2/regressions/`; `Http2RegressionCorpusTest` executes every file and +asserts the terminal frame and error code. The nightly `Http2SoakTest` defaults to ten minutes of +GET, POST, streaming DATA, reset and PING traffic, with retained-heap assertions. A short run can +be requested with `-Dflash.http2.soak=true -Dflash.http2.soak.seconds=10`. + +## Deliberately absent features + +- HTTP/2 server push is not exposed. A client cannot send `PUSH_PROMISE` to a server (RFC 9113 + section 6.6); receiving one is a connection error. Flash does not originate push. +- RFC 7540 dependency-tree priority scheduling is not implemented. RFC 9113 section 5.3.2 + deprecates the scheme; PRIORITY frames are validated and ignored as required. +- `Upgrade: h2c` is not implemented. RFC 9113 section 3.1 removed the HTTP/1.1 upgrade mechanism; + cleartext support uses section 3.3 prior knowledge. + +These omissions do not create alternate request/response APIs: HTTP/1.1 and HTTP/2 remain peers +behind the transport protocol boundary. diff --git a/flash/docs/http2/CONNECTION.md b/flash/docs/http2/CONNECTION.md new file mode 100644 index 0000000..c51cf77 --- /dev/null +++ b/flash/docs/http2/CONNECTION.md @@ -0,0 +1,70 @@ +# HTTP/2 connection control + +`Http2Connection` owns only connection-level protocol state. It verifies the preface, drives the +frame reader, dispatches control frames and performs shutdown. HPACK fragment extraction and decode +live in `Http2HeaderBlockDecoder`; socket serialization remains exclusively in +`Http2FrameWriter`. Stream dispatch and application handlers are separate layers. + +Each accepted HTTP/2 socket receives a new `Http2Connection`. Sharing the stateless +`Http1Connection` implementation is safe, but sharing an HTTP/2 instance would leak dynamic HPACK, +SETTINGS, flow-control and GOAWAY state between peers. + +## Demultiplexing invariant + +The demux thread never invokes application work. It reads and validates frames, updates bounded +connection state, and enqueues or directly writes control frames. A registered handler cannot delay +SETTINGS or PING processing. The connection reader polls at a short interval so server shutdown is +observed promptly, while `Http2FrameReader` retains one non-renewable absolute deadline for a +partially received frame; polling therefore does not weaken slow-frame protection. + +## Settings + +| Identifier | Default | Validation and handling | +|---|---:|---| +| `HEADER_TABLE_SIZE` | 4096 | Unsigned 32-bit; locally capped | +| `ENABLE_PUSH` | 1 | Only 0 or 1; Flash advertises 0 | +| `MAX_CONCURRENT_STREAMS` | unlimited | Unsigned 32-bit | +| `INITIAL_WINDOW_SIZE` | 65535 | At most 2^31-1 | +| `MAX_FRAME_SIZE` | 16384 | 16384 through 16777215 | +| `MAX_HEADER_LIST_SIZE` | unlimited | Unsigned 32-bit | + +Unknown identifiers are ignored. A payload is validated as a transaction before values are +committed. The initial-window delta is handed to the stream table as one operation: negative stream +windows are valid, but any result above 2^31-1 rejects the complete update with +`FLOW_CONTROL_ERROR`. Every non-ACK SETTINGS frame receives an empty ACK; locally sent settings are +bounded and have an acknowledgement deadline. + +## Priority control writes + +`Http2FrameWriter` has one priority MPSC lane in front of its ordinary stream-data lane. PING and +SETTINGS acknowledgements, RST_STREAM and GOAWAY use reusable control intents from the connection +scratch. They can overtake queued DATA but never split or interrupt a socket write already in +progress. Both PING and SETTINGS response queues are bounded. + +## Shutdown + +Graceful shutdown follows the two-stage protocol: + +1. Send GOAWAY with last-stream-id 2^31-1 and `NO_ERROR`. +2. Send a connection PING and wait for its matching ACK, establishing a round trip. +3. Send a second GOAWAY with the real last processed stream id, then close after current work. + +A connection error instead sends one GOAWAY with the precise error code, the real last processed +stream id and a bounded diagnostic string. A preface mismatch closes silently because the peer has +not established a valid HTTP/2 connection. + +## Verification + +The reusable control lifecycle (preface, SETTINGS/ACK, PING/PONG, WINDOW_UPDATE and received +GOAWAY) measures 974.263 ns/op and 0.008 B/op on JDK 21.0.11; the allocation figure is the JMH GC +profiler noise floor with no collections. `curl 8.5.0` using h2c prior knowledge completed the +handshake and observed both clean GOAWAY stages. It exits with code 56 because this phase +deliberately sends no response HEADERS or DATA; those arrive with the response and stream phases. + +h2spec 2.6.0 passes 28 of the 35 selected section 3, 4, 6.5, 6.7, 6.8 and 6.9 cases, including all +connection-owned SETTINGS validation, PING, GOAWAY, frame-format, HPACK interleaving and +connection-window cases. Six failures require response HEADERS/DATA or per-stream flow control and +remain assigned to the response, stream and DATA phases. The seventh is h2spec's expectation of a +GOAWAY after an invalid preface; Flash intentionally closes without writing because no valid HTTP/2 +connection exists yet, as permitted by RFC 7540 §3.5 and required by this implementation's preface +contract. diff --git a/flash/docs/http2/FLOW-CONTROL.md b/flash/docs/http2/FLOW-CONTROL.md new file mode 100644 index 0000000..a2ff2b7 --- /dev/null +++ b/flash/docs/http2/FLOW-CONTROL.md @@ -0,0 +1,48 @@ +# HTTP/2 bodies and flow control + +HTTP/2 applies flow control independently to the connection and to every stream. Flash advertises +a 1 MiB receive window at both levels and sends WINDOW_UPDATE only after the application has +consumed at least half a window. A DATA frame decrements both windows by its complete payload +length, including the pad-length byte and padding; only its unpadded data reaches the handler. + +## Request bodies + +Known bodies up to 64 KiB remain in one reusable contiguous stream buffer. Their handler is +dispatched at END_STREAM, and `RequestBody.bytes()` performs the only allocation: the byte array +returned to application code. For a 1,024-byte body JMH reports exactly 1,040 B/op, the array plus +its object header, with no framework allocation around it. + +Larger or unknown-length bodies dispatch after request headers. DATA is copied out of the frame +reader into a connection-owned pool of 64 reusable 16 KiB buffers. Small adjacent frames coalesce +inside a buffer, so the pool is bounded by bytes rather than frame count. The existing +`RequestBody.stream()` blocks only the handler's virtual thread when data is absent. Buffers return +to the pool as reads consume them, and that consumption reopens both receive windows. If a handler +does not read its body, the normal post-handler drain performs the same bounded consumption. + +The connection window and pool both cover exactly 1 MiB, so the peer can never hold more credit +than the server can store before backpressure takes effect. Per-stream accepted body bytes remain +bounded by `MAX_REQUEST_BODY_SIZE`. Declared content length is parsed without a String and checked +against the unpadded DATA total at END_STREAM. + +## Responses + +Fixed byte arrays, known-length streams and unknown-length streams all use one resumable +`Http2ResponseWriter`. It emits DATA frames no larger than the peer's frame limit, the available +connection window, the available stream window and the reusable 16 KiB relay buffer. A +WINDOW_UPDATE schedules the stream on the shared virtual-thread executor; application streams are +never read by the demultiplexer. + +`Response.chunked(InputStream)` means unknown-length streaming at the application API. HTTP/2 has +no chunked transfer coding, so Flash emits ordinary DATA followed by END_STREAM and never sends a +`transfer-encoding` field. `Response.stream(InputStream, length)` emits `content-length` and fails +the stream if the source ends before that length. + +## Verification + +- A real Java HTTP/2 client uploads and downloads 100 MiB over TLS; both directions are validated + byte-for-byte without materializing the test payload. +- A synthetic 100 MiB response proves serialized scratch storage stays below 64 KiB. +- h2spec sections 5, 6.1, 6.9 and 8: 50 passed, one h2spec-skipped case, zero failures. +- Clean Maven build with JMH sources: 633 tests, no failures. +- JMH request streaming: 159.408 ns/op, 0.001 B/op, no GC. +- JMH response streaming frame: 219.090 ns/op, 0.002 B/op, no GC. diff --git a/flash/docs/http2/FRAMES.md b/flash/docs/http2/FRAMES.md new file mode 100644 index 0000000..397c182 --- /dev/null +++ b/flash/docs/http2/FRAMES.md @@ -0,0 +1,147 @@ +# The frame layer + +Audience: contributors. This is the design record for `dev.relism.flash.http2.frame`'s frame +reading, validation, and writing — the 9-byte header and payload boundary, with no connection +semantics, no streams, and no HPACK above it. + +## Why this is simpler than the h1 parser + +HTTP/1.1 request parsing must scan for `\r\n\r\n` (`RequestParser`, `ByteScan.indexOfCrLfCrLf`) +because nothing in the h1 wire format states the header block's length up front. HTTP/2 states +every frame's payload length in the first three bytes of its 9-byte header — nothing is ever +scanned for. `Http2FrameReader` is a length-prefixed reader and nothing more: read 9 bytes, +decode the length, ensure that many more bytes are available, done. + +## The wire format + +``` ++-----------------------------------------------+ +| Length (24) | ++---------------+---------------+---------------+ +| Type (8) | Flags (8) | ++-+-------------+---------------+-------------------------------+ +|R| Stream Identifier (31) | ++=+=============================================================+ +| Frame Payload (0...) ... ++---------------------------------------------------------------+ +``` + +`R` (RFC 9113 §4.1) is reserved and MUST be ignored on receipt — `FrameHeader.reset` masks it +out of `streamId()` once, so no caller has to remember to. + +## Package layout + +``` +dev.relism.flash.http2.frame +├── FrameType the 10 known types + per-type validation descriptor (min/max length, stream-id rule) +├── FrameFlags END_STREAM/ACK/END_HEADERS/PADDED/PRIORITY bit constants + predicates +├── FrameHeader flyweight over a read buffer: length/type/flags/streamId/payloadOffset +├── Http2FrameReader length-prefixed reader, RequestParser's buffer/compaction discipline +├── FrameValidator table-driven per-type RFC validation, specific error code per rule +├── Padding RFC 9113 §6.1/§6.2 pad-length byte + trailing padding, DATA/HEADERS +├── FrameWriteBuffer beginFrame()/endFrame() length back-patching over a ByteWriter +├── Http2FrameWriter the connection's single serialized writer +├── WriteIntent caller-owned serialized frame batch +└── IntrusiveMpscQueue allocation-free contended-write queue +``` + +## The validation table + +Every rule below is enforced by `FrameValidator.validate(FrameHeader, insideHeaderBlock)`, in +this order: unknown-type handling, `SETTINGS`' modulus-6 special case, the generic +min/max length bounds, the `MAX_FRAME_SIZE_LOCAL` ceiling, the stream-id rule, then +`PUSH_PROMISE`'s always-reject rule. + +| Type | Code | Length | Stream id | Notes / RFC | +|---|---|---|---|---| +| DATA | 0x0 | 0..MAX_FRAME_SIZE | required (≠0) | §6.1. Padding via `Padding.unpad`. | +| HEADERS | 0x1 | 0..MAX_FRAME_SIZE | required (≠0) | §6.2. Padding and optional PRIORITY fields are parsed before HPACK. | +| PRIORITY | 0x2 | exactly 5 | required (≠0) | §6.3. Deprecated (§5.3.2) — parsed, discarded, never acted on. | +| RST_STREAM | 0x3 | exactly 4 | required (≠0) | §6.4. The 4 bytes are the error code. | +| SETTINGS | 0x4 | multiple of 6 | forbidden (=0) | §6.5. Modulus checked before the generic bounds. | +| PUSH_PROMISE | 0x5 | ≥4 | required (≠0) | §6.6. Always `PROTOCOL_ERROR` from a client — never sent by Flash. | +| PING | 0x6 | exactly 8 | forbidden (=0) | §6.7. Opaque 8-byte payload, echoed on ACK. | +| GOAWAY | 0x7 | ≥8 | forbidden (=0) | §6.8. Last-stream-id (4) + error code (4) + optional debug data. | +| WINDOW_UPDATE | 0x8 | exactly 4 | either | §6.9. 0 = connection window, ≠0 = one stream's window. | +| CONTINUATION | 0x9 | 0..MAX_FRAME_SIZE | required (≠0) | §6.10. Continues a header block; see the flood guard below. | +| *(unrecognised)* | >0x9 | — | — | §4.1: ignored outside a header block, `PROTOCOL_ERROR` inside one (§6.10). | + +**The error code is not uniform per type** — a `SETTINGS` frame with a bad length is +`FRAME_SIZE_ERROR`; the same frame with a non-zero stream id is `PROTOCOL_ERROR`. Every violation +in the table above carries its own RFC citation and the specific code that citation mandates; +`FrameValidatorTest` has one test per row asserting the exact code, not merely "an exception". + +## Ignore vs. reject policy + +RFC 9113 §4.1 makes unknown frame types part of the protocol's extension mechanism: an endpoint +that does not recognise a type MUST read and discard its payload, never reject the connection for +it. `FrameType.fromCode` returns `null` for anything above `CONTINUATION` (0x9); `FrameHeader` +still exposes the raw `typeCode()` for logging even when `type()` is `null`. + +The one exception (§6.10): if an unrecognised-type frame arrives **between** a HEADERS/ +PUSH_PROMISE frame that lacked `END_HEADERS` and the CONTINUATION that eventually sets it, the +HPACK decoder's state has nowhere to put that frame's bytes without desynchronizing — so this one +case *is* a `PROTOCOL_ERROR`, tracked by `FrameValidator.validate`'s `insideHeaderBlock` +parameter (owned and threaded through by the connection loop, which is the only caller +that knows whether a header block is currently open). + +`PRIORITY` frames are a different kind of "ignore": they are a recognised, well-formed type that +Flash chooses not to act on (RFC 9113 §5.3.2 deprecates priority signalling and permits an +implementation to disregard it) — they are still fully parsed and validated like any other frame, +just never influence scheduling. `PUSH_PROMISE` is the opposite: recognised, but **always** +rejected when received (Flash advertises `SETTINGS_ENABLE_PUSH=0` and never sends one itself), so +receiving one at all can only mean the peer has the client/server roles backwards. + +## Buffer discipline and the frame-size defence + +`Http2FrameReader` never grows its buffer to accommodate a declared length before checking that +length against `Http2Limits.MAX_FRAME_SIZE_LOCAL` — the check happens first, so a hostile 16 MB +declared length is rejected at the cost of reading 9 bytes, not at the cost of a 16 MB +allocation. This mirrors `RequestParser`'s own `EX-08` discipline (bound the request line before +trusting it) applied to the frame layer's own attack surface. + +The buffer itself follows `RequestParser`'s compact-before-grow policy: unconsumed bytes slide to +offset 0 when there is room to do so without growing, and growth only happens when compaction +alone cannot make room — bounded, because the reader's own length check already rejected +anything that would require growing past `9 + MAX_FRAME_SIZE_LOCAL`. + +## Padding + +`Padding.unpad` locates the actual data range within a `PADDED` frame's payload: 1 byte of +pad-length, then data, then that many padding bytes (whose contents carry no meaning — they exist +only to obscure payload size from network observers). A pad length greater than or equal to the +whole payload length is `PROTOCOL_ERROR` (RFC 9113 §6.1), checked before any arithmetic that +could otherwise underflow. Flow-control accounting for padded DATA frames (RFC 9113 §6.9.1: the +*whole* payload counts against the window, not just the data) is applied by +`Http2FlowController`; `Padding` only locates the data range. + +## Writing: `FrameWriteBuffer`'s back-patching + +A frame's length is rarely known before its payload is serialized (an HPACK-encoded header block, +in particular, has no cheap way to be measured in advance). `FrameWriteBuffer.beginFrame` writes +a 9-byte header with a placeholder length; the caller writes the payload directly through the +same `ByteWriter`; `endFrame` computes the actual length from how far the writer has advanced and +rewrites the three length bytes in place. This is *why* `Http2FrameWriter` serializes a +complete buffer before ever taking the connection lock, rather than streaming bytes as they are +produced — streaming would need the length upfront, which back-patching deliberately avoids +needing. + +## Buffered-source deadline regression + +`BufferedByteSource`'s deadline mechanism (`EX-07`'s actual fix) turned out to have zero dedicated +unit tests and an unconditional `socket.setSoTimeout(...)` call that NPE'd against the `null` +socket every isolated unit test in this codebase uses. Found while writing +`Http2FrameReaderTest`, fixed, and given its own regression suite (`BufferedByteSourceTest`). + +## Testing + +- `Http2FrameReaderTest` — round-trips every frame type, boundary lengths (0, 1, 16383, 16384, + 16385), a frame split across three socket reads, a frame exactly filling the initial buffer, + multiple sequential frames, clean-EOF-vs-mid-frame-EOF, and reserved-bit masking. +- `FrameValidatorTest` — one test per RFC-mandated rejection above, asserting the specific + `Http2ErrorCode`. +- `Http2FrameReaderFuzzTest` — 10 000 000 random-length (0–64 byte), random-content inputs; only + `Http2Exception`, `EOFException`, or `SocketTimeoutException` may escape. Green, ~14s. +- `PaddingTest` — every boundary of the pad-length arithmetic, including the exact + `padLength == payloadLength - 1` (maximum valid) and `padLength >= payloadLength` (rejected) + cases. diff --git a/flash/docs/http2/HPACK.md b/flash/docs/http2/HPACK.md new file mode 100644 index 0000000..b4b1652 --- /dev/null +++ b/flash/docs/http2/HPACK.md @@ -0,0 +1,82 @@ +# HPACK decoder + +This document records the implementation constraints of Flash's RFC 7541 decoder. It is +contributor documentation, not application API documentation. + +## Representation model + +`HpackDecoder` accepts all RFC 7541 field representations: indexed fields, literals with +incremental indexing, literals without indexing, never-indexed literals, and dynamic-table size +updates. Prefix integers are bounded against overflow, Huffman padding and EOS are validated, and +decoded string lengths are checked while bytes are produced. + +The static table is stored as 61 immutable name/value byte pairs. Encoder-oriented reverse lookup +uses fixed open-addressed integer tables built during class initialization; lookup never converts +header bytes to `String` and never calls `HashMap` on the hot path. + +The dynamic table owns a bounded byte arena and a ring of primitive entry descriptors. Entry size +is `name length + value length + 32`, and eviction is oldest-first as required by RFC 7541 §4.1. +When the arena tail is too short, live entries are compacted into a contiguous prefix. This avoids +segmented views in every consumer and keeps indexed fields cheap to copy. + +## Ownership and eviction safety + +Views emitted by `HeaderSink` are callback-scoped. Production decoding targets a reusable +`HpackHeaderBlock` owned by the stream, which copies each name and value into its own arena. + +The copy is required for correctness. Consider stream A referencing a dynamic-table entry while +its handler is running. The connection thread can then decode stream B, evict that entry, and +reuse its bytes. If stream A retained the dynamic-table view, its headers would silently change. +Per-stream storage removes that race without reference counting or synchronization. + +The precise copy model is: + +- HTTP/1.1 copies nothing per request but scans header bytes in the connection buffer. +- HTTP/2 copies decoded request headers into stream-owned storage because multiplexed handlers + outlive subsequent HPACK mutations. +- Novel incrementally-indexed fields are also copied once into the connection's dynamic table. + +`HpackEvictionRaceTest` contains both the unsafe borrowed-view demonstration and the stable +stream-owned result. + +## Header-list rejection + +The decoder counts RFC header-list size cumulatively. Once the configured limit is crossed it +stops emitting fields, but continues parsing the entire block and applying dynamic-table updates. +Only after the block ends does it throw `HeaderListSizeException`. The stream layer can reject the +request while the connection's compression state remains synchronized. + +## CONTINUATION assembly + +`ContinuationAssembler` copies HEADERS and CONTINUATION fragments into one bounded connection +buffer. It rejects interleaving, stream-id changes, excessive continuation count, and blocks that +exceed the configured capacity. `SegmentedByteView` is intentionally not used here: RFC 9113 §6.10 +requires a contiguous, non-interleaved continuation sequence, and one bounded copy makes the HPACK +decoder and all downstream views simpler. + +## Verification + +- RFC 7541 Appendix C.1–C.6 vectors, including dynamic-table state after every sequence. +- Invalid integer, Huffman, index, size-update, and header-list inputs. +- Ten million deterministic random blocks; only typed protocol rejections may escape. +- JMH `-prof gc`: `decodeStaticRequest` measured 102.725 ns/op and 0.001 B/op on JDK 21.0.11. The + latter is the profiler's sampling noise floor; no garbage collections occurred. + +## Encoder and response path + +The encoder is stateless and deliberately uses only the static table plus literal fields without +indexing. It emits a dynamic-table-size update of zero at the start of the connection's first +response block. This avoids mutable compression state shared by concurrent streams; the trade-off +is a few more wire bytes for repeated custom response fields. + +Status and known content-type fields are HPACK-encoded during class initialization. The cached Date +header refreshes both its HTTP/1 and HPACK forms once per second. Runtime values are raw literals by +default; `FlashConfiguration.h2HuffmanDynamicValues` enables Huffman coding when deployment-specific +measurements justify its CPU/wire-size trade-off. + +`Http2ResponseWriter` is reusable per stream. It lowercases field names, removes forbidden +connection-specific fields, enforces the peer's header-list bound, keeps HEADERS and CONTINUATION +frames in one write intent, and appends a small fixed DATA body when flow-control permits. + +JMH `-prof gc` measured the representative response path at 174.309 ns/op and 0.001 B/op on JDK +21.0.11, with no garbage collections. The reported allocation is the profiler noise floor. diff --git a/flash/docs/http2/PERFORMANCE.md b/flash/docs/http2/PERFORMANCE.md new file mode 100644 index 0000000..faa22d5 --- /dev/null +++ b/flash/docs/http2/PERFORMANCE.md @@ -0,0 +1,99 @@ +# HTTP/2 performance + +## Method + +Measurements were taken on 2026-08-13 under Linux 6.12/KVM with six exposed AMD Ryzen 7 1700X +cores, Temurin 21.0.11, JMH 1.37 and nghttp2 1.59.0. JMH component benchmarks use prepared, +reusable protocol state and forked JVMs. `h2load` exercises the real cleartext server on loopback; +Flash and nghttpd run on the same host in alternating order. Results are snapshots, not promises +for different hardware. + +No "unmatched throughput" claim is supported. nghttpd is normally faster in this matrix; Flash's +numbers include framework routing, request-model assembly and handler dispatch that the static +reference server does not. + +## Component results + +The CI-controlled allocation and percentile numbers are in `BASELINES.md`. Additional average +time measurements from the same run were: + +| Scenario | Result | +|---|---:| +| h2 responses across 1 live stream | 74.405 ns | +| h2 responses across 8 live streams | 705.368 ns | +| h2 responses across 64 live streams | 5,591.368 ns | +| h2 responses across 256 live streams | 28,961.681 ns | +| h2 POST lifecycle with 1 KiB DATA | 741.031 ns, 0.005 B/op | +| 1 MiB streaming response | 78,681.815 ns | + +The multiplexing benchmark reports one complete response-encoding pass across all live streams, +not per-stream time. `Http2BodyBenchmark` separately covers the 1 KiB request-body shape and the +1 MiB response shape. `FrameWriterBenchmark` retains the Phase 3 contention matrix and its +per-write latency distribution. + +## End-to-end h2load comparison + +Each row uses at least 1,000 requests. Requested stream concurrency is capped first by Flash's +advertised 64-stream setting and then to 4,096 aggregate active streams so the 1,000-connection +rows remain bounded. Both requested and effective values are shown. + +| Connections | Requested/effective streams | Flash req/s | nghttpd req/s | +|---:|---:|---:|---:| +| 1 | 1 / 1 | 2,136.18 | 12,786.42 | +| 1 | 10 / 10 | 18,396.56 | 83,521.26 | +| 1 | 100 / 64 | 17,039.55 | 66,746.76 | +| 10 | 1 / 1 | 11,247.08 | 37,838.66 | +| 10 | 10 / 10 | 25,055.12 | 104,964.84 | +| 10 | 100 / 64 | 3,878.28 | 67,303.81 | +| 100 | 1 / 1 | 5,517.94 | 42,319.09 | +| 100 | 10 / 10 | 1,818.52 | 26,732.25 | +| 100 | 100 / 40 | 7,042.85 | 136,585.90 | +| 1,000 | 1 / 1 | 1,393.17 | 3,877.62 | +| 1,000 | 10 / 4 | 10,374.83 | 40,976.05 | +| 1,000 | 100 / 4 | 49,622.10 | 85,344.40 | + +The matrix found a correctness issue before it produced these final numbers: closed streams still +occupied live admission slots while their final write callback was pending. The bounded detach +fix is recorded as EX-57 and covered by regression tests. + +## Tuning decisions + +| Knob | Measurement | Decision | +|---|---|---| +| 16 KiB / 64 KiB / 1 MiB response frame | 1 MiB stream: 104,071 / 97,996 / 98,769 ns in the non-Huffman sweep | Keep 16 KiB. The roughly 6% gain at 64 KiB does not justify 4x per-connection buffer exposure on this noisy host. | +| 1 MiB initial receive window | 100 MiB Phase 11 transfer and the load matrix complete without flow stalls | Keep; it matches bounded receive capacity and changing it independently would not isolate a throughput claim. | +| half-window WINDOW_UPDATE hysteresis | 1 MiB streaming and 100 MiB transfer complete with steady pooled reads | Keep; no per-frame update traffic and no demonstrated reason to weaken backpressure. | +| 64 KiB inline body | 1 KiB inline materialization is one 1,040 B allocation; streaming steady state is ≈0 B/op | Keep the explicit one-array small-body tradeoff and stream larger bodies. | +| 64 × 16 KiB DATA buffers | 1 MiB streaming is 78,682 ns with ≈0 B/op; h2load stays bounded | Keep; larger chunks did not produce a clear win beyond the frame-size sweep. | +| `ScratchPool` bound | 64 objects per exposed CPU, capped at 4,096; full 1,000-connection matrix completes | Keep the capacity bound; it affects retained burst memory, not steady-state request instructions. | +| word-at-a-time route compare | 14.289 ns versus 22.313 ns bytewise, 36.0% faster | Keep. | +| SWAR header-end scan | 89.919 ns versus 128.460 ns scalar, 30.0% faster | Keep. | +| `SlicePool` size 4 | Header/path/query view benchmarks remain allocation-free | Keep; size changes lifetime capacity, not lookup work, and four simultaneous borrowed views match the documented contract. | +| runtime-value Huffman | representative response headers: 375.761 ns versus 180.129 ns at 16 KiB | Keep disabled by default; this header set is 109% slower to encode. | + +The frame-size/Huffman factorial produced counterintuitive variation in the body-only rows, so it +was not used to claim a Huffman body effect: Huffman only prepares headers. This is treated as +host noise rather than reverse-engineered into a preferred result. + +## Profiling + +async-profiler 4.4 was run against the representative browser HPACK decode. The top CPU leaves +were `Huffman.decode` (72.67%), `HpackHeaderBlock.accept` (7.00%), `HpackDecoder.decode` (5.00%), +JVM byte-array copy (4.00%), `HpackHeaderBlock.copy` (4.00%), `HpackStaticTable.name` (2.00%), +`PooledSlice.reset` (1.00%), `PooledSlice.array` (0.67%), JVM byte-arraycopy (0.67%), and +`HpackDecoder.decodeString` (0.67%). Each belongs to decoding, bounded arena ownership, or the +copy that makes header lifetime independent of dynamic-table eviction; none is incidental +locking or logging. + +The allocation profile produced no samples on the gated decode path. The realistic eight-writer +lock profile produced no sampled contended locks; the writer benchmark measured 185.605 bursts/s, +p50 1.2 µs, p99 5.3 µs and p999 41.6 µs. CPU, allocation and lock artifacts were generated under +`/tmp/phase17-*` and are intentionally not committed. + +CI runs the complete suite with `-Djdk.tracePinnedThreads=full`. The forked allocation and p99 +gates run only under the Maven `jmh` profile; the h2load comparison remains informational and +conditional because cross-runner throughput is not a stable correctness gate. + +The reconstructed Phase-0 HTTP/1 comparison is documented in `BASELINES.md`. Its confidence +interval overlaps the Phase-17 result, while normalized allocation falls from 224.007 B/op to +0.007 B/op. diff --git a/flash/docs/http2/README.md b/flash/docs/http2/README.md new file mode 100644 index 0000000..4473665 --- /dev/null +++ b/flash/docs/http2/README.md @@ -0,0 +1,43 @@ +# HTTP/2 in Flash + +Flash treats HTTP/1.1 and HTTP/2 as peer transports behind one connection boundary. TLS ALPN or +the cleartext prior-knowledge preface selects a protocol once; both paths then feed the same +router, `Request`, `Response`, handler, trailer, streaming and WebSocket APIs. HTTP/2 adds a +bounded frame decoder, HPACK codec, stream state machine, two-level flow control and one serialized +writer per connection. Application code does not branch on the wire protocol. + +The implementation is deliberately layered: + +```text +listener / TLS + -> protocol negotiation + -> HTTP/1.1 parser ---------+ + -> HTTP/2 frames + HPACK ----+-> shared request model -> router -> handler + shared response model + <- HTTP/1.1 serializer -----+ + <- HTTP/2 stream writer ----+ +``` + +This page covers the HTTP/2-specific layers only. The transport, message model, and byte +primitives shared with HTTP/1.1 live in [`../core/`](../core/README.md). + +## Protocol layers + +- [Connection](CONNECTION.md) and [streams](STREAMS.md) — HTTP/2 connection and stream state. +- [Flow control](FLOW-CONTROL.md) — request backpressure and streamed responses. +- [Cleartext](CLEARTEXT.md) — prior knowledge and the 421 misdirected-request rule. +- [WebSockets](WEBSOCKET.md) — RFC 8441 extended CONNECT using the existing WebSocket API. + +## Wire internals + +- [Serialized writer](WRITER.md) — the single-owner output path and contention model. +- [Frames](FRAMES.md) — frame parsing, validation and error scope. +- [HPACK](HPACK.md) — integer/Huffman coding and static/dynamic table ownership. + +## Operate and verify + +- [Security](SECURITY.md) — every HTTP/2 limit, default and abuse control. +- [Troubleshooting](TROUBLESHOOTING.md) — GOAWAY/RST_STREAM diagnosis and protocol tracing. +- [Compliance](COMPLIANCE.md) — h2spec, interoperability, fuzzing and deliberate omissions. +- [Performance](PERFORMANCE.md) and [CI baselines](BASELINES.md) — measurements and regression + gates, including the comparison with nghttpd. diff --git a/flash/docs/http2/SECURITY.md b/flash/docs/http2/SECURITY.md new file mode 100644 index 0000000..ef0ceaa --- /dev/null +++ b/flash/docs/http2/SECURITY.md @@ -0,0 +1,42 @@ +# HTTP/2 security controls + +HTTP/2 multiplexing lets one connection create disproportionate parser, stream and response work. +Flash therefore combines structural bounds, flow-control bounds and rate bounds. Rate counters use +two fixed half-window buckets, allocate nothing per frame and need no timer thread. +JMH on JDK 21 measures one rate-counter increment at 38.083 ns/op and approximately +`10^-4 B/op` (allocation noise floor, no GC). + +| Limit | Default | Defence / tuning guidance | +|---|---:|---| +| `MAX_CONCURRENT_STREAMS` | 64 | Bounds simultaneously retained stream state. | +| `MAX_STREAMS_CREATED_PER_INTERVAL` | 400 / 10 s | Companion to Rapid Reset; tune with `h2MaxStreamsCreatedPerInterval`. | +| `MAX_RESET_STREAMS_PER_INTERVAL` | 200 / 10 s | CVE-2023-44487 Rapid Reset; tune with `h2MaxResetStreamsPerInterval`. | +| `MAX_CONTINUATION_FRAMES_PER_BLOCK` | 8 | CVE-2024-27316 CONTINUATION flood. | +| `MAX_HEADER_LIST_SIZE` | 32 KiB | Stops HPACK expansion before fields reach stream storage. | +| `MAX_HPACK_STRING_LENGTH` | 8 KiB | Bounds one decoded literal, including Huffman expansion. | +| `MAX_SETTINGS_PER_INTERVAL` | 100 / 10 s | Bounds mandatory SETTINGS acknowledgements. | +| `MAX_PINGS_PER_INTERVAL` | 200 / 10 s | Bounds mandatory PING acknowledgements. | +| `MAX_USELESS_FRAMES_PER_INTERVAL` | 10,000 / 10 s | Aggregate CPU bound for PRIORITY, WINDOW_UPDATE, empty DATA and unknown frames. | +| `MAX_SETTINGS_ACK_QUEUE_DEPTH` | 64 | Bounds queued SETTINGS control writes. | +| `MAX_PING_QUEUE_DEPTH` | 64 | Bounds queued PING control writes. | +| `MAX_EMPTY_DATA_FRAMES_PER_STREAM` | 1,000 | Stops DATA work that spends no flow-control credit. | +| `INITIAL_WINDOW_SIZE_LOCAL` | 1 MiB | Matches the bounded DATA pool; consumption, not receipt, returns credit. | +| `MAX_REQUEST_BODY_SIZE` | 100 MiB | Hard per-stream request body bound. | +| `HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS` | 10 s | Absolute HEADERS-to-END_HEADERS deadline. | +| `STREAM_IDLE_TIMEOUT_MS` | 60 s | Cancels retained inactive streams; tune with `h2StreamIdleTimeoutMs`. | +| `FRAME_READ_TIMEOUT_MS` | 20 s | Absolute partial-frame deadline. | +| `WRITE_TIMEOUT_MS` | 30 s | Interrupts a socket writer blocked by a peer that stopped reading. | +| `MAX_STREAMS_PER_CONNECTION` | 100,000 | Optional connection churn budget; zero disables, tune with `h2MaxStreamsPerConnection`. | +| `MAX_BYTES_PER_CONNECTION` | disabled | Optional wire-byte budget; tune with `h2MaxBytesPerConnection`. | +| `MAX_CONNECTION_LIFETIME_MS` | disabled | Optional lifetime rotation; tune with `h2MaxConnectionLifetimeMs`. | + +`h2AbuseRateIntervalMs` changes the rolling interval for reset and stream-creation operator +limits. Breaching a connection-wide rate or budget produces GOAWAY `ENHANCE_YOUR_CALM`; malformed +stream-local messages use the RFC-defined stream error. The ordinary write queue is bounded by the +64 live streams and their single-in-flight response intent; control writes use the fixed scratch +slots above, so a slow reader cannot create an unbounded application queue. + +The security suite covers Rapid Reset, stream churn, SETTINGS/PING/non-progress floods, 100,000 +CONTINUATION frames, HPACK expansion, malformed names/pseudo-fields, configurable resource budgets, +header assembly deadlines and idle-stream cancellation. HTTP/1 request/header/body security tests +remain in the full suite and the shared public message model uses the same bounds on both paths. diff --git a/flash/docs/http2/STREAMS.md b/flash/docs/http2/STREAMS.md new file mode 100644 index 0000000..2a60637 --- /dev/null +++ b/flash/docs/http2/STREAMS.md @@ -0,0 +1,48 @@ +# HTTP/2 streams and request dispatch + +Each connection owns a fixed-capacity `Http2StreamTable`. Client stream identifiers are validated +as odd, non-zero and strictly increasing before a stream object is acquired. The table uses +primitive open addressing and a bounded object free list; it never grows beyond the advertised 64 +concurrent streams. An excess request receives `REFUSED_STREAM`, allowing the peer to retry it. + +## State model + +`Http2StreamState` represents `IDLE`, `OPEN`, `HALF_CLOSED_REMOTE`, `HALF_CLOSED_LOCAL` and +`CLOSED`. A class-initialized table maps every receive/send event to either its next state or the +correct stream error. Frames racing with a recently closed stream follow RFC 9113 §5.1 rather than +being rejected uniformly. + +## Header and request model + +Decoded HPACK fields are copied into storage owned by the stream. Before dispatch, +`PseudoHeaders` enforces ordering, uniqueness, required request pseudo-fields, lowercase regular +names, connection-specific-field rejection, the `te: trailers` exception and host/authority +consistency. Pseudo-fields are not exposed as regular headers; `:authority` is also visible as +`host` so existing middleware sees the same authority through HTTP/1.1 and HTTP/2. + +The stream assembles the existing protocol-neutral `Request`, `RequestLine`, `RequestBody` and +`HeaderView` models. Path/query splitting, routing, middleware, not-found handling and exception +handling therefore use the same code as HTTP/1.1. `FastPathRouterImpl` is unchanged. + +## Dispatch and ownership + +The connection thread decodes and validates frames only. Completed bodyless streams are queued in +a fixed array while more frame bytes are already buffered, then submitted to the server's shared +virtual-thread executor before the demultiplexer waits for the network again. This preserves burst +admission semantics without adding a dispatch timer or blocking the connection thread. + +The stream owns its pooled request, response, body, decoded-header arena and response writer. +Normal response completion releases it through the serialized writer callback. RST_STREAM marks a +queued or running stream cancelled and defers release to that sole owner; setup, routing and handler +failures send an appropriate stream reset and release in the failure path. A 100,000-cycle test +proves stable pool counts, and an immediate request/reset/request regression test covers reuse +while dispatch is pending. + +## Verification + +- The complete clean Maven/JMH suite is recorded in `COMPLIANCE.md` and `PERFORMANCE.md`. +- h2spec sections 5 and 8 pass after request DATA byte accounting landed. +- Java `HttpClient` negotiates HTTP/2 over TLS and runs an existing parameterized route unchanged. +- curl prior-knowledge h2c receives a valid `200` response and body. +- The pooled lifecycle (HPACK decode, request assembly, response write and release) remains a + zero-GC CI gate; current percentile and allocation baselines live in `BASELINES.md`. diff --git a/flash/docs/http2/TROUBLESHOOTING.md b/flash/docs/http2/TROUBLESHOOTING.md new file mode 100644 index 0000000..1e08fed --- /dev/null +++ b/flash/docs/http2/TROUBLESHOOTING.md @@ -0,0 +1,73 @@ +# HTTP/2 troubleshooting + +## Confirm which protocol was selected + +For TLS, the client and server must both offer `h2` through ALPN. Enable +`FlashConfiguration.http2Enabled`, use a certificate valid for the requested hostname, then check +with `curl --http2 -v https://host/path` or `nghttp -nv https://host/path`. The trace must report +ALPN `h2`; a successful HTTP/1.1 response usually means HTTP/2 was not enabled or the client did +not offer it. + +For plaintext, enable `http2CleartextEnabled` and use prior knowledge: + +```bash +curl --http2-prior-knowledge -v http://host:port/path +nghttp -nv http://host:port/path +``` + +Flash does not support `Upgrade: h2c`. A client configured for Upgrade rather than prior knowledge +will remain on HTTP/1.1. + +## Read GOAWAY and RST_STREAM + +GOAWAY terminates or drains a connection; `last_stream_id` identifies the highest client stream +the server may have processed. A client may retry a stream above that id only when its own request +semantics make retry safe. RST_STREAM affects one stream and leaves the connection usable. + +| Error | What it usually means | What to check | +|---|---|---| +| `NO_ERROR` | Graceful shutdown or connection rotation. | Server lifecycle and configured connection lifetime. | +| `PROTOCOL_ERROR` | Invalid preface, pseudo-header ordering, stream state or frame semantics. | A verbose frame trace and the first rejected stream. | +| `INTERNAL_ERROR` | Handler, response production or I/O failed unexpectedly. | The server exception immediately preceding stream cancellation. | +| `FLOW_CONTROL_ERROR` | A window overflow or DATA exceeded available credit. | Client flow-control implementation and SETTINGS deltas. | +| `SETTINGS_TIMEOUT` | The peer did not complete required SETTINGS progress. | Network stalls or a non-compliant peer. | +| `STREAM_CLOSED` | A frame targeted a stream whose remote side or whole lifecycle was closed. | Late DATA/HEADERS and duplicate terminal frames. | +| `FRAME_SIZE_ERROR` | A frame length violated its type or the negotiated maximum. | The nine-byte frame header and peer frame-size configuration. | +| `REFUSED_STREAM` | Live or pending-output capacity was temporarily exhausted. | Client concurrency versus the advertised maximum; retry only when safe. | +| `CANCEL` | The request, handler or streamed response was cancelled. | Client cancellation and application producer logs. | +| `COMPRESSION_ERROR` | HPACK integer, Huffman, index or table update was invalid. | Header-block bytes and whether an intermediary rewrote them. | +| `CONNECT_ERROR` | A CONNECT tunnel failed. | Upstream tunnel or extended-CONNECT negotiation. | +| `ENHANCE_YOUR_CALM` | A configured abuse, rate, header, body or queue bound was exceeded. | [Security controls](SECURITY.md) and traffic rate before increasing a limit. | +| `INADEQUATE_SECURITY` | TLS does not meet HTTP/2 requirements. | TLS version, cipher suite and ALPN configuration. | +| `HTTP_1_1_REQUIRED` | The peer should retry using HTTP/1.1. | Protocol policy and intermediary compatibility. | + +Flash caps GOAWAY debug data, and clients must not depend on it being present. The numeric error +code and last stream id are the reliable diagnostic fields. + +## Capture a frame trace + +Flash does not log every frame in production: frame logs leak header and traffic metadata and add +work to the hottest connection loop. Reproduce against a verbose client instead: + +```bash +nghttp -nv https://host/path +curl --http2 -v https://host/path +``` + +`nghttp -nv` prints SETTINGS, HEADERS, DATA, WINDOW_UPDATE, RST_STREAM and GOAWAY in wire order. For +a server-side-only failure, capture the connection with an approved packet tool; TLS traffic must +be decrypted in a controlled environment. Never attach production header blocks or payloads to a +ticket without redacting credentials and personal data. + +## Common misconfiguration patterns + +1. **HTTP/2 switch disabled.** `http2Enabled` controls TLS ALPN and + `http2CleartextEnabled` controls prior knowledge independently. +2. **Wrong cleartext mode.** The client sends `Upgrade: h2c`; Flash expects the RFC 9113 prior- + knowledge preface on the shared plaintext listener. +3. **ALPN or certificate mismatch.** A custom `TlsConfig.ofContext` omits `h2`, or hostname + verification rejects the certificate before HTTP/2 starts. Inspect the TLS handshake first. + +If a connection closes under load rather than at startup, compare the observed rate and retained +stream count with [the security defaults](SECURITY.md), especially reset/stream creation budgets, +the 64 concurrent-stream setting, header assembly time and stream idle time. diff --git a/flash/docs/http2/WEBSOCKET.md b/flash/docs/http2/WEBSOCKET.md new file mode 100644 index 0000000..2e228e2 --- /dev/null +++ b/flash/docs/http2/WEBSOCKET.md @@ -0,0 +1,52 @@ +# WebSockets over HTTP/2 + +Flash implements RFC 8441 extended CONNECT alongside the existing HTTP/1.1 WebSocket upgrade. +Both transports resolve the same `ws(path, handler)` registration through `AbstractWsRouter` and +run the same `WebSocketSession`, frame parser, handler callbacks, and close lifecycle. + +## Protocol negotiation + +Every HTTP/2 server connection advertises `SETTINGS_ENABLE_CONNECT_PROTOCOL` (`0x8`) with value +`1`. A WebSocket request uses this pseudo-header shape: + +```text +:method CONNECT +:protocol websocket +:scheme https # or http +:authority example.com +:path /live +``` + +The normal HTTP/1.1 upgrade fields (`Connection`, `Upgrade`, `Sec-WebSocket-Key`, and +`Sec-WebSocket-Accept`) are neither required nor permitted on this path. A matched route receives +status `200`; a missing route receives `404`. + +## Shared application behavior + +At the router boundary, an extended CONNECT for `websocket` is represented as a GET so the +existing WebSocket router can be reused without a second registration table or protocol-specific +handler API. The wire validator retains the original CONNECT semantics and rejects malformed +pseudo-header combinations before dispatch. + +Request DATA is exposed through the existing streaming `RequestBody`. WebSocket output passes +through the common push-style `ResponseStream`, so HTTP/2 stream and connection flow-control +windows apply without changing the WebSocket codec. Messages may cross any number of DATA-frame +boundaries; those boundaries are invisible to RFC 6455 framing. Client-to-server masking remains +mandatory and is validated by the same frame parser used for HTTP/1.1. + +## Lifecycle and backpressure + +Response HEADERS are sent before the push producer is allowed to wait for request DATA. This is +required for a full-duplex protocol: waiting for the first WebSocket frame before publishing the +successful CONNECT response would deadlock compliant clients. Subsequent response batches block +behind the bounded response bridge and resume when HTTP/2 flow-control credit becomes available. + +Handler failures from `onOpen` or `onMessage` are reported through `onError`; `onClose` is invoked +once and the transport is released even if the close callback itself fails. + +## Verification + +`WebSocketOverH2Test` exercises the extended CONNECT exchange, fragmented text, masking, graceful +close, and a binary message larger than the initial one-mebibyte stream window. +`WebSocketParityTest` sends the same message through one route and handler over HTTP/1.1 and +HTTP/2 and compares the result byte for byte. diff --git a/flash/docs/http2/WRITER.md b/flash/docs/http2/WRITER.md new file mode 100644 index 0000000..72a5f80 --- /dev/null +++ b/flash/docs/http2/WRITER.md @@ -0,0 +1,275 @@ +# The serialized frame writer + +Audience: contributors. This is the design record and benchmark evidence for +`dev.relism.flash.http2.frame.Http2FrameWriter`, the one component every HTTP/2 write in this +codebase passes through. It was the central architectural risk: frames, HPACK and flow control are +table-driven, but multiplexed streams require concurrent producers to share one socket without +interleaving bytes or pinning carrier threads. The measured gate is recorded below. + +## The problem, precisely + +Under HTTP/1.1, one virtual thread owns one connection's socket for the request/response it is +currently serving; there is never a second writer. Under HTTP/2, N streams share one connection +and their frames must interleave on the wire, so every write must pass through a serialization +point that plain HTTP/1.1 never needed. A lock taken naively per frame — `synchronized` or an +uncontended `ReentrantLock.lock()` — costs more per write than every allocation this codebase has +ever saved elsewhere (`EX-04` through `EX-29`), because it sits on the one path every response, +of either protocol width, eventually goes through. + +## The design, three layers + +**Layer 1 — serialize outside the lock.** By the time `Http2FrameWriter.write(WriteIntent)` is +called, the caller (a stream, or a connection-level singleton such as a precompiled SETTINGS ACK) +has already built its complete frame — header, HPACK block, payload — into a buffer it owns. The +writer never serializes anything; it holds the lock only for the duration of one bulk +`sink.write(buffer, offset, length)` call, never for a sequence of small writes. This is why +`EX-27` (collapsing `HttpServer.writeResponse`'s ~10 small writes into one) is a prerequisite for +HTTP/1.1 too; `Http1ResponseWriter` now follows the same bulk-write discipline. + +**Layer 2 — `ReentrantLock`, never `synchronized`.** On Java 21, a virtual thread that blocks +inside a `synchronized` block pins its carrier platform thread (JEP 491, which removes this, +only lands in JDK 24+). Blocking on a `ReentrantLock` unmounts the virtual thread instead. This +is the same fix `EX-01` applies to `WebSocketSession`, generalized to the connection writer where +it matters far more (N streams instead of one WebSocket session). `ReentrantLock` is load-bearing +for a second reason `synchronized` cannot offer: `tryLock()`. + +**Layer 3 — `tryLock()` fast path, intrusive MPSC fallback.** The overwhelmingly common instant, +even on a genuinely multiplexed connection, has exactly one stream wanting to write: a browser +calling one API endpoint, a gRPC unary call. `tryLock()` on an uncontended lock is one successful +CAS; the calling thread writes inline and releases — no handoff, no queue touched, no allocation, +no context switch. Only when `tryLock()` fails — genuine contention, genuine multiplexing — does +the intent get published through `IntrusiveMpscQueue` (one more CAS, still zero allocation: the +`WriteIntent` itself is the queue node, via `mpscNext()`/`setMpscNext`) for the current lock +holder to drain. + +``` +happy path (1 active writer): tryLock → sink.write → unlock ≈ 1 CAS +contended (N active writers): tryLock fails → CAS enqueue → return + current holder drains the queue before unlocking +``` + +### Why the fast path checks `queue.hasWork()`, not just `tryLock()` + +Found by this phase's own stress test at N=64/256 — exactly the class of bug R10 exists to catch +before it ships, not after. Writing an intent immediately, ahead of anything already queued, is +only safe when nothing is already queued. Without the `hasWork()` guard: + +1. Producer P calls `write(a)`, then `write(b)`. Both contend (someone else holds the lock) and + both get queued — fire-and-forget from P's point of view. +2. The current holder is *about* to drain them but has not yet done so. +3. P's very next call, `write(c)`, finds the lock free (the holder released it between P's calls) + and — without the guard — would write `c` directly, landing it on the wire *before* `a` and + `b`, which are still sitting in the queue. + +`write()` therefore checks `!queue.hasWork() && lock.tryLock()` before taking the direct path: +"bypass the queue" only happens when the queue is observed genuinely empty, i.e. everything any +producer has ever offered has already been written. `hasWork()` never false-negatives (it would +only ever wrongly report work that isn't there, which just costs an extra harmless `tryLock()` +attempt), so this preserves per-producer ordering without adding a false rejection of the fast +path. + +## Lost-wakeup avoidance + +The classic hazard for a design like this: a producer offers its intent to the queue at the exact +moment the current lock holder has just found the queue empty and is about to unlock. Without +care, the item is stranded — offered, but nobody left to drain it, and the producer already +returned believing the write is in flight. + +``` +Producer P Holder H (currently draining, about to unlock) +─────────── ────────────────────────────────────────────── + next = queue.poll() // null: queue looks empty +queue.offer(intent) ← races here → +if (lock.tryLock()) lock.unlock() + drive(null) // P's own second chance: if P wins the tryLock() race + // immediately after H's unlock(), P itself becomes the new + // holder and drains — including its own just-offered intent. +``` + +Two cooperating mechanisms close this, and both are required — neither alone is sufficient: + +1. **The producer's own second chance.** After a failed `tryLock()`, `write()` offers the intent + *then* immediately attempts `tryLock()` again. If H has already unlocked by this point, P wins + the second `tryLock()` and drains the queue itself (`drive(null)` — draining whatever is + queued, which necessarily includes the intent P just offered, since `offer()` had + already completed). +2. **The holder's re-check-after-unlock loop**, in `drive()`: after `unlock()`, re-read + `queue.hasWork()`. If non-empty, attempt `tryLock()` again and drain, then unlock and re-check + once more — looping, because this recheck cycle can itself race the same way a first pass can. + If a second `tryLock()` in this loop fails, some *other* thread now holds the lock, and by the + same argument that other holder's own re-check-after-unlock covers the item once it releases. + +The correctness argument for why together these are sufficient is a happens-before chain through +the queue's `AtomicReference` (`IntrusiveMpscQueue.head`, a `getAndSet` per `offer`) and the +lock's own acquire/release ordering: every `offer()` happens-before some subsequent `poll()` that +observes it (directly, or via the momentary-`null` self-correcting race documented on +`IntrusiveMpscQueue` itself — see its class Javadoc), and every thread that successfully offers +either (a) is itself about to attempt `tryLock()` and, on success, drains everything including its +own offer, or (b) fails that `tryLock()`, meaning some other thread holds the lock *at that +instant* and that thread's own unlock will trigger its own re-check-after-unlock loop. There is no +interleaving in which an offered intent is neither drained by its own producer nor covered by some +other thread's re-check loop. + +A frame's bytes are never interleaved with another frame's bytes: every write of one intent is a +single `sink.write` call issued while holding the lock, and the lock is not released between a +`WriteIntent`'s bytes — proven directly by `Http2FrameWriterStressTest`, which reassembles +producer/sequence/marker-tagged frames from the sink's output and fails loudly on any torn, +duplicated, reordered, or lost frame. + +## Write timeout + +A blocking write is unavoidable when the kernel send buffer is full and the peer is not reading — +whoever holds the lock is blocked in the syscall, holding up every other stream on the connection. +This is bounded by `Http2Limits.WRITE_TIMEOUT_MS` (30 s), enforced by a single shared daemon +thread (`Http2FrameWriter.WriteTimeoutReaper`) rather than `Socket#setSoTimeout`, which bounds +reads, not writes. + +The reaper deliberately does **not** ask each write to record a `System.nanoTime()` deadline — an +early revision did, and this phase's own N=1 benchmark measured that single `nanoTime()` call +(plus the extra `volatile` field it required) costing enough to put per-write overhead over the +50 ns-over-baseline gate budget. Instead, the reaper scans every registered writer every +`SCAN_INTERVAL_MS` (50 ms) and counts *consecutive* scans a writer has been observed still blocked +(`writingThread` non-null); a writer blocked for more than `WRITE_TIMEOUT_MS / SCAN_INTERVAL_MS` +consecutive scans is interrupted. This trades a little precision — up to one scan interval of +slop, already inherent to any background-reaper design — for removing all per-write timing cost +from the path this document's gate criteria are strictest about. + +## Benchmark methodology + +`flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java` (a JMH source root +registered only under the `jmh` Maven profile, not `src/test/java`) compares four harnesses at +`threads` ∈ {1, 2, 4, 8, 16, 64}: + +- `trylock_mpsc` — the shipped `Http2FrameWriter` design. +- `plain_lock` — every write blocks on `ReentrantLock.lock()` unconditionally (candidate (a)). +- `dedicated_thread` — every write hands off to one dedicated platform thread via the same + `IntrusiveMpscQueue`, parked/unparked, never busy-polled (candidate (c)). +- `raw_unsynchronized` — no coordination at all; not a candidate (concurrent writers would tear + each other's frames), included only to answer "what does a write cost with zero coordination", + which the N=1 gate criterion is defined relative to. + +Each JMH "operation" is a full burst: `threads` virtual producer threads each write 4 000 frames +of 512 bytes into a `CountingSink` that discards the bytes but atomically counts completed writes; +`runBurst` blocks until the count reaches the expected total, so the timed interval always covers +real completion, not mere submission (`write()` can return once an intent is merely *queued* on +the contended path — timing only "how long until every `write()` call returned" would flatter +whichever design most aggressively defers work). `@Threads` was not usable here: it requires a +compile-time constant, not a value swept via `@Param`, and JMH's own thread pool is platform +threads, not the virtual threads under test. + +**Two honest caveats, stated plainly rather than glossed over (R3):** + +1. **The sink is an in-memory counter, not a real socket.** "p999 latency ... on loopback" in the + plan's gate wording implies real socket I/O; this harness measures writer-lock-contention + latency in isolation from network variance, which is the right isolation for judging *this + component*, but it means the recorded p999 numbers below are a lower bound on what a real + loopback socket would show, not a direct stand-in for it. Frame size used is 512 B, not the + plan's illustrative 1 KB — chosen to keep the burst's own array allocation small relative to + JVM defaults; the writer's cost model does not depend on frame size (it copies nothing; see + `WriteIntent`'s Javadoc), so this does not affect the gate conclusions. +2. **One JMH "op" is a whole burst (4 000 writes), not one write**, because `@OperationsPerInvocation` + requires a compile-time constant and cannot vary with the `threads` `@Param`. Every burst also + pays fixed harness costs common to *all four* designs equally: one `ExecutorService` (a + virtual-thread-per-task executor) created and torn down, one `Future[]` array, one + `long[threads][4000]` latency-sample array, and one fresh `BenchIntent` object allocated per + write (matching the stress test's own pattern, not the writer's actual production contract — + a real stream is long-lived and reuses itself as its own `WriteIntent`). Because this cost is + identical across designs, **absolute** `gc.alloc.rate.norm` numbers below are dominated by this + shared harness cost (~33 443 B/op), not by the design under test; the number that actually + answers the "0 B/op" gate criterion is the **differential** between a design and the + `raw_unsynchronized` baseline, which isolates exactly the bytes that design itself adds. + +## Results + +All runs: JDK 21.0.11 (Temurin), this development sandbox, JMH 1.37, `-Fork` per run noted below. +Raw JMH output is not reproduced in full here; the numbers below are the reported means with +their 99.9% CI half-widths. + +### N=1 — throughput and allocation (`-f 4 -wi 5 -w 1s -i 12 -r 2s`, throughput; separately +`-f 2 -wi 3 -i 8`, `-prof gc`) + +| design | ops/s (bursts/s) | derived ns/write | gc.alloc.rate.norm (B/op, per burst) | +|---|---|---|---| +| `trylock_mpsc` | 2007.930 ± 60.623 | 124.5 ns | 33 449.253 ± 13.383 | +| `raw_unsynchronized` | 3052.036 ± 52.515 | 81.9 ns | 33 443.349 ± 1.572 | + +- **Overhead vs. raw unsynchronized:** 124.5 − 81.9 = **42.6 ns** (point estimate). Worst case + within the 99.9% CI (slowest plausible `trylock_mpsc`, fastest plausible baseline): + ≈ **47.9 ns**. Both are under the **50 ns** gate budget. +- **Allocation delta:** 33 449.253 − 33 443.349 = **5.9 B per 4 000-write burst** ≈ **0.0015 B per + write** — within `trylock_mpsc`'s own ±13.383 error band, i.e. not distinguishable from zero. + Consistent with the design: the fast path is `queue.hasWork()` (a volatile read) plus + `ReentrantLock.tryLock()`/`unlock()` (well-known non-allocating on the JDK's implementation) + plus one bulk `sink.write`. **Gate criterion: 0 B/op — PASS.** + +### N=64 — throughput retention and tail latency (`-f 2 -wi 3 -w 1s -i 5 -r 1s`) + +| design | N=1 writes/s (per-thread) | N=64 writes/s (aggregate) | retention | p999 @ N=64 | +|---|---|---|---|---| +| `trylock_mpsc` (shipped) | 8 721 148 | 5 712 640 | **65.5 %** | **11.8–14.2 µs** | +| `plain_lock` (candidate a) | 10 469 956 | 6 082 048 | 58.1 % | 1627–1952 µs | +| `dedicated_thread` (candidate c) | 2 673 964 | 5 718 528 | 213.8 %† | 1.5–6.7 µs | +| `raw_unsynchronized` (unsafe baseline) | 11 353 924 | 20 764 160 | n/a | n/a | + +† `dedicated_thread`'s N=1 baseline is itself poor (every uncontended write still pays a full +park/unpark handoff to the dedicated thread — there is no fast path for the "only one writer" +case at all), so a >100% "retention" number reflects a bad denominator, not superlinear scaling. +It is reported for completeness, not as a pass/fail signal — the gate criterion is defined +relative to `trylock_mpsc`'s own N=1 baseline, which is the design that shipped. + +- **`trylock_mpsc` throughput retention:** 65.5 % ≥ the required 60 %. **PASS.** +- **`trylock_mpsc` p999 latency:** 11.8–14.2 µs, far under the 1 ms budget. **PASS.** +- (Not gate-relevant, but part of why (b) was chosen over (a) and (c), per the plan's task 6: + `plain_lock` blows past the 1 ms p999 budget by ~1000× under load — unfair blocking causes tail + pile-up exactly as expected from a design with no fast path and no fairness guarantee. + `dedicated_thread` has the best tail latency of the three but a **~3.3×** throughput penalty at + N=1, because *every* write, even genuinely uncontended ones, pays a full thread handoff. Neither + alternative is a better shipped default than `trylock_mpsc`.) + +### Stress test — correctness under concurrency, 1000 iterations per N + +Run via an ad hoc reflective driver invoking `Http2FrameWriterStressTest`'s private `runStress` +method directly (the shipped test class runs reduced counts for a fast default `mvn test`; this +is the full gate verification described in that class's own Javadoc), for `N` ∈ {1, 2, 8, 64, +256}, 1000 iterations each: + +| Scheduler | n=1 | n=2 | n=8 | n=64 | n=256 | Total wall time | +|---|---|---|---|---|---|---| +| default parallelism | 0 failures | 0 failures | 0 failures | 0 failures | 0 failures | ≈ 9.1 s | +| `-Djdk.virtualThreadScheduler.parallelism=1` | 0 failures | 0 failures | 0 failures | 0 failures | 0 failures | ≈ 8.9 s | + +Every byte of every frame arrived, correctly ordered per-producer, with no tearing, duplication, +or loss, in both configurations — **10 000 total stress runs, 0 failures.** + +**Carrier pinning:** the `parallelism=1` run above was additionally run under +`-Djdk.tracePinnedThreads=full`, which prints a stack trace to stderr for any virtual thread found +blocked while pinning its carrier. Zero output — **no pinning observed**, consistent with the +design's exclusive use of `ReentrantLock` (never `synchronized`) on every path that can block. + +## Gate criteria — final tally + +| # | Criterion | Result | Verdict | +|---|---|---|---| +| 1 | N=1: 0 B/op | 0.0015 B/write differential vs. baseline, within noise | **PASS** | +| 1 | N=1: ≤50 ns overhead vs. raw unsynchronized | 42.6 ns point estimate, ≤47.9 ns worst-case CI | **PASS** | +| 2 | N=64: throughput ≥60% of N=1 per-thread rate | 65.5 % | **PASS** | +| 2 | N=64: p999 <1 ms (512 B frame, in-memory sink) | 11.8–14.2 µs | **PASS** | +| 3 | No carrier pinning under `-Djdk.tracePinnedThreads=full` | none observed | **PASS** | +| 4 | Stress test green at every N, 1000 iterations, incl. parallelism=1 | 10 000/10 000 | **PASS** | + +**All four gate criteria are met.** `Http2FrameWriter` ships as designed: a `tryLock()` fast path +with an intrusive MPSC fallback. + +## What this design costs vs. what it saves + +The honest framing (per R3, extended from HPACK's own to the writer): the writer's happy path +costs one uncontended CAS (`ReentrantLock.tryLock()`) plus a volatile read (`queue.hasWork()`) +plus the write syscall itself — on the order of tens of nanoseconds, measured above at ~42.6 ns +over a raw unsynchronized write. What it buys is the only thing that makes HTTP/2 multiplexing +possible on a codebase built around "one thread owns the socket": N concurrent streams can write +frames to the same connection without a naive per-frame lock (which the `plain_lock` comparison +above shows costs ~1000× more in tail latency once real contention appears), and without +committing every connection to a dedicated writer thread's per-write handoff cost (which the +`dedicated_thread` comparison shows costs ~3.3× throughput at the N=1 case that dominates real +traffic). Forty-two nanoseconds is a price worth paying once, on the one path that gates +multiplexed HTTP/2 correctness at all. diff --git a/flash/pom.xml b/flash/pom.xml index 3559a2a..56f07f2 100644 --- a/flash/pom.xml +++ b/flash/pom.xml @@ -37,4 +37,82 @@ + + + + jmh + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.codehaus.mojo + build-helper-maven-plugin + ${build.helper.plugin.version} + + + add-jmh-source + generate-test-sources + + add-test-source + + + + src/jmh/java + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + ${lombok.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + + + + diff --git a/flash/src/bench/java/dev/relism/flash/bench/BenchmarkMain.java b/flash/src/bench/java/dev/relism/flash/bench/BenchmarkMain.java new file mode 100644 index 0000000..3c4040e --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/BenchmarkMain.java @@ -0,0 +1,69 @@ +package dev.relism.flash.bench; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import java.net.ServerSocket; +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Real-server, real-network throughput and latency benchmark: boots one live Flash server on + * loopback exposing {@code GET /hello}, then drives it end to end — real sockets, real accept + * loop, real routing and response serialization — with independent HTTP clients across protocols + * and concurrency levels. This is not a component-scoped JMH microbenchmark; it is the same shape + * of measurement a tool like {@code h2load} or {@code wrk} gives any other server. + * + *

Never wired into the build or CI — run manually with: {@code mvn -pl flash -Pbench + * exec:java}. Override scenario length with {@code -Dflash.bench.warmupSeconds} / {@code + * -Dflash.bench.measureSeconds} (defaults: 2 / 5). + */ +public final class BenchmarkMain { + + private static final int[] CONCURRENCY_LEVELS = {1, 8, 32, 128}; + + public static void main(String[] args) throws Exception { + Duration warmup = seconds("flash.bench.warmupSeconds", 2); + Duration measurement = seconds("flash.bench.measureSeconds", 5); + + int port = freePort(); + FlashApp app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .build()); + app.get("/hello", (request, response) -> "hello"); + app.start(); + + try { + URI target = URI.create("http://127.0.0.1:" + port + "/hello"); + Report.print(runAllScenarios(target, warmup, measurement)); + } finally { + app.stop().join(); + } + } + + private static List runAllScenarios(URI target, Duration warmup, Duration measurement) + throws InterruptedException { + List results = new ArrayList<>(); + for (int concurrency : CONCURRENCY_LEVELS) { + results.add( + new Http1Driver() + .run("http/1.1 c=" + concurrency, target, concurrency, warmup, measurement)); + } + return results; + } + + private static Duration seconds(String property, int fallback) { + return Duration.ofSeconds(Long.getLong(property, fallback)); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/Http1Driver.java b/flash/src/bench/java/dev/relism/flash/bench/Http1Driver.java new file mode 100644 index 0000000..bfebe77 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/Http1Driver.java @@ -0,0 +1,37 @@ +package dev.relism.flash.bench; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; + +/** + * HTTP/1.1 keep-alive load driver backed by the JDK's own {@link HttpClient} — an independent + * client implementation, not Flash's own code, measuring the server end to end. + */ +final class Http1Driver implements LoadDriver { + + @Override + public LoadResult run( + String scenarioLabel, URI target, int concurrency, Duration warmup, Duration measurement) + throws InterruptedException { + HttpRequest request = HttpRequest.newBuilder(target).timeout(Duration.ofSeconds(5)).GET().build(); + return LoadRunner.execute( + scenarioLabel, + concurrency, + warmup, + measurement, + () -> { + // One HttpClient per worker: its own connection pool, reused keep-alive across requests. + HttpClient client = HttpClient.newBuilder().version(HttpClient.Version.HTTP_1_1).build(); + return () -> { + HttpResponse response = + client.send(request, HttpResponse.BodyHandlers.discarding()); + if (response.statusCode() != 200) { + throw new IllegalStateException("status " + response.statusCode()); + } + }; + }); + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/LatencyRecorder.java b/flash/src/bench/java/dev/relism/flash/bench/LatencyRecorder.java new file mode 100644 index 0000000..c99d5f8 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/LatencyRecorder.java @@ -0,0 +1,22 @@ +package dev.relism.flash.bench; + +import java.util.Arrays; + +/** One worker's latency samples, in nanoseconds. Grows without boxing on the request loop. */ +final class LatencyRecorder { + private long[] samples = new long[1024]; + private int count; + + void record(long nanos) { + if (count == samples.length) samples = Arrays.copyOf(samples, samples.length * 2); + samples[count++] = nanos; + } + + int count() { + return count; + } + + long[] toArray() { + return Arrays.copyOf(samples, count); + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/LoadDriver.java b/flash/src/bench/java/dev/relism/flash/bench/LoadDriver.java new file mode 100644 index 0000000..fbaea3a --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/LoadDriver.java @@ -0,0 +1,11 @@ +package dev.relism.flash.bench; + +import java.net.URI; +import java.time.Duration; + +/** Runs one scenario (a protocol at a fixed concurrency) against a live target and returns its stats. */ +interface LoadDriver { + LoadResult run( + String scenarioLabel, URI target, int concurrency, Duration warmup, Duration measurement) + throws InterruptedException; +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/LoadResult.java b/flash/src/bench/java/dev/relism/flash/bench/LoadResult.java new file mode 100644 index 0000000..466c5a1 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/LoadResult.java @@ -0,0 +1,17 @@ +package dev.relism.flash.bench; + +/** One scenario's outcome: throughput and latency distribution over the measured phase only. */ +record LoadResult( + String scenario, + long requests, + long errors, + double seconds, + double meanLatencyMicros, + double p50Micros, + double p99Micros, + double p999Micros) { + + double requestsPerSecond() { + return seconds == 0 ? 0 : requests / seconds; + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/LoadRunner.java b/flash/src/bench/java/dev/relism/flash/bench/LoadRunner.java new file mode 100644 index 0000000..e15d216 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/LoadRunner.java @@ -0,0 +1,69 @@ +package dev.relism.flash.bench; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.LongAdder; + +/** + * Drives a fixed number of concurrent virtual-thread workers against one {@link WorkerFactory}, + * each worker looping its own {@link WorkUnit#request()} until a wall-clock deadline. A discarded + * warmup phase runs first so JIT warmup and connection setup don't skew the measured phase. + */ +final class LoadRunner { + + private LoadRunner() {} + + static LoadResult execute( + String scenarioLabel, + int concurrency, + Duration warmup, + Duration measurement, + WorkerFactory factory) + throws InterruptedException { + runUntil(concurrency, System.nanoTime() + warmup.toNanos(), factory, null, null); + + LongAdder errors = new LongAdder(); + List perWorker = new ArrayList<>(concurrency); + for (int i = 0; i < concurrency; i++) perWorker.add(new LatencyRecorder()); + + long measureStart = System.nanoTime(); + runUntil(concurrency, measureStart + measurement.toNanos(), factory, errors, perWorker); + + return Stats.summarize(scenarioLabel, perWorker, errors.sum(), System.nanoTime() - measureStart); + } + + private static void runUntil( + int concurrency, + long deadlineNanos, + WorkerFactory factory, + LongAdder errors, + List perWorker) + throws InterruptedException { + try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) { + for (int i = 0; i < concurrency; i++) { + LatencyRecorder recorder = perWorker == null ? null : perWorker.get(i); + pool.execute(() -> worker(deadlineNanos, factory, errors, recorder)); + } + } + } + + private static void worker( + long deadlineNanos, WorkerFactory factory, LongAdder errors, LatencyRecorder recorder) { + try (WorkUnit unit = factory.create()) { + while (System.nanoTime() < deadlineNanos) { + long start = System.nanoTime(); + try { + unit.request(); + if (recorder != null) recorder.record(System.nanoTime() - start); + } catch (Exception requestFailure) { + if (errors != null) errors.increment(); + } + } + } catch (Exception setupFailure) { + if (errors != null) errors.increment(); + } + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/Report.java b/flash/src/bench/java/dev/relism/flash/bench/Report.java new file mode 100644 index 0000000..c6756c3 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/Report.java @@ -0,0 +1,27 @@ +package dev.relism.flash.bench; + +import java.util.List; + +/** Prints results as a fixed-width table on stdout — no file output, this is a manual tool. */ +final class Report { + + private Report() {} + + static void print(List results) { + System.out.printf( + "%-16s %10s %8s %12s %10s %10s %10s %10s%n", + "scenario", "requests", "errors", "req/s", "mean(us)", "p50(us)", "p99(us)", "p999(us)"); + for (LoadResult result : results) { + System.out.printf( + "%-16s %10d %8d %12.1f %10.1f %10.1f %10.1f %10.1f%n", + result.scenario(), + result.requests(), + result.errors(), + result.requestsPerSecond(), + result.meanLatencyMicros(), + result.p50Micros(), + result.p99Micros(), + result.p999Micros()); + } + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/Stats.java b/flash/src/bench/java/dev/relism/flash/bench/Stats.java new file mode 100644 index 0000000..c8dab74 --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/Stats.java @@ -0,0 +1,52 @@ +package dev.relism.flash.bench; + +import java.util.Arrays; +import java.util.List; + +/** Merges every worker's samples and reduces them to one {@link LoadResult}. */ +final class Stats { + + private Stats() {} + + static LoadResult summarize( + String scenarioLabel, List perWorker, long errors, long elapsedNanos) { + int total = 0; + for (LatencyRecorder recorder : perWorker) total += recorder.count(); + + long[] merged = new long[total]; + int offset = 0; + for (LatencyRecorder recorder : perWorker) { + long[] samples = recorder.toArray(); + System.arraycopy(samples, 0, merged, offset, samples.length); + offset += samples.length; + } + Arrays.sort(merged); + + return new LoadResult( + scenarioLabel, + merged.length, + errors, + elapsedNanos / 1_000_000_000.0, + microsOf(mean(merged)), + microsOf(percentile(merged, 0.50)), + microsOf(percentile(merged, 0.99)), + microsOf(percentile(merged, 0.999))); + } + + private static double mean(long[] sorted) { + if (sorted.length == 0) return 0; + long sum = 0; + for (long value : sorted) sum += value; + return (double) sum / sorted.length; + } + + private static long percentile(long[] sorted, double fraction) { + if (sorted.length == 0) return 0; + int index = (int) Math.min(sorted.length - 1, Math.floor(fraction * sorted.length)); + return sorted[index]; + } + + private static double microsOf(double nanos) { + return nanos / 1000.0; + } +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/WorkUnit.java b/flash/src/bench/java/dev/relism/flash/bench/WorkUnit.java new file mode 100644 index 0000000..94a09ca --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/WorkUnit.java @@ -0,0 +1,9 @@ +package dev.relism.flash.bench; + +/** One worker's request loop body. {@link #close()} releases whatever {@link WorkerFactory} opened. */ +interface WorkUnit extends AutoCloseable { + void request() throws Exception; + + @Override + default void close() throws Exception {} +} diff --git a/flash/src/bench/java/dev/relism/flash/bench/WorkerFactory.java b/flash/src/bench/java/dev/relism/flash/bench/WorkerFactory.java new file mode 100644 index 0000000..d1ce35b --- /dev/null +++ b/flash/src/bench/java/dev/relism/flash/bench/WorkerFactory.java @@ -0,0 +1,7 @@ +package dev.relism.flash.bench; + +/** Builds one worker's {@link WorkUnit} — its own connection/client, isolated per virtual thread. */ +@FunctionalInterface +interface WorkerFactory { + WorkUnit create() throws Exception; +} diff --git a/flash/src/jmh/java/dev/relism/flash/RequestPipelineBenchmark.java b/flash/src/jmh/java/dev/relism/flash/RequestPipelineBenchmark.java new file mode 100644 index 0000000..44fb366 --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/RequestPipelineBenchmark.java @@ -0,0 +1,123 @@ +package dev.relism.flash; + +import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestHandler; +import dev.relism.flash.models.SimpleHandler; +import dev.relism.flash.routing.Middleware; +import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl; +import dev.relism.flash.transport.BufferedByteSource; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** + * The h1 zero-alloc contract: "an h1 {@code GET /users/{id}} request that reads three headers and + * one path param must be 0 B/op end to end except for the user-facing {@code String}s the handler + * explicitly asks for." This benchmark measures the actual number with {@code -prof gc}. Before + * model and view pooling, {@code parseAndRoute} measured 120.008 B/op. It now measures at JMH's + * allocation noise floor. The two methods below isolate the parser/router path from the unavoidable + * cost of explicit {@code String} reads by comparing a route with no header or parameter access + * against one that reads a path parameter and two headers. + * + *

Uses a hand-rolled repeating {@link InputStream} (never allocates, cycles the same request + * bytes indefinitely) rather than a fresh {@code ByteArrayInputStream}/{@code BufferedByteSource} + * per invocation, so the timed path matches production exactly: one {@link BufferedByteSource} + * created once per connection and reused across every request, per {@code Http1Connection}'s own + * shape — not recreated per benchmark iteration, which would contaminate the measurement with + * harness allocation unrelated to the parser/router/model code under test (the same lesson {@code + * WRITER.md} documents for {@code FrameWriterBenchmark}'s own harness). + */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class RequestPipelineBenchmark { + + /** + * Cycles a fixed byte[] indefinitely — simulates an infinite pipelined keep-alive stream of + * identical requests without allocating anything per read. + */ + private static final class RepeatingByteStream extends InputStream { + private final byte[] template; + private int pos; + + RepeatingByteStream(byte[] template) { + this.template = template; + } + + @Override + public int read() { + byte b = template[pos]; + pos = (pos + 1) % template.length; + return b & 0xFF; + } + + @Override + public int read(byte[] dst, int off, int len) { + for (int i = 0; i < len; i++) { + dst[off + i] = template[pos]; + pos = (pos + 1) % template.length; + } + return len; + } + } + + private RequestParser parser; + private BufferedByteSource in; + private FastPathRouterImpl router; + private Object routeScratch; + + @Setup(Level.Trial) + public void setup() { + String req = + "GET /users/12345 HTTP/1.1\r\n" + + "Host: api.example.com\r\n" + + "Accept: application/json\r\n" + + "Authorization: Bearer abcdefghijklmnopqrstuvwxyz\r\n" + + "\r\n"; + byte[] template = req.getBytes(StandardCharsets.US_ASCII); + in = new BufferedByteSource(new RepeatingByteStream(template), null); + parser = new RequestParser(64 * 1024); + + router = new FastPathRouterImpl(); + RequestHandler handler = new SimpleHandler((r, res) -> "ok"); + router.doRegister(HttpMethod.GET, "/users/{id}", handler, new Middleware[0]); + router.compile(); + routeScratch = router.newScratch(); + } + + /** Parse and route without requesting user-facing header or parameter strings. */ + @Benchmark + public RequestHandler parseAndRoute() throws IOException { + Request request = parser.parse(in); + request.drain(); + return router.route(request, routeScratch); + } + + /** Parse, route, and read one path parameter and two headers as strings. */ + @Benchmark + public Object parseRouteAndExtractThreeFields() throws IOException { + Request request = parser.parse(in); + RequestHandler handler = router.route(request, routeScratch); + String id = request.param("id"); + String host = request.header("Host"); + String auth = request.header("Authorization"); + request.drain(); + return id.length() + host.length() + auth.length() + (handler != null ? 1 : 0); + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/bytes/ByteScanBenchmark.java b/flash/src/jmh/java/dev/relism/flash/bytes/ByteScanBenchmark.java new file mode 100644 index 0000000..ec05344 --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/bytes/ByteScanBenchmark.java @@ -0,0 +1,62 @@ +package dev.relism.flash.bytes; + +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Compares {@link ByteScan#indexOfCrLfCrLf} (SWAR) against {@link ByteScan#indexOfCrLfCrLfScalar} + * on a realistic HTTP/1.1 request header block. It lives in this package to reach the + * package-private scalar reference method without widening that method's visibility solely for + * measurement. + * + *

Run: {@code mvn -Pjmh -pl flash test-compile} then {@code java -cp + * flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath + * -Dmdep.outputFile=/dev/stdout -q) org.openjdk.jmh.Main ByteScanBenchmark}. + */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class ByteScanBenchmark { + + /** A realistic request: request line + 7 headers + terminator, ~330 bytes. */ + private byte[] requestBuf; + + @Setup(Level.Trial) + public void setup() { + String req = + "GET /users/12345?sort=desc&limit=20 HTTP/1.1\r\n" + + "Host: api.example.com\r\n" + + "User-Agent: Mozilla/5.0 (compatible; FlashBench/1.0)\r\n" + + "Accept: application/json\r\n" + + "Accept-Encoding: gzip, deflate, br\r\n" + + "Authorization: Bearer abcdefghijklmnopqrstuvwxyz0123456789\r\n" + + "Cookie: session=xyz123abc; theme=dark; lang=en-US\r\n" + + "Connection: keep-alive\r\n" + + "\r\n"; + requestBuf = req.getBytes(StandardCharsets.US_ASCII); + } + + @Benchmark + public int headerEndScan_swar() { + return ByteScan.indexOfCrLfCrLf(requestBuf, 0, requestBuf.length); + } + + @Benchmark + public int headerEndScan_scalar() { + return ByteScan.indexOfCrLfCrLfScalar(requestBuf, 0, requestBuf.length); + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/Http2ConnectionBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/Http2ConnectionBenchmark.java new file mode 100644 index 0000000..f6dcffb --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/Http2ConnectionBenchmark.java @@ -0,0 +1,131 @@ +package dev.relism.flash.http2; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.frame.FrameWriteBuffer; +import dev.relism.flash.http2.frame.Http2FrameReader; +import dev.relism.flash.http2.frame.Http2FrameWriter; +import dev.relism.flash.transport.BufferedByteSource; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** Measures the allocation-free control-frame lifecycle after connection objects are prepared. */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class Http2ConnectionBenchmark { + private static final java.util.function.BooleanSupplier RUNNING = () -> false; + + private byte[] wire; + private Http2Connection connection; + private BufferedByteSource input; + private Http2FrameReader reader; + private Http2FrameWriter writer; + private CountingSink sink; + private ResettableInputStream stream; + + @Setup(Level.Trial) + public void buildWire() { + ByteWriter bytes = new ByteWriter(128); + bytes.writeBytes("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII)); + FrameWriteBuffer frames = new FrameWriteBuffer(bytes); + frames.beginFrame(FrameType.SETTINGS, 0, 0); + frames.endFrame(); + frames.beginFrame(FrameType.SETTINGS, FrameFlags.ACK, 0); + frames.endFrame(); + frames.beginFrame(FrameType.PING, 0, 0); + bytes.writeAscii("12345678"); + frames.endFrame(); + frames.beginFrame(FrameType.WINDOW_UPDATE, 0, 0); + bytes.writeUInt31(1); + frames.endFrame(); + frames.beginFrame(FrameType.GOAWAY, 0, 0); + bytes.writeUInt31(0); + bytes.writeUInt32(Http2ErrorCode.NO_ERROR.code()); + frames.endFrame(); + wire = new byte[bytes.length()]; + System.arraycopy(bytes.array(), 0, wire, 0, wire.length); + setupConnection(); + } + + private void setupConnection() { + connection = new Http2Connection(); + stream = new ResettableInputStream(wire); + input = new BufferedByteSource(stream, null); + reader = new Http2FrameReader(input); + sink = new CountingSink(); + writer = new Http2FrameWriter(sink, 5_000); + } + + @Setup(Level.Invocation) + public void resetConnection() { + stream.reset(); + connection.reset(); + sink.bytes = 0; + } + + @TearDown(Level.Trial) + public void closeWriter() { + writer.close(); + } + + @Benchmark + public int controlLifecycle() throws Exception { + connection.runPrepared(input, reader, writer, RUNNING); + return sink.bytes; + } + + private static final class ResettableInputStream extends InputStream { + private final byte[] bytes; + private int position; + + private ResettableInputStream(byte[] bytes) { + this.bytes = bytes; + } + + @Override + public void reset() { + position = 0; + } + + @Override + public int read() { + return position == bytes.length ? -1 : bytes[position++] & 0xff; + } + + @Override + public int read(byte[] target, int offset, int length) { + if (position == bytes.length) return -1; + int count = Math.min(length, bytes.length - position); + System.arraycopy(bytes, position, target, offset, count); + position += count; + return count; + } + } + + private static final class CountingSink implements Http2FrameWriter.Sink { + private int bytes; + + @Override + public void write(byte[] buffer, int offset, int length) { + bytes += length; + } + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/PerformanceGateTest.java b/flash/src/jmh/java/dev/relism/flash/http2/PerformanceGateTest.java new file mode 100644 index 0000000..e24ae0a --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/PerformanceGateTest.java @@ -0,0 +1,95 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collection; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.profile.GCProfiler; +import org.openjdk.jmh.results.Result; +import org.openjdk.jmh.results.RunResult; +import org.openjdk.jmh.runner.Runner; +import org.openjdk.jmh.runner.options.ChainedOptionsBuilder; +import org.openjdk.jmh.runner.options.Options; +import org.openjdk.jmh.runner.options.OptionsBuilder; +import org.openjdk.jmh.runner.options.TimeValue; + +/** Short, forked JMH gates used by CI; full publication runs retain each benchmark's annotations. */ +@EnabledIfSystemProperty(named = "flash.performance.gates", matches = "true") +class PerformanceGateTest { + private static final double ALLOCATION_NOISE_FLOOR = 0.05; + private static final String INCLUDE = + "(RequestPipelineBenchmark.parseAndRoute" + + "|Http2StreamBenchmark.lifecycle" + + "|Http2ResponseWriterBenchmark.encodeResponse" + + "|HpackDecoderBenchmark.decodeTypicalBrowserRequest" + + "|HpackEncoderBenchmark.encodeTypicalResponse" + + "|FrameLayerBenchmark.readValidateAndDiscard)"; + + @Test + void allocationAndLatencyBaselinesHold() throws Exception { + Collection allocationResults = new Runner(allocationOptions()).run(); + assertFalse(allocationResults.isEmpty(), "JMH did not discover the allocation gates"); + for (RunResult run : allocationResults) { + String benchmark = shortName(run.getParams().getBenchmark()); + Result allocation = run.getSecondaryResults().get("gc.alloc.rate.norm"); + assertTrue(allocation != null, "missing allocation measurement for " + benchmark); + assertTrue( + allocation.getScore() <= ALLOCATION_NOISE_FLOOR, + () -> benchmark + " allocated " + allocation.getScore() + " B/op"); + } + + Collection latencyResults = new Runner(latencyOptions()).run(); + assertFalse(latencyResults.isEmpty(), "JMH did not discover the latency gates"); + for (RunResult run : latencyResults) { + String benchmark = shortName(run.getParams().getBenchmark()); + Double maximumNanos = MAXIMUM_P99_NANOS.get(benchmark); + assertTrue(maximumNanos != null, "missing latency baseline for " + benchmark); + Result p99 = run.getSecondaryResults().get("p0.99"); + assertTrue(p99 != null, "missing p99 measurement for " + benchmark); + double score = p99.getScore(); + assertTrue( + score <= maximumNanos, + () -> benchmark + " p99 regressed to " + score + " ns/op; gate is " + maximumNanos); + } + } + + private static Options allocationOptions() { + return commonOptions() + .mode(Mode.AverageTime) + .addProfiler(GCProfiler.class) + .build(); + } + + private static Options latencyOptions() { + return commonOptions().mode(Mode.SampleTime).build(); + } + + private static ChainedOptionsBuilder commonOptions() { + return new OptionsBuilder() + .include(INCLUDE) + .warmupIterations(2) + .warmupTime(TimeValue.milliseconds(250)) + .measurementIterations(3) + .measurementTime(TimeValue.milliseconds(350)) + .forks(1) + .shouldFailOnError(true); + } + + private static String shortName(String benchmark) { + return benchmark.substring(benchmark.lastIndexOf('.') + 1); + } + + // Filled from the controlled baseline run documented in BASELINES.md, with 35% CI headroom. + private static final Map MAXIMUM_P99_NANOS = + Map.of( + "parseAndRoute", 45_000.0, + "lifecycle", 2_900.0, + "encodeResponse", 1_350.0, + "decodeTypicalBrowserRequest", 7_100.0, + "encodeTypicalResponse", 850.0, + "readValidateAndDiscard", 2_700.0); +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/RollingWindowCounterBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/RollingWindowCounterBenchmark.java new file mode 100644 index 0000000..b1b7a8c --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/RollingWindowCounterBenchmark.java @@ -0,0 +1,27 @@ +package dev.relism.flash.http2; + +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +@Fork(2) +public class RollingWindowCounterBenchmark { + private final RollingWindowCounter counter = new RollingWindowCounter(10_000); + + @Benchmark + public boolean increment() { + return counter.incrementExceeded(Integer.MAX_VALUE); + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameLayerBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameLayerBenchmark.java new file mode 100644 index 0000000..b7229f9 --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameLayerBenchmark.java @@ -0,0 +1,111 @@ +package dev.relism.flash.http2.frame; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.transport.BufferedByteSource; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Measures allocation and latency for reading, validating and discarding a frame and for writing a + * frame header. Allocation is measured with {@code -prof gc}, not inferred from inspection. + * + *

Uses the same hand-rolled repeating {@link InputStream} technique {@code + * RequestPipelineBenchmark} established: one {@link BufferedByteSource}/ {@link Http2FrameReader} + * pair created once per trial and reused across every invocation, matching how a real connection's + * demux loop owns exactly one of each for its whole lifetime, rather than paying for harness-side + * (re)construction inside the timed path. + */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class FrameLayerBenchmark { + + private static final class RepeatingByteStream extends InputStream { + private final byte[] template; + private int pos; + + RepeatingByteStream(byte[] template) { + this.template = template; + } + + @Override + public int read() { + byte b = template[pos]; + pos = (pos + 1) % template.length; + return b & 0xFF; + } + + @Override + public int read(byte[] dst, int off, int len) { + for (int i = 0; i < len; i++) { + dst[off + i] = template[pos]; + pos = (pos + 1) % template.length; + } + return len; + } + } + + // ── Read + validate ────────────────────────────────────────────────────── + + private Http2FrameReader reader; + + @Setup(Level.Trial) + public void setupReader() { + FrameWriteBuffer out = new FrameWriteBuffer(new ByteWriter(64)); + out.beginFrame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1); + byte[] payload = new byte[48]; + for (int i = 0; i < payload.length; i++) payload[i] = (byte) i; + out.writer().writeBytes(payload); + out.endFrame(); + byte[] template = new byte[out.writer().length()]; + System.arraycopy(out.writer().array(), 0, template, 0, template.length); + + BufferedByteSource src = new BufferedByteSource(new RepeatingByteStream(template), null); + reader = new Http2FrameReader(src); + } + + @Benchmark + public int readValidateAndDiscard() throws IOException { + FrameHeader header = reader.readFrame(); + FrameValidator.validate(header, false); + int checksum = header.buffer()[header.payloadOffset()]; + reader.consumeFrame(); + return checksum; + } + + // ── Write ──────────────────────────────────────────────────────────────── + + private FrameWriteBuffer writeBuffer; + private byte[] writePayload; + + @Setup(Level.Trial) + public void setupWriter() { + writeBuffer = new FrameWriteBuffer(new ByteWriter(64)); + writePayload = new byte[48]; + for (int i = 0; i < writePayload.length; i++) writePayload[i] = (byte) i; + } + + @Benchmark + public int writeFrame() { + writeBuffer.writer().reset(); + writeBuffer.beginFrame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1); + writeBuffer.writer().writeBytes(writePayload); + writeBuffer.endFrame(); + return writeBuffer.writer().length(); + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java new file mode 100644 index 0000000..74c91ff --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java @@ -0,0 +1,353 @@ +package dev.relism.flash.http2.frame; + +import java.util.Arrays; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.LockSupport; +import java.util.concurrent.locks.ReentrantLock; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Compares three writer designs at N ∈ {1, 2, 4, 8, 16, 64} concurrent virtual-thread + * writers: + * + *

    + *
  • {@code trylock_mpsc} — the design that ships as {@link Http2FrameWriter}: {@code tryLock()} + * fast path, intrusive MPSC fallback. + *
  • {@code plain_lock} — every write blocks on {@code ReentrantLock#lock()}, unconditionally. + *
  • {@code dedicated_thread} — every write hands off to a single dedicated platform thread via + * the same {@link IntrusiveMpscQueue}, parked/unparked (never a busy poll). + *
+ * + *

Why this benchmark drives its own concurrency instead of JMH's {@code @Threads}

+ * + * {@code @Threads} requires a compile-time constant, not a {@code @Param}-swept value, and JMH's + * thread pool is platform threads, not virtual threads — the exact scheduling behaviour under test. + * Each {@code @Benchmark} invocation therefore spawns {@link #threads} virtual threads itself, has + * them race a fixed burst of writes to a counting no-op sink, and reports the burst's wall-clock + * rate; JMH still owns fork/warmup/measurement-iteration control and (via {@code -prof gc}) the + * zero-allocation verification. + * + *

Why {@code runBurst} waits on a write counter, not just thread completion

+ * + * {@code write()} does not mean "already on the wire" for every design: the shipped design's + * contended path, and the dedicated-thread design's handoff, can both return once the frame is + * merely *queued*. Timing only "how long until every producer's {@code write()} call returned" + * would therefore measure submission speed, not completion speed, and would flatter exactly the + * designs that most aggressively defer work — the opposite of a fair comparison. Every harness here + * writes through {@link CountingSink} and {@link #runBurst} waits for its counter to reach the + * expected total before returning, so the timed interval always covers real completion. + * + *

Per-write latency percentiles are computed by hand from {@code System.nanoTime()} samples + * collected during the burst (JMH's own {@code Mode.SampleTime} does not fit a custom-concurrency + * benchmark method) and printed once per (design, threads) combination — see {@code WRITER.md} for + * the recorded results and the gate decision. + * + *

Run: {@code mvn -Pjmh -pl flash test-compile} then {@code java -cp + * flash/target/test-classes:flash/target/classes:$(mvn -Pjmh -pl flash dependency:build-classpath + * -Dmdep.outputFile=/dev/stdout -q) org.openjdk.jmh.Main FrameWriterBenchmark -prof gc}. + */ +@State(Scope.Benchmark) +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.SECONDS) +@Fork(1) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class FrameWriterBenchmark { + + private static final int FRAMES_PER_THREAD = 4000; + private static final int FRAME_SIZE = 512; + + @Param({"trylock_mpsc", "plain_lock", "dedicated_thread", "raw_unsynchronized"}) + public String design; + + @Param({"1", "2", "4", "8", "16", "64"}) + public int threads; + + private DesignHarness harness; + private byte[] payload; + + @Setup(Level.Trial) + public void setup() { + payload = new byte[FRAME_SIZE]; + harness = + switch (design) { + case "trylock_mpsc" -> new TryLockMpscHarness(); + case "plain_lock" -> new PlainLockHarness(); + case "dedicated_thread" -> new DedicatedThreadHarness(); + case "raw_unsynchronized" -> new RawUnsynchronizedHarness(); + default -> throw new IllegalStateException("unknown design: " + design); + }; + } + + @TearDown(Level.Trial) + public void teardown() { + harness.shutdown(); + } + + /** + * One "operation" here is a full burst: {@link #threads} virtual threads each writing {@link + * #FRAMES_PER_THREAD} frames. Reported ops/sec must be multiplied by {@code threads * + * FRAMES_PER_THREAD} to get frames/sec — done during result analysis, not via + * {@code @OperationsPerInvocation} (which requires a compile-time constant and cannot vary with + * the {@code threads} @Param). + */ + @Benchmark + public void burst() throws Exception { + harness.runBurst(threads, FRAMES_PER_THREAD, payload); + } + + // ── Harness abstraction and the three designs under comparison ───────────── + + private interface DesignHarness { + void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception; + + void shutdown(); + } + + /** + * Discards everything (isolating the writer designs from real socket variance) but counts every + * completed write, so callers can wait for true completion rather than mere submission — see the + * class Javadoc. + */ + private static final class CountingSink implements Http2FrameWriter.Sink { + final AtomicLong count = new AtomicLong(); + + @Override + public void write(byte[] buf, int off, int len) { + count.incrementAndGet(); + } + } + + private static final class BenchIntent implements WriteIntent { + final byte[] buf; + WriteIntent next; + + BenchIntent(byte[] buf) { + this.buf = buf; + } + + @Override + public byte[] buffer() { + return buf; + } + + @Override + public int offset() { + return 0; + } + + @Override + public int length() { + return buf.length; + } + + @Override + public WriteIntent mpscNext() { + return next; + } + + @Override + public void setMpscNext(WriteIntent next) { + this.next = next; + } + } + + private interface ThrowingConsumer { + void accept(T t) throws Exception; + } + + /** + * Spawns {@code threadCount} virtual threads, has each write {@code framesPerThread} fresh {@link + * BenchIntent}s (one per write — matches production usage, where a stream's scratch buffer holds + * exactly one in-flight frame at a time), records per-write latency samples, then blocks until + * {@code sink}'s counter reflects every one of them actually written. + */ + private static void race( + int threadCount, int framesPerThread, CountingSink sink, ThrowingConsumer write) + throws Exception { + long target = sink.count.get() + (long) threadCount * framesPerThread; + byte[] payload = new byte[FRAME_SIZE]; + long[][] samplesByThread = new long[threadCount][framesPerThread]; + try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { + Future[] futures = new Future[threadCount]; + for (int t = 0; t < threadCount; t++) { + int idx = t; + futures[t] = + exec.submit( + () -> { + long[] samples = samplesByThread[idx]; + for (int i = 0; i < framesPerThread; i++) { + BenchIntent intent = new BenchIntent(payload); + long start = System.nanoTime(); + try { + write.accept(intent); + } catch (Exception e) { + throw new RuntimeException(e); + } + samples[i] = System.nanoTime() - start; + } + }); + } + for (Future f : futures) f.get(); + } + while (sink.count.get() < target) { + Thread.onSpinWait(); + } + LatencyReport.recordAndMaybePrint(samplesByThread); + } + + /** + * Prints p50/p99/p999 to stdout once per thread-count actually exercised, from the first burst + * observed for it — cheap, and avoids flooding the JMH log with one line per measurement + * iteration. + */ + private static final class LatencyReport { + private static final Set PRINTED = ConcurrentHashMap.newKeySet(); + + static void recordAndMaybePrint(long[][] samplesByThread) { + String key = samplesByThread.length + "t"; + if (!PRINTED.add(key)) return; + + int total = 0; + for (long[] s : samplesByThread) total += s.length; + long[] all = new long[total]; + int pos = 0; + for (long[] s : samplesByThread) { + System.arraycopy(s, 0, all, pos, s.length); + pos += s.length; + } + Arrays.sort(all); + long p50 = all[(int) (all.length * 0.50)]; + long p99 = all[(int) (all.length * 0.99)]; + long p999 = all[Math.min(all.length - 1, (int) (all.length * 0.999))]; + System.out.printf( + "[latency threads=%d] p50=%.1fus p99=%.1fus p999=%.1fus (n=%d)%n", + samplesByThread.length, p50 / 1000.0, p99 / 1000.0, p999 / 1000.0, all.length); + } + } + + // ── Baseline: no synchronization at all ───────────────────────────────────── + // Not a candidate design (concurrent writers would tear each other's frames) — exists + // purely to establish "what a write costs with zero coordination overhead" for the N=1 + // gate criterion ("per-frame overhead versus a raw unsynchronized write is within 50 ns"). + // At N=1 there genuinely is no concurrent writer, so the missing safety is moot there. + + private static final class RawUnsynchronizedHarness implements DesignHarness { + private final CountingSink sink = new CountingSink(); + + @Override + public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { + race( + threads, + framesPerThread, + sink, + intent -> sink.write(intent.buffer(), intent.offset(), intent.length())); + } + + @Override + public void shutdown() {} + } + + // ── Design (a): plain lock ────────────────────────────────────────────────── + + private static final class PlainLockHarness implements DesignHarness { + private final CountingSink sink = new CountingSink(); + private final ReentrantLock lock = new ReentrantLock(); + + @Override + public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { + race( + threads, + framesPerThread, + sink, + intent -> { + lock.lock(); + try { + sink.write(intent.buffer(), intent.offset(), intent.length()); + } finally { + lock.unlock(); + } + }); + } + + @Override + public void shutdown() {} + } + + // ── Design (b): tryLock + intrusive MPSC — the shipped design ────────────── + + private static final class TryLockMpscHarness implements DesignHarness { + private final CountingSink sink = new CountingSink(); + private final Http2FrameWriter writer = new Http2FrameWriter(sink, 30_000); + + @Override + public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { + race(threads, framesPerThread, sink, writer::write); + } + + @Override + public void shutdown() { + writer.close(); + } + } + + // ── Design (c): always hand off to one dedicated writer thread ───────────── + + private static final class DedicatedThreadHarness implements DesignHarness { + private final CountingSink sink = new CountingSink(); + private final IntrusiveMpscQueue queue = new IntrusiveMpscQueue(); + private final Thread writerThread; + private volatile boolean running = true; + + DedicatedThreadHarness() { + this.writerThread = Thread.ofPlatform().name("bench-dedicated-writer").start(this::loop); + } + + private void loop() { + while (running) { + WriteIntent intent = queue.poll(); + if (intent == null) { + LockSupport.park(); + continue; + } + sink.write(intent.buffer(), intent.offset(), intent.length()); + } + } + + @Override + public void runBurst(int threads, int framesPerThread, byte[] payload) throws Exception { + race( + threads, + framesPerThread, + sink, + intent -> { + queue.offer(intent); + LockSupport.unpark(writerThread); + }); + } + + @Override + public void shutdown() { + running = false; + writerThread.interrupt(); + } + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/hpack/HpackDecoderBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/hpack/HpackDecoderBenchmark.java new file mode 100644 index 0000000..3524f8d --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/hpack/HpackDecoderBenchmark.java @@ -0,0 +1,62 @@ +package dev.relism.flash.http2.hpack; + +import java.util.concurrent.TimeUnit; +import dev.relism.flash.bytes.ByteWriter; +import java.nio.charset.StandardCharsets; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** Measures steady-state decoding into reusable per-stream storage. */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class HpackDecoderBenchmark { + private final HpackDecoder decoder = new HpackDecoder(); + private final HpackHeaderBlock headers = new HpackHeaderBlock(); + private final byte[] block = {(byte) 0x82, (byte) 0x87, (byte) 0x84, (byte) 0x88}; + private byte[] browserBlock; + + @Setup(Level.Trial) + public void setupTypicalBlock() { + ByteWriter encoded = new ByteWriter(256); + HpackEncoder.writeIndexed(encoded, 2); + HpackEncoder.writeIndexed(encoded, 7); + HpackEncoder.writeLiteralWithNameIndex( + encoded, 4, "/products?category=books".getBytes(StandardCharsets.US_ASCII), true); + HpackEncoder.writeLiteralWithNameIndex( + encoded, 1, "shop.example.com".getBytes(StandardCharsets.US_ASCII), true); + HpackEncoder.writeLiteralWithNameIndex( + encoded, 19, "text/html,application/xhtml+xml".getBytes(StandardCharsets.US_ASCII), true); + HpackEncoder.writeLiteralWithNameIndex( + encoded, 16, "gzip, deflate".getBytes(StandardCharsets.US_ASCII), true); + HpackEncoder.writeLiteralWithNameIndex( + encoded, 55, "Mozilla/5.0 benchmark".getBytes(StandardCharsets.US_ASCII), true); + browserBlock = java.util.Arrays.copyOf(encoded.array(), encoded.length()); + } + + @Benchmark + public int decodeStaticRequest() { + headers.reset(); + decoder.decode(block, 0, block.length, headers); + return headers.count(); + } + + @Benchmark + public int decodeTypicalBrowserRequest() { + headers.reset(); + decoder.decode(browserBlock, 0, browserBlock.length, headers); + return headers.count(); + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/hpack/HpackEncoderBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/hpack/HpackEncoderBenchmark.java new file mode 100644 index 0000000..2a6b73b --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/hpack/HpackEncoderBenchmark.java @@ -0,0 +1,43 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.flash.bytes.ByteWriter; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** Measures a representative stateless response header block. */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class HpackEncoderBenchmark { + private static final byte[] CONTENT_LENGTH = "1024".getBytes(StandardCharsets.US_ASCII); + private static final byte[] CONTENT_TYPE = "application/json".getBytes(StandardCharsets.US_ASCII); + private static final byte[] CACHE_CONTROL = "no-cache".getBytes(StandardCharsets.US_ASCII); + private static final byte[] ETAG_NAME = "etag".getBytes(StandardCharsets.US_ASCII); + private static final byte[] ETAG = "\"abc123\"".getBytes(StandardCharsets.US_ASCII); + private static final byte[] SERVER = "Flash".getBytes(StandardCharsets.US_ASCII); + private final ByteWriter output = new ByteWriter(128); + + @Benchmark + public int encodeTypicalResponse() { + output.reset(); + HpackEncoder.writeIndexed(output, 8); + HpackEncoder.writeLiteralWithNameIndex(output, 31, CONTENT_TYPE, true); + HpackEncoder.writeLiteralWithNameIndex(output, 28, CONTENT_LENGTH, false); + HpackEncoder.writeLiteralWithNameIndex(output, 24, CACHE_CONTROL, true); + HpackEncoder.writeLiteralWithNameIndex(output, 51, SERVER, true); + HpackEncoder.writeLiteral(output, ETAG_NAME, ETAG); + return output.length(); + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/message/Http2BodyBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/message/Http2BodyBenchmark.java new file mode 100644 index 0000000..415f7f1 --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/message/Http2BodyBenchmark.java @@ -0,0 +1,122 @@ +package dev.relism.flash.http2.message; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.models.RequestBody; +import dev.relism.flash.models.Response; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Warmup(iterations = 3) +@Measurement(iterations = 5) +@Fork(2) +@State(Scope.Thread) +public class Http2BodyBenchmark { + private static final Http2RequestBody.ConsumptionListener NOOP = bytes -> {}; + + private final byte[] payload = new byte[1024]; + private final byte[] streamingPayload = new byte[1024 * 1024]; + private final byte[] target = new byte[1024]; + private DataBufferPool pool; + private Http2RequestBody source; + private RequestBody body; + private Response response; + private Http2ResponseWriter responseWriter; + private ResettableInputStream responseSource; + private ResettableInputStream largeResponseSource; + + @Setup(Level.Trial) + public void setup() throws IOException { + pool = new DataBufferPool(16_384, 1); + source = new Http2RequestBody(pool); + body = new RequestBody(); + response = new Response(200, ContentType.BINARY); + responseWriter = new Http2ResponseWriter(); + responseSource = new ResettableInputStream(payload); + largeResponseSource = new ResettableInputStream(streamingPayload); + source.begin(-1, false, NOOP); + source.offer(1, payload, 0, payload.length, payload.length); + source.finish(1); + source.read(target); + } + + @Benchmark + public byte[] inlineBytes() { + source.begin(payload.length, true, NOOP); + source.offer(1, payload, 0, payload.length, payload.length); + source.finish(1); + body.reset(source, payload.length, null, 0, 0); + return body.bytes(); + } + + @Benchmark + public int streamingRead() throws IOException { + source.begin(-1, false, NOOP); + source.offer(1, payload, 0, payload.length, payload.length); + source.finish(1); + return source.read(target, 0, target.length); + } + + @Benchmark + public int streamingResponseFrame() throws IOException { + responseSource.rewind(); + response.reset(200, ContentType.BINARY).stream(responseSource, payload.length); + responseWriter.startFlowControlled( + response, 1, false, false, true, false, false, 16_384, 32_768, 16_384); + return responseWriter.length(); + } + + @Benchmark + public int streamingResponseOneMiB() throws IOException { + largeResponseSource.rewind(); + response.reset(200, ContentType.BINARY).stream(largeResponseSource, streamingPayload.length); + responseWriter.startFlowControlled( + response, 1, false, false, true, false, false, 16_384, 32_768, 16_384); + int wireBytes = responseWriter.length(); + while (!responseWriter.finished()) { + responseWriter.resume(16_384, 16_384); + wireBytes += responseWriter.length(); + } + return wireBytes; + } + + private static final class ResettableInputStream extends InputStream { + private final byte[] source; + private int position; + + ResettableInputStream(byte[] source) { + this.source = source; + } + + void rewind() { + position = 0; + } + + @Override + public int read() { + return position == source.length ? -1 : source[position++] & 0xff; + } + + @Override + public int read(byte[] target, int offset, int length) { + if (position == source.length) return -1; + int count = Math.min(length, source.length - position); + System.arraycopy(source, position, target, offset, count); + position += count; + return count; + } + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/message/Http2ResponseWriterBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/message/Http2ResponseWriterBenchmark.java new file mode 100644 index 0000000..bf8bdcb --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/message/Http2ResponseWriterBenchmark.java @@ -0,0 +1,44 @@ +package dev.relism.flash.http2.message; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.models.PreEncodedHeader; +import dev.relism.flash.models.Response; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** Measures the steady-state allocation cost of a representative fixed HTTP/2 response. */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class Http2ResponseWriterBenchmark { + private Http2ResponseWriter writer; + private Response response; + + @Setup + public void setup() { + writer = new Http2ResponseWriter(); + response = + new Response(200, "hello", ContentType.JSON) + .header(new PreEncodedHeader("cache-control", "no-store")) + .header(new PreEncodedHeader("x-trace", "abc123")); + writer.prepare(response, 1, false, true, true, false, false, 16_384, 16_384, 65_535); + } + + @Benchmark + public int encodeResponse() { + writer.prepare(response, 1, false, true, true, false, false, 16_384, 16_384, 65_535); + return writer.length(); + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/message/Http2TuningBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/message/Http2TuningBenchmark.java new file mode 100644 index 0000000..4a81dff --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/message/Http2TuningBenchmark.java @@ -0,0 +1,117 @@ +package dev.relism.flash.http2.message; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.models.PreEncodedHeader; +import dev.relism.flash.models.Response; +import java.io.IOException; +import java.io.InputStream; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** Factorial measurements for the response knobs considered during tuning. */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class Http2TuningBenchmark { + @Param({"false", "true"}) + public boolean huffmanDynamicValues; + + @Param({"16384", "65536", "1048576"}) + public int maxFrameSize; + + private final byte[] largeBody = new byte[1024 * 1024]; + private Http2ResponseWriter writer; + private Response response; + private Response streamingResponse; + private ResettableInputStream source; + + @Setup(Level.Trial) + public void setup() { + writer = new Http2ResponseWriter(); + response = + new Response(200, "hello", ContentType.JSON) + .header(new PreEncodedHeader("cache-control", "private, max-age=60")) + .header(new PreEncodedHeader("x-request-id", "d7bca219-6dd4-4ef0-a881-f21931e249c7")); + source = new ResettableInputStream(largeBody); + streamingResponse = new Response(200, ContentType.BINARY).stream(source, largeBody.length); + } + + @Benchmark + public int encodeResponseHeaders() { + writer.prepare( + response, + 1, + false, + true, + true, + huffmanDynamicValues, + false, + maxFrameSize, + 32_768, + 65_535); + return writer.length(); + } + + @Benchmark + public int streamOneMiB() throws IOException { + source.rewind(); + writer.startFlowControlled( + streamingResponse, + 1, + false, + false, + true, + huffmanDynamicValues, + false, + maxFrameSize, + 32_768, + maxFrameSize); + int wireBytes = writer.length(); + while (!writer.finished()) { + writer.resume(maxFrameSize, maxFrameSize); + wireBytes += writer.length(); + } + return wireBytes; + } + + private static final class ResettableInputStream extends InputStream { + private final byte[] bytes; + private int position; + + private ResettableInputStream(byte[] bytes) { + this.bytes = bytes; + } + + private void rewind() { + position = 0; + } + + @Override + public int read() { + return position == bytes.length ? -1 : bytes[position++] & 0xff; + } + + @Override + public int read(byte[] target, int offset, int length) { + if (position == bytes.length) return -1; + int count = Math.min(length, bytes.length - position); + System.arraycopy(bytes, position, target, offset, count); + position += count; + return count; + } + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/stream/Http2MultiplexingBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/stream/Http2MultiplexingBenchmark.java new file mode 100644 index 0000000..ac6521c --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/stream/Http2MultiplexingBenchmark.java @@ -0,0 +1,77 @@ +package dev.relism.flash.http2.stream; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.flash.http2.hpack.HpackEncoder; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** Measures one response pass across a connection with N simultaneously live request streams. */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class Http2MultiplexingBenchmark { + private static final byte[] BODY = "ok".getBytes(StandardCharsets.US_ASCII); + + @Param({"1", "8", "64", "256"}) + public int liveStreams; + + private Http2Stream[] streams; + + @Setup(Level.Trial) + public void setup() { + Http2StreamTable table = new Http2StreamTable(liveStreams); + streams = new Http2Stream[liveStreams]; + ByteWriter encoded = new ByteWriter(64); + HpackEncoder.writeIndexed(encoded, 2); + HpackEncoder.writeIndexed(encoded, 7); + HpackEncoder.writeLiteralWithNameIndex( + encoded, 4, "/get".getBytes(StandardCharsets.US_ASCII), false); + HpackEncoder.writeLiteralWithNameIndex( + encoded, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false); + HpackDecoder decoder = new HpackDecoder(); + for (int i = 0; i < streams.length; i++) { + Http2Stream stream = table.acquire(i * 2 + 1); + decoder.decode(encoded.array(), 0, encoded.length(), stream.headerBlock()); + stream.assembleRequest(null, null); + streams[i] = stream; + } + } + + @Benchmark + public int encodeAllLiveStreamResponses() { + int wireBytes = 0; + for (Http2Stream stream : streams) { + stream + .responseWriter() + .prepare( + stream.resetResponse().body(BODY), + stream.id(), + false, + false, + true, + false, + false, + 16_384, + 32_768, + 65_535); + wireBytes += stream.responseWriter().length(); + } + return wireBytes; + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/http2/stream/Http2StreamBenchmark.java b/flash/src/jmh/java/dev/relism/flash/http2/stream/Http2StreamBenchmark.java new file mode 100644 index 0000000..7ccb7c8 --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/http2/stream/Http2StreamBenchmark.java @@ -0,0 +1,108 @@ +package dev.relism.flash.http2.stream; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.flash.http2.hpack.HpackEncoder; +import dev.relism.flash.models.Response; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** Measures the pooled HPACK-decode, request-assembly and fixed-response stream lifecycle. */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class Http2StreamBenchmark { + private static final byte[] BODY = "pong".getBytes(StandardCharsets.US_ASCII); + private static final byte[] POST_BODY = new byte[1024]; + + private Http2StreamTable streams; + private HpackDecoder decoder; + private byte[] requestBlock; + private int requestLength; + private byte[] postBlock; + private int postLength; + + @Setup + public void setup() { + streams = new Http2StreamTable(1); + decoder = new HpackDecoder(); + ByteWriter block = new ByteWriter(64); + HpackEncoder.writeIndexed(block, 2); + HpackEncoder.writeIndexed(block, 7); + HpackEncoder.writeLiteralWithNameIndex( + block, 4, "/ping".getBytes(StandardCharsets.US_ASCII), false); + HpackEncoder.writeLiteralWithNameIndex( + block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false); + requestBlock = block.array(); + requestLength = block.length(); + ByteWriter post = new ByteWriter(96); + HpackEncoder.writeIndexed(post, 3); + HpackEncoder.writeIndexed(post, 7); + HpackEncoder.writeLiteralWithNameIndex( + post, 4, "/ping".getBytes(StandardCharsets.US_ASCII), false); + HpackEncoder.writeLiteralWithNameIndex( + post, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false); + HpackEncoder.writeLiteralWithNameIndex( + post, 28, "1024".getBytes(StandardCharsets.US_ASCII), false); + postBlock = post.array(); + postLength = post.length(); + lifecycle(); + postOneKiB(); + } + + @Benchmark + public int lifecycle() { + Http2Stream stream = streams.acquire(1); + decoder.decode(requestBlock, 0, requestLength, stream.headerBlock()); + stream.assembleRequest(null, null); + Response response = stream.resetResponse().body(BODY); + stream + .responseWriter() + .prepare(response, 1, false, false, true, false, false, 16_384, 32_768, 65_535); + int bytes = stream.responseWriter().length(); + streams.remove(1); + streams.release(stream); + return bytes; + } + + /** Unary request shape: HPACK decode, one 1 KiB DATA payload, assembly and fixed response. */ + @Benchmark + public int postOneKiB() { + Http2Stream stream = streams.acquire(1); + decoder.decode(postBlock, 0, postLength, stream.headerBlock()); + stream.prepareRequestBody(null, false); + stream.receiveData(POST_BODY, 0, POST_BODY.length, POST_BODY.length); + stream.finishRequestBody(); + stream.assembleRequest(null, null); + stream + .responseWriter() + .prepare( + stream.resetResponse().body(BODY), + 1, + false, + false, + true, + false, + false, + 16_384, + 32_768, + 65_535); + int bytes = stream.responseWriter().length(); + streams.remove(1); + streams.release(stream); + return bytes; + } +} diff --git a/flash/src/jmh/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterBenchmark.java b/flash/src/jmh/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterBenchmark.java new file mode 100644 index 0000000..e9e2068 --- /dev/null +++ b/flash/src/jmh/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterBenchmark.java @@ -0,0 +1,119 @@ +package dev.relism.flash.routing.routers.fastpathrouter; + +import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestHandler; +import dev.relism.flash.models.SimpleHandler; +import dev.relism.fpr.core.internal.runtime.ByteCompare; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Warmup; + +/** + * Two related but distinct measurements of router and byte-comparison performance. + * + *

{@code router_*} exercises the shipped {@link FastPathRouterImpl#route} end to end, + * including lazy-compiled route-table lookup, {@link FastPathRouterImpl.RouteScratch} reuse and + * path-parameter extraction. + * + *

{@code byteCompare_*} directly compares {@code ByteCompare.equals} with its + * word-at-a-time path enabled and disabled over representative array-backed content. This is + * separate because router matching uses the composite, non-array-backed {@link + * FastPathViews.MethodPathByteView}, so the router measurements cannot expose the word path's + * effect. + */ +@State(Scope.Thread) +@BenchmarkMode(Mode.AverageTime) +@OutputTimeUnit(TimeUnit.NANOSECONDS) +@Fork(2) +@Warmup(iterations = 3, time = 1) +@Measurement(iterations = 5, time = 1) +public class FastPathRouterBenchmark { + + // ── router_*: the real, shipped router, end to end ────────────────────── + + private FastPathRouterImpl router; + private Object scratch; + private Request staticRequest; + private Request paramRequest; + + @Setup(Level.Trial) + public void setupRouter() { + router = new FastPathRouterImpl(); + RequestHandler h = new SimpleHandler((req, res) -> "ok"); + router.doRegister(HttpMethod.GET, "/health", h, new dev.relism.flash.routing.Middleware[0]); + router.doRegister(HttpMethod.GET, "/users/{id}", h, new dev.relism.flash.routing.Middleware[0]); + router.doRegister( + HttpMethod.GET, + "/users/{id}/posts/{postId}", + h, + new dev.relism.flash.routing.Middleware[0]); + router.doRegister(HttpMethod.POST, "/users", h, new dev.relism.flash.routing.Middleware[0]); + router.doRegister( + HttpMethod.GET, + "/api/v1/products/{category}/{id}", + h, + new dev.relism.flash.routing.Middleware[0]); + router.compile(); + scratch = router.newScratch(); + + staticRequest = mockRequest(HttpMethod.GET, "/health"); + paramRequest = mockRequest(HttpMethod.GET, "/users/12345/posts/67890"); + } + + @Benchmark + public RequestHandler router_staticRoute() { + return router.route(staticRequest, scratch); + } + + @Benchmark + public RequestHandler router_parametricRoute() { + return router.route(paramRequest, scratch); + } + + private static Request mockRequest(HttpMethod method, String path) { + byte[] bytes = path.getBytes(StandardCharsets.UTF_8); + FastPathViews.RequestByteView pathView = + new FastPathViews.RequestByteView(bytes, 0, bytes.length); + dev.relism.flash.models.RequestLine line = + new dev.relism.flash.models.RequestLine( + method, + pathView, + null, + new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8), + new dev.relism.flash.models.Http1HeaderMap()); + return new Request(line, new byte[0]); + } + + // ── byteCompare_*: word-at-a-time comparison in isolation ────────────── + + private FastPathViews.RequestByteView cmpView; + private byte[] cmpOther; + + @Setup(Level.Trial) + public void setupByteCompare() { + byte[] content = "/api/v1/products/electronics/00012345".getBytes(StandardCharsets.US_ASCII); + cmpView = new FastPathViews.RequestByteView(content, 0, content.length); + cmpOther = content.clone(); + } + + @Benchmark + public boolean byteCompare_longPath() { + return ByteCompare.equals(cmpView, 0, cmpOther, 0, cmpOther.length, true); + } + + @Benchmark + public boolean byteCompare_byteAtATime() { + return ByteCompare.equals(cmpView, 0, cmpOther, 0, cmpOther.length, false); + } +} diff --git a/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java b/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java index 492dc7d..182c8a1 100644 --- a/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java +++ b/flash/src/main/java/dev/relism/flash/ChunkedInputStream.java @@ -1,24 +1,49 @@ package dev.relism.flash; -import java.io.ByteArrayInputStream; +import dev.relism.flash.exceptions.MalformedRequestException; +import dev.relism.flash.http.Http1Limits; +import dev.relism.flash.models.BodyCompletion; +import dev.relism.flash.models.MutableHeaderMap; +import dev.relism.flash.transport.BufferedByteSource; + import java.io.IOException; import java.io.InputStream; -import java.io.SequenceInputStream; /** * De-chunking {@link InputStream} for HTTP/1.1 {@code Transfer-Encoding: chunked} request bodies. * Handles pre-buffered bytes from the header read-ahead, chunk framing, and trailer consumption. * Returns -1 at end of the final chunk; the underlying socket is left positioned for the next request. + * + * instead of the raw, unbuffered socket stream. Chunk-size digits, the trailing CRLF after each + * chunk, and trailer lines are all read one byte at a time by design (the framing is + * byte-oriented) — that used to mean one {@code read(2)} syscall per byte on the raw socket; + * against {@link BufferedByteSource} it is a read from an already-filled in-memory buffer. + * The header-parser's read-ahead bytes are handed to {@code src} via + * {@link BufferedByteSource#prependOnce}, replacing the {@code SequenceInputStream}/ + * {@code ByteArrayInputStream} pair the previous implementation allocated per chunked request. */ -final class ChunkedInputStream extends InputStream { - private final InputStream src; +final class ChunkedInputStream extends InputStream implements BodyCompletion { + private final BufferedByteSource src; private int chunkRemaining = 0; private boolean done = false; + private int chunksSeen = 0; + private final MutableHeaderMap trailers; + private final byte[] trailerLine = new byte[Http1Limits.MAX_HEADER_VALUE_LENGTH]; - ChunkedInputStream(InputStream socket, byte[] preBuf, int preBufOff, int preBufLen) { - src = preBufLen > 0 - ? new SequenceInputStream(new ByteArrayInputStream(preBuf, preBufOff, preBufLen), socket) - : socket; + ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen, + MutableHeaderMap trailers) { + this.src = src; + this.trailers = trailers; + if (preBufLen > 0) src.prependOnce(preBuf, preBufOff, preBufLen); + } + + ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen) { + this(src, preBuf, preBufOff, preBufLen, new MutableHeaderMap()); + } + + @Override + public boolean fullyRead() { + return done; } @Override @@ -29,7 +54,7 @@ final class ChunkedInputStream extends InputStream { if (chunkRemaining == 0) { consumeTrailers(); done = true; return -1; } } int b = src.read(); - if (b >= 0 && --chunkRemaining == 0) { src.read(); src.read(); } // consume trailing \r\n + if (b >= 0 && --chunkRemaining == 0) consumeChunkTerminator(); return b; } @@ -43,30 +68,154 @@ final class ChunkedInputStream extends InputStream { int n = src.read(buf, off, Math.min(len, chunkRemaining)); if (n > 0) { chunkRemaining -= n; - if (chunkRemaining == 0) { src.read(); src.read(); } // consume trailing \r\n + if (chunkRemaining == 0) consumeChunkTerminator(); } return n; } + /** Validates and consumes the CRLF that terminates every chunk's data (RFC 9112 §7.1.1). */ + private void consumeChunkTerminator() throws IOException { + int cr = src.read(); + int lf = src.read(); + if (cr != '\r' || lf != '\n') { + throw new MalformedRequestException(400, "Malformed chunk terminator"); + } + } + + /** + * Reads one chunk-size line: hex digits, an optional {@code ;}-prefixed chunk-extension + * (discarded — RFC 9112 §7.1.1 permits ignoring extensions this server does not recognise), + * then CRLF. Bounded per {@code Http1Limits} against: more than 16 hex digits (a chunk size + * cannot legitimately need more — {@code Long.MAX_VALUE} is 16 hex digits), a size above + * {@link Http1Limits#MAX_CHUNK_SIZE}, an extension longer than + * {@link Http1Limits#MAX_CHUNK_EXT_LENGTH}, and more than + * {@link Http1Limits#MAX_CHUNKS_PER_BODY} chunks per body — all defences against a peer + * that is technically well-formed but deliberately expensive to parse. + */ private int readChunkSize() throws IOException { + if (++chunksSeen > Http1Limits.MAX_CHUNKS_PER_BODY) { + throw new MalformedRequestException(413, "Too many chunks"); + } + long size = 0; - int b; - while ((b = src.read()) != -1) { - if (b >= '0' && b <= '9') size = (size << 4) | (b - '0'); - else if (b >= 'a' && b <= 'f') size = (size << 4) | (b - 'a' + 10); - else if (b >= 'A' && b <= 'F') size = (size << 4) | (b - 'A' + 10); - else { while ((b = src.read()) != -1 && b != '\n'); break; } // ext or \r\n - if (size > Integer.MAX_VALUE) throw new IOException("Chunk size exceeds 2 GB limit"); + int digits = 0; + int b = src.read(); + while (isHexDigit(b)) { + if (++digits > 16) throw new MalformedRequestException(400, "Chunk size line too long"); + size = (size << 4) | hexValue(b); + if (size > Http1Limits.MAX_CHUNK_SIZE) { + throw new MalformedRequestException(413, "Chunk size exceeds configured maximum"); + } + b = src.read(); + } + if (digits == 0) throw new MalformedRequestException(400, "Malformed chunk size"); + + int extLen = 0; + while (b != -1 && b != '\r') { + if (++extLen > Http1Limits.MAX_CHUNK_EXT_LENGTH) { + throw new MalformedRequestException(400, "Chunk extension too long"); + } + b = src.read(); + } + if (b != '\r' || src.read() != '\n') { + throw new MalformedRequestException(400, "Malformed chunk size line terminator"); } return (int) size; } - // Reads and discards trailer headers until the empty line that terminates the chunked body. + private static boolean isHexDigit(int b) { + return (b >= '0' && b <= '9') || (b >= 'a' && b <= 'f') || (b >= 'A' && b <= 'F'); + } + + private static int hexValue(int b) { + if (b <= '9') return b - '0'; + if (b <= 'F') return b - 'A' + 10; + return b - 'a' + 10; + } + + /** + * Reads and discards trailer headers until the empty line that terminates the chunked body + * (RFC 9112 §7.1.2). Bounded by {@link Http1Limits#MAX_TRAILER_COUNT} and + * {@link Http1Limits#MAX_HEADER_VALUE_LENGTH} — without a bound, a peer could follow the + * final chunk with an unbounded trailer section purely to waste CPU discarding it. Trailers + * ({@code Request.trailers()}). + */ private void consumeTrailers() throws IOException { + int trailerCount = 0; while (true) { int b = src.read(); - if (b == -1 || b == '\r') { src.read(); return; } // empty line — done - while ((b = src.read()) != -1 && b != '\n'); // skip non-empty trailer line + if (b == -1) throw new MalformedRequestException(400, "Truncated trailer section"); + if (b == '\r') { + if (src.read() != '\n') { + throw new MalformedRequestException(400, "Malformed trailer section terminator"); + } + return; // empty line — trailer section done + } + if (++trailerCount > Http1Limits.MAX_TRAILER_COUNT) { + throw new MalformedRequestException(431, "Too many trailers"); + } + int lineLen = 0; + trailerLine[lineLen++] = (byte) b; + while ((b = src.read()) != -1 && b != '\n') { + if (lineLen == trailerLine.length) { + throw new MalformedRequestException(431, "Trailer line too long"); + } + trailerLine[lineLen++] = (byte) b; + } + if (b != '\n' || lineLen == 0 || trailerLine[lineLen - 1] != '\r') { + throw new MalformedRequestException(400, "Malformed trailer line"); + } + addTrailer(lineLen - 1); } } + + private void addTrailer(int lineLength) throws MalformedRequestException { + int colon = -1; + for (int i = 0; i < lineLength; i++) { + if (trailerLine[i] == ':') { colon = i; break; } + } + if (colon <= 0) throw new MalformedRequestException(400, "Malformed trailer field"); + for (int i = 0; i < colon; i++) { + int c = trailerLine[i] & 0xff; + if (!isToken(c)) { + throw new MalformedRequestException(400, "Invalid trailer field name"); + } + } + int valueStart = colon + 1; + while (valueStart < lineLength + && (trailerLine[valueStart] == ' ' || trailerLine[valueStart] == '\t')) valueStart++; + int valueEnd = lineLength; + while (valueEnd > valueStart + && (trailerLine[valueEnd - 1] == ' ' || trailerLine[valueEnd - 1] == '\t')) valueEnd--; + if (forbidden(trailerLine, colon)) { + throw new MalformedRequestException(400, "Forbidden trailer field"); + } + trailers.add(trailerLine, 0, colon, trailerLine, valueStart, valueEnd - valueStart); + } + + private static boolean isToken(int c) { + return (c >= '0' && c <= '9') + || (c >= 'A' && c <= 'Z') + || (c >= 'a' && c <= 'z') + || c == '!' || c == '#' || c == '$' || c == '%' || c == '&' || c == '\'' + || c == '*' || c == '+' || c == '-' || c == '.' || c == '^' || c == '_' + || c == '`' || c == '|' || c == '~'; + } + + private static boolean forbidden(byte[] name, int length) { + return asciiEquals(name, length, "content-length") + || asciiEquals(name, length, "transfer-encoding") + || asciiEquals(name, length, "host") + || asciiEquals(name, length, "trailer"); + } + + private static boolean asciiEquals(byte[] bytes, int length, String expected) { + if (length != expected.length()) return false; + for (int i = 0; i < length; i++) { + int c = bytes[i] & 0xff; + if (c >= 'A' && c <= 'Z') c += 32; + if (c != expected.charAt(i)) return false; + } + return true; + } } diff --git a/flash/src/main/java/dev/relism/flash/HttpServer.java b/flash/src/main/java/dev/relism/flash/HttpServer.java deleted file mode 100644 index d352134..0000000 --- a/flash/src/main/java/dev/relism/flash/HttpServer.java +++ /dev/null @@ -1,564 +0,0 @@ -package dev.relism.flash; - -import dev.relism.flash.extension.FlashApp; -import dev.relism.flash.extension.FlashConfiguration; -import dev.relism.flash.http.ContentType; -import dev.relism.flash.http.HttpMethod; -import dev.relism.flash.http.HttpStatus; -import dev.relism.flash.models.Request; -import dev.relism.flash.models.RequestHandler; -import dev.relism.flash.models.Response; -import dev.relism.flash.routing.AbstractRouter; -import dev.relism.flash.routing.AbstractWsRouter; -import dev.relism.flash.tls.TlsConfig; -import dev.relism.flash.websocket.WebSocketFrame; -import dev.relism.flash.websocket.WebSocketHandler; -import dev.relism.flash.websocket.WebSocketSession; -import dev.relism.fpr.core.ByteView; - -import lombok.extern.slf4j.Slf4j; - -import javax.net.ssl.SSLServerSocket; -import javax.net.ssl.SSLSocket; - -import java.io.*; -import java.net.InetSocketAddress; -import java.net.ServerSocket; -import java.net.Socket; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.ArrayList; -import java.util.Base64; -import java.util.List; -import java.util.Set; -import java.util.concurrent.*; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; - -/** - * Pure I/O transport layer. Owns one {@link ServerSocket} per configured listener (plain or - * TLS), the virtual-thread executor, and the keep-alive accept loop. Routing is delegated to - * HTTP and WS routers — identically, regardless of which listener accepted the connection. - * - *

TLS is a transport-level concern only: once a {@link BoundListener} is bound, an accepted - * {@link Socket} is either plain or an {@code SSLSocket} indistinguishably from here on — - * {@link #process} never branches on it. This is also why WSS needs no separate code path from - * WS: the WebSocket upgrade happens over whatever transport {@link #process} was handed. - * - *

Allocation model

- *
    - *
  • {@code LONG_BUF} (20 bytes) and {@code STREAM_RELAY_BUFFER} (8 KB, for a streaming - * {@link Response} body — see {@link #writeStreamingBody}) are the only {@link ThreadLocal}s - * kept here. Both are per-connection, not per-request: one virtual thread runs a - * connection's whole keep-alive request loop (see {@link #process}), so a handler that - * streams a large response on every request allocates its relay buffer once per - * connection, not once per request.
  • - *
  • WS handshake SHA-1: {@link ThreadLocal}<{@link MessageDigest}> — one per - * accept thread (there are now {@code ACCEPT_THREADS} of them, not one).
  • - *
- */ -@Slf4j -class HttpServer implements ServerHandle { - - // ── Tuning constants ────────────────────────────────────────────────────── - - /** - * Number of platform threads competing on {@code serverSocket.accept()}. - * Rule of thumb: number of available CPU cores, capped at 8. - * More than this rarely helps — accept is cheap; the bottleneck is usually - * the virtual-thread executor dispatching the connection handler. - */ - private static final int ACCEPT_THREADS = Math.min(Runtime.getRuntime().availableProcessors(), 8); - - /** - * TCP listen backlog. The kernel holds up to this many fully-established - * (SYN+ACK sent, ACK received) connections waiting for accept(). - * 4096 is safe on Linux; /proc/sys/net/core/somaxconn must be >= this value, - * or the kernel silently caps it. Raise somaxconn if needed: - * sysctl -w net.core.somaxconn=4096 - */ - private static final int ACCEPT_BACKLOG = 4096; - - /** - * Socket send/receive buffer sizes. Matched to the WS frame read buffer - * ({@link FlashConfiguration#getWsFrameBufferSize()}) so the kernel never - * needs to fragment a full frame into multiple TCP segments on the receive - * side, and never blocks a write waiting for the send buffer to drain. - * - * Linux default is 87380 bytes (rmem) / 16384 bytes (wmem). We raise both - * to 256 KB — a good fit for up to ~250 KB WS frames with no partial reads. - */ - private static final int SOCKET_BUF_SIZE = 256 * 1024; - - // ── Instance fields ─────────────────────────────────────────────────────── - - private final FlashConfiguration configuration; - private final List boundListeners; - private final AbstractRouter router; - private final AbstractWsRouter wsRouter; - private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); - private final Set activeSockets = ConcurrentHashMap.newKeySet(); - private volatile boolean stopped = false; - - /** Latch that reaches 0 when all accept threads, across all listeners, have exited. */ - private final CountDownLatch acceptLatch; - - /** One bound listener socket (plain or TLS) plus whether it is TLS, for logging only. */ - private record BoundListener(ServerSocket socket, boolean secure) {} - - // ── Static byte constants (written once, read-only on hot path) ────────── - - private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8); - private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8); - private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8); - private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8); - private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8); - private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8); - private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8); - private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8); - private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8); - - private static final byte[] WS_HANDSHAKE_PREFIX = - ("HTTP/1.1 101 Switching Protocols\r\n" + - "Upgrade: websocket\r\n" + - "Connection: Upgrade\r\n" + - "Sec-WebSocket-Accept: ") - .getBytes(StandardCharsets.ISO_8859_1); - private static final byte[] WS_HANDSHAKE_SUFFIX = - "\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1); - private static final byte[] WS_REJECT_400 = - "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" - .getBytes(StandardCharsets.ISO_8859_1); - - private static final byte[] WS_GUID_BYTES = - "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1); - - private static final ThreadLocal SHA1 = - ThreadLocal.withInitial(() -> { - try { return MessageDigest.getInstance("SHA-1"); } - catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); } - }); - - private static final ThreadLocal LONG_BUF = ThreadLocal.withInitial(() -> new byte[20]); - - /** - * Relay buffer for copying a streaming {@link Response} body to the client — shared by - * {@link #writeStreamingBody}'s non-chunked path and {@link #writeChunked}, so both draw - * from the same reused array instead of each allocating its own {@code byte[8192]} (the - * non-chunked path previously relied on {@link InputStream#transferTo}, which allocates - * internally on every call). Sized to match the pre-existing behavior this replaces, not - * newly tuned — not exposed as a {@link FlashConfiguration} tunable since nothing here - * needed one before. - */ - private static final int STREAM_RELAY_BUFFER_SIZE = 8192; - private static final ThreadLocal STREAM_RELAY_BUFFER = - ThreadLocal.withInitial(() -> new byte[STREAM_RELAY_BUFFER_SIZE]); - - private static final int SHA1_LEN = 20; - private static final int WS_ACCEPT_LEN = 28; - - // ── Constructor ─────────────────────────────────────────────────────────── - - HttpServer(FlashConfiguration configuration, AbstractRouter router, AbstractWsRouter wsRouter) throws IOException { - this.configuration = configuration; - this.router = router; - this.wsRouter = wsRouter; - - List specs = configuration.getListeners().isEmpty() - ? List.of(new FlashConfiguration.Listener( - configuration.getPort(), configuration.getHost(), configuration.getTls())) - : configuration.getListeners(); - - List bound = new ArrayList<>(specs.size()); - for (FlashConfiguration.Listener spec : specs) bound.add(bind(spec)); - this.boundListeners = List.copyOf(bound); - this.acceptLatch = new CountDownLatch(ACCEPT_THREADS * boundListeners.size()); - - for (BoundListener bl : boundListeners) { - log.info("HttpServer bound on {}:{} (tls={}, backlog={}, acceptThreads={})", - bl.socket().getInetAddress(), bl.socket().getLocalPort(), bl.secure(), - ACCEPT_BACKLOG, ACCEPT_THREADS); - } - } - - /** - * Binds one listener. A TLS listener gets its {@link ServerSocket} from - * {@link TlsConfig#serverSocketFactory()} instead of {@code new ServerSocket()}, and its - * protocol/client-auth parameters from {@link TlsConfig#applyTo} — reuse-address, receive - * buffer size, backlog and the bind call itself are identical either way. TLS only changes - * which bytes come out of {@code accept()}; it never changes how the accept loop, or - * anything downstream of it, treats them. - */ - private static BoundListener bind(FlashConfiguration.Listener spec) throws IOException { - TlsConfig tls = spec.tls(); - - ServerSocket socket = tls != null ? tls.serverSocketFactory().createServerSocket() : new ServerSocket(); - // setReuseAddress(true) must be called BEFORE bind(). - socket.setReuseAddress(true); - socket.setReceiveBufferSize(SOCKET_BUF_SIZE); - if (tls != null) tls.applyTo((SSLServerSocket) socket); - - InetSocketAddress addr = spec.host() != null - ? new InetSocketAddress(spec.host(), spec.port()) - : new InetSocketAddress(spec.port()); - socket.bind(addr, ACCEPT_BACKLOG); - - return new BoundListener(socket, tls != null); - } - - // ── Lifecycle ───────────────────────────────────────────────────────────── - - @Override - public void start() { - for (int li = 0; li < boundListeners.size(); li++) { - BoundListener listener = boundListeners.get(li); - for (int i = 0; i < ACCEPT_THREADS; i++) { - Thread.ofPlatform() - .name("flash-accept-" + li + "-" + i) - .daemon(false) - .start(() -> acceptLoop(listener)); - } - } - } - - @Override - public void startAndBlock() { - start(); - try { acceptLatch.await(); } - catch (InterruptedException e) { Thread.currentThread().interrupt(); } - } - - /** - * Single accept loop body — runs on each of the {@code ACCEPT_THREADS} - * platform threads bound to one {@code listener}. All threads for that listener block on - * the same {@link ServerSocket}; the JVM ensures only one wakes per incoming connection - * (no thundering herd). Other listeners' accept threads are entirely independent. - */ - private void acceptLoop(BoundListener listener) { - try { - while (!stopped) { - try { - process(listener.socket().accept()); - } catch (IOException e) { - if (!stopped) log.error("Accept error", e); - } - } - } finally { - acceptLatch.countDown(); - } - } - - @Override - public CompletableFuture stop() { - return CompletableFuture.runAsync(() -> { - stopped = true; - for (BoundListener bl : boundListeners) { - try { bl.socket().close(); } catch (IOException e) { log.error("Error closing server socket", e); } - } - activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} }); - executorService.shutdown(); - try { - if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) - executorService.shutdownNow(); - } catch (InterruptedException e) { - executorService.shutdownNow(); - Thread.currentThread().interrupt(); - } - }); - } - - // ── Hot-path ────────────────────────────────────────────────────────────── - - private void process(Socket socket) { - try { - executorService.submit(() -> { - activeSockets.add(socket); - try (socket; - InputStream in = socket.getInputStream(); - OutputStream out = new BufferedOutputStream(socket.getOutputStream())) { - - // TCP_NODELAY: disable Nagle's algorithm. - // Small WS frames (< MSS) are sent immediately rather than - // waiting up to 200 ms for more data to coalesce. Latency - // drops significantly at the cost of slightly more TCP segments - // under sustained bulk transfer — acceptable for interactive WS. - socket.setTcpNoDelay(true); - socket.setSendBufferSize(SOCKET_BUF_SIZE); - - // rawOut is the unbuffered socket stream — passed to WebSocketSession - // directly. WS writes are already bulk (header + payload in two calls); - // with TCP_NODELAY the kernel ships them without Nagle delay, so no - // userspace buffer is needed and no flush() is required per frame. - // HTTP responses continue to use the BufferedOutputStream (out) because - // writeResponse() does many small individual writes that benefit from - // userspace coalescing before a single syscall. - OutputStream rawOut = socket.getOutputStream(); - - RequestParser parser = new RequestParser( - configuration.getMaxHeaderBufferSize(), - (InetSocketAddress) socket.getRemoteSocketAddress(), - socket instanceof SSLSocket sslSocket ? sslSocket : null); - - while (!stopped) { - Request request = parser.parse(in); - if (request == null) break; - - if (request.method() == HttpMethod.GET && isWebSocketUpgrade(request)) { - WebSocketHandler wsHandler = wsRouter.route(request); - if (wsHandler == null) { - out.write(WS_REJECT_400); - out.flush(); - break; - } - // Flush buffered HTTP bytes (the 101 response) before WebSocketSession - // takes over rawOut — otherwise the handshake reply stays stuck in - // the BufferedOutputStream buffer and the client never sees it. - performHandshake(out, request); - out.flush(); - request.drain(); - WebSocketSession session = new WebSocketSession( - in, rawOut, configuration.getWsFrameBufferSize(), request, false); - runWsLoop(session, wsHandler); - return; - } - - boolean keepAlive = isKeepAlive(request); - Response response = new Response(200, ContentType.TEXT_PLAIN); - - RequestHandler handler = router.route(request); - if (handler == null) handler = router.getNotFoundHandler(); - - try { - Object result = handler.handle(request, response); - if (result instanceof Response r) response = r; - else if (result != null) response.setBody(result); - } catch (Exception ex) { - Object result = router.getExceptionHandler().handle(ex, request, response); - if (result instanceof Response r) response = r; - else if (result != null) response.setBody(result); - } - - writeResponse(out, response, keepAlive); - request.drain(); - if (!keepAlive) break; - } - - } catch (IOException e) { - if (!stopped) { - if (e instanceof java.net.SocketException) - log.debug("Connection closed: {}", e.getMessage()); - else - log.error("I/O error handling request", e); - } - } catch (Exception e) { - // Anything not an IOException here means a collaborator misbehaved on the TLS - // handshake path — most likely a custom TlsConfig#ofContext KeyManager/ - // TrustManager throwing (e.g. a failed DB lookup or on-demand cert issuance). - // That failure is isolated to this one virtual thread/connection: the - // try-with-resources above still closes the socket, the finally below still - // runs, and the accept loop (a different thread entirely) never sees this. - if (!stopped) log.error("Unexpected error handling connection", e); - } finally { - activeSockets.remove(socket); - } - }); - } catch (RejectedExecutionException ignored) { - try { socket.close(); } catch (IOException e) { log.debug("Error closing socket on shutdown", e); } - } - } - - // ── WebSocket upgrade detection (zero-alloc) ────────────────────────────── - - private static boolean isWebSocketUpgrade(Request request) { - ByteView upgrade = request.getRequestLine().getHeaders().view("Upgrade"); - if (upgrade == null) return false; - if (!tokenEqualsIgnoreCase(upgrade, 0, upgrade.length(), "websocket")) return false; - return connectionContainsUpgrade(request); - } - - private static boolean connectionContainsUpgrade(Request request) { - ByteView conn = request.getRequestLine().getHeaders().view("Connection"); - if (conn == null) return false; - int len = conn.length(), i = 0; - while (i < len) { - while (i < len && conn.byteAt(i) == ' ') i++; - int start = i; - while (i < len && conn.byteAt(i) != ',') i++; - if (tokenEqualsIgnoreCase(conn, start, i, "upgrade")) return true; - i++; - } - return false; - } - - private static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) { - int tlen = token.length(); - int wlen = end - start; - while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--; - if (wlen != tlen) return false; - for (int i = 0; i < tlen; i++) { - byte b = view.byteAt(start + i); - if (b >= 'A' && b <= 'Z') b += 32; - if (b != (byte) token.charAt(i)) return false; - } - return true; - } - - // ── WebSocket handshake ─────────────────────────────────────────────────── - - private void performHandshake(OutputStream out, Request request) throws IOException { - ByteView keyView = request.getRequestLine().getHeaders().view("Sec-WebSocket-Key"); - if (keyView == null) throw new IOException("Missing Sec-WebSocket-Key header"); - - MessageDigest sha1 = SHA1.get(); - sha1.reset(); - for (int i = 0, len = keyView.length(); i < len; i++) sha1.update(keyView.byteAt(i)); - sha1.update(WS_GUID_BYTES); - - byte[] accept = Base64.getEncoder().encode(sha1.digest()); - - out.write(WS_HANDSHAKE_PREFIX); - out.write(accept); - out.write(WS_HANDSHAKE_SUFFIX); - out.flush(); - } - - // ── WebSocket session loop ──────────────────────────────────────────────── - - private void runWsLoop(WebSocketSession session, WebSocketHandler handler) { - handler.onOpen(session); - WebSocketFrame frame = new WebSocketFrame(); - try { - while (session.isOpen()) { - if (!session.readFrame(frame)) break; - switch (frame.opcode()) { - case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY - -> handler.onMessage(session, frame); - case WebSocketFrame.OP_CLOSE - -> session.closeFromPeer(frame); - case WebSocketFrame.OP_PING - -> session.sendPong(frame); - case WebSocketFrame.OP_PONG -> { /* heartbeat ack, no-op */ } - } - } - } catch (IOException e) { - handler.onError(session, e); - } finally { - handler.onClose(session, session.closeCode()); - session.forceClose(); - } - } - - // ── HTTP keep-alive detection ───────────────────────────────────────────── - - private static boolean isKeepAlive(Request request) { - if (request.headerEquals("Connection", "close")) return false; - ByteView protocol = request.getRequestLine().getProtocol(); - int plen = protocol.length(); - if (plen == 8) { - byte minor = protocol.byteAt(7); - if (minor == '1') return true; - if (minor == '0') return request.headerEquals("Connection", "keep-alive"); - } - log.debug("Unrecognised protocol '{}', treating as close", protocol); - return false; - } - - // ── Response serialisation ──────────────────────────────────────────────── - - private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException { - out.write(HTTP_1_1); - byte[] statusBytes = response.getStatusBytes(); - if (statusBytes != null) out.write(statusBytes); - else writeStatusPhrase(out, response.getStatusCode()); - out.write(CRLF); - out.write(CONTENT_TYPE); - out.write(response.getContentType()); - out.write(CRLF); - response.writeHeaders(out); - - if (response.isStreaming()) { - writeStreamingBody(out, response, keepAlive); - } else { - byte[] body = response.getBody(); - out.write(CONTENT_LENGTH); - writeLong(out, body != null ? body.length : 0); - out.write(CRLF); - out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE); - out.write(CRLF); - if (body != null) out.write(body); - } - out.flush(); - } - - private static void writeStreamingBody(OutputStream out, Response response, boolean keepAlive) throws IOException { - if (!response.isChunked()) { - out.write(CONTENT_LENGTH); - writeLong(out, response.getStreamLength()); - out.write(CRLF); - out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE); - out.write(CRLF); - relay(response.getStream(), out); - } else { - out.write(TRANSFER_CHUNKED); - out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE); - out.write(CRLF); - writeChunked(out, response.getStream()); - } - } - - /** - * Copies {@code in} to {@code out} until EOF, same contract as {@link InputStream#transferTo} - * — but via {@link #STREAM_RELAY_BUFFER} instead of a fresh {@code byte[]} per call, which is - * what {@code transferTo}'s own (JDK-internal) implementation would otherwise allocate on - * every streamed response. - */ - private static void relay(InputStream in, OutputStream out) throws IOException { - byte[] buf = STREAM_RELAY_BUFFER.get(); - int n; - while ((n = in.read(buf)) > 0) out.write(buf, 0, n); - } - - private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException { - byte[] phrase = HttpStatus.bytesForCode(statusCode); - if (phrase != null) out.write(phrase); - else { writeLong(out, statusCode); out.write(UNKNOWN_STATUS_SUFFIX); } - } - - private static void writeLong(OutputStream out, long value) throws IOException { - if (value == 0) { out.write('0'); return; } - byte[] buf = LONG_BUF.get(); - int pos = buf.length; - boolean neg = value < 0; - if (neg) value = -value; - do { buf[--pos] = (byte) ('0' + value % 10); value /= 10; } while (value > 0); - if (neg) buf[--pos] = '-'; - out.write(buf, pos, buf.length - pos); - } - - private static void writeChunked(OutputStream out, InputStream stream) throws IOException { - byte[] buf = STREAM_RELAY_BUFFER.get(); - int n; - while ((n = stream.read(buf)) > 0) { - writeHex(out, n); - out.write(CRLF); - out.write(buf, 0, n); - out.write(CRLF); - } - out.write(FINAL_CHUNK); - } - - private static void writeHex(OutputStream out, int value) throws IOException { - int shift = 28; - boolean leading = true; - while (shift >= 0) { - int digit = (value >>> shift) & 0xF; - if (digit != 0 || !leading) { - leading = false; - out.write(digit < 10 ? '0' + digit : 'a' + digit - 10); - } - shift -= 4; - } - if (leading) out.write('0'); - } -} \ No newline at end of file diff --git a/flash/src/main/java/dev/relism/flash/RequestParser.java b/flash/src/main/java/dev/relism/flash/RequestParser.java index 1a50a25..fd3a19e 100644 --- a/flash/src/main/java/dev/relism/flash/RequestParser.java +++ b/flash/src/main/java/dev/relism/flash/RequestParser.java @@ -1,17 +1,22 @@ package dev.relism.flash; +import dev.relism.flash.bytes.ByteScan; +import dev.relism.flash.exceptions.MalformedRequestException; +import dev.relism.flash.http.Http1Limits; import dev.relism.flash.http.HttpMethod; -import dev.relism.flash.models.HeaderMap; +import dev.relism.flash.models.Http1HeaderMap; +import dev.relism.flash.models.MutableHeaderMap; import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestBody; import dev.relism.flash.models.RequestLine; import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews; +import dev.relism.flash.transport.BufferedByteSource; import lombok.extern.slf4j.Slf4j; import javax.net.ssl.SSLSocket; import java.io.IOException; -import java.io.InputStream; import java.net.InetSocketAddress; import java.util.Arrays; @@ -35,6 +40,15 @@ import java.util.Arrays; * to the next request. They are snapshotted at the top of {@link #parse} * and reset to {@code 0/0} before any work begins, so an exception thrown mid-parse * leaves the fields clean rather than pointing at stale data from a previous request. + * + * Anything wrong with the request itself — smuggling-relevant ambiguity, an over-limit + * header, a malformed byte where the grammar forbids one — is reported as a + * {@link MalformedRequestException} carrying the exact status the caller must respond with. + * This is distinct from {@link IOException}, which still means "the socket failed" (EOF, + * reset, timeout). The caller ({@code HttpServer.process}) must always close the connection + * after a {@link MalformedRequestException}, never keep it alive — RFC 9112 §6.1's rationale + * for rejecting {@code Content-Length} + {@code Transfer-Encoding} outright is exactly that a + * kept-alive connection after a disputed request boundary is what a smuggling attack needs. */ @Slf4j public class RequestParser { @@ -43,7 +57,17 @@ public class RequestParser { private final int maxHeaderBufferSize; private final InetSocketAddress remoteAddress; private final SSLSocket sslSocket; - private final HeaderMap headerMap = new HeaderMap(); + private final Http1HeaderMap headerMap = new Http1HeaderMap(); + private final MutableHeaderMap trailerMap = new MutableHeaderMap(); + // request — same idiom as headerMap above. + private final RequestLine requestLine = new RequestLine(); + private final Request request = new Request(); + private final RequestBody requestBody = new RequestBody(); + // RequestLine/Response themselves. queryView is only reset and used when a query string is + // actually present; RequestLine.getQuery() must keep returning null otherwise (see reset()). + private final FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(null, 0, 0); + private final FastPathViews.RequestByteView queryView = new FastPathViews.RequestByteView(null, 0, 0); + private final FastPathViews.RequestByteView protocolView = new FastPathViews.RequestByteView(null, 0, 0); private byte[] buffer; // Unconsumed bytes belonging to the NEXT request. @@ -65,6 +89,19 @@ public class RequestParser { this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)]; } + /** + * Whether bytes from a previous {@link #parse} call are already buffered and ready to be + * consumed by the next call without reading anything further from the source — the HTTP + * pipelining case. The caller (the connection loop) uses this to decide whether it is safe + * to skip waiting for "the next request has started arriving": if bytes are already + * buffered, the next request has, by definition, already started (and may even be + * complete), so an idle-timeout wait on the underlying source would wait for bytes that + * were never going to arrive there — they are already here. + */ + public boolean hasBufferedBytes() { + return bufLen > 0; + } + /** * Parses the next HTTP request from {@code in}. * @@ -75,9 +112,12 @@ public class RequestParser { * the same parser instance is reused after an error. * * @return the parsed {@link Request}, or {@code null} on clean EOF. - * @throws IOException on malformed headers or I/O failure. + * @throws MalformedRequestException if the request violates the HTTP/1.1 grammar or a + * configured safety limit — carries the exact status to respond with. + * @throws IOException on genuine I/O failure (socket reset, timeout). */ - public Request parse(InputStream in) throws IOException { + public Request parse(BufferedByteSource in) throws IOException { + trailerMap.reset(); // Snapshot leftover bytes from the previous request, then reset immediately. // Any exception thrown below leaves bufBase/bufLen at 0 — safe state. int base = bufBase; @@ -85,7 +125,7 @@ public class RequestParser { bufBase = 0; bufLen = 0; - int headerEndIdx = totalRead > 0 ? findEndOfHeader(buffer, base, base + totalRead) : -1; + int headerEndIdx = totalRead > 0 ? ByteScan.indexOfCrLfCrLf(buffer, base, base + totalRead) : -1; while (headerEndIdx == -1) { if (base + totalRead == buffer.length) { if (base > 0) { @@ -94,7 +134,8 @@ public class RequestParser { System.arraycopy(buffer, base, buffer, 0, totalRead); base = 0; } else if (buffer.length >= maxHeaderBufferSize) { - throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes"); + throw new MalformedRequestException(431, + "Request headers exceed " + maxHeaderBufferSize + " bytes"); } else { buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize)); } @@ -103,65 +144,133 @@ public class RequestParser { if (n <= 0) break; int prevTotal = totalRead; totalRead += n; - headerEndIdx = findEndOfHeader(buffer, base + Math.max(0, prevTotal - 3), base + totalRead); + headerEndIdx = ByteScan.indexOfCrLfCrLf(buffer, base + Math.max(0, prevTotal - 3), base + totalRead); } if (totalRead <= 0) return null; if (headerEndIdx == -1) { - throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes"); + throw new MalformedRequestException(431, + "Request headers exceed " + maxHeaderBufferSize + " bytes"); } // ── Request line ───────────────────────────────────────────────────── - int methodEnd = find(buffer, base, headerEndIdx, (byte) ' '); - if (methodEnd == -1) throw new IOException("Invalid request line (method)"); + int methodEnd = ByteScan.indexOf(buffer, base, headerEndIdx, (byte) ' '); + if (methodEnd == -1) throw new MalformedRequestException(400, "Invalid request line (method)"); + if (methodEnd == base) throw new MalformedRequestException(400, "Missing HTTP method"); HttpMethod method = HttpMethod.fromBytes(buffer, base, methodEnd - base); - if (method == null) throw new IOException("Unsupported HTTP method"); + if (method == null) throw new MalformedRequestException(501, "Unsupported HTTP method"); int pathStart = methodEnd + 1; - int pathEnd = find(buffer, pathStart, headerEndIdx, (byte) ' '); - if (pathEnd == -1) throw new IOException("Invalid request line (path)"); + int pathEnd = ByteScan.indexOf(buffer, pathStart, headerEndIdx, (byte) ' '); + if (pathEnd == -1) throw new MalformedRequestException(400, "Invalid request line (path)"); - int queryMark = find(buffer, pathStart, pathEnd, (byte) '?'); - FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart, - queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart); - FastPathViews.RequestByteView queryView = queryMark != -1 - ? new FastPathViews.RequestByteView(buffer, queryMark + 1, pathEnd - queryMark - 1) - : null; + int queryMark = ByteScan.indexOf(buffer, pathStart, pathEnd, (byte) '?'); + pathView.reset(buffer, pathStart, queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart); + if (queryMark != -1) queryView.reset(buffer, queryMark + 1, pathEnd - queryMark - 1); int protocolStart = pathEnd + 1; - int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r'); - if (protocolEnd == -1) throw new IOException("Invalid request line (protocol)"); + int protocolEnd = ByteScan.indexOf(buffer, protocolStart, headerEndIdx, (byte) '\r'); + if (protocolEnd == -1) throw new MalformedRequestException(400, "Invalid request line (protocol)"); - FastPathViews.RequestByteView protocolView = - new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart); + // from the overall header-block size, so an oversized request line gets its own, + // specific rejection rather than being folded into the generic "headers too large" case. + if (protocolEnd - base > Http1Limits.MAX_REQUEST_LINE_LENGTH) { + throw new MalformedRequestException(431, "Request line exceeds " + Http1Limits.MAX_REQUEST_LINE_LENGTH + " bytes"); + } + + protocolView.reset(buffer, protocolStart, protocolEnd - protocolStart); // ── Headers ────────────────────────────────────────────────────────── - int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1; + int sectionStart = ByteScan.indexOf(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1; int current = sectionStart; - long contentLength = 0; - boolean isChunked = false; + long contentLength = -1; + boolean contentLengthSeen = false; + boolean transferEncodingSeen = false; + boolean transferEncodingChunked = false; + int headerCount = 0; + headerMap.beginParsed(buffer, sectionStart, headerEndIdx); while (current < headerEndIdx) { - int lineEnd = find(buffer, current, headerEndIdx + 1, (byte) '\r'); + // deprecates line folding and treating a folded continuation as part of the + // previous header's value is a known request-smuggling vector. + byte first = buffer[current]; + if (first == ' ' || first == '\t') { + throw new MalformedRequestException(400, "Obsolete line folding is not supported"); + } + + int lineEnd = ByteScan.indexOf(buffer, current, headerEndIdx + 1, (byte) '\r'); if (lineEnd == -1 || lineEnd == current) break; - int colon = find(buffer, current, lineEnd, (byte) ':'); - if (colon != -1) { - int valueStart = colon + 1; - while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++; + // advancing past two bytes — a bare '\r' not followed by '\n' desynchronizes the + // parse and is a known bare-CR smuggling surface. Safe to read lineEnd+1: lineEnd + // is at most headerEndIdx, and findEndOfHeader already guaranteed 4 readable bytes + // (\r\n\r\n) starting at headerEndIdx. + if (buffer[lineEnd + 1] != '\n') { + throw new MalformedRequestException(400, "Malformed line terminator (bare CR)"); + } - if (equalsIgnoreCase(buffer, current, colon, "content-length")) { - contentLength = parseLong(buffer, valueStart, lineEnd); - } else if (equalsIgnoreCase(buffer, current, colon, "transfer-encoding")) { - isChunked = equalsIgnoreCase(buffer, valueStart, lineEnd, "chunked"); + if (++headerCount > Http1Limits.MAX_HEADER_COUNT) { + throw new MalformedRequestException(431, "Too many headers"); + } + + int colon = ByteScan.indexOf(buffer, current, lineEnd, (byte) ':'); + if (colon == -1) { + throw new MalformedRequestException(400, "Header line missing ':'"); + } + if (colon - current > Http1Limits.MAX_HEADER_NAME_LENGTH) { + throw new MalformedRequestException(431, "Header name exceeds " + Http1Limits.MAX_HEADER_NAME_LENGTH + " bytes"); + } + for (int i = current; i < colon; i++) { + if (!ByteScan.isTChar(buffer[i])) { + throw new MalformedRequestException(400, "Invalid header name character"); } } + + int valueStart = colon + 1; + while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++; + if (lineEnd - valueStart > Http1Limits.MAX_HEADER_VALUE_LENGTH) { + throw new MalformedRequestException(431, "Header value exceeds " + Http1Limits.MAX_HEADER_VALUE_LENGTH + " bytes"); + } + headerMap.addParsed(current, colon - current, valueStart, lineEnd - valueStart); + + if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "content-length")) { + // parseLong, which silently accepted "5abc" as 5 and "-1" as 1. + long parsed = parseContentLengthStrict(buffer, valueStart, lineEnd); + // Multiple Content-Length lines with differing values is itself a smuggling + // permits a recipient to treat that as one value). + if (contentLengthSeen && parsed != contentLength) { + throw new MalformedRequestException(400, "Conflicting Content-Length values"); + } + contentLength = parsed; + contentLengthSeen = true; + } else if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "transfer-encoding")) { + transferEncodingSeen = true; + // "chunked", so "gzip, chunked" — valid per RFC 9112 §6.1, where chunked need + // only be the FINAL coding — was silently treated as not chunked at all, + // corrupting the message boundary. Fixed by inspecting only the last token. + transferEncodingChunked = isFinalCodingChunked(buffer, valueStart, lineEnd); + } current = lineEnd + 2; } - headerMap.reset(buffer, sectionStart, headerEndIdx); + // MUST be treated as an error by an origin server — this is the canonical CL.TE/TE.CL + // smuggling vector. Checked once both headers are known, regardless of the order they + // appeared in, so ordering games cannot bypass it. + if (contentLengthSeen && transferEncodingSeen) { + throw new MalformedRequestException(400, "Content-Length and Transfer-Encoding both present"); + } + boolean isChunked; + if (transferEncodingSeen) { + if (!transferEncodingChunked) { + throw new MalformedRequestException(501, "Unsupported Transfer-Encoding"); + } + isChunked = true; + } else { + isChunked = false; + if (!contentLengthSeen) contentLength = 0; + } // ── Body / pipelining accounting ───────────────────────────────────── @@ -181,50 +290,69 @@ public class RequestParser { preBufLen = (int) contentLength; } - RequestLine requestLine = new RequestLine(method, pathView, queryView, protocolView, headerMap); + requestLine.reset(method, pathView, queryMark != -1 ? queryView : null, protocolView, headerMap); + // Javadoc) -- reset() repositions it for the fixed-length/empty case (contentLength == 0 + // is handled by the same call: preBufLen is already forced to 0 for it above) or the + // chunked case, never reallocated. if (isChunked) { - return Request.forParsed(requestLine, - new ChunkedInputStream(in, buffer, bodyStart, preBufLen), - -1L, null, 0, 0, remoteAddress, sslSocket); + requestBody.reset( + new ChunkedInputStream(in, buffer, bodyStart, preBufLen, trailerMap), + -1L, null, 0, 0); + } else { + requestBody.reset(in, contentLength, buffer, bodyStart, preBufLen); } - return Request.forParsed(requestLine, in, contentLength, buffer, bodyStart, preBufLen, remoteAddress, sslSocket); + Request parsed = Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket); + parsed.setTrailers(trailerMap); + return parsed; } - // ── Buffer scanning utilities (hot path — keep branch-free where possible) ── - - private static int findEndOfHeader(byte[] buf, int from, int len) { - for (int i = from; i <= len - 4; i++) { - if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n') - return i; - } - return -1; - } - - private static int find(byte[] buf, int start, int end, byte target) { - for (int i = start; i < end; i++) { - if (buf[i] == target) return i; - } - return -1; - } - - private static boolean equalsIgnoreCase(byte[] buf, int start, int end, String target) { + /** + * value, any non-digit byte (including a leading {@code +}/{@code -}, which are not + * digits), more than 19 digits (the longest possible {@code Long.MAX_VALUE}), arithmetic + * overflow past {@code Long.MAX_VALUE}, and a value above + * {@link Http1Limits#MAX_CONTENT_LENGTH}. The pre-existing {@code parseLong} silently + * skipped any non-digit character instead of rejecting it — {@code "5abc"} parsed as + * {@code 5} and {@code "-1"} parsed as {@code 1}. + */ + private static long parseContentLengthStrict(byte[] buf, int start, int end) throws MalformedRequestException { int len = end - start; - if (len != target.length()) return false; - for (int i = 0; i < len; i++) { - byte b = buf[start + i]; - if (b >= 'A' && b <= 'Z') b += 32; - if (b != (byte) target.charAt(i)) return false; - } - return true; - } - - private static long parseLong(byte[] buf, int start, int end) { + if (len == 0) throw new MalformedRequestException(400, "Empty Content-Length value"); + if (len > 19) throw new MalformedRequestException(400, "Content-Length value too long"); long value = 0; for (int i = start; i < end; i++) { byte c = buf[i]; - if (c >= '0' && c <= '9') value = value * 10 + (c - '0'); + if (c < '0' || c > '9') { + throw new MalformedRequestException(400, "Malformed Content-Length value"); + } + int digit = c - '0'; + if (value > (Long.MAX_VALUE - digit) / 10) { + throw new MalformedRequestException(400, "Content-Length overflow"); + } + value = value * 10 + digit; + } + if (value > Http1Limits.MAX_CONTENT_LENGTH) { + throw new MalformedRequestException(413, "Content-Length exceeds configured maximum"); } return value; } -} \ No newline at end of file + + /** + * RFC 9112 §6.1: when {@code Transfer-Encoding} lists multiple codings + * ({@code "gzip, chunked"}), {@code chunked} MUST be the final one for the message to be + * self-delimiting. Returns whether the last comma-separated token in {@code [start, end)} + * is exactly {@code "chunked"} (case-insensitive), ignoring surrounding whitespace around + * misclassified any multi-coding value as non-chunked. + */ + private static boolean isFinalCodingChunked(byte[] buf, int start, int end) { + int e = end; + while (e > start && (buf[e - 1] == ' ' || buf[e - 1] == '\t')) e--; + int lastComma = start - 1; + for (int i = start; i < e; i++) { + if (buf[i] == ',') lastComma = i; + } + int tokenStart = lastComma + 1; + while (tokenStart < e && (buf[tokenStart] == ' ' || buf[tokenStart] == '\t')) tokenStart++; + return ByteScan.equalsIgnoreCaseAscii(buf, tokenStart, e, "chunked"); + } +} diff --git a/flash/src/main/java/dev/relism/flash/ServerHandle.java b/flash/src/main/java/dev/relism/flash/ServerHandle.java index d1d3cc5..e03e8ce 100644 --- a/flash/src/main/java/dev/relism/flash/ServerHandle.java +++ b/flash/src/main/java/dev/relism/flash/ServerHandle.java @@ -4,6 +4,7 @@ import dev.relism.flash.extension.FlashApp; import dev.relism.flash.extension.FlashConfiguration; import dev.relism.flash.routing.AbstractRouter; import dev.relism.flash.routing.AbstractWsRouter; +import dev.relism.flash.transport.TransportFactory; import java.io.IOException; import java.util.concurrent.CompletableFuture; @@ -11,7 +12,7 @@ import java.util.concurrent.CompletableFuture; /** * Public handle to the underlying HTTP transport. Returned by {@link #create} * so that {@link FlashApp} can start and stop the server - * without holding a direct reference to the package-private {@link HttpServer}. + * without holding a direct reference to the transport's internal composition */ public interface ServerHandle { @@ -30,6 +31,6 @@ public interface ServerHandle { static ServerHandle create(FlashConfiguration config, AbstractRouter httpRouter, AbstractWsRouter wsRouter) throws IOException { - return new HttpServer(config, httpRouter, wsRouter); + return TransportFactory.create(config, httpRouter, wsRouter); } } diff --git a/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java b/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java index ceea189..e8f2367 100644 --- a/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java +++ b/flash/src/main/java/dev/relism/flash/api/multipart/Multipart.java @@ -1,7 +1,9 @@ package dev.relism.flash.api.multipart; +import dev.relism.flash.http.Http1Limits; import dev.relism.flash.models.Request; +import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -41,6 +43,7 @@ import java.util.*; *

Thread safety: not thread-safe; one instance per request. */ public final class Multipart { + private int partCount; private static final int BUF_CAP = 8192; @@ -156,6 +159,11 @@ public final class Multipart { Map headers = readPartHeaders(); if (headers == null) { done = true; return null; } + // unbounded growth of `scanned` and unbounded cumulative header-parsing work. + if (++partCount > Http1Limits.MAX_MULTIPART_PARTS) { + throw new IOException("multipart body exceeds max part count (" + Http1Limits.MAX_MULTIPART_PARTS + ")"); + } + String disp = headers.get("content-disposition"); String name = extractParam(disp, "name"); String filename = extractParam(disp, "filename"); @@ -168,8 +176,9 @@ public final class Multipart { // File part — expose streaming body; not cached (stream is consumed once) p = Part.streaming(name, filename, ct, active); } else { - // Text part, or full-scan path: buffer body now - byte[] body = active.readAllBytes(); + // InputStream.readAllBytes() — an unbounded field/file body would otherwise let a + // hostile peer force an arbitrarily large single heap allocation. + byte[] body = readBoundedBody(active); active = null; p = Part.buffered(name, filename, ct, body); scanned.add(p); @@ -177,6 +186,27 @@ public final class Multipart { return p; } + /** + * Reads {@code in} to EOF into a {@code byte[]}, bounded by + * {@link Http1Limits#MAX_MULTIPART_BUFFERED_PART_SIZE} — see that constant's Javadoc for why + * this bound is necessary even though the overall request body already has one. + */ + private static byte[] readBoundedBody(InputStream in) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(BUF_CAP); + byte[] chunk = new byte[BUF_CAP]; + long total = 0; + int n; + while ((n = in.read(chunk)) > 0) { + total += n; + if (total > Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE) { + throw new IOException("multipart part body exceeds max buffered size (" + + Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + " bytes)"); + } + out.write(chunk, 0, n); + } + return out.toByteArray(); + } + // ------------------------------------------------------------------------- // PartBodyStream — inner class sharing the window buffer // ------------------------------------------------------------------------- @@ -266,9 +296,15 @@ public final class Multipart { private Map readPartHeaders() throws IOException { Map map = new HashMap<>(); + int count = 0; while (true) { String line = readLine(); if (line == null || line.isEmpty()) break; + // header lines before the blank line that ends a part's header block. + if (++count > Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT) { + throw new IOException("multipart part exceeds max header count (" + + Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT + ")"); + } int colon = line.indexOf(':'); if (colon > 0) map.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT), @@ -289,6 +325,7 @@ public final class Multipart { sb.append(new String(win, wPos, i - wPos, StandardCharsets.UTF_8)); int consumed = i - wPos + 2; wPos += consumed; wLen -= consumed; + checkHeaderLineLength(sb.length()); return sb.toString(); } } @@ -298,14 +335,26 @@ public final class Multipart { sb.append(new String(win, wPos, append, StandardCharsets.UTF_8)); wPos += append; wLen -= append; } + // growing for as long as it keeps streaming bytes — the multipart-header analogue of + // RequestParser's Http1Limits.MAX_HEADER_VALUE_LENGTH check, which does not apply + // here since these header lines live inside the body, not the top-level HTTP headers. + checkHeaderLineLength(sb.length()); if (srcEof && wLen > 0) { sb.append(new String(win, wPos, wLen, StandardCharsets.UTF_8)); wPos += wLen; wLen = 0; + checkHeaderLineLength(sb.length()); return sb.toString(); } } } + private static void checkHeaderLineLength(int length) throws IOException { + if (length > Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH) { + throw new IOException("multipart header line exceeds " + + Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH + " bytes"); + } + } + // ------------------------------------------------------------------------- // Utilities // ------------------------------------------------------------------------- diff --git a/flash/src/main/java/dev/relism/flash/bytes/ArrayBackedByteView.java b/flash/src/main/java/dev/relism/flash/bytes/ArrayBackedByteView.java new file mode 100644 index 0000000..bfed512 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/bytes/ArrayBackedByteView.java @@ -0,0 +1,36 @@ +package dev.relism.flash.bytes; + +import dev.relism.fpr.core.ByteView; + +/** + * Capability interface for a {@link ByteView} that is a contiguous slice of a single backing + * {@code byte[]} — as opposed to a {@link SegmentedByteView}, which spans several arrays and + * cannot expose a single {@code (array, offset)} pair. + * + *

Every array-backed view in this codebase implements this: {@code RequestByteView}, + * {@code SocketByteView}, {@code StringByteView} (all in + * {@code dev.relism.flash.routing.routers.fastpathrouter.FastPathViews}), and {@link PooledSlice}. + * {@code MethodPathByteView} deliberately does not — it is a composite of a {@code byte[]} + * (method) and another {@link ByteView} (path), so it has no single backing array. + * + *

What this enables

+ * Anywhere code holds a plain {@link ByteView} and wants the fast path when the concrete + * instance happens to be array-backed, an {@code instanceof ArrayBackedByteView} check unlocks: + *
    + *
  • 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 + *
  • A single {@code System.arraycopy} instead of a manual loop wherever a view's bytes need + * to be copied.
  • + *
+ * Code that only has a bare {@link ByteView} (e.g. because it received one across the + * {@link SegmentedByteView} boundary) keeps the + * byte-at-a-time fallback — this interface is an opportunistic fast path, never a requirement. + */ +public interface ArrayBackedByteView extends ByteView { + /** The backing array. Bytes {@code [offset(), offset() + length())} belong to this view. */ + byte[] array(); + + /** Offset of this view's first byte within {@link #array()}. */ + int offset(); +} diff --git a/flash/src/main/java/dev/relism/flash/bytes/ByteScan.java b/flash/src/main/java/dev/relism/flash/bytes/ByteScan.java new file mode 100644 index 0000000..2cf56aa --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/bytes/ByteScan.java @@ -0,0 +1,324 @@ +package dev.relism.flash.bytes; + +import dev.relism.fpr.core.ByteView; + +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.nio.ByteOrder; + +/** + * The single home for protocol-neutral byte scanning: single-byte search, the four-byte + * {@code \r\n\r\n} header-terminator search (SWAR-accelerated), case-insensitive comparison, + * comma-separated token-list scanning ({@code Connection: a, b, c}), RFC 9110 {@code tchar} + * validation, and the case-insensitive header-name hash {@link dev.relism.flash.models.Http1HeaderMap}'s + * + *

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 + * + *

The SWAR technique used throughout

+ * Both {@link #indexOf} and {@link #indexOfCrLfCrLf} use the classic "does this word contain + * byte {@code b}" bit trick (Bit Twiddling Hacks, "Determine if a word has a byte equal to n"): + * XOR the 8-byte word against {@code b} broadcast into every lane (turning matching lanes to + * {@code 0x00}), then test for any zero lane with + * {@code (v - 0x0101010101010101L) & ~v & 0x8080808080808080L} — non-zero exactly when some lane + * was {@code 0x00} before the subtraction, i.e. some original lane equalled {@code b}. This finds + * *that a* matching lane exists in one word-sized read plus a handful of ALU ops, touching every + * byte only once per 8-byte stride in the common (no-match-yet) case, instead of once per byte. + * + *

Reading the word uses {@link MethodHandles#byteArrayViewVarHandle} with + * {@link ByteOrder#nativeOrder()} — deliberately native rather than a fixed order (contrast + * {@code fpr-core}'s {@code ByteCompare}, which fixes {@code LITTLE_ENDIAN} because it compares + * two independently-read words for bit-exact equality and so needs a byte order both reads + * agree on; nothing here compares across two separately-decoded words, so the fastest order for + * the host CPU is free to use). Byte-equality detection itself (finding that a matching lane + * exists in the mask) does not depend on which order was used to assemble the word — XOR and the + * haszero test are lane-wise operations, indifferent to how lanes map to memory offsets. + * Position extraction does depend on it: converting "which bit of the 64-bit mask is set" + * back into "which array index did that byte come from" requires knowing whether array byte 0 + * became the long's least-significant byte (little-endian) or most-significant byte + * (big-endian) — {@link #laneIndexOf} branches on {@link #NATIVE_IS_LITTLE} once, at class-init + * time, precisely to get this right on either host. + */ +public final class ByteScan { + private ByteScan() {} + + private static final ByteOrder NATIVE_ORDER = ByteOrder.nativeOrder(); + private static final boolean NATIVE_IS_LITTLE = NATIVE_ORDER == ByteOrder.LITTLE_ENDIAN; + private static final VarHandle LONG_VIEW = + MethodHandles.byteArrayViewVarHandle(long[].class, NATIVE_ORDER); + + private static final long LANE_LSB = 0x0101010101010101L; + private static final long LANE_MSB = 0x8080808080808080L; + + // ── tchar (RFC 9110 §5.6.2) ────────────────────────────────────────────── + + /** + * RFC 9110 §5.6.2 {@code tchar} set, table-driven so validation is a single array read per + * only the ASCII range a valid header-name character can ever occupy is populated. + */ + private static final boolean[] TCHAR = new boolean[128]; + + static { + for (byte b : "!#$%&'*+-.^_`|~".getBytes(java.nio.charset.StandardCharsets.US_ASCII)) { + TCHAR[b] = true; + } + for (char c = '0'; c <= '9'; c++) TCHAR[c] = true; + for (char c = 'A'; c <= 'Z'; c++) TCHAR[c] = true; + for (char c = 'a'; c <= 'z'; c++) TCHAR[c] = true; + } + + /** Whether {@code b} is a valid RFC 9110 §5.6.2 {@code tchar} (a legal header-name byte). */ + public static boolean isTChar(byte b) { + return b >= 0 && b < 128 && TCHAR[b]; + } + + // ── Single-byte search ─────────────────────────────────────────────────── + + /** + * Index of the first occurrence of {@code target} in {@code buf[from, to)}, or {@code -1}. + * SWAR-accelerated: touches 8 bytes per word while no match has been found, falling back to + * a byte-at-a-time tail once fewer than 8 bytes remain. + */ + public static int indexOf(byte[] buf, int from, int to, byte target) { + long broadcast = (target & 0xFFL) * LANE_LSB; + int i = from; + while (i + 8 <= to) { + long word = (long) LONG_VIEW.get(buf, i); + long masked = hasZeroLane(word ^ broadcast); + if (masked != 0) { + return i + laneIndexOf(masked); + } + i += 8; + } + for (; i < to; i++) { + if (buf[i] == target) return i; + } + return -1; + } + + /** Plain byte-at-a-time reference implementation of {@link #indexOf} — the test oracle. */ + static int indexOfScalar(byte[] buf, int from, int to, byte target) { + for (int i = from; i < to; i++) { + if (buf[i] == target) return i; + } + return -1; + } + + // ── \r\n\r\n header terminator search ──────────────────────────────────── + + private static final byte CR = '\r', LF = '\n'; + + /** + * Index of the first {@code "\r\n\r\n"} in {@code buf[from, to)}, or {@code -1}. SWAR + * pre-filter (find a candidate {@code CR} byte 8 at a time) plus a cheap scalar 3-byte + * verify at each candidate — see the class Javadoc for the technique and + */ + public static int indexOfCrLfCrLf(byte[] buf, int from, int to) { + int limit = to - 4; // last index at which a 4-byte match can start + int i = from; + while (i + 8 <= to) { + long word = (long) LONG_VIEW.get(buf, i); + long masked = hasZeroLane(word ^ CR_BROADCAST); + if (masked == 0) { + i += 8; + continue; + } + int crPos = i + laneIndexOf(masked); + if (crPos > limit) { + // Nearest CR candidate in this word can't fit a full match before `to`; no CR + // exists before it in [i, crPos) (laneIndexOf always finds the lowest-address + // match first), so nothing in [i, crPos) can match either — the scalar tail + // below, bounded by `limit`, correctly finds nothing without re-deriving that. + break; + } + if (buf[crPos + 1] == LF && buf[crPos + 2] == CR && buf[crPos + 3] == LF) { + return crPos; + } + i = crPos + 1; + } + for (; i <= limit; i++) { + if (buf[i] == CR && buf[i + 1] == LF && buf[i + 2] == CR && buf[i + 3] == LF) { + return i; + } + } + return -1; + } + + private static final long CR_BROADCAST = (CR & 0xFFL) * LANE_LSB; + + /** Plain byte-at-a-time reference implementation of {@link #indexOfCrLfCrLf} — the test oracle. */ + static int indexOfCrLfCrLfScalar(byte[] buf, int from, int to) { + for (int i = from; i <= to - 4; i++) { + if (buf[i] == CR && buf[i + 1] == LF && buf[i + 2] == CR && buf[i + 3] == LF) { + return i; + } + } + return -1; + } + + /** "Determine if a word has a byte equal to n" (Bit Twiddling Hacks), applied to {@code xored}. */ + private static long hasZeroLane(long xored) { + return (xored - LANE_LSB) & ~xored & LANE_MSB; + } + + /** Converts a {@link #hasZeroLane} result into the array-index offset of its lowest matching lane. */ + private static int laneIndexOf(long masked) { + return NATIVE_IS_LITTLE + ? Long.numberOfTrailingZeros(masked) >>> 3 + : 7 - (Long.numberOfLeadingZeros(masked) >>> 3); + } + + // ── Case-insensitive comparison ────────────────────────────────────────── + + private static byte foldAsciiUpper(byte b) { + return (b >= 'A' && b <= 'Z') ? (byte) (b + 32) : b; + } + + /** Case-insensitive (ASCII) equality of {@code buf[start, end)} against {@code target}. */ + public static boolean equalsIgnoreCaseAscii(byte[] buf, int start, int end, String target) { + int len = end - start; + if (len != target.length()) return false; + for (int i = 0; i < len; i++) { + if (foldAsciiUpper(buf[start + i]) != foldAsciiUpper((byte) target.charAt(i))) return false; + } + return true; + } + + /** Case-insensitive (ASCII) equality of two byte-array ranges. */ + public static boolean equalsIgnoreCaseAscii(byte[] a, int aStart, int aLen, byte[] b, int bStart, int bLen) { + if (aLen != bLen) return false; + for (int i = 0; i < aLen; i++) { + if (foldAsciiUpper(a[aStart + i]) != foldAsciiUpper(b[bStart + i])) return false; + } + return true; + } + + /** Case-insensitive (ASCII) equality of {@code view[start, end)} against {@code target}. */ + public static boolean equalsIgnoreCase(ByteView view, int start, int end, String target) { + int len = end - start; + if (len != target.length()) return false; + for (int i = 0; i < len; i++) { + if (foldAsciiUpper(view.byteAt(start + i)) != foldAsciiUpper((byte) target.charAt(i))) return false; + } + return true; + } + + // ── Comma-separated token lists (e.g. `Connection: keep-alive, Upgrade`) ──── + + /** + * Whether the comma-separated, OWS-tolerant token list {@code view} contains {@code token} + * (case-insensitive). The shared scanner behind both {@code Http1KeepAlive.isKeepAlive} and + * drift apart the way a whole-value {@code equals} check once did. + */ + public static boolean tokenListContains(ByteView view, String token) { + int len = view.length(), i = 0; + while (i < len) { + while (i < len && view.byteAt(i) == ' ') i++; + int start = i; + while (i < len && view.byteAt(i) != ',') i++; + if (tokenEqualsIgnoreCase(view, start, i, token)) return true; + i++; + } + return false; + } + + /** Case-insensitive compare of {@code view[start, end)}, trimming trailing spaces, against {@code token}. */ + public static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) { + int wlen = end - start; + while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--; + return equalsIgnoreCase(view, start, start + wlen, token); + } + + + /** + * Case-insensitive (ASCII fold) 32-bit FNV-1a hash of {@code buf[start, start + len)}. Used + * by {@link dev.relism.flash.models.Http1HeaderMap}'s per-request index to compare a cheap hash + * before falling back to a full case-insensitive {@code memcmp}-equivalent + * ({@link #equalsIgnoreCaseAscii}) — two header names that differ anywhere hash differently + * with overwhelming probability, so the common "not the header I'm looking for" case resolves + * in one hash compare instead of a byte-by-byte scan. + */ + public static int hashNameIgnoreCaseAscii(byte[] buf, int start, int len) { + int hash = 0x811C9DC5; // FNV-1a 32-bit offset basis + for (int i = 0; i < len; i++) { + hash ^= (foldAsciiUpper(buf[start + i]) & 0xFF); + hash *= 0x01000193; // FNV-1a 32-bit prime + } + return hash; + } + + /** + * Same hash as {@link #hashNameIgnoreCaseAscii(byte[], int, int)}, computed directly from a + * lookup-key {@code String} (e.g. {@code "Content-Type"}) instead of already-scanned bytes — + * the two must agree bit-for-bit on equivalent ASCII content for + * {@link dev.relism.flash.models.Http1HeaderMap}'s index (hash the request-declared bytes once at + * {@code reset()}; hash the caller's lookup key once per {@code first()}/{@code all()} call; + * compare the two cheap hashes before ever touching a full case-insensitive comparison). + */ + public static int hashNameIgnoreCaseAscii(String name) { + int hash = 0x811C9DC5; + int len = name.length(); + for (int i = 0; i < len; i++) { + hash ^= (foldAsciiUpper((byte) name.charAt(i)) & 0xFF); + hash *= 0x01000193; + } + return hash; + } + + // ── Decimal / hex parsing ──────────────────────────────────────────────── + + /** Sentinel returned by {@link #parseDecimalStrict} on any malformed or out-of-range input. */ + public static final long PARSE_INVALID = -1L; + + /** + * Strict, overflow-safe unsigned decimal parse of {@code buf[start, end)}: rejects an empty + * range, any non-{@code '0'..'9'} byte, more than 19 digits, and arithmetic overflow past + * {@link Long#MAX_VALUE}. Returns {@link #PARSE_INVALID} rather than throwing — the same + * shape {@code RequestParser}'s own {@code Content-Length} parser already hand-rolls (kept + * separate there since it also needs to throw a specific, differently-worded + * {@code MalformedRequestException} per failure mode); this is the general-purpose version + * for callers (HPACK integer decoding, frame-length fields) that just need a valid/invalid + * signal. + */ + public static long parseDecimalStrict(byte[] buf, int start, int end) { + int len = end - start; + if (len == 0 || len > 19) return PARSE_INVALID; + long value = 0; + for (int i = start; i < end; i++) { + byte c = buf[i]; + if (c < '0' || c > '9') return PARSE_INVALID; + int digit = c - '0'; + if (value > (Long.MAX_VALUE - digit) / 10) return PARSE_INVALID; + value = value * 10 + digit; + } + return value; + } + + /** + * Parses up to {@code maxDigits} hex digits (ASCII, either case) from {@code buf[start, end)} + * as an unsigned value. Returns {@link #PARSE_INVALID} if the range is empty, contains a + * non-hex-digit byte, or would need more than {@code maxDigits} digits to represent (the + * caller's bound against, e.g., a chunk-size line with an implausible number of digits). + */ + public static long parseHexStrict(byte[] buf, int start, int end, int maxDigits) { + int len = end - start; + if (len == 0 || len > maxDigits) return PARSE_INVALID; + long value = 0; + for (int i = start; i < end; i++) { + int digit = hexDigit(buf[i]); + if (digit < 0) return PARSE_INVALID; + value = (value << 4) | digit; + } + return value; + } + + private static int hexDigit(byte b) { + if (b >= '0' && b <= '9') return b - '0'; + if (b >= 'a' && b <= 'f') return b - 'a' + 10; + if (b >= 'A' && b <= 'F') return b - 'A' + 10; + return -1; + } +} diff --git a/flash/src/main/java/dev/relism/flash/bytes/ByteWriter.java b/flash/src/main/java/dev/relism/flash/bytes/ByteWriter.java new file mode 100644 index 0000000..3a646ba --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/bytes/ByteWriter.java @@ -0,0 +1,162 @@ +package dev.relism.flash.bytes; + +import java.nio.charset.StandardCharsets; + +/** + * Index-based writer into a growable {@code byte[]} scratch buffer. Every {@code write*} method + * bounds-checks and grows the backing array only when the write would not otherwise fit — + * on an already-warm buffer (the steady-state case: the buffer has already grown to the + * connection's high-water mark), no method here allocates. + * + * Callers build a complete message in a {@code ByteWriter}-backed scratch buffer and then issue + * one bulk {@code write(buffer, 0, length())}. The same writer is shared by HTTP/1.1 and HTTP/2. + * + *

Lifetime and thread-safety contract

+ * Not thread-safe — exactly one writer at a time, matching every other per-connection scratch + * object in this codebase ({@code ConnectionScratch}, {@code Http1HeaderMap}). {@link #reset()} + * repositions this writer to the start of its backing array for the next message; the backing + * array itself is never shrunk back down, only grown — the same amortized-to-zero-allocation + * growth policy {@code RequestParser}'s read buffer already uses. + */ +public final class ByteWriter { + private byte[] buf; + private final byte[] digits = new byte[20]; + private int len; + + public ByteWriter(int initialCapacity) { + this.buf = new byte[Math.max(initialCapacity, 16)]; + } + + /** Repositions this writer to the start of its buffer, ready for the next message. */ + public void reset() { + len = 0; + } + + /** The backing buffer. Valid content is {@code [0, length())} — never assume {@code buf.length == length()}. */ + public byte[] array() { + return buf; + } + + /** How many bytes have been written since the last {@link #reset()}. */ + public int length() { + return len; + } + + private void ensure(int additional) { + int needed = len + additional; + if (needed <= buf.length) return; + int grown = buf.length * 2; + while (grown < needed) grown *= 2; + byte[] next = new byte[grown]; + System.arraycopy(buf, 0, next, 0, len); + buf = next; + } + + public void writeByte(byte b) { + ensure(1); + buf[len++] = b; + } + + public void writeBytes(byte[] src) { + writeBytes(src, 0, src.length); + } + + public void writeBytes(byte[] src, int off, int srcLen) { + ensure(srcLen); + System.arraycopy(src, off, buf, len, srcLen); + len += srcLen; + } + + /** + * Writes {@code value}'s ASCII decimal digits (no sign — callers write {@code '-'} via + * {@link #writeByte} first if needed). {@code value} must be non-negative. + */ + public void writeDecimal(long value) { + if (value < 0) throw new IllegalArgumentException("writeDecimal requires a non-negative value: " + value); + if (value == 0) { + writeByte((byte) '0'); + return; + } + // Digits emerge least-significant-first. The reusable field holds every possible long + // representation, so decimal rendering does not allocate on a warm writer. + int n = 0; + long v = value; + while (v > 0) { + digits[n++] = (byte) ('0' + (v % 10)); + v /= 10; + } + ensure(n); + for (int i = n - 1; i >= 0; i--) buf[len++] = digits[i]; + } + + private static final byte[] HEX_DIGITS = "0123456789abcdef".getBytes(StandardCharsets.US_ASCII); + + /** Writes {@code value}'s lowercase hex digits, no leading zeros (except for {@code value == 0}, which writes {@code "0"}). */ + public void writeHex(int value) { + if (value == 0) { + writeByte((byte) '0'); + return; + } + int n = 0; + int v = value; + while (v != 0) { + digits[n++] = HEX_DIGITS[v & 0xF]; + v >>>= 4; + } + ensure(n); + for (int i = n - 1; i >= 0; i--) buf[len++] = digits[i]; + } + + /** Writes {@code s}'s ASCII bytes, lower-cased. {@code s} must be ASCII-only. */ + public void writeAsciiLower(String s) { + int n = s.length(); + ensure(n); + for (int i = 0; i < n; i++) { + char c = s.charAt(i); + if (c >= 'A' && c <= 'Z') c += 32; + buf[len++] = (byte) c; + } + } + + /** + * Writes {@code s}'s ASCII bytes, case preserved. {@code s} must be ASCII-only. Unlike + * {@code new String(...).getBytes(UTF_8)}, writes each character directly into this buffer + * and avoids an intermediate {@code byte[]}. + */ + public void writeAscii(String s) { + int n = s.length(); + ensure(n); + for (int i = 0; i < n; i++) { + buf[len++] = (byte) s.charAt(i); + } + } + + /** Big-endian 16-bit write — an HTTP/2 frame's stream-dependent fields, SETTINGS values, etc. */ + public void writeUInt16(int value) { + ensure(2); + buf[len++] = (byte) (value >>> 8); + buf[len++] = (byte) value; + } + + /** Big-endian 24-bit write — an HTTP/2 frame header's length field. */ + public void writeUInt24(int value) { + ensure(3); + buf[len++] = (byte) (value >>> 16); + buf[len++] = (byte) (value >>> 8); + buf[len++] = (byte) value; + } + + /** Big-endian 31-bit write (top bit always 0) — an HTTP/2 stream identifier. */ + public void writeUInt31(int value) { + writeUInt32(value & 0x7FFFFFFF); + } + + /** Big-endian 32-bit write — an HTTP/2 window-size increment, SETTINGS value, etc. */ + public void writeUInt32(int value) { + ensure(4); + buf[len++] = (byte) (value >>> 24); + buf[len++] = (byte) (value >>> 16); + buf[len++] = (byte) (value >>> 8); + buf[len++] = (byte) value; + } +} diff --git a/flash/src/main/java/dev/relism/flash/bytes/Pairs.java b/flash/src/main/java/dev/relism/flash/bytes/Pairs.java new file mode 100644 index 0000000..c3fea83 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/bytes/Pairs.java @@ -0,0 +1,42 @@ +package dev.relism.flash.bytes; + +/** + * The allocation-free idiom for returning two {@code int}s from a method without an object: + * pack both into one {@code long}, unpack at the call site. Already used, hand-rolled, in four + * places ({@code Http1HeaderMap.findFirst}, {@code QueryParams.findFirst}, and others) before this + * class existed — this is the single named home for the shifts so they are not duplicated (and + * potentially inconsistently duplicated — e.g. one copy masking with {@code 0xFFFFFFFFL} and + * another forgetting to) five times over. + * + *

Why this works

+ * A {@code long} is 64 bits; each packed {@code int} is 32. {@link #pack} left-shifts the high + * half into the top 32 bits and OR's the low half into the bottom 32. {@link #lo} must mask with + * {@code 0xFFFFFFFFL} rather than simply cast to {@code int} after no mask, because a right-shift + * of a negative {@code long} sign-extends — the mask discards everything above bit 31 before the + * narrowing cast happens implicitly. {@link #hi} needs no mask: a right-shift by 32 already + * leaves only the original high bits in the low 32 positions of the result. + * + *

Encoding convention used across this codebase

+ * Every {@code findFirst}-shaped method in this codebase packs {@code (start << 32) | length}, + * i.e. {@code hi() == start} and {@code lo() == length}. {@code -1L} is the shared "not found" + * sentinel (a valid {@code (start, length)} pair can never be negative, since both halves are + * non-negative offsets/lengths). + */ +public final class Pairs { + private Pairs() {} + + /** Packs two {@code int}s into one {@code long}: {@code hi} in the upper 32 bits, {@code lo} in the lower 32. */ + public static long pack(int hi, int lo) { + return ((long) hi << 32) | (lo & 0xFFFFFFFFL); + } + + /** Extracts the upper 32 bits packed by {@link #pack}. */ + public static int hi(long packed) { + return (int) (packed >> 32); + } + + /** Extracts the lower 32 bits packed by {@link #pack}. */ + public static int lo(long packed) { + return (int) (packed & 0xFFFFFFFFL); + } +} diff --git a/flash/src/main/java/dev/relism/flash/bytes/PooledSlice.java b/flash/src/main/java/dev/relism/flash/bytes/PooledSlice.java new file mode 100644 index 0000000..ff771e9 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/bytes/PooledSlice.java @@ -0,0 +1,52 @@ +package dev.relism.flash.bytes; + +/** + * per-call {@code new ByteView() { ... }} anonymous-class allocation that used to live in + * {@code Http1HeaderMap.view}, {@code QueryParams.view}, and {@code PathParams.view}: instead of + * allocating a fresh view object (plus its capturing instance) on every call, a small + * {@link SlicePool} of these hands out an existing instance, repositioned in place. + * + *

Lifetime contract

+ * A {@code PooledSlice} handed out by {@link SlicePool#acquire} is valid only until the pool + * wraps around and reuses the same slot — see {@link SlicePool}'s own Javadoc for the exact + * "valid until the Nth subsequent acquire, or end of request" rule the owning class (e.g. + * {@code Http1HeaderMap}) documents precisely for its own {@code view()} method. Never retain a + * {@code PooledSlice} past that window, for the same reason the old anonymous view could not be + * retained past the handler: the bytes (and, here, additionally the slice object itself) are + * about to be repositioned out from under a stale reference. + */ +public final class PooledSlice implements ArrayBackedByteView { + private byte[] array; + private int offset; + private int length; + + /** Repositions this slice over {@code array[offset, offset + length)}. Zero allocation. */ + public void reset(byte[] array, int offset, int length) { + this.array = array; + this.offset = offset; + this.length = length; + } + + @Override + public byte[] array() { + return array; + } + + @Override + public int offset() { + return offset; + } + + @Override + public int length() { + return length; + } + + @Override + public byte byteAt(int index) { + if (index < 0 || index >= length) { + throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + length); + } + return array[offset + index]; + } +} diff --git a/flash/src/main/java/dev/relism/flash/bytes/SegmentedByteView.java b/flash/src/main/java/dev/relism/flash/bytes/SegmentedByteView.java new file mode 100644 index 0000000..a3389ed --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/bytes/SegmentedByteView.java @@ -0,0 +1,80 @@ +package dev.relism.flash.bytes; + +import dev.relism.fpr.core.ByteView; + +/** + * A {@link ByteView} over up to {@code K} discontiguous {@code byte[]} segments, presented as one + * logical byte sequence. Exists for the one case in this codebase where a "single contiguous + * slice of one buffer" model (every other {@link ByteView} implementation) does not hold: an + * HPACK header block whose encoding spans more than one {@code CONTINUATION} frame (RFC 9113 + * §6.10), where each frame's payload lives in its own connection-buffer region. + * + *

Deliberately not array-backed

+ * This does not implement {@link ArrayBackedByteView} — there is no single {@code (array, + * offset)} pair that describes it — and {@link #supportsLong()} returns {@code false} + * time path is only sound for a genuinely contiguous backing array; see + * {@code FastPathViews.MethodPathByteView} for the other deliberately-segmented view in this + * codebase, which makes the same choice for the same reason). + * + *

Reusable, not allocated per block

+ * {@link #reset} repositions this view over a new set of segments without allocating — the same + * idiom {@link PooledSlice} uses for the contiguous case. The {@code segments}/{@code offsets}/ + * {@code lengths} arrays passed to {@link #reset} are retained by reference, not copied; the + * caller owns their lifetime (typically the connection's HPACK scratch, sized to + * {@code Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK}). + * + *

Cost model

+ * {@link #byteAt} walks the segment table to find which segment an index falls in — O(segments), + * not O(1) — because this view exists precisely for the rare, deliberately-bounded case + * (at most {@code MAX_CONTINUATION_FRAMES_PER_BLOCK} segments); optimizing it further would add + * complexity for a path that, by construction, is never hot. + */ +public final class SegmentedByteView implements ByteView { + private byte[][] segments; + private int[] offsets; + private int[] lengths; + private int count; + private int totalLength; + + /** + * Repositions this view over {@code segments[0..count)}, where segment {@code i} contributes + * bytes {@code segments[i][offsets[i], offsets[i] + lengths[i])}. Zero allocation: the three + * arrays are retained by reference. + */ + public void reset(byte[][] segments, int[] offsets, int[] lengths, int count) { + this.segments = segments; + this.offsets = offsets; + this.lengths = lengths; + this.count = count; + int total = 0; + for (int i = 0; i < count; i++) total += lengths[i]; + this.totalLength = total; + } + + @Override + public int length() { + return totalLength; + } + + @Override + public byte byteAt(int index) { + if (index < 0 || index >= totalLength) { + throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + totalLength); + } + int remaining = index; + for (int i = 0; i < count; i++) { + int len = lengths[i]; + if (remaining < len) { + return segments[i][offsets[i] + remaining]; + } + remaining -= len; + } + throw new IndexOutOfBoundsException("Index " + index + " out of bounds for length " + totalLength); + } + + /** Always {@code false} — see the class Javadoc for why a cross-segment word read is unsound. */ + @Override + public boolean supportsLong() { + return false; + } +} diff --git a/flash/src/main/java/dev/relism/flash/bytes/SlicePool.java b/flash/src/main/java/dev/relism/flash/bytes/SlicePool.java new file mode 100644 index 0000000..20b07d4 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/bytes/SlicePool.java @@ -0,0 +1,51 @@ +package dev.relism.flash.bytes; + +/** + * A small, fixed-size ring of {@link PooledSlice} instances — one per {@code ConnectionScratch}- + * {@code Http1HeaderMap.view}, {@code QueryParams.view}, {@code PathParams.view}). + * + *

Why a ring, not a single reused slice

+ * A single reused slice (the shape {@code Http1HeaderMap.forEach} already uses for its two + * {@code nameSlice}/{@code valueSlice} fields) is correct only when the caller is guaranteed to + * finish with one slice before the next is produced — true for a single {@code forEach} callback + * invocation, false for {@code view()}: a handler might reasonably call + * {@code headers.view("A")} and {@code headers.view("B")} and want to compare both. A ring of + * {@code size} slices lets up to {@code size} calls' results stay simultaneously valid. + * + *

Lifetime contract

+ * A slice returned by {@link #acquire} is valid until either the request ends, or {@link #acquire} + * is called {@code size} more times on the same pool (at which point the ring has wrapped around + * and repositioned that same slot for a new caller) — whichever comes first. This must be + * restated precisely on every method that hands out a slice from a pool (see + * {@code Http1HeaderMap.view}'s Javadoc for the canonical wording); it is a real, testable hazard, not + * a hypothetical one — see {@code SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice} for + * a demonstration. + */ +public final class SlicePool { + private final PooledSlice[] slices; + private int next = 0; + + /** A ring of {@code size} reusable slices. {@code size} must be at least 1. */ + public SlicePool(int size) { + if (size < 1) throw new IllegalArgumentException("SlicePool size must be at least 1: " + size); + slices = new PooledSlice[size]; + for (int i = 0; i < size; i++) slices[i] = new PooledSlice(); + } + + /** How many slices this pool cycles through before a caller's slice is reused. */ + public int size() { + return slices.length; + } + + /** + * Returns the next slice in the ring, repositioned over {@code array[offset, offset + length)}. + * Zero allocation — the returned instance already existed. + */ + public PooledSlice acquire(byte[] array, int offset, int length) { + PooledSlice slice = slices[next]; + next++; + if (next == slices.length) next = 0; + slice.reset(array, offset, length); + return slice; + } +} diff --git a/flash/src/main/java/dev/relism/flash/exceptions/MalformedRequestException.java b/flash/src/main/java/dev/relism/flash/exceptions/MalformedRequestException.java new file mode 100644 index 0000000..18197e1 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/exceptions/MalformedRequestException.java @@ -0,0 +1,22 @@ +package dev.relism.flash.exceptions; + +/** + * Thrown by the HTTP/1.1 parser when a request violates a protocol rule that must be rejected + * outright — most importantly the request-smuggling defenses of RFC 9112 §6.1 (see + * + *

Distinct from {@link HttpException}, which a handler throws to describe an + * application-level failure and which is routed through the user's configured exception + * handler ({@code AbstractRouter.getExceptionHandler()}). A malformed request never reaches a + * handler, or middleware, or the user's exception handler at all: it is rejected by the + * transport itself, with a fixed, minimal, non-customizable response, and the connection is + * always closed afterwards — never kept alive. Keeping a connection alive after a rejected + * request is exactly the situation a smuggling attempt exploits (a rejected first request + * hiding a crafted second one in the same TCP stream), so the transport never offers that + * choice to user code. + */ +public class MalformedRequestException extends HttpException { + + public MalformedRequestException(int status, String message) { + super(status, message); + } +} diff --git a/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java b/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java index b5ddc26..db38882 100644 --- a/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java +++ b/flash/src/main/java/dev/relism/flash/extension/AnnotationProcessor.java @@ -7,7 +7,7 @@ import java.util.List; /** * Inspects a handler class at registration time and returns zero or more - * {@link Middleware middlewares} to inject automatically. + * {@link MiddlewareNode middleware nodes} to inject automatically. * *

Processors are called once per register call, before * the handler is compiled into the router. Returning an empty list is always diff --git a/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java b/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java index 5a8745c..b92d40c 100644 --- a/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java +++ b/flash/src/main/java/dev/relism/flash/extension/FlashConfiguration.java @@ -1,13 +1,11 @@ package dev.relism.flash.extension; import dev.relism.flash.tls.TlsConfig; - +import java.util.List; import lombok.Builder; import lombok.Singular; import lombok.Value; -import java.util.List; - /** * Configuration for a {@link FlashApp} instance. * @@ -39,27 +37,128 @@ import java.util.List; @Builder public class FlashConfiguration { - int port; - String host; + int port; + String host; - /** TLS for the single default listener ({@link #port}/{@link #host}). Ignored if {@link #listeners} is non-empty. */ - TlsConfig tls; + /** + * TLS for the single default listener ({@link #port}/{@link #host}). Ignored if {@link + * #listeners} is non-empty. + */ + TlsConfig tls; - /** One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link #host}/{@link #tls}. */ - @Singular - List listeners; + /** + * One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link + * #host}/{@link #tls}. + */ + @Singular List listeners; - /** Maximum size of the request header buffer in bytes. Default: 64 KB. */ - @Builder.Default - int maxHeaderBufferSize = 64 * 1024; + /** Maximum size of the request header buffer in bytes. Default: 64 KB. */ + @Builder.Default int maxHeaderBufferSize = 64 * 1024; - /** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */ - @Builder.Default - int wsFrameBufferSize = 64 * 1024; + /** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */ + @Builder.Default int wsFrameBufferSize = 64 * 1024; - /** One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS. */ - public record Listener(int port, String host, TlsConfig tls) { - public Listener(int port) { this(port, null, null); } - public Listener(int port, TlsConfig tls) { this(port, null, tls); } + /** + * Maximum time, in milliseconds, allowed for a request's headers to be fully read once the first + * byte of it has arrived. Bounds the classic slowloris attack: a peer that trickles one header + * byte every few seconds forever. Enforced by an absolute deadline (see {@code + * dev.relism.flash.transport.BufferedByteSource}), not merely a per-read socket timeout — a + * per-read timeout alone never trips as long as each individual read succeeds within the window, + * no matter how long the overall header block takes. Default: 10 000 + */ + @Builder.Default int headerReadTimeoutMs = 10_000; + + /** + * Maximum time, in milliseconds, a keep-alive connection may sit idle waiting for its next + * request before being closed. More generous than {@link #headerReadTimeoutMs} because an idle + * keep-alive connection is normal, expected behaviour, not an attack in progress — the tighter + * bound applies only once bytes have actually started arriving. Default: 60 000 + */ + @Builder.Default int idleKeepAliveTimeoutMs = 60_000; + + /** + * Maximum time, in milliseconds, a request's body may take to be fully read (by the handler or by + * the automatic drain after it returns) once headers are parsed. Default: 30 000 + */ + @Builder.Default int bodyReadTimeoutMs = 30_000; + + /** + * Maximum time, in milliseconds, {@link dev.relism.flash.ServerHandle#stop()} waits for in-flight + * requests to finish after it stops accepting new connections, before force- + */ + @Builder.Default int shutdownDrainTimeoutMs = 15_000; + + /** + * Maximum connections admitted across all listeners before new connections are closed + * immediately at accept time, before any per-connection state (TLS handshake, protocol + * negotiation, HPACK tables, buffers) is set up. Defaults to an auto-scaled budget based on the + * JVM's max heap ({@link dev.relism.flash.transport.TransportLimits#defaultMaxConnections()}), + * so a connection flood cannot exhaust the heap out of the box. Set explicitly if you know your + * deployment's real capacity, or to {@code 0} to disable the check entirely (unlimited). + */ + @Builder.Default int maxConnections = + dev.relism.flash.transport.TransportLimits.defaultMaxConnections(); + + /** Whether TLS listeners advertise HTTP/2 through ALPN. */ + @Builder.Default boolean http2Enabled = false; + + /** + * Whether plaintext listeners accept the HTTP/2 prior-knowledge preface. This is independent + * from TLS HTTP/2 and deliberately disabled by default. + */ + @Builder.Default boolean http2CleartextEnabled = false; + + /** + * Whether runtime-generated HTTP/2 header values use HPACK Huffman coding. Constants are always + * compressed once at startup; leaving this disabled avoids a per-byte encode pass on responses. + */ + @Builder.Default boolean h2HuffmanDynamicValues = false; + + /** Maximum peer RST_STREAM frames per rolling interval. */ + @Builder.Default int h2MaxResetStreamsPerInterval = + dev.relism.flash.http2.Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL; + + /** Maximum peer-created streams per rolling interval. */ + @Builder.Default int h2MaxStreamsCreatedPerInterval = + dev.relism.flash.http2.Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL; + + /** Rolling interval used by HTTP/2 abuse-rate counters. */ + @Builder.Default long h2AbuseRateIntervalMs = + dev.relism.flash.http2.Http2Limits.RESET_RATE_INTERVAL_MS; + + /** Maximum total streams served by one HTTP/2 connection; zero disables the budget. */ + @Builder.Default long h2MaxStreamsPerConnection = + dev.relism.flash.http2.Http2Limits.MAX_STREAMS_PER_CONNECTION; + + /** Maximum wire bytes read by one HTTP/2 connection; zero disables the budget. */ + @Builder.Default long h2MaxBytesPerConnection = + dev.relism.flash.http2.Http2Limits.MAX_BYTES_PER_CONNECTION; + + /** Maximum HTTP/2 connection lifetime in milliseconds; zero disables the budget. */ + @Builder.Default long h2MaxConnectionLifetimeMs = + dev.relism.flash.http2.Http2Limits.MAX_CONNECTION_LIFETIME_MS; + + /** Maximum inactivity time for an open HTTP/2 stream. */ + @Builder.Default long h2StreamIdleTimeoutMs = + dev.relism.flash.http2.Http2Limits.STREAM_IDLE_TIMEOUT_MS; + + /** + * Whether every response includes a {@code Date} header (RFC 9110 §6.6.1). Default {@code true}; + * set {@code false} if Flash sits behind a reverse proxy that already adds one, to skip the + * (already cheap — see {@code dev.relism.flash.http.DateHeader}) write. + */ + @Builder.Default boolean sendDate = true; + + /** + * One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS. + */ + public record Listener(int port, String host, TlsConfig tls) { + public Listener(int port) { + this(port, null, null); } + + public Listener(int port, TlsConfig tls) { + this(port, null, tls); + } + } } diff --git a/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java b/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java index ce378d4..7c1393f 100644 --- a/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java +++ b/flash/src/main/java/dev/relism/flash/extension/PackageScanner.java @@ -2,8 +2,9 @@ package dev.relism.flash.extension; import dev.relism.flash.exceptions.InitializationException; import dev.relism.flash.models.RequestHandler; -import dev.relism.flash.routing.Ws; +import dev.relism.flash.routing.Route; import dev.relism.flash.routing.Routes; +import dev.relism.flash.routing.Ws; import dev.relism.flash.websocket.WebSocketEndpoint; import java.io.File; diff --git a/flash/src/main/java/dev/relism/flash/http/ContentType.java b/flash/src/main/java/dev/relism/flash/http/ContentType.java index e95079b..365e483 100644 --- a/flash/src/main/java/dev/relism/flash/http/ContentType.java +++ b/flash/src/main/java/dev/relism/flash/http/ContentType.java @@ -1,65 +1,88 @@ package dev.relism.flash.http; +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.hpack.HpackEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; import lombok.Getter; -import java.nio.charset.StandardCharsets; - /** - * Pre-compiled byte representations of common HTTP {@code Content-Type} values. - * {@link #getBytes()} returns the pre-computed array directly, never allocates. + * Pre-compiled byte representations of common HTTP {@code Content-Type} values. {@code getBytes()} + * returns the pre-computed array directly, never allocates. */ @Getter public enum ContentType { + NONE(""), - NONE (""), + // Text + TEXT_PLAIN("text/plain"), + TEXT_HTML("text/html"), + TEXT_CSS("text/css"), + TEXT_JAVASCRIPT("text/javascript"), + TEXT_XML("text/xml"), + TEXT_CSV("text/csv"), + TEXT_MARKDOWN("text/markdown"), + TEXT_EVENT_STREAM("text/event-stream"), - // Text - TEXT_PLAIN ("text/plain"), - TEXT_HTML ("text/html"), - TEXT_CSS ("text/css"), - TEXT_JAVASCRIPT ("text/javascript"), - TEXT_XML ("text/xml"), - TEXT_CSV ("text/csv"), - TEXT_MARKDOWN ("text/markdown"), - TEXT_EVENT_STREAM ("text/event-stream"), + // Application + JSON("application/json"), + XML("application/xml"), + BINARY("application/octet-stream"), + PDF("application/pdf"), + ZIP("application/zip"), + GZIP("application/gzip"), + FORM_URLENCODED("application/x-www-form-urlencoded"), + MULTIPART_FORM("multipart/form-data"), + GRAPHQL("application/graphql"), + NDJSON("application/x-ndjson"), + MSGPACK("application/msgpack"), + CBOR("application/cbor"), + LD_JSON("application/ld+json"), - // Application - JSON ("application/json"), - XML ("application/xml"), - BINARY ("application/octet-stream"), - PDF ("application/pdf"), - ZIP ("application/zip"), - GZIP ("application/gzip"), - FORM_URLENCODED ("application/x-www-form-urlencoded"), - MULTIPART_FORM ("multipart/form-data"), - GRAPHQL ("application/graphql"), - NDJSON ("application/x-ndjson"), - MSGPACK ("application/msgpack"), - CBOR ("application/cbor"), - LD_JSON ("application/ld+json"), + // Image + IMAGE_PNG("image/png"), + IMAGE_JPEG("image/jpeg"), + IMAGE_GIF("image/gif"), + IMAGE_WEBP("image/webp"), + IMAGE_SVG("image/svg+xml"), + IMAGE_ICO("image/x-icon"), + IMAGE_AVIF("image/avif"), - // Image - IMAGE_PNG ("image/png"), - IMAGE_JPEG ("image/jpeg"), - IMAGE_GIF ("image/gif"), - IMAGE_WEBP ("image/webp"), - IMAGE_SVG ("image/svg+xml"), - IMAGE_ICO ("image/x-icon"), - IMAGE_AVIF ("image/avif"), + // Font + FONT_WOFF("font/woff"), + FONT_WOFF2("font/woff2"), - // Font - FONT_WOFF ("font/woff"), - FONT_WOFF2 ("font/woff2"), + // Audio / Video + AUDIO_MPEG("audio/mpeg"), + AUDIO_OGG("audio/ogg"), + VIDEO_MP4("video/mp4"), + VIDEO_WEBM("video/webm"); - // Audio / Video - AUDIO_MPEG ("audio/mpeg"), - AUDIO_OGG ("audio/ogg"), - VIDEO_MP4 ("video/mp4"), - VIDEO_WEBM ("video/webm"); + private final byte[] bytes; + private final byte[] hpackBytes; + private static final ContentType[] ALL = values(); - private final byte[] bytes; - - ContentType(String value) { - this.bytes = value.getBytes(StandardCharsets.UTF_8); + ContentType(String value) { + this.bytes = value.getBytes(StandardCharsets.UTF_8); + if (bytes.length == 0) { + this.hpackBytes = bytes; + } else { + ByteWriter out = new ByteWriter(32); + HpackEncoder.writeLiteralWithNameIndex(out, 31, bytes, true); + this.hpackBytes = Arrays.copyOf(out.array(), out.length()); } + } + + /** Precompiled HPACK {@code content-type} field, or an empty array for {@link #NONE}. */ + public byte[] getHpackBytes() { + return hpackBytes; + } + + /** Finds the boot-time HPACK rendering for a response content-type byte array. */ + public static byte[] hpackBytesFor(byte[] value) { + for (ContentType type : ALL) { + if (type.bytes == value || Arrays.equals(type.bytes, value)) return type.hpackBytes; + } + return null; + } } diff --git a/flash/src/main/java/dev/relism/flash/http/DateHeader.java b/flash/src/main/java/dev/relism/flash/http/DateHeader.java new file mode 100644 index 0000000..6b6f2ed --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http/DateHeader.java @@ -0,0 +1,77 @@ +package dev.relism.flash.http; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.hpack.HpackEncoder; +import java.nio.charset.StandardCharsets; +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Locale; + +/** + * A daemon refreshes both protocol renderings once per second. Response writers only perform one + * volatile read and copy already-encoded bytes into their output buffer. + */ +public final class DateHeader { + + private DateHeader() {} + + private static final DateTimeFormatter FORMATTER = + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss 'GMT'", Locale.US) + .withZone(ZoneOffset.UTC); + + private record Snapshot(byte[] http1, byte[] hpack) {} + + private static volatile Snapshot current = encode(); + + static { + Thread refresher = + new Thread( + () -> { + while (true) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + current = encode(); + } + }, + "flash-date-header"); + refresher.setDaemon(true); + refresher.start(); + } + + private static Snapshot encode() { + byte[] value = format(ZonedDateTime.now(ZoneOffset.UTC)).getBytes(StandardCharsets.US_ASCII); + byte[] prefix = "Date: ".getBytes(StandardCharsets.US_ASCII); + byte[] http1 = new byte[prefix.length + value.length + 2]; + System.arraycopy(prefix, 0, http1, 0, prefix.length); + System.arraycopy(value, 0, http1, prefix.length, value.length); + http1[http1.length - 2] = '\r'; + http1[http1.length - 1] = '\n'; + + ByteWriter encoded = new ByteWriter(32); + HpackEncoder.writeLiteralWithNameIndex(encoded, 33, value, true); + return new Snapshot(http1, Arrays.copyOf(encoded.array(), encoded.length())); + } + + static String format(ZonedDateTime time) { + return FORMATTER.format(time); + } + + /** + * The current pre-encoded {@code "Date: ...\r\n"} field line, accurate to within one second. + * Never allocates — the same array is returned until the next refresh. + */ + public static byte[] bytes() { + return current.http1; + } + + /** Current precompiled HPACK {@code date} field. */ + public static byte[] hpackBytes() { + return current.hpack; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http/HopByHopHeaders.java b/flash/src/main/java/dev/relism/flash/http/HopByHopHeaders.java new file mode 100644 index 0000000..385616f --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http/HopByHopHeaders.java @@ -0,0 +1,87 @@ +package dev.relism.flash.http; + +import dev.relism.flash.models.HeaderView; +import dev.relism.fpr.core.ByteView; + +/** Shared proxy policy for fields that must not cross an HTTP connection boundary. */ +public final class HopByHopHeaders { + public enum Protocol { + HTTP_1_1, + HTTP_2 + } + + private HopByHopHeaders() {} + + /** Returns whether a field may be copied to a new downstream connection. */ + public static boolean shouldForward( + HeaderView source, + ByteView name, + ByteView value, + Protocol sourceProtocol, + Protocol targetProtocol) { + if (name.length() == 0 || name.byteAt(0) == ':') return false; + if (is(name, "connection") + || is(name, "keep-alive") + || is(name, "proxy-connection") + || is(name, "proxy-authenticate") + || is(name, "proxy-authorization") + || is(name, "trailer") + || is(name, "transfer-encoding") + || is(name, "upgrade")) { + return false; + } + if (isConnectionListed(source, name)) return false; + if (is(name, "te")) { + return targetProtocol == Protocol.HTTP_2 && isTrimmed(value, "trailers"); + } + return true; + } + + private static boolean isConnectionListed(HeaderView source, ByteView fieldName) { + for (String value : source.all("connection")) { + int start = 0; + while (start < value.length()) { + int comma = value.indexOf(',', start); + int end = comma < 0 ? value.length() : comma; + while (start < end && isWhitespace(value.charAt(start))) start++; + while (end > start && isWhitespace(value.charAt(end - 1))) end--; + if (equalsAsciiIgnoreCase(fieldName, value, start, end)) return true; + start = comma < 0 ? value.length() : comma + 1; + } + } + return false; + } + + private static boolean is(ByteView bytes, String expected) { + return equalsAsciiIgnoreCase(bytes, expected, 0, expected.length()); + } + + private static boolean isTrimmed(ByteView bytes, String expected) { + int start = 0; + int end = bytes.length(); + while (start < end && isWhitespace((char) bytes.byteAt(start))) start++; + while (end > start && isWhitespace((char) bytes.byteAt(end - 1))) end--; + if (end - start != expected.length()) return false; + for (int i = 0; i < expected.length(); i++) { + if (lower(bytes.byteAt(start + i) & 0xff) != lower(expected.charAt(i))) return false; + } + return true; + } + + private static boolean equalsAsciiIgnoreCase( + ByteView bytes, String expected, int expectedStart, int expectedEnd) { + if (bytes.length() != expectedEnd - expectedStart) return false; + for (int i = 0; i < bytes.length(); i++) { + if (lower(bytes.byteAt(i) & 0xff) != lower(expected.charAt(expectedStart + i))) return false; + } + return true; + } + + private static int lower(int value) { + return value >= 'A' && value <= 'Z' ? value + ('a' - 'A') : value; + } + + private static boolean isWhitespace(char value) { + return value == ' ' || value == '\t'; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http/Http1Limits.java b/flash/src/main/java/dev/relism/flash/http/Http1Limits.java new file mode 100644 index 0000000..b921f94 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http/Http1Limits.java @@ -0,0 +1,156 @@ +package dev.relism.flash.http; + +/** + * Bounds the HTTP/1.1 parser ({@code RequestParser}, {@code ChunkedInputStream}) enforces + * against a peer's input, in one place. + * + * against a named constant here — never against an ad-hoc literal, and never by letting the + * underlying buffer throw on overrun. Each field's Javadoc names the specific attack it bounds. + * + * Compare {@code dev.relism.flash.http2.Http2Limits}, the HTTP/2 equivalent. + */ +public final class Http1Limits { + + private Http1Limits() { + } + + /** + * The largest {@code Content-Length} value accepted, in bytes. RFC 9112 places no upper + * bound on the header's numeric value, but an unbounded value from a hostile peer is a + * resource-exhaustion vector for any code path that pre-sizes a buffer from it. Requests + * declaring a length above this are rejected with {@code 413 Payload Too Large} before any + * body byte is read. + * + *

4 GiB — generous enough for legitimate large uploads (Flash is a general-purpose + * server, not an API-only framework with a tiny default), while still bounding a hostile + * peer to a finite, known-in-advance number rather than the effectively unbounded + * {@code Long.MAX_VALUE} the parser accepted before this limit existed. Comfortably above + * {@code Integer.MAX_VALUE} (~2.1 billion) so legitimate very-large declared lengths are + */ + public static final long MAX_CONTENT_LENGTH = 4L * 1024 * 1024 * 1024; + + /** + * Maximum number of header lines accepted in a single request. Without this bound, a + * request with tens of thousands of one-byte headers passes the total header-block size + * check ({@code maxHeaderBufferSize}) while still forcing every subsequent + * {@code Http1HeaderMap} lookup to scan all of them — turning a small request into quadratic CPU + */ + public static final int MAX_HEADER_COUNT = 100; + + /** + * Maximum length, in bytes, of a single header field name. RFC 9110 §5.1 places no formal + * limit; this bound exists purely to cap per-header memory and scan cost. + */ + public static final int MAX_HEADER_NAME_LENGTH = 256; + + /** + * Maximum length, in bytes, of a single header field value. Bounds per-header memory and + * scan cost the same way {@link #MAX_HEADER_NAME_LENGTH} bounds the name. + */ + public static final int MAX_HEADER_VALUE_LENGTH = 8_192; + + /** + * Maximum length, in bytes, of the request line ({@code METHOD SP target SP version}). + * Tracked separately from the overall header-buffer size so an oversized request line is + * rejected with a specific, correct status ({@code 414 URI Too Long}) rather than folded + * into the generic header-block-too-large case. + */ + public static final int MAX_REQUEST_LINE_LENGTH = 8_192; + + /** + * Maximum size, in bytes, of a single {@code Transfer-Encoding: chunked} chunk. + * {@code ChunkedInputStream.readChunkSize} previously accepted any value up to 2 GiB before + * rejecting it; a hostile peer can advertise a huge chunk size and then trickle bytes, + * forcing the connection to stay open far longer than any legitimate chunk would need + * (bounded separately by {@code bodyReadTimeoutMs}, but this limit catches the size claim + * itself before that timeout would). + */ + public static final long MAX_CHUNK_SIZE = 16L * 1024 * 1024; + + /** + * Maximum length, in bytes, of the chunk-extension section (the optional + * {@code ;name=value} data after a chunk size and before its CRLF, RFC 9112 §7.1.1). Flash + * does not interpret chunk extensions; without a bound, a peer could send an arbitrarily + * long extension on every chunk purely to waste CPU discarding it. + */ + public static final int MAX_CHUNK_EXT_LENGTH = 256; + + /** + * Maximum number of chunks accepted in a single request body. Without this bound, a peer + * can send an unbounded number of minimal (or zero-length) chunks, each cheap individually + * but collectively forcing unbounded per-chunk framing work — a "death by a thousand + * chunks" variant of a slow-body attack. + */ + public static final int MAX_CHUNKS_PER_BODY = 100_000; + + /** + * Maximum number of trailer header lines accepted after the final chunk of a chunked body + * (RFC 9112 §7.1.2). Bounded for the same reason {@link #MAX_HEADER_COUNT} bounds the + * regular header section; trailer values are separately bounded by + * {@link #MAX_HEADER_VALUE_LENGTH}. + */ + public static final int MAX_TRAILER_COUNT = 50; + + /** + * buffer as the response head (status line + headers) and written with it in a single + * {@code OutputStream.write} call; larger bodies are written in a second {@code write} right + * after the head, since copying a large body into the head buffer first would cost more + * (an extra full-body memcpy) than the syscall it saves. 8 KiB — matches this codebase's + * other "one socket-buffer's worth" constants ({@code ConnectionScratch.RELAY_BUFFER_SIZE}, + * {@code BufferedByteSource.DEFAULT_BUFFER_SIZE}) rather than introducing an uncalibrated + */ + public static final int INLINE_BODY_THRESHOLD = 8192; + + /** + * {@code multipart/form-data} body. Without this bound, a peer can send an unbounded number + * of minimal parts — each cheap individually but forcing unbounded growth of the parser's + * {@code scanned} list and unbounded per-part header-parsing work, the multipart analogue of + * {@link #MAX_CHUNKS_PER_BODY}. + */ + public static final int MAX_MULTIPART_PARTS = 1_000; + + /** + * {@code Content-Type}, …) accepted per multipart part. Real clients send at most two or + * three; without a bound a peer could send an effectively unlimited number before the blank + * line that ends a part's header block, forcing unbounded {@code HashMap} growth per part. + */ + public static final int MAX_MULTIPART_PART_HEADER_COUNT = 20; + + /** + * header block. {@code Multipart.readLine} otherwise has no bound of its own to fall back + * on — unlike the top-level HTTP headers (bounded by {@link #MAX_HEADER_VALUE_LENGTH} in + * {@code RequestParser}), a line here with no {@code \r\n} would grow its {@code StringBuilder} + * without limit for as long as the peer keeps streaming bytes. + */ + public static final int MAX_MULTIPART_HEADER_LINE_LENGTH = 8_192; + + /** + * buffers eagerly into a {@code byte[]} — text fields (always buffered) and, during a full + * {@code parts()}/{@code parts(String)} scan, file bodies too. {@link #MAX_CONTENT_LENGTH} + * bounds the whole request body, but at 4 GiB (and effectively unbounded for a chunked body, + * see {@link #MAX_CHUNKS_PER_BODY} × {@link #MAX_CHUNK_SIZE}) it does nothing to stop a + * single part from exhausting the heap on its own — this is the bound that actually protects + * {@code ByteArrayOutputStream}-style eager buffering. Deliberately does not apply to + * {@code Part.materialize()} on a streaming file part returned by {@code Multipart.file()} — + * that call is documented as an explicit, opt-in heap allocation the caller chooses to pay for. + */ + public static final long MAX_MULTIPART_BUFFERED_PART_SIZE = 10L * 1024 * 1024; + + /** + * Maximum combined size, in bytes, of every response header's name + value bytes + * ({@code Response.header(...)}'s growable {@code headerRegion}). Unlike every other bound in + * this class, this one guards against a bug in Flash's own caller rather than a + * hostile peer — a handler that calls {@code header(...)} in an unbounded loop (e.g. echoing + * an unbounded collection into headers) would otherwise grow this connection's scratch region + * without limit for the rest of its lifetime, since it is never shrunk back down between + */ + public static final int MAX_RESPONSE_HEADER_BYTES = 65_536; + + /** + * Maximum number of {@code Response.header(...)} calls (any overload) accepted on a single + * response. Same rationale as {@link #MAX_RESPONSE_HEADER_BYTES}: bounds the response-side + * analogue of {@link #MAX_HEADER_COUNT}, since an unbounded call count grows the header index + * arrays even if each individual header is small. + */ + public static final int MAX_RESPONSE_HEADER_COUNT = 1_000; +} diff --git a/flash/src/main/java/dev/relism/flash/http/HttpStatus.java b/flash/src/main/java/dev/relism/flash/http/HttpStatus.java index 58bafba..3e846f8 100644 --- a/flash/src/main/java/dev/relism/flash/http/HttpStatus.java +++ b/flash/src/main/java/dev/relism/flash/http/HttpStatus.java @@ -1,101 +1,164 @@ package dev.relism.flash.http; +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.hpack.HpackEncoder; import java.nio.charset.StandardCharsets; -import java.util.List; /** - * Pre-compiled byte representations of standard HTTP status lines. - * Uses a direct-access array for O(1) lookup with zero allocation. + * Pre-compiled byte representations of standard HTTP status lines. Uses a direct-access array for + * O(1) lookup with zero allocation. */ public enum HttpStatus { - // 1xx - CONTINUE (100, "Continue"), - SWITCHING_PROTOCOLS (101, "Switching Protocols"), + // 1xx + CONTINUE(100, "Continue"), + SWITCHING_PROTOCOLS(101, "Switching Protocols"), - // 2xx - OK (200, "OK"), - CREATED (201, "Created"), - ACCEPTED (202, "Accepted"), - NO_CONTENT (204, "No Content"), - PARTIAL_CONTENT (206, "Partial Content"), + // 2xx + OK(200, "OK"), + CREATED(201, "Created"), + ACCEPTED(202, "Accepted"), + NO_CONTENT(204, "No Content"), + PARTIAL_CONTENT(206, "Partial Content"), - // 3xx - MOVED_PERMANENTLY (301, "Moved Permanently"), - FOUND (302, "Found"), - NOT_MODIFIED (304, "Not Modified"), - TEMPORARY_REDIRECT (307, "Temporary Redirect"), - PERMANENT_REDIRECT (308, "Permanent Redirect"), + // 3xx + MOVED_PERMANENTLY(301, "Moved Permanently"), + FOUND(302, "Found"), + NOT_MODIFIED(304, "Not Modified"), + TEMPORARY_REDIRECT(307, "Temporary Redirect"), + PERMANENT_REDIRECT(308, "Permanent Redirect"), - // 4xx - BAD_REQUEST (400, "Bad Request"), - UNAUTHORIZED (401, "Unauthorized"), - FORBIDDEN (403, "Forbidden"), - NOT_FOUND (404, "Not Found"), - METHOD_NOT_ALLOWED (405, "Method Not Allowed"), - NOT_ACCEPTABLE (406, "Not Acceptable"), - CONFLICT (409, "Conflict"), - GONE (410, "Gone"), - LENGTH_REQUIRED (411, "Length Required"), - PAYLOAD_TOO_LARGE (413, "Payload Too Large"), - URI_TOO_LONG (414, "URI Too Long"), - UNSUPPORTED_MEDIA_TYPE (415, "Unsupported Media Type"), - UNPROCESSABLE_ENTITY (422, "Unprocessable Entity"), - TOO_MANY_REQUESTS (429, "Too Many Requests"), + // 4xx + BAD_REQUEST(400, "Bad Request"), + UNAUTHORIZED(401, "Unauthorized"), + FORBIDDEN(403, "Forbidden"), + NOT_FOUND(404, "Not Found"), + METHOD_NOT_ALLOWED(405, "Method Not Allowed"), + NOT_ACCEPTABLE(406, "Not Acceptable"), + CONFLICT(409, "Conflict"), + GONE(410, "Gone"), + LENGTH_REQUIRED(411, "Length Required"), + PRECONDITION_FAILED(412, "Precondition Failed"), + PAYLOAD_TOO_LARGE(413, "Payload Too Large"), + URI_TOO_LONG(414, "URI Too Long"), + UNSUPPORTED_MEDIA_TYPE(415, "Unsupported Media Type"), + RANGE_NOT_SATISFIABLE(416, "Range Not Satisfiable"), + EXPECTATION_FAILED(417, "Expectation Failed"), + MISDIRECTED_REQUEST(421, "Misdirected Request"), + UNPROCESSABLE_ENTITY(422, "Unprocessable Entity"), + TOO_MANY_REQUESTS(429, "Too Many Requests"), + REQUEST_HEADER_FIELDS_TOO_LARGE(431, "Request Header Fields Too Large"), - // 5xx - INTERNAL_SERVER_ERROR (500, "Internal Server Error"), - NOT_IMPLEMENTED (501, "Not Implemented"), - BAD_GATEWAY (502, "Bad Gateway"), - SERVICE_UNAVAILABLE (503, "Service Unavailable"), - GATEWAY_TIMEOUT (504, "Gateway Timeout"); + // 5xx + INTERNAL_SERVER_ERROR(500, "Internal Server Error"), + NOT_IMPLEMENTED(501, "Not Implemented"), + BAD_GATEWAY(502, "Bad Gateway"), + SERVICE_UNAVAILABLE(503, "Service Unavailable"), + GATEWAY_TIMEOUT(504, "Gateway Timeout"), + HTTP_VERSION_NOT_SUPPORTED(505, "HTTP Version Not Supported"), + INSUFFICIENT_STORAGE(507, "Insufficient Storage"), + NETWORK_AUTHENTICATION_REQUIRED(511, "Network Authentication Required"); - private static final int MAX_STATUS_CODE = 504; - private static final byte[][] INDEX = new byte[MAX_STATUS_CODE + 1][]; - private static final String[] REASONS = new String[MAX_STATUS_CODE + 1]; + // ArrayIndexOutOfBoundsException from this static initializer the moment any constant + // above it (421, 431, 505, 507, 511 — several of which HTTP/2 needs, see MISDIRECTED_REQUEST + // and REQUEST_HEADER_FIELDS_TOO_LARGE above) was added. Computed from values() instead, so + // adding a status code can never silently break class loading again. + private static final int MAX_STATUS_CODE; + private static final byte[][] INDEX; + private static final byte[][] HPACK_INDEX; + private static final String[] REASONS; - static { - for (HttpStatus s : values()) { - INDEX[s.code] = s.bytes; - REASONS[s.code] = s.reason; - } + static { + int max = 0; + for (HttpStatus s : values()) max = Math.max(max, s.code); + MAX_STATUS_CODE = max; + INDEX = new byte[MAX_STATUS_CODE + 1][]; + HPACK_INDEX = new byte[MAX_STATUS_CODE + 1][]; + REASONS = new String[MAX_STATUS_CODE + 1]; + for (HttpStatus s : values()) { + INDEX[s.code] = s.bytes; + HPACK_INDEX[s.code] = s.hpackBytes; + REASONS[s.code] = s.reason; } + } - private final int code; - private final String reason; - private final byte[] bytes; + private final int code; + private final String reason; + private final byte[] bytes; + private final byte[] hpackBytes; - HttpStatus(int code, String reason) { - this.code = code; - this.reason = reason; - this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8); + HttpStatus(int code, String reason) { + this.code = code; + this.reason = reason; + this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8); + this.hpackBytes = encodeHpack(code); + } + + /** Numeric status code (e.g. {@code 200}). */ + public int code() { + return code; + } + + /** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */ + public byte[] bytes() { + return bytes; + } + + /** Precompiled HPACK representation of {@code :status}. */ + public byte[] hpackBytes() { + return hpackBytes; + } + + /** Reason phrase (e.g. {@code "OK"}). */ + public String reason() { + return reason; + } + + /** + * Returns pre-compiled status bytes for the given code. Access is O(1) and generates zero + * garbage. + */ + public static byte[] bytesForCode(int code) { + if (code >= 0 && code <= MAX_STATUS_CODE) { + return INDEX[code]; } + return null; + } - /** Numeric status code (e.g. {@code 200}). */ - public int code() { return code; } - - /** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */ - public byte[] bytes() { return bytes; } - - /** Reason phrase (e.g. {@code "OK"}). */ - public String reason() { return reason; } - - /** - * Returns pre-compiled status bytes for the given code. - * Access is O(1) and generates zero garbage. - */ - public static byte[] bytesForCode(int code) { - if (code >= 0 && code <= MAX_STATUS_CODE) { - return INDEX[code]; - } - return null; + /** Returns reason phrase for the given code, or null if unknown. */ + public static String reasonForCode(int code) { + if (code >= 0 && code <= MAX_STATUS_CODE) { + return REASONS[code]; } + return null; + } - /** Returns reason phrase for the given code, or null if unknown. */ - public static String reasonForCode(int code) { - if (code >= 0 && code <= MAX_STATUS_CODE) { - return REASONS[code]; - } - return null; + /** Returns the precompiled HPACK status field for a known code, or {@code null}. */ + public static byte[] hpackBytesForCode(int code) { + return code >= 0 && code <= MAX_STATUS_CODE ? HPACK_INDEX[code] : null; + } + + private static byte[] encodeHpack(int code) { + int staticIndex = + switch (code) { + case 200 -> 8; + case 204 -> 9; + case 206 -> 10; + case 304 -> 11; + case 400 -> 12; + case 404 -> 13; + case 500 -> 14; + default -> 0; + }; + ByteWriter out = new ByteWriter(8); + if (staticIndex != 0) { + HpackEncoder.writeIndexed(out, staticIndex); + } else { + byte[] value = { + (byte) ('0' + code / 100), (byte) ('0' + code / 10 % 10), (byte) ('0' + code % 10) + }; + HpackEncoder.writeLiteralWithNameIndex(out, 8, value, true); } -} \ No newline at end of file + return java.util.Arrays.copyOf(out.array(), out.length()); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http1/Http1Connection.java b/flash/src/main/java/dev/relism/flash/http1/Http1Connection.java new file mode 100644 index 0000000..2d70da1 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http1/Http1Connection.java @@ -0,0 +1,141 @@ +package dev.relism.flash.http1; + +import dev.relism.flash.RequestParser; +import dev.relism.flash.exceptions.MalformedRequestException; +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestHandler; +import dev.relism.flash.models.Response; +import dev.relism.flash.transport.BufferedByteSource; +import dev.relism.flash.transport.ConnectionContext; +import dev.relism.flash.transport.ConnectionProtocol; +import dev.relism.flash.websocket.WebSocketHandler; +import dev.relism.flash.websocket.WebSocketLoop; +import dev.relism.flash.websocket.WebSocketSession; +import dev.relism.flash.websocket.WebSocketUpgrade; + +import java.io.IOException; +import java.io.OutputStream; +import java.net.SocketTimeoutException; + +/** + * The HTTP/1.1 keep-alive request loop: parse → route → handle → respond, repeated until the + * connection closes. Sole responsibility: drive that loop for one connection; parsing lives in + * {@link RequestParser}, serialization in {@link Http1ResponseWriter}, and the WebSocket upgrade + * path hands off to {@link WebSocketUpgrade}/{@link WebSocketLoop} entirely — once a connection + * upgrades, this class has nothing further to do with it. + */ +public final class Http1Connection implements ConnectionProtocol { + + @Override + public void run(ConnectionContext ctx) throws IOException { + RequestParser parser = new RequestParser( + ctx.configuration().getMaxHeaderBufferSize(), + ctx.remoteAddress(), + ctx.sslSocket()); + + BufferedByteSource in = ctx.in(); + OutputStream out = ctx.out(); + byte[] idleProbe = new byte[1]; + + // reused across every request on this connection — see AbstractRouter#newScratch. + Object routeScratch = ctx.router().newScratch(); + Object wsRouteScratch = ctx.wsRouter().newScratch(); + + Response pooledResponse = new Response(200, ContentType.TEXT_PLAIN); + + while (!ctx.stopped().getAsBoolean()) { + // idle-keep-alive timeout — sitting idle between keep-alive requests is normal, not + // an attack. Skipped when the parser already has bytes buffered from a previous + // read (HTTP pipelining): the next request has, by definition, already started, so + // waiting on the *source* for a fresh byte would wait for something that already + // arrived and is sitting in the parser's own buffer. + if (!parser.hasBufferedBytes()) { + in.setDeadline(System.nanoTime() + ctx.configuration().getIdleKeepAliveTimeoutMs() * 1_000_000L); + int firstByteSeen; + try { + firstByteSeen = in.peek(idleProbe, 0, 1); + } catch (SocketTimeoutException e) { + break; // idle timeout — nothing pending; close quietly, like EOF + } + if (firstByteSeen <= 0) break; // clean EOF + } + + // Bytes have started arriving: tighten to the slowloris-specific bound for the rest + // of the header block. + in.setDeadline(System.nanoTime() + ctx.configuration().getHeaderReadTimeoutMs() * 1_000_000L); + Request request; + try { + request = parser.parse(in); + } catch (MalformedRequestException e) { + // through a handler or the user's exception handler — and the connection is + // always closed afterwards, never kept alive. + Response rejection = new Response(e.status(), e.getMessage(), ContentType.TEXT_PLAIN); + Http1ResponseWriter.writeResponse(out, rejection, null, false, ctx.configuration().isSendDate(), ctx.scratch()); + break; + } catch (SocketTimeoutException e) { + break; // header-read deadline exceeded — close + } + if (request == null) break; + + if (request.method() == HttpMethod.GET && WebSocketUpgrade.isWebSocketUpgrade(request)) { + in.clearDeadline(); // the WS session loop is long-lived; it paces itself + WebSocketHandler wsHandler = ctx.wsRouter().route(request, wsRouteScratch); + if (wsHandler == null) { + out.write(WebSocketUpgrade.REJECT_400); + out.flush(); + break; + } + // Flush buffered HTTP bytes (the 101 response) before WebSocketSession takes + // over rawOut — otherwise the handshake reply stays stuck in the buffered + // stream and the client never sees it. + WebSocketUpgrade.performHandshake(out, request, ctx.scratch()); + out.flush(); + request.drain(); + WebSocketSession session = new WebSocketSession( + in, ctx.rawOut(), ctx.configuration().getWsFrameBufferSize(), request, false); + WebSocketLoop.run(session, wsHandler); + return; + } + + // Headers are fully read; the body (if any) may still be pending — whether the + // handler consumes it or the automatic drain() below does, bound it by the same + // deadline. + in.setDeadline(System.nanoTime() + ctx.configuration().getBodyReadTimeoutMs() * 1_000_000L); + + boolean keepAlive = Http1KeepAlive.isKeepAlive(request); + Response response = pooledResponse.reset(200, ContentType.TEXT_PLAIN); + + RequestHandler handler = ctx.router().route(request, routeScratch); + if (handler == null) handler = ctx.router().getNotFoundHandler(); + + try { + Object result = handler.handle(request, response); + if (result instanceof Response r) response = r; + else if (result != null) response.setBody(result); + } catch (Exception ex) { + Object result = ctx.router().getExceptionHandler().handle(ex, request, response); + if (result instanceof Response r) response = r; + else if (result != null) response.setBody(result); + } + + // this handler was running (the common case: draining connections mid-request) must + // still force this response to Connection: close, not whatever was decided before + // the handler ran. + boolean actuallyKeepAlive = keepAlive && !ctx.stopped().getAsBoolean(); + Http1ResponseWriter.writeResponse(out, response, request.method(), actuallyKeepAlive, + ctx.configuration().isSendDate(), ctx.scratch()); + request.drain(); + // dropped, if the connection closes) — poison them in dev mode so any reference the + // handler improperly retained (a captured field, an async callback) fails loudly on + // its next access instead of silently reading whatever comes next. Only the pooled + // Response is recycled: if the handler returned a different instance, that object was + // never pooled in the first place and owes nothing back to this connection. + request.recycle(); + if (response == pooledResponse) pooledResponse.recycle(); + in.clearDeadline(); + if (!actuallyKeepAlive) break; + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/http1/Http1KeepAlive.java b/flash/src/main/java/dev/relism/flash/http1/Http1KeepAlive.java new file mode 100644 index 0000000..f6765e7 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http1/Http1KeepAlive.java @@ -0,0 +1,70 @@ +package dev.relism.flash.http1; + +import dev.relism.flash.models.Request; +import dev.relism.fpr.core.ByteView; + +/** + * HTTP/1.1 keep-alive decision (RFC 9110 §7.6.1) and the shared {@code Connection} header + * token-list scanner both it and WebSocket upgrade detection need. + * + * (e.g. {@code "Connection: keep-alive, Upgrade"}), not a single value — a whole-value compare + * against {@code "close"} misses exactly that case. {@link #tokenListContains} is the one + * scanner both this class's {@link #isKeepAlive} and {@code WebSocketUpgrade}'s + * {@code Connection: Upgrade} check use, so the two can never drift apart again. + */ +public final class Http1KeepAlive { + + private Http1KeepAlive() { + } + + /** + * Whether the connection should remain open after this response. HTTP/1.1 defaults to + * keep-alive unless {@code Connection} lists {@code close}; HTTP/1.0 defaults to close + * unless it lists {@code keep-alive}. + */ + public static boolean isKeepAlive(Request request) { + if (connectionContainsToken(request, "close")) return false; + ByteView protocol = request.getRequestLine().getProtocol(); + int plen = protocol.length(); + if (plen == 8) { + byte minor = protocol.byteAt(7); + if (minor == '1') return true; + if (minor == '0') return connectionContainsToken(request, "keep-alive"); + } + return false; + } + + /** Whether the request's {@code Connection} header lists {@code token} (case-insensitive). */ + public static boolean connectionContainsToken(Request request, String token) { + ByteView conn = request.getRequestLine().getHeaders().view("Connection"); + if (conn == null) return false; + return tokenListContains(conn, token); + } + + /** Scans a comma-separated token list for {@code token} (case-insensitive, OWS-tolerant). */ + public static boolean tokenListContains(ByteView view, String token) { + int len = view.length(), i = 0; + while (i < len) { + while (i < len && view.byteAt(i) == ' ') i++; + int start = i; + while (i < len && view.byteAt(i) != ',') i++; + if (tokenEqualsIgnoreCase(view, start, i, token)) return true; + i++; + } + return false; + } + + /** Case-insensitive compare of {@code view[start, end)}, trimming trailing spaces, against {@code token}. */ + public static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) { + int tlen = token.length(); + int wlen = end - start; + while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--; + if (wlen != tlen) return false; + for (int i = 0; i < tlen; i++) { + byte b = view.byteAt(start + i); + if (b >= 'A' && b <= 'Z') b += 32; + if (b != (byte) token.charAt(i)) return false; + } + return true; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http1/Http1ResponseWriter.java b/flash/src/main/java/dev/relism/flash/http1/Http1ResponseWriter.java new file mode 100644 index 0000000..305aac2 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http1/Http1ResponseWriter.java @@ -0,0 +1,238 @@ +package dev.relism.flash.http1; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http.DateHeader; +import dev.relism.flash.http.Http1Limits; +import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.http.HttpStatus; +import dev.relism.flash.models.Response; +import dev.relism.flash.transport.ConnectionScratch; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; + +/** + * Serializes a {@link Response} as an HTTP/1.1 message. Sole responsibility: response + * serialization — routing, handler dispatch, and the request loop live in + * {@link Http1Connection}. + * + * The status line, {@code Content-Type}, {@code Date}, every custom header, and + * {@code Content-Length}/{@code Connection} are all serialized into + * {@link ConnectionScratch#responseHead} (a reused {@link ByteWriter}) before a single + * {@code OutputStream.write} call — not one small {@code write} per field, and no + * {@link java.io.BufferedOutputStream} coalescing them at the stream layer (this class removes + * the need for one entirely on the h1 response path). A body at or below + * {@link Http1Limits#INLINE_BODY_THRESHOLD} is copied into the same scratch buffer and goes out + * in that same syscall; a larger body is written separately right after, since copying it into + * the head buffer first would cost an extra full-body memcpy the syscall it saves does not pay + * for. Streaming/chunked bodies write the head, then relay their own bytes as they arrive — by + * definition unknown or too large to fold into one buffer up front. + */ +public final class Http1ResponseWriter { + + private Http1ResponseWriter() { + } + + private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8); + private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8); + private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8); + private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8); + private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8); + private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8); + private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8); + private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8); + private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8); + + /** + * Writes {@code response} to {@code out} as a complete HTTP/1.1 message. + * + * @param method the request method — {@code null} is treated as "not HEAD" (used for + * parser-rejection responses, which never reach a handler and so have no + * associated method) + * @param sendDate whether to include the {@code Date} header ({@code FlashConfiguration#isSendDate()}) + */ + public static void writeResponse(OutputStream out, Response response, HttpMethod method, + boolean keepAlive, boolean sendDate, ConnectionScratch scratch) throws IOException { + int statusCode = response.getStatusCode(); + // RFC 9110 §8.6/§15: 204, 304 and all 1xx responses MUST NOT carry Content-Length or a + // still reports the Content-Length GET would have, but never writes body bytes. + boolean noContentAllowed = statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200); + boolean suppressBody = noContentAllowed || method == HttpMethod.HEAD; + + ByteWriter head = scratch.responseHead; + head.reset(); + head.writeBytes(HTTP_1_1); + byte[] statusBytes = response.getStatusBytes(); + if (statusBytes != null) head.writeBytes(statusBytes); + else writeStatusPhrase(head, statusCode); + head.writeBytes(CRLF); + + // "Content-Type: \r\n" — a header with no value. Skip the line entirely instead. + byte[] contentType = response.getContentType(); + if (contentType != null && contentType.length > 0) { + head.writeBytes(CONTENT_TYPE); + head.writeBytes(contentType); + head.writeBytes(CRLF); + } + + // one write into the scratch, never a per-response format call. + if (sendDate) head.writeBytes(DateHeader.bytes()); + + response.writeHeadersInto(head); + + if (response.hasTrailers()) { + writeTrailerBody(out, head, response, keepAlive, suppressBody, scratch); + } else if (response.isStreaming()) { + writeStreamingBody(out, head, response, keepAlive, noContentAllowed, suppressBody, scratch); + } else { + byte[] body = response.getBody(); + int len = body != null ? body.length : 0; + if (!noContentAllowed) { + head.writeBytes(CONTENT_LENGTH); + head.writeDecimal(len); + head.writeBytes(CRLF); + } + head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE); + head.writeBytes(CRLF); + + // the body itself. + boolean writeBody = body != null && !suppressBody; + if (writeBody && len <= Http1Limits.INLINE_BODY_THRESHOLD) { + // one syscall. + head.writeBytes(body); + out.write(head.array(), 0, head.length()); + } else { + out.write(head.array(), 0, head.length()); + if (writeBody) out.write(body); + } + } + out.flush(); + } + + private static void writeTrailerBody(OutputStream out, ByteWriter head, Response response, + boolean keepAlive, boolean suppressBody, + ConnectionScratch scratch) throws IOException { + head.writeBytes(TRANSFER_CHUNKED); + head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE); + head.writeBytes(CRLF); + out.write(head.array(), 0, head.length()); + if (suppressBody) return; + if (response.isStreaming()) { + writeChunkedAndClose(out, response, scratch); + } else { + byte[] body = response.getBody(); + if (body != null && body.length != 0) { + writeHex(out, body.length); + out.write(CRLF); + out.write(body); + out.write(CRLF); + } + writeFinalChunk(out, response); + } + } + + private static void writeStreamingBody(OutputStream out, ByteWriter head, Response response, boolean keepAlive, + boolean noContentAllowed, boolean suppressBody, + ConnectionScratch scratch) throws IOException { + if (!response.isChunked()) { + if (!noContentAllowed) { + head.writeBytes(CONTENT_LENGTH); + head.writeDecimal(response.getStreamLength()); + head.writeBytes(CRLF); + } + head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE); + head.writeBytes(CRLF); + out.write(head.array(), 0, head.length()); + if (!suppressBody) relayAndClose(response.getStream(), out, scratch); + } else { + head.writeBytes(TRANSFER_CHUNKED); + head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE); + head.writeBytes(CRLF); + out.write(head.array(), 0, head.length()); + // A HEAD response still declares the Transfer-Encoding GET would have used (RFC + // 9110 §9.3.2) but writes zero body bytes — not even the final-chunk marker, since + // there is no chunk framing at all for a message with no body. + if (!suppressBody) writeChunkedAndClose(out, response, scratch); + } + } + + /** + * Closes the handler's stream on every exit — clean EOF or a write failure partway through + * (e.g. the client disconnected mid-transfer). Without this, a handler whose stream only + * releases a held resource (a pooled backend connection, say) from {@code close()} — not from + * observing EOF on a {@code read()} that a downstream write failure means it never reaches — + * leaks that resource for as long as the JVM takes to finalize it. A well-behaved stream's + * {@code close()} must already be idempotent (Java's own contract for {@link InputStream}), so + * this costs nothing extra on the ordinary clean-EOF path. + */ + private static void relayAndClose(InputStream in, OutputStream out, ConnectionScratch scratch) + throws IOException { + try { + relay(in, out, scratch); + } finally { + in.close(); + } + } + + private static void writeChunkedAndClose(OutputStream out, Response response, ConnectionScratch scratch) + throws IOException { + try { + writeChunked(out, response.getStream(), response, scratch); + } finally { + response.getStream().close(); + } + } + + /** + * Copies {@code in} to {@code out} until EOF, via {@link ConnectionScratch#relayBuffer} + * instead of a fresh {@code byte[]} per call. + */ + private static void relay(InputStream in, OutputStream out, ConnectionScratch scratch) throws IOException { + byte[] buf = scratch.relayBuffer; + int n; + while ((n = in.read(buf)) > 0) out.write(buf, 0, n); + } + + private static void writeStatusPhrase(ByteWriter head, int statusCode) { + byte[] phrase = HttpStatus.bytesForCode(statusCode); + if (phrase != null) head.writeBytes(phrase); + else { head.writeDecimal(statusCode); head.writeBytes(UNKNOWN_STATUS_SUFFIX); } + } + + private static void writeChunked(OutputStream out, InputStream stream, Response response, + ConnectionScratch scratch) throws IOException { + byte[] buf = scratch.relayBuffer; + int n; + while ((n = stream.read(buf)) > 0) { + writeHex(out, n); + out.write(CRLF); + out.write(buf, 0, n); + out.write(CRLF); + } + if (response.hasTrailers()) writeFinalChunk(out, response); + else out.write(FINAL_CHUNK); + } + + private static void writeFinalChunk(OutputStream out, Response response) throws IOException { + out.write('0'); + out.write(CRLF); + response.writeTrailers(out); + out.write(CRLF); + } + + private static void writeHex(OutputStream out, int value) throws IOException { + int shift = 28; + boolean leading = true; + while (shift >= 0) { + int digit = (value >>> shift) & 0xF; + if (digit != 0 || !leading) { + leading = false; + out.write(digit < 10 ? '0' + digit : 'a' + digit - 10); + } + shift -= 4; + } + if (leading) out.write('0'); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2AbuseGuard.java b/flash/src/main/java/dev/relism/flash/http2/Http2AbuseGuard.java new file mode 100644 index 0000000..579acf6 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2AbuseGuard.java @@ -0,0 +1,114 @@ +package dev.relism.flash.http2; + +import dev.relism.flash.extension.FlashConfiguration; + +/** Enforces per-connection HTTP/2 rate limits and lifetime budgets. */ +final class Http2AbuseGuard { + private RollingWindowCounter resetRate; + private RollingWindowCounter streamCreationRate; + private RollingWindowCounter settingsRate; + private RollingWindowCounter pingRate; + private RollingWindowCounter uselessFrameRate; + private int maxResetRate; + private int maxStreamCreationRate; + private long maxStreams; + private long maxBytes; + private long maxLifetimeNanos; + private long startedNanos; + private long wireBytes; + private long streams; + + Http2AbuseGuard() { + configure(FlashConfiguration.builder().build()); + } + + void configure(FlashConfiguration configuration) { + long interval = configuration.getH2AbuseRateIntervalMs(); + if (interval < 2) throw new IllegalArgumentException("h2AbuseRateIntervalMs must be at least 2"); + resetRate = new RollingWindowCounter(interval); + streamCreationRate = new RollingWindowCounter(interval); + settingsRate = new RollingWindowCounter(interval); + pingRate = new RollingWindowCounter(interval); + uselessFrameRate = new RollingWindowCounter(interval); + maxResetRate = + positive( + configuration.getH2MaxResetStreamsPerInterval(), + "h2MaxResetStreamsPerInterval"); + maxStreamCreationRate = + positive( + configuration.getH2MaxStreamsCreatedPerInterval(), + "h2MaxStreamsCreatedPerInterval"); + maxStreams = + nonNegative(configuration.getH2MaxStreamsPerConnection(), "h2MaxStreamsPerConnection"); + maxBytes = + nonNegative(configuration.getH2MaxBytesPerConnection(), "h2MaxBytesPerConnection"); + long lifetime = + nonNegative( + configuration.getH2MaxConnectionLifetimeMs(), "h2MaxConnectionLifetimeMs"); + maxLifetimeNanos = toNanos(lifetime); + } + + void start() { + startedNanos = System.nanoTime(); + } + + void receivedFrame(int payloadLength) { + wireBytes += 9L + payloadLength; + checkBudgets(); + } + + void streamCreated() { + if (streamCreationRate.incrementExceeded(maxStreamCreationRate)) { + calm("stream creation rate"); + } + streams++; + if (maxStreams > 0 && streams > maxStreams) calm("connection stream budget"); + } + + void resetReceived() { + if (resetRate.incrementExceeded(maxResetRate)) calm("RST_STREAM rate"); + } + + void settingsReceived() { + if (settingsRate.incrementExceeded(Http2Limits.MAX_SETTINGS_PER_INTERVAL)) { + calm("SETTINGS rate"); + } + } + + void pingReceived() { + if (pingRate.incrementExceeded(Http2Limits.MAX_PINGS_PER_INTERVAL)) calm("PING rate"); + } + + void uselessFrameReceived() { + if (uselessFrameRate.incrementExceeded(Http2Limits.MAX_USELESS_FRAMES_PER_INTERVAL)) { + calm("non-progress frame rate"); + } + } + + void checkBudgets() { + if (maxBytes > 0 && wireBytes > maxBytes) calm("connection byte budget"); + if (maxLifetimeNanos > 0 && System.nanoTime() - startedNanos > maxLifetimeNanos) { + calm("connection lifetime budget"); + } + } + + private static int positive(int value, String name) { + if (value <= 0) throw new IllegalArgumentException(name + " must be positive"); + return value; + } + + private static long nonNegative(long value, String name) { + if (value < 0) throw new IllegalArgumentException(name + " must not be negative"); + return value; + } + + private static long toNanos(long milliseconds) { + return milliseconds > Long.MAX_VALUE / 1_000_000L + ? Long.MAX_VALUE + : milliseconds * 1_000_000L; + } + + private static void calm(String reason) { + throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, reason + " exceeded"); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2Authority.java b/flash/src/main/java/dev/relism/flash/http2/Http2Authority.java new file mode 100644 index 0000000..e0e4210 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2Authority.java @@ -0,0 +1,54 @@ +package dev.relism.flash.http2; + +import java.security.cert.Certificate; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.Collection; +import java.util.List; +import java.util.Locale; +import javax.net.ssl.SSLSession; + +/** Validates a coalesced request authority against the certificate selected for its connection. */ +final class Http2Authority { + private Http2Authority() {} + + static boolean isServed(String authority, SSLSession session) { + if (session == null || authority == null) return true; + String host = host(authority); + try { + Certificate[] certificates = session.getLocalCertificates(); + if (certificates == null || certificates.length == 0 + || !(certificates[0] instanceof X509Certificate certificate)) { + return true; + } + Collection> names = certificate.getSubjectAlternativeNames(); + if (names == null) return true; + for (List name : names) { + int type = (Integer) name.get(0); + if ((type == 2 || type == 7) && matches(host, name.get(1).toString())) return true; + } + return false; + } catch (CertificateParsingException failure) { + return true; + } + } + + static boolean matches(String authority, String certificateName) { + String host = host(authority).toLowerCase(Locale.ROOT); + String name = certificateName.toLowerCase(Locale.ROOT); + if (!name.startsWith("*.")) return host.equals(name); + String suffix = name.substring(1); + if (!host.endsWith(suffix)) return false; + int prefixLength = host.length() - suffix.length(); + return prefixLength > 0 && host.indexOf('.') == prefixLength; + } + + private static String host(String authority) { + if (authority.startsWith("[")) { + int closing = authority.indexOf(']'); + return closing < 0 ? authority : authority.substring(1, closing); + } + int colon = authority.lastIndexOf(':'); + return colon > 0 && authority.indexOf(':') == colon ? authority.substring(0, colon) : authority; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2Connection.java b/flash/src/main/java/dev/relism/flash/http2/Http2Connection.java new file mode 100644 index 0000000..ba58d76 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2Connection.java @@ -0,0 +1,660 @@ +package dev.relism.flash.http2; + +import dev.relism.flash.bytes.Pairs; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.http2.Http2ConnectionScratch.ControlIntent; +import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameHeader; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.frame.FrameValidator; +import dev.relism.flash.http2.frame.Http2FrameReader; +import dev.relism.flash.http2.frame.Http2FrameWriter; +import dev.relism.flash.http2.frame.Padding; +import dev.relism.flash.http2.hpack.HeaderSink; +import dev.relism.flash.http2.message.DataBufferPool; +import dev.relism.flash.http2.stream.Http2FlowController; +import dev.relism.flash.http2.stream.Http2Stream; +import dev.relism.flash.http2.stream.Http2StreamState; +import dev.relism.flash.http2.stream.Http2StreamTable; +import dev.relism.flash.transport.BufferedByteSource; +import dev.relism.flash.transport.ConnectionContext; +import dev.relism.flash.transport.ConnectionProtocol; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.util.function.BooleanSupplier; +import lombok.extern.slf4j.Slf4j; + +/** + * Owns one HTTP/2 connection's demultiplexing and connection-level protocol state. The demux loop + * never invokes application code and never waits for a handler or body consumer; stream dispatch is + * handed to independent virtual threads by the stream layer. + */ +@Slf4j +public final class Http2Connection implements ConnectionProtocol { + private static final byte[] SHUTDOWN_PING = { + (byte) 0x46, (byte) 0x4c, (byte) 0x41, (byte) 0x53, + (byte) 0x48, (byte) 0x47, (byte) 0x4f, (byte) 0x21 + }; + + private final Http2Settings peerSettings = new Http2Settings(); + private final Http2ConnectionScratch scratch = new Http2ConnectionScratch(); + private final Http2Settings.StreamWindowUpdater streamWindows; + private final long settingsAckTimeoutMs; + private final Http2HeaderBlockDecoder headerBlocks = new Http2HeaderBlockDecoder(); + private final DataBufferPool dataBuffers = + new DataBufferPool(Http2Limits.MAX_FRAME_SIZE_LOCAL, Http2Limits.DATA_BUFFER_POOL_SIZE); + private final Http2StreamTable streams = + new Http2StreamTable(Http2Limits.MAX_CONCURRENT_STREAMS, dataBuffers); + private static final HeaderSink DISCARD_HEADERS = (name, value, never) -> {}; + + private Http2FlowController flowController; + private int outstandingLocalSettings; + private long oldestSettingsSentNanos; + private int lastProcessedStreamId; + private int peerLastStreamId = Integer.MAX_VALUE; + private int peerErrorCode; + private boolean peerGoAway; + private boolean gracefulStarted; + private boolean gracefulFinished; + private int highestClientStreamId; + private Http2Stream pendingHeaderStream; + private boolean refusingHeaderStream; + private boolean pendingTrailers; + private Http2StreamDispatcher streamDispatcher; + private final Http2Stream[] dispatchQueue = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS]; + private int dispatchCount; + private final Http2AbuseGuard abuse = new Http2AbuseGuard(); + private long streamIdleTimeoutNanos = Http2Limits.STREAM_IDLE_TIMEOUT_MS * 1_000_000L; + private final Http2Stream[] idleSweep = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS]; + + public Http2Connection() { + this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS); + } + + public Http2Connection(Http2Settings.StreamWindowUpdater streamWindows) { + this(streamWindows, Http2Limits.SETTINGS_ACK_TIMEOUT_MS); + } + + Http2Connection(Http2Settings.StreamWindowUpdater streamWindows, long settingsAckTimeoutMs) { + this.streamWindows = + delta -> { + try { + if (flowController == null) streams.adjustAllSendWindows(delta); + else flowController.applyInitialWindowDelta(streams, delta); + } catch (IllegalStateException overflow) { + throw Http2Exception.FLOW_CONTROL_ERROR; + } + streamWindows.applyInitialWindowDelta(delta); + }; + this.settingsAckTimeoutMs = settingsAckTimeoutMs; + } + + @Override + public void run(ConnectionContext ctx) throws IOException { + configure(ctx.configuration()); + Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write); + flowController = + new Http2FlowController( + (streamId, increment) -> sendWindowUpdate(writer, streamId, increment)); + streamDispatcher = + new Http2StreamDispatcher( + ctx, + writer, + peerSettings, + streams, + flowController, + (streamId, error) -> sendRstStream(writer, streamId, error)); + try { + run(ctx.in(), writer, ctx.stopped()); + } finally { + writer.close(); + } + } + + void run(BufferedByteSource input, Http2FrameWriter writer, BooleanSupplier stopped) + throws IOException { + if (flowController == null) { + flowController = + new Http2FlowController( + (streamId, increment) -> sendWindowUpdate(writer, streamId, increment)); + } + Http2FrameReader reader = new Http2FrameReader(input); + runPrepared(input, reader, writer, stopped); + } + + /** Runs with connection collaborators that were allocated during connection setup. */ + void runPrepared( + BufferedByteSource input, + Http2FrameReader reader, + Http2FrameWriter writer, + BooleanSupplier stopped) + throws IOException { + PrefaceResult preface = verifyPreface(input); + if (preface == PrefaceResult.TRUNCATED) return; + if (preface == PrefaceResult.INVALID) { + sendGoAway(writer, 0, Http2ErrorCode.PROTOCOL_ERROR, "invalid client preface"); + writer.drain(); + return; + } + abuse.start(); + + sendConstant(writer, Http2Preface.serverSettings()); + sendConstant(writer, Http2Preface.initialConnectionWindow()); + outstandingLocalSettings = 1; + oldestSettingsSentNanos = System.nanoTime(); + + boolean firstFrame = true; + try { + while (!gracefulFinished && !peerGoAway) { + abuse.checkBudgets(); + closeIdleStreams(writer); + if (stopped.getAsBoolean() && !gracefulStarted) startGracefulShutdown(writer); + FrameHeader frame; + try { + frame = reader.readFrame(Math.min(100, nextReadTimeoutMs())); + } catch (SocketTimeoutException timeout) { + checkSettingsTimeout(); + headerBlocks.checkTimeout(); + if (stopped.getAsBoolean() && !gracefulStarted) { + startGracefulShutdown(writer); + continue; + } + if (reader.frameDeadlineExpired()) throw timeout; + continue; + } + if (frame == null) break; + abuse.receivedFrame(frame.length()); + try { + headerBlocks.checkTimeout(); + FrameValidator.validate(frame, headerBlocks.insideHeaderBlock()); + if (headerBlocks.insideHeaderBlock() && frame.type() != FrameType.CONTINUATION) { + throw Http2Exception.PROTOCOL_ERROR; + } + if (firstFrame && frame.type() != FrameType.SETTINGS) { + throw Http2Exception.PROTOCOL_ERROR; + } + if (firstFrame && FrameFlags.isAck(frame.flags()) && frame.length() == 0) { + throw Http2Exception.PROTOCOL_ERROR; + } + firstFrame = false; + dispatch(frame, writer); + } catch (Http2StreamException streamError) { + sendRstStream(writer, streamError); + closeStreamAfterError(streamError.streamId()); + } finally { + reader.consumeFrame(); + } + writer.drain(); + if (dispatchCount > 0 && !reader.hasBufferedInput()) dispatchPendingStreams(); + checkSettingsTimeout(); + } + } catch (Http2Exception connectionError) { + sendGoAway( + writer, lastProcessedStreamId, connectionError.errorCode(), connectionError.getMessage()); + } catch (IOException io) { + throw io; + } catch (RuntimeException unexpected) { + log.error("Unexpected failure in HTTP/2 demux loop", unexpected); + sendGoAway(writer, lastProcessedStreamId, Http2ErrorCode.INTERNAL_ERROR, "internal error"); + } + } + + private PrefaceResult verifyPreface(BufferedByteSource input) throws IOException { + byte[] preface = scratch.prefaceBuffer(); + int read = 0; + input.setDeadline(System.nanoTime() + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L); + try { + while (read < preface.length) { + int n = input.read(preface, read, preface.length - read); + if (n < 0) return PrefaceResult.TRUNCATED; + read += n; + } + return Http2Preface.matchesClientPreface(preface) + ? PrefaceResult.MATCHED + : PrefaceResult.INVALID; + } finally { + input.clearDeadline(); + } + } + + private enum PrefaceResult { + MATCHED, + INVALID, + TRUNCATED + } + + private void dispatch(FrameHeader frame, Http2FrameWriter writer) throws IOException { + FrameType type = frame.type(); + if (type == null) { + abuse.uselessFrameReceived(); + return; + } + switch (type) { + case SETTINGS -> receiveSettings(frame, writer); + case PING -> receivePing(frame, writer); + case WINDOW_UPDATE -> receiveWindowUpdate(frame); + case GOAWAY -> receiveGoAway(frame); + case HEADERS -> receiveHeaders(frame, writer); + case CONTINUATION -> receiveContinuation(frame, writer); + case DATA -> receiveData(frame); + case RST_STREAM -> receiveRstStream(frame); + case PRIORITY -> receivePriority(frame); + default -> {} + } + } + + private void receiveHeaders(FrameHeader frame, Http2FrameWriter writer) throws IOException { + int streamId = frame.streamId(); + if ((streamId & 1) == 0) throw Http2Exception.PROTOCOL_ERROR; + Http2Stream existing = streams.get(streamId); + if (existing != null) { + existing.touch(); + if (existing.state() == Http2StreamState.HALF_CLOSED_REMOTE) { + throw new Http2StreamException( + streamId, Http2ErrorCode.STREAM_CLOSED, "stream is half-closed remotely"); + } + if (!FrameFlags.isEndStream(frame.flags())) { + throw new Http2StreamException( + streamId, Http2ErrorCode.PROTOCOL_ERROR, "trailers require END_STREAM"); + } + pendingHeaderStream = existing; + pendingTrailers = true; + existing.trailerBlock().reset(); + if (headerBlocks.accept(frame, existing.trailerBlock())) completeHeaders(writer, streamId); + return; + } + if (streamId <= highestClientStreamId) { + int closedKind = streams.closedKind(streamId); + if (closedKind == Http2StreamTable.CLOSED_NORMALLY) { + throw Http2Exception.of(Http2ErrorCode.STREAM_CLOSED, "frame on a closed stream"); + } + if (closedKind == Http2StreamTable.CLOSED_BY_RESET) { + throw new Http2StreamException( + streamId, Http2ErrorCode.STREAM_CLOSED, "stream was reset"); + } + throw Http2Exception.PROTOCOL_ERROR; + } + abuse.streamCreated(); + highestClientStreamId = streamId; + pendingTrailers = false; + + pendingHeaderStream = streams.acquire(streamId); + refusingHeaderStream = pendingHeaderStream == null; + if (pendingHeaderStream != null) { + flowController.initializeStreamSendWindow( + pendingHeaderStream, peerSettings.initialWindowSize()); + } + HeaderSink sink = + refusingHeaderStream + ? DISCARD_HEADERS + : (pendingTrailers + ? pendingHeaderStream.trailerBlock() + : pendingHeaderStream.headerBlock()); + if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId()); + } + + private void receiveContinuation(FrameHeader frame, Http2FrameWriter writer) throws IOException { + if (pendingHeaderStream == null && !refusingHeaderStream) { + throw Http2Exception.PROTOCOL_ERROR; + } + HeaderSink sink = + refusingHeaderStream + ? DISCARD_HEADERS + : (pendingTrailers + ? pendingHeaderStream.trailerBlock() + : pendingHeaderStream.headerBlock()); + if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId()); + } + + private void completeHeaders(Http2FrameWriter writer, int streamId) throws IOException { + try { + if (refusingHeaderStream) { + sendRstStream(writer, streamId, Http2ErrorCode.REFUSED_STREAM); + } else { + Http2Stream stream = pendingHeaderStream; + boolean dispatch; + if (pendingTrailers) { + stream.validateTrailers(); + stream.finishRequestBody(); + stream.transition(Http2StreamState.Event.RECV_HEADERS_ES); + dispatch = !stream.dispatched(); + } else { + if (streamDispatcher != null) stream.validateHeaders(); + dispatch = stream.prepareRequestBody(flowController, headerBlocks.endStream()); + stream.transition( + headerBlocks.endStream() + ? Http2StreamState.Event.RECV_HEADERS_ES + : Http2StreamState.Event.RECV_HEADERS); + lastProcessedStreamId = Math.max(lastProcessedStreamId, streamId); + } + if (streamDispatcher == null && !pendingTrailers) { + streams.retire(stream, streamId); + if (!gracefulStarted) startGracefulShutdown(writer); + } else if (dispatch) { + enqueueDispatch(stream); + } else if (stream.responseStarted() && stream.responseWriter().finished() + && !stream.responseInFlight() && stream.state() == Http2StreamState.CLOSED) { + streams.retire(stream, streamId); + } + } + } finally { + pendingHeaderStream = null; + refusingHeaderStream = false; + pendingTrailers = false; + } + } + + private void receivePriority(FrameHeader frame) { + abuse.uselessFrameReceived(); + int dependency = readUInt31(frame.buffer(), frame.payloadOffset()); + if (dependency == frame.streamId()) { + throw new Http2StreamException( + frame.streamId(), Http2ErrorCode.PROTOCOL_ERROR, "stream depends on itself"); + } + } + + private void receiveData(FrameHeader frame) { + if (frame.length() == 0) abuse.uselessFrameReceived(); + flowController.receiveConnectionBytes(frame.length()); + Http2Stream stream = streams.get(frame.streamId()); + if (stream == null) { + discardConnectionBytes(frame.length()); + if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR; + throw new Http2StreamException( + frame.streamId(), Http2ErrorCode.STREAM_CLOSED, "stream is closed"); + } + boolean bodyAccepted = false; + try { + stream.touch(); + stream.transition( + FrameFlags.isEndStream(frame.flags()) + ? Http2StreamState.Event.RECV_DATA_ES + : Http2StreamState.Event.RECV_DATA); + flowController.receiveStreamBytes(stream, frame.length()); + long unpadded = + Padding.unpad( + frame.buffer(), + frame.payloadOffset(), + frame.length(), + FrameFlags.isPadded(frame.flags())); + int dataOffset = Pairs.hi(unpadded); + int dataLength = Pairs.lo(unpadded); + if (frame.length() == 0) { + if (stream.incrementEmptyDataFrames() + > Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM) { + throw new Http2StreamException( + frame.streamId(), Http2ErrorCode.ENHANCE_YOUR_CALM, "empty DATA frame limit exceeded"); + } + } else { + stream.resetEmptyDataFrames(); + } + stream.receiveData(frame.buffer(), dataOffset, dataLength, frame.length()); + bodyAccepted = true; + if (FrameFlags.isEndStream(frame.flags())) { + stream.finishRequestBody(); + if (streamDispatcher != null && !stream.dispatched()) enqueueDispatch(stream); + else if (stream.responseStarted() && stream.responseWriter().finished() + && !stream.responseInFlight() + && stream.state() == Http2StreamState.CLOSED) { + streams.retire(stream, frame.streamId()); + } + } + } catch (RuntimeException failure) { + if (!bodyAccepted) discardConnectionBytes(frame.length()); + throw failure; + } + } + + private void receiveRstStream(FrameHeader frame) { + abuse.resetReceived(); + Http2Stream stream = streams.get(frame.streamId()); + if (stream == null) { + if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR; + return; + } + boolean releaseDeferred = + stream.dispatched() || stream.state() == Http2StreamState.HALF_CLOSED_REMOTE; + stream.transition(Http2StreamState.Event.RECV_RST); + if (!streams.removeIfSame(stream, frame.streamId())) return; + streams.rememberReset(frame.streamId()); + if (releaseDeferred) { + stream.cancel(); + if (stream.responseStarted() && !stream.responseInFlight()) streams.release(stream); + } else { + streams.release(stream); + } + } + + private void enqueueDispatch(Http2Stream stream) { + if (dispatchCount == dispatchQueue.length) { + throw new Http2StreamException( + stream.id(), Http2ErrorCode.REFUSED_STREAM, "dispatch queue is full"); + } + stream.markDispatched(); + dispatchQueue[dispatchCount++] = stream; + } + + private void dispatchPendingStreams() { + int count = dispatchCount; + dispatchCount = 0; + for (int i = 0; i < count; i++) { + Http2Stream stream = dispatchQueue[i]; + dispatchQueue[i] = null; + streamDispatcher.dispatch(stream); + } + } + + private void receiveSettings(FrameHeader frame, Http2FrameWriter writer) throws IOException { + boolean ack = FrameFlags.isAck(frame.flags()); + if (ack) { + if (frame.length() != 0) throw Http2Exception.FRAME_SIZE_ERROR; + if (outstandingLocalSettings == 0) throw Http2Exception.PROTOCOL_ERROR; + outstandingLocalSettings--; + if (outstandingLocalSettings == 0) oldestSettingsSentNanos = 0; + return; + } + abuse.settingsReceived(); + peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), streamWindows); + sendConstant(writer, Http2Preface.settingsAck()); + } + + private void receivePing(FrameHeader frame, Http2FrameWriter writer) throws IOException { + if (FrameFlags.isAck(frame.flags())) { + if (gracefulStarted && matches(frame.buffer(), frame.payloadOffset(), SHUTDOWN_PING)) { + sendGoAway(writer, lastProcessedStreamId, Http2ErrorCode.NO_ERROR, "shutdown complete"); + gracefulFinished = true; + } + return; + } + abuse.pingReceived(); + ControlIntent pong = scratch.acquire(ControlKind.PING); + pong.frame(FrameType.PING, FrameFlags.ACK, 0, frame.buffer(), frame.payloadOffset(), 8); + writer.writePriority(pong); + } + + private void receiveWindowUpdate(FrameHeader frame) { + abuse.uselessFrameReceived(); + int increment = readUInt31(frame.buffer(), frame.payloadOffset()); + if (increment == 0) { + if (frame.streamId() == 0) throw Http2Exception.PROTOCOL_ERROR; + throw new Http2StreamException( + frame.streamId(), Http2ErrorCode.PROTOCOL_ERROR, "zero window increment"); + } + if (frame.streamId() != 0) { + Http2Stream stream = streams.get(frame.streamId()); + if (stream == null) { + if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR; + return; + } + try { + stream.touch(); + flowController.increaseStreamSendWindow(stream, increment); + } catch (IllegalStateException overflow) { + throw new Http2StreamException( + frame.streamId(), Http2ErrorCode.FLOW_CONTROL_ERROR, "stream window overflow"); + } + if (streamDispatcher != null) streamDispatcher.streamWindowUpdated(stream); + return; + } + flowController.increaseConnectionSendWindow(increment); + if (streamDispatcher != null) streamDispatcher.connectionWindowUpdated(); + } + + private void receiveGoAway(FrameHeader frame) { + peerLastStreamId = readUInt31(frame.buffer(), frame.payloadOffset()); + peerErrorCode = readInt(frame.buffer(), frame.payloadOffset() + 4); + peerGoAway = true; + } + + void configure(FlashConfiguration configuration) { + abuse.configure(configuration); + long idle = configuration.getH2StreamIdleTimeoutMs(); + if (idle <= 0) throw new IllegalArgumentException("h2StreamIdleTimeoutMs must be positive"); + streamIdleTimeoutNanos = idle > Long.MAX_VALUE / 1_000_000L + ? Long.MAX_VALUE : idle * 1_000_000L; + } + + private void closeIdleStreams(Http2FrameWriter writer) throws IOException { + int count = streams.copyValues(idleSweep); + long now = System.nanoTime(); + for (int i = 0; i < count; i++) { + Http2Stream stream = idleSweep[i]; + idleSweep[i] = null; + if (!stream.idleExpired(now, streamIdleTimeoutNanos)) continue; + int streamId = stream.id(); + if (!streams.removeIfSame(stream, streamId)) continue; + streams.rememberReset(streamId); + sendRstStream(writer, streamId, Http2ErrorCode.CANCEL); + if (stream.dispatched()) stream.cancel(); + else streams.release(stream); + } + } + + private void startGracefulShutdown(Http2FrameWriter writer) throws IOException { + gracefulStarted = true; + sendGoAway(writer, Integer.MAX_VALUE, Http2ErrorCode.NO_ERROR, "server shutting down"); + ControlIntent ping = scratch.acquire(ControlKind.PING); + ping.frame(FrameType.PING, 0, 0, SHUTDOWN_PING, 0, SHUTDOWN_PING.length); + writer.writePriority(ping); + } + + private void sendRstStream(Http2FrameWriter writer, Http2StreamException error) + throws IOException { + ControlIntent rst = scratch.acquire(ControlKind.SETTINGS_OR_OTHER); + rst.frame(FrameType.RST_STREAM, 0, error.streamId(), error.errorCode().bytes(), 0, 4); + writer.writePriority(rst); + } + + private void sendRstStream(Http2FrameWriter writer, int streamId, Http2ErrorCode error) + throws IOException { + ControlIntent rst = scratch.acquire(ControlKind.SETTINGS_OR_OTHER); + rst.frame(FrameType.RST_STREAM, 0, streamId, error.bytes(), 0, 4); + writer.writePriority(rst); + } + + private void sendWindowUpdate(Http2FrameWriter writer, int streamId, int increment) + throws IOException { + ControlIntent update = scratch.acquire(ControlKind.SETTINGS_OR_OTHER); + update.windowUpdate(streamId, increment); + writer.writePriority(update); + } + + private void discardConnectionBytes(int bytes) { + try { + flowController.discarded(bytes); + } catch (IOException failure) { + throw new IllegalStateException("failed to restore connection flow-control window", failure); + } + } + + private void closeStreamAfterError(int streamId) { + Http2Stream stream = streams.get(streamId); + if (stream == null) return; + if (!streams.removeIfSame(stream, streamId)) return; + streams.rememberReset(streamId); + if (stream.dispatched()) stream.cancel(); + else streams.release(stream); + if (pendingHeaderStream == stream) pendingHeaderStream = null; + } + + private void sendGoAway( + Http2FrameWriter writer, int lastStreamId, Http2ErrorCode error, String debug) + throws IOException { + ControlIntent goAway = scratch.acquire(ControlKind.SETTINGS_OR_OTHER); + goAway.goAway(lastStreamId, error, debug == null ? "" : debug); + writer.writePriority(goAway); + } + + private void sendConstant(Http2FrameWriter writer, byte[] bytes) throws IOException { + ControlIntent intent = scratch.acquire(ControlKind.SETTINGS_OR_OTHER); + intent.copy(bytes); + writer.writePriority(intent); + } + + private long nextReadTimeoutMs() { + if (outstandingLocalSettings == 0) return Http2Limits.FRAME_READ_TIMEOUT_MS; + long elapsed = System.nanoTime() - oldestSettingsSentNanos; + long remainingNanos = settingsAckTimeoutMs * 1_000_000L - elapsed; + if (remainingNanos <= 0) throw Http2Exception.SETTINGS_TIMEOUT; + long remainingMs = Math.max(1, (remainingNanos + 999_999L) / 1_000_000L); + return Math.min(Http2Limits.FRAME_READ_TIMEOUT_MS, remainingMs); + } + + private void checkSettingsTimeout() { + if (outstandingLocalSettings != 0 + && System.nanoTime() - oldestSettingsSentNanos >= settingsAckTimeoutMs * 1_000_000L) { + throw Http2Exception.SETTINGS_TIMEOUT; + } + } + + private static boolean matches(byte[] buf, int off, byte[] expected) { + int different = 0; + for (int i = 0; i < expected.length; i++) different |= buf[off + i] ^ expected[i]; + return different == 0; + } + + private static int readUInt31(byte[] buf, int off) { + return readInt(buf, off) & 0x7FFF_FFFF; + } + + private static int readInt(byte[] buf, int off) { + return ((buf[off] & 0xFF) << 24) + | ((buf[off + 1] & 0xFF) << 16) + | ((buf[off + 2] & 0xFF) << 8) + | (buf[off + 3] & 0xFF); + } + + public Http2Settings peerSettings() { + return peerSettings; + } + + public long connectionSendWindow() { + return flowController == null ? 65_535 : flowController.connectionSendWindow(); + } + + public int peerLastStreamId() { + return peerLastStreamId; + } + + public int peerErrorCode() { + return peerErrorCode; + } + + void reset() { + peerSettings.reset(); + flowController = null; + outstandingLocalSettings = 0; + oldestSettingsSentNanos = 0; + lastProcessedStreamId = 0; + peerLastStreamId = Integer.MAX_VALUE; + peerErrorCode = 0; + peerGoAway = false; + gracefulStarted = false; + gracefulFinished = false; + highestClientStreamId = 0; + pendingHeaderStream = null; + refusingHeaderStream = false; + dispatchCount = 0; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2ConnectionScratch.java b/flash/src/main/java/dev/relism/flash/http2/Http2ConnectionScratch.java new file mode 100644 index 0000000..ff6e049 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2ConnectionScratch.java @@ -0,0 +1,179 @@ +package dev.relism.flash.http2; + +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.frame.WriteIntent; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +/** Reusable control-frame storage owned by one HTTP/2 connection. */ +final class Http2ConnectionScratch { + private static final int CONTROL_SLOT_COUNT = + Http2Limits.MAX_PING_QUEUE_DEPTH + Http2Limits.MAX_SETTINGS_ACK_QUEUE_DEPTH + 8; + private static final int CONTROL_FRAME_CAPACITY = 256; + + private final ControlIntent[] controls = new ControlIntent[CONTROL_SLOT_COUNT]; + private final AtomicInteger pingResponses = new AtomicInteger(); + private final AtomicInteger settingsAcks = new AtomicInteger(); + private final byte[] preface = new byte[Http2Preface.clientPrefaceLength()]; + + Http2ConnectionScratch() { + for (int i = 0; i < controls.length; i++) { + controls[i] = new ControlIntent(this, CONTROL_FRAME_CAPACITY); + } + } + + byte[] prefaceBuffer() { + return preface; + } + + ControlIntent acquire(ControlKind kind) { + AtomicInteger counter = counter(kind); + int limit = limit(kind); + int queued = counter.incrementAndGet(); + if (queued > limit) { + counter.decrementAndGet(); + throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, kind + " queue exhausted"); + } + for (ControlIntent intent : controls) { + if (intent.claim(kind)) return intent; + } + counter.decrementAndGet(); + throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, "control-frame queue exhausted"); + } + + private void release(ControlIntent intent) { + counter(intent.kind).decrementAndGet(); + intent.release(); + } + + private AtomicInteger counter(ControlKind kind) { + return kind == ControlKind.PING ? pingResponses : settingsAcks; + } + + private static int limit(ControlKind kind) { + return kind == ControlKind.PING + ? Http2Limits.MAX_PING_QUEUE_DEPTH + : Http2Limits.MAX_SETTINGS_ACK_QUEUE_DEPTH; + } + + enum ControlKind { + PING, + SETTINGS_OR_OTHER + } + + static final class ControlIntent implements WriteIntent { + private final Http2ConnectionScratch owner; + private final byte[] buffer; + private final AtomicBoolean claimed = new AtomicBoolean(); + private volatile WriteIntent next; + private ControlKind kind; + private int length; + + private ControlIntent(Http2ConnectionScratch owner, int capacity) { + this.owner = owner; + this.buffer = new byte[capacity]; + } + + private boolean claim(ControlKind kind) { + if (!claimed.compareAndSet(false, true)) return false; + this.kind = kind; + this.length = 0; + this.next = null; + return true; + } + + void copy(byte[] source) { + System.arraycopy(source, 0, buffer, 0, source.length); + length = source.length; + } + + void frame(FrameType type, int flags, int streamId, byte[] payload, int off, int len) { + if (9 + len > buffer.length) { + throw new IllegalArgumentException("control frame exceeds scratch capacity"); + } + buffer[0] = (byte) (len >>> 16); + buffer[1] = (byte) (len >>> 8); + buffer[2] = (byte) len; + buffer[3] = (byte) type.code(); + buffer[4] = (byte) flags; + writeUInt31(buffer, 5, streamId); + System.arraycopy(payload, off, buffer, 9, len); + length = 9 + len; + } + + void goAway(int lastStreamId, Http2ErrorCode error, String debug) { + int debugLength = + Math.min( + debug.length(), + Math.min(Http2Limits.MAX_GOAWAY_DEBUG_DATA_LENGTH, buffer.length - 17)); + int payloadLength = 8 + debugLength; + buffer[0] = 0; + buffer[1] = 0; + buffer[2] = (byte) payloadLength; + buffer[3] = (byte) FrameType.GOAWAY.code(); + buffer[4] = 0; + writeUInt31(buffer, 5, 0); + writeUInt31(buffer, 9, lastStreamId); + writeUInt32(buffer, 13, error.code()); + for (int i = 0; i < debugLength; i++) buffer[17 + i] = (byte) debug.charAt(i); + length = 17 + debugLength; + } + + void windowUpdate(int streamId, int increment) { + buffer[0] = 0; + buffer[1] = 0; + buffer[2] = 4; + buffer[3] = (byte) FrameType.WINDOW_UPDATE.code(); + buffer[4] = 0; + writeUInt31(buffer, 5, streamId); + writeUInt31(buffer, 9, increment); + length = 13; + } + + private static void writeUInt31(byte[] target, int off, int value) { + writeUInt32(target, off, value & 0x7FFF_FFFF); + } + + private static void writeUInt32(byte[] target, int off, int value) { + target[off] = (byte) (value >>> 24); + target[off + 1] = (byte) (value >>> 16); + target[off + 2] = (byte) (value >>> 8); + target[off + 3] = (byte) value; + } + + @Override + public byte[] buffer() { + return buffer; + } + + @Override + public int offset() { + return 0; + } + + @Override + public int length() { + return length; + } + + @Override + public WriteIntent mpscNext() { + return next; + } + + @Override + public void setMpscNext(WriteIntent next) { + this.next = next; + } + + @Override + public void completed() { + owner.release(this); + } + + private void release() { + next = null; + claimed.set(false); + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2ErrorCode.java b/flash/src/main/java/dev/relism/flash/http2/Http2ErrorCode.java new file mode 100644 index 0000000..917babe --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2ErrorCode.java @@ -0,0 +1,94 @@ +package dev.relism.flash.http2; + +/** + * The 14 HTTP/2 error codes defined by RFC 9113 §7. + * + *

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 + * + *

{@code Http2ErrorCode} is used to reject a peer and to interpret what a peer + * sends us: {@link #fromCode(int)} decodes a received 32-bit value. RFC 9113 does not reserve + * unknown codes for future use in a way that requires us to accept them silently as one of the + * 14 — an endpoint that receives an error code it does not recognise treats it as + * {@code INTERNAL_ERROR}-equivalent for logging purposes; {@link #fromCode(int)} returns + * {@code null} for that case and callers log the raw integer rather than guessing a mapping. + */ +public enum Http2ErrorCode { + + /** Graceful shutdown or successful completion; not an error. RFC 9113 §7. */ + NO_ERROR(0x00), + /** The peer violated the protocol in a way not covered by a more specific code. */ + PROTOCOL_ERROR(0x01), + /** Unexpected internal condition on our side (e.g. an uncaught exception on the demux loop). */ + INTERNAL_ERROR(0x02), + /** A flow-control window was violated: overflow past 2^31-1, or a peer exceeded its window. */ + FLOW_CONTROL_ERROR(0x03), + /** The peer did not acknowledge our SETTINGS within {@code SETTINGS_ACK_TIMEOUT_MS}. */ + SETTINGS_TIMEOUT(0x04), + /** A frame was received for a stream that is already closed. */ + STREAM_CLOSED(0x05), + /** A frame's length did not match what its type requires (RFC 9113 §4.2, per-type rules). */ + FRAME_SIZE_ERROR(0x06), + /** The stream was refused before any processing; safe for the client to retry elsewhere. */ + REFUSED_STREAM(0x07), + /** Used by clients to cancel a stream; Flash never sends it, only receives it. */ + CANCEL(0x08), + /** An HPACK decoding failure. Terminates the connection because the dynamic table state is lost. */ + COMPRESSION_ERROR(0x09), + /** A CONNECT-tunnelled stream failed. */ + CONNECT_ERROR(0x0a), + /** The peer is generating excessive load (rate-limit rejection: Rapid Reset, PING/SETTINGS floods). */ + ENHANCE_YOUR_CALM(0x0b), + /** The negotiated TLS parameters fall below RFC 9113 §9.2's minimum security requirements. */ + INADEQUATE_SECURITY(0x0c), + /** Defined by RFC 9113 for HTTP/1.1-only resources; Flash serves everything over h2, so unused. */ + HTTP_1_1_REQUIRED(0x0d); + + private static final Http2ErrorCode[] BY_CODE = new Http2ErrorCode[values().length]; + + static { + for (Http2ErrorCode c : values()) { + BY_CODE[c.code] = c; + } + } + + private final int code; + private final byte[] bytes; + + Http2ErrorCode(int code) { + this.code = code; + this.bytes = new byte[]{ + (byte) (code >>> 24), + (byte) (code >>> 16), + (byte) (code >>> 8), + (byte) code + }; + } + + /** The numeric error code as it appears on the wire. */ + public int code() { + return code; + } + + /** + * The pre-encoded 4-byte big-endian wire form. Safe to write directly into a + * {@code RST_STREAM} or {@code GOAWAY} payload with a single {@code System.arraycopy} — + * never allocated or formatted per use. + */ + public byte[] bytes() { + return bytes; + } + + /** + * Decodes a 32-bit error code received from a peer. Returns {@code null} for a value + * outside the 14 defined codes; the caller should log the raw integer rather than assume + * a mapping, since RFC 9113 permits future extension codes we do not yet know about. + */ + public static Http2ErrorCode fromCode(int code) { + if (code >= 0 && code < BY_CODE.length) { + return BY_CODE[code]; + } + return null; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2Exception.java b/flash/src/main/java/dev/relism/flash/http2/Http2Exception.java new file mode 100644 index 0000000..94efaf9 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2Exception.java @@ -0,0 +1,70 @@ +package dev.relism.flash.http2; + +/** + * A connection-level HTTP/2 error. Thrown anywhere a peer's frame, HPACK block, or + * SETTINGS value violates the protocol in a way that leaves the connection's state (the HPACK + * dynamic table, a flow-control window, the stream table) unrecoverable. + * + * single site: it sends {@code GOAWAY} with {@link #errorCode()} and closes the connection. + * Compare {@link Http2StreamException}, whose scope is one stream and which results in + * {@code RST_STREAM} while the connection survives. + * + *

Deliberately does not extend {@link java.io.IOException}: the connection loop + * distinguishes a protocol violation (a decision Flash made about the peer's bytes) from a + * socket failure (the peer went away) by catching these as unrelated types. Conflating them + * would make it possible to accidentally treat a hostile peer's malformed frame as a harmless + * disconnect, or vice versa. + * + *

Why stack traces are disabled

+ * This exception is thrown on the connection's hot rejection path — a single malformed byte + * from a hostile or buggy peer can trigger it, and under a scripted attack that can happen many + * times per second across many connections. JVM stack trace capture ({@code fillInStackTrace}) + * is by far the most expensive part of constructing a {@code Throwable}, and it buys nothing + * here: the call site is exactly where {@code errorCode()} says it is, and the debug message + * already names the specific violation. The 4-argument {@link RuntimeException} constructor + * disables both suppression and stack-trace writing. + * + *

Preallocated singletons

+ * For the common, message-less rejections (frame validation failures, HPACK structural errors) + * this class exposes shared singleton instances. Reusing one instance across threads and across + * many throws is safe only because the instance carries no per-throw mutable state and + * writable-stack-trace is disabled — nothing about a throw mutates the exception object. + */ +public final class Http2Exception extends RuntimeException { + + private final Http2ErrorCode errorCode; + + private Http2Exception(Http2ErrorCode errorCode, String message) { + super(message, null, false, false); + this.errorCode = errorCode; + } + + /** The RFC 9113 §7 error code to send in the {@code GOAWAY} frame. */ + public Http2ErrorCode errorCode() { + return errorCode; + } + + /** + * Builds a connection error carrying a caller-supplied debug message. Allocates a new + * message carries information specific to this occurrence (e.g. the offending stream id or + * a decoded value); use one of the preallocated singletons below when it does not. + */ + public static Http2Exception of(Http2ErrorCode code, String message) { + return new Http2Exception(code, message); + } + + // ── Preallocated, message-less singletons for the hot rejection paths ────────────────── + + public static final Http2Exception PROTOCOL_ERROR = + new Http2Exception(Http2ErrorCode.PROTOCOL_ERROR, "protocol error"); + public static final Http2Exception FRAME_SIZE_ERROR = + new Http2Exception(Http2ErrorCode.FRAME_SIZE_ERROR, "frame size error"); + public static final Http2Exception FLOW_CONTROL_ERROR = + new Http2Exception(Http2ErrorCode.FLOW_CONTROL_ERROR, "flow control error"); + public static final Http2Exception COMPRESSION_ERROR = + new Http2Exception(Http2ErrorCode.COMPRESSION_ERROR, "compression error"); + public static final Http2Exception INTERNAL_ERROR = + new Http2Exception(Http2ErrorCode.INTERNAL_ERROR, "internal error"); + public static final Http2Exception SETTINGS_TIMEOUT = + new Http2Exception(Http2ErrorCode.SETTINGS_TIMEOUT, "settings ack timeout"); +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2HeaderBlockDecoder.java b/flash/src/main/java/dev/relism/flash/http2/Http2HeaderBlockDecoder.java new file mode 100644 index 0000000..9d820c8 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2HeaderBlockDecoder.java @@ -0,0 +1,115 @@ +package dev.relism.flash.http2; + +import dev.relism.flash.bytes.Pairs; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameHeader; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.frame.Padding; +import dev.relism.flash.http2.hpack.ContinuationAssembler; +import dev.relism.flash.http2.hpack.HeaderListSizeException; +import dev.relism.flash.http2.hpack.HeaderSink; +import dev.relism.flash.http2.hpack.HpackDecoder; + +/** Composes frame fragment extraction, CONTINUATION assembly and HPACK decoding. */ +final class Http2HeaderBlockDecoder { + private static final int PRIORITY_FIELDS_LENGTH = 5; + + private final ContinuationAssembler assembler = new ContinuationAssembler(); + private final HpackDecoder decoder = new HpackDecoder(); + private final long assemblyTimeoutNanos; + private boolean endStream; + private long assemblyStartedNanos; + + Http2HeaderBlockDecoder() { + this(Http2Limits.HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS); + } + + Http2HeaderBlockDecoder(long assemblyTimeoutMillis) { + if (assemblyTimeoutMillis <= 0) throw new IllegalArgumentException("non-positive timeout"); + assemblyTimeoutNanos = assemblyTimeoutMillis * 1_000_000L; + } + + boolean insideHeaderBlock() { + return assembler.isActive(); + } + + /** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */ + boolean accept(FrameHeader frame, HeaderSink sink) { + checkTimeout(); + if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) { + throw Http2Exception.PROTOCOL_ERROR; + } + if (frame.type() == FrameType.HEADERS) { + begin(frame); + } else if (frame.type() == FrameType.CONTINUATION) { + assembler.continuation( + frame.streamId(), + frame.buffer(), + frame.payloadOffset(), + frame.length(), + FrameFlags.isEndHeaders(frame.flags())); + } else { + return false; + } + if (!assembler.isComplete()) return false; + + try { + decoder.decode(assembler.buffer(), 0, assembler.length(), sink); + } catch (HeaderListSizeException tooLarge) { + int streamId = assembler.streamId(); + assembler.reset(); + throw new Http2StreamException( + streamId, Http2ErrorCode.ENHANCE_YOUR_CALM, tooLarge.getMessage()); + } + assembler.reset(); + assemblyStartedNanos = 0; + return true; + } + + void checkTimeout() { + if (assembler.isActive() + && System.nanoTime() - assemblyStartedNanos >= assemblyTimeoutNanos) { + assembler.reset(); + assemblyStartedNanos = 0; + throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, + "header block assembly timeout"); + } + } + + boolean endStream() { + return endStream; + } + + private void begin(FrameHeader frame) { + assemblyStartedNanos = System.nanoTime(); + endStream = FrameFlags.isEndStream(frame.flags()); + long unpadded = + Padding.unpad( + frame.buffer(), + frame.payloadOffset(), + frame.length(), + FrameFlags.isPadded(frame.flags())); + int fragmentOffset = Pairs.hi(unpadded); + int fragmentLength = Pairs.lo(unpadded); + if (FrameFlags.hasPriority(frame.flags())) { + if (fragmentLength < PRIORITY_FIELDS_LENGTH) throw Http2Exception.FRAME_SIZE_ERROR; + int dependency = + ((frame.buffer()[fragmentOffset] & 0x7f) << 24) + | ((frame.buffer()[fragmentOffset + 1] & 0xff) << 16) + | ((frame.buffer()[fragmentOffset + 2] & 0xff) << 8) + | (frame.buffer()[fragmentOffset + 3] & 0xff); + if (dependency == frame.streamId()) { + throw new Http2StreamException( + frame.streamId(), Http2ErrorCode.PROTOCOL_ERROR, "stream depends on itself"); + } + fragmentOffset += PRIORITY_FIELDS_LENGTH; + fragmentLength -= PRIORITY_FIELDS_LENGTH; + } + assembler.begin( + frame.streamId(), + frame.buffer(), + fragmentOffset, + fragmentLength, + FrameFlags.isEndHeaders(frame.flags())); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2Limits.java b/flash/src/main/java/dev/relism/flash/http2/Http2Limits.java new file mode 100644 index 0000000..ad11118 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2Limits.java @@ -0,0 +1,219 @@ +package dev.relism.flash.http2; + +/** + * Every bound the HTTP/2 implementation enforces against a peer's input, in one place. + * + *

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. + * + *

These are compile-time defaults, not runtime configuration. A limit becomes configurable only + * when the operational need and its safe range are established. + * + *

Each field is introduced with the feature that enforces it; this class contains no unused + * placeholders. + */ +public final class Http2Limits { + + private Http2Limits() {} + + /** + * Maximum number of streams a single connection may have open concurrently. Advertised to the + * peer as {@code SETTINGS_MAX_CONCURRENT_STREAMS}. Bounds per-connection memory (each open stream + * owns a per-stream HPACK arena and request/response state) against a peer that simply opens + * streams and never closes them. + */ + public static final int MAX_CONCURRENT_STREAMS = 64; + + /** + * The largest frame payload we accept without the peer first raising it via our own {@code + * SETTINGS_MAX_FRAME_SIZE}. RFC 9113 §4.2 fixes the protocol default at 16384 and requires any + * advertised value to stay within {@code 16384..16777215}. Bounds the memory a single frame read + * can force us to hold. + */ + public static final int MAX_FRAME_SIZE_LOCAL = 16_384; + + /** + * Maximum total size (name + value + 32 per RFC 7541 §4.1's accounting, summed over every header) + * of a decoded header list. Advertised as {@code SETTINGS_MAX_HEADER_LIST_SIZE} (RFC 9113 + * §6.5.2). This is the primary defence against an HPACK bomb: a small compressed block that + * references dynamic-table entries to expand into an enormous header list. + */ + public static final int MAX_HEADER_LIST_SIZE = 32_768; + + /** + * Maximum number of CONTINUATION frames accepted for a single header block before the connection + * is torn down. Defence against CVE-2024-27316 (the "HTTP/2 CONTINUATION Flood"): a peer that + * never sets {@code END_HEADERS} can otherwise force unbounded decode/reassembly work per header + * block. + */ + public static final int MAX_CONTINUATION_FRAMES_PER_BLOCK = 8; + + /** + * Maximum number of {@code RST_STREAM} frames accepted from the peer within {@link + * #RESET_RATE_INTERVAL_MS}. Defence against CVE-2023-44487 ("HTTP/2 Rapid Reset"): opening a + * stream and immediately resetting it does not count against {@link #MAX_CONCURRENT_STREAMS}, so + * without a rate bound a peer can force unbounded per-stream setup/teardown work at effectively + * unlimited concurrency. + */ + public static final int MAX_RESET_STREAMS_PER_INTERVAL = 200; + + /** + * The rolling window (milliseconds) over which {@link #MAX_RESET_STREAMS_PER_INTERVAL} is + * measured. + */ + public static final long RESET_RATE_INTERVAL_MS = 10_000; + + /** + * Maximum number of new streams accepted from the peer within {@link #RESET_RATE_INTERVAL_MS}. A + * companion bound to {@link #MAX_RESET_STREAMS_PER_INTERVAL}: Rapid Reset defences that only + * count resets can still be bypassed by a peer that creates streams fast enough that the reset + * counter never saturates within any single window boundary. + * + *

Matches {@link #MAX_STREAMS_PER_CONNECTION}'s lifetime budget by design: a connection may + * not create more streams in one rolling burst window than it is ever allowed to create in its + * whole lifetime. An earlier value of 400 (40/s) measured the RST_STREAM flood attack this bound + * exists for, but also rejected ordinary high-concurrency multiplexed clients well below the + * throughput a hardened server is expected to sustain — h2load's default light-load pattern (10 + * connections, 10 concurrent streams each) alone drives multiple thousands of legitimate stream + * creations per connection per second on a fast peer, which 400/10s cannot distinguish from + * abuse. The RST_STREAM-rate counter above measures the actual CVE-2023-44487 signature (resets, + * not creates); this bound only needs to catch a peer creating streams fast enough to dodge that + * counter, which a much higher ceiling still does. + */ + public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 100_000; + + /** Maximum SETTINGS frames accepted within one abuse-rate interval. */ + public static final int MAX_SETTINGS_PER_INTERVAL = 100; + + /** Maximum non-acknowledgement PING frames accepted within one abuse-rate interval. */ + public static final int MAX_PINGS_PER_INTERVAL = 200; + + /** + * Aggregate bound for frames that consume parsing work without carrying application data: + * PRIORITY, WINDOW_UPDATE, empty DATA and unknown extension frames. + */ + public static final int MAX_USELESS_FRAMES_PER_INTERVAL = 10_000; + + /** Default total-stream budget for one connection; zero disables the budget. */ + public static final long MAX_STREAMS_PER_CONNECTION = 100_000; + + /** Default wire-byte budget for one connection; zero disables the budget. */ + public static final long MAX_BYTES_PER_CONNECTION = 0; + + /** Default connection lifetime budget in milliseconds; zero disables the budget. */ + public static final long MAX_CONNECTION_LIFETIME_MS = 0; + + /** + * Maximum number of SETTINGS parameter entries accepted in a single SETTINGS frame. A SETTINGS + * frame is already bounded in byte length by {@link #MAX_FRAME_SIZE_LOCAL} (each entry is 6 + * bytes), but an explicit entry-count bound keeps the per-entry validation loop itself cheap to + * reason about and gives a distinct, loud rejection reason. + */ + public static final int MAX_SETTINGS_ENTRIES_PER_FRAME = 64; + + /** Maximum number of locally-sent SETTINGS frames awaiting acknowledgement. */ + public static final int MAX_OUTSTANDING_LOCAL_SETTINGS = 8; + + /** Maximum time allowed for the peer to acknowledge a locally-sent SETTINGS frame. */ + public static final long SETTINGS_ACK_TIMEOUT_MS = 10_000; + + /** + * Maximum number of SETTINGS acknowledgements waiting behind a blocked socket writer. This + * prevents a peer from turning a stream of empty SETTINGS frames into an unbounded queue of + * mandatory responses. + */ + public static final int MAX_SETTINGS_ACK_QUEUE_DEPTH = 64; + + /** Maximum diagnostic bytes included in an outbound GOAWAY frame. */ + public static final int MAX_GOAWAY_DEBUG_DATA_LENGTH = 128; + + /** + * Maximum number of outstanding (unanswered) PING responses queued for the writer. A PING flood + * forces a PONG per PING; without a bound, a peer that reads its own responses slowly can make us + * buffer unbounded PONG frames. + */ + public static final int MAX_PING_QUEUE_DEPTH = 64; + + /** + * Maximum number of zero-length DATA frames accepted per stream. Zero-length DATA consumes no + * flow-control window, so window accounting does not bound it — without this limit a peer can + * force unbounded per-frame dispatch/validation CPU work at zero cost to itself. + */ + public static final int MAX_EMPTY_DATA_FRAMES_PER_STREAM = 1_000; + + /** Largest request body retained contiguously before dispatching its handler. */ + public static final int INLINE_BODY_THRESHOLD = 64 * 1024; + + /** Hard limit for request body bytes accepted on one stream. */ + public static final int MAX_REQUEST_BODY_SIZE = 100 * 1024 * 1024; + + /** Number of frame-sized buffers available to streaming request bodies on one connection. */ + public static final int DATA_BUFFER_POOL_SIZE = 64; + + /** + * The value of {@code SETTINGS_INITIAL_WINDOW_SIZE} Flash advertises for every new stream: + * deliberately large (1 MiB, versus the RFC default of 65535) so that a normal-sized + */ + public static final int INITIAL_WINDOW_SIZE_LOCAL = 1_048_576; + + /** + * The connection-level flow-control window Flash advertises. Sized above {@link + * #INITIAL_WINDOW_SIZE_LOCAL} so a single active stream is never bottlenecked by the connection + * window before its own stream window, but well below {@code MAX_CONCURRENT_STREAMS * + * INITIAL_WINDOW_SIZE_LOCAL} — real traffic is never all streams simultaneously saturating their + * windows, and sizing for that worst case would commit 100 MiB of receive window to every + * connection regardless of load. + */ + public static final int CONNECTION_WINDOW_SIZE_LOCAL = 1_048_576; + + /** + * The HPACK dynamic table size Flash's decoder honours, in bytes of RFC 7541 §4.1 accounting. RFC + * 7541's protocol default. The encoder never uses a dynamic table at all + */ + public static final int HPACK_DYNAMIC_TABLE_SIZE_LOCAL = 4_096; + + /** + * Maximum length, in decoded bytes, of a single HPACK string literal. Applied during Huffman + * decode as bytes are produced, not to the encoded length — a Huffman string can expand by + * roughly 8/5, so bounding only the encoded length would let a compact input still decode past + * this limit. + */ + public static final int MAX_HPACK_STRING_LENGTH = 8_192; + + /** + * Maximum time, in milliseconds, allowed between a HEADERS frame's arrival and the header block's + * completion (its {@code END_HEADERS} flag, possibly after CONTINUATION frames). A peer that + * starts a header block and then stalls indefinitely would otherwise hold the per-stream arena + * and the connection's HPACK assembly buffer forever. + */ + public static final long HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS = 10_000; + + /** + * Maximum time, in milliseconds, a stream may remain open with no frame activity in either + * direction. Bounds resource pinning by a peer that opens a stream and then goes silent without + * closing it — the h2 equivalent of the h1 slowloris defence in {@code + * FlashConfiguration.idleKeepAliveTimeoutMs}. + */ + public static final long STREAM_IDLE_TIMEOUT_MS = 60_000; + + /** + * Maximum time, in milliseconds, {@code Http2FrameWriter} may spend blocked inside a single + * socket write. A blocking write is unavoidable when the kernel send buffer is full and the peer + * is not reading (that peer holds the connection's single writer lock for the duration — see + * {@code WRITER.md}), but it must not be unbounded: a peer that simply stops reading would + * otherwise let a single stalled connection wedge the writer forever. Enforced via a background + * reaper interrupting the blocked thread past the deadline, not {@code Socket#setSoTimeout} — + * that option bounds reads, not writes. + */ + public static final long WRITE_TIMEOUT_MS = 30_000; + + /** + * Maximum time, in milliseconds, {@code Http2FrameReader} may wait for a single frame's + * header and payload to fully arrive. Bounds the same slowloris-shaped hazard: + * without it, a peer that sends 9 header bytes and then never sends the declared payload + * would hold this connection's frame reader waiting forever. + */ + public static final long FRAME_READ_TIMEOUT_MS = 20_000; +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2Preface.java b/flash/src/main/java/dev/relism/flash/http2/Http2Preface.java new file mode 100644 index 0000000..7c0169d --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2Preface.java @@ -0,0 +1,87 @@ +package dev.relism.flash.http2; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.frame.FrameWriteBuffer; +import java.nio.charset.StandardCharsets; + +/** Byte-exact client preface and immutable server startup frames, compiled once at class load. */ +public final class Http2Preface { + private static final byte[] CLIENT_PREFACE = + "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + private static final byte[] SERVER_SETTINGS = buildServerSettings(); + private static final byte[] SETTINGS_ACK = frame(FrameType.SETTINGS, FrameFlags.ACK, 0, 0); + private static final byte[] INITIAL_CONNECTION_WINDOW = buildInitialConnectionWindow(); + + private Http2Preface() {} + + /** Immutable client connection preface bytes. Callers must not modify the returned array. */ + public static byte[] clientPreface() { + return CLIENT_PREFACE; + } + + static int clientPrefaceLength() { + return CLIENT_PREFACE.length; + } + + static boolean matchesClientPreface(byte[] candidate) { + if (candidate.length != CLIENT_PREFACE.length) return false; + int different = 0; + for (int i = 0; i < CLIENT_PREFACE.length; i++) { + different |= candidate[i] ^ CLIENT_PREFACE[i]; + } + return different == 0; + } + + static byte[] serverSettings() { + return SERVER_SETTINGS; + } + + static byte[] settingsAck() { + return SETTINGS_ACK; + } + + static byte[] initialConnectionWindow() { + return INITIAL_CONNECTION_WINDOW; + } + + private static byte[] buildServerSettings() { + ByteWriter bytes = new ByteWriter(64); + FrameWriteBuffer frame = new FrameWriteBuffer(bytes); + frame.beginFrame(FrameType.SETTINGS, 0, 0); + setting(bytes, Http2Settings.HEADER_TABLE_SIZE, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL); + setting(bytes, Http2Settings.ENABLE_PUSH, 0); + setting(bytes, Http2Settings.MAX_CONCURRENT_STREAMS, Http2Limits.MAX_CONCURRENT_STREAMS); + setting(bytes, Http2Settings.INITIAL_WINDOW_SIZE, Http2Limits.INITIAL_WINDOW_SIZE_LOCAL); + setting(bytes, Http2Settings.MAX_HEADER_LIST_SIZE, Http2Limits.MAX_HEADER_LIST_SIZE); + setting(bytes, Http2Settings.ENABLE_CONNECT_PROTOCOL, 1); + frame.endFrame(); + return copy(bytes); + } + + private static byte[] buildInitialConnectionWindow() { + int increment = Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL - 65_535; + return frame(FrameType.WINDOW_UPDATE, 0, 0, increment); + } + + private static byte[] frame(FrameType type, int flags, int streamId, int payload) { + ByteWriter bytes = new ByteWriter(16); + FrameWriteBuffer frame = new FrameWriteBuffer(bytes); + frame.beginFrame(type, flags, streamId); + if (type == FrameType.WINDOW_UPDATE) bytes.writeUInt31(payload); + frame.endFrame(); + return copy(bytes); + } + + private static void setting(ByteWriter bytes, int id, int value) { + bytes.writeUInt16(id); + bytes.writeUInt32(value); + } + + private static byte[] copy(ByteWriter bytes) { + byte[] result = new byte[bytes.length()]; + System.arraycopy(bytes.array(), 0, result, 0, result.length); + return result; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2Settings.java b/flash/src/main/java/dev/relism/flash/http2/Http2Settings.java new file mode 100644 index 0000000..75b1636 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2Settings.java @@ -0,0 +1,149 @@ +package dev.relism.flash.http2; + +/** + * The peer's HTTP/2 SETTINGS state. A received payload is validated completely before any value is + * applied, so a malformed parameter cannot leave a partially-updated connection. + */ +public final class Http2Settings { + public static final int HEADER_TABLE_SIZE = 0x1; + public static final int ENABLE_PUSH = 0x2; + public static final int MAX_CONCURRENT_STREAMS = 0x3; + public static final int INITIAL_WINDOW_SIZE = 0x4; + public static final int MAX_FRAME_SIZE = 0x5; + public static final int MAX_HEADER_LIST_SIZE = 0x6; + public static final int ENABLE_CONNECT_PROTOCOL = 0x8; + + public static final int DEFAULT_HEADER_TABLE_SIZE = 4_096; + public static final int DEFAULT_INITIAL_WINDOW_SIZE = 65_535; + public static final int DEFAULT_MAX_FRAME_SIZE = 16_384; + + /** + * Applies an INITIAL_WINDOW_SIZE delta to every open stream. Implementations must validate all + * resulting windows before changing any of them; negative results are valid, while a result above + * {@link Integer#MAX_VALUE} is a connection FLOW_CONTROL_ERROR. + */ + @FunctionalInterface + public interface StreamWindowUpdater { + void applyInitialWindowDelta(int delta); + } + + private int headerTableSize = DEFAULT_HEADER_TABLE_SIZE; + private boolean pushEnabled = true; + private long maxConcurrentStreams = 0xFFFF_FFFFL; + private int initialWindowSize = DEFAULT_INITIAL_WINDOW_SIZE; + private int maxFrameSize = DEFAULT_MAX_FRAME_SIZE; + private long maxHeaderListSize = 0xFFFF_FFFFL; + + /** Validates and applies one SETTINGS payload. Unknown identifiers are ignored. */ + public void apply(byte[] payload, int off, int len, StreamWindowUpdater streamWindows) { + if (len % 6 != 0) throw Http2Exception.FRAME_SIZE_ERROR; + int entries = len / 6; + if (entries > Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME) { + throw Http2Exception.of( + Http2ErrorCode.ENHANCE_YOUR_CALM, "too many SETTINGS entries: " + entries); + } + checkRange(payload, off, len); + + int nextHeaderTableSize = headerTableSize; + boolean nextPushEnabled = pushEnabled; + long nextMaxConcurrentStreams = maxConcurrentStreams; + int nextInitialWindowSize = initialWindowSize; + int nextMaxFrameSize = maxFrameSize; + long nextMaxHeaderListSize = maxHeaderListSize; + for (int pos = off; pos < off + len; pos += 6) { + int id = readUInt16(payload, pos); + long value = readUInt32(payload, pos + 2); + validate(id, value); + switch (id) { + case HEADER_TABLE_SIZE -> + nextHeaderTableSize = (int) Math.min(value, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL); + case ENABLE_PUSH -> nextPushEnabled = value == 1; + case MAX_CONCURRENT_STREAMS -> nextMaxConcurrentStreams = value; + case INITIAL_WINDOW_SIZE -> nextInitialWindowSize = (int) value; + case MAX_FRAME_SIZE -> nextMaxFrameSize = (int) value; + case MAX_HEADER_LIST_SIZE -> nextMaxHeaderListSize = value; + default -> { + // RFC 9113 §6.5.2: ignore unknown settings. + } + } + } + + streamWindows.applyInitialWindowDelta(nextInitialWindowSize - initialWindowSize); + headerTableSize = nextHeaderTableSize; + pushEnabled = nextPushEnabled; + maxConcurrentStreams = nextMaxConcurrentStreams; + initialWindowSize = nextInitialWindowSize; + maxFrameSize = nextMaxFrameSize; + maxHeaderListSize = nextMaxHeaderListSize; + } + + private static void validate(int id, long value) { + switch (id) { + case ENABLE_PUSH, ENABLE_CONNECT_PROTOCOL -> { + if (value > 1) throw Http2Exception.PROTOCOL_ERROR; + } + case INITIAL_WINDOW_SIZE -> { + if (value > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR; + } + case MAX_FRAME_SIZE -> { + if (value < 16_384 || value > 16_777_215) { + throw Http2Exception.PROTOCOL_ERROR; + } + } + default -> { + // HEADER_TABLE_SIZE, MAX_CONCURRENT_STREAMS and MAX_HEADER_LIST_SIZE accept + // every unsigned 32-bit value. Unknown identifiers are ignored by the RFC. + } + } + } + + private static void checkRange(byte[] payload, int off, int len) { + if (off < 0 || len < 0 || off > payload.length - len) { + throw new IndexOutOfBoundsException("invalid SETTINGS payload range"); + } + } + + private static int readUInt16(byte[] buf, int off) { + return ((buf[off] & 0xFF) << 8) | (buf[off + 1] & 0xFF); + } + + private static long readUInt32(byte[] buf, int off) { + return ((long) (buf[off] & 0xFF) << 24) + | ((long) (buf[off + 1] & 0xFF) << 16) + | ((long) (buf[off + 2] & 0xFF) << 8) + | (buf[off + 3] & 0xFFL); + } + + public int headerTableSize() { + return headerTableSize; + } + + public boolean pushEnabled() { + return pushEnabled; + } + + public long maxConcurrentStreams() { + return maxConcurrentStreams; + } + + public int initialWindowSize() { + return initialWindowSize; + } + + public int maxFrameSize() { + return maxFrameSize; + } + + public long maxHeaderListSize() { + return maxHeaderListSize; + } + + void reset() { + headerTableSize = DEFAULT_HEADER_TABLE_SIZE; + pushEnabled = true; + maxConcurrentStreams = 0xFFFF_FFFFL; + initialWindowSize = DEFAULT_INITIAL_WINDOW_SIZE; + maxFrameSize = DEFAULT_MAX_FRAME_SIZE; + maxHeaderListSize = 0xFFFF_FFFFL; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2StreamDispatcher.java b/flash/src/main/java/dev/relism/flash/http2/Http2StreamDispatcher.java new file mode 100644 index 0000000..113b05c --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2StreamDispatcher.java @@ -0,0 +1,305 @@ +package dev.relism.flash.http2; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.http.HttpStatus; +import dev.relism.flash.http2.frame.Http2FrameWriter; +import dev.relism.flash.http2.message.Http2ResponseWriter; +import dev.relism.flash.http2.stream.Http2FlowController; +import dev.relism.flash.http2.stream.Http2Stream; +import dev.relism.flash.http2.stream.Http2StreamState; +import dev.relism.flash.http2.stream.Http2StreamTable; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestHandler; +import dev.relism.flash.models.Response; +import dev.relism.flash.models.ResponseStreamOutputStream; +import dev.relism.flash.transport.ConnectionContext; +import dev.relism.flash.websocket.WebSocketHandler; +import dev.relism.flash.websocket.WebSocketLoop; +import dev.relism.flash.websocket.WebSocketSession; +import java.io.IOException; +import java.util.concurrent.RejectedExecutionException; +import lombok.extern.slf4j.Slf4j; + +/** Dispatches completed request streams without blocking the connection demultiplexer. */ +@Slf4j +final class Http2StreamDispatcher implements Http2Stream.ResponseSink { + @FunctionalInterface + interface FailureSink { + void fail(int streamId, Http2ErrorCode errorCode) throws IOException; + } + + private final ConnectionContext context; + private final Http2FrameWriter frameWriter; + private final Http2Settings peerSettings; + private final Http2StreamTable streams; + private final Http2FlowController flowController; + private final FailureSink failures; + private final Http2Stream[] resumeScratch = + new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS]; + private volatile boolean firstResponse = true; + + Http2StreamDispatcher( + ConnectionContext context, + Http2FrameWriter frameWriter, + Http2Settings peerSettings, + Http2StreamTable streams, + Http2FlowController flowController, + FailureSink failures) { + this.context = context; + this.frameWriter = frameWriter; + this.peerSettings = peerSettings; + this.streams = streams; + this.flowController = flowController; + this.failures = failures; + } + + void streamWindowUpdated(Http2Stream stream) { + scheduleResume(stream); + } + + void connectionWindowUpdated() { + int count = streams.copyValues(resumeScratch); + for (int i = 0; i < count; i++) { + Http2Stream stream = resumeScratch[i]; + resumeScratch[i] = null; + scheduleResume(stream); + } + } + + private void scheduleResume(Http2Stream stream) { + if (!stream.responseStarted() || stream.cancelled()) return; + if (!stream.beginResponseBatch()) return; + stream.markResumeTask(); + try { + context.executor().execute(stream); + } catch (RejectedExecutionException rejected) { + stream.endResponseBatch(); + failAndRelease(stream, Http2ErrorCode.REFUSED_STREAM, rejected); + } + } + + void dispatch(Http2Stream stream) { + if (stream.cancelled()) { + streams.release(stream); + return; + } + stream.markDispatched(); + stream.responseSink(this); + try { + context.executor().execute(stream); + } catch (RejectedExecutionException rejected) { + failAndRelease(stream, Http2ErrorCode.REFUSED_STREAM, rejected); + } + } + + @Override + public void handleRequest(Http2Stream stream) { + handle(stream); + } + + private void handle(Http2Stream stream) { + stream.touch(); + if (stream.cancelled()) { + streams.release(stream); + return; + } + try { + Request request = stream.assembleRequest(context.remoteAddress(), context.sslSocket()); + Response pooled = stream.resetResponse(); + Response response = pooled; + if (!Http2Authority.isServed(request.header("host"), request.sslSession())) { + response.status(HttpStatus.MISDIRECTED_REQUEST); + if (stream.websocketConnect()) response.type(ContentType.NONE).streaming(output -> {}); + } else if (stream.websocketConnect()) { + WebSocketHandler handler = + context.wsRouter().route(request, stream.wsRouteScratch(context.wsRouter())); + response.type(ContentType.NONE); + if (handler == null) { + response.status(HttpStatus.NOT_FOUND).streaming(output -> {}); + } else { + response.streaming( + output -> + WebSocketLoop.run( + new WebSocketSession( + request.body().stream(), + new ResponseStreamOutputStream(output), + context.configuration().getWsFrameBufferSize(), + request, + false), + handler)); + } + } else { + Object routeScratch = stream.routeScratch(context.router()); + RequestHandler handler = context.router().route(request, routeScratch); + if (handler == null) handler = context.router().getNotFoundHandler(); + try { + Object result = handler.handle(request, response); + if (result instanceof Response returned) response = returned; + else if (result != null) response.setBody(result); + } catch (Exception handlerFailure) { + Object result = + context.router().getExceptionHandler().handle(handlerFailure, request, response); + if (result instanceof Response returned) response = returned; + else if (result != null) response.setBody(result); + } + } + + boolean pushStreaming = response.isPushStreaming(); + if (!pushStreaming) request.drain(); + Http2ResponseWriter responseWriter = stream.responseWriter(); + if (stream.cancelled()) { + request.recycle(); + if (response == pooled) pooled.recycle(); + streams.release(stream); + return; + } + boolean headRequest = request.method() == HttpMethod.HEAD; + int reserved; + int used; + synchronized (this) { + boolean tableUpdate = firstResponse; + reserved = flowController.reserveSend(stream, peerSettings.maxFrameSize()); + used = 0; + try { + used = + responseWriter.startFlowControlled( + response, + stream.id(), + headRequest, + context.configuration().isSendDate(), + true, + context.configuration().isH2HuffmanDynamicValues(), + tableUpdate, + peerSettings.maxFrameSize(), + peerSettings.maxHeaderListSize(), + reserved); + } finally { + flowController.refundSend(stream, reserved - used); + } + firstResponse = false; + } + if (!pushStreaming) request.recycle(); + stream.markResponseStarted(); + applyBatchTransition(stream, responseWriter); + if (!stream.beginResponseBatch()) { + throw new IllegalStateException("response batch already in flight"); + } + detachFinalBatch(stream, responseWriter); + frameWriter.write(responseWriter); + } catch (Exception failure) { + failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure); + } + } + + private void tryResumeResponse(Http2Stream stream) { + stream.touch(); + if (stream.cancelled()) { + stream.endResponseBatch(); + streams.release(stream); + return; + } + Http2ResponseWriter responseWriter = stream.responseWriter(); + int streamId = stream.id(); + if (responseWriter.finished()) { + stream.endResponseBatch(); + if (stream.state() == Http2StreamState.CLOSED) { + streams.retire(stream, streamId); + } + return; + } + int reserved = flowController.reserveSend(stream, peerSettings.maxFrameSize()); + if (reserved == 0) { + stream.endResponseBatch(); + return; + } + try { + int used = 0; + try { + used = responseWriter.resume(peerSettings.maxFrameSize(), reserved); + } finally { + flowController.refundSend(stream, reserved - used); + } + applyBatchTransition(stream, responseWriter); + detachFinalBatch(stream, responseWriter); + frameWriter.write(responseWriter); + } catch (Exception failure) { + stream.endResponseBatch(); + failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure); + } + } + + @Override + public void resumeResponse(Http2Stream stream) { + tryResumeResponse(stream); + } + + private static void applyBatchTransition( + Http2Stream stream, Http2ResponseWriter responseWriter) { + if (responseWriter.headersInBatch()) { + if (responseWriter.finished() && responseWriter.dataBytesInBatch() == 0 + && !responseWriter.trailerHeadersInBatch()) { + stream.transition(Http2StreamState.Event.SEND_HEADERS_ES); + return; + } + stream.transition(Http2StreamState.Event.SEND_HEADERS); + } + if (responseWriter.dataBytesInBatch() != 0 || responseWriter.endStreamInBatch()) { + if (responseWriter.dataBytesInBatch() != 0) { + stream.transition( + responseWriter.endStreamInBatch() && !responseWriter.trailerHeadersInBatch() + ? Http2StreamState.Event.SEND_DATA_ES + : Http2StreamState.Event.SEND_DATA); + } + if (responseWriter.trailerHeadersInBatch()) { + stream.transition(Http2StreamState.Event.SEND_HEADERS_ES); + } else if (responseWriter.dataBytesInBatch() == 0 && responseWriter.endStreamInBatch()) { + stream.transition(Http2StreamState.Event.SEND_DATA_ES); + } + } + } + + @Override + public void responseBatchCompleted(Http2Stream stream) { + stream.touch(); + stream.endResponseBatch(); + int streamId = stream.id(); + if (streamId == 0) return; + if (stream.cancelled()) { + streams.remove(stream.id()); + streams.release(stream); + return; + } + if (stream.responseWriter().finished()) { + if (stream.state() == Http2StreamState.CLOSED) { + if (!streams.retire(stream, streamId)) streams.release(stream); + } + return; + } + scheduleResume(stream); + } + + private void detachFinalBatch(Http2Stream stream, Http2ResponseWriter writer) { + if (writer.finished() && stream.state() == Http2StreamState.CLOSED) { + streams.detach(stream, stream.id()); + } + } + + private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) { + int streamId = stream.id(); + if (!streams.removeIfSame(stream, streamId) && stream.id() != streamId) return; + if (cause != null) log.error("HTTP/2 stream {} failed", streamId, cause); + try { + stream.cancel(); + } catch (RuntimeException cancellationFailure) { + log.debug("Failed to cancel HTTP/2 stream {} cleanly", streamId, cancellationFailure); + } + try { + failures.fail(streamId, error); + } catch (IOException writeFailure) { + log.debug("Failed to write RST_STREAM for {}", streamId, writeFailure); + } finally { + streams.release(stream); + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/Http2StreamException.java b/flash/src/main/java/dev/relism/flash/http2/Http2StreamException.java new file mode 100644 index 0000000..d8dab3b --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/Http2StreamException.java @@ -0,0 +1,43 @@ +package dev.relism.flash.http2; + +/** + * A stream-level HTTP/2 error, scoped to one stream id. Results in an {@code RST_STREAM} + * frame for {@link #streamId()} with {@link #errorCode()}; the connection and every other + * stream on it are unaffected. Compare {@link Http2Exception}, whose scope is the whole + * connection. + * + *

Deliberately does not extend {@link java.io.IOException}, for the same reason as + * {@link Http2Exception}: the connection loop must be able to distinguish "we decided to reject + * this stream" from "the socket failed" by catching unrelated exception types. + * + *

Why this allocates, unlike {@code Http2Exception}'s singletons

+ * Every instance carries a distinct {@link #streamId()}, so it cannot be a shared singleton the + * exempts error paths. The scenario where this matters most — a peer opening and resetting + * thousands of streams per second (the Rapid Reset pattern, CVE-2023-44487) — is bounded by + * that can force RST_STREAM generation fast enough for GC pressure to matter has already + * tripped {@code Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL} and the connection is being torn + * down anyway. + * + *

Stack trace capture is disabled for the same cost reason as {@link Http2Exception}. + */ +public final class Http2StreamException extends RuntimeException { + + private final Http2ErrorCode errorCode; + private final int streamId; + + public Http2StreamException(int streamId, Http2ErrorCode errorCode, String message) { + super(message, null, false, false); + this.streamId = streamId; + this.errorCode = errorCode; + } + + /** The id of the stream this error terminates. */ + public int streamId() { + return streamId; + } + + /** The RFC 9113 §7 error code to send in the {@code RST_STREAM} frame. */ + public Http2ErrorCode errorCode() { + return errorCode; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/RollingWindowCounter.java b/flash/src/main/java/dev/relism/flash/http2/RollingWindowCounter.java new file mode 100644 index 0000000..4c7b154 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/RollingWindowCounter.java @@ -0,0 +1,31 @@ +package dev.relism.flash.http2; + +/** Allocation-free two-bucket rolling rate counter owned by one connection thread. */ +final class RollingWindowCounter { + private final long bucketNanos; + private long currentBucket; + private int currentCount; + private int previousCount; + + RollingWindowCounter(long intervalMillis) { + if (intervalMillis < 2) throw new IllegalArgumentException("interval must be at least 2 ms"); + bucketNanos = intervalMillis * 1_000_000L / 2; + } + + boolean incrementExceeded(int limit) { + return incrementExceeded(limit, System.nanoTime()); + } + + boolean incrementExceeded(int limit, long nowNanos) { + long bucket = nowNanos / bucketNanos; + if (currentBucket == 0) { + currentBucket = bucket; + } else if (bucket != currentBucket) { + previousCount = bucket == currentBucket + 1 ? currentCount : 0; + currentCount = 0; + currentBucket = bucket; + } + currentCount++; + return currentCount + previousCount > limit; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/frame/FrameFlags.java b/flash/src/main/java/dev/relism/flash/http2/frame/FrameFlags.java new file mode 100644 index 0000000..b61c4e6 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/frame/FrameFlags.java @@ -0,0 +1,39 @@ +package dev.relism.flash.http2.frame; + +/** + * The frame-header flag bits (RFC 9113 §6), as bitwise constants plus predicate helpers. + * + *

The deliberate collision

+ * Bit {@code 0x1} means different things on different frame types: {@link #END_STREAM} on + * {@code DATA}/{@code HEADERS}, {@link #ACK} on {@code SETTINGS}/{@code PING}. They are the same + * bit position because the RFC defines flags per-type, not globally — reusing the numeric value + * is intentional on the wire, not a naming accident here. **Never call {@link #isEndStream} on a + * SETTINGS/PING frame's flags, or {@link #isAck} on a DATA/HEADERS frame's** — each predicate is + * named for the one frame type family it is valid to call it on; mixing them up silently + * misreads an unrelated bit rather than throwing, because the bit pattern is, by construction, + * identical. + * + *

RFC 9113 §4.1: flag bits not defined for a frame's type MUST be ignored on receipt and MUST + * NOT be set when sending. This class only ever tests bits it defines for the type the caller is + * working with; undefined bits are never inspected. + */ +public final class FrameFlags { + private FrameFlags() {} + + /** DATA/HEADERS: no more frames will be sent for this stream in this direction. */ + public static final int END_STREAM = 0x1; + /** SETTINGS/PING: this frame acknowledges the peer's own frame, rather than proposing new values. */ + public static final int ACK = 0x1; + /** HEADERS/PUSH_PROMISE/CONTINUATION: the header block is complete — no CONTINUATION follows. */ + public static final int END_HEADERS = 0x4; + /** DATA/HEADERS/PUSH_PROMISE: a pad-length byte and trailing padding are present — see {@link Padding}. */ + public static final int PADDED = 0x8; + /** HEADERS: deprecated stream-dependency/weight fields are present (RFC 9113 §5.3.2 — parsed and discarded). */ + public static final int PRIORITY = 0x20; + + public static boolean isEndStream(int flags) { return (flags & END_STREAM) != 0; } + public static boolean isAck(int flags) { return (flags & ACK) != 0; } + public static boolean isEndHeaders(int flags) { return (flags & END_HEADERS) != 0; } + public static boolean isPadded(int flags) { return (flags & PADDED) != 0; } + public static boolean hasPriority(int flags) { return (flags & PRIORITY) != 0; } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/frame/FrameHeader.java b/flash/src/main/java/dev/relism/flash/http2/frame/FrameHeader.java new file mode 100644 index 0000000..c7b646c --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/frame/FrameHeader.java @@ -0,0 +1,83 @@ +package dev.relism.flash.http2.frame; + +/** + * A flyweight over one frame's 9-byte header plus its payload location, both still living + * in {@link Http2FrameReader}'s own read buffer. One instance per connection, {@link #reset} + * in place by every {@link Http2FrameReader#readFrame()} call — never allocated per frame + * (mirrors the existing {@code WebSocketFrame} reuse idiom in {@code dev.relism.flash.websocket}). + * + *

Lifetime contract

+ * Valid only until the next {@link Http2FrameReader#readFrame()}/{@code consumeFrame()} call on + * the same reader — same "do not retain past the handler" rule the rest of this codebase's + * buffer-backed flyweights (`Http1HeaderMap`, `WebSocketFrame`) already document. The payload bytes + * are also transient: whatever layer needs to retain a DATA frame's payload past this window + * + *

Reserved bit and unknown types

+ * {@link #streamId()} has already had the wire's reserved high bit (RFC 9113 §4.1: "R: A + * reserved 1-bit field... The semantics of this bit are undefined, and the bit MUST be ignored + * when receiving") masked off during {@link #reset} — callers never see it and never need to + * mask it themselves. {@link #type()} is {@code null} for a type code {@link FrameType} does not + * recognise (i.e. {@code typeCode() > FrameType.maxKnown()}); per RFC 9113 §4.1 such frames must + * be ignored, not rejected — {@link #typeCode()} remains available so the caller can still log + * or count it before skipping the payload. + */ +public final class FrameHeader { + private byte[] buf; + private int length; + private int typeCode; + private FrameType type; + private int flags; + private int streamId; + private int payloadOffset; + + /** Called by {@link Http2FrameReader} only, once the full 9-byte header is available at {@code buf[off]}. */ + void reset(byte[] buf, int off) { + this.buf = buf; + int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF; + this.length = (b0 << 16) | (b1 << 8) | b2; + this.typeCode = buf[off + 3] & 0xFF; + this.type = FrameType.fromCode(typeCode); + this.flags = buf[off + 4] & 0xFF; + // RFC 9113 §4.1: the top bit of byte 5 is reserved and MUST be ignored on receipt — + // masked here, once, rather than requiring every caller to remember to. + int b5 = buf[off + 5] & 0x7F; + int b6 = buf[off + 6] & 0xFF, b7 = buf[off + 7] & 0xFF, b8 = buf[off + 8] & 0xFF; + this.streamId = (b5 << 24) | (b6 << 16) | (b7 << 8) | b8; + this.payloadOffset = off + 9; + } + + /** Payload length in bytes, as declared by the frame header (0..2^24-1 before any limit check). */ + public int length() { + return length; + } + + /** The raw wire type byte, valid even when {@link #type()} is {@code null} (an unrecognised type). */ + public int typeCode() { + return typeCode; + } + + /** The recognised frame type, or {@code null} if {@link #typeCode()} is not one of RFC 9113's 10. */ + public FrameType type() { + return type; + } + + /** The raw flags byte — interpret via {@link FrameFlags}, which is type-specific. */ + public int flags() { + return flags; + } + + /** Stream identifier, reserved bit already masked. {@code 0} means "the connection itself". */ + public int streamId() { + return streamId; + } + + /** The backing buffer — see the class Javadoc's lifetime contract before retaining a reference. */ + public byte[] buffer() { + return buf; + } + + /** Offset of the first payload byte within {@link #buffer()}. Payload spans {@code [payloadOffset(), payloadOffset() + length())}. */ + public int payloadOffset() { + return payloadOffset; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/frame/FrameType.java b/flash/src/main/java/dev/relism/flash/http2/frame/FrameType.java new file mode 100644 index 0000000..41757c9 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/frame/FrameType.java @@ -0,0 +1,113 @@ +package dev.relism.flash.http2.frame; + +/** + * The 10 HTTP/2 frame types (RFC 9113 §6), plus the shared per-type validation rules {@link + * FrameValidator} enforces. Values above {@code 0x9} are not assigned a constant here — RFC 9113 + * §4.1 requires unknown types to be silently ignored (read and discard the payload), which {@link + * Http2FrameReader}'s caller implements by checking {@code type > FrameType.maxKnown()} rather than + * by this enum growing an {@code UNKNOWN} member (an {@code UNKNOWN} constant would misleadingly + * suggest "a recognised category of unrecognised frame", when the correct handling is simply "not + * this table, skip it"). + * + *

Each constant carries the RFC-mandated payload length bounds, whether a zero stream id is + * required/forbidden/either, and whether the frame counts toward the CONTINUATION-flood guard + * (bounded by {@code Http2Limits#MAX_CONTINUATION_FRAMES_PER_BLOCK}) — see {@link FrameValidator} + * for how these are applied and the specific RFC citation per rule. + */ +public enum FrameType { + /** RFC 9113 §6.1. Stream body bytes. Stream id required. Length: 0..MAX_FRAME_SIZE. */ + DATA(0x0, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED), + /** RFC 9113 §6.2. Header block fragment (HPACK). Stream id required. */ + HEADERS(0x1, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED), + PRIORITY(0x2, 5, 5, StreamIdRule.REQUIRED), + /** RFC 9113 §6.4. Stream-level error. Exactly 4 bytes (the error code). Stream id required. */ + RST_STREAM(0x3, 4, 4, StreamIdRule.REQUIRED), + /** + * RFC 9113 §6.5. Connection-level parameters. Length must be a multiple of 6. Stream id must be + * 0. + */ + SETTINGS(0x4, 0, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN), + /** + * RFC 9113 §6.6. Never sent (Flash advertises {@code SETTINGS_ENABLE_PUSH=0}); receiving one from + * a client is a protocol error. + */ + PUSH_PROMISE(0x5, 4, Integer.MAX_VALUE, StreamIdRule.REQUIRED), + /** + * RFC 9113 §6.7. Connection liveness / RTT probe. Exactly 8 bytes of opaque data. Stream id must + * be 0. + */ + PING(0x6, 8, 8, StreamIdRule.FORBIDDEN), + /** + * RFC 9113 §6.8. Connection shutdown notice. At least 8 bytes (last-stream-id + error code). + * Stream id must be 0. + */ + GOAWAY(0x7, 8, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN), + /** + * RFC 9113 §6.9. Flow-control window increment. Exactly 4 bytes. Stream id may be either (0 = + * connection window). + */ + WINDOW_UPDATE(0x8, 4, 4, StreamIdRule.EITHER), + /** + * RFC 9113 §6.10. Continuation of a header block that did not fit one HEADERS/PUSH_PROMISE frame. + * Stream id required. + */ + CONTINUATION(0x9, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED); + + /** Whether a frame type requires stream id 0, requires it non-zero, or permits either. */ + public enum StreamIdRule { + REQUIRED, + FORBIDDEN, + EITHER + } + + private static final FrameType[] BY_CODE = new FrameType[values().length]; + + static { + for (FrameType t : values()) { + BY_CODE[t.code] = t; + } + } + + private final int code; + private final int minLength; + private final int maxLength; + private final StreamIdRule streamIdRule; + + FrameType(int code, int minLength, int maxLength, StreamIdRule streamIdRule) { + this.code = code; + this.minLength = minLength; + this.maxLength = maxLength; + this.streamIdRule = streamIdRule; + } + + public int code() { + return code; + } + + public int minLength() { + return minLength; + } + + public int maxLength() { + return maxLength; + } + + public StreamIdRule streamIdRule() { + return streamIdRule; + } + + /** + * The highest type code this enum recognises — anything above must be ignored per RFC 9113 §4.1. + */ + public static int maxKnown() { + return CONTINUATION.code; + } + + /** + * Looks up the constant for a wire type byte, or {@code null} if it is an unrecognised + * (to-be-ignored) type. + */ + public static FrameType fromCode(int code) { + return (code >= 0 && code < BY_CODE.length) ? BY_CODE[code] : null; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/frame/FrameValidator.java b/flash/src/main/java/dev/relism/flash/http2/frame/FrameValidator.java new file mode 100644 index 0000000..bb91a9f --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/frame/FrameValidator.java @@ -0,0 +1,88 @@ +package dev.relism.flash.http2.frame; + +import dev.relism.flash.http2.Http2ErrorCode; +import dev.relism.flash.http2.Http2Exception; +import dev.relism.flash.http2.Http2Limits; + +/** + * Table-driven RFC 9113 per-frame-type validation: length bounds, the stream-id + * required/forbidden/either rule, and the two special-cased structural rules ({@code SETTINGS}' + * multiple-of-6 length, {@code PUSH_PROMISE} always rejected from a client) that do not fit a + * class is the code that reads it. + * + *

The error code is not uniform — read the RFC per violation, not just per type. A + * {@code SETTINGS} frame with a bad length is {@code FRAME_SIZE_ERROR}; the same frame with a + * non-zero stream id is {@code PROTOCOL_ERROR}. This class throws the specific code each + * violation's own RFC citation requires, not a single blanket code per type. + */ +public final class FrameValidator { + private FrameValidator() {} + + /** + * Validates {@code header} against RFC 9113's rules for its type. + * + * @param insideHeaderBlock whether this frame arrived between a HEADERS/PUSH_PROMISE frame + * lacking {@code END_HEADERS} and its terminating CONTINUATION — + * changes the handling of an unrecognised type (§6.10: a + * {@code PROTOCOL_ERROR}, not the usual silent ignore, since an + * in-progress header block cannot tolerate an interloper frame of + * any kind without desynchronizing HPACK's stateful decode) + * @throws Http2Exception on any RFC violation, with the specific error code the violated + * rule mandates + */ + public static void validate(FrameHeader header, boolean insideHeaderBlock) { + FrameType type = header.type(); + + if (type == null) { + // RFC 9113 §4.1: unknown frame types MUST be ignored — except inside an in-progress + // header block (§6.10), where anything other than CONTINUATION desynchronizes HPACK. + if (insideHeaderBlock) { + throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, + "unrecognised frame type " + header.typeCode() + " received inside a header block"); + } + return; + } + + int length = header.length(); + + // RFC 9113 §6.5: a SETTINGS frame's length MUST be a multiple of 6 (each entry is a + // 2-byte identifier + 4-byte value). Checked before the generic bounds below, since the + // generic table only expresses a min/max range, not a modulus. + if (type == FrameType.SETTINGS && length % 6 != 0) { + throw Http2Exception.FRAME_SIZE_ERROR; + } + + if (length < type.minLength() || length > type.maxLength()) { + throw Http2Exception.FRAME_SIZE_ERROR; + } + + // Redundant with Http2FrameReader's own pre-allocation check for frames it read itself, + // but this method must also be correct for a FrameHeader built any other way (tests, + // and — in later phases — frames reassembled from multiple reads), so the bound is + // re-asserted here rather than trusted from the caller. + if (length > Http2Limits.MAX_FRAME_SIZE_LOCAL) { + throw Http2Exception.FRAME_SIZE_ERROR; + } + + int streamId = header.streamId(); + switch (type.streamIdRule()) { + case REQUIRED -> { + if (streamId == 0) { + throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, type + " requires a non-zero stream id"); + } + } + case FORBIDDEN -> { + if (streamId != 0) { + throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, type + " must have stream id 0, got " + streamId); + } + } + case EITHER -> { /* WINDOW_UPDATE: 0 (connection window) or non-zero (stream window) both valid */ } + } + + // (Flash advertises SETTINGS_ENABLE_PUSH=0 and never sends one); receiving one at all + // means the peer believes it is talking to a client, which is always a protocol error. + if (type == FrameType.PUSH_PROMISE) { + throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, "PUSH_PROMISE received from a client"); + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/frame/FrameWriteBuffer.java b/flash/src/main/java/dev/relism/flash/http2/frame/FrameWriteBuffer.java new file mode 100644 index 0000000..00f7e31 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/frame/FrameWriteBuffer.java @@ -0,0 +1,75 @@ +package dev.relism.flash.http2.frame; + +import dev.relism.flash.bytes.ByteWriter; + +/** + * Serializes HTTP/2 frames into a {@link ByteWriter} scratch buffer with the standard + * length-back-patching technique: {@link #beginFrame} writes a 9-byte header with a placeholder + * length, the caller writes the payload directly through {@link #writer()} (the same + * {@link ByteWriter}), and {@link #endFrame} rewrites the length once it is known — the payload + * size is rarely known before it is serialized (an HPACK-encoded header block, in particular, + * has no cheap way to be measured in advance). + * + * issues one bulk {@code write}, rather than streaming bytes as they are produced: streaming + * would require knowing the length before the first byte goes out, which back-patching + * deliberately avoids needing. + * + *

Usage

+ *
{@code
+ * FrameWriteBuffer out = new FrameWriteBuffer(new ByteWriter(4096));
+ * out.beginFrame(FrameType.SETTINGS, 0, 0);
+ * out.writer().writeUInt16(SETTINGS_MAX_CONCURRENT_STREAMS);
+ * out.writer().writeUInt32(100);
+ * out.endFrame();
+ * // out.writer().array()[0, out.writer().length()) now holds one complete, correctly-lengthed frame
+ * }
+ * + *

Multiple frames, one buffer

+ * {@link #beginFrame}/{@link #endFrame} pairs may be repeated on the same instance without a + * {@link ByteWriter#reset()} between them — each pair appends one more complete frame after + * whatever was already written, which is exactly what {@link Http2FrameWriter#write} wants for a + * single bulk write covering several frames (e.g. HEADERS followed immediately by its first + * DATA frame). + * + *

Thread-safety

+ * Not thread-safe — exactly one writer at a time, the same convention every other per-connection + * scratch object in this codebase follows. + */ +public final class FrameWriteBuffer { + private final ByteWriter writer; + private int headerStart = -1; + + public FrameWriteBuffer(ByteWriter writer) { + this.writer = writer; + } + + /** The underlying {@link ByteWriter} — write the frame's payload directly through this between {@link #beginFrame} and {@link #endFrame}. */ + public ByteWriter writer() { + return writer; + } + + /** Writes a 9-byte frame header with a placeholder length, to be filled in by {@link #endFrame}. */ + public void beginFrame(FrameType type, int flags, int streamId) { + if (headerStart != -1) { + throw new IllegalStateException("beginFrame() called again before the previous frame's endFrame()"); + } + headerStart = writer.length(); + writer.writeUInt24(0); // length placeholder + writer.writeByte((byte) type.code()); + writer.writeByte((byte) flags); + writer.writeUInt31(streamId); + } + + /** Back-patches the length field written by {@link #beginFrame} now that the payload's size is known. */ + public void endFrame() { + if (headerStart == -1) { + throw new IllegalStateException("endFrame() called without a matching beginFrame()"); + } + int payloadLength = writer.length() - (headerStart + 9); + byte[] buf = writer.array(); + buf[headerStart] = (byte) (payloadLength >>> 16); + buf[headerStart + 1] = (byte) (payloadLength >>> 8); + buf[headerStart + 2] = (byte) payloadLength; + headerStart = -1; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/frame/Http2FrameReader.java b/flash/src/main/java/dev/relism/flash/http2/frame/Http2FrameReader.java new file mode 100644 index 0000000..7fedaf0 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/frame/Http2FrameReader.java @@ -0,0 +1,160 @@ +package dev.relism.flash.http2.frame; + +import dev.relism.flash.http2.Http2Exception; +import dev.relism.flash.http2.Http2Limits; +import dev.relism.flash.transport.BufferedByteSource; +import java.io.EOFException; +import java.io.IOException; +import java.util.Arrays; + +/** + * Reads length-prefixed HTTP/2 frames from one connection's {@link BufferedByteSource}. Simpler + * than {@code RequestParser} by construction: HTTP/2 frames declare their length up front (the + * 9-byte header), so nothing is ever scanned for — {@code Http2FrameReader} only ever needs to know + * "do I have N bytes yet", never "where does this end". + * + *

Buffer discipline

+ * + * 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} before the buffer + * length-check, not after an allocation already paid for it. + * + *

Usage

+ * + *
{@code
+ * FrameHeader header = reader.readFrame();
+ * if (header == null) { /* clean EOF between frames — connection closing *\/ }
+ * // ... process header.buffer()[header.payloadOffset(), +header.length()) ...
+ * reader.consumeFrame(); // MUST be called before the next readFrame()
+ * }
+ * + *

Thread-safety

+ * + * Not thread-safe — exactly one virtual thread (the connection's demux loop) ever calls this, the + * same invariant every other per-connection reader in this codebase assumes. + */ +public final class Http2FrameReader { + private static final int FRAME_HEADER_SIZE = 9; + private static final int INITIAL_BUFFER_SIZE = 16 * 1024; + + private final BufferedByteSource in; + private final FrameHeader header = new FrameHeader(); + private byte[] buffer; + private int base; // offset of the first unconsumed byte + private int totalRead; // count of valid unconsumed bytes at [base, base + totalRead) + private long frameDeadlineNanos; + + public Http2FrameReader(BufferedByteSource in) { + this(in, INITIAL_BUFFER_SIZE); + } + + public Http2FrameReader(BufferedByteSource in, int initialBufferSize) { + this.in = in; + this.buffer = new byte[Math.max(initialBufferSize, FRAME_HEADER_SIZE)]; + } + + /** + * Reads the next frame's header and payload, bounded by {@link + * Http2Limits#FRAME_READ_TIMEOUT_MS}, and returns the reused {@link FrameHeader} flyweight + * positioned over it — or {@code null} on a clean EOF between frames (the peer closed the + * connection while nothing was in flight; not an error). + * + *

The caller MUST call {@link #consumeFrame()} exactly once after processing this frame (or + * deciding to discard it) and before calling this method again. + * + * @throws Http2Exception if the declared length exceeds {@link Http2Limits#MAX_FRAME_SIZE_LOCAL} + * @throws EOFException if the connection closes after a frame has already started arriving + * @throws java.net.SocketTimeoutException if {@link Http2Limits#FRAME_READ_TIMEOUT_MS} elapses + */ + public FrameHeader readFrame() throws IOException { + return readFrame(Http2Limits.FRAME_READ_TIMEOUT_MS); + } + + /** Reads one frame using a caller-supplied upper bound for this frame's absolute deadline. */ + public FrameHeader readFrame(long timeoutMs) throws IOException { + if (timeoutMs <= 0) throw new IllegalArgumentException("timeoutMs must be positive"); + long now = System.nanoTime(); + if (frameDeadlineNanos == 0) { + frameDeadlineNanos = now + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L; + } + in.setDeadline(Math.min(frameDeadlineNanos, now + timeoutMs * 1_000_000L)); + try { + if (!ensureAvailable(FRAME_HEADER_SIZE)) { + frameDeadlineNanos = 0; + return null; // clean EOF: nothing buffered yet, peer closed between frames + } + int declaredLength = decodeLength(buffer, base); + // never causes an oversized allocation, only a rejection. + if (declaredLength > Http2Limits.MAX_FRAME_SIZE_LOCAL) { + throw Http2Exception.FRAME_SIZE_ERROR; + } + ensureAvailable(FRAME_HEADER_SIZE + declaredLength); + header.reset(buffer, base); + return header; + } catch (java.net.SocketTimeoutException timeout) { + if (totalRead == 0) frameDeadlineNanos = 0; + throw timeout; + } finally { + in.clearDeadline(); + } + } + + /** Advances past the frame last returned by {@link #readFrame()}. Zero-copy, zero-allocation. */ + public void consumeFrame() { + int consumed = FRAME_HEADER_SIZE + header.length(); + base += consumed; + totalRead -= consumed; + if (totalRead == 0) { + base = 0; // nothing buffered — reset to the front rather than drifting forever + } + frameDeadlineNanos = 0; + } + + /** Whether a partially received frame exhausted its non-renewable absolute deadline. */ + public boolean frameDeadlineExpired() { + return totalRead != 0 && System.nanoTime() >= frameDeadlineNanos; + } + + /** Whether another frame may be consumed immediately without waiting for network input. */ + public boolean hasBufferedInput() { + return totalRead != 0 || in.available() != 0; + } + + private static int decodeLength(byte[] buf, int off) { + int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF; + return (b0 << 16) | (b1 << 8) | b2; + } + + /** + * Ensures at least {@code need} bytes are available starting at {@link #base}, growing or + * compacting the buffer as necessary. Returns {@code false} only for a clean EOF with nothing at + * all buffered yet (the between-frames case); an EOF after any bytes of the current frame have + * already arrived is a genuine truncation and throws. + */ + private boolean ensureAvailable(int need) throws IOException { + while (totalRead < need) { + if (base + need > buffer.length) { + if (base > 0) { + // Compact: slide unconsumed bytes to the front — frees room without growing. + System.arraycopy(buffer, base, buffer, 0, totalRead); + base = 0; + } else { + // need <= 9 + MAX_FRAME_SIZE_LOCAL always, by readFrame()'s own check before + // the payload-sized call — grow exactly enough, never unbounded. + int grown = buffer.length; + while (grown < need) grown *= 2; + buffer = Arrays.copyOf(buffer, grown); + } + } + int n = in.read(buffer, base + totalRead, buffer.length - base - totalRead); + if (n < 0) { + if (totalRead == 0) return false; + throw new EOFException( + "connection closed mid-frame (" + totalRead + "/" + need + " bytes read)"); + } + totalRead += n; + } + return true; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/frame/Http2FrameWriter.java b/flash/src/main/java/dev/relism/flash/http2/frame/Http2FrameWriter.java new file mode 100644 index 0000000..9048c96 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/frame/Http2FrameWriter.java @@ -0,0 +1,283 @@ +package dev.relism.flash.http2.frame; + +import dev.relism.flash.http2.Http2Limits; +import java.io.IOException; +import java.io.InterruptedIOException; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +/** + * The one component every HTTP/2 write in this codebase passes through — connection frames and + * stream frames alike (both are just {@link WriteIntent}s). Its entire job is serializing + * concurrent access to one connection's socket write side as cheaply as physically possible, + * because under multiplexing every stream on a connection shares that one socket. + * + *

The design, three layers

+ * + *

Layer 1 — serialize outside the lock. By the time {@link #write} is called, the caller + * has already built its complete frame into a buffer it owns (see {@link WriteIntent}). This writer + * never serializes anything; it only ever issues one bulk {@code sink.write(buffer, offset, + * length)} call while holding the lock — never many small writes, which would turn "hold the lock" + * into "hold the lock across a serialization pass." + * + *

Layer 2 — {@link ReentrantLock}, never {@code synchronized}. On Java 21, a virtual + * thread blocking inside {@code synchronized} pins its carrier platform thread; blocking on a + * {@link ReentrantLock} unmounts it instead (JEP 491, which removes the {@code synchronized} {@code + * ReentrantLock} is also load-bearing here for a second reason {@code synchronized} cannot offer: + * {@link ReentrantLock#tryLock()}. + * + *

Layer 3 — {@code tryLock()} fast path, intrusive MPSC fallback. The overwhelmingly + * common case, even on a genuinely multiplexed connection, is exactly one stream wanting to write + * at a given instant. {@code tryLock()} on an uncontended lock is one successful CAS; the calling + * thread writes inline and releases — no handoff, no queue touched, no allocation, no context + * switch. Only when {@code tryLock()} fails (genuine contention) does the intent get published + * through {@link IntrusiveMpscQueue} (one more CAS, still zero allocation — the intent itself is + * the queue node) for the current lock holder to drain. + * + *

Lost-wakeup avoidance

+ * + * The classic hazard: a producer offers its intent to the queue at the exact moment the current + * holder has just found the queue empty and is about to unlock — the item would be stranded with + * nobody left to drain it. This is closed by two cooperating checks, and the correctness argument + * for why together they are sufficient is a happens-before chain through the queue's {@code + * AtomicReference} and the lock's own acquire/release ordering (recorded in full in {@code + * WRITER.md}, since it is exactly the kind of reasoning a future reader must be able to re-derive, + * not just trust): + * + *
+ * write(intent):
+ *   if tryLock() succeeds:           // 1 CAS, the fast path
+ *       drive(intent)                // write intent directly, then drain the queue, then unlock
+ *   else:
+ *       queue.offer(intent)          // 1 CAS, zero allocation
+ *       if tryLock() succeeds:       // the producer's own second chance
+ *           drive(null)              // drain whatever is queued, including our own intent
+ *
+ * drive(firstIntentOrNull):
+ *   write firstIntentOrNull if present, then poll-and-write until the queue is empty
+ *   unlock()
+ *   while queue.hasWork():           // the re-check-after-unlock that closes the race
+ *       if !tryLock(): break         // someone else is now responsible; their own recheck covers us
+ *       poll-and-write until empty
+ *       unlock()
+ * 
+ * + * A frame's bytes are never interleaved with another frame's bytes: every write of one intent is a + * single {@code sink.write} call issued while holding the lock, and the lock is not released + * between a {@code WriteIntent}'s bytes. + * + *

Write timeout

+ * + * A blocking write is unavoidable when the kernel send buffer is full and the peer is not reading — + * whoever holds the lock is blocked in the syscall, holding up every other stream on the + * connection. This is bounded by {@link Http2Limits#WRITE_TIMEOUT_MS}, enforced by a shared + * background reaper ({@link WriteTimeoutReaper}) that interrupts the blocked thread past the + * deadline — {@code Socket#setSoTimeout} bounds reads, not writes, so it cannot be used here. + * connection setup), so arming/disarming the deadline for each individual write is two {@code + * volatile} field writes, not an allocation. + */ +public final class Http2FrameWriter { + + /** + * What a frame's serialized bytes are ultimately written to. Kept minimal and separate from + * {@code java.io.OutputStream} so this class is testable without a real socket. + */ + public interface Sink { + void write(byte[] buf, int off, int len) throws IOException; + } + + private final Sink sink; + private final long writeTimeoutMs; + private final ReentrantLock lock = new ReentrantLock(); + private final IntrusiveMpscQueue priorityQueue = new IntrusiveMpscQueue(); + private final IntrusiveMpscQueue queue = new IntrusiveMpscQueue(); + + // Set only for the duration of an in-flight sink.write() call; see WriteTimeoutReaper. A + // single volatile write to arm, one to disarm — no timestamp is recorded here (see the + // reaper's own Javadoc for why: a per-write System.nanoTime() call measurably missed the + // N=1 gate's 50 ns overhead budget when this was first benchmarked, recorded in WRITER.md). + private volatile Thread writingThread; + + public Http2FrameWriter(Sink sink) { + this(sink, Http2Limits.WRITE_TIMEOUT_MS); + } + + public Http2FrameWriter(Sink sink, long writeTimeoutMs) { + this.sink = sink; + this.writeTimeoutMs = writeTimeoutMs; + WriteTimeoutReaper.register(this); + } + + /** + * Serializes and writes one frame. Returns when the bytes are in the socket buffer or safely + * queued behind another writer. Never blocks on another stream's I/O while holding the lock for + * longer than that stream's own single bulk write. + * + *

Why the fast path is gated on {@code !queue.hasWork()}, not just {@code tryLock()} + * Writing {@code intent} immediately, before anything already queued, is only safe when nothing + * is already queued. Without the {@code hasWork()} check, this sequence is possible — and + * violates same-producer ordering, which the stress test asserts: a producer's {@code write(a)} + * then {@code write(b)} contends and both get queued (fire-and-forget); the current holder is + * about to drain them but has not yet; that producer's very next call, {@code write(c)}, finds + * the lock free (the holder released it between the producer's calls) and would otherwise write + * {@code c} directly — landing on the wire before {@code a} and {@code b}, which are still + * sitting in the queue. Checking {@code hasWork()} first means "bypass the queue" only happens + * when the queue is observed genuinely empty, i.e. everything previously offered — by any + * producer — has already been written; see {@code WRITER.md} for the full argument. + */ + public void write(WriteIntent intent) throws IOException { + if (!priorityQueue.hasWork() && !queue.hasWork() && lock.tryLock()) { + drive(intent); + } else { + queue.offer(intent); + if (lock.tryLock()) { + drive(null); + } + } + } + + /** + * Writes a connection-control frame ahead of queued stream data. An already executing socket + * write is never interrupted, but once it completes the priority queue is drained before the + * ordinary queue. This is used for PING acknowledgements, SETTINGS acknowledgements, GOAWAY and + * RST_STREAM. + */ + public void writePriority(WriteIntent intent) throws IOException { + priorityQueue.offer(intent); + if (lock.tryLock()) { + drive(null); + } + } + + /** + * Flushes any queued intents. Called by the demux loop when it has nothing left to read — a no-op + * on the (overwhelmingly common) fast path where nothing is queued. + */ + public void drain() throws IOException { + if (!priorityQueue.hasWork() && !queue.hasWork()) return; + if (lock.tryLock()) { + drive(null); + } + } + + /** + * Deregisters this writer from the write-timeout reaper. Call once, when the connection closes. + */ + public void close() { + WriteTimeoutReaper.unregister(this); + } + + private void drive(WriteIntent firstIntentOrNull) throws IOException { + try { + if (firstIntentOrNull != null) writeDirect(firstIntentOrNull); + drainQueues(); + } finally { + lock.unlock(); + } + // Lost-wakeup fix: re-check after unlocking, looping because this cycle itself can race + // the same way — see the class Javadoc for the correctness argument. + while (priorityQueue.hasWork() || queue.hasWork()) { + if (!lock.tryLock()) break; + try { + drainQueues(); + } finally { + lock.unlock(); + } + } + } + + private void drainQueues() throws IOException { + WriteIntent next; + while (true) { + while ((next = priorityQueue.poll()) != null) { + writeDirect(next); + } + next = queue.poll(); + if (next == null) return; + writeDirect(next); + } + } + + private void writeDirect(WriteIntent intent) throws IOException { + writingThread = Thread.currentThread(); + try { + sink.write(intent.buffer(), intent.offset(), intent.length()); + } catch (IOException e) { + if (Thread.interrupted()) { + InterruptedIOException timeout = + new InterruptedIOException("HTTP/2 write timed out after ~" + writeTimeoutMs + " ms"); + timeout.initCause(e); + throw timeout; + } + throw e; + } finally { + writingThread = null; + Thread.interrupted(); // clear a stray interrupt flag defensively before returning control + intent.completed(); + } + } + + /** + * A single shared daemon thread scanning every registered {@link Http2FrameWriter} for a blocking + * write that has overrun {@link Http2Limits#WRITE_TIMEOUT_MS}. One thread for the whole process + * (like {@code DateHeader}'s refresher), not one per connection — registration per-write one. + * + *

Deliberately does not ask each write to record a {@code System.nanoTime()} {@code + * nanoTime()} call (plus the extra volatile field it required) costing enough to miss the N=1 + * gate's 50 ns-over-baseline budget (recorded in {@code WRITER.md}). Instead, the reaper counts + * consecutive scans a given writer has been observed still blocked ({@link + * #writingThread} non-null); a writer blocked for more than {@code WRITE_TIMEOUT_MS / + * SCAN_INTERVAL_MS} consecutive scans is interrupted. This trades a little precision (up to one + * scan interval of slop — already inherent to any background-reaper design) for removing all + * per-write timing cost. + */ + static final class WriteTimeoutReaper { + private static final long SCAN_INTERVAL_MS = 50; + private static final Set ACTIVE = ConcurrentHashMap.newKeySet(); + // Touched only by the single reaper thread -- no synchronization needed. + private static final java.util.Map BLOCKED_SCAN_COUNTS = + new java.util.IdentityHashMap<>(); + + static { + Thread reaper = + new Thread( + () -> { + while (true) { + try { + Thread.sleep(SCAN_INTERVAL_MS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (Http2FrameWriter writer : ACTIVE) { + Thread t = writer.writingThread; + if (t == null) { + BLOCKED_SCAN_COUNTS.remove(writer); + continue; + } + int scans = BLOCKED_SCAN_COUNTS.merge(writer, 1, Integer::sum); + long thresholdScans = Math.max(1, writer.writeTimeoutMs / SCAN_INTERVAL_MS); + if (scans >= thresholdScans) { + t.interrupt(); + BLOCKED_SCAN_COUNTS.remove(writer); + } + } + } + }, + "flash-http2-write-timeout-reaper"); + reaper.setDaemon(true); + reaper.start(); + } + + private WriteTimeoutReaper() {} + + static void register(Http2FrameWriter writer) { + ACTIVE.add(writer); + } + + static void unregister(Http2FrameWriter writer) { + ACTIVE.remove(writer); + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/frame/IntrusiveMpscQueue.java b/flash/src/main/java/dev/relism/flash/http2/frame/IntrusiveMpscQueue.java new file mode 100644 index 0000000..e1dd83e --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/frame/IntrusiveMpscQueue.java @@ -0,0 +1,101 @@ +package dev.relism.flash.http2.frame; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * A Vyukov-style intrusive multi-producer, single-consumer queue of {@link WriteIntent}s. + * "Intrusive" means the queued object is the node — {@link WriteIntent#mpscNext()} / + * {@link WriteIntent#setMpscNext} supply the linkage — so {@link #offer} allocates nothing: one + * {@link AtomicReference#getAndSet} CAS and that is the entire cost. + * + *

Only {@link Http2FrameWriter} calls {@link #poll()}

+ * This queue is safe for any number of concurrent {@link #offer} callers, but {@link #poll()} + * must only ever be called by the single thread currently holding the writer's lock — exactly + * the invariant {@code Http2FrameWriter} maintains (it never calls {@code poll()} without + * holding the lock). Calling {@code poll()} from two threads concurrently is undefined. + * + *

The stub node and the "inconsistent" result

+ * The queue always contains at least one node — a private, singleton {@code stub} — which lets + * {@link #offer} and {@link #poll} both proceed without ever observing a literal {@code null} + * head. A subtlety of this algorithm (documented here because it surprises readers unfamiliar + * with it, and it is the reason {@code Http2FrameWriter}'s drain loop is itself a loop, not a + * single pass): {@link #poll()} can return {@code null} even when {@link #offer} has completed + * and is "logically" enqueued, if that producer's {@code getAndSet} (which publishes the new + * tail pointer) has completed but its following {@code setMpscNext} (which links the *previous* + * tail to it) has not yet landed. This is a momentary, self-correcting race — the next + * {@code poll()} call (even from the same thread, immediately after) will see it — never a + * permanent loss. {@code Http2FrameWriter}'s lost-wakeup-avoidance protocol (see its Javadoc) + * already retries in exactly the way this requires. + */ +final class IntrusiveMpscQueue { + + /** + * Sentinel node that is never returned by {@link #poll()} and never appears anywhere except + * internally. Its own {@code mpscNext} field is the only piece of mutable state on it. + */ + private static final class Stub implements WriteIntent { + private volatile WriteIntent next; + + @Override public byte[] buffer() { throw new UnsupportedOperationException("stub node"); } + @Override public int offset() { throw new UnsupportedOperationException("stub node"); } + @Override public int length() { throw new UnsupportedOperationException("stub node"); } + @Override public WriteIntent mpscNext() { return next; } + @Override public void setMpscNext(WriteIntent next) { this.next = next; } + } + + private final Stub stub = new Stub(); + private final AtomicReference head = new AtomicReference<>(stub); + private WriteIntent tail = stub; // consumer-only; never touched by offer() + + /** Enqueues {@code node}. Safe from any number of concurrent threads. Zero allocation. */ + void offer(WriteIntent node) { + node.setMpscNext(null); + WriteIntent prev = head.getAndSet(node); + prev.setMpscNext(node); + } + + /** + * Dequeues the next intent, or {@code null} if the queue is empty or a producer is + * momentarily mid-{@link #offer} — see the class Javadoc. Single-consumer only. + */ + WriteIntent poll() { + WriteIntent t = tail; + WriteIntent next = t.mpscNext(); + + if (t == stub) { + if (next == null) { + return null; // genuinely empty + } + tail = next; + t = next; + next = t.mpscNext(); + } + + if (next != null) { + tail = next; + return t; + } + + WriteIntent h = head.get(); + if (t != h) { + return null; // producer mid-offer; momentary, retry later + } + + // t is the last real node and head hasn't moved past it: park the stub here so the + // next poll() (once a future offer() lands) has somewhere to advance from, then check + // whether t already gained a follower while we were doing this. + offer(stub); + next = t.mpscNext(); + if (next != null) { + tail = next; + return t; + } + return null; + } + + /** Cheap, conservative "might there be work" check — never a false negative, may be a false + * positive (harmless: the caller just attempts a {@code tryLock()} that finds nothing). */ + boolean hasWork() { + return head.get() != tail; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/frame/Padding.java b/flash/src/main/java/dev/relism/flash/http2/frame/Padding.java new file mode 100644 index 0000000..9f2acff --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/frame/Padding.java @@ -0,0 +1,66 @@ +package dev.relism.flash.http2.frame; + +import dev.relism.flash.bytes.Pairs; +import dev.relism.flash.http2.Http2ErrorCode; +import dev.relism.flash.http2.Http2Exception; + +/** + * RFC 9113 §6.1 (DATA) / §6.2 (HEADERS) padding. When {@link FrameFlags#PADDED} is set, a + * frame's payload is laid out as: 1 pad-length byte, then the actual data (or header-block + * fragment), then that many padding bytes (RFC 9113 gives no meaning to the padding bytes + * themselves — they exist only to obscure payload size from network observers). + * + *

Padding is not optional to support: any client may send it on DATA or HEADERS + * regardless of whether the server ever sends padded frames itself. + * + *

Flow control (forward note, not implemented here)

+ * RFC 9113 §6.9.1: padding bytes count against the DATA flow-control window even though they + * carry no data — the whole frame payload (pad-length byte + data + padding) is what a + * #dataLength(long)}. This class only locates the data range within the payload; it performs no + * flow-control accounting itself. + */ +public final class Padding { + private Padding() {} + + /** + * Locates the actual data range within a payload that may or may not be padded. When + * {@code padded} is {@code false}, returns the whole payload unchanged (zero-cost — no + * padding byte to read, no arithmetic beyond the pack). When {@code true}, reads the + * pad-length byte at {@code buf[payloadOffset]}, validates it, and returns the data range + * that follows it. + * + * @return {@code Pairs.pack(dataOffset, dataLength)} — unpack with {@link Pairs#hi}/{@link Pairs#lo} + * @throws Http2Exception ({@code PROTOCOL_ERROR}) if {@code padded} is set but + * {@code payloadLength == 0} (no room for the pad-length byte itself), or if the + * claimed pad length is greater than or equal to the whole payload length (RFC 9113 + * §6.1: "If the length of the padding is the length of the frame payload or + * greater, the recipient MUST treat this as a connection error") + */ + public static long unpad(byte[] buf, int payloadOffset, int payloadLength, boolean padded) { + if (!padded) { + return Pairs.pack(payloadOffset, payloadLength); + } + if (payloadLength == 0) { + throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, + "PADDED flag set but the frame has no payload for the pad-length byte"); + } + int padLength = buf[payloadOffset] & 0xFF; + if (padLength >= payloadLength) { + throw Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, + "pad length " + padLength + " >= frame payload length " + payloadLength); + } + int dataOffset = payloadOffset + 1; + int dataLength = payloadLength - 1 - padLength; + return Pairs.pack(dataOffset, dataLength); + } + + /** Extracts the data offset from a value returned by {@link #unpad}. */ + public static int dataOffset(long unpadded) { + return Pairs.hi(unpadded); + } + + /** Extracts the data length from a value returned by {@link #unpad}. */ + public static int dataLength(long unpadded) { + return Pairs.lo(unpadded); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/frame/WriteIntent.java b/flash/src/main/java/dev/relism/flash/http2/frame/WriteIntent.java new file mode 100644 index 0000000..c034a02 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/frame/WriteIntent.java @@ -0,0 +1,49 @@ +package dev.relism.flash.http2.frame; + +/** + * "Serialize yourself, then hand me the finished bytes." The interface a stream (and, eventually, + * connection-level singletons — the precompiled SETTINGS ACK, PING ACK, GOAWAY, WINDOW_UPDATE + * frames) implements to write through {@link Http2FrameWriter}. + * + *

Layer 1 — serialize outside the lock

+ * + * By the time {@link Http2FrameWriter#write} is called, the implementation has already built its + * complete output (frame header + HPACK block + payload, or whatever the frame needs) into a buffer + * it owns — a per-stream scratch buffer, reused across writes, never allocated per call. {@link + * #buffer()}/{@link #offset()}/{@link #length()} just describe where that already-finished output + * lives. {@code Http2FrameWriter} never serializes anything itself; it only ever issues one bulk + * {@code write(buffer, offset, length)} while holding the connection's write lock — see {@code + * WRITER.md} for why that distinction is the entire point of this design (the lock must never be + * held across serialization work, only across the syscall). + * + *

Intrusive queue linkage

+ * + * {@link #mpscNext()}/{@link #setMpscNext} are not part of the writer's public contract — they + * exist so a {@code WriteIntent} can double as an {@link IntrusiveMpscQueue} node with zero extra + * allocation when the writer is contended. Implementations provide simple field storage; nothing + * about the field is meaningful outside {@link IntrusiveMpscQueue}. + */ +public interface WriteIntent { + + /** The buffer holding this intent's already-serialized bytes. */ + byte[] buffer(); + + /** Offset of the first byte to write, within {@link #buffer()}. */ + int offset(); + + /** Number of bytes to write, starting at {@link #offset()}. */ + int length(); + + /** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */ + WriteIntent mpscNext(); + + /** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */ + void setMpscNext(WriteIntent next); + + /** + * Called exactly once after this intent leaves the writer, whether the socket write succeeded or + * failed. Pooled control-frame intents use this hook to return their slot to the owning + * connection without allocating a completion object. + */ + default void completed() {} +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/ContinuationAssembler.java b/flash/src/main/java/dev/relism/flash/http2/hpack/ContinuationAssembler.java new file mode 100644 index 0000000..5a26a9b --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/ContinuationAssembler.java @@ -0,0 +1,84 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.flash.http2.Http2Exception; +import dev.relism.flash.http2.Http2Limits; + +/** Reassembles one HEADERS/CONTINUATION sequence into a bounded contiguous connection buffer. */ +public final class ContinuationAssembler { + private final byte[] buffer; + private int streamId; + private int length; + private int continuationCount; + private boolean active; + private boolean complete; + + public ContinuationAssembler() { + this(Http2Limits.MAX_HEADER_LIST_SIZE); + } + + public ContinuationAssembler(int maximumBlockSize) { + if (maximumBlockSize <= 0) throw new IllegalArgumentException("non-positive block size"); + buffer = new byte[maximumBlockSize]; + } + + public void begin( + int streamId, byte[] source, int offset, int fragmentLength, boolean endHeaders) { + if (active || streamId <= 0) throw Http2Exception.PROTOCOL_ERROR; + reset(); + this.streamId = streamId; + append(source, offset, fragmentLength); + complete = endHeaders; + active = !endHeaders; + } + + public void continuation( + int streamId, byte[] source, int offset, int fragmentLength, boolean endHeaders) { + if (!active || streamId != this.streamId) throw Http2Exception.PROTOCOL_ERROR; + if (++continuationCount > Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK) { + throw Http2Exception.PROTOCOL_ERROR; + } + append(source, offset, fragmentLength); + complete = endHeaders; + active = !endHeaders; + } + + public byte[] buffer() { + return buffer; + } + + public int length() { + return length; + } + + public int streamId() { + return streamId; + } + + public boolean isComplete() { + return complete; + } + + public boolean isActive() { + return active; + } + + public void reset() { + streamId = 0; + length = 0; + continuationCount = 0; + active = false; + complete = false; + } + + private void append(byte[] source, int offset, int fragmentLength) { + if (source == null + || offset < 0 + || fragmentLength < 0 + || offset > source.length - fragmentLength + || fragmentLength > buffer.length - length) { + throw Http2Exception.COMPRESSION_ERROR; + } + System.arraycopy(source, offset, buffer, length, fragmentLength); + length += fragmentLength; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderListSizeException.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderListSizeException.java new file mode 100644 index 0000000..79d4e02 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderListSizeException.java @@ -0,0 +1,20 @@ +package dev.relism.flash.http2.hpack; + +/** + * Signals that a fully decoded HPACK block exceeded the configured header-list limit. The decoder + * delays this exception until the complete block has been consumed so dynamic-table state remains + * synchronized with the peer. The stream layer maps it to a request rejection without closing the + * HTTP/2 connection. + */ +public final class HeaderListSizeException extends RuntimeException { + private final long decodedSize; + + HeaderListSizeException(long decodedSize) { + super("decoded header list exceeds limit: " + decodedSize, null, false, false); + this.decodedSize = decodedSize; + } + + public long decodedSize() { + return decodedSize; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderSink.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderSink.java new file mode 100644 index 0000000..2d455a7 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HeaderSink.java @@ -0,0 +1,13 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.fpr.core.ByteView; + +/** Receives decoded HPACK fields in wire order. */ +@FunctionalInterface +public interface HeaderSink { + /** + * Accepts one field. The views are valid only for the duration of this call; a sink that needs + * them afterwards must copy them into storage owned by the stream. + */ + void accept(ByteView name, ByteView value, boolean neverIndexed); +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDecoder.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDecoder.java new file mode 100644 index 0000000..4d1afa9 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDecoder.java @@ -0,0 +1,156 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.flash.bytes.Pairs; +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.flash.http2.Http2Exception; +import dev.relism.flash.http2.Http2Limits; +import dev.relism.fpr.core.ByteView; + +/** Stateful, allocation-free HPACK decoder for one HTTP/2 connection direction. */ +public final class HpackDecoder { + private final HpackDynamicTable dynamicTable; + private final int maximumHeaderListSize; + private final byte[] nameScratch = new byte[Http2Limits.MAX_HPACK_STRING_LENGTH]; + private final byte[] valueScratch = new byte[Http2Limits.MAX_HPACK_STRING_LENGTH]; + private final PooledSlice nameView = new PooledSlice(); + private final PooledSlice valueView = new PooledSlice(); + + public HpackDecoder(int advertisedTableSize, int maximumHeaderListSize) { + if (maximumHeaderListSize < 0) throw new IllegalArgumentException("negative header-list size"); + this.dynamicTable = new HpackDynamicTable(advertisedTableSize); + this.maximumHeaderListSize = maximumHeaderListSize; + } + + public HpackDecoder() { + this(Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL, Http2Limits.MAX_HEADER_LIST_SIZE); + } + + /** Decodes one complete header block. */ + public void decode(byte[] buffer, int offset, int length, HeaderSink sink) { + if (buffer == null + || sink == null + || offset < 0 + || length < 0 + || offset > buffer.length - length) { + throw new IllegalArgumentException("invalid HPACK decode arguments"); + } + + int position = offset; + int limit = offset + length; + boolean sawHeader = false; + boolean oversized = false; + long headerListSize = 0; + + while (position < limit) { + int first = buffer[position] & 0xff; + if ((first & 0x80) != 0) { + long decoded = HpackIntegers.decode(buffer, position, limit, 7); + int index = Pairs.hi(decoded); + position = Pairs.lo(decoded); + if (index == 0) throw Http2Exception.COMPRESSION_ERROR; + resolve(index, nameView, valueView); + sawHeader = true; + headerListSize += fieldSize(nameView, valueView); + if (headerListSize <= maximumHeaderListSize) sink.accept(nameView, valueView, false); + else oversized = true; + continue; + } + + if ((first & 0x40) != 0) { + long decoded = HpackIntegers.decode(buffer, position, limit, 6); + int nameIndex = Pairs.hi(decoded); + position = Pairs.lo(decoded); + position = decodeName(buffer, position, limit, nameIndex); + position = decodeString(buffer, position, limit, valueScratch, valueView); + sawHeader = true; + headerListSize += fieldSize(nameView, valueView); + if (headerListSize <= maximumHeaderListSize) sink.accept(nameView, valueView, false); + else oversized = true; + dynamicTable.add(nameView, valueView); + continue; + } + + if ((first & 0x20) != 0) { + if (sawHeader) throw Http2Exception.COMPRESSION_ERROR; + long decoded = HpackIntegers.decode(buffer, position, limit, 5); + dynamicTable.setMaximumSize(Pairs.hi(decoded)); + position = Pairs.lo(decoded); + continue; + } + + boolean neverIndexed = (first & 0x10) != 0; + long decoded = HpackIntegers.decode(buffer, position, limit, 4); + int nameIndex = Pairs.hi(decoded); + position = Pairs.lo(decoded); + position = decodeName(buffer, position, limit, nameIndex); + position = decodeString(buffer, position, limit, valueScratch, valueView); + sawHeader = true; + headerListSize += fieldSize(nameView, valueView); + if (headerListSize <= maximumHeaderListSize) sink.accept(nameView, valueView, neverIndexed); + else oversized = true; + } + + if (oversized) throw new HeaderListSizeException(headerListSize); + } + + public HpackDynamicTable dynamicTable() { + return dynamicTable; + } + + private int decodeName(byte[] buffer, int position, int limit, int index) { + if (index != 0) { + resolveName(index, nameView); + return position; + } + return decodeString(buffer, position, limit, nameScratch, nameView); + } + + private static int decodeString( + byte[] buffer, int position, int limit, byte[] scratch, PooledSlice output) { + if (position >= limit) throw Http2Exception.COMPRESSION_ERROR; + boolean huffman = (buffer[position] & 0x80) != 0; + long decoded = HpackIntegers.decode(buffer, position, limit, 7); + int encodedLength = Pairs.hi(decoded); + int dataStart = Pairs.lo(decoded); + if (encodedLength > limit - dataStart) throw Http2Exception.COMPRESSION_ERROR; + if (huffman) { + int decodedLength = + Huffman.decode(buffer, dataStart, encodedLength, scratch, 0, scratch.length); + output.reset(scratch, 0, decodedLength); + } else { + if (encodedLength > Http2Limits.MAX_HPACK_STRING_LENGTH) + throw Http2Exception.COMPRESSION_ERROR; + output.reset(buffer, dataStart, encodedLength); + } + return dataStart + encodedLength; + } + + private void resolve(int index, PooledSlice name, PooledSlice value) { + if (index <= HpackStaticTable.LENGTH) { + byte[] staticName = HpackStaticTable.name(index); + byte[] staticValue = HpackStaticTable.value(index); + name.reset(staticName, 0, staticName.length); + value.reset(staticValue, 0, staticValue.length); + return; + } + dynamicTable.get(index - HpackStaticTable.LENGTH, name, value); + } + + private void resolveName(int index, PooledSlice name) { + if (index <= 0) throw Http2Exception.COMPRESSION_ERROR; + if (index <= HpackStaticTable.LENGTH) { + byte[] staticName = HpackStaticTable.name(index); + name.reset(staticName, 0, staticName.length); + return; + } + dynamicTable.get(index - HpackStaticTable.LENGTH, name, valueView); + // An incremental-indexing representation can evict or compact the entry that supplied its + // indexed name. Preserve the name before insertion mutates the dynamic table arena. + System.arraycopy(name.array(), name.offset(), nameScratch, 0, name.length()); + name.reset(nameScratch, 0, name.length()); + } + + private static long fieldSize(ByteView name, ByteView value) { + return (long) name.length() + value.length() + 32; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDynamicTable.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDynamicTable.java new file mode 100644 index 0000000..7b48595 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackDynamicTable.java @@ -0,0 +1,132 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.flash.bytes.ArrayBackedByteView; +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.flash.http2.Http2Exception; +import dev.relism.fpr.core.ByteView; + +/** + * Per-connection HPACK dynamic table. Entries are kept in FIFO order in a descriptor ring while + * their bytes live in one bounded arena. The arena is compacted only when its free tail cannot hold + * the next entry, keeping every returned view contiguous. + */ +public final class HpackDynamicTable { + private final byte[] arena; + private final int[] nameOffsets; + private final int[] nameLengths; + private final int[] valueOffsets; + private final int[] valueLengths; + private final int advertisedMaximum; + + private int maximumSize; + private int currentSize; + private int head; + private int count; + private int arenaEnd; + + public HpackDynamicTable(int advertisedMaximum) { + if (advertisedMaximum < 0) throw new IllegalArgumentException("negative HPACK table size"); + this.advertisedMaximum = advertisedMaximum; + this.maximumSize = advertisedMaximum; + this.arena = new byte[Math.max(1, advertisedMaximum)]; + int entryCapacity = Math.max(1, advertisedMaximum / 32 + 1); + this.nameOffsets = new int[entryCapacity]; + this.nameLengths = new int[entryCapacity]; + this.valueOffsets = new int[entryCapacity]; + this.valueLengths = new int[entryCapacity]; + } + + public int count() { + return count; + } + + public int size() { + return currentSize; + } + + public int maximumSize() { + return maximumSize; + } + + /** Applies an RFC 7541 §4.2 table-size update and evicts oldest entries as necessary. */ + public void setMaximumSize(int newMaximum) { + if (newMaximum < 0 || newMaximum > advertisedMaximum) throw Http2Exception.COMPRESSION_ERROR; + maximumSize = newMaximum; + evictToFit(0); + if (count == 0) arenaEnd = 0; + } + + /** Inserts a new entry, copying its bytes before performing FIFO eviction. */ + public void add(ByteView name, ByteView value) { + int byteLength = name.length() + value.length(); + int entrySize = byteLength + 32; + if (entrySize > maximumSize) { + clear(); + return; + } + + evictToFit(entrySize); + if (arena.length - arenaEnd < byteLength) compact(); + + int slot = (head + count) % nameOffsets.length; + nameOffsets[slot] = arenaEnd; + nameLengths[slot] = name.length(); + copy(name, arena, arenaEnd); + arenaEnd += name.length(); + valueOffsets[slot] = arenaEnd; + valueLengths[slot] = value.length(); + copy(value, arena, arenaEnd); + arenaEnd += value.length(); + count++; + currentSize += entrySize; + } + + /** Resolves a dynamic index where {@code 1} is the newest entry. */ + public void get(int relativeIndex, PooledSlice name, PooledSlice value) { + if (relativeIndex < 1 || relativeIndex > count) throw Http2Exception.COMPRESSION_ERROR; + int slot = (head + count - relativeIndex) % nameOffsets.length; + name.reset(arena, nameOffsets[slot], nameLengths[slot]); + value.reset(arena, valueOffsets[slot], valueLengths[slot]); + } + + public void clear() { + head = 0; + count = 0; + currentSize = 0; + arenaEnd = 0; + } + + private void evictToFit(int incomingSize) { + while (count > 0 && currentSize + incomingSize > maximumSize) { + int slot = head; + currentSize -= nameLengths[slot] + valueLengths[slot] + 32; + head = (head + 1) % nameOffsets.length; + count--; + } + if (count == 0) arenaEnd = 0; + } + + private void compact() { + int destination = 0; + for (int i = 0; i < count; i++) { + int slot = (head + i) % nameOffsets.length; + int nameLength = nameLengths[slot]; + int valueLength = valueLengths[slot]; + System.arraycopy(arena, nameOffsets[slot], arena, destination, nameLength); + nameOffsets[slot] = destination; + destination += nameLength; + System.arraycopy(arena, valueOffsets[slot], arena, destination, valueLength); + valueOffsets[slot] = destination; + destination += valueLength; + } + arenaEnd = destination; + } + + private static void copy(ByteView source, byte[] target, int offset) { + if (source instanceof ArrayBackedByteView contiguous) { + System.arraycopy(contiguous.array(), contiguous.offset(), target, offset, source.length()); + return; + } + for (int i = 0; i < source.length(); i++) target[offset + i] = source.byteAt(i); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackEncoder.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackEncoder.java new file mode 100644 index 0000000..aedf0aa --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackEncoder.java @@ -0,0 +1,108 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.fpr.core.ByteView; + +/** + * Stateless HPACK encoder for response header blocks. It uses the RFC 7541 static table and literal + * fields without indexing; consequently, concurrent streams never share mutable encoder state. + */ +public final class HpackEncoder { + private HpackEncoder() {} + + /** Declares that this endpoint will not use an encoder-side dynamic table. */ + public static void writeDynamicTableSizeUpdateZero(ByteWriter out) { + HpackIntegers.encode(out, 0x20, 5, 0); + } + + public static void writeIndexed(ByteWriter out, int staticIndex) { + if (staticIndex < 1 || staticIndex > HpackStaticTable.LENGTH) { + throw new IllegalArgumentException("invalid HPACK static index: " + staticIndex); + } + HpackIntegers.encode(out, 0x80, 7, staticIndex); + } + + public static void writeLiteral(ByteWriter out, byte[] name, byte[] value) { + writeLiteral(out, name, 0, name.length, value, 0, value.length, false); + } + + /** Writes a non-indexed literal directly from protocol-neutral byte views. */ + public static void writeLiteral(ByteWriter out, ByteView name, ByteView value) { + HpackIntegers.encode(out, 0, 4, 0); + HpackIntegers.encode(out, 0, 7, name.length()); + for (int i = 0; i < name.length(); i++) { + int octet = name.byteAt(i) & 0xff; + if (octet >= 'A' && octet <= 'Z') octet += 'a' - 'A'; + out.writeByte((byte) octet); + } + HpackIntegers.encode(out, 0, 7, value.length()); + for (int i = 0; i < value.length(); i++) out.writeByte(value.byteAt(i)); + } + + public static void writeLiteral( + ByteWriter out, + byte[] name, + int nameOff, + int nameLen, + byte[] value, + int valueOff, + int valueLen, + boolean huffmanValue) { + HpackIntegers.encode(out, 0, 4, 0); + writeLowercaseName(out, name, nameOff, nameLen); + writeString(out, value, valueOff, valueLen, huffmanValue); + } + + public static void writeLiteralWithNameIndex( + ByteWriter out, int nameIndex, byte[] value, boolean huffmanValue) { + writeLiteralWithNameIndex(out, nameIndex, value, 0, value.length, huffmanValue); + } + + public static void writeLiteralWithNameIndex( + ByteWriter out, + int nameIndex, + byte[] value, + int valueOff, + int valueLen, + boolean huffmanValue) { + if (nameIndex < 1 || nameIndex > HpackStaticTable.LENGTH) { + throw new IllegalArgumentException("invalid HPACK static name index: " + nameIndex); + } + HpackIntegers.encode(out, 0, 4, nameIndex); + writeString(out, value, valueOff, valueLen, huffmanValue); + } + + public static void writeLiteralNeverIndexed( + ByteWriter out, byte[] name, byte[] value, boolean huffmanValue) { + HpackIntegers.encode(out, 0x10, 4, 0); + writeLowercaseName(out, name, 0, name.length); + writeString(out, value, 0, value.length, huffmanValue); + } + + public static void writeLiteralNeverIndexedWithNameIndex( + ByteWriter out, int nameIndex, byte[] value, boolean huffmanValue) { + HpackIntegers.encode(out, 0x10, 4, nameIndex); + writeString(out, value, 0, value.length, huffmanValue); + } + + private static void writeLowercaseName(ByteWriter out, byte[] name, int nameOff, int nameLen) { + HpackIntegers.encode(out, 0, 7, nameLen); + int end = nameOff + nameLen; + for (int i = nameOff; i < end; i++) { + int value = name[i] & 0xff; + if (value >= 'A' && value <= 'Z') value += 'a' - 'A'; + assert value < 'A' || value > 'Z' : "HTTP/2 field names must be lowercase"; + out.writeByte((byte) value); + } + } + + private static void writeString(ByteWriter out, byte[] value, int off, int len, boolean huffman) { + if (huffman) { + HpackIntegers.encode(out, 0x80, 7, Huffman.encodedLength(value, off, len)); + Huffman.encode(out, value, off, len); + } else { + HpackIntegers.encode(out, 0, 7, len); + out.writeBytes(value, off, len); + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackHeaderBlock.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackHeaderBlock.java new file mode 100644 index 0000000..fae66a8 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackHeaderBlock.java @@ -0,0 +1,85 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.flash.bytes.ArrayBackedByteView; +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.flash.http2.Http2Limits; +import dev.relism.fpr.core.ByteView; + +/** + * Reusable per-stream storage for decoded header fields. Copying at the decoder boundary makes a + * stream independent of later HPACK dynamic-table eviction on the connection thread. + */ +public final class HpackHeaderBlock implements HeaderSink { + private final byte[] arena; + private final int[] nameOffsets; + private final int[] nameLengths; + private final int[] valueOffsets; + private final int[] valueLengths; + private final boolean[] neverIndexed; + private int arenaEnd; + private int count; + + public HpackHeaderBlock() { + this(Http2Limits.MAX_HEADER_LIST_SIZE, Http2Limits.MAX_HEADER_LIST_SIZE / 32 + 1); + } + + HpackHeaderBlock(int arenaCapacity, int fieldCapacity) { + arena = new byte[arenaCapacity]; + nameOffsets = new int[fieldCapacity]; + nameLengths = new int[fieldCapacity]; + valueOffsets = new int[fieldCapacity]; + valueLengths = new int[fieldCapacity]; + neverIndexed = new boolean[fieldCapacity]; + } + + public void reset() { + arenaEnd = 0; + count = 0; + } + + public int count() { + return count; + } + + public boolean neverIndexed(int index) { + checkIndex(index); + return neverIndexed[index]; + } + + public void get(int index, PooledSlice name, PooledSlice value) { + checkIndex(index); + name.reset(arena, nameOffsets[index], nameLengths[index]); + value.reset(arena, valueOffsets[index], valueLengths[index]); + } + + @Override + public void accept(ByteView name, ByteView value, boolean sensitive) { + int bytes = name.length() + value.length(); + if (count >= nameOffsets.length || bytes > arena.length - arenaEnd) { + throw new IllegalStateException("decoded header block exceeds its configured storage"); + } + nameOffsets[count] = arenaEnd; + nameLengths[count] = name.length(); + copy(name, arenaEnd); + arenaEnd += name.length(); + valueOffsets[count] = arenaEnd; + valueLengths[count] = value.length(); + copy(value, arenaEnd); + arenaEnd += value.length(); + neverIndexed[count] = sensitive; + count++; + } + + private void copy(ByteView source, int destination) { + if (source instanceof ArrayBackedByteView contiguous) { + System.arraycopy( + contiguous.array(), contiguous.offset(), arena, destination, source.length()); + return; + } + for (int i = 0; i < source.length(); i++) arena[destination + i] = source.byteAt(i); + } + + private void checkIndex(int index) { + if (index < 0 || index >= count) throw new IndexOutOfBoundsException(index); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackIntegers.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackIntegers.java new file mode 100644 index 0000000..ac70eed --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackIntegers.java @@ -0,0 +1,96 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.bytes.Pairs; +import dev.relism.flash.http2.Http2Exception; + +/** + * RFC 7541 §5.1 prefix-coded integer encode/decode. An {@code N}-bit prefix holds values {@code + * 0..2^N-2} directly; the sentinel {@code 2^N-1} means "the real value is at least this large, keep + * reading continuation octets" — each contributes 7 bits, low-to-high, with the high bit as a + * continue flag. + * + *

Overflow safety ({@code HPACK bomb})

+ * + * RFC 7541 places no upper bound on the number of continuation octets — a hostile peer can encode + * an arbitrarily large integer (conceptually up to 2^64 and beyond) in a handful of bytes. {@link + * #decode} rejects any integer needing more than {@link #MAX_CONTINUATION_OCTETS} continuation + * octets, and independently rejects one that would exceed {@link Integer#MAX_VALUE} even within + * that octet budget — belt-and-suspenders, since with the octet cap in place the second check is + * not expected to ever fire on a real input. Both throw the preallocated {@link + * Http2Exception#COMPRESSION_ERROR} singleton — zero allocation on this hot rejection path (see + * that exception's own Javadoc for why reusing a singleton here is safe). + */ +public final class HpackIntegers { + + private HpackIntegers() {} + + /** + * Maximum number of continuation octets {@link #decode} accepts. 4 octets contribute {@code 4 * 7 + * = 28} bits beyond the prefix — comfortably enough for any legitimate HPACK integer (table + * indices, string lengths, table size updates are all far smaller in practice) while keeping a + * hostile peer's worst case bounded to a handful of wasted bytes per rejected block, not an + * unbounded read loop. + */ + private static final int MAX_CONTINUATION_OCTETS = 4; + + /** + * Decodes a prefix-coded integer starting at {@code buf[pos]}, where the low {@code prefixBits} + * bits of {@code buf[pos]} carry the prefix (any higher bits — e.g. a representation's leading + * flag bits — are the caller's concern and are masked off here). {@code limit} is the exclusive + * end of the region this integer may read from (typically the end of the current HPACK block) — + * reading past it means a truncated/malformed encoding, not "need more input", since by the time + * this runs the whole block is already contiguous in memory (RFC 9113 §6.10: CONTINUATION frames + * are never interleaved with other frames). + * + * @return {@link Pairs#pack}({@code value}, {@code newPos}) — the decoded value in the high 32 + * bits, the position just past the last consumed byte in the low 32 bits + * @throws Http2Exception {@code COMPRESSION_ERROR} on truncation, on exceeding {@link + * #MAX_CONTINUATION_OCTETS}, or on a value that would exceed {@link Integer#MAX_VALUE} + */ + public static long decode(byte[] buf, int pos, int limit, int prefixBits) { + if (pos >= limit) throw Http2Exception.COMPRESSION_ERROR; + int prefixMask = (1 << prefixBits) - 1; + int first = buf[pos] & 0xFF; + int value = first & prefixMask; + int p = pos + 1; + if (value < prefixMask) { + return Pairs.pack(value, p); + } + + long accumulated = prefixMask; + int shift = 0; + int continuationOctets = 0; + while (true) { + if (p >= limit) throw Http2Exception.COMPRESSION_ERROR; + if (++continuationOctets > MAX_CONTINUATION_OCTETS) throw Http2Exception.COMPRESSION_ERROR; + int b = buf[p++] & 0xFF; + accumulated += (long) (b & 0x7F) << shift; + if (accumulated > Integer.MAX_VALUE) throw Http2Exception.COMPRESSION_ERROR; + if ((b & 0x80) == 0) break; + shift += 7; + } + return Pairs.pack((int) accumulated, p); + } + + /** + * Encodes {@code value} as a prefix-coded integer into {@code prefixByteFlags | encoded value}, + * writing into {@code out}. {@code prefixByteFlags} carries whatever high bits the representation + * needs (e.g. {@code 0x80} for an Indexed Header Field) already shifted into position — this + * method only ever sets the low {@code prefixBits} bits of the first byte. + */ + public static void encode(ByteWriter out, int prefixByteFlags, int prefixBits, int value) { + int prefixMask = (1 << prefixBits) - 1; + if (value < prefixMask) { + out.writeByte((byte) (prefixByteFlags | value)); + return; + } + out.writeByte((byte) (prefixByteFlags | prefixMask)); + int remaining = value - prefixMask; + while (remaining >= 0x80) { + out.writeByte((byte) ((remaining & 0x7F) | 0x80)); + remaining >>>= 7; + } + out.writeByte((byte) remaining); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/HpackStaticTable.java b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackStaticTable.java new file mode 100644 index 0000000..a2a247d --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/HpackStaticTable.java @@ -0,0 +1,169 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.fpr.core.ByteView; +import java.nio.charset.StandardCharsets; + +/** The immutable 61-entry HPACK static table defined by RFC 7541 Appendix A. */ +public final class HpackStaticTable { + public static final int LENGTH = 61; + + private static final byte[][] NAMES = new byte[LENGTH + 1][]; + private static final byte[][] VALUES = new byte[LENGTH + 1][]; + private static final int[] NAME_INDEX = new int[128]; + private static final int[] PAIR_INDEX = new int[128]; + + static { + add(1, ":authority", ""); + add(2, ":method", "GET"); + add(3, ":method", "POST"); + add(4, ":path", "/"); + add(5, ":path", "/index.html"); + add(6, ":scheme", "http"); + add(7, ":scheme", "https"); + add(8, ":status", "200"); + add(9, ":status", "204"); + add(10, ":status", "206"); + add(11, ":status", "304"); + add(12, ":status", "400"); + add(13, ":status", "404"); + add(14, ":status", "500"); + add(15, "accept-charset", ""); + add(16, "accept-encoding", "gzip, deflate"); + add(17, "accept-language", ""); + add(18, "accept-ranges", ""); + add(19, "accept", ""); + add(20, "access-control-allow-origin", ""); + add(21, "age", ""); + add(22, "allow", ""); + add(23, "authorization", ""); + add(24, "cache-control", ""); + add(25, "content-disposition", ""); + add(26, "content-encoding", ""); + add(27, "content-language", ""); + add(28, "content-length", ""); + add(29, "content-location", ""); + add(30, "content-range", ""); + add(31, "content-type", ""); + add(32, "cookie", ""); + add(33, "date", ""); + add(34, "etag", ""); + add(35, "expect", ""); + add(36, "expires", ""); + add(37, "from", ""); + add(38, "host", ""); + add(39, "if-match", ""); + add(40, "if-modified-since", ""); + add(41, "if-none-match", ""); + add(42, "if-range", ""); + add(43, "if-unmodified-since", ""); + add(44, "last-modified", ""); + add(45, "link", ""); + add(46, "location", ""); + add(47, "max-forwards", ""); + add(48, "proxy-authenticate", ""); + add(49, "proxy-authorization", ""); + add(50, "range", ""); + add(51, "referer", ""); + add(52, "refresh", ""); + add(53, "retry-after", ""); + add(54, "server", ""); + add(55, "set-cookie", ""); + add(56, "strict-transport-security", ""); + add(57, "transfer-encoding", ""); + add(58, "user-agent", ""); + add(59, "vary", ""); + add(60, "via", ""); + add(61, "www-authenticate", ""); + + for (int i = LENGTH; i >= 1; i--) { + put(NAME_INDEX, hash(NAMES[i]), i, false); + put(PAIR_INDEX, hashPair(NAMES[i], VALUES[i]), i, true); + } + } + + private HpackStaticTable() {} + + private static void add(int index, String name, String value) { + NAMES[index] = name.getBytes(StandardCharsets.US_ASCII); + VALUES[index] = value.getBytes(StandardCharsets.US_ASCII); + } + + public static byte[] name(int index) { + checkIndex(index); + return NAMES[index]; + } + + public static byte[] value(int index) { + checkIndex(index); + return VALUES[index]; + } + + public static int findName(ByteView name) { + int slot = hash(name) & (NAME_INDEX.length - 1); + while (NAME_INDEX[slot] != 0) { + int index = NAME_INDEX[slot]; + if (equals(name, NAMES[index])) return index; + slot = (slot + 1) & (NAME_INDEX.length - 1); + } + return 0; + } + + public static int findPair(ByteView name, ByteView value) { + int slot = hashPair(name, value) & (PAIR_INDEX.length - 1); + while (PAIR_INDEX[slot] != 0) { + int index = PAIR_INDEX[slot]; + if (equals(name, NAMES[index]) && equals(value, VALUES[index])) return index; + slot = (slot + 1) & (PAIR_INDEX.length - 1); + } + return 0; + } + + private static void put(int[] table, int hash, int index, boolean pair) { + int slot = hash & (table.length - 1); + while (table[slot] != 0 + && !(equals(NAMES[index], NAMES[table[slot]]) + && (!pair || equals(VALUES[index], VALUES[table[slot]])))) { + slot = (slot + 1) & (table.length - 1); + } + table[slot] = index; + } + + private static int hash(ByteView value) { + int hash = 0x811C9DC5; + for (int i = 0; i < value.length(); i++) hash = (hash ^ (value.byteAt(i) & 0xff)) * 0x01000193; + return hash; + } + + private static int hash(byte[] value) { + int hash = 0x811C9DC5; + for (byte b : value) hash = (hash ^ (b & 0xff)) * 0x01000193; + return hash; + } + + private static int hashPair(ByteView name, ByteView value) { + int hash = hash(name); + for (int i = 0; i < value.length(); i++) hash = (hash ^ (value.byteAt(i) & 0xff)) * 0x01000193; + return hash; + } + + private static int hashPair(byte[] name, byte[] value) { + int hash = hash(name); + for (byte b : value) hash = (hash ^ (b & 0xff)) * 0x01000193; + return hash; + } + + private static boolean equals(ByteView view, byte[] bytes) { + if (view.length() != bytes.length) return false; + for (int i = 0; i < bytes.length; i++) if (view.byteAt(i) != bytes[i]) return false; + return true; + } + + private static boolean equals(byte[] left, byte[] right) { + return java.util.Arrays.equals(left, right); + } + + private static void checkIndex(int index) { + if (index < 1 || index > LENGTH) + throw new IndexOutOfBoundsException("HPACK static index " + index); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/hpack/Huffman.java b/flash/src/main/java/dev/relism/flash/http2/hpack/Huffman.java new file mode 100644 index 0000000..939b6ad --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/hpack/Huffman.java @@ -0,0 +1,344 @@ +package dev.relism.flash.http2.hpack; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.Http2Exception; + +/** + * RFC 7541 §5.2 / Appendix B: the fixed canonical Huffman code used to compress HPACK string + * literals. {@link #CODES}/{@link #LENGTHS} are transcribed verbatim from Appendix B (each pair + * cross-checked against the RFC's own "code as hex" / "code as bits" columns, which the RFC gives + * redundantly for exactly this reason — a transcription error in either column disagrees with the + * other). Every other structure in this class — the decode trie, the nibble-driven FSM, the + * padding-validity table — is built from that one 257-row table at class-init time, not + * hand-derived, so a mistake in this class's own logic (as opposed to the RFC table itself) shows + * up as a decode/round-trip test failure rather than a silently wrong hand-written FSM. + * + *

Decoding: a nibble-driven FSM

+ * + * {@link #decode} processes each input byte as two 4-bit nibbles (high nibble, then low), doing one + * array lookup per nibble instead of one branch per bit. Each {@link #TRANSITIONS} entry packs: the + * next trie state, whether a symbol was completed while consuming this nibble's 4 bits (at most one + * — the shortest real code is 5 bits, longer than a nibble, so two symbols can never complete + * within a single nibble transition, see {@link #buildTransitionTable} for the proof this relies + * on), and that symbol's byte value if so. A "dead" transition (this nibble's bits cannot be a + * prefix of any valid code, at this position) is a distinct packed flag the decode loop checks + * first. + * + *

Padding (RFC 7541 §5.2)

+ * + * A Huffman-coded string is padded to a byte boundary with the high-order bits of the EOS code (all + * 1s), strictly fewer than 8 of them. Inserting the EOS code itself into the trie (as a real, if + * never-emittable, leaf) means every prefix of the all-1s path already exists as a trie node from + * ordinary trie construction — {@link #buildPaddingValidity} marks exactly those nodes (reachable + * only via 1-bits from the root, depth 1..7) as valid end-of-input states. Anything else left over + * when the input ends — an incomplete real code, or 8+ bits of trailing 1s — is {@code + * COMPRESSION_ERROR}, and so is the EOS symbol appearing anywhere in the input (RFC 7541 §5.2: "a + * Huffman-encoded string literal containing the EOS symbol MUST be treated as a decoding error"). + */ +public final class Huffman { + + private Huffman() {} + + /** + * Symbol id used internally for the EOS code (RFC 7541 Appendix B, row 256) — one past the last + * real byte value; never a legal decode output. + */ + private static final int EOS_SYMBOL = 256; + + // RFC 7541 Appendix B, verbatim: CODES[s]/LENGTHS[s] is symbol s's code (LSB-aligned, per the + // RFC's own "code as hex" column) and its bit length, for s in [0, 255] plus EOS at s = 256. + private static final int[] CODES = { + 0x1ff8, 0x7fffd8, 0xfffffe2, 0xfffffe3, 0xfffffe4, 0xfffffe5, 0xfffffe6, 0xfffffe7, 0xfffffe8, + 0xffffea, + 0x3ffffffc, 0xfffffe9, 0xfffffea, 0x3ffffffd, 0xfffffeb, 0xfffffec, 0xfffffed, 0xfffffee, + 0xfffffef, 0xffffff0, + 0xffffff1, 0xffffff2, 0x3ffffffe, 0xffffff3, 0xffffff4, 0xffffff5, 0xffffff6, 0xffffff7, + 0xffffff8, 0xffffff9, + 0xffffffa, 0xffffffb, 0x14, 0x3f8, 0x3f9, 0xffa, 0x1ff9, 0x15, 0xf8, 0x7fa, + 0x3fa, 0x3fb, 0xf9, 0x7fb, 0xfa, 0x16, 0x17, 0x18, 0x0, 0x1, + 0x2, 0x19, 0x1a, 0x1b, 0x1c, 0x1d, 0x1e, 0x1f, 0x5c, 0xfb, + 0x7ffc, 0x20, 0xffb, 0x3fc, 0x1ffa, 0x21, 0x5d, 0x5e, 0x5f, 0x60, + 0x61, 0x62, 0x63, 0x64, 0x65, 0x66, 0x67, 0x68, 0x69, 0x6a, + 0x6b, 0x6c, 0x6d, 0x6e, 0x6f, 0x70, 0x71, 0x72, 0xfc, 0x73, + 0xfd, 0x1ffb, 0x7fff0, 0x1ffc, 0x3ffc, 0x22, 0x7ffd, 0x3, 0x23, 0x4, + 0x24, 0x5, 0x25, 0x26, 0x27, 0x6, 0x74, 0x75, 0x28, 0x29, + 0x2a, 0x7, 0x2b, 0x76, 0x2c, 0x8, 0x9, 0x2d, 0x77, 0x78, + 0x79, 0x7a, 0x7b, 0x7ffe, 0x7fc, 0x3ffd, 0x1ffd, 0xffffffc, 0xfffe6, 0x3fffd2, + 0xfffe7, 0xfffe8, 0x3fffd3, 0x3fffd4, 0x3fffd5, 0x7fffd9, 0x3fffd6, 0x7fffda, 0x7fffdb, + 0x7fffdc, + 0x7fffdd, 0x7fffde, 0xffffeb, 0x7fffdf, 0xffffec, 0xffffed, 0x3fffd7, 0x7fffe0, 0xffffee, + 0x7fffe1, + 0x7fffe2, 0x7fffe3, 0x7fffe4, 0x1fffdc, 0x3fffd8, 0x7fffe5, 0x3fffd9, 0x7fffe6, 0x7fffe7, + 0xffffef, + 0x3fffda, 0x1fffdd, 0xfffe9, 0x3fffdb, 0x3fffdc, 0x7fffe8, 0x7fffe9, 0x1fffde, 0x7fffea, + 0x3fffdd, + 0x3fffde, 0xfffff0, 0x1fffdf, 0x3fffdf, 0x7fffeb, 0x7fffec, 0x1fffe0, 0x1fffe1, 0x3fffe0, + 0x1fffe2, + 0x7fffed, 0x3fffe1, 0x7fffee, 0x7fffef, 0xfffea, 0x3fffe2, 0x3fffe3, 0x3fffe4, 0x7ffff0, + 0x3fffe5, + 0x3fffe6, 0x7ffff1, 0x3ffffe0, 0x3ffffe1, 0xfffeb, 0x7fff1, 0x3fffe7, 0x7ffff2, 0x3fffe8, + 0x1ffffec, + 0x3ffffe2, 0x3ffffe3, 0x3ffffe4, 0x7ffffde, 0x7ffffdf, 0x3ffffe5, 0xfffff1, 0x1ffffed, 0x7fff2, + 0x1fffe3, + 0x3ffffe6, 0x7ffffe0, 0x7ffffe1, 0x3ffffe7, 0x7ffffe2, 0xfffff2, 0x1fffe4, 0x1fffe5, 0x3ffffe8, + 0x3ffffe9, + 0xffffffd, 0x7ffffe3, 0x7ffffe4, 0x7ffffe5, 0xfffec, 0xfffff3, 0xfffed, 0x1fffe6, 0x3fffe9, + 0x1fffe7, + 0x1fffe8, 0x7ffff3, 0x3fffea, 0x3fffeb, 0x1ffffee, 0x1ffffef, 0xfffff4, 0xfffff5, 0x3ffffea, + 0x7ffff4, + 0x3ffffeb, 0x7ffffe6, 0x3ffffec, 0x3ffffed, 0x7ffffe7, 0x7ffffe8, 0x7ffffe9, 0x7ffffea, + 0x7ffffeb, 0xffffffe, + 0x7ffffec, 0x7ffffed, 0x7ffffee, 0x7ffffef, 0x7fffff0, 0x3ffffee, 0x3fffffff, + }; + + private static final int[] LENGTHS = { + 13, 23, 28, 28, 28, 28, 28, 28, 28, 24, 30, 28, 28, 30, 28, 28, 28, 28, 28, 28, + 28, 28, 30, 28, 28, 28, 28, 28, 28, 28, 28, 28, 6, 10, 10, 12, 13, 6, 8, 11, + 10, 10, 8, 11, 8, 6, 6, 6, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 7, 8, + 15, 6, 12, 10, 13, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, + 7, 7, 7, 7, 7, 7, 7, 7, 8, 7, 8, 13, 19, 13, 14, 6, 15, 5, 6, 5, + 6, 5, 6, 6, 6, 5, 7, 7, 6, 6, 6, 5, 6, 7, 6, 5, 5, 6, 7, 7, + 7, 7, 7, 15, 11, 14, 13, 28, 20, 22, 20, 20, 22, 22, 22, 23, 22, 23, 23, 23, + 23, 23, 24, 23, 24, 24, 22, 23, 24, 23, 23, 23, 23, 21, 22, 23, 22, 23, 23, 24, + 22, 21, 20, 22, 22, 23, 23, 21, 23, 22, 22, 24, 21, 22, 23, 23, 21, 21, 22, 21, + 23, 22, 23, 23, 20, 22, 22, 22, 23, 22, 22, 23, 26, 26, 20, 19, 22, 23, 22, 25, + 26, 26, 26, 27, 27, 26, 24, 25, 19, 21, 26, 27, 27, 26, 27, 24, 21, 21, 26, 26, + 28, 27, 27, 27, 20, 24, 20, 21, 22, 21, 21, 23, 22, 22, 25, 25, 24, 24, 26, 23, + 26, 27, 26, 26, 27, 27, 27, 27, 27, 28, 27, 27, 27, 27, 27, 26, 30, + }; + + // ── Trie, built once from CODES/LENGTHS ───────────────────────────────────── + + private static final int ROOT = 0; + private static final int NO_CHILD = -1; + private static final int NO_SYMBOL = -1; + + private static final int[] child0; + private static final int[] child1; + private static final int[] symbolAt; + private static final int[] depthOf; + private static final boolean[] onesSpine; + private static final int nodeCount; + + // ── Nibble-driven FSM, built from the trie above ──────────────────────────── + + private static final int STATE_BITS = 16; + private static final int STATE_MASK = (1 << STATE_BITS) - 1; + private static final int FLAG_SYMBOL = 1 << STATE_BITS; + private static final int FLAG_INVALID = 1 << (STATE_BITS + 1); + private static final int SYMBOL_SHIFT = 24; + + private static final int[] TRANSITIONS; + private static final boolean[] validEndState; + + static { + // Build the trie: one node per distinct bit-prefix any of the 257 codes passes through. + // Sized generously (sum of code lengths bounds the true worst case; the actual codes + // share far more prefix structure than that bound suggests). + int capacity = 4096; + int[] c0 = new int[capacity]; + int[] c1 = new int[capacity]; + int[] sym = new int[capacity]; + int[] depth = new int[capacity]; + boolean[] spine = new boolean[capacity]; + java.util.Arrays.fill(c0, NO_CHILD); + java.util.Arrays.fill(c1, NO_CHILD); + java.util.Arrays.fill(sym, NO_SYMBOL); + spine[ROOT] = true; + int[] count = {1}; // node 0 = root, already allocated + + for (int s = 0; s <= EOS_SYMBOL; s++) { + insert(c0, c1, sym, depth, spine, count, CODES[s], LENGTHS[s], s); + } + + nodeCount = count[0]; + child0 = java.util.Arrays.copyOf(c0, nodeCount); + child1 = java.util.Arrays.copyOf(c1, nodeCount); + symbolAt = java.util.Arrays.copyOf(sym, nodeCount); + depthOf = java.util.Arrays.copyOf(depth, nodeCount); + onesSpine = java.util.Arrays.copyOf(spine, nodeCount); + + if (nodeCount > (1 << STATE_BITS)) { + // Defensive: would only trip if a future edit changed the table shape drastically. + throw new ExceptionInInitializerError( + "Huffman trie grew to " + nodeCount + " nodes, exceeding STATE_BITS budget"); + } + + TRANSITIONS = buildTransitionTable(); + validEndState = buildPaddingValidity(); + } + + private static void insert( + int[] c0, + int[] c1, + int[] sym, + int[] depth, + boolean[] spine, + int[] count, + int code, + int length, + int symbolValue) { + int node = ROOT; + for (int i = length - 1; i >= 0; i--) { + int bit = (code >>> i) & 1; + int[] children = bit == 0 ? c0 : c1; + int next = children[node]; + if (next == NO_CHILD) { + next = count[0]++; + depth[next] = depth[node] + 1; + spine[next] = spine[node] && bit == 1; + children[node] = next; + } + node = next; + } + sym[node] = symbolValue; + } + + /** + * Builds the {@code state * 16 + nibble} transition table. For each state and each possible 4-bit + * nibble value, walks up to 4 trie edges from that state, MSB-first within the nibble. + * + *

Why at most one symbol per nibble: the shortest real code in {@link #LENGTHS} is 5 + * bits (verified by {@code HuffmanTest.everyRealCodeIsAtLeastFiveBitsLong} — the invariant this + * method's design depends on). Completing a symbol mid-nibble consumes at least 1 of the nibble's + * 4 bits; whatever remains (at most 3) is too short to complete a second code from a fresh root. + * So this method never needs to track more than one emission per entry. + * + *

A missing child edge (a bit sequence that is not a prefix of any of the 257 codes) is always + * {@code FLAG_INVALID}, unconditionally — including at what turns out to be the last nibble of + * the input. This is intentional, not an approximation: valid padding never causes a missing-edge + * walk to begin with (see {@link #buildPaddingValidity}) — it only ever causes the input to run + * out while sitting at a legitimate partial state, which this method's caller ({@link #decode}) + * checks separately once the whole input has been consumed. + */ + private static int[] buildTransitionTable() { + int[] table = new int[nodeCount * 16]; + for (int state = 0; state < nodeCount; state++) { + for (int nibble = 0; nibble < 16; nibble++) { + table[state * 16 + nibble] = simulateNibble(state, nibble); + } + } + return table; + } + + private static int simulateNibble(int startState, int nibble) { + int state = startState; + boolean emitted = false; + int emittedSymbol = -1; + for (int bitIndex = 3; bitIndex >= 0; bitIndex--) { + int bit = (nibble >>> bitIndex) & 1; + int next = bit == 0 ? child0[state] : child1[state]; + if (next == NO_CHILD) { + return FLAG_INVALID; + } + state = next; + if (symbolAt[state] != NO_SYMBOL) { + if (symbolAt[state] == EOS_SYMBOL) { + return FLAG_INVALID; // RFC 7541 5.2: EOS in the input is always an error + } + emitted = true; + emittedSymbol = symbolAt[state]; + state = ROOT; // remaining bits of this nibble (if any) start a fresh code + } + } + int packed = state; + if (emitted) { + packed |= FLAG_SYMBOL | (emittedSymbol << SYMBOL_SHIFT); + } + return packed; + } + + /** + * {@code validEndState[s]}: {@code true} if the decoder may legally have consumed all input while + * sitting at trie state {@code s} — root (nothing pending) or 1..7 bits into the all-1s + * (EOS-prefix) spine. + */ + private static boolean[] buildPaddingValidity() { + boolean[] valid = new boolean[nodeCount]; + valid[ROOT] = true; + for (int s = 1; s < nodeCount; s++) { + valid[s] = onesSpine[s] && depthOf[s] <= 7; + } + return valid; + } + + // ── Public API ─────────────────────────────────────────────────────────── + + /** + * Decodes the Huffman-coded string {@code src[srcOff, srcOff + srcLen)} into {@code dst[dstOff, + * dstLimit)}, returning the number of bytes written. The output bound is enforced as bytes + * are produced, not after accumulating into an unbounded buffer — callers pass a {@code + * dst}/{@code dstLimit} sized to their own maximum (typically {@code + * Http2Limits.MAX_HPACK_STRING_LENGTH}), and a string that would decode past it is rejected + * mid-decode. + * + * @throws Http2Exception {@code COMPRESSION_ERROR} — on any bit sequence that is not a prefix of + * a real code, on the EOS symbol appearing in the input, on invalid trailing padding (not + * all-1s, or 8+ bits), or on exceeding {@code dstLimit} + */ + public static int decode( + byte[] src, int srcOff, int srcLen, byte[] dst, int dstOff, int dstLimit) { + int state = ROOT; + int dstPos = dstOff; + int end = srcOff + srcLen; + for (int i = srcOff; i < end; i++) { + int b = src[i] & 0xFF; + + int t = TRANSITIONS[state * 16 + (b >>> 4)]; + if ((t & FLAG_INVALID) != 0) throw Http2Exception.COMPRESSION_ERROR; + if ((t & FLAG_SYMBOL) != 0) { + if (dstPos >= dstLimit) throw Http2Exception.COMPRESSION_ERROR; + dst[dstPos++] = (byte) (t >>> SYMBOL_SHIFT); + } + state = t & STATE_MASK; + + t = TRANSITIONS[state * 16 + (b & 0xF)]; + if ((t & FLAG_INVALID) != 0) throw Http2Exception.COMPRESSION_ERROR; + if ((t & FLAG_SYMBOL) != 0) { + if (dstPos >= dstLimit) throw Http2Exception.COMPRESSION_ERROR; + dst[dstPos++] = (byte) (t >>> SYMBOL_SHIFT); + } + state = t & STATE_MASK; + } + if (!validEndState[state]) throw Http2Exception.COMPRESSION_ERROR; + return dstPos - dstOff; + } + + /** + * Huffman-encodes {@code src[off, off + len)}, writing directly into {@code out}. Pads the final + * byte with the high-order bits of the EOS code (all 1s), per RFC 7541 §5.2. + */ + public static void encode(ByteWriter out, byte[] src, int off, int len) { + long accumulator = 0; + int bitCount = 0; + int end = off + len; + for (int i = off; i < end; i++) { + int v = src[i] & 0xFF; + int codeLen = LENGTHS[v]; + accumulator = (accumulator << codeLen) | (CODES[v] & ((1L << codeLen) - 1)); + bitCount += codeLen; + while (bitCount >= 8) { + bitCount -= 8; + out.writeByte((byte) (accumulator >>> bitCount)); + } + } + if (bitCount > 0) { + int padBits = 8 - bitCount; + long lastByte = ((accumulator << padBits) | ((1L << padBits) - 1)) & 0xFF; + out.writeByte((byte) lastByte); + } + } + + /** + * The number of bytes {@link #encode} would produce for {@code src[off, off + len)} — the ceiling + * of the total bit length over 8. + */ + public static int encodedLength(byte[] src, int off, int len) { + long bits = 0; + int end = off + len; + for (int i = off; i < end; i++) { + bits += LENGTHS[src[i] & 0xFF]; + } + return (int) ((bits + 7) / 8); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/message/DataBufferPool.java b/flash/src/main/java/dev/relism/flash/http2/message/DataBufferPool.java new file mode 100644 index 0000000..48409cc --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/message/DataBufferPool.java @@ -0,0 +1,69 @@ +package dev.relism.flash.http2.message; + +/** Bounded connection-owned free list of frame-sized request-body buffers. */ +public final class DataBufferPool { + static final class DataBuffer { + final byte[] bytes; + DataBuffer next; + int position; + int length; + int flowControlledBytes; + + DataBuffer(int size) { + bytes = new byte[size]; + } + + void reset() { + next = null; + position = 0; + length = 0; + flowControlledBytes = 0; + } + } + + private final int bufferSize; + private final int maxBuffers; + private DataBuffer free; + private int created; + private int available; + + public DataBufferPool(int bufferSize, int maxBuffers) { + if (bufferSize < 1 || maxBuffers < 1) { + throw new IllegalArgumentException("bufferSize and maxBuffers must be positive"); + } + this.bufferSize = bufferSize; + this.maxBuffers = maxBuffers; + } + + synchronized DataBuffer acquire() { + DataBuffer buffer = free; + if (buffer != null) { + free = buffer.next; + available--; + buffer.reset(); + return buffer; + } + if (created == maxBuffers) return null; + created++; + return new DataBuffer(bufferSize); + } + + synchronized void release(DataBuffer buffer) { + buffer.reset(); + buffer.next = free; + free = buffer; + available++; + } + + public synchronized int createdCount() { + return created; + } + + public synchronized int availableCount() { + return available; + } + + public int capacity() { + return maxBuffers; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/message/Http2HeaderMap.java b/flash/src/main/java/dev/relism/flash/http2/message/Http2HeaderMap.java new file mode 100644 index 0000000..2853f86 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/message/Http2HeaderMap.java @@ -0,0 +1,153 @@ +package dev.relism.flash.http2.message; + +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.flash.http2.hpack.HpackHeaderBlock; +import dev.relism.flash.models.HeaderView; +import dev.relism.fpr.core.ByteView; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** Header view over stream-owned decoded HPACK storage. Pseudo-fields are excluded. */ +public final class Http2HeaderMap implements HeaderView { + private static final int VIEW_COUNT = 4; + + private final PooledSlice scanName = new PooledSlice(); + private final PooledSlice scanValue = new PooledSlice(); + private final PooledSlice[] views = new PooledSlice[VIEW_COUNT]; + private HpackHeaderBlock block; + private PseudoHeaders pseudoHeaders; + private int viewCursor; + private int regularCount = -1; + + public Http2HeaderMap() { + for (int i = 0; i < views.length; i++) views[i] = new PooledSlice(); + } + + public void reset(HpackHeaderBlock block, PseudoHeaders pseudoHeaders) { + this.block = block; + this.pseudoHeaders = pseudoHeaders; + viewCursor = 0; + regularCount = -1; + } + + public void reset(HpackHeaderBlock block) { + reset(block, null); + } + + @Override + public String first(String name) { + ByteView value = find(name, scanValue); + if (value == null) return null; + PooledSlice slice = (PooledSlice) value; + return new String(slice.array(), slice.offset(), slice.length(), StandardCharsets.UTF_8); + } + + @Override + public List all(String name) { + List result = null; + for (int i = 0; i < block.count(); i++) { + block.get(i, scanName, scanValue); + if (scanName.byteAt(0) == ':' || !equalsIgnoreCase(scanName, name)) continue; + if (result == null) result = new ArrayList<>(); + result.add( + new String( + scanValue.array(), scanValue.offset(), scanValue.length(), StandardCharsets.UTF_8)); + } + if (result == null && pseudoHeaders != null + && isAuthorityAlias(name) && pseudoHeaders.authority().array() != null) { + return List.of(string(pseudoHeaders.authority())); + } + return result == null ? List.of() : result; + } + + @Override + public List all() { + List result = new ArrayList<>(regularCount < 0 ? block.count() : regularCount); + int found = 0; + for (int i = 0; i < block.count(); i++) { + block.get(i, scanName, scanValue); + if (scanName.byteAt(0) != ':') { + result.add(string(scanValue)); + found++; + } + } + regularCount = found; + return result; + } + + @Override + public ByteView view(String name) { + PooledSlice target = views[viewCursor++ & (views.length - 1)]; + return find(name, target); + } + + @Override + public boolean valueEqualsIgnoreCase(String name, String value) { + ByteView found = find(name, scanValue); + return found != null && equalsIgnoreCase(found, value); + } + + @Override + public boolean contains(String name) { + return find(name, scanValue) != null; + } + + @Override + public int count() { + if (regularCount < 0) { + int found = 0; + for (int i = 0; i < block.count(); i++) { + block.get(i, scanName, scanValue); + if (scanName.byteAt(0) != ':') found++; + } + regularCount = found; + } + return regularCount; + } + + @Override + public void forEach(HeaderConsumer consumer) { + for (int i = 0; i < block.count(); i++) { + block.get(i, scanName, scanValue); + if (scanName.byteAt(0) != ':') consumer.accept(scanName, scanValue); + } + } + + private PooledSlice find(String requested, PooledSlice target) { + for (int i = 0; i < block.count(); i++) { + block.get(i, scanName, scanValue); + if (scanName.byteAt(0) != ':' && equalsIgnoreCase(scanName, requested)) { + target.reset(scanValue.array(), scanValue.offset(), scanValue.length()); + return target; + } + } + if (pseudoHeaders != null + && isAuthorityAlias(requested) && pseudoHeaders.authority().array() != null) { + PooledSlice authority = pseudoHeaders.authority(); + target.reset(authority.array(), authority.offset(), authority.length()); + return target; + } + return null; + } + + private static boolean isAuthorityAlias(String name) { + return name.equalsIgnoreCase("host") || name.equalsIgnoreCase(":authority"); + } + + private static boolean equalsIgnoreCase(ByteView bytes, String value) { + if (bytes.length() != value.length()) return false; + for (int i = 0; i < bytes.length(); i++) { + int left = bytes.byteAt(i) & 0xff; + int right = value.charAt(i); + if (left >= 'A' && left <= 'Z') left += 32; + if (right >= 'A' && right <= 'Z') right += 32; + if (left != right) return false; + } + return true; + } + + private static String string(PooledSlice slice) { + return new String(slice.array(), slice.offset(), slice.length(), StandardCharsets.UTF_8); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/message/Http2RequestBody.java b/flash/src/main/java/dev/relism/flash/http2/message/Http2RequestBody.java new file mode 100644 index 0000000..92b64ae --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/message/Http2RequestBody.java @@ -0,0 +1,246 @@ +package dev.relism.flash.http2.message; + +import dev.relism.flash.http2.Http2ErrorCode; +import dev.relism.flash.http2.Http2Limits; +import dev.relism.flash.http2.Http2StreamException; +import dev.relism.flash.http2.message.DataBufferPool.DataBuffer; +import java.io.IOException; +import java.io.InputStream; +import dev.relism.flash.models.BodyCompletion; +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.ReentrantLock; + +/** Reusable request-body source fed by the connection demultiplexer. */ +public final class Http2RequestBody extends InputStream implements BodyCompletion { + @FunctionalInterface + public interface ConsumptionListener { + void consumed(int flowControlledBytes) throws IOException; + } + + private final DataBufferPool pool; + private final ReentrantLock lock = new ReentrantLock(); + private final Condition dataAvailable = lock.newCondition(); + private final byte[] oneByte = new byte[1]; + private byte[] inline; + private DataBuffer head; + private DataBuffer tail; + private ConsumptionListener listener; + private long declaredLength; + private long received; + private int inlinePosition; + private int inlineFlowControlledBytes; + private boolean inlineMode; + private boolean finished; + private boolean fullyRead; + + public Http2RequestBody(DataBufferPool pool) { + this.pool = pool; + } + + public void begin(long declaredLength, boolean inlineMode, ConsumptionListener listener) { + releaseQueued(); + this.declaredLength = declaredLength; + this.inlineMode = inlineMode; + this.listener = listener; + received = 0; + inlinePosition = 0; + inlineFlowControlledBytes = 0; + finished = false; + fullyRead = false; + if (inlineMode && inline == null) inline = new byte[Http2Limits.INLINE_BODY_THRESHOLD]; + } + + public void offer( + int streamId, byte[] source, int offset, int length, int flowControlledBytes) { + long next = received + length; + if (next > Http2Limits.MAX_REQUEST_BODY_SIZE) { + throw new Http2StreamException( + streamId, Http2ErrorCode.PROTOCOL_ERROR, "request body exceeds configured limit"); + } + if (declaredLength >= 0 && next > declaredLength) { + throw new Http2StreamException( + streamId, Http2ErrorCode.PROTOCOL_ERROR, "request body exceeds content-length"); + } + if (length == 0) { + notifyConsumed(flowControlledBytes); + return; + } + if (inlineMode) { + if (next > inline.length) { + throw new Http2StreamException( + streamId, Http2ErrorCode.PROTOCOL_ERROR, "inline request body exceeded its bound"); + } + System.arraycopy(source, offset, inline, (int) received, length); + received = next; + inlineFlowControlledBytes += flowControlledBytes; + return; + } + + lock.lock(); + try { + int remaining = length; + int sourcePosition = offset; + while (remaining > 0) { + if (tail == null || tail.length == tail.bytes.length) { + DataBuffer buffer = pool.acquire(); + if (buffer == null) { + throw new Http2StreamException( + streamId, + Http2ErrorCode.ENHANCE_YOUR_CALM, + "request body buffer pool exhausted"); + } + if (tail == null) head = buffer; + else tail.next = buffer; + tail = buffer; + } + int copied = Math.min(remaining, tail.bytes.length - tail.length); + System.arraycopy(source, sourcePosition, tail.bytes, tail.length, copied); + tail.length += copied; + sourcePosition += copied; + remaining -= copied; + } + tail.flowControlledBytes += flowControlledBytes; + received = next; + dataAvailable.signal(); + } finally { + lock.unlock(); + } + } + + public void finish(int streamId) { + if (declaredLength >= 0 && received != declaredLength) { + throw new Http2StreamException( + streamId, + Http2ErrorCode.PROTOCOL_ERROR, + "content-length does not match received DATA bytes"); + } + lock.lock(); + try { + finished = true; + dataAvailable.signalAll(); + } finally { + lock.unlock(); + } + } + + public int cancel() { + int discarded; + lock.lock(); + try { + finished = true; + discarded = inlineFlowControlledBytes + releaseQueuedLocked(); + inlineFlowControlledBytes = 0; + dataAvailable.signalAll(); + } finally { + lock.unlock(); + } + return discarded; + } + + public long declaredLength() { + return declaredLength; + } + + @Override + public int read() throws IOException { + int count = read(oneByte, 0, 1); + return count < 0 ? -1 : oneByte[0] & 0xff; + } + + @Override + public int read(byte[] target, int offset, int length) throws IOException { + if (length == 0) return 0; + if (inlineMode) return readInline(target, offset, length); + + DataBuffer consumed = null; + int copied; + int flowControlled = 0; + lock.lock(); + try { + while (head == null && !finished) { + try { + dataAvailable.await(); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException("interrupted while waiting for request DATA", interrupted); + } + } + if (head == null) { + fullyRead = true; + return -1; + } + DataBuffer buffer = head; + copied = Math.min(length, buffer.length - buffer.position); + System.arraycopy(buffer.bytes, buffer.position, target, offset, copied); + buffer.position += copied; + if (buffer.position == buffer.length) { + head = buffer.next; + if (head == null) tail = null; + flowControlled = buffer.flowControlledBytes; + consumed = buffer; + } + } finally { + lock.unlock(); + } + if (consumed != null) { + pool.release(consumed); + notifyConsumed(flowControlled); + } + return copied; + } + + private int readInline(byte[] target, int offset, int length) throws IOException { + if (!finished) { + throw new IOException("inline request body is not complete"); + } + if (inlinePosition == received) { + fullyRead = true; + return -1; + } + int copied = (int) Math.min(length, received - inlinePosition); + System.arraycopy(inline, inlinePosition, target, offset, copied); + inlinePosition += copied; + if (inlinePosition == received && inlineFlowControlledBytes != 0) { + int flowControlled = inlineFlowControlledBytes; + inlineFlowControlledBytes = 0; + notifyConsumed(flowControlled); + } + return copied; + } + + @Override + public boolean fullyRead() { + return fullyRead || (finished && received == 0); + } + + private void notifyConsumed(int bytes) { + if (bytes == 0 || listener == null) return; + try { + listener.consumed(bytes); + } catch (IOException failure) { + cancel(); + throw new IllegalStateException("failed to update request flow-control window", failure); + } + } + + private void releaseQueued() { + lock.lock(); + try { + releaseQueuedLocked(); + } finally { + lock.unlock(); + } + } + + private int releaseQueuedLocked() { + int flowControlled = 0; + while (head != null) { + DataBuffer released = head; + head = released.next; + flowControlled += released.flowControlledBytes; + pool.release(released); + } + tail = null; + return flowControlled; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/message/Http2ResponseWriter.java b/flash/src/main/java/dev/relism/flash/http2/message/Http2ResponseWriter.java new file mode 100644 index 0000000..137646a --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/message/Http2ResponseWriter.java @@ -0,0 +1,460 @@ +package dev.relism.flash.http2.message; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.DateHeader; +import dev.relism.flash.http.HttpStatus; +import dev.relism.flash.http2.Http2ErrorCode; +import dev.relism.flash.http2.Http2Limits; +import dev.relism.flash.http2.Http2StreamException; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.frame.FrameWriteBuffer; +import dev.relism.flash.http2.frame.WriteIntent; +import dev.relism.flash.http2.hpack.HpackEncoder; +import dev.relism.flash.models.Response; +import dev.relism.flash.models.ResponseSerializer; +import java.io.IOException; +import java.io.InputStream; + +/** + * Reusable per-stream HTTP/2 response serializer. It prepares a complete small response outside the + * connection write lock and exposes it as one {@link WriteIntent}. + */ +public final class Http2ResponseWriter implements WriteIntent, ResponseSerializer.FieldConsumer { + @FunctionalInterface + public interface Completion { + void responseWriteCompleted(); + } + + private static final int STATUS_NAME_LENGTH = 7; + private static final int CONTENT_LENGTH_NAME_LENGTH = 14; + + private final ByteWriter headerBlock; + private final ByteWriter output; + private final FrameWriteBuffer frames; + private final byte[] decimalScratch = new byte[20]; + private final byte[] relay = new byte[Http2Limits.MAX_FRAME_SIZE_LOCAL]; + private WriteIntent next; + private boolean huffmanDynamicValues; + private int streamId; + private long headerListSize; + private long maxHeaderListSize; + private Completion completion; + private byte[] fixedBody; + private InputStream streamBody; + private long bodyRemaining; + private int fixedPosition; + private boolean unknownLength; + private boolean pushBody; + private boolean finished; + private boolean headersInBatch; + private boolean endStreamInBatch; + private int dataBytesInBatch; + private boolean trailerHeadersInBatch; + private Response response; + + public Http2ResponseWriter() { + this(1024, 2048); + } + + public Http2ResponseWriter(int initialHeaderCapacity, int initialOutputCapacity) { + headerBlock = new ByteWriter(initialHeaderCapacity); + output = new ByteWriter(initialOutputCapacity); + frames = new FrameWriteBuffer(output); + } + + public void completion(Completion completion) { + this.completion = completion; + } + + /** + * Prepares a non-streaming response. Returns {@code false} when the body needs the deferred DATA + * flow-control path implemented by the stream scheduler. + */ + public boolean prepare( + Response response, + int streamId, + boolean headRequest, + boolean sendDate, + boolean sendContentLength, + boolean huffmanDynamicValues, + boolean emitTableSizeUpdate, + int maxFrameSize, + long maxHeaderListSize, + int availableFlowWindow) { + if (streamId <= 0) throw new IllegalArgumentException("streamId must be positive"); + if (maxFrameSize <= 0) throw new IllegalArgumentException("maxFrameSize must be positive"); + if (response.isStreaming()) { + throw new IllegalArgumentException("streaming responses use the HTTP/2 DATA scheduler"); + } + + headerBlock.reset(); + output.reset(); + + byte[] body = response.getBody(); + int bodyLength = body == null ? 0 : body.length; + int statusCode = response.getStatusCode(); + boolean bodyForbidden = + statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200); + boolean suppressBody = headRequest || bodyForbidden; + if (!suppressBody + && bodyLength > 0 + && (bodyLength > maxFrameSize || bodyLength > availableFlowWindow)) { + return false; + } + + this.streamId = streamId; + this.huffmanDynamicValues = huffmanDynamicValues; + this.maxHeaderListSize = maxHeaderListSize; + this.response = response; + headerListSize = 0; + next = null; + + if (emitTableSizeUpdate) HpackEncoder.writeDynamicTableSizeUpdateZero(headerBlock); + writeStatus(statusCode); + writeContentType(response.getContentType()); + + if (sendDate) { + addHeaderListSize(4, 29); + headerBlock.writeBytes(DateHeader.hpackBytes()); + } + if (sendContentLength && !bodyForbidden) { + addHeaderListSize(CONTENT_LENGTH_NAME_LENGTH, decimalLength(bodyLength)); + writeDecimalLiteral(28, bodyLength); + } + + ResponseSerializer.forEachCustomField(response, this); + boolean hasTrailers = response.hasTrailers() && !suppressBody; + writeHeaderFrames(maxFrameSize, suppressBody || (bodyLength == 0 && !hasTrailers)); + if (!suppressBody && bodyLength > 0) { + frames.beginFrame(FrameType.DATA, hasTrailers ? 0 : FrameFlags.END_STREAM, streamId); + output.writeBytes(body); + frames.endFrame(); + } + if (hasTrailers) appendTrailers(maxFrameSize); + return true; + } + + /** Starts a response whose DATA may span multiple flow-control windows. */ + public int startFlowControlled( + Response response, + int streamId, + boolean headRequest, + boolean sendDate, + boolean sendContentLength, + boolean huffmanDynamicValues, + boolean emitTableSizeUpdate, + int maxFrameSize, + long maxHeaderListSize, + int availableFlowWindow) + throws IOException { + if (streamId <= 0) throw new IllegalArgumentException("streamId must be positive"); + if (maxFrameSize <= 0 || availableFlowWindow < 0) { + throw new IllegalArgumentException( + "frame size must be positive and flow window non-negative"); + } + headerBlock.reset(); + output.reset(); + this.streamId = streamId; + this.huffmanDynamicValues = huffmanDynamicValues; + this.maxHeaderListSize = maxHeaderListSize; + headerListSize = 0; + next = null; + headersInBatch = true; + endStreamInBatch = false; + dataBytesInBatch = 0; + trailerHeadersInBatch = false; + this.response = response; + fixedPosition = 0; + fixedBody = response.isStreaming() ? null : response.getBody(); + streamBody = response.isStreaming() ? response.getStream() : null; + unknownLength = response.isStreaming() && response.isChunked(); + pushBody = response.isPushStreaming(); + if (response.isStreaming() && !unknownLength && response.getStreamLength() < 0) { + throw new IllegalArgumentException("known response stream length must not be negative"); + } + bodyRemaining = + response.isStreaming() + ? (unknownLength ? -1 : response.getStreamLength()) + : (fixedBody == null ? 0 : fixedBody.length); + long representationLength = bodyRemaining; + boolean representationUnknownLength = unknownLength; + + int statusCode = response.getStatusCode(); + boolean bodyForbidden = + statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200); + if (headRequest || bodyForbidden) { + fixedBody = null; + streamBody = null; + unknownLength = false; + bodyRemaining = 0; + } + + if (emitTableSizeUpdate) HpackEncoder.writeDynamicTableSizeUpdateZero(headerBlock); + writeStatus(statusCode); + writeContentType(response.getContentType()); + if (sendDate) { + addHeaderListSize(4, 29); + headerBlock.writeBytes(DateHeader.hpackBytes()); + } + if (sendContentLength && !bodyForbidden && !representationUnknownLength) { + addHeaderListSize(CONTENT_LENGTH_NAME_LENGTH, decimalLength(representationLength)); + writeDecimalLiteral(28, representationLength); + } + ResponseSerializer.forEachCustomField(response, this); + + boolean hasBody = unknownLength || bodyRemaining > 0; + boolean hasTrailers = !headRequest && !bodyForbidden && response.hasTrailers(); + writeHeaderFrames(maxFrameSize, !hasBody && !hasTrailers); + finished = !hasBody && !hasTrailers; + if (!hasBody && hasTrailers) { + appendTrailers(maxFrameSize); + finished = true; + endStreamInBatch = true; + } + if (hasBody && availableFlowWindow > 0 && !pushBody) { + appendData(maxFrameSize, availableFlowWindow); + } + return dataBytesInBatch; + } + + /** Serializes the next DATA batch after a WINDOW_UPDATE or previous write completion. */ + public int resume(int maxFrameSize, int availableFlowWindow) throws IOException { + if (finished || availableFlowWindow <= 0) return 0; + output.reset(); + next = null; + headersInBatch = false; + endStreamInBatch = false; + dataBytesInBatch = 0; + trailerHeadersInBatch = false; + appendData(maxFrameSize, availableFlowWindow); + return dataBytesInBatch; + } + + private void appendData(int maxFrameSize, int availableFlowWindow) throws IOException { + int target = Math.min(relay.length, Math.min(maxFrameSize, availableFlowWindow)); + int count; + boolean end; + if (fixedBody != null) { + count = (int) Math.min(target, bodyRemaining); + boolean finalData = count == bodyRemaining; + boolean trailersFollow = finalData && response.hasTrailers(); + frames.beginFrame( + FrameType.DATA, finalData && !trailersFollow ? FrameFlags.END_STREAM : 0, streamId); + output.writeBytes(fixedBody, fixedPosition, count); + frames.endFrame(); + fixedPosition += count; + bodyRemaining -= count; + end = bodyRemaining == 0; + } else { + int limit = unknownLength ? target : (int) Math.min(target, bodyRemaining); + count = 0; + boolean eof = false; + if (pushBody) { + int read = streamBody.read(relay, 0, limit); + if (read < 0) eof = true; + else count = read; + } else { + while (count < limit) { + int read = streamBody.read(relay, count, limit - count); + if (read < 0) { + eof = true; + break; + } + if (read == 0) { + int one = streamBody.read(); + if (one < 0) { + eof = true; + break; + } + relay[count++] = (byte) one; + } else { + count += read; + } + } + } + if (!unknownLength) { + bodyRemaining -= count; + if (eof && bodyRemaining != 0) { + throw new IOException("streaming response ended before its declared length"); + } + } + end = unknownLength ? eof : bodyRemaining == 0; + boolean trailersFollow = end && response.hasTrailers(); + if (count != 0 || !trailersFollow) { + frames.beginFrame( + FrameType.DATA, end && !trailersFollow ? FrameFlags.END_STREAM : 0, streamId); + output.writeBytes(relay, 0, count); + frames.endFrame(); + } + } + dataBytesInBatch = count; + if (end && response.hasTrailers()) { + appendTrailers(maxFrameSize); + endStreamInBatch = true; + } else { + endStreamInBatch = end; + } + finished = end; + } + + private void appendTrailers(int maxFrameSize) { + headerBlock.reset(); + headerListSize = 0; + ResponseSerializer.forEachTrailerField(response, this); + writeHeaderFrames(maxFrameSize, true); + trailerHeadersInBatch = true; + } + + public boolean finished() { + return finished; + } + + public boolean headersInBatch() { + return headersInBatch; + } + + public boolean endStreamInBatch() { + return endStreamInBatch; + } + + public int dataBytesInBatch() { + return dataBytesInBatch; + } + + public boolean trailerHeadersInBatch() { + return trailerHeadersInBatch; + } + + @Override + public void accept( + byte[] name, int nameOff, int nameLen, byte[] value, int valueOff, int valueLen) { + if (isForbidden(name, nameOff, nameLen)) return; + addHeaderListSize(nameLen, valueLen); + HpackEncoder.writeLiteral( + headerBlock, name, nameOff, nameLen, value, valueOff, valueLen, huffmanDynamicValues); + } + + private void writeStatus(int statusCode) { + addHeaderListSize(STATUS_NAME_LENGTH, 3); + byte[] precompiled = HttpStatus.hpackBytesForCode(statusCode); + if (precompiled != null) { + headerBlock.writeBytes(precompiled); + return; + } + if (statusCode < 100 || statusCode > 999) { + throw new Http2StreamException( + streamId, Http2ErrorCode.INTERNAL_ERROR, "HTTP status must contain three digits"); + } + writeDecimalLiteral(8, statusCode); + } + + private void writeContentType(byte[] contentType) { + if (contentType == null || contentType.length == 0) return; + addHeaderListSize(12, contentType.length); + byte[] precompiled = ContentType.hpackBytesFor(contentType); + if (precompiled != null) { + headerBlock.writeBytes(precompiled); + } else { + HpackEncoder.writeLiteralWithNameIndex(headerBlock, 31, contentType, huffmanDynamicValues); + } + } + + private void writeDecimalLiteral(int nameIndex, long value) { + int length = decimalLength(value); + int offset = decimalScratch.length - length; + long current = value; + for (int i = decimalScratch.length - 1; i >= offset; i--) { + decimalScratch[i] = (byte) ('0' + current % 10); + current /= 10; + } + HpackEncoder.writeLiteralWithNameIndex( + headerBlock, nameIndex, decimalScratch, offset, length, huffmanDynamicValues); + } + + private void writeHeaderFrames(int maxFrameSize, boolean endStream) { + int remaining = headerBlock.length(); + int offset = 0; + boolean first = true; + do { + int fragment = Math.min(remaining, maxFrameSize); + boolean last = fragment == remaining; + int flags = last ? FrameFlags.END_HEADERS : 0; + if (first && endStream) flags |= FrameFlags.END_STREAM; + frames.beginFrame(first ? FrameType.HEADERS : FrameType.CONTINUATION, flags, streamId); + output.writeBytes(headerBlock.array(), offset, fragment); + frames.endFrame(); + offset += fragment; + remaining -= fragment; + first = false; + } while (remaining > 0); + } + + private void addHeaderListSize(int nameLength, int valueLength) { + headerListSize += nameLength + valueLength + 32L; + if (headerListSize > maxHeaderListSize) { + throw new Http2StreamException( + streamId, + Http2ErrorCode.INTERNAL_ERROR, + "response header list exceeds peer limit " + maxHeaderListSize); + } + } + + private static boolean isForbidden(byte[] name, int off, int len) { + return equalsAscii(name, off, len, "connection") + || equalsAscii(name, off, len, "keep-alive") + || equalsAscii(name, off, len, "proxy-connection") + || equalsAscii(name, off, len, "transfer-encoding") + || equalsAscii(name, off, len, "upgrade"); + } + + private static boolean equalsAscii(byte[] bytes, int off, int len, String expected) { + if (len != expected.length()) return false; + for (int i = 0; i < len; i++) { + int actual = bytes[off + i] & 0xff; + if (actual >= 'A' && actual <= 'Z') actual += 32; + if (actual != expected.charAt(i)) return false; + } + return true; + } + + private static int decimalLength(long value) { + int length = 1; + while (value >= 10) { + value /= 10; + length++; + } + return length; + } + + @Override + public byte[] buffer() { + return output.array(); + } + + @Override + public int offset() { + return 0; + } + + @Override + public int length() { + return output.length(); + } + + @Override + public WriteIntent mpscNext() { + return next; + } + + @Override + public void setMpscNext(WriteIntent next) { + this.next = next; + } + + @Override + public void completed() { + if (completion != null) completion.responseWriteCompleted(); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/message/PseudoHeaders.java b/flash/src/main/java/dev/relism/flash/http2/message/PseudoHeaders.java new file mode 100644 index 0000000..1dd63b4 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/message/PseudoHeaders.java @@ -0,0 +1,169 @@ +package dev.relism.flash.http2.message; + +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.flash.http2.Http2ErrorCode; +import dev.relism.flash.http2.Http2StreamException; +import dev.relism.flash.http2.hpack.HpackHeaderBlock; +import dev.relism.fpr.core.ByteView; + +/** Validates request pseudo-headers and HTTP/2 field rules while extracting request metadata. */ +public final class PseudoHeaders { + private static final int METHOD = 1; + private static final int SCHEME = 2; + private static final int PATH = 4; + private static final int AUTHORITY = 8; + private static final int PROTOCOL = 16; + + private final PooledSlice name = new PooledSlice(); + private final PooledSlice value = new PooledSlice(); + private final PooledSlice method = new PooledSlice(); + private final PooledSlice scheme = new PooledSlice(); + private final PooledSlice path = new PooledSlice(); + private final PooledSlice authority = new PooledSlice(); + private final PooledSlice protocol = new PooledSlice(); + private final PooledSlice host = new PooledSlice(); + private int present; + + public void validate(HpackHeaderBlock block, int streamId) { + present = 0; + method.reset(null, 0, 0); + scheme.reset(null, 0, 0); + path.reset(null, 0, 0); + authority.reset(null, 0, 0); + protocol.reset(null, 0, 0); + host.reset(null, 0, 0); + boolean regularSeen = false; + + for (int i = 0; i < block.count(); i++) { + block.get(i, name, value); + if (name.length() == 0) fail(streamId, "empty field name"); + boolean pseudo = name.byteAt(0) == ':'; + if (pseudo) { + if (regularSeen) fail(streamId, "pseudo-header after regular field"); + int bit = pseudoBit(name); + if (bit == 0) fail(streamId, "unknown pseudo-header"); + if ((present & bit) != 0) fail(streamId, "duplicate pseudo-header"); + present |= bit; + copySlice(bit, value); + } else { + regularSeen = true; + validateRegular(name, value, streamId); + if (equals(name, "host")) copy(value, host); + } + } + + if ((present & METHOD) == 0) fail(streamId, "missing :method"); + boolean connect = equals(method, "CONNECT"); + boolean extendedConnect = (present & PROTOCOL) != 0; + if (extendedConnect) { + if (!connect) fail(streamId, ":protocol requires CONNECT"); + int required = METHOD | SCHEME | PATH | AUTHORITY | PROTOCOL; + if ((present & required) != required) { + fail(streamId, "extended CONNECT missing pseudo-header"); + } + if (path.length() == 0) fail(streamId, "empty :path"); + } else if (connect) { + if ((present & AUTHORITY) == 0) fail(streamId, "CONNECT requires :authority"); + if ((present & (SCHEME | PATH)) != 0) fail(streamId, "CONNECT forbids :scheme and :path"); + } else { + int required = METHOD | SCHEME | PATH | AUTHORITY; + if ((present & required) != required) fail(streamId, "missing request pseudo-header"); + if (path.length() == 0) fail(streamId, "empty :path"); + } + if (host.array() != null && authority.array() != null && !equals(host, authority)) { + fail(streamId, "host conflicts with :authority"); + } + } + + /** Validates a trailing field section, where pseudo-fields are never permitted. */ + public static void validateTrailers(HpackHeaderBlock block, int streamId) { + PooledSlice name = new PooledSlice(); + PooledSlice value = new PooledSlice(); + for (int i = 0; i < block.count(); i++) { + block.get(i, name, value); + if (name.length() == 0 || name.byteAt(0) == ':') fail(streamId, "pseudo-header in trailers"); + validateRegular(name, value, streamId); + if (equals(name, "content-length") || equals(name, "host") || equals(name, "te")) { + fail(streamId, "field is not permitted in trailers"); + } + } + } + + public PooledSlice method() { + return method; + } + + public PooledSlice scheme() { + return scheme; + } + + public PooledSlice path() { + return path; + } + + public PooledSlice authority() { + return authority; + } + + public boolean websocket() { + return protocol.array() != null && equals(protocol, "websocket"); + } + + private void copySlice(int bit, PooledSlice source) { + if (bit == METHOD) copy(source, method); + else if (bit == SCHEME) copy(source, scheme); + else if (bit == PATH) copy(source, path); + else if (bit == AUTHORITY) copy(source, authority); + else copy(source, protocol); + } + + private static void copy(PooledSlice source, PooledSlice target) { + target.reset(source.array(), source.offset(), source.length()); + } + + private static int pseudoBit(ByteView name) { + if (equals(name, ":method")) return METHOD; + if (equals(name, ":scheme")) return SCHEME; + if (equals(name, ":path")) return PATH; + if (equals(name, ":authority")) return AUTHORITY; + if (equals(name, ":protocol")) return PROTOCOL; + return 0; + } + + private static void validateRegular(ByteView name, ByteView value, int streamId) { + for (int i = 0; i < name.length(); i++) { + int c = name.byteAt(i) & 0xff; + if (c >= 'A' && c <= 'Z') fail(streamId, "uppercase field name"); + } + if (equals(name, "connection") + || equals(name, "keep-alive") + || equals(name, "proxy-connection") + || equals(name, "transfer-encoding") + || equals(name, "upgrade")) { + fail(streamId, "connection-specific field"); + } + if (equals(name, "te") && !equals(value, "trailers")) { + fail(streamId, "invalid te field"); + } + } + + static boolean equals(ByteView view, String expected) { + if (view.length() != expected.length()) return false; + for (int i = 0; i < view.length(); i++) { + if ((view.byteAt(i) & 0xff) != expected.charAt(i)) return false; + } + return true; + } + + static boolean equals(ByteView left, ByteView right) { + if (left.length() != right.length()) return false; + for (int i = 0; i < left.length(); i++) { + if (left.byteAt(i) != right.byteAt(i)) return false; + } + return true; + } + + private static void fail(int streamId, String message) { + throw new Http2StreamException(streamId, Http2ErrorCode.PROTOCOL_ERROR, message); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/stream/Http2FlowController.java b/flash/src/main/java/dev/relism/flash/http2/stream/Http2FlowController.java new file mode 100644 index 0000000..3836c24 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/stream/Http2FlowController.java @@ -0,0 +1,108 @@ +package dev.relism.flash.http2.stream; + +import dev.relism.flash.http2.Http2ErrorCode; +import dev.relism.flash.http2.Http2Exception; +import dev.relism.flash.http2.Http2Limits; +import dev.relism.flash.http2.Http2StreamException; +import java.io.IOException; + +/** Connection-level half of HTTP/2's two-level flow-control accounting. */ +public final class Http2FlowController { + @FunctionalInterface + public interface WindowUpdateSink { + void update(int streamId, int increment) throws IOException; + } + + private final WindowUpdateSink updates; + private int receiveWindow = Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL; + private int consumedSinceUpdate; + private long sendWindow = 65_535; + + public Http2FlowController(WindowUpdateSink updates) { + this.updates = updates; + } + + public synchronized void receiveConnectionBytes(int bytes) { + if (bytes < 0) throw new IllegalArgumentException("bytes must not be negative"); + if (bytes > receiveWindow) throw Http2Exception.FLOW_CONTROL_ERROR; + receiveWindow -= bytes; + } + + public void consumed(Http2Stream stream, int bytes) throws IOException { + int connectionIncrement = 0; + synchronized (this) { + consumedSinceUpdate += bytes; + if (consumedSinceUpdate >= Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL / 2) { + connectionIncrement = consumedSinceUpdate; + receiveWindow += connectionIncrement; + consumedSinceUpdate = 0; + } + } + int streamIncrement = stream.consumedReceiveBytes(bytes); + if (streamIncrement != 0) updates.update(stream.id(), streamIncrement); + if (connectionIncrement != 0) updates.update(0, connectionIncrement); + } + + public void discarded(int bytes) throws IOException { + int increment = 0; + synchronized (this) { + consumedSinceUpdate += bytes; + if (consumedSinceUpdate >= Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL / 2) { + increment = consumedSinceUpdate; + receiveWindow += increment; + consumedSinceUpdate = 0; + } + } + if (increment != 0) updates.update(0, increment); + } + + public synchronized int reserveSend(Http2Stream stream, int requested) { + int streamWindow = stream.sendWindow(); + if (requested <= 0 || sendWindow <= 0 || streamWindow <= 0) return 0; + int granted = + (int) + Math.min(requested, Math.min(sendWindow, Math.min(streamWindow, Integer.MAX_VALUE))); + sendWindow -= granted; + stream.adjustSendWindow(-granted); + return granted; + } + + public synchronized void refundSend(Http2Stream stream, int bytes) { + if (bytes == 0) return; + sendWindow += bytes; + stream.adjustSendWindow(bytes); + } + + public synchronized void increaseConnectionSendWindow(int increment) { + long next = sendWindow + increment; + if (next > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR; + sendWindow = next; + } + + public synchronized void increaseStreamSendWindow(Http2Stream stream, int increment) { + stream.adjustSendWindow(increment); + } + + public synchronized void initializeStreamSendWindow(Http2Stream stream, int initialWindow) { + stream.adjustSendWindow(initialWindow - 65_535); + } + + public synchronized void applyInitialWindowDelta(Http2StreamTable streams, int delta) { + streams.adjustAllSendWindows(delta); + } + + public void receiveStreamBytes(Http2Stream stream, int bytes) { + if (!stream.receiveBytes(bytes)) { + throw new Http2StreamException( + stream.id(), Http2ErrorCode.FLOW_CONTROL_ERROR, "stream receive window exceeded"); + } + } + + public synchronized int connectionReceiveWindow() { + return receiveWindow; + } + + public synchronized long connectionSendWindow() { + return sendWindow; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/stream/Http2Stream.java b/flash/src/main/java/dev/relism/flash/http2/stream/Http2Stream.java new file mode 100644 index 0000000..1c7808d --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/stream/Http2Stream.java @@ -0,0 +1,380 @@ +package dev.relism.flash.http2.stream; + +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.http2.Http2ErrorCode; +import dev.relism.flash.http2.Http2Limits; +import dev.relism.flash.http2.Http2StreamException; +import dev.relism.flash.http2.hpack.HpackHeaderBlock; +import dev.relism.flash.http2.message.DataBufferPool; +import dev.relism.flash.http2.message.Http2HeaderMap; +import dev.relism.flash.http2.message.Http2RequestBody; +import dev.relism.flash.http2.message.Http2ResponseWriter; +import dev.relism.flash.http2.message.PseudoHeaders; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.RequestBody; +import dev.relism.flash.models.RequestLine; +import dev.relism.flash.models.Response; +import dev.relism.flash.routing.AbstractRouter; +import dev.relism.flash.routing.AbstractWsRouter; +import dev.relism.fpr.core.ByteView; +import java.io.IOException; +import java.net.InetSocketAddress; +import javax.net.ssl.SSLSocket; + +/** Per-stream request, response, decoded-header and write state. */ +public final class Http2Stream + implements Http2ResponseWriter.Completion, Http2RequestBody.ConsumptionListener, Runnable { + public interface ResponseSink { + void handleRequest(Http2Stream stream); + + void responseBatchCompleted(Http2Stream stream); + + void resumeResponse(Http2Stream stream); + } + + private static final byte[] HTTP_2 = {'H', 'T', 'T', 'P', '/', '2'}; + + private final HpackHeaderBlock headerBlock = new HpackHeaderBlock(); + private final HpackHeaderBlock trailerBlock = new HpackHeaderBlock(); + private final PseudoHeaders pseudoHeaders = new PseudoHeaders(); + private final Http2HeaderMap headers = new Http2HeaderMap(); + private final Http2HeaderMap trailers = new Http2HeaderMap(); + private final RequestLine requestLine = new RequestLine(); + private final RequestBody requestBody = new RequestBody(); + private final Http2RequestBody http2Body; + private final Request request = new Request(); + private final Response response = new Response(200, ContentType.TEXT_PLAIN); + private final Http2ResponseWriter responseWriter = new Http2ResponseWriter(); + private final PooledSlice path = new PooledSlice(); + private final PooledSlice query = new PooledSlice(); + private final PooledSlice protocol = new PooledSlice(); + private final PooledSlice scanName = new PooledSlice(); + private final PooledSlice scanValue = new PooledSlice(); + + private int id; + private Http2StreamState state = Http2StreamState.IDLE; + private int sendWindow = 65_535; + private int receiveWindow = Http2Limits.INITIAL_WINDOW_SIZE_LOCAL; + private int consumedReceiveBytes; + private int emptyDataFrames; + private Http2StreamTable owner; + private Object routeScratch; + private Object wsRouteScratch; + private volatile boolean dispatched; + private volatile boolean cancelled; + private boolean headersValidated; + private Http2FlowController flowController; + private ResponseSink responseSink; + private volatile boolean responseInFlight; + private volatile boolean responseStarted; + private boolean releaseClaimed; + private volatile boolean resumeTask; + private volatile long lastActivityNanos; + Http2Stream poolNext; + + Http2Stream(DataBufferPool dataBuffers) { + http2Body = new Http2RequestBody(dataBuffers); + responseWriter.completion(this); + protocol.reset(HTTP_2, 0, HTTP_2.length); + } + + void reset(int id, Http2StreamTable owner) { + this.id = id; + this.owner = owner; + state = Http2StreamState.IDLE; + sendWindow = 65_535; + receiveWindow = Http2Limits.INITIAL_WINDOW_SIZE_LOCAL; + consumedReceiveBytes = 0; + emptyDataFrames = 0; + dispatched = false; + cancelled = false; + headersValidated = false; + responseInFlight = false; + responseStarted = false; + releaseClaimed = false; + resumeTask = false; + responseSink = null; + headerBlock.reset(); + trailerBlock.reset(); + trailers.reset(trailerBlock); + touch(); + } + + void clear() { + request.recycle(); + response.recycle(); + id = 0; + owner = null; + state = Http2StreamState.CLOSED; + } + + public Request assembleRequest(InetSocketAddress remoteAddress, SSLSocket sslSocket) { + validateHeaders(); + headers.reset(headerBlock, pseudoHeaders); + PooledSlice rawPath = pseudoHeaders.path(); + if (rawPath.array() == null) rawPath = pseudoHeaders.authority(); + int question = -1; + for (int i = 0; i < rawPath.length(); i++) { + if (rawPath.byteAt(i) == '?') { + question = i; + break; + } + } + if (question < 0) { + path.reset(rawPath.array(), rawPath.offset(), rawPath.length()); + query.reset(null, 0, 0); + } else { + path.reset(rawPath.array(), rawPath.offset(), question); + query.reset( + rawPath.array(), rawPath.offset() + question + 1, rawPath.length() - question - 1); + } + PooledSlice methodBytes = pseudoHeaders.method(); + HttpMethod method = + HttpMethod.fromBytes(methodBytes.array(), methodBytes.offset(), methodBytes.length()); + if (method == null) { + throw new Http2StreamException( + id, Http2ErrorCode.PROTOCOL_ERROR, "unsupported request method"); + } + if (pseudoHeaders.websocket()) method = HttpMethod.GET; + requestLine.reset(method, path, question < 0 ? null : query, protocol, headers); + requestBody.reset(http2Body, http2Body.declaredLength(), null, 0, 0); + Request assembled = + Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket); + assembled.setTrailers(trailers); + return assembled; + } + + public void validateHeaders() { + if (headersValidated) return; + pseudoHeaders.validate(headerBlock, id); + headersValidated = true; + } + + public boolean prepareRequestBody(Http2FlowController flowController, boolean endStream) { + this.flowController = flowController; + long contentLength = parseContentLength(); + if (contentLength < 0 && endStream) contentLength = 0; + boolean inline = contentLength >= 0 && contentLength <= Http2Limits.INLINE_BODY_THRESHOLD; + http2Body.begin(contentLength, inline, this); + if (endStream) http2Body.finish(id); + return endStream || !inline; + } + + public void receiveData(byte[] source, int offset, int length, int flowControlledBytes) { + http2Body.offer(id, source, offset, length, flowControlledBytes); + } + + public void finishRequestBody() { + http2Body.finish(id); + } + + public void validateTrailers() { + PseudoHeaders.validateTrailers(trailerBlock, id); + trailers.reset(trailerBlock); + } + + private long parseContentLength() { + long parsed = -1; + for (int i = 0; i < headerBlock.count(); i++) { + headerBlock.get(i, scanName, scanValue); + if (!equals(scanName, "content-length")) continue; + long value = parseDecimal(scanValue); + if (parsed >= 0 && parsed != value) { + throw new Http2StreamException( + id, Http2ErrorCode.PROTOCOL_ERROR, "conflicting content-length fields"); + } + parsed = value; + } + return parsed; + } + + private long parseDecimal(ByteView value) { + if (value.length() == 0) { + throw new Http2StreamException(id, Http2ErrorCode.PROTOCOL_ERROR, "empty content-length"); + } + long parsed = 0; + for (int i = 0; i < value.length(); i++) { + int digit = (value.byteAt(i) & 0xff) - '0'; + if (digit < 0 || digit > 9 || parsed > (Http2Limits.MAX_REQUEST_BODY_SIZE - digit) / 10L) { + throw new Http2StreamException( + id, Http2ErrorCode.PROTOCOL_ERROR, "invalid or oversized content-length"); + } + parsed = parsed * 10 + digit; + } + return parsed; + } + + private static boolean equals(ByteView value, String expected) { + if (value.length() != expected.length()) return false; + for (int i = 0; i < value.length(); i++) { + if ((value.byteAt(i) & 0xff) != expected.charAt(i)) return false; + } + return true; + } + + public Response resetResponse() { + return response.reset(200, ContentType.TEXT_PLAIN); + } + + public void transition(Http2StreamState.Event event) { + state = state.transition(id, event); + } + + public int id() { + return id; + } + + public Http2StreamState state() { + return state; + } + + public HpackHeaderBlock headerBlock() { + return headerBlock; + } + + public HpackHeaderBlock trailerBlock() { + return trailerBlock; + } + + public Http2ResponseWriter responseWriter() { + return responseWriter; + } + + public void responseSink(ResponseSink responseSink) { + this.responseSink = responseSink; + } + + public void markResponseStarted() { + responseStarted = true; + } + + public boolean responseStarted() { + return responseStarted; + } + + public void markResumeTask() { + resumeTask = true; + } + + public synchronized boolean beginResponseBatch() { + if (responseInFlight) return false; + responseInFlight = true; + return true; + } + + public synchronized void endResponseBatch() { + responseInFlight = false; + } + + public synchronized boolean responseInFlight() { + return responseInFlight; + } + + synchronized boolean claimRelease() { + if (releaseClaimed) return false; + releaseClaimed = true; + return true; + } + + public Object routeScratch(AbstractRouter router) { + if (routeScratch == null) routeScratch = router.newScratch(); + return routeScratch; + } + + public Object wsRouteScratch(AbstractWsRouter router) { + if (wsRouteScratch == null) wsRouteScratch = router.newScratch(); + return wsRouteScratch; + } + + public boolean websocketConnect() { + return pseudoHeaders.websocket(); + } + + public void markDispatched() { + dispatched = true; + } + + public boolean dispatched() { + return dispatched; + } + + public void cancel() { + cancelled = true; + int discarded = http2Body.cancel(); + if (discarded != 0 && flowController != null) { + try { + flowController.discarded(discarded); + } catch (IOException failure) { + throw new IllegalStateException("failed to restore discarded flow-control bytes", failure); + } + } + } + + public boolean cancelled() { + return cancelled; + } + + public void touch() { + lastActivityNanos = System.nanoTime(); + } + + public boolean idleExpired(long nowNanos, long timeoutNanos) { + return timeoutNanos > 0 && nowNanos - lastActivityNanos >= timeoutNanos; + } + + public synchronized int sendWindow() { + return sendWindow; + } + + public synchronized void adjustSendWindow(int delta) { + long adjusted = (long) sendWindow + delta; + if (adjusted > Integer.MAX_VALUE) throw new IllegalStateException("stream window overflow"); + sendWindow = (int) adjusted; + } + + public synchronized boolean receiveBytes(int bytes) { + if (bytes > receiveWindow) return false; + receiveWindow -= bytes; + return true; + } + + public synchronized int consumedReceiveBytes(int bytes) { + consumedReceiveBytes += bytes; + if (consumedReceiveBytes < Http2Limits.INITIAL_WINDOW_SIZE_LOCAL / 2) { + return 0; + } + int increment = consumedReceiveBytes; + receiveWindow += increment; + consumedReceiveBytes = 0; + return increment; + } + + public int incrementEmptyDataFrames() { + return ++emptyDataFrames; + } + + public void resetEmptyDataFrames() { + emptyDataFrames = 0; + } + + @Override + public void consumed(int flowControlledBytes) throws IOException { + flowController.consumed(this, flowControlledBytes); + } + + @Override + public void responseWriteCompleted() { + ResponseSink sink = responseSink; + if (sink != null) sink.responseBatchCompleted(this); + } + + @Override + public void run() { + ResponseSink sink = responseSink; + if (sink == null) return; + if (resumeTask) sink.resumeResponse(this); + else sink.handleRequest(this); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/stream/Http2StreamState.java b/flash/src/main/java/dev/relism/flash/http2/stream/Http2StreamState.java new file mode 100644 index 0000000..29e3d85 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/stream/Http2StreamState.java @@ -0,0 +1,97 @@ +package dev.relism.flash.http2.stream; + +import dev.relism.flash.http2.Http2ErrorCode; +import dev.relism.flash.http2.Http2StreamException; + +/** Explicit RFC 9113 stream-state transition table. */ +public enum Http2StreamState { + IDLE, + OPEN, + HALF_CLOSED_REMOTE, + HALF_CLOSED_LOCAL, + CLOSED; + + public enum Event { + RECV_HEADERS, + RECV_HEADERS_ES, + RECV_DATA, + RECV_DATA_ES, + RECV_RST, + SEND_HEADERS, + SEND_HEADERS_ES, + SEND_DATA, + SEND_DATA_ES, + SEND_RST + } + + private static final byte ERROR = -1; + private static final byte[][] TRANSITIONS = buildTransitions(); + // enum values() clones its backing array on every call; this is read-only and shared safely. + private static final Http2StreamState[] VALUES = values(); + + public Http2StreamState transition(int streamId, Event event) { + int next = TRANSITIONS[ordinal()][event.ordinal()]; + if (next == ERROR) { + throw new Http2StreamException( + streamId, errorFor(event), "invalid stream transition " + this + " + " + event); + } + return VALUES[next]; + } + + private Http2ErrorCode errorFor(Event event) { + if (this == CLOSED) return Http2ErrorCode.STREAM_CLOSED; + if (this == HALF_CLOSED_REMOTE + && (event == Event.RECV_DATA + || event == Event.RECV_DATA_ES + || event == Event.RECV_HEADERS + || event == Event.RECV_HEADERS_ES)) { + return Http2ErrorCode.STREAM_CLOSED; + } + return Http2ErrorCode.PROTOCOL_ERROR; + } + + public static boolean isValid(Http2StreamState state, Event event) { + return TRANSITIONS[state.ordinal()][event.ordinal()] != ERROR; + } + + private static byte[][] buildTransitions() { + byte[][] table = new byte[values().length][Event.values().length]; + for (byte[] row : table) java.util.Arrays.fill(row, ERROR); + + set(table, IDLE, Event.RECV_HEADERS, OPEN); + set(table, IDLE, Event.RECV_HEADERS_ES, HALF_CLOSED_REMOTE); + + set(table, OPEN, Event.RECV_HEADERS, OPEN); + set(table, OPEN, Event.RECV_HEADERS_ES, HALF_CLOSED_REMOTE); + set(table, OPEN, Event.RECV_DATA, OPEN); + set(table, OPEN, Event.RECV_DATA_ES, HALF_CLOSED_REMOTE); + set(table, OPEN, Event.RECV_RST, CLOSED); + set(table, OPEN, Event.SEND_HEADERS, OPEN); + set(table, OPEN, Event.SEND_HEADERS_ES, HALF_CLOSED_LOCAL); + set(table, OPEN, Event.SEND_DATA, OPEN); + set(table, OPEN, Event.SEND_DATA_ES, HALF_CLOSED_LOCAL); + set(table, OPEN, Event.SEND_RST, CLOSED); + + set(table, HALF_CLOSED_REMOTE, Event.RECV_RST, CLOSED); + set(table, HALF_CLOSED_REMOTE, Event.SEND_HEADERS, HALF_CLOSED_REMOTE); + set(table, HALF_CLOSED_REMOTE, Event.SEND_HEADERS_ES, CLOSED); + set(table, HALF_CLOSED_REMOTE, Event.SEND_DATA, HALF_CLOSED_REMOTE); + set(table, HALF_CLOSED_REMOTE, Event.SEND_DATA_ES, CLOSED); + set(table, HALF_CLOSED_REMOTE, Event.SEND_RST, CLOSED); + + set(table, HALF_CLOSED_LOCAL, Event.RECV_HEADERS, HALF_CLOSED_LOCAL); + set(table, HALF_CLOSED_LOCAL, Event.RECV_HEADERS_ES, CLOSED); + set(table, HALF_CLOSED_LOCAL, Event.RECV_DATA, HALF_CLOSED_LOCAL); + set(table, HALF_CLOSED_LOCAL, Event.RECV_DATA_ES, CLOSED); + set(table, HALF_CLOSED_LOCAL, Event.RECV_RST, CLOSED); + set(table, HALF_CLOSED_LOCAL, Event.SEND_RST, CLOSED); + + set(table, CLOSED, Event.RECV_RST, CLOSED); + set(table, CLOSED, Event.SEND_RST, CLOSED); + return table; + } + + private static void set(byte[][] table, Http2StreamState from, Event event, Http2StreamState to) { + table[from.ordinal()][event.ordinal()] = (byte) to.ordinal(); + } +} diff --git a/flash/src/main/java/dev/relism/flash/http2/stream/Http2StreamTable.java b/flash/src/main/java/dev/relism/flash/http2/stream/Http2StreamTable.java new file mode 100644 index 0000000..fb0a801 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http2/stream/Http2StreamTable.java @@ -0,0 +1,219 @@ +package dev.relism.flash.http2.stream; + +import dev.relism.flash.http2.Http2Limits; +import dev.relism.flash.http2.message.DataBufferPool; +import java.util.Arrays; + +/** Fixed-capacity primitive stream-id table using linear-probed open addressing. */ +public final class Http2StreamTable { + + @FunctionalInterface + public interface StreamConsumer { + void accept(Http2Stream stream); + } + + private final int[] keys; + private final Http2Stream[] values; + private final int mask; + private final int maxEntries; + private final int maxObjects; + private final int[] closedIds; + private final byte[] closedKinds; + private int size; + private Http2Stream free; + private int created; + private final DataBufferPool dataBuffers; + private int closedCursor; + + public static final int CLOSED_UNKNOWN = 0; + public static final int CLOSED_NORMALLY = 1; + public static final int CLOSED_BY_RESET = 2; + + public Http2StreamTable(int maxEntries) { + this( + maxEntries, + new DataBufferPool(Http2Limits.MAX_FRAME_SIZE_LOCAL, Http2Limits.DATA_BUFFER_POOL_SIZE)); + } + + public Http2StreamTable(int maxEntries, DataBufferPool dataBuffers) { + if (maxEntries < 1) throw new IllegalArgumentException("maxEntries must be positive"); + int capacity = 1; + while (capacity < maxEntries * 2) capacity <<= 1; + keys = new int[capacity]; + values = new Http2Stream[capacity]; + mask = capacity - 1; + this.maxEntries = maxEntries; + maxObjects = maxEntries * 2; + this.dataBuffers = dataBuffers; + closedIds = new int[maxEntries * 2]; + closedKinds = new byte[closedIds.length]; + } + + public synchronized Http2Stream get(int streamId) { + int slot = find(streamId); + return keys[slot] == streamId ? values[slot] : null; + } + + public synchronized void put(Http2Stream stream) { + if (size == maxEntries) throw new IllegalStateException("stream table capacity exceeded"); + int streamId = stream.id(); + int slot = find(streamId); + if (keys[slot] == streamId) throw new IllegalStateException("duplicate stream " + streamId); + keys[slot] = streamId; + values[slot] = stream; + size++; + } + + public synchronized Http2Stream acquire(int streamId) { + if (size == maxEntries) return null; + Http2Stream stream = free; + if (stream != null) { + free = stream.poolNext; + stream.poolNext = null; + } else { + if (created == maxObjects) return null; + stream = new Http2Stream(dataBuffers); + created++; + } + stream.reset(streamId, this); + put(stream); + return stream; + } + + public synchronized void release(Http2Stream stream) { + if (!stream.claimRelease()) return; + stream.clear(); + stream.poolNext = free; + free = stream; + } + + public synchronized Http2Stream remove(int streamId) { + int slot = find(streamId); + if (keys[slot] != streamId) return null; + Http2Stream removed = values[slot]; + keys[slot] = 0; + values[slot] = null; + size--; + + int scan = (slot + 1) & mask; + while (keys[scan] != 0) { + int key = keys[scan]; + Http2Stream value = values[scan]; + keys[scan] = 0; + values[scan] = null; + size--; + put(value); + scan = (scan + 1) & mask; + } + return removed; + } + + /** Removes a stream only when both its id and pooled-object identity still match. */ + public synchronized boolean removeIfSame(Http2Stream stream, int streamId) { + if (streamId <= 0) return false; + int slot = find(streamId); + if (keys[slot] != streamId || values[slot] != stream) return false; + remove(streamId); + return true; + } + + /** Atomically removes and recycles the matching generation of a pooled stream. */ + public synchronized boolean retire(Http2Stream stream, int streamId) { + if (!removeIfSame(stream, streamId)) return false; + rememberClosed(streamId, CLOSED_NORMALLY); + release(stream); + return true; + } + + /** Removes a wire-closed stream from live concurrency while retaining its in-flight buffer. */ + public synchronized boolean detach(Http2Stream stream, int streamId) { + if (!removeIfSame(stream, streamId)) return false; + rememberClosed(streamId, CLOSED_NORMALLY); + return true; + } + + public synchronized void rememberReset(int streamId) { + rememberClosed(streamId, CLOSED_BY_RESET); + } + + public synchronized int closedKind(int streamId) { + for (int i = 0; i < closedIds.length; i++) { + if (closedIds[i] == streamId) return closedKinds[i]; + } + return CLOSED_UNKNOWN; + } + + public synchronized void forEach(StreamConsumer consumer) { + for (int i = 0; i < keys.length; i++) { + if (keys[i] != 0) consumer.accept(values[i]); + } + } + + public synchronized int copyValues(Http2Stream[] target) { + int count = 0; + for (int i = 0; i < keys.length && count < target.length; i++) { + if (keys[i] != 0) target[count++] = values[i]; + } + return count; + } + + public synchronized void adjustAllSendWindows(int delta) { + for (int i = 0; i < keys.length; i++) { + if (keys[i] == 0) continue; + long adjusted = (long) values[i].sendWindow() + delta; + if (adjusted > Integer.MAX_VALUE) { + throw new IllegalStateException("stream window overflow"); + } + } + for (int i = 0; i < keys.length; i++) { + if (keys[i] != 0) values[i].adjustSendWindow(delta); + } + } + + public synchronized int size() { + return size; + } + + public int capacity() { + return maxEntries; + } + + public synchronized int createdCount() { + return created; + } + + public synchronized int freeCount() { + int count = 0; + for (Http2Stream stream = free; stream != null; stream = stream.poolNext) count++; + return count; + } + + public synchronized void clear() { + Arrays.fill(keys, 0); + Arrays.fill(values, null); + Arrays.fill(closedIds, 0); + Arrays.fill(closedKinds, (byte) 0); + size = 0; + closedCursor = 0; + } + + private void rememberClosed(int streamId, int kind) { + closedIds[closedCursor] = streamId; + closedKinds[closedCursor] = (byte) kind; + closedCursor = (closedCursor + 1) % closedIds.length; + } + + private int find(int streamId) { + if (streamId <= 0) throw new IllegalArgumentException("streamId must be positive"); + int slot = mix(streamId) & mask; + while (keys[slot] != 0 && keys[slot] != streamId) slot = (slot + 1) & mask; + return slot; + } + + private static int mix(int value) { + value ^= value >>> 16; + value *= 0x7feb352d; + value ^= value >>> 15; + return value; + } +} diff --git a/flash/src/main/java/dev/relism/flash/models/BodyCompletion.java b/flash/src/main/java/dev/relism/flash/models/BodyCompletion.java new file mode 100644 index 0000000..106cb69 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/models/BodyCompletion.java @@ -0,0 +1,6 @@ +package dev.relism.flash.models; + +/** Internal completion signal used to enforce request-trailer ordering. */ +public interface BodyCompletion { + boolean fullyRead(); +} diff --git a/flash/src/main/java/dev/relism/flash/models/EmptyHeaderView.java b/flash/src/main/java/dev/relism/flash/models/EmptyHeaderView.java new file mode 100644 index 0000000..92afc66 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/models/EmptyHeaderView.java @@ -0,0 +1,18 @@ +package dev.relism.flash.models; + +import dev.relism.fpr.core.ByteView; +import java.util.List; + +/** Immutable empty header collection shared by requests without trailers. */ +public enum EmptyHeaderView implements HeaderView { + INSTANCE; + + @Override public String first(String name) { return null; } + @Override public List all(String name) { return List.of(); } + @Override public List all() { return List.of(); } + @Override public ByteView view(String name) { return null; } + @Override public boolean valueEqualsIgnoreCase(String name, String value) { return false; } + @Override public boolean contains(String name) { return false; } + @Override public int count() { return 0; } + @Override public void forEach(HeaderConsumer consumer) {} +} diff --git a/flash/src/main/java/dev/relism/flash/models/HeaderMap.java b/flash/src/main/java/dev/relism/flash/models/HeaderMap.java deleted file mode 100644 index 090d6ec..0000000 --- a/flash/src/main/java/dev/relism/flash/models/HeaderMap.java +++ /dev/null @@ -1,221 +0,0 @@ -package dev.relism.flash.models; - -import dev.relism.fpr.core.ByteView; -import lombok.NoArgsConstructor; - -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; - -/** - * Lazy, zero-copy header access backed directly by the request parser's byte buffer. - * Strings are allocated only when {@link #first} / {@link #all} / {@link #view} is called; - * the raw bytes are never copied at parse time. - * - *

Lifetime contract — read carefully

- * One {@code HeaderMap} instance lives on the connection (not per-request). On every - * keep-alive request {@link #reset} is called to slide the window over the new header - * section of the same reused buffer. This has two critical implications: - * - *
    - *
  1. Do not retain the {@code HeaderMap} beyond the handler. After the handler - * returns, the next request reuses and overwrites the buffer. Any {@code String} - * values retrieved via {@link #first}/{@link #all} are safe (they are independent - * heap copies); the {@code HeaderMap} object itself is not.
  2. - *
  3. {@link #view} returns a zero-copy {@link dev.relism.fpr.core.ByteView} slice - * into the live buffer. Storing this view and reading it after the handler - * returns (e.g. in an async callback, a {@link java.util.concurrent.CompletableFuture} - * continuation, or a virtual-thread handoff) is a data race — the bytes - * may have been overwritten by the next request. Copy to a {@code String} or - * {@code byte[]} before leaving the synchronous handler scope.
  4. - *
- */ -@NoArgsConstructor -public class HeaderMap { - private byte[] buffer; - private int sectionStart; - private int sectionEnd; - - // Lazily created, then reused for the life of this HeaderMap (i.e. the connection — - // see the class javadoc) across every #forEach call and every header within a call. - // Same idiom as #view's per-call anonymous ByteView, just amortized to zero allocations - // instead of two per header: the slices are repositioned in place, not reallocated. - private Slice nameSlice; - private Slice valueSlice; - - /** Resets this map to the header section {@code buffer[sectionStart, sectionEnd)}. */ - public void reset(byte[] buffer, int sectionStart, int sectionEnd) { - this.buffer = buffer; - this.sectionStart = sectionStart; - this.sectionEnd = sectionEnd; - } - - /** - * Visits every header in declaration order without allocating — no per-header {@code - * String}/{@link ByteView}/list-entry object, unlike {@link #all()}. {@code name}/{@code - * value} are the same two {@link ByteView} instances on every call, repositioned in place; - * they are valid only for the duration of that single {@link HeaderConsumer#accept} call — - * same "do not retain past the handler" rule as {@link #view}, just per-invocation instead - * of per-request. Prefer a non-capturing or field-reusing {@link HeaderConsumer} (see its - * javadoc) if the call site itself needs to stay allocation-free too. - * - *

Exists for callers that must handle an open-ended set of header names — e.g. a reverse - * proxy forwarding whatever the client sent — where {@link #first}/{@link #all}'s per-name - * lookup isn't usable because the set of names isn't known upfront. - */ - public void forEach(HeaderConsumer consumer) { - if (buffer == null) return; - if (nameSlice == null) { - nameSlice = new Slice(); - valueSlice = new Slice(); - } - int i = sectionStart; - while (i < sectionEnd) { - int lineEnd = findCR(i); - int colon = findColon(i, lineEnd); - if (colon != -1) { - int vs = skipSpaces(colon + 1, lineEnd); - nameSlice.start = i; - nameSlice.len = colon - i; - valueSlice.start = vs; - valueSlice.len = lineEnd - vs; - consumer.accept(nameSlice, valueSlice); - } - i = lineEnd + 2; - } - } - - /** - * Callback for {@link #forEach}. Implement with a reusable, field-holding instance (reset - * before each {@code forEach} call) rather than a capturing lambda if the call site itself - * needs to be allocation-free too — a capturing lambda is its own per-call allocation, same - * as anywhere else on a hot path (see {@code docs/CODE-STYLE.md} in the Pathway project for - * the idiom this mirrors). - */ - @FunctionalInterface - public interface HeaderConsumer { - void accept(ByteView name, ByteView value); - } - - /** Mutable zero-copy slice into {@link #buffer} — see {@link #forEach}. */ - private final class Slice implements ByteView { - int start; - int len; - - @Override public int length() { return len; } - @Override public byte byteAt(int i) { return buffer[start + i]; } - } - - /** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */ - public String first(String name) { - long r = findFirst(name); - if (r < 0) return null; - int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL); - return new String(buffer, s, l, StandardCharsets.UTF_8); - } - - /** Returns all values of header {@code name} in declaration order, or an empty list. */ - public List all(String name) { - if (buffer == null) return List.of(); - List result = null; - int i = sectionStart; - while (i < sectionEnd) { - int lineEnd = findCR(i); - int colon = findColon(i, lineEnd); - if (colon != -1 && keyMatches(i, colon - i, name)) { - int vs = skipSpaces(colon + 1, lineEnd); - if (result == null) result = new ArrayList<>(); - result.add(new String(buffer, vs, lineEnd - vs, StandardCharsets.UTF_8)); - } - i = lineEnd + 2; - } - return result != null ? result : List.of(); - } - - /** Returns all header values in declaration order. */ - public List all() { - if (buffer == null) return List.of(); - List result = new ArrayList<>(); - int i = sectionStart; - while (i < sectionEnd) { - int lineEnd = findCR(i); - int colon = findColon(i, lineEnd); - if (colon != -1) { - int vs = skipSpaces(colon + 1, lineEnd); - result.add(new String(buffer, vs, lineEnd - vs, StandardCharsets.UTF_8)); - } - i = lineEnd + 2; - } - return result; - } - - /** Case-insensitive comparison of the first value of {@code name} against {@code value}. */ - public boolean valueEqualsIgnoreCase(String name, String value) { - long r = findFirst(name); - if (r < 0) return false; - int vs = (int) (r >> 32), vl = (int) (r & 0xFFFFFFFFL); - if (vl != value.length()) return false; - for (int i = 0; i < vl; i++) { - byte b = buffer[vs + i]; - if (b >= 'A' && b <= 'Z') b += 32; - char c = value.charAt(i); - if (c >= 'A' && c <= 'Z') c += 32; - if (b != (byte) c) return false; - } - return true; - } - - /** Returns a zero-copy {@link ByteView} over the first value of {@code name}, or {@code null}. */ - public ByteView view(String name) { - long r = findFirst(name); - if (r < 0) return null; - final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL); - return new ByteView() { - public int length() { return l; } - public byte byteAt(int i) { return buffer[s + i]; } - }; - } - - /** Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. */ - private long findFirst(String name) { - if (buffer == null) return -1L; - int i = sectionStart; - while (i < sectionEnd) { - int lineEnd = findCR(i); - int colon = findColon(i, lineEnd); - if (colon != -1 && keyMatches(i, colon - i, name)) { - int vs = skipSpaces(colon + 1, lineEnd); - return ((long) vs << 32) | (lineEnd - vs); - } - i = lineEnd + 2; - } - return -1L; - } - - private int findCR(int from) { - for (int i = from; i < sectionEnd; i++) if (buffer[i] == '\r') return i; - return sectionEnd; - } - - private int findColon(int from, int to) { - for (int i = from; i < to; i++) if (buffer[i] == ':') return i; - return -1; - } - - private int skipSpaces(int from, int end) { - while (from < end && buffer[from] == ' ') from++; - return from; - } - - private boolean keyMatches(int start, int len, String name) { - if (len != name.length()) return false; - for (int i = 0; i < len; i++) { - byte b = buffer[start + i]; - if (b >= 'A' && b <= 'Z') b += 32; - char c = name.charAt(i); - if (c >= 'A' && c <= 'Z') c += 32; - if (b != (byte) c) return false; - } - return true; - } -} diff --git a/flash/src/main/java/dev/relism/flash/models/HeaderView.java b/flash/src/main/java/dev/relism/flash/models/HeaderView.java new file mode 100644 index 0000000..712ac18 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/models/HeaderView.java @@ -0,0 +1,62 @@ +package dev.relism.flash.models; + +import dev.relism.fpr.core.ByteView; + +import java.util.List; + +/** + * 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. + * + *

{@link RequestLine#getHeaders()} exposes this interface rather than a protocol-specific + * implementation so request handling remains independent of the transport protocol. + * + *

Lifetime contract

+ * 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 + * HeaderConsumer#accept} are not — see each implementation's own Javadoc for its exact reuse + * window. + */ +public interface HeaderView { + + /** Returns the first value of header {@code name} (case-insensitive), or {@code null}. */ + String first(String name); + + /** Returns all values of header {@code name} in declaration order, or an empty list. */ + List all(String name); + + /** Returns all header values in declaration order. */ + List all(); + + /** Returns a view over the first value of {@code name}, or {@code null} — see the implementation's own reuse-window contract. */ + ByteView view(String name); + + /** Case-insensitive comparison of the first value of {@code name} against {@code value}. */ + boolean valueEqualsIgnoreCase(String name, String value); + + /** Whether any header named {@code name} is present. */ + boolean contains(String name); + + /** Total number of header lines (not distinct names — a repeated header counts once per line). */ + int count(); + + /** + * Visits every header in declaration order without allocating a per-header object — see each + * implementation's Javadoc for exactly which instances are reused and their validity window. + */ + void forEach(HeaderConsumer consumer); + + /** + * Callback for {@link #forEach}. Implement with a reusable, field-holding instance (reset + * before each {@code forEach} call) rather than a capturing lambda if the call site itself + * needs to be allocation-free too — a capturing lambda is its own per-call allocation, same + * as anywhere else on a hot path. + */ + @FunctionalInterface + interface HeaderConsumer { + void accept(ByteView name, ByteView value); + } +} diff --git a/flash/src/main/java/dev/relism/flash/models/Http1HeaderMap.java b/flash/src/main/java/dev/relism/flash/models/Http1HeaderMap.java new file mode 100644 index 0000000..af932a1 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/models/Http1HeaderMap.java @@ -0,0 +1,253 @@ +package dev.relism.flash.models; + +import dev.relism.flash.bytes.ByteScan; +import dev.relism.flash.bytes.SlicePool; +import dev.relism.flash.http.Http1Limits; +import dev.relism.fpr.core.ByteView; +import lombok.NoArgsConstructor; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +/** + * {@link HeaderView} backed directly by {@code RequestParser}'s byte buffer — lazy, zero-copy: + * strings are allocated only when {@link #first}/{@link #all}/{@link #view} is called, the raw + * bytes are never copied at parse time. + * + *

Package placement

+ * Despite the {@code Http1} prefix, this class lives in {@code dev.relism.flash.models}, not + * {@code dev.relism.flash.http1}, deliberately: {@code RequestParser} (which owns and resets one + * 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 + * reader does not "fix" the location back to what the plan's Files list originally suggested. + * + *

Lifetime contract — read carefully

+ * One {@code Http1HeaderMap} instance lives on the connection (not per-request). On every + * keep-alive request {@link #reset} is called to slide the window over the new header + * section of the same reused buffer. This has two critical implications: + * + *
    + *
  1. Do not retain the {@code Http1HeaderMap} beyond the handler. After the handler + * returns, the next request reuses and overwrites the buffer. Any {@code String} + * values retrieved via {@link #first}/{@link #all} are safe (they are independent + * heap copies); the {@code Http1HeaderMap} object itself is not.
  2. + *
  3. {@link #view} returns a zero-copy {@link dev.relism.fpr.core.ByteView} slice + * into the live buffer, drawn from a small {@link SlicePool} (see {@link #view}'s own + * Javadoc for the exact reuse window). Storing this view and reading it after the + * handler returns (e.g. in an async callback, a {@link java.util.concurrent.CompletableFuture} + * continuation, or a virtual-thread handoff) is a data race — the bytes + * may have been overwritten by the next request. Copy to a {@code String} or + * {@code byte[]} before leaving the synchronous handler scope.
  4. + *
+ * + * {@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 + * ({@link #first}, {@link #all}, {@link #view}, {@link #valueEqualsIgnoreCase}) then walks that + * small index instead of rescanning raw bytes: a hash compare (cheap) before ever falling back to + * a full case-insensitive name comparison. + */ +@NoArgsConstructor +public class Http1HeaderMap implements HeaderView { + private static final int INITIAL_INDEX_CAPACITY = 16; + private static final int VIEW_POOL_SIZE = 4; + + private byte[] buffer; + private int sectionStart; + private int sectionEnd; + + // 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; + private int[] nameOffsets = new int[INITIAL_INDEX_CAPACITY]; + private int[] nameLengths = new int[INITIAL_INDEX_CAPACITY]; + private int[] valueOffsets = new int[INITIAL_INDEX_CAPACITY]; + private int[] valueLengths = new int[INITIAL_INDEX_CAPACITY]; + private int[] nameHashes = new int[INITIAL_INDEX_CAPACITY]; + + private final SlicePool viewPool = new SlicePool(VIEW_POOL_SIZE); + + // forEach's own pair, reused across every header of every call — same idiom as viewPool, + // just a fixed pair rather than a ring, since forEach's contract only needs one name/value + // pair valid at a time (see forEach's Javadoc). + private Slice nameSlice; + private Slice valueSlice; + + public void reset(byte[] buffer, int sectionStart, int sectionEnd) { + beginParsed(buffer, sectionStart, sectionEnd); + buildIndex(); + } + + /** + * Starts an index populated by the request parser while it validates the same header lines. + * This avoids rescanning a validated section solely to recover offsets already known there. + */ + public void beginParsed(byte[] buffer, int sectionStart, int sectionEnd) { + this.buffer = buffer; + this.sectionStart = sectionStart; + this.sectionEnd = sectionEnd; + headerCount = 0; + } + + /** Adds one already-validated header to the current zero-copy index. */ + public void addParsed(int nameOffset, int nameLength, int valueOffset, int valueLength) { + ensureIndexCapacity(headerCount + 1); + nameOffsets[headerCount] = nameOffset; + nameLengths[headerCount] = nameLength; + valueOffsets[headerCount] = valueOffset; + valueLengths[headerCount] = valueLength; + nameHashes[headerCount] = + ByteScan.hashNameIgnoreCaseAscii(buffer, nameOffset, nameLength); + headerCount++; + } + + private void buildIndex() { + headerCount = 0; + if (buffer == null) return; + int i = sectionStart; + while (i < sectionEnd) { + int lineEnd = findCR(i); + int colon = findColon(i, lineEnd); + if (colon != -1) { + int vs = skipSpaces(colon + 1, lineEnd); + addParsed(i, colon - i, vs, lineEnd - vs); + } + i = lineEnd + 2; + } + } + + private void ensureIndexCapacity(int needed) { + if (needed <= nameOffsets.length) return; + // 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. + assert needed <= Http1Limits.MAX_HEADER_COUNT + : "header count " + needed + " exceeds Http1Limits.MAX_HEADER_COUNT — RequestParser should have rejected this already"; + int grown = nameOffsets.length; + while (grown < needed) grown *= 2; + nameOffsets = Arrays.copyOf(nameOffsets, grown); + nameLengths = Arrays.copyOf(nameLengths, grown); + valueOffsets = Arrays.copyOf(valueOffsets, grown); + valueLengths = Arrays.copyOf(valueLengths, grown); + nameHashes = Arrays.copyOf(nameHashes, grown); + } + + @Override + public void forEach(HeaderConsumer consumer) { + if (buffer == null) return; + if (nameSlice == null) { + nameSlice = new Slice(); + valueSlice = new Slice(); + } + for (int i = 0; i < headerCount; i++) { + nameSlice.start = nameOffsets[i]; + nameSlice.len = nameLengths[i]; + valueSlice.start = valueOffsets[i]; + valueSlice.len = valueLengths[i]; + consumer.accept(nameSlice, valueSlice); + } + } + + /** Mutable zero-copy slice into {@link #buffer} — see {@link #forEach}. */ + private final class Slice implements ByteView { + int start; + int len; + + @Override public int length() { return len; } + @Override public byte byteAt(int i) { return buffer[start + i]; } + } + + @Override + public String first(String name) { + int i = indexOfHeader(name); + if (i < 0) return null; + return new String(buffer, valueOffsets[i], valueLengths[i], StandardCharsets.UTF_8); + } + + @Override + public List all(String name) { + if (buffer == null) return List.of(); + List result = null; + int hash = ByteScan.hashNameIgnoreCaseAscii(name); + for (int i = 0; i < headerCount; i++) { + if (nameHashes[i] == hash && ByteScan.equalsIgnoreCaseAscii(buffer, nameOffsets[i], nameOffsets[i] + nameLengths[i], name)) { + if (result == null) result = new ArrayList<>(); + result.add(new String(buffer, valueOffsets[i], valueLengths[i], StandardCharsets.UTF_8)); + } + } + return result != null ? result : List.of(); + } + + @Override + public List all() { + if (buffer == null) return List.of(); + List result = new ArrayList<>(headerCount); + for (int i = 0; i < headerCount; i++) { + result.add(new String(buffer, valueOffsets[i], valueLengths[i], StandardCharsets.UTF_8)); + } + return result; + } + + @Override + public boolean valueEqualsIgnoreCase(String name, String value) { + int i = indexOfHeader(name); + if (i < 0) return false; + return ByteScan.equalsIgnoreCaseAscii(buffer, valueOffsets[i], valueOffsets[i] + valueLengths[i], value); + } + + @Override + public boolean contains(String name) { + return indexOfHeader(name) >= 0; + } + + @Override + public int count() { + return headerCount; + } + + /** + * Returns a zero-copy {@link ByteView} over the first value of {@code name}, or {@code null}. + * + * 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 + * first — at which point the ring wraps around and silently repositions the same instance + * over different bytes. A handler that needs more than {@value #VIEW_POOL_SIZE} views alive + * at once should copy the earlier ones to {@code String}/{@code byte[]} before requesting more. + */ + @Override + public ByteView view(String name) { + int i = indexOfHeader(name); + if (i < 0) return null; + return viewPool.acquire(buffer, valueOffsets[i], valueLengths[i]); + } + + private int indexOfHeader(String name) { + if (buffer == null) return -1; + int hash = ByteScan.hashNameIgnoreCaseAscii(name); + for (int i = 0; i < headerCount; i++) { + if (nameHashes[i] == hash && ByteScan.equalsIgnoreCaseAscii(buffer, nameOffsets[i], nameOffsets[i] + nameLengths[i], name)) { + return i; + } + } + return -1; + } + + private int findCR(int from) { + for (int i = from; i < sectionEnd; i++) if (buffer[i] == '\r') return i; + return sectionEnd; + } + + private int findColon(int from, int to) { + for (int i = from; i < to; i++) if (buffer[i] == ':') return i; + return -1; + } + + private int skipSpaces(int from, int end) { + while (from < end && buffer[from] == ' ') from++; + return from; + } +} diff --git a/flash/src/main/java/dev/relism/flash/models/MutableHeaderMap.java b/flash/src/main/java/dev/relism/flash/models/MutableHeaderMap.java new file mode 100644 index 0000000..b008a26 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/models/MutableHeaderMap.java @@ -0,0 +1,138 @@ +package dev.relism.flash.models; + +import dev.relism.flash.bytes.ByteScan; +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.fpr.core.ByteView; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.io.IOException; +import java.io.OutputStream; + +/** Reusable owned-byte header collection for sections parsed outside the request head buffer. */ +public final class MutableHeaderMap implements HeaderView { + private final ByteWriter bytes = new ByteWriter(128); + private final PooledSlice view = new PooledSlice(); + private final PooledSlice scanName = new PooledSlice(); + private final PooledSlice scanValue = new PooledSlice(); + private int[] fields = new int[16]; + private int count; + + public void reset() { + bytes.reset(); + count = 0; + } + + public void add(byte[] name, int nameOffset, int nameLength, + byte[] value, int valueOffset, int valueLength) { + ensure(count + 1); + int base = count * 4; + fields[base] = bytes.length(); + fields[base + 1] = nameLength; + bytes.writeBytes(name, nameOffset, nameLength); + fields[base + 2] = bytes.length(); + fields[base + 3] = valueLength; + bytes.writeBytes(value, valueOffset, valueLength); + count++; + } + + public void writeLines(OutputStream output) throws IOException { + for (int i = 0; i < count; i++) { + int base = i * 4; + output.write(bytes.array(), fields[base], fields[base + 1]); + output.write(':'); + output.write(' '); + output.write(bytes.array(), fields[base + 2], fields[base + 3]); + output.write('\r'); + output.write('\n'); + } + } + + void forEachStructured(ResponseSerializer.FieldConsumer consumer) { + for (int i = 0; i < count; i++) { + int base = i * 4; + consumer.accept(bytes.array(), fields[base], fields[base + 1], + bytes.array(), fields[base + 2], fields[base + 3]); + } + } + + @Override + public String first(String name) { + int index = indexOf(name, 0); + return index < 0 ? null : value(index); + } + + @Override + public List all(String name) { + List result = null; + int from = 0; + int index; + while ((index = indexOf(name, from)) >= 0) { + if (result == null) result = new ArrayList<>(); + result.add(value(index)); + from = index + 1; + } + return result == null ? List.of() : result; + } + + @Override + public List all() { + if (count == 0) return List.of(); + List result = new ArrayList<>(count); + for (int i = 0; i < count; i++) result.add(value(i)); + return result; + } + + @Override + public ByteView view(String name) { + int index = indexOf(name, 0); + if (index < 0) return null; + int base = index * 4; + view.reset(bytes.array(), fields[base + 2], fields[base + 3]); + return view; + } + + @Override + public boolean valueEqualsIgnoreCase(String name, String value) { + int index = indexOf(name, 0); + if (index < 0) return false; + int base = index * 4; + return ByteScan.equalsIgnoreCaseAscii( + bytes.array(), fields[base + 2], fields[base + 2] + fields[base + 3], value); + } + + @Override public boolean contains(String name) { return indexOf(name, 0) >= 0; } + @Override public int count() { return count; } + + @Override + public void forEach(HeaderConsumer consumer) { + for (int i = 0; i < count; i++) { + int base = i * 4; + scanName.reset(bytes.array(), fields[base], fields[base + 1]); + scanValue.reset(bytes.array(), fields[base + 2], fields[base + 3]); + consumer.accept(scanName, scanValue); + } + } + + private int indexOf(String name, int from) { + for (int i = from; i < count; i++) { + int base = i * 4; + if (ByteScan.equalsIgnoreCaseAscii( + bytes.array(), fields[base], fields[base] + fields[base + 1], name)) return i; + } + return -1; + } + + private String value(int index) { + int base = index * 4; + return new String(bytes.array(), fields[base + 2], fields[base + 3], StandardCharsets.UTF_8); + } + + private void ensure(int needed) { + int ints = needed * 4; + if (ints <= fields.length) return; + fields = Arrays.copyOf(fields, Math.max(ints, fields.length * 2)); + } +} diff --git a/flash/src/main/java/dev/relism/flash/models/PathParams.java b/flash/src/main/java/dev/relism/flash/models/PathParams.java index a97c156..7239ab2 100644 --- a/flash/src/main/java/dev/relism/flash/models/PathParams.java +++ b/flash/src/main/java/dev/relism/flash/models/PathParams.java @@ -1,5 +1,7 @@ package dev.relism.flash.models; +import dev.relism.flash.bytes.ArrayBackedByteView; +import dev.relism.flash.bytes.SlicePool; import dev.relism.flash.routing.AbstractRouter; import dev.relism.fpr.core.ByteView; @@ -7,19 +9,64 @@ import java.nio.charset.StandardCharsets; /** * Path parameters captured during routing, stored as byte offsets into the path view. - * {@link #get} allocates a String on call; {@link #view} is zero-copy. + * {@link #get} allocates a {@code String} on call (in one allocation when {@link #source} is + * + * 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 + * {@code PathParams} whose backing arrays are grown to the connection's high-water mark and + * never reallocated after warmup; because those arrays can be larger than the current request's + * actual param count, that path uses {@link #reset}, which — unlike the constructor — takes the + * live count explicitly rather than inferring it from array length. Both this constructor and + * {@link #reset} are {@code public} rather than package-private (matching + * {@link Http1HeaderMap#reset}'s own precedent for a reusable buffer-backed object): the router + * implementation that owns the reusable instance lives in a different package + * ({@code dev.relism.flash.routing.routers.fastpathrouter}), and {@code PathParams.inject}'s + * own doc explains why this codebase prefers a small public surface here over a cross-package + * friend-access workaround. A {@code PathParams} obtained this way has the same "do not retain + * past the handler" lifetime contract as {@link Http1HeaderMap}'s buffer-backed views: the next + * request on the same connection repositions the same arrays. */ public class PathParams { - private final ByteView source; + private static final int VIEW_POOL_SIZE = 4; + + private ByteView source; private final String[] names; private final int[] starts; private final int[] lens; + private int count; + + private SlicePool viewPool; public PathParams(ByteView source, String[] names, int[] starts, int[] lens) { this.source = source; this.names = names; this.starts = starts; this.lens = lens; + this.count = names.length; + } + + /** + * Builds an instance meant only for {@link #reset}: no source yet, and {@code count} starts + * at 0 until the first {@link #reset} call. {@code names}/{@code starts}/{@code lens} may be + * larger than any single request's param count — see the class Javadoc. + */ + public PathParams(String[] names, int[] starts, int[] lens) { + this.source = null; + this.names = names; + this.starts = starts; + this.lens = lens; + this.count = 0; + } + + /** + * Repositions this instance over a new request: {@code count} (which may be less than + * {@code names.length} — see the class Javadoc) params are now valid, read out of the same + * backing arrays the constructor was given, against the new {@code source}. Zero allocation. + */ + public void reset(ByteView source, int count) { + this.source = source; + this.count = count; } /** @@ -34,23 +81,39 @@ public class PathParams { public String get(String name) { int i = indexOf(name); if (i < 0) return null; - byte[] bytes = new byte[lens[i]]; - for (int j = 0; j < lens[i]; j++) bytes[j] = source.byteAt(starts[i] + j); + int start = starts[i], len = lens[i]; + // (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) { + return new String(abv.array(), abv.offset() + start, len, StandardCharsets.UTF_8); + } + byte[] bytes = new byte[len]; + for (int j = 0; j < len; j++) bytes[j] = source.byteAt(start + j); return new String(bytes, StandardCharsets.UTF_8); } + /** + * 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. + */ ByteView view(String name) { int i = indexOf(name); if (i < 0) return null; - final int s = starts[i], l = lens[i]; + int s = starts[i], l = lens[i]; + if (source instanceof ArrayBackedByteView abv) { + if (viewPool == null) viewPool = new SlicePool(VIEW_POOL_SIZE); + return viewPool.acquire(abv.array(), abv.offset() + s, l); + } + final int fs = s, fl = l; return new ByteView() { - public int length() { return l; } - public byte byteAt(int idx) { return source.byteAt(s + idx); } + public int length() { return fl; } + public byte byteAt(int idx) { return source.byteAt(fs + idx); } }; } private int indexOf(String name) { - for (int i = 0; i < names.length; i++) if (names[i].equals(name)) return i; + for (int i = 0; i < count; i++) if (names[i].equals(name)) return i; return -1; } } diff --git a/flash/src/main/java/dev/relism/flash/models/PreEncodedHeader.java b/flash/src/main/java/dev/relism/flash/models/PreEncodedHeader.java new file mode 100644 index 0000000..a9f0204 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/models/PreEncodedHeader.java @@ -0,0 +1,51 @@ +package dev.relism.flash.models; + +import java.nio.charset.StandardCharsets; +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)}. + * + *

Unlike {@link Response#header(byte[])}, which accepts an opaque HTTP/1 field line, this class + * preserves the name/value boundary. HTTP/1 can render it as a line and HTTP/2 can encode it with + * HPACK, so one constant works on both protocols. + */ +public final class PreEncodedHeader { + private final byte[] nameBytes; + private final byte[] valueBytes; + + public PreEncodedHeader(String name, String value) { + this.nameBytes = name.getBytes(StandardCharsets.US_ASCII); + this.valueBytes = value.getBytes(StandardCharsets.US_ASCII); + } + + /** Header-name ASCII bytes. The returned array is immutable by contract. */ + byte[] nameBytes() { + return nameBytes; + } + + /** Header-value ASCII bytes. The returned array is immutable by contract. */ + byte[] valueBytes() { + return valueBytes; + } + + @Override + public String toString() { + return new String(nameBytes, StandardCharsets.US_ASCII) + + ": " + + new String(valueBytes, StandardCharsets.US_ASCII); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PreEncodedHeader other)) return false; + return Arrays.equals(nameBytes, other.nameBytes) && Arrays.equals(valueBytes, other.valueBytes); + } + + @Override + public int hashCode() { + return 31 * Arrays.hashCode(nameBytes) + Arrays.hashCode(valueBytes); + } +} diff --git a/flash/src/main/java/dev/relism/flash/models/ProducerInputStream.java b/flash/src/main/java/dev/relism/flash/models/ProducerInputStream.java new file mode 100644 index 0000000..7f0e89d --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/models/ProducerInputStream.java @@ -0,0 +1,103 @@ +package dev.relism.flash.models; + +import java.io.IOException; +import java.io.InputStream; +import java.io.PipedInputStream; +import java.io.PipedOutputStream; +import java.util.function.Consumer; + +/** Bounded bridge from a push producer to the protocol writers' common pull path. */ +final class ProducerInputStream extends InputStream { + private final PipedInputStream input; + private final ProducerOutput output; + private final Consumer producer; + private volatile Throwable failure; + private boolean started; + + ProducerInputStream(Consumer producer, Response response) { + try { + input = new PipedInputStream(16 * 1024); + output = new ProducerOutput(new PipedOutputStream(input), response); + } catch (IOException impossible) { + throw new IllegalStateException(impossible); + } + this.producer = producer; + } + + @Override + public int read() throws IOException { + start(); + int value = input.read(); + checkFailure(value < 0); + return value; + } + + @Override + public int read(byte[] bytes, int offset, int length) throws IOException { + start(); + int count = input.read(bytes, offset, length); + checkFailure(count < 0); + return count; + } + + @Override + public void close() throws IOException { + input.close(); + } + + private synchronized void start() { + if (started) return; + started = true; + Thread.startVirtualThread(() -> { + try (output) { + producer.accept(output); + } catch (Throwable thrown) { + failure = thrown; + try { + output.close(); + } catch (IOException ignored) { + } + } + }); + } + + private void checkFailure(boolean eof) throws IOException { + if (eof && failure != null) throw new IOException("response stream producer failed", failure); + } + + private static final class ProducerOutput implements ResponseStream { + private final PipedOutputStream output; + private final Response response; + private boolean closed; + + ProducerOutput(PipedOutputStream output, Response response) { + this.output = output; + this.response = response; + } + + @Override + public synchronized void write(byte[] data, int offset, int length) throws IOException { + if (closed) throw new IOException("response stream is closed"); + output.write(data, offset, length); + } + + @Override + public synchronized void flush() throws IOException { + if (closed) throw new IOException("response stream is closed"); + output.flush(); + } + + @Override + public synchronized void trailer(String name, String value) { + if (closed) throw new IllegalStateException("response stream is closed"); + response.trailer(name, value); + } + + @Override + public synchronized void close() throws IOException { + if (closed) return; + closed = true; + output.close(); + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/models/QueryParams.java b/flash/src/main/java/dev/relism/flash/models/QueryParams.java index d9c6a9f..27ecb29 100644 --- a/flash/src/main/java/dev/relism/flash/models/QueryParams.java +++ b/flash/src/main/java/dev/relism/flash/models/QueryParams.java @@ -1,5 +1,8 @@ package dev.relism.flash.models; +import dev.relism.flash.bytes.ArrayBackedByteView; +import dev.relism.flash.bytes.Pairs; +import dev.relism.flash.bytes.SlicePool; import dev.relism.fpr.core.ByteView; import java.nio.charset.StandardCharsets; @@ -15,9 +18,15 @@ import java.util.List; */ public class QueryParams { public static final QueryParams EMPTY = new QueryParams(null); + private static final int VIEW_POOL_SIZE = 4; private final ByteView raw; + // 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(). + private SlicePool viewPool; + public QueryParams(ByteView raw) { this.raw = raw; } @@ -25,16 +34,29 @@ public class QueryParams { public String get(String name) { long r = findFirst(name); if (r < 0) return null; - return decode((int) (r >> 32), (int) (r & 0xFFFFFFFFL)); + return decode(Pairs.hi(r), Pairs.lo(r)); } + /** + * Returns a view over the first raw (not percent-decoded) value of {@code name}, or + * {@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 + * comes first. Falls back to a fresh (allocating) view when {@link #raw} is not array-backed + * — never exercised on the real request path (see {@link ArrayBackedByteView}'s Javadoc). + */ ByteView view(String name) { long r = findFirst(name); if (r < 0) return null; - final int s = (int) (r >> 32), l = (int) (r & 0xFFFFFFFFL); + int s = Pairs.hi(r), l = Pairs.lo(r); + if (raw instanceof ArrayBackedByteView abv) { + if (viewPool == null) viewPool = new SlicePool(VIEW_POOL_SIZE); + return viewPool.acquire(abv.array(), abv.offset() + s, l); + } + final int fs = s, fl = l; return new ByteView() { - public int length() { return l; } - public byte byteAt(int idx) { return raw.byteAt(s + idx); } + public int length() { return fl; } + public byte byteAt(int idx) { return raw.byteAt(fs + idx); } }; } @@ -62,7 +84,7 @@ public class QueryParams { // ── Internals ───────────────────────────────────────────────────────────── - /** Returns (valStart << 32) | valLen, or -1 if not found. */ + /** Returns {@code (valStart << 32) | valLen}, or {@code -1} if not found. */ private long findFirst(String name) { if (raw == null) return -1L; int i = 0, len = raw.length(); @@ -74,7 +96,7 @@ public class QueryParams { i++; int valStart = i; while (i < len && raw.byteAt(i) != '&') i++; - if (keyMatches(keyStart, keyLen, name)) return ((long) valStart << 32) | (i - valStart); + if (keyMatches(keyStart, keyLen, name)) return Pairs.pack(valStart, i - valStart); } if (i < len && raw.byteAt(i) == '&') i++; } @@ -91,8 +113,26 @@ public class QueryParams { * Percent-decodes a value slice from {@code raw} into a UTF-8 String. * {@code %XX} triplets are decoded to their byte values; {@code +} decodes as space. * Invalid {@code %} sequences are passed through as-is. + * + * {@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. */ private String decode(int start, int length) { + boolean clean = true; + for (int i = 0; i < length; i++) { + byte b = raw.byteAt(start + i); + if (b == '%' || b == '+') { clean = false; break; } + } + if (clean) { + if (raw instanceof ArrayBackedByteView abv) { + return new String(abv.array(), abv.offset() + start, length, StandardCharsets.UTF_8); + } + byte[] out = new byte[length]; + for (int i = 0; i < length; i++) out[i] = raw.byteAt(start + i); + return new String(out, StandardCharsets.UTF_8); + } + byte[] out = new byte[length]; // upper bound — decoded is never longer int w = 0; for (int i = 0; i < length; i++) { diff --git a/flash/src/main/java/dev/relism/flash/models/Request.java b/flash/src/main/java/dev/relism/flash/models/Request.java index 2d80b72..b7b0f03 100644 --- a/flash/src/main/java/dev/relism/flash/models/Request.java +++ b/flash/src/main/java/dev/relism/flash/models/Request.java @@ -1,26 +1,23 @@ package dev.relism.flash.models; +import dev.relism.flash.Flash; import dev.relism.flash.RequestParser; +import dev.relism.flash.bytes.ArrayBackedByteView; import dev.relism.fpr.core.ByteView; import dev.relism.flash.http.HttpMethod; -import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.ToString; -import lombok.Value; -import lombok.experimental.NonFinal; import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSocket; -import java.io.InputStream; import java.net.InetSocketAddress; import java.nio.charset.StandardCharsets; import java.util.List; /** - * Immutable view of an incoming HTTP/1.1 request. Constructed by {@link RequestParser} - * and passed directly to route handlers; never modified after creation (path/query params are - * injected once by the router before the handler runs). + * View of an incoming HTTP/1.1 request. Constructed once per connection by {@link RequestParser} + * and repositioned (never reallocated) for every request on that connection — never modified by + * user code after creation (path/query params are injected once by the router before the + * handler runs). * *

{@code
  * server.get("/users/{id}", (req, res) -> {
@@ -31,62 +28,102 @@ import java.util.List;
  *     InputStream in = req.body().stream();         // zero-copy stream
  * });
  * }
+ * + * A {@code Request} instance is owned by its connection (HTTP/1.1) or its stream (HTTP/2) and is + * recycled after the handler returns. Do not retain it — the same instance is repositioned + * over the next request's data as soon as this one's handler returns. {@code equals}/ + * {@code hashCode} are the inherited identity-based {@link Object} versions and are meaningless + * across requests (compare two different {@code Request}s from the same connection and they may + * be {@code ==} to each other despite describing entirely different requests, at different + * points in time). {@code String} values returned by {@link #path()}, {@link #header(String)}, + * {@link #param(String)}, {@link #query(String)} are independent heap copies and are always safe + * to retain past the handler. + * + *

Dev-mode use-after-recycle guard

+ * When {@link Flash#DEV} is {@code true}, every accessor checks that this instance is still the + * one currently being handled; a call after the handler has already returned (e.g. from a + * captured reference in an async callback, a {@link java.util.concurrent.CompletableFuture} + * continuation, or a background thread) throws {@link IllegalStateException} immediately, + * 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 + * the measured cost. */ -@Value -@ToString public class Request { - @Getter(lombok.AccessLevel.NONE) - @EqualsAndHashCode.Exclude - @ToString.Exclude - RequestBody body; + private RequestBody body; + private HeaderView trailers = EmptyHeaderView.INSTANCE; /** Internal: the parsed request line (method, path, query, protocol, headers). */ - RequestLine requestLine; + private RequestLine requestLine; - @NonFinal PathParams pathParams; - @NonFinal QueryParams queryParams; - @NonFinal String cachedPath; + private PathParams pathParams; + private QueryParams queryParams; + private String cachedPath; + + private InetSocketAddress remoteAddress; + private SSLSocket sslSocket; + + // unsafe to use further. Only consulted when poisoningEnabled is true (see checkActive()). + private boolean active; + + // Defaults to the real Flash.DEV value. Flash.DEV is a static final boolean fixed once at + // JVM startup (from a system property), so no individual test can toggle it — this field + // exists solely so RequestRecycleGuardTest can exercise the dev-mode branch without a + // fragile reflective override of a `static final` field. Package-private: only this + // package's own tests reach for it; production code never touches it. + private static volatile boolean poisoningEnabled = Flash.DEV; + + /** Test-only override of the dev-mode poisoning check — see the field's own comment. */ + static void setPoisoningEnabledForTesting(boolean enabled) { + poisoningEnabled = enabled; + } + + /** Pooled instance, populated later via {@link #reset}. One per connection — see {@link RequestParser}. */ + public Request() { + } + + /** Test / manual constructor — {@code remoteAddress()} returns {@code null}, {@code isSecure()} is {@code false}. */ + public Request(RequestLine requestLine, byte[] body) { + reset(requestLine, RequestBody.of(body), null, null); + } /** - * Remote socket address of the connected client. Set once at connection time from - * {@link java.net.Socket#getRemoteSocketAddress()} : the {@link InetSocketAddress} - * object already exists in the JDK and is passed by reference: zero allocation, - * zero copy. {@code null} only in test-constructed requests. - * - *

Use {@link #remoteAddress()} to access it. String conversion - * ({@code .getAddress().getHostAddress()}) is deferred to the caller, lazy and - * only paid when actually needed. + * Repositions this instance over a new request. Package-private: only {@link RequestParser} + * (same package) calls this — user code never constructs or resets a {@code Request} + * directly outside the test constructor above. */ - @Getter(lombok.AccessLevel.NONE) - @EqualsAndHashCode.Exclude - @ToString.Exclude - InetSocketAddress remoteAddress; - - /** - * The accepted socket for this connection, or {@code null} if plain HTTP — set once per - * connection by {@link RequestParser}, same lifetime and reference-only cost as - * {@link #remoteAddress}. Every request on the same keep-alive connection shares the - * identical instance. - * - *

Never exposed directly: {@link #isSecure()} and {@link #sslSession()} are the public - * surface. {@link javax.net.ssl.SSLSocket#getSession()} is deferred to {@link #sslSession()} - * rather than called here — by the time a handler can call it, the handshake this connection - * needed to reach the handler has already completed, so it is a cached-field read, never a - * forced handshake. - */ - @Getter(lombok.AccessLevel.NONE) - @EqualsAndHashCode.Exclude - @ToString.Exclude - SSLSocket sslSocket; - - private Request(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) { + void reset(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) { this.requestLine = requestLine; this.body = body; + this.trailers = EmptyHeaderView.INSTANCE; this.pathParams = null; this.queryParams = null; + this.cachedPath = null; this.remoteAddress = remoteAddress; this.sslSocket = sslSocket; + this.active = true; + } + + /** + * Marks this instance unsafe for further use. Called by the connection driver (e.g. + * {@code Http1Connection}) once the handler (and any automatic post-handler work, e.g. + * {@link #drain()}) has finished with it, before the connection loop reuses it for the next + * request — {@code public} because the connection driver lives in a different package + * (matching {@link RequestLine#reset}'s own precedent), not because user code should ever + * call it. A no-op in production beyond the field write — see the class Javadoc's dev-mode + * guard section. + */ + public void recycle() { + this.active = false; + } + + private void checkActive() { + if (poisoningEnabled && !active) { + throw new IllegalStateException( + "Request used after the handler returned — do not retain a Request past the " + + "handler; copy any String values you need instead"); + } } /** @@ -96,33 +133,41 @@ public class Request { */ void setPathParams(PathParams p) { this.pathParams = p; } - /** Test / manual constructor — {@code remoteAddress()} returns {@code null}, {@code isSecure()} is {@code false}. */ - public Request(RequestLine requestLine, byte[] body) { - this(requestLine, RequestBody.of(body), null, null); + /** + * 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 + * method's only job is wiring it, {@code requestLine}, and the connection identity fields + * into {@code pooled}. + */ + public static Request forParsed(Request pooled, RequestLine requestLine, RequestBody body, + InetSocketAddress remoteAddress, SSLSocket sslSocket) { + pooled.reset(requestLine, body, remoteAddress, sslSocket); + return pooled; } - public static Request forParsed(RequestLine requestLine, InputStream stream, - long contentLength, byte[] headerBuf, - int bodyStart, int preBufLen, - InetSocketAddress remoteAddress, SSLSocket sslSocket) { - RequestBody rb = contentLength > 0 ? new RequestBody(stream, contentLength, headerBuf, bodyStart, preBufLen) - : contentLength == 0 ? RequestBody.empty() - : /* chunked */ new RequestBody(stream, -1L, null, 0, 0); - return new Request(requestLine, rb, remoteAddress, sslSocket); + /** Internal protocol hook that supplies the request's trailer collection. */ + public void setTrailers(HeaderView trailers) { + this.trailers = trailers == null ? EmptyHeaderView.INSTANCE : trailers; } // ── Request line ────────────────────────────────────────────────────────── /** HTTP method ({@code GET}, {@code POST}, …). */ - public HttpMethod method() { return requestLine.getMethod(); } + public HttpMethod method() { checkActive(); return requestLine.getMethod(); } /** * Request path decoded as UTF-8. Includes a leading slash; never includes the query string. * Example: a request for {@code /users/42?page=1} returns {@code "/users/42"}. */ public String path() { + checkActive(); if (cachedPath != null) return cachedPath; ByteView v = requestLine.getPath(); + // 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) { + return cachedPath = new String(abv.array(), abv.offset(), v.length(), StandardCharsets.UTF_8); + } byte[] buf = new byte[v.length()]; for (int i = 0; i < v.length(); i++) buf[i] = v.byteAt(i); return cachedPath = new String(buf, StandardCharsets.UTF_8); @@ -134,20 +179,20 @@ public class Request { * Returns the first value of header {@code name}, or {@code null} if absent. * Lookup is case-insensitive ({@code "content-type"} and {@code "Content-Type"} are equivalent). */ - public String header(String name) { return requestLine.getHeaders().first(name); } + public String header(String name) { checkActive(); return requestLine.getHeaders().first(name); } /** * Returns all values of header {@code name} in declaration order. * Useful for headers that appear multiple times (e.g. {@code Accept}, {@code Cookie}). * Lookup is case-insensitive. Returns an empty list if the header is absent. */ - public List headers(String name) { return requestLine.getHeaders().all(name); } + public List headers(String name) { checkActive(); return requestLine.getHeaders().all(name); } /** * Returns all header values in declaration order, one entry per header line. * Useful for debugging; for targeted access prefer {@link #header(String)}. */ - public List headers() { return requestLine.getHeaders().all(); } + public List headers() { checkActive(); return requestLine.getHeaders().all(); } // ── Path parameters ─────────────────────────────────────────────────────── @@ -157,7 +202,7 @@ public class Request { * injected by the router before the handler runs. Returns {@code null} if this * route has no such parameter or the route is not parametric. */ - public String param(String name) { return pathParams != null ? pathParams.get(name) : null; } + public String param(String name) { checkActive(); return pathParams != null ? pathParams.get(name) : null; } // ── Query parameters ────────────────────────────────────────────────────── @@ -166,14 +211,14 @@ public class Request { * The query string is parsed lazily on the first call and cached for the request lifetime. * For {@code ?a=1&a=2}, returns {@code "1"}. */ - public String query(String name) { return resolveQueryParams().get(name); } + public String query(String name) { checkActive(); return resolveQueryParams().get(name); } /** * Returns all query parameters named {@code name} in declaration order. * For {@code ?tag=a&tag=b}, returns {@code ["a", "b"]}. * Returns an empty list if the parameter is absent. */ - public List queries(String name) { return resolveQueryParams().getAll(name); } + public List queries(String name) { checkActive(); return resolveQueryParams().getAll(name); } // ── Remote address ──────────────────────────────────────────────────────── @@ -189,12 +234,12 @@ public class Request { * if (addr != null) String ip = addr.getAddress().getHostAddress(); * } */ - public InetSocketAddress remoteAddress() { return remoteAddress; } + public InetSocketAddress remoteAddress() { checkActive(); return remoteAddress; } // ── TLS ─────────────────────────────────────────────────────────────────── /** Whether this request arrived over TLS (HTTPS). */ - public boolean isSecure() { return sslSocket != null; } + public boolean isSecure() { checkActive(); return sslSocket != null; } /** * Returns the TLS session for this connection, or {@code null} for plain HTTP. @@ -204,7 +249,7 @@ public class Request { * diagnostics. {@code null} rather than throwing when {@link #isSecure()} is {@code false} — * check that first, or just null-check the result. */ - public SSLSession sslSession() { return sslSocket != null ? sslSocket.getSession() : null; } + public SSLSession sslSession() { checkActive(); return sslSocket != null ? sslSocket.getSession() : null; } // ── Body ────────────────────────────────────────────────────────────────── @@ -213,15 +258,35 @@ public class Request { * the full body or {@link RequestBody#stream()} for zero-copy streaming access. * The two modes are mutually exclusive per request. */ - public RequestBody body() { return body; } + public RequestBody body() { checkActive(); return body; } + + /** + * Returns request trailers after the body has been consumed completely. + * + * @throws IllegalStateException when called before the body reaches EOF + */ + public HeaderView trailers() { + checkActive(); + if (!body.fullyRead()) { + throw new IllegalStateException("request trailers are available only after the body is fully read"); + } + return trailers; + } /** Discards unread body bytes; called by the server after each request on keep-alive connections. */ public void drain() { body.drain(); } // ── Internal ───────────────────────────────────────────────────────────── + /** Internal: the parsed request line (method, path, query, protocol, headers). */ + public RequestLine getRequestLine() { checkActive(); return requestLine; } + + /** Internal: path parameters injected by the router, or {@code null} if none matched. */ + public PathParams getPathParams() { checkActive(); return pathParams; } + /** Internal: case-insensitive header value comparison used by the server keep-alive logic. */ public boolean headerEquals(String name, String value) { + checkActive(); return requestLine.getHeaders().valueEqualsIgnoreCase(name, value); } @@ -232,4 +297,10 @@ public class Request { } return queryParams; } + + @Override + public String toString() { + return "Request(method=" + (requestLine != null ? requestLine.getMethod() : null) + + ", path=" + (requestLine != null ? requestLine.getPath() : null) + ")"; + } } diff --git a/flash/src/main/java/dev/relism/flash/models/RequestBody.java b/flash/src/main/java/dev/relism/flash/models/RequestBody.java index 371a41b..1104273 100644 --- a/flash/src/main/java/dev/relism/flash/models/RequestBody.java +++ b/flash/src/main/java/dev/relism/flash/models/RequestBody.java @@ -10,9 +10,8 @@ import java.io.*; * Safe to call multiple times; the second call returns the cached array. Throws for * bodies larger than 2 GB. *

  • {@link #stream()} — returns a bounded {@link InputStream} without upfront allocation. - * For fixed-length bodies this is a view 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.
  • + * into the already-buffered header bytes stitched to the socket; for chunked bodies it is + * the raw {@link dev.relism.flash.ChunkedInputStream} that de-chunks on the fly. * * *

    Mutual exclusivity: calling both {@code bytes()} and {@code stream()} on the same @@ -20,37 +19,64 @@ import java.io.*; * *

    Keep-alive: unread body bytes are discarded by {@link Request#drain()} after the * handler returns so the socket is correctly positioned for the next pipelined request. + * + * 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. + * + * {@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. + * {@link #drain()}'s chunked-body path used to call {@code InputStream.transferTo}, which + * allocates a fresh 8 KiB {@code byte[]} internally on every call (the JDK default + * implementation); it now drains through a lazily-created, persistent buffer instead. */ -public final class RequestBody { - private static final byte[] EMPTY_BYTES = new byte[0]; - - private final InputStream socket; - private final long contentLength; - private final byte[] preBuf; - private final int preBufOff; - private final int preBufLen; +public class RequestBody { + private InputStream socket; + private long contentLength; + private byte[] preBuf; + private int preBufOff; + private int preBufLen; private byte[] resolved; private long socketConsumed; + private BoundedBufferedInputStream boundedStream; + + private byte[] drainBuffer; + + /** Pooled instance, populated later via {@link #reset}. One per connection — see {@code RequestParser}. */ + public RequestBody() { + } + RequestBody(InputStream socket, long contentLength, byte[] preBuf, int preBufOff, int preBufLen) { + reset(socket, contentLength, preBuf, preBufOff, preBufLen); + } + + /** Pre-resolved body: test payloads and empty body — skips all I/O. Always a freestanding, unpooled instance. */ + private RequestBody(byte[] preResolved) { + reset(null, preResolved.length, null, 0, 0); + this.resolved = preResolved; + } + + /** + * Repositions this instance over a new request. {@code public} because {@code RequestParser} + * (a different package) owns and resets its own pooled instance directly — matching + * {@link RequestLine#reset}'s precedent — not because user code should ever call it. + */ + public void reset(InputStream socket, long contentLength, byte[] preBuf, int preBufOff, int preBufLen) { this.socket = socket; this.contentLength = contentLength; this.preBuf = preBuf; this.preBufOff = preBufOff; this.preBufLen = preBufLen; + this.resolved = null; + this.socketConsumed = 0; } - /** Pre-resolved body: test payloads and empty body — skips all I/O. */ - private RequestBody(byte[] preResolved) { - this(null, preResolved.length, null, 0, 0); - this.resolved = preResolved; - } - - private static final RequestBody EMPTY = new RequestBody(EMPTY_BYTES); - static RequestBody of(byte[] bytes) { return new RequestBody(bytes); } - static RequestBody empty() { return EMPTY; } + static RequestBody empty() { return new RequestBody(new byte[0]); } /** {@code true} if the body has zero bytes ({@code Content-Length: 0} or no body). */ public boolean isEmpty() { return contentLength == 0; } @@ -61,6 +87,15 @@ public final class RequestBody { */ public long contentLength() { return contentLength; } + /** Whether the complete body has been consumed by the application. */ + public boolean fullyRead() { + if (resolved != null || contentLength == 0) return true; + if (socket instanceof BodyCompletion completion) return completion.fullyRead(); + if (contentLength < 0) return false; + if (boundedStream != null) return boundedStream.complete(); + return preBufLen >= contentLength; + } + /** * Materialises and caches the full body. Suitable for JSON, small form data, and any payload * that must be inspected in full. The result is cached — repeated calls return the same array. @@ -96,54 +131,92 @@ public final class RequestBody { /** * Returns a bounded {@link InputStream} over the body without upfront allocation. * - *

    For fixed-length bodies: a {@link SequenceInputStream} of any already-buffered header - * bytes followed by a bounded view of the socket stream — zero heap beyond those small - * pre-buffered bytes. + *

    For fixed-length bodies: a reused {@link BoundedBufferedInputStream} (see the class + * view of the socket stream — zero allocation on a warm connection. * - *

    For chunked bodies: the raw {@link dev.relism.ChunkedInputStream} that de-chunks on + *

    For chunked bodies: the raw {@link dev.relism.flash.ChunkedInputStream} that de-chunks on * the fly; EOF signals the end of the logical body and leaves the socket positioned for * the next keep-alive request. * *

    If {@link #bytes()} was called first, returns a fresh {@link java.io.ByteArrayInputStream} - * over the cached array. */ public InputStream stream() { if (resolved != null) return new ByteArrayInputStream(resolved); if (contentLength < 0) return socket; // ChunkedInputStream — EOF signals end of body + if (boundedStream == null) boundedStream = new BoundedBufferedInputStream(); int fromBuf = (int) Math.min(preBufLen, contentLength); long fromSocket = contentLength - fromBuf; - InputStream bufPart = new ByteArrayInputStream(preBuf, preBufOff, fromBuf); - return fromSocket == 0 ? bufPart : new SequenceInputStream(bufPart, bounded(socket, fromSocket)); + boundedStream.reset(preBuf, preBufOff, fromBuf, fromSocket); + return boundedStream; } /** Discards unread body bytes to reposition the socket for the next keep-alive request. */ void drain() { if (isEmpty() || resolved != null) return; if (contentLength < 0) { - try { socket.transferTo(OutputStream.nullOutputStream()); } catch (IOException ignored) {} + // 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]; + try { + while (socket.read(drainBuffer) > 0) { /* discard */ } + } catch (IOException ignored) { + } return; } long remaining = (contentLength - preBufLen) - socketConsumed; if (remaining > 0) try { socket.skipNBytes(remaining); } catch (IOException ignored) {} } - private InputStream bounded(InputStream src, long limit) { - return new InputStream() { - private long left = limit; + /** + * 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 + * lives on the owning {@link RequestBody} for the whole connection; {@link #reset} repositions + * it for each new request. + */ + private final class BoundedBufferedInputStream extends InputStream { + private byte[] preBuf; + private int preBufPos; + private int preBufRemaining; + private long socketRemaining; - @Override public int read() throws IOException { - if (left == 0) return -1; - int b = src.read(); - if (b >= 0) { left--; socketConsumed++; } - return b; + void reset(byte[] preBuf, int preBufOff, int preBufLen, long socketRemaining) { + this.preBuf = preBuf; + this.preBufPos = preBufOff; + this.preBufRemaining = preBufLen; + this.socketRemaining = socketRemaining; + } + + boolean complete() { + return preBufRemaining == 0 && socketRemaining == 0; + } + + @Override + public int read() throws IOException { + if (preBufRemaining > 0) { + preBufRemaining--; + return preBuf[preBufPos++] & 0xFF; } + if (socketRemaining == 0) return -1; + int b = socket.read(); + if (b >= 0) { socketRemaining--; socketConsumed++; } + return b; + } - @Override public int read(byte[] buf, int off, int len) throws IOException { - if (left == 0) return -1; - int n = src.read(buf, off, (int) Math.min(len, left)); - if (n > 0) { left -= n; socketConsumed += n; } + @Override + public int read(byte[] dst, int off, int len) throws IOException { + if (len == 0) return 0; + if (preBufRemaining > 0) { + int n = Math.min(len, preBufRemaining); + System.arraycopy(preBuf, preBufPos, dst, off, n); + preBufPos += n; + preBufRemaining -= n; return n; } - }; + if (socketRemaining == 0) return -1; + int n = socket.read(dst, off, (int) Math.min(len, socketRemaining)); + if (n > 0) { socketRemaining -= n; socketConsumed += n; } + return n; + } } } diff --git a/flash/src/main/java/dev/relism/flash/models/RequestLine.java b/flash/src/main/java/dev/relism/flash/models/RequestLine.java index 92e367e..4a21c18 100644 --- a/flash/src/main/java/dev/relism/flash/models/RequestLine.java +++ b/flash/src/main/java/dev/relism/flash/models/RequestLine.java @@ -2,16 +2,64 @@ package dev.relism.flash.models; import dev.relism.fpr.core.ByteView; import dev.relism.flash.http.HttpMethod; -import lombok.ToString; -import lombok.Value; -@ToString -@Value +/** + * The parsed request line plus headers: method, path, optional query, optional protocol token, + * and the header container. Internal — reached via {@link Request#getRequestLine()}, not + * user-facing API. + * + * 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 + * {@link Http1HeaderMap#reset}'s and {@link PathParams#reset}'s own precedent — because + * {@code RequestParser} (the owner and sole caller) lives in a different package + * ({@code dev.relism.flash}, not {@code dev.relism.flash.models}). The public constructor below + * remains for test/manual construction and simply delegates to {@link #reset}. + * + *

    {@code protocol} is optional

    + * HTTP/1.1 always has a protocol token on the wire ({@code "HTTP/1.1"}); HTTP/2 has no equivalent + * — a stream's version is implicit in which connection it belongs to. {@link #getProtocol()} may + * be {@code null} for a header container built by a future non-h1 implementation; h1 always + * supplies a non-null value today. + */ public class RequestLine { - HttpMethod method; - ByteView path; - /** Raw query string bytes (after {@code ?}), {@code null} if the URI has no query string. */ - ByteView query; - ByteView protocol; - HeaderMap headers; + private HttpMethod method; + private ByteView path; + private ByteView query; + private ByteView protocol; + private HeaderView headers; + + /** Pooled instance, populated later via {@link #reset}. */ + public RequestLine() { + } + + /** Test / manual construction — delegates to {@link #reset}. */ + public RequestLine(HttpMethod method, ByteView path, ByteView query, ByteView protocol, HeaderView headers) { + reset(method, path, query, protocol, headers); + } + + /** Repositions this instance over a new request. See the class Javadoc for why this is {@code public}. */ + public void reset(HttpMethod method, ByteView path, ByteView query, ByteView protocol, HeaderView headers) { + this.method = method; + this.path = path; + this.query = query; + this.protocol = protocol; + this.headers = headers; + } + + public HttpMethod getMethod() { return method; } + public ByteView getPath() { return path; } + + /** Raw query string bytes (after {@code ?}), or {@code null} if the URI has no query string. */ + public ByteView getQuery() { return query; } + + /** The wire protocol token (e.g. {@code "HTTP/1.1"}), or {@code null} — see the class Javadoc. */ + public ByteView getProtocol() { return protocol; } + + public HeaderView getHeaders() { return headers; } + + @Override + public String toString() { + return "RequestLine(method=" + method + ", path=" + path + ")"; + } } diff --git a/flash/src/main/java/dev/relism/flash/models/Response.java b/flash/src/main/java/dev/relism/flash/models/Response.java index 4a60d9c..246c1bd 100644 --- a/flash/src/main/java/dev/relism/flash/models/Response.java +++ b/flash/src/main/java/dev/relism/flash/models/Response.java @@ -1,17 +1,19 @@ package dev.relism.flash.models; +import dev.relism.flash.Flash; +import dev.relism.flash.bytes.ByteWriter; import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.Http1Limits; import dev.relism.flash.http.HttpStatus; -import lombok.Getter; -import lombok.Setter; -import lombok.ToString; - import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; /** * HTTP response. All mutating methods return {@code this} for fluent chaining. @@ -26,159 +28,595 @@ import java.util.List; * // unknown-length stream → Transfer-Encoding: chunked * return new Response(200, ContentType.TEXT_PLAIN).chunked(source); * } + * + * 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 applies + * identically here). A handler that returns a different {@code Response} instance (e.g. + * {@code return new Response(404, "Not Found", ContentType.TEXT_PLAIN);}) is fully supported — that + * instance is a normal, unpooled, freshly-constructed object like any public-constructor {@code + * Response} always was; only the connection driver's own default instance is pooled and poisoned + * after use. */ -@Getter -@ToString public class Response { - @Setter private int statusCode; - private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int) - private byte[] body; - @ToString.Exclude - private InputStream stream; - private long streamLength; // meaningful only when isStreaming() && !chunked - private boolean chunked; - private byte[] contentType; - @Getter(lombok.AccessLevel.NONE) - private List headers; // pre-encoded "Name: Value\r\n" entries + private int statusCode; + private byte[] statusBytes; // pre-encoded "200 OK"; null when set via status(int) + private byte[] body; + private InputStream stream; + private long streamLength; // meaningful only when isStreaming() && !chunked + private boolean chunked; + private boolean pushStreaming; + private byte[] contentType; + private final MutableHeaderMap trailers = new MutableHeaderMap(); - // ------------------------------------------------------------------------- - // Constructors - // ------------------------------------------------------------------------- + // of a List 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 + // pre-rendered line (the legacy header(byte[]) overload) has no name/value structure to + // decompose into the same region: + // tag 0 -> a (name, value) pair; headerRefs[i] indexes headerQuads (groups of 4) + // tag 1 -> a raw pre-rendered line; headerRefs[i] indexes rawHeaderLines + private ByteWriter headerRegion; // tag-0 storage: name+value bytes back to back + private int[] headerQuads; // tag-0 storage: groups of (nameOff,nameLen,valOff,valLen) + private int headerQuadCount; + private List rawHeaderLines; // tag-1 storage: legacy header(byte[]) entries, verbatim + private byte[] headerTags; // one entry per header(), in call order: 0 or 1 + 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 - public Response(int statusCode, ContentType contentType) { - this.statusCode = statusCode; - this.contentType = contentType.getBytes(); + private boolean active = true; + private static volatile boolean poisoningEnabled = Flash.DEV; + + /** + * Test-only override of the dev-mode poisoning check — mirrors {@code Request}'s identical hook. + */ + static void setPoisoningEnabledForTesting(boolean enabled) { + poisoningEnabled = enabled; + } + + private void checkActive() { + if (poisoningEnabled && !active) { + throw new IllegalStateException( + "Response used after the handler returned — do not retain a Response past the handler"); } + } - public Response(int statusCode, byte[] body, ContentType contentType) { - this(statusCode, contentType); - this.body = body; + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + public Response(int statusCode, ContentType contentType) { + this.statusCode = statusCode; + this.contentType = contentType.getBytes(); + } + + public Response(int statusCode, byte[] body, ContentType contentType) { + this(statusCode, contentType); + this.body = body; + } + + public Response(int statusCode, String text, ContentType contentType) { + this(statusCode, text.getBytes(StandardCharsets.UTF_8), contentType); + } + + // ------------------------------------------------------------------------- + // Pooling + // ------------------------------------------------------------------------- + + /** + * Repositions this instance for a new request/response cycle — clears the body, stream, status, + * content type, and every header recorded by the previous cycle. Public because the connection + * driver that owns the pooled instance lives in a different package (matching {@link + * RequestLine#reset}'s precedent); user code never calls this. + */ + public Response reset(int statusCode, ContentType contentType) { + this.statusCode = statusCode; + this.statusBytes = null; + this.body = null; + this.stream = null; + this.streamLength = 0; + this.chunked = false; + this.pushStreaming = false; + this.contentType = contentType.getBytes(); + this.headerQuadCount = 0; + this.headerCount = 0; + this.trailers.reset(); + if (rawHeaderLines != null) rawHeaderLines.clear(); + this.active = true; + return this; + } + + /** + * Marks this instance unsafe for further use — see {@link Request#recycle()} for the full + * rationale, identical here. {@code public} for the same cross-package reason. + */ + public void recycle() { + this.active = false; + } + + // ------------------------------------------------------------------------- + // Fluent mutators + // ------------------------------------------------------------------------- + + /** Sets the status code. The phrase is looked up from {@link HttpStatus} on the write path. */ + public Response status(int code) { + checkActive(); + this.statusCode = code; + this.statusBytes = null; + return this; + } + + /** + * Lombok-style setter kept for API compatibility — equivalent to {@link #status(int)} without the + * fluent return. + */ + public void setStatusCode(int code) { + status(code); + } + + /** + * Sets the status from an {@link HttpStatus} constant. The pre-encoded bytes are used directly on + * the write path — zero lookup, zero allocation. + */ + public Response status(HttpStatus status) { + checkActive(); + this.statusCode = status.code(); + this.statusBytes = status.bytes(); + return this; + } + + public Response type(ContentType ct) { + checkActive(); + this.contentType = ct.getBytes(); + return this; + } + + public Response type(String ct) { + checkActive(); + this.contentType = ct.getBytes(StandardCharsets.UTF_8); + return this; + } + + public Response body(byte[] bytes) { + checkActive(); + this.body = bytes; + this.stream = null; + this.pushStreaming = false; + return this; + } + + public Response body(String text) { + return body(text.getBytes(StandardCharsets.UTF_8)); + } + + /** Streaming response with known length; written with {@code Content-Length}. */ + public Response stream(InputStream is, long length) { + checkActive(); + this.stream = is; + this.streamLength = length; + this.chunked = false; + this.body = null; + this.pushStreaming = false; + return this; + } + + /** Streaming response with unknown length; written with {@code Transfer-Encoding: chunked}. */ + public Response chunked(InputStream is) { + checkActive(); + this.stream = is; + this.chunked = true; + this.body = null; + this.pushStreaming = false; + return this; + } + + /** Push-style streaming response with bounded blocking backpressure. */ + public Response streaming(Consumer producer) { + checkActive(); + this.stream = new ProducerInputStream(Objects.requireNonNull(producer), this); + this.streamLength = -1; + this.chunked = true; + this.pushStreaming = true; + this.body = null; + return this; + } + + /** Adds a trailer rendered after the response body on both HTTP versions. */ + public Response trailer(String name, String value) { + checkActive(); + validateTrailer(name, value); + byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII); + byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8); + trailers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length); + return this; + } + + /** Adds a pre-encoded structured trailer. */ + public Response trailer(PreEncodedHeader trailer) { + checkActive(); + byte[] name = trailer.nameBytes(); + byte[] value = trailer.valueBytes(); + validateTrailer( + new String(name, StandardCharsets.US_ASCII), + new String(value, StandardCharsets.US_ASCII)); + trailers.add(name, 0, name.length, value, 0, value.length); + return this; + } + + private static void validateTrailer(String name, String value) { + if (name.isEmpty() || name.charAt(0) == ':' || containsLineBreak(name) + || containsLineBreak(value)) { + throw new IllegalArgumentException("invalid response trailer"); } - - public Response(int statusCode, String text, ContentType contentType) { - this(statusCode, text.getBytes(StandardCharsets.UTF_8), contentType); + if (name.equalsIgnoreCase("content-length") + || name.equalsIgnoreCase("transfer-encoding") + || name.equalsIgnoreCase("connection") + || name.equalsIgnoreCase("host") + || name.equalsIgnoreCase("te") + || name.equalsIgnoreCase("trailer")) { + throw new IllegalArgumentException("field is not permitted in response trailers: " + name); } + } - // ------------------------------------------------------------------------- - // Fluent mutators - // ------------------------------------------------------------------------- + private static boolean containsLineBreak(String value) { + return value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0; + } - /** Sets the status code. The phrase is looked up from {@link HttpStatus} on the write path. */ - public Response status(int code) { this.statusCode = code; this.statusBytes = null; return this; } + /** + * 302 Found redirect. Clears the body, sets status and {@code Location} header. Encoded once at + * call time; zero-alloc on the write path. + * + *
    {@code
    +   * return res.redirect("/login");
    +   * }
    + */ + public Response redirect(String url) { + return redirect(HttpStatus.FOUND, url); + } - /** Sets the status from an {@link HttpStatus} constant. The pre-encoded bytes are used - * directly on the write path — zero lookup, zero allocation. */ - public Response status(HttpStatus status) { this.statusCode = status.code(); this.statusBytes = status.bytes(); return this; } - public Response type(ContentType ct) { this.contentType = ct.getBytes(); return this; } - public Response type(String ct) { this.contentType = ct.getBytes(StandardCharsets.UTF_8); return this; } + /** + * Redirect with an explicit 3xx status. Use {@link HttpStatus#MOVED_PERMANENTLY}, {@link + * HttpStatus#TEMPORARY_REDIRECT} (307), or {@link HttpStatus#PERMANENT_REDIRECT} (308) when + * semantics matter. + * + *
    {@code
    +   * return res.redirect(HttpStatus.MOVED_PERMANENTLY, "/new-path");
    +   * }
    + */ + public Response redirect(HttpStatus status, String url) { + checkActive(); + this.statusCode = status.code(); + this.statusBytes = status.bytes(); + this.body = null; + this.stream = null; + return header("Location", url); + } - public Response body(byte[] bytes) { - this.body = bytes; - this.stream = null; - return this; + /** + * 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. + */ + public Response header(String name, String value) { + checkActive(); + checkHeaderBudget(); + if (headerRegion == null) { + headerRegion = new ByteWriter(128); + headerQuads = new int[16]; } + ensureQuadCapacity(headerQuadCount + 1); + int nameOff = headerRegion.length(); + headerRegion.writeAscii(name); + int nameLen = headerRegion.length() - nameOff; + int valOff = headerRegion.length(); + headerRegion.writeAscii(value); + int valLen = headerRegion.length() - valOff; + checkHeaderRegionBudget(); - public Response body(String text) { - return body(text.getBytes(StandardCharsets.UTF_8)); + int base = headerQuadCount * 4; + headerQuads[base] = nameOff; + headerQuads[base + 1] = nameLen; + headerQuads[base + 2] = valOff; + headerQuads[base + 3] = valLen; + recordHeaderEntry((byte) 0, headerQuadCount); + headerQuadCount++; + return this; + } + + /** + * 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. Preserving the field structure makes it usable by both HTTP versions. + */ + public Response header(PreEncodedHeader preEncoded) { + checkActive(); + checkHeaderBudget(); + if (headerRegion == null) { + headerRegion = new ByteWriter(128); + headerQuads = new int[16]; } + ensureQuadCapacity(headerQuadCount + 1); + byte[] nameBytes = preEncoded.nameBytes(); + byte[] valueBytes = preEncoded.valueBytes(); + int nameOff = headerRegion.length(); + headerRegion.writeBytes(nameBytes); + int valOff = headerRegion.length(); + headerRegion.writeBytes(valueBytes); + checkHeaderRegionBudget(); - /** Streaming response with known length; written with {@code Content-Length}. */ - public Response stream(InputStream is, long length) { - this.stream = is; - this.streamLength = length; - this.chunked = false; - this.body = null; - return this; + int base = headerQuadCount * 4; + headerQuads[base] = nameOff; + headerQuads[base + 1] = nameBytes.length; + headerQuads[base + 2] = valOff; + headerQuads[base + 3] = valueBytes.length; + recordHeaderEntry((byte) 0, headerQuadCount); + headerQuadCount++; + return this; + } + + /** + * Adds a pre-encoded, fully-rendered header line (e.g. a static {@code "X-RateLimit-Limit: + * 100\r\n"} byte array pre-built at boot time). Zero-alloc on both the call path and the h1 write + * path. + * + *

    h1-only: 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 HTTP/2 response path + * — prefer {@link #header(PreEncodedHeader)} for anything that must render correctly on both + * protocols. Kept for existing HTTP/1-only callers. + */ + public Response header(byte[] preEncoded) { + checkActive(); + checkHeaderBudget(); + if (rawHeaderLines == null) rawHeaderLines = new ArrayList<>(); + rawHeaderLines.add(preEncoded); + recordHeaderEntry((byte) 1, rawHeaderLines.size() - 1); + return this; + } + + /** 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 + + " headers — check for an unbounded loop calling header(...)"); } + } - /** Streaming response with unknown length; written with {@code Transfer-Encoding: chunked}. */ - public Response chunked(InputStream is) { - this.stream = is; - this.chunked = true; - this.body = null; - return this; + private void checkHeaderRegionBudget() { + if (headerRegion.length() > Http1Limits.MAX_RESPONSE_HEADER_BYTES) { + throw new IllegalStateException( + "response header region exceeds " + + Http1Limits.MAX_RESPONSE_HEADER_BYTES + + " bytes — check for an unbounded loop or an oversized value passed to header(...)"); } + } - /** - * 302 Found redirect. Clears the body, sets status and {@code Location} header. - * Encoded once at call time; zero-alloc on the write path. - * - *

    {@code
    -     * return res.redirect("/login");
    -     * }
    - */ - public Response redirect(String url) { - return redirect(HttpStatus.FOUND, url); + private void recordHeaderEntry(byte tag, int ref) { + if (headerTags == null) { + headerTags = new byte[16]; + headerRefs = new int[16]; + } else if (headerCount == headerTags.length) { + int grown = headerTags.length * 2; + headerTags = Arrays.copyOf(headerTags, grown); + headerRefs = Arrays.copyOf(headerRefs, grown); } + headerTags[headerCount] = tag; + headerRefs[headerCount] = ref; + headerCount++; + } - /** - * Redirect with an explicit 3xx status. Use {@link HttpStatus#MOVED_PERMANENTLY}, - * {@link HttpStatus#TEMPORARY_REDIRECT} (307), or {@link HttpStatus#PERMANENT_REDIRECT} (308) - * when semantics matter. - * - *
    {@code
    -     * return res.redirect(HttpStatus.MOVED_PERMANENTLY, "/new-path");
    -     * }
    - */ - public Response redirect(HttpStatus status, String url) { - this.statusCode = status.code(); - this.statusBytes = status.bytes(); - this.body = null; - this.stream = null; - if (headers == null) headers = new ArrayList<>(); - headers.add(("Location: " + url + "\r\n").getBytes(StandardCharsets.UTF_8)); - return this; + private void ensureQuadCapacity(int neededQuads) { + int neededInts = neededQuads * 4; + if (neededInts <= headerQuads.length) return; + int grown = headerQuads.length; + while (grown < neededInts) grown *= 2; + headerQuads = Arrays.copyOf(headerQuads, grown); + } + + // ------------------------------------------------------------------------- + // State queries + // ------------------------------------------------------------------------- + + public boolean isStreaming() { + checkActive(); + return stream != null; + } + + public boolean isChunked() { + checkActive(); + return chunked; + } + + /** Internal distinction between producer-driven and InputStream-driven response bodies. */ + public boolean isPushStreaming() { + checkActive(); + return pushStreaming; + } + + public int getStatusCode() { + checkActive(); + return statusCode; + } + + public byte[] getStatusBytes() { + checkActive(); + return statusBytes; + } + + public byte[] getBody() { + checkActive(); + return body; + } + + public byte[] getContentType() { + checkActive(); + return contentType; + } + + public InputStream getStream() { + checkActive(); + return stream; + } + + public long getStreamLength() { + checkActive(); + return streamLength; + } + + public boolean hasTrailers() { + checkActive(); + return trailers.count() != 0; + } + + public void writeTrailers(OutputStream output) throws IOException { + checkActive(); + trailers.writeLines(output); + } + + void forEachTrailerField(ResponseSerializer.FieldConsumer consumer) { + trailers.forEachStructured(consumer); + } + + // ------------------------------------------------------------------------- + // Internal setters used by HttpServer for handler return values + // ------------------------------------------------------------------------- + + /** + * Sets the body from a handler return value. Accepted types: {@code byte[]}, {@link String}, + * {@link CharSequence}. Any other non-null type throws {@link IllegalArgumentException} — return + * a {@code Response} directly, or serialize to {@code String}/{@code byte[]} before returning. + */ + public Response setBody(Object body) { + checkActive(); + if (body instanceof byte[] bytes) { + this.body = bytes; + return this; } - - /** Adds a response header. Encoded once at call time; zero-alloc on the write path. */ - public Response header(String name, String value) { - if (headers == null) headers = new ArrayList<>(); - headers.add((name + ": " + value + "\r\n").getBytes(StandardCharsets.UTF_8)); - return this; + if (body instanceof String s) { + this.body = s.getBytes(StandardCharsets.UTF_8); + return this; } - - /** - * Adds a pre-encoded header (e.g. a static {@code "X-RateLimit-Limit: 100\r\n"} byte array - * pre-built at boot time). Zero-alloc on both the call path and the write path. - */ - public Response header(byte[] preEncoded) { - if (headers == null) headers = new ArrayList<>(); - headers.add(preEncoded); - return this; + if (body instanceof CharSequence s) { + this.body = s.toString().getBytes(StandardCharsets.UTF_8); + return this; } + if (body != null) + throw new IllegalArgumentException( + "Handler returned unsupported type: " + + body.getClass().getName() + + " — return String, byte[], Response, or null"); + return this; + } - // ------------------------------------------------------------------------- - // State queries - // ------------------------------------------------------------------------- - - public boolean isStreaming() { return stream != null; } - - // ------------------------------------------------------------------------- - // Internal setters used by HttpServer for handler return values - // ------------------------------------------------------------------------- - - /** - * Sets the body from a handler return value. Accepted types: {@code byte[]}, - * {@link String}, {@link CharSequence}. Any other non-null type throws - * {@link IllegalArgumentException} — return a {@code Response} directly, or - * serialize to {@code String}/{@code byte[]} before returning. - */ - public Response setBody(Object body) { - if (body instanceof byte[] bytes) { this.body = bytes; return this; } - if (body instanceof String s) { this.body = s.getBytes(StandardCharsets.UTF_8); return this; } - if (body instanceof CharSequence s) { this.body = s.toString().getBytes(StandardCharsets.UTF_8); return this; } - if (body != null) throw new IllegalArgumentException( - "Handler returned unsupported type: " + body.getClass().getName() - + " — return String, byte[], Response, or null"); - return this; + /** + * Returns custom headers as fully-rendered {@code "Name: Value\r\n"} lines, or an empty list if + * none were added. Introspection/debugging accessor — reconstructs each line from the internal + * region on every call, so it is not on the zero-alloc write path; {@link #writeHeaders} and + * {@link ResponseSerializer} read the internal representation directly instead of going through + * this method. + */ + public List getHeaders() { + checkActive(); + if (headerCount == 0) return List.of(); + List result = new ArrayList<>(headerCount); + for (int i = 0; i < headerCount; i++) { + if (headerTags[i] == 1) { + result.add(rawHeaderLines.get(headerRefs[i])); + } else { + int base = headerRefs[i] * 4; + byte[] region = headerRegion.array(); + int nameOff = headerQuads[base], nameLen = headerQuads[base + 1]; + int valOff = headerQuads[base + 2], valLen = headerQuads[base + 3]; + byte[] line = new byte[nameLen + 2 + valLen + 2]; + int p = 0; + System.arraycopy(region, nameOff, line, p, nameLen); + p += nameLen; + line[p++] = ':'; + line[p++] = ' '; + System.arraycopy(region, valOff, line, p, valLen); + p += valLen; + line[p++] = '\r'; + line[p] = '\n'; + result.add(line); + } } + return result; + } - /** Returns custom headers, or an empty list if none were added. */ - public List getHeaders() { return headers != null ? headers : List.of(); } - - /** Writes pre-encoded custom headers directly to {@code out}. Zero-alloc when no headers are set. */ - public void writeHeaders(OutputStream out) throws IOException { - if (headers == null) return; - for (byte[] header : headers) out.write(header); + /** + * Writes every custom header directly into {@code head} (a scratch {@link ByteWriter} — 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. + */ + public void writeHeadersInto(ByteWriter head) { + for (int i = 0; i < headerCount; i++) { + if (headerTags[i] == 1) { + head.writeBytes(rawHeaderLines.get(headerRefs[i])); + } else { + int base = headerRefs[i] * 4; + byte[] region = headerRegion.array(); + head.writeBytes(region, headerQuads[base], headerQuads[base + 1]); + head.writeByte((byte) ':'); + head.writeByte((byte) ' '); + head.writeBytes(region, headerQuads[base + 2], headerQuads[base + 3]); + head.writeByte((byte) '\r'); + head.writeByte((byte) '\n'); + } } + } + + /** + * Writes every custom header directly to {@code out}, in call order. Zero-alloc when no headers + * are set or on a warm region. + */ + public void writeHeaders(OutputStream out) throws IOException { + for (int i = 0; i < headerCount; i++) { + if (headerTags[i] == 1) { + out.write(rawHeaderLines.get(headerRefs[i])); + } else { + int base = headerRefs[i] * 4; + byte[] region = headerRegion.array(); + out.write(region, headerQuads[base], headerQuads[base + 1]); + out.write(':'); + out.write(' '); + out.write(region, headerQuads[base + 2], headerQuads[base + 3]); + out.write('\r'); + out.write('\n'); + } + } + } + + // ── Internal: name/value field enumeration for ResponseSerializer ────────── + + /** + * Visits every {@code header(String,String)}/{@code header(PreEncodedHeader)}-added field as a + * structured (name, value) byte range — not the {@code header(byte[])} legacy entries, + * which have no such structure (see that method's own Javadoc). Package-private: {@link + * ResponseSerializer} is this method's only caller. + */ + void forEachStructuredField(ResponseSerializer.FieldConsumer consumer) { + if (headerQuadCount == 0) return; + byte[] region = headerRegion.array(); + for (int i = 0; i < headerQuadCount; i++) { + int base = i * 4; + consumer.accept( + region, + headerQuads[base], + headerQuads[base + 1], + region, + headerQuads[base + 2], + headerQuads[base + 3]); + } + } + + @Override + public String toString() { + return "Response(statusCode=" + + statusCode + + ", contentType=" + + (contentType != null ? new String(contentType, StandardCharsets.UTF_8) : null) + + ")"; + } } diff --git a/flash/src/main/java/dev/relism/flash/models/ResponseSerializer.java b/flash/src/main/java/dev/relism/flash/models/ResponseSerializer.java new file mode 100644 index 0000000..47831fe --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/models/ResponseSerializer.java @@ -0,0 +1,62 @@ +package dev.relism.flash.models; + +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 writer, so field selection cannot drift between HTTP/1 and HTTP/2. + * + *

    Scope: response-object fields only, not connection framing

    + * + * Deliberately does not enumerate {@code Content-Length}, {@code Connection}, or {@code + * Date} — those are connection/transport framing decisions (body length, keep-alive negotiation, + * wall-clock time), not properties of the {@code Response} object itself, and HTTP/2 has no + * equivalent of {@code Connection} at all (RFC 9113 §8.2.2 forbids connection-specific fields in + * h2). Each protocol's own writer computes and emits those itself, exactly as {@code + * Http1ResponseWriter} already did before this class existed. + * + *

    Scope: excludes {@link Response#header(byte[])}'s legacy entries

    + * + * A header added via the raw, fully-pre-rendered {@code header(byte[])} overload has no recoverable + * (name, value) structure — see that method's own Javadoc — so it cannot appear in this + * enumeration. HTTP/1 still renders it via {@link Response#writeHeaders}; HTTP/2 cannot recover its + * field structure and ignores it. + */ +public final class ResponseSerializer { + private ResponseSerializer() {} + + /** + * One rendered header field: a byte range for the name, and a byte range for the value — both + * slices of caller-owned arrays, never copied. + */ + @FunctionalInterface + public interface FieldConsumer { + void accept( + byte[] nameBuf, int nameOff, int nameLen, byte[] valueBuf, int valueOff, int valueLen); + } + + private static final byte[] CONTENT_TYPE_NAME = + "Content-Type".getBytes(StandardCharsets.US_ASCII); + + /** + * Enumerates {@code response}'s fields in a fixed order: non-empty {@code Content-Type}, then + * structured custom fields in call order. Every range is a slice of existing response storage. + */ + public static void forEachField(Response response, FieldConsumer consumer) { + byte[] ct = response.getContentType(); + if (ct != null && ct.length > 0) { + consumer.accept(CONTENT_TYPE_NAME, 0, CONTENT_TYPE_NAME.length, ct, 0, ct.length); + } + forEachCustomField(response, consumer); + } + + /** Enumerates structured custom fields only, excluding {@code content-type}. */ + public static void forEachCustomField(Response response, FieldConsumer consumer) { + response.forEachStructuredField(consumer); + } + + /** Enumerates response trailers in declaration order. */ + public static void forEachTrailerField(Response response, FieldConsumer consumer) { + response.forEachTrailerField(consumer); + } +} diff --git a/flash/src/main/java/dev/relism/flash/models/ResponseStream.java b/flash/src/main/java/dev/relism/flash/models/ResponseStream.java new file mode 100644 index 0000000..8c531da --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/models/ResponseStream.java @@ -0,0 +1,11 @@ +package dev.relism.flash.models; + +import java.io.IOException; + +/** Blocking, flow-controlled response body used by push-style streaming producers. */ +public interface ResponseStream extends AutoCloseable { + void write(byte[] data, int offset, int length) throws IOException; + void flush() throws IOException; + void trailer(String name, String value); + @Override void close() throws IOException; +} diff --git a/flash/src/main/java/dev/relism/flash/models/ResponseStreamOutputStream.java b/flash/src/main/java/dev/relism/flash/models/ResponseStreamOutputStream.java new file mode 100644 index 0000000..34eb05e --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/models/ResponseStreamOutputStream.java @@ -0,0 +1,35 @@ +package dev.relism.flash.models; + +import java.io.IOException; +import java.io.OutputStream; + +/** Adapts a flow-controlled response stream to APIs that write to an {@link OutputStream}. */ +public final class ResponseStreamOutputStream extends OutputStream { + private final ResponseStream stream; + private final byte[] single = new byte[1]; + + public ResponseStreamOutputStream(ResponseStream stream) { + this.stream = stream; + } + + @Override + public void write(int value) throws IOException { + single[0] = (byte) value; + stream.write(single, 0, 1); + } + + @Override + public void write(byte[] bytes, int offset, int length) throws IOException { + stream.write(bytes, offset, length); + } + + @Override + public void flush() throws IOException { + stream.flush(); + } + + @Override + public void close() throws IOException { + stream.close(); + } +} diff --git a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java index 1ccd6d9..4ad7dcb 100644 --- a/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java +++ b/flash/src/main/java/dev/relism/flash/routing/AbstractRouter.java @@ -6,7 +6,6 @@ import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl; import dev.relism.flash.Flash; import dev.relism.flash.http.ContentType; import dev.relism.flash.http.HttpMethod; -import dev.relism.fpr.core.ByteView; import dev.relism.flash.template.ErrorPages; import java.nio.charset.StandardCharsets; @@ -85,7 +84,10 @@ public abstract class AbstractRouter { */ public AbstractRouter doRegister(HttpMethod method, String path, RequestHandler handler, Middleware[] middlewares) { - return addRoute(method, PathUtils.sanitize(path), compile(handler, middlewares)); + String target = method == HttpMethod.CONNECT + ? PathUtils.sanitizeAuthority(path) + : PathUtils.sanitize(path); + return addRoute(method, target, compile(handler, middlewares)); } /** @@ -101,14 +103,31 @@ public abstract class AbstractRouter { // ── Routing ────────────────────────────────────────────────────────────── - public abstract RequestHandler route(Request request); + /** + * Creates a fresh per-connection scratch object for {@link #route}, or {@code null} if this + * router implementation keeps no reusable per-connection state. Called once per connection + * by the connection driver (e.g. {@code Http1Connection}), which holds the opaque result and + * passes it back into every {@link #route} call for that connection's whole lifetime — the + * same "create once per connection, reuse across requests" shape already used there for + * {@code RequestParser}. + * + * 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 + * than extending {@code ConnectionScratch} itself, which is what an earlier draft of this + * fix assumed). + */ + public Object newScratch() { + return null; + } + + public abstract RequestHandler route(Request request, Object scratch); protected abstract AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler); - protected static void setPathParams(Request request, String[] names, ByteView source, int[] starts, int[] lens) { - PathParams.inject(request, new PathParams(source, names, starts, lens)); - } - @FunctionalInterface public interface ExceptionHandler { Object handle(Exception exception, Request request, Response response); diff --git a/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java b/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java index 6b78755..416ffd9 100644 --- a/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java +++ b/flash/src/main/java/dev/relism/flash/routing/AbstractWsRouter.java @@ -15,7 +15,16 @@ public abstract class AbstractWsRouter { return addRoute(method, PathUtils.sanitize(path), handler); } - public abstract WebSocketHandler route(Request request); + /** + * 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 + * router. + */ + public Object newScratch() { + return null; + } + + public abstract WebSocketHandler route(Request request, Object scratch); protected abstract AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler); diff --git a/flash/src/main/java/dev/relism/flash/routing/PathUtils.java b/flash/src/main/java/dev/relism/flash/routing/PathUtils.java index a972b49..c43c88c 100644 --- a/flash/src/main/java/dev/relism/flash/routing/PathUtils.java +++ b/flash/src/main/java/dev/relism/flash/routing/PathUtils.java @@ -23,6 +23,14 @@ public class PathUtils { return sanitized; } + /** Normalizes an authority-form CONNECT target without turning it into an origin-form path. */ + public static String sanitizeAuthority(String authority) { + if (authority == null) return ""; + String sanitized = authority.trim(); + while (sanitized.startsWith("/")) sanitized = sanitized.substring(1); + return sanitized; + } + /** * Joins two path segments and ensures the result is sanitized. * Prevents "double namespace" if the path already starts with the base. diff --git a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java index 6041521..12c821b 100644 --- a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java +++ b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImpl.java @@ -6,15 +6,20 @@ import dev.relism.fpr.core.MatchResult; import dev.relism.fpr.core.RouterBuilder; import dev.relism.fpr.core.dsl.StringRouteParser; import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.models.PathParams; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestHandler; import dev.relism.flash.routing.AbstractRouter; +import java.util.Arrays; + /** * Router backed by the {@code fpr-core} byte-level state machine. Routes are compiled lazily * on the first request and recompiled when routes are added after startup. Matching runs on a - * virtual {@code METHOD + path} byte sequence in a single pass; {@link MatchResult} and - * {@link FastPathViews.MethodPathByteView} are reused per-thread to avoid hot-path allocations. + * 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 + * request. */ public class FastPathRouterImpl extends AbstractRouter { private final RouterBuilder builder = new RouterBuilder<>(); @@ -23,21 +28,47 @@ public class FastPathRouterImpl extends AbstractRouter { public FastPathRouterImpl() {} - private static final class FastPathRouterContext { - private static final ThreadLocal> RESULT_HOLDER = - ThreadLocal.withInitial(() -> new MatchResult<>(32, 128)); - private static final ThreadLocal COMBINED_VIEW_HOLDER = - ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new); + /** + * 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). + * + * 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 + * {@code RequestParser}'s read buffer already uses. {@code pathParams} is the single + * {@link PathParams} instance repositioned (via {@link PathParams#reset}) over those arrays + * every time a match has params, instead of a fresh {@code PathParams} per request. + */ + static final class RouteScratch { + final MatchResult matchResult = new MatchResult<>(32, 128); + final FastPathViews.MethodPathByteView combinedView = new FastPathViews.MethodPathByteView(); - public static MatchResult getResult() { - return RESULT_HOLDER.get(); - } + String[] paramNames = new String[8]; + int[] paramStarts = new int[8]; + int[] paramLens = new int[8]; + PathParams pathParams = new PathParams(paramNames, paramStarts, paramLens); - public static FastPathViews.MethodPathByteView getCombinedView() { - return COMBINED_VIEW_HOLDER.get(); + void ensureParamCapacity(int count) { + if (count <= paramNames.length) return; + int grown = paramNames.length; + while (grown < count) grown *= 2; + paramNames = Arrays.copyOf(paramNames, grown); + paramStarts = Arrays.copyOf(paramStarts, grown); + paramLens = Arrays.copyOf(paramLens, grown); + // The arrays PathParams reads are now different instances — rebuild it. This is the + // only case in which a RouteScratch allocates past connection setup, and only on a + // connection whose route mix keeps needing more params than ever seen before; it + // never happens again once this connection's high-water mark stabilizes. + pathParams = new PathParams(paramNames, paramStarts, paramLens); } } + @Override + public Object newScratch() { + return new RouteScratch(); + } + @Override protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) { builder.add(StringRouteParser.parse(method.name() + path), handler); @@ -46,15 +77,16 @@ public class FastPathRouterImpl extends AbstractRouter { } @Override - public RequestHandler route(Request request) { + public RequestHandler route(Request request, Object scratchObj) { ensureCompiled(); + RouteScratch scratch = (RouteScratch) scratchObj; - MatchResult result = FastPathRouterContext.getResult(); + MatchResult result = scratch.matchResult; result.reset(); HttpMethod method = request.getRequestLine().getMethod(); ByteView pathView = request.getRequestLine().getPath(); - FastPathViews.MethodPathByteView combinedView = FastPathRouterContext.getCombinedView(); + FastPathViews.MethodPathByteView combinedView = scratch.combinedView; combinedView.reset(method.getBytes(), pathView); int labelId = router.match(combinedView, result); @@ -65,18 +97,20 @@ public class FastPathRouterImpl extends AbstractRouter { int count = result.paramCount(); if (count > 0) { - int methodLen = method.getBytes().length; - String[] all = cachedParamNames; - String[] names = new String[count]; - int[] starts = new int[count]; - int[] lens = new int[count]; + scratch.ensureParamCapacity(count); + int methodLen = method.getBytes().length; + String[] all = cachedParamNames; + String[] names = scratch.paramNames; + int[] starts = scratch.paramStarts; + int[] lens = scratch.paramLens; for (int i = 0; i < count; i++) { names[i] = all[result.keyIdAt(i)]; starts[i] = result.startAt(i) - methodLen; lens[i] = result.lenAt(i); } - setPathParams(request, names, pathView, starts, lens); + scratch.pathParams.reset(pathView, count); + PathParams.inject(request, scratch.pathParams); } return result.handler(); diff --git a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViews.java b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViews.java index 2473c87..2601fd7 100644 --- a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViews.java +++ b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViews.java @@ -1,18 +1,56 @@ package dev.relism.flash.routing.routers.fastpathrouter; +import dev.relism.flash.bytes.ArrayBackedByteView; import dev.relism.fpr.core.ByteView; import lombok.NoArgsConstructor; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.VarHandle; +import java.nio.ByteOrder; import java.nio.charset.StandardCharsets; /** {@link dev.relism.fpr.core.ByteView} implementations used on the router and parser hot paths. */ @NoArgsConstructor public final class FastPathViews { - public static final class RequestByteView implements ByteView { - private final byte[] buffer; - private final int start; - private final int length; + /** + * 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 + * {@link ByteView#longAt} returns. For that comparison to be correct, {@code longAt} must + * therefore return the identical little-endian-assembled value for the same 8 + * bytes — fixed to {@code LITTLE_ENDIAN} specifically (not {@code nativeOrder()}) so the + * contract holds on every host regardless of the JVM's native byte order, matching + * {@code ByteCompare}'s own fixed choice exactly. Confirmed by decompiling + * {@code fpr-core-1.1.1}'s {@code ByteCompare.class} (its {@code LONG_VIEW} field), not + * merely assumed — see {@code FastPathViewsLongAtTest} for the runtime verification the + * plan requires beyond reading bytecode. + */ + private static final VarHandle LONG_VIEW_LE = + MethodHandles.byteArrayViewVarHandle(long[].class, ByteOrder.LITTLE_ENDIAN); + + /** + * Reads 8 bytes at {@code array[pos, pos + 8)} as fpr-core's {@code ByteCompare} expects a + * {@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 + */ + private static long longAtLittleEndian(byte[] array, int pos) { + return (long) LONG_VIEW_LE.get(array, pos); + } + + /** + * 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 + * per-connection object in this codebase already follows ({@code Http1HeaderMap}, + * {@code RequestLine}, {@code Request}, {@code RequestBody}). The public constructor remains + * for one-shot, non-pooled use (tests, other call sites that build a single fixed view). + */ + public static final class RequestByteView implements ArrayBackedByteView { + private byte[] buffer; + private int start; + private int length; public RequestByteView(byte[] buffer, int start, int length) { this.buffer = buffer; @@ -20,6 +58,13 @@ public final class FastPathViews { this.length = length; } + /** Repositions this instance over new bounds. Zero allocation. */ + public void reset(byte[] buffer, int start, int length) { + this.buffer = buffer; + this.start = start; + this.length = length; + } + @Override public int length() { return length; @@ -33,13 +78,46 @@ public final class FastPathViews { return buffer[start + index]; } + @Override + public byte[] array() { + return buffer; + } + + @Override + public int offset() { + return start; + } + + @Override + public boolean supportsLong() { + return true; + } + + @Override + public long longAt(int index) { + return longAtLittleEndian(buffer, start + index); + } + @Override public String toString() { return new String(buffer, start, length, StandardCharsets.UTF_8); } } - /** Mutable composite view: method bytes + path. Reused via ThreadLocal, call reset() before use. */ + /** + * Mutable composite view: method bytes + path. Reused per connection, call {@link #reset} + * + * 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 + * span, and a byte index near the method/path boundary could straddle both sources entirely, + * making a single contiguous 8-byte read structurally impossible in general (not merely + * unimplemented) — the same reasoning {@link dev.relism.flash.bytes.SegmentedByteView} + * documents for the analogous HPACK CONTINUATION case. Falls back to the inherited + * {@link ByteView#supportsLong} default ({@code false}); {@code fpr-core}'s router-matching + * path already handles that correctly (it only takes the word-at-a-time branch when + * {@code supportsLong()} is {@code true}). + */ public static final class MethodPathByteView implements ByteView { private byte[] method; private ByteView path; @@ -62,7 +140,7 @@ public final class FastPathViews { } } - public static class SocketByteView implements ByteView { + public static class SocketByteView implements ArrayBackedByteView { private final byte[] data; public SocketByteView(byte[] data) { @@ -78,9 +156,29 @@ public final class FastPathViews { public byte byteAt(int index) { return data[index]; } + + @Override + public byte[] array() { + return data; + } + + @Override + public int offset() { + return 0; + } + + @Override + public boolean supportsLong() { + return true; + } + + @Override + public long longAt(int index) { + return longAtLittleEndian(data, index); + } } - public static class StringByteView implements ByteView { + public static class StringByteView implements ArrayBackedByteView { private final byte[] bytes; public StringByteView(String str) { @@ -96,5 +194,25 @@ public final class FastPathViews { public byte byteAt(int index) { return bytes[index]; } + + @Override + public byte[] array() { + return bytes; + } + + @Override + public int offset() { + return 0; + } + + @Override + public boolean supportsLong() { + return true; + } + + @Override + public long longAt(int index) { + return longAtLittleEndian(bytes, index); + } } } diff --git a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java index 339fe4d..e8be90d 100644 --- a/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java +++ b/flash/src/main/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathWsRouterImpl.java @@ -10,12 +10,31 @@ import dev.relism.flash.models.Request; import dev.relism.flash.routing.AbstractWsRouter; import dev.relism.flash.websocket.WebSocketHandler; +/** + * WebSocket-upgrade counterpart of {@link FastPathRouterImpl} — same {@code fpr-core} matching + * via {@link #newScratch} in place of the {@code ThreadLocal}s this class used to hold). Unlike + * 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 + * this was not flagged as a hot-path allocation concern. + */ public final class FastPathWsRouterImpl extends AbstractWsRouter { private final RouterBuilder builder = new RouterBuilder<>(); private volatile FastPathRouter router; private String[] cachedParamNames; + /** Per-connection reusable matching state. */ + static final class RouteScratch { + final MatchResult matchResult = new MatchResult<>(32, 128); + final FastPathViews.MethodPathByteView combinedView = new FastPathViews.MethodPathByteView(); + } + + @Override + public Object newScratch() { + return new RouteScratch(); + } + @Override protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) { builder.add(StringRouteParser.parse(method.name() + path), handler); @@ -24,15 +43,16 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter { } @Override - public WebSocketHandler route(Request request) { + public WebSocketHandler route(Request request, Object scratchObj) { ensureCompiled(); + RouteScratch scratch = (RouteScratch) scratchObj; - MatchResult result = Context.result(); + MatchResult result = scratch.matchResult; result.reset(); HttpMethod method = request.getRequestLine().getMethod(); ByteView pathView = request.getRequestLine().getPath(); - FastPathViews.MethodPathByteView combined = Context.combined(); + FastPathViews.MethodPathByteView combined = scratch.combinedView; combined.reset(method.getBytes(), pathView); int labelId = router.match(combined, result); @@ -58,14 +78,4 @@ public final class FastPathWsRouterImpl extends AbstractWsRouter { @Override public void compile() { ensureCompiled(); } - - private static final class Context { - private static final ThreadLocal> RESULT = - ThreadLocal.withInitial(() -> new MatchResult<>(32, 128)); - private static final ThreadLocal COMBINED = - ThreadLocal.withInitial(FastPathViews.MethodPathByteView::new); - - static MatchResult result() { return RESULT.get(); } - static FastPathViews.MethodPathByteView combined() { return COMBINED.get(); } - } } diff --git a/flash/src/main/java/dev/relism/flash/template/ByteTemplate.java b/flash/src/main/java/dev/relism/flash/template/ByteTemplate.java index 1fff0ff..3a85880 100644 --- a/flash/src/main/java/dev/relism/flash/template/ByteTemplate.java +++ b/flash/src/main/java/dev/relism/flash/template/ByteTemplate.java @@ -2,21 +2,29 @@ package dev.relism.flash.template; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; /** * Precompiled, allocation-minimal byte template. *

    * Placeholders of the form {@code {{name}}} are detected once at construction. - * Each {@link #render} call makes exactly one allocation: the output byte[]. + * Each {@link #render} call makes exactly one allocation: the output byte[] + * (plus one {@code byte[]} per distinct key-value pair, for its UTF-8 bytes). *

    * Layout: seg[0] slot[0] seg[1] slot[1] … seg[n-1] slot[n-1] seg[n] + * + * 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. */ public final class ByteTemplate { - private final byte[][] segments; // literal byte segments - private final String[] slots; // placeholder names in order - private final int staticLength; // sum of all segment lengths (precomputed) + private final byte[][] segments; // literal byte segments + private final String[] slots; // placeholder names in order + private final int staticLength; // sum of all segment lengths (precomputed) + private final Map slotIndex; // slot name -> every slot index using that name public ByteTemplate(String source) { List segs = new ArrayList<>(); @@ -40,27 +48,68 @@ public final class ByteTemplate { int sl = 0; for (byte[] s : segments) sl += s.length; staticLength = sl; + + Map> byName = new HashMap<>(); + for (int j = 0; j < slots.length; j++) { + byName.computeIfAbsent(slots[j], k -> new ArrayList<>()).add(j); + } + Map idx = new HashMap<>(); + for (Map.Entry> e : byName.entrySet()) { + int[] arr = new int[e.getValue().size()]; + for (int j = 0; j < arr.length; j++) arr[j] = e.getValue().get(j); + idx.put(e.getKey(), arr); + } + slotIndex = idx; } /** * Render with alternating key-value String pairs: {@code k1, v1, k2, v2, …} - * Unmatched slots are rendered as empty. + * Unmatched slots are rendered as empty. Allocates the returned {@code byte[]}; for a + * caller-supplied buffer see {@link #renderInto(byte[], int, String...)}. */ public byte[] render(String... kvPairs) { + byte[][] values = resolveValues(kvPairs); + byte[] out = new byte[length(values)]; + writeInto(out, 0, values); + return out; + } + + /** + * Renders into {@code buffer} starting at {@code offset}, making no allocation beyond the + * per-pair UTF-8 conversion of {@code kvPairs}' values. Returns the number of bytes written. + * + * @throws IndexOutOfBoundsException if {@code buffer} does not have enough room from {@code offset} + */ + public int renderInto(byte[] buffer, int offset, String... kvPairs) { + byte[][] values = resolveValues(kvPairs); + int len = length(values); + if (offset < 0 || offset + len > buffer.length) { + throw new IndexOutOfBoundsException( + "buffer too small: need " + len + " bytes at offset " + offset + ", have " + (buffer.length - offset)); + } + writeInto(buffer, offset, values); + return len; + } + + private byte[][] resolveValues(String[] kvPairs) { byte[][] values = new byte[slots.length][]; for (int i = 0; i + 1 < kvPairs.length; i += 2) { - String key = kvPairs[i]; + int[] matches = slotIndex.get(kvPairs[i]); + if (matches == null) continue; byte[] val = kvPairs[i + 1].getBytes(StandardCharsets.UTF_8); - for (int j = 0; j < slots.length; j++) { - if (slots[j].equals(key)) { values[j] = val; } - } + for (int idx : matches) values[idx] = val; } + return values; + } + private int length(byte[][] values) { int len = staticLength; for (byte[] v : values) if (v != null) len += v.length; + return len; + } - byte[] out = new byte[len]; - int pos = 0; + private void writeInto(byte[] out, int offset, byte[][] values) { + int pos = offset; for (int i = 0; i < slots.length; i++) { System.arraycopy(segments[i], 0, out, pos, segments[i].length); pos += segments[i].length; @@ -70,6 +119,5 @@ public final class ByteTemplate { } } System.arraycopy(segments[slots.length], 0, out, pos, segments[slots.length].length); - return out; } } diff --git a/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java b/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java index 16935c2..576e374 100644 --- a/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java +++ b/flash/src/main/java/dev/relism/flash/tls/TlsConfig.java @@ -1,5 +1,14 @@ package dev.relism.flash.tls; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; import javax.net.ssl.KeyManager; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; @@ -8,125 +17,468 @@ import javax.net.ssl.SSLServerSocket; import javax.net.ssl.SSLServerSocketFactory; import javax.net.ssl.X509ExtendedKeyManager; -import java.io.IOException; -import java.io.InputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.security.GeneralSecurityException; -import java.security.KeyStore; - /** - * Declarative TLS configuration for a {@link dev.relism.flash.extension.FlashConfiguration.Listener}. + * Declarative TLS configuration for a {@link + * dev.relism.flash.extension.FlashConfiguration.Listener}. * *

    Two ways in

    + * *
      *
    • {@link #keystore(Path, String)} — Flash builds the {@link SSLContext} from a PKCS12/JKS - * keystore. A keystore holding more than one certificate entry gets SNI-based selection - * for free (see {@link SniKeyManager}) — no per-hostname config needed. Flash also pins - * {@code TLSv1.2}/{@code TLSv1.3} as the enabled protocols; cipher suites are left at the - * JDK's own curated default, which each JDK security release keeps current — Flash does - * not maintain its own suite allow-list.
    • - *
    • {@link #ofContext(SSLContext)} — escape hatch. The given {@link SSLContext} is used - * exactly as built: Flash never calls {@code setSSLParameters} on this path unless you - * explicitly call {@link #applicationProtocols} or {@link #clientAuth} yourself, so - * anything else you configured on it is 100% authoritative.
    • + * keystore. A keystore holding more than one certificate entry gets SNI-based selection for + * free (see {@link SniKeyManager}) — no per-hostname config needed. Flash also pins {@code + * TLSv1.2}/{@code TLSv1.3} as the enabled protocols; cipher suites are left at the JDK's own + * curated default, which each JDK security release keeps current — Flash does not maintain + * its own suite allow-list. + *
    • {@link #ofContext(SSLContext)} — escape hatch. The given {@link SSLContext} is used exactly + * as built: Flash never calls {@code setSSLParameters} on this path unless you explicitly + * call {@link #applicationProtocols} or {@link #clientAuth} yourself, so anything else you + * configured on it is 100% authoritative. *
    * - *

    {@link #clientAuth(ClientAuth)} and {@link #applicationProtocols(String...)} apply on - * either path — they are explicit instructions through this API, not Flash-chosen defaults, so - * each is only ever applied when called. Neither has a value by default, on either path. + *

    {@link #clientAuth(ClientAuth)} and {@link #applicationProtocols(String...)} apply on either + * path — they are explicit instructions through this API, not Flash-chosen defaults, so each is + * only ever applied when called. Neither has a value by default, on either path. * *

    ALPN (e.g. TLS-ALPN-01 / RFC 8737)

    - * {@link #applicationProtocols(String...)} sets the listener's negotiable protocol list via - * {@link SSLParameters#setApplicationProtocols}, inherited by every accepted socket exactly like - * {@link ClientAuth} — no per-connection code needed. ALPN is resolved during {@code ClientHello} - * processing/{@code ServerHello} production, which always precedes {@code Certificate} production - * — so a custom {@link javax.net.ssl.X509ExtendedKeyManager} deciding which certificate to serve - * can read the client's negotiated protocol via {@code engine.getHandshakeApplicationProtocol()} - * (or {@code ((SSLSocket) socket).getHandshakeApplicationProtocol()}) inside - * {@code chooseEngineServerAlias}/{@code chooseServerAlias} and it is already resolved by then. + * + * {@link #applicationProtocols(String...)} sets the listener's negotiable protocol list via {@link + * SSLParameters#setApplicationProtocols}, inherited by every accepted socket exactly like {@link + * ClientAuth} — no per-connection code needed. ALPN is resolved during {@code ClientHello} + * processing/{@code ServerHello} production, which always precedes {@code Certificate} production — + * so a custom {@link javax.net.ssl.X509ExtendedKeyManager} deciding which certificate to serve can + * read the client's negotiated protocol via {@code engine.getHandshakeApplicationProtocol()} (or + * {@code ((SSLSocket) socket).getHandshakeApplicationProtocol()}) inside {@code + * chooseEngineServerAlias}/{@code chooseServerAlias} and it is already resolved by then. */ public final class TlsConfig { - private static final String[] SECURE_PROTOCOLS = { "TLSv1.3", "TLSv1.2" }; + private static final String[] SECURE_PROTOCOLS = {"TLSv1.3", "TLSv1.2"}; - private final SSLContext context; - private final boolean hardenDefaults; - private final ClientAuth clientAuth; - private final String[] applicationProtocols; + /** + * 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 suites (the + * {@code TLS_AES_*}/{@code TLS_CHACHA20_*} identifiers) appear here, since TLS 1.3 removed + * static/non-ephemeral key exchange and CBC-mode ciphers entirely — the exact property this + * blocklist exists to enforce for TLS 1.2. + * + *

    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 + * 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 + */ + private static final Set TLS12_H2_BLOCKED_CIPHERS = + Set.of( + "TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA", + "TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA", + "TLS_DHE_DSS_WITH_AES_128_CBC_SHA", + "TLS_DHE_DSS_WITH_AES_128_CBC_SHA256", + "TLS_DHE_DSS_WITH_AES_256_CBC_SHA", + "TLS_DHE_DSS_WITH_AES_256_CBC_SHA256", + "TLS_DHE_DSS_WITH_ARIA_128_CBC_SHA256", + "TLS_DHE_DSS_WITH_ARIA_256_CBC_SHA384", + "TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA", + "TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA", + "TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA256", + "TLS_DHE_DSS_WITH_DES_CBC_SHA", + "TLS_DHE_DSS_WITH_SEED_CBC_SHA", + "TLS_DHE_PSK_WITH_3DES_EDE_CBC_SHA", + "TLS_DHE_PSK_WITH_AES_128_CBC_SHA", + "TLS_DHE_PSK_WITH_AES_128_CBC_SHA256", + "TLS_DHE_PSK_WITH_AES_256_CBC_SHA", + "TLS_DHE_PSK_WITH_AES_256_CBC_SHA384", + "TLS_DHE_PSK_WITH_ARIA_128_CBC_SHA256", + "TLS_DHE_PSK_WITH_ARIA_256_CBC_SHA384", + "TLS_DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384", + "TLS_DHE_PSK_WITH_NULL_SHA", + "TLS_DHE_PSK_WITH_NULL_SHA256", + "TLS_DHE_PSK_WITH_NULL_SHA384", + "TLS_DHE_PSK_WITH_RC4_128_SHA", + "TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA", + "TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA", + "TLS_DHE_RSA_WITH_AES_128_CBC_SHA", + "TLS_DHE_RSA_WITH_AES_128_CBC_SHA256", + "TLS_DHE_RSA_WITH_AES_256_CBC_SHA", + "TLS_DHE_RSA_WITH_AES_256_CBC_SHA256", + "TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256", + "TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384", + "TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA", + "TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA", + "TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256", + "TLS_DHE_RSA_WITH_DES_CBC_SHA", + "TLS_DHE_RSA_WITH_SEED_CBC_SHA", + "TLS_DH_DSS_EXPORT_WITH_DES40_CBC_SHA", + "TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA", + "TLS_DH_DSS_WITH_AES_128_CBC_SHA", + "TLS_DH_DSS_WITH_AES_128_CBC_SHA256", + "TLS_DH_DSS_WITH_AES_128_GCM_SHA256", + "TLS_DH_DSS_WITH_AES_256_CBC_SHA", + "TLS_DH_DSS_WITH_AES_256_CBC_SHA256", + "TLS_DH_DSS_WITH_AES_256_GCM_SHA384", + "TLS_DH_DSS_WITH_ARIA_128_CBC_SHA256", + "TLS_DH_DSS_WITH_ARIA_128_GCM_SHA256", + "TLS_DH_DSS_WITH_ARIA_256_CBC_SHA384", + "TLS_DH_DSS_WITH_ARIA_256_GCM_SHA384", + "TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA", + "TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_DH_DSS_WITH_CAMELLIA_128_GCM_SHA256", + "TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA", + "TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA256", + "TLS_DH_DSS_WITH_CAMELLIA_256_GCM_SHA384", + "TLS_DH_DSS_WITH_DES_CBC_SHA", + "TLS_DH_DSS_WITH_SEED_CBC_SHA", + "TLS_DH_RSA_EXPORT_WITH_DES40_CBC_SHA", + "TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA", + "TLS_DH_RSA_WITH_AES_128_CBC_SHA", + "TLS_DH_RSA_WITH_AES_128_CBC_SHA256", + "TLS_DH_RSA_WITH_AES_128_GCM_SHA256", + "TLS_DH_RSA_WITH_AES_256_CBC_SHA", + "TLS_DH_RSA_WITH_AES_256_CBC_SHA256", + "TLS_DH_RSA_WITH_AES_256_GCM_SHA384", + "TLS_DH_RSA_WITH_ARIA_128_CBC_SHA256", + "TLS_DH_RSA_WITH_ARIA_128_GCM_SHA256", + "TLS_DH_RSA_WITH_ARIA_256_CBC_SHA384", + "TLS_DH_RSA_WITH_ARIA_256_GCM_SHA384", + "TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA", + "TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_DH_RSA_WITH_CAMELLIA_128_GCM_SHA256", + "TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA", + "TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA256", + "TLS_DH_RSA_WITH_CAMELLIA_256_GCM_SHA384", + "TLS_DH_RSA_WITH_DES_CBC_SHA", + "TLS_DH_RSA_WITH_SEED_CBC_SHA", + "TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA", + "TLS_DH_anon_EXPORT_WITH_RC4_40_MD5", + "TLS_DH_anon_WITH_3DES_EDE_CBC_SHA", + "TLS_DH_anon_WITH_AES_128_CBC_SHA", + "TLS_DH_anon_WITH_AES_128_CBC_SHA256", + "TLS_DH_anon_WITH_AES_128_GCM_SHA256", + "TLS_DH_anon_WITH_AES_256_CBC_SHA", + "TLS_DH_anon_WITH_AES_256_CBC_SHA256", + "TLS_DH_anon_WITH_AES_256_GCM_SHA384", + "TLS_DH_anon_WITH_ARIA_128_CBC_SHA256", + "TLS_DH_anon_WITH_ARIA_128_GCM_SHA256", + "TLS_DH_anon_WITH_ARIA_256_CBC_SHA384", + "TLS_DH_anon_WITH_ARIA_256_GCM_SHA384", + "TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA", + "TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_DH_anon_WITH_CAMELLIA_128_GCM_SHA256", + "TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA", + "TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA256", + "TLS_DH_anon_WITH_CAMELLIA_256_GCM_SHA384", + "TLS_DH_anon_WITH_DES_CBC_SHA", + "TLS_DH_anon_WITH_RC4_128_MD5", + "TLS_DH_anon_WITH_SEED_CBC_SHA", + "TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA", + "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA", + "TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256", + "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA", + "TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384", + "TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256", + "TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384", + "TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384", + "TLS_ECDHE_ECDSA_WITH_NULL_SHA", + "TLS_ECDHE_ECDSA_WITH_RC4_128_SHA", + "TLS_ECDHE_PSK_WITH_3DES_EDE_CBC_SHA", + "TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA", + "TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256", + "TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA", + "TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384", + "TLS_ECDHE_PSK_WITH_ARIA_128_CBC_SHA256", + "TLS_ECDHE_PSK_WITH_ARIA_256_CBC_SHA384", + "TLS_ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384", + "TLS_ECDHE_PSK_WITH_NULL_SHA", + "TLS_ECDHE_PSK_WITH_NULL_SHA256", + "TLS_ECDHE_PSK_WITH_NULL_SHA384", + "TLS_ECDHE_PSK_WITH_RC4_128_SHA", + "TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA", + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA", + "TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256", + "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA", + "TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384", + "TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256", + "TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384", + "TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384", + "TLS_ECDHE_RSA_WITH_NULL_SHA", + "TLS_ECDHE_RSA_WITH_RC4_128_SHA", + "TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA", + "TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA", + "TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256", + "TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA", + "TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384", + "TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDH_ECDSA_WITH_ARIA_128_CBC_SHA256", + "TLS_ECDH_ECDSA_WITH_ARIA_128_GCM_SHA256", + "TLS_ECDH_ECDSA_WITH_ARIA_256_CBC_SHA384", + "TLS_ECDH_ECDSA_WITH_ARIA_256_GCM_SHA384", + "TLS_ECDH_ECDSA_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_ECDH_ECDSA_WITH_CAMELLIA_128_GCM_SHA256", + "TLS_ECDH_ECDSA_WITH_CAMELLIA_256_CBC_SHA384", + "TLS_ECDH_ECDSA_WITH_CAMELLIA_256_GCM_SHA384", + "TLS_ECDH_ECDSA_WITH_NULL_SHA", + "TLS_ECDH_ECDSA_WITH_RC4_128_SHA", + "TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA", + "TLS_ECDH_RSA_WITH_AES_128_CBC_SHA", + "TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256", + "TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256", + "TLS_ECDH_RSA_WITH_AES_256_CBC_SHA", + "TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384", + "TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384", + "TLS_ECDH_RSA_WITH_ARIA_128_CBC_SHA256", + "TLS_ECDH_RSA_WITH_ARIA_128_GCM_SHA256", + "TLS_ECDH_RSA_WITH_ARIA_256_CBC_SHA384", + "TLS_ECDH_RSA_WITH_ARIA_256_GCM_SHA384", + "TLS_ECDH_RSA_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_ECDH_RSA_WITH_CAMELLIA_128_GCM_SHA256", + "TLS_ECDH_RSA_WITH_CAMELLIA_256_CBC_SHA384", + "TLS_ECDH_RSA_WITH_CAMELLIA_256_GCM_SHA384", + "TLS_ECDH_RSA_WITH_NULL_SHA", + "TLS_ECDH_RSA_WITH_RC4_128_SHA", + "TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA", + "TLS_ECDH_anon_WITH_AES_128_CBC_SHA", + "TLS_ECDH_anon_WITH_AES_256_CBC_SHA", + "TLS_ECDH_anon_WITH_NULL_SHA", + "TLS_ECDH_anon_WITH_RC4_128_SHA", + "TLS_EMPTY_RENEGOTIATION_INFO_SCSV", + "TLS_KRB5_EXPORT_WITH_DES_CBC_40_MD5", + "TLS_KRB5_EXPORT_WITH_DES_CBC_40_SHA", + "TLS_KRB5_EXPORT_WITH_RC2_CBC_40_MD5", + "TLS_KRB5_EXPORT_WITH_RC2_CBC_40_SHA", + "TLS_KRB5_EXPORT_WITH_RC4_40_MD5", + "TLS_KRB5_EXPORT_WITH_RC4_40_SHA", + "TLS_KRB5_WITH_3DES_EDE_CBC_MD5", + "TLS_KRB5_WITH_3DES_EDE_CBC_SHA", + "TLS_KRB5_WITH_DES_CBC_MD5", + "TLS_KRB5_WITH_DES_CBC_SHA", + "TLS_KRB5_WITH_IDEA_CBC_MD5", + "TLS_KRB5_WITH_IDEA_CBC_SHA", + "TLS_KRB5_WITH_RC4_128_MD5", + "TLS_KRB5_WITH_RC4_128_SHA", + "TLS_NULL_WITH_NULL_NULL", + "TLS_PSK_WITH_3DES_EDE_CBC_SHA", + "TLS_PSK_WITH_AES_128_CBC_SHA", + "TLS_PSK_WITH_AES_128_CBC_SHA256", + "TLS_PSK_WITH_AES_128_CCM", + "TLS_PSK_WITH_AES_128_CCM_8", + "TLS_PSK_WITH_AES_128_GCM_SHA256", + "TLS_PSK_WITH_AES_256_CBC_SHA", + "TLS_PSK_WITH_AES_256_CBC_SHA384", + "TLS_PSK_WITH_AES_256_CCM", + "TLS_PSK_WITH_AES_256_CCM_8", + "TLS_PSK_WITH_AES_256_GCM_SHA384", + "TLS_PSK_WITH_ARIA_128_CBC_SHA256", + "TLS_PSK_WITH_ARIA_128_GCM_SHA256", + "TLS_PSK_WITH_ARIA_256_CBC_SHA384", + "TLS_PSK_WITH_ARIA_256_GCM_SHA384", + "TLS_PSK_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_PSK_WITH_CAMELLIA_128_GCM_SHA256", + "TLS_PSK_WITH_CAMELLIA_256_CBC_SHA384", + "TLS_PSK_WITH_CAMELLIA_256_GCM_SHA384", + "TLS_PSK_WITH_NULL_SHA", + "TLS_PSK_WITH_NULL_SHA256", + "TLS_PSK_WITH_NULL_SHA384", + "TLS_PSK_WITH_RC4_128_SHA", + "TLS_RSA_EXPORT_WITH_DES40_CBC_SHA", + "TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5", + "TLS_RSA_EXPORT_WITH_RC4_40_MD5", + "TLS_RSA_PSK_WITH_3DES_EDE_CBC_SHA", + "TLS_RSA_PSK_WITH_AES_128_CBC_SHA", + "TLS_RSA_PSK_WITH_AES_128_CBC_SHA256", + "TLS_RSA_PSK_WITH_AES_128_GCM_SHA256", + "TLS_RSA_PSK_WITH_AES_256_CBC_SHA", + "TLS_RSA_PSK_WITH_AES_256_CBC_SHA384", + "TLS_RSA_PSK_WITH_AES_256_GCM_SHA384", + "TLS_RSA_PSK_WITH_ARIA_128_CBC_SHA256", + "TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256", + "TLS_RSA_PSK_WITH_ARIA_256_CBC_SHA384", + "TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384", + "TLS_RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_RSA_PSK_WITH_CAMELLIA_128_GCM_SHA256", + "TLS_RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384", + "TLS_RSA_PSK_WITH_CAMELLIA_256_GCM_SHA384", + "TLS_RSA_PSK_WITH_NULL_SHA", + "TLS_RSA_PSK_WITH_NULL_SHA256", + "TLS_RSA_PSK_WITH_NULL_SHA384", + "TLS_RSA_PSK_WITH_RC4_128_SHA", + "TLS_RSA_WITH_3DES_EDE_CBC_SHA", + "TLS_RSA_WITH_AES_128_CBC_SHA", + "TLS_RSA_WITH_AES_128_CBC_SHA256", + "TLS_RSA_WITH_AES_128_CCM", + "TLS_RSA_WITH_AES_128_CCM_8", + "TLS_RSA_WITH_AES_128_GCM_SHA256", + "TLS_RSA_WITH_AES_256_CBC_SHA", + "TLS_RSA_WITH_AES_256_CBC_SHA256", + "TLS_RSA_WITH_AES_256_CCM", + "TLS_RSA_WITH_AES_256_CCM_8", + "TLS_RSA_WITH_AES_256_GCM_SHA384", + "TLS_RSA_WITH_ARIA_128_CBC_SHA256", + "TLS_RSA_WITH_ARIA_128_GCM_SHA256", + "TLS_RSA_WITH_ARIA_256_CBC_SHA384", + "TLS_RSA_WITH_ARIA_256_GCM_SHA384", + "TLS_RSA_WITH_CAMELLIA_128_CBC_SHA", + "TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256", + "TLS_RSA_WITH_CAMELLIA_128_GCM_SHA256", + "TLS_RSA_WITH_CAMELLIA_256_CBC_SHA", + "TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256", + "TLS_RSA_WITH_CAMELLIA_256_GCM_SHA384", + "TLS_RSA_WITH_DES_CBC_SHA", + "TLS_RSA_WITH_IDEA_CBC_SHA", + "TLS_RSA_WITH_NULL_MD5", + "TLS_RSA_WITH_NULL_SHA", + "TLS_RSA_WITH_NULL_SHA256", + "TLS_RSA_WITH_RC4_128_MD5", + "TLS_RSA_WITH_RC4_128_SHA", + "TLS_RSA_WITH_SEED_CBC_SHA", + "TLS_SRP_SHA_DSS_WITH_3DES_EDE_CBC_SHA", + "TLS_SRP_SHA_DSS_WITH_AES_128_CBC_SHA", + "TLS_SRP_SHA_DSS_WITH_AES_256_CBC_SHA", + "TLS_SRP_SHA_RSA_WITH_3DES_EDE_CBC_SHA", + "TLS_SRP_SHA_RSA_WITH_AES_128_CBC_SHA", + "TLS_SRP_SHA_RSA_WITH_AES_256_CBC_SHA", + "TLS_SRP_SHA_WITH_3DES_EDE_CBC_SHA", + "TLS_SRP_SHA_WITH_AES_128_CBC_SHA", + "TLS_SRP_SHA_WITH_AES_256_CBC_SHA"); - private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth, String[] applicationProtocols) { - this.context = context; - this.hardenDefaults = hardenDefaults; - this.clientAuth = clientAuth; - this.applicationProtocols = applicationProtocols; - } + /** + * RFC 9113 §9.2.2: an h2 endpoint MUST support this cipher suite. Not enforced (Flash cannot + * force a peer to offer it), but documented here as the fact {@link #applyTo}'s filtering relies + * on: filtering the blocklist above out of the JDK's default enabled set never removes this one, + * because it was never in the blocklist to begin with. + */ + static final String REQUIRED_H2_CIPHER_SUITE = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256"; - /** - * Builds an {@link SSLContext} from a PKCS12/JKS keystore — type is guessed from the file - * extension ({@code .jks} means JKS, anything else PKCS12). The private-key password is - * assumed equal to the store password, the common case for PKCS12. - */ - public static TlsConfig keystore(Path path, String password) { - try { - KeyStore store = KeyStore.getInstance(path.toString().endsWith(".jks") ? "JKS" : "PKCS12"); - try (InputStream in = Files.newInputStream(path)) { - store.load(in, password.toCharArray()); - } - KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(store, password.toCharArray()); + private final SSLContext context; + private final boolean hardenDefaults; + private final ClientAuth clientAuth; + private final String[] applicationProtocols; - KeyManager[] managers = kmf.getKeyManagers(); - for (int i = 0; i < managers.length; i++) { - if (managers[i] instanceof X509ExtendedKeyManager x509) { - managers[i] = new SniKeyManager(x509, store); - } - } + private TlsConfig( + SSLContext context, + boolean hardenDefaults, + ClientAuth clientAuth, + String[] applicationProtocols) { + this.context = context; + this.hardenDefaults = hardenDefaults; + this.clientAuth = clientAuth; + this.applicationProtocols = applicationProtocols; + } - SSLContext ctx = SSLContext.getInstance("TLS"); - ctx.init(managers, null, null); - return new TlsConfig(ctx, true, ClientAuth.NONE, null); - } catch (GeneralSecurityException | IOException e) { - throw new IllegalArgumentException("Failed to load TLS keystore: " + path, e); + /** + * Builds an {@link SSLContext} from a PKCS12/JKS keystore — type is guessed from the file + * extension ({@code .jks} means JKS, anything else PKCS12). The private-key password is assumed + * equal to the store password, the common case for PKCS12. + */ + public static TlsConfig keystore(Path path, String password) { + try { + KeyStore store = KeyStore.getInstance(path.toString().endsWith(".jks") ? "JKS" : "PKCS12"); + try (InputStream in = Files.newInputStream(path)) { + store.load(in, password.toCharArray()); + } + KeyManagerFactory kmf = + KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); + kmf.init(store, password.toCharArray()); + + KeyManager[] managers = kmf.getKeyManagers(); + for (int i = 0; i < managers.length; i++) { + if (managers[i] instanceof X509ExtendedKeyManager x509) { + managers[i] = new SniKeyManager(x509, store); } + } + + SSLContext ctx = SSLContext.getInstance("TLS"); + ctx.init(managers, null, null); + return new TlsConfig(ctx, true, ClientAuth.NONE, null); + } catch (GeneralSecurityException | IOException e) { + throw new IllegalArgumentException("Failed to load TLS keystore: " + path, e); } + } - /** - * Escape hatch — see class Javadoc. Flash applies nothing to the socket beyond what you - * explicitly call ({@link #clientAuth}/{@link #applicationProtocols}) on this instance. - */ - public static TlsConfig ofContext(SSLContext context) { - return new TlsConfig(context, false, ClientAuth.NONE, null); - } + /** + * Escape hatch — see class Javadoc. Flash applies nothing to the socket beyond what you + * explicitly call ({@link #clientAuth}/{@link #applicationProtocols}) on this instance. + */ + public static TlsConfig ofContext(SSLContext context) { + return new TlsConfig(context, false, ClientAuth.NONE, null); + } - /** Client-certificate requirement. Applies on either construction path — see class Javadoc. */ - public TlsConfig clientAuth(ClientAuth mode) { - return new TlsConfig(context, hardenDefaults, mode, applicationProtocols); - } + /** Client-certificate requirement. Applies on either construction path — see class Javadoc. */ + public TlsConfig clientAuth(ClientAuth mode) { + return new TlsConfig(context, hardenDefaults, mode, applicationProtocols); + } - /** - * ALPN protocols this listener negotiates, in preference order (e.g. - * {@code "acme-tls/1", "http/1.1"}). Applies on either construction path — see class Javadoc - * for how a custom {@code KeyManager} observes the negotiated value. - */ - public TlsConfig applicationProtocols(String... protocols) { - return new TlsConfig(context, hardenDefaults, clientAuth, protocols.clone()); - } + /** + * ALPN protocols this listener negotiates, in preference order (e.g. {@code "acme-tls/1", + * "http/1.1"}). Applies on either construction path — see class Javadoc for how a custom {@code + * KeyManager} observes the negotiated value. + */ + public TlsConfig applicationProtocols(String... protocols) { + return new TlsConfig(context, hardenDefaults, clientAuth, protocols.clone()); + } - // ── Consumed by HttpServer at bind time — not meant for direct use ────────── - - public SSLServerSocketFactory serverSocketFactory() { - return context.getServerSocketFactory(); - } - - public void applyTo(SSLServerSocket socket) { - if (hardenDefaults || applicationProtocols != null) { - SSLParameters params = socket.getSSLParameters(); - if (hardenDefaults) params.setProtocols(SECURE_PROTOCOLS); - if (applicationProtocols != null) params.setApplicationProtocols(applicationProtocols); - socket.setSSLParameters(params); + /** Returns this TLS configuration with HTTP/2 enabled and HTTP/1.1 retained as fallback. */ + public TlsConfig enableHttp2Alpn() { + List protocols = new ArrayList<>(); + if (applicationProtocols != null) { + for (String protocol : applicationProtocols) { + if (!"h2".equals(protocol) && !"http/1.1".equals(protocol)) { + protocols.add(protocol); } - if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true); - else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true); + } } + protocols.add("h2"); + protocols.add("http/1.1"); + return new TlsConfig(context, hardenDefaults, clientAuth, protocols.toArray(String[]::new)); + } + + // ── Consumed by HttpServer at bind time — not meant for direct use ────────── + + public SSLServerSocketFactory serverSocketFactory() { + return context.getServerSocketFactory(); + } + + public void applyTo(SSLServerSocket socket) { + if (hardenDefaults || applicationProtocols != null) { + SSLParameters params = socket.getSSLParameters(); + if (hardenDefaults) params.setProtocols(SECURE_PROTOCOLS); + if (applicationProtocols != null) params.setApplicationProtocols(applicationProtocols); + socket.setSSLParameters(params); + } + if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true); + else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true); + + // 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. + if (negotiatesH2()) { + String[] enabled = socket.getEnabledCipherSuites(); + List filtered = new ArrayList<>(enabled.length); + for (String suite : enabled) { + if (!TLS12_H2_BLOCKED_CIPHERS.contains(suite)) filtered.add(suite); + } + socket.setEnabledCipherSuites(filtered.toArray(new String[0])); + } + } + + /** + * Whether this listener's configured ALPN protocol list ({@link #applicationProtocols}) includes + * {@code "h2"}. Lets a caller (the connection runner, for logging; {@link #applyTo} duplicating + * the offered-protocols check. + */ + public boolean negotiatesH2() { + if (applicationProtocols == null) return false; + for (String protocol : applicationProtocols) { + if ("h2".equals(protocol)) return true; + } + return false; + } } diff --git a/flash/src/main/java/dev/relism/flash/transport/AcceptLoop.java b/flash/src/main/java/dev/relism/flash/transport/AcceptLoop.java new file mode 100644 index 0000000..42bea09 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/AcceptLoop.java @@ -0,0 +1,29 @@ +package dev.relism.flash.transport; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.function.BooleanSupplier; + +/** + * Single accept-loop body — runs on each of a listener's accept threads. All threads for the + * same listener block on the same {@link java.net.ServerSocket}; the JVM ensures only one wakes + * per incoming connection (no thundering herd). Other listeners' accept threads are entirely + * independent. + */ +@Slf4j +public final class AcceptLoop { + + private AcceptLoop() { + } + + public static void run(BoundListener listener, ConnectionRunner runner, BooleanSupplier stopped) { + while (!stopped.getAsBoolean()) { + try { + runner.accept(listener.socket().accept(), stopped); + } catch (IOException e) { + if (!stopped.getAsBoolean()) log.error("Accept error", e); + } + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/BoundListener.java b/flash/src/main/java/dev/relism/flash/transport/BoundListener.java new file mode 100644 index 0000000..6fd2fe6 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/BoundListener.java @@ -0,0 +1,7 @@ +package dev.relism.flash.transport; + +import java.net.ServerSocket; + +/** One bound listener socket (plain or TLS) plus whether it is TLS, for logging only. */ +public record BoundListener(ServerSocket socket, boolean secure) { +} diff --git a/flash/src/main/java/dev/relism/flash/transport/BufferedByteSource.java b/flash/src/main/java/dev/relism/flash/transport/BufferedByteSource.java new file mode 100644 index 0000000..a160c51 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/BufferedByteSource.java @@ -0,0 +1,277 @@ +package dev.relism.flash.transport; + +import java.io.IOException; +import java.io.InputStream; +import java.net.Socket; +import java.net.SocketTimeoutException; + +/** + * The single buffered view over one connection's inbound bytes, for the whole lifetime of the + * gives {@link dev.relism.flash.transport.ProtocolNegotiator} a way to inspect the first bytes + * of a plaintext connection (the h2c preface) without consuming them. + * + *

    Why this exists instead of {@link java.io.BufferedInputStream}

    + * A generic buffered stream would already fix the per-byte-syscall problem, but it cannot + * "un-consume" bytes without a fragile {@code mark()}/{@code reset()} dance, and it has no way + * to bound an individual read by an absolute wall-clock deadline (see below). This class is + * purpose-built for exactly the two things this connection loop needs beyond plain buffering: + * {@link #peek(byte[], int, int)} (look-ahead without consuming — used once, at connection + * start, for h2c prior-knowledge detection) and {@link #prependOnce(byte[], int, int)} + * (zero-allocation, zero-copy re-insertion of bytes the caller already read into its own + * buffer — used by {@code ChunkedInputStream} to hand back the header-parser's read-ahead + * bytes instead of the {@code SequenceInputStream}/{@code ByteArrayInputStream} wrapping this + * replaces). + * + *

    Deadline, not {@code SO_TIMEOUT} alone

    + * {@link Socket#setSoTimeout(int)} bounds a single {@code read()} call, not a sequence of them — + * a peer that trickles one byte every 9 seconds never trips a 10-second {@code SO_TIMEOUT}, since + * each individual read succeeds within the window. {@link #setDeadline(long)} instead records an + * 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 — + * deadline, do not rely on {@code setSoTimeout} alone." + * + *

    Thread-safety

    + * Not thread-safe, by design — exactly one virtual thread ever owns a connection's inbound + * bytes at a time (the same invariant {@code RequestParser} and {@code ChunkedInputStream} + * already assume). + */ +public final class BufferedByteSource extends InputStream { + + /** + * Default internal buffer size. Matches the relay-buffer convention already used elsewhere + * in this codebase (the 8 KB {@code STREAM_RELAY_BUFFER} in {@code HttpServer}) rather than + * introducing a new tuning constant nothing has calibrated yet. + */ + public static final int DEFAULT_BUFFER_SIZE = 8192; + + private final InputStream in; + private final Socket socket; + private final byte[] buf; + private int pos; + private int limit; + + // One-shot prepend window (prependOnce) — consumed before buf and before any underlying + // read. References the caller's own array; never copies it. + private byte[] prefixBuf; + private int prefixPos; + private int prefixLen; + + private boolean deadlineActive; + private long deadlineNanos; + + public BufferedByteSource(InputStream in, Socket socket) { + this(in, socket, DEFAULT_BUFFER_SIZE); + } + + public BufferedByteSource(InputStream in, Socket socket, int bufferSize) { + this.in = in; + this.socket = socket; + this.buf = new byte[bufferSize]; + } + + // ── Deadline ───────────────────────────────────────────────────────────── + + /** + * Every underlying socket read performed after this call is bounded so that it cannot + * still be blocking past {@code deadlineNanoTime} (an absolute value comparable to + * {@link System#nanoTime()}). A read that would exceed the deadline throws + * {@link SocketTimeoutException} instead of blocking further. Bytes already sitting in the + * internal buffer or the prepend window are served immediately regardless of the deadline — + * only reads that would otherwise block on the network are bounded. + */ + public void setDeadline(long deadlineNanoTime) { + this.deadlineActive = true; + this.deadlineNanos = deadlineNanoTime; + } + + /** + * Removes the deadline and restores the socket to blocking indefinitely + * ({@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). + * + * 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 + * bookkeeping is reset. Production always supplies a real socket, so this changes no + * production behavior; without it, no test can exercise the deadline mechanism at all. + */ + public void clearDeadline() throws IOException { + this.deadlineActive = false; + if (socket != null) socket.setSoTimeout(0); + } + + // ── InputStream ────────────────────────────────────────────────────────── + + @Override + public int read() throws IOException { + if (prefixLen > 0) { + prefixLen--; + return prefixBuf[prefixPos++] & 0xFF; + } + if (pos >= limit) { + int n = fillFromUnderlying(buf, 0, buf.length); + if (n <= 0) return -1; + pos = 0; + limit = n; + } + return buf[pos++] & 0xFF; + } + + @Override + public int read(byte[] dst, int off, int len) throws IOException { + if (len == 0) return 0; + if (prefixLen > 0) { + int n = Math.min(len, prefixLen); + System.arraycopy(prefixBuf, prefixPos, dst, off, n); + prefixPos += n; + prefixLen -= n; + return n; + } + if (pos < limit) { + int n = Math.min(len, limit - pos); + System.arraycopy(buf, pos, dst, off, n); + pos += n; + return n; + } + // Buffer empty. A large request (this is the path RequestParser's own bulk + // header-buffer fill takes) bypasses the internal buffer entirely — copying it through + // `buf` first would cost a full extra memcpy for no benefit, since the caller's own + // array is at least as large as what we would have buffered. + if (len >= buf.length) { + return fillFromUnderlying(dst, off, len); + } + int n = fillFromUnderlying(buf, 0, buf.length); + if (n <= 0) return n; + pos = 0; + limit = n; + int c = Math.min(len, limit); + System.arraycopy(buf, 0, dst, off, c); + pos = c; + return c; + } + + @Override + public long skip(long n) throws IOException { + if (n <= 0) return 0; + long remaining = n; + if (prefixLen > 0) { + int s = (int) Math.min(remaining, prefixLen); + prefixPos += s; + prefixLen -= s; + remaining -= s; + } + if (remaining > 0 && pos < limit) { + int s = (int) Math.min(remaining, limit - pos); + pos += s; + remaining -= s; + } + if (remaining > 0) { + remaining -= Math.max(0, in.skip(remaining)); + } + return n - remaining; + } + + @Override + public int available() { + return prefixLen + (limit - pos); + } + + @Override + public void close() throws IOException { + in.close(); + } + + // ── Peek and prepend — the two operations beyond InputStream's contract ──── + + /** + * Ensures up to {@code len} bytes are buffered and copies them into {@code dst} without + * advancing the read position — a subsequent {@code read()} still returns the same + * bytes. Blocks (bounded by the active deadline, if any) until {@code len} bytes are + * available or the underlying stream reaches EOF. Returns the number of bytes actually made + * available, which is less than {@code len} only at EOF. + * + *

    Only valid before anything has been {@link #prependOnce prepended} — in practice this + * means it is only ever called once, by {@code ProtocolNegotiator}, at the very start of a + * connection before any other read. + * + * @throws IllegalArgumentException if {@code len} exceeds the internal buffer's capacity — + * this class cannot peek further ahead than it buffers. + */ + public int peek(byte[] dst, int off, int len) throws IOException { + if (len > buf.length) { + throw new IllegalArgumentException( + "peek length " + len + " exceeds buffer capacity " + buf.length); + } + if (prefixLen > 0) { + throw new IllegalStateException( + "peek() is only valid before any bytes have been prepended to this source"); + } + while (limit - pos < len) { + if (pos > 0) { + System.arraycopy(buf, pos, buf, 0, limit - pos); + limit -= pos; + pos = 0; + } + int n = fillFromUnderlying(buf, limit, buf.length - limit); + if (n <= 0) break; + limit += n; + } + int available = Math.min(len, limit - pos); + System.arraycopy(buf, pos, dst, off, available); + return available; + } + + /** + * Queues {@code len} bytes, starting at {@code off} in the caller-owned array {@code src}, + * to be served by the next reads before anything else — zero allocation and zero + * copy, since {@code src} is referenced directly, not duplicated. The caller must not + * mutate {@code src[off..off+len)} until the prefix is fully consumed. + * + *

    Exactly one prefix may be pending at a time. This is intentional: it exists solely to + * hand {@code RequestParser}'s header-buffer read-ahead bytes to a fresh + * {@code ChunkedInputStream} at the start of a chunked body, a single well-defined moment + * per request — it is not a general-purpose pushback stack. + * + * @throws IllegalStateException if a prefix is already pending + */ + public void prependOnce(byte[] src, int off, int len) { + if (prefixLen > 0) { + throw new IllegalStateException("a prefix is already pending on this source"); + } + this.prefixBuf = src; + this.prefixPos = off; + this.prefixLen = len; + } + + // ── Internal fill ──────────────────────────────────────────────────────── + + /** + * The only place this class ever touches the underlying socket stream. When a deadline is + * active, computes the exact remaining budget and hands it to {@link Socket#setSoTimeout} + * before reading, so a {@link SocketTimeoutException} from {@code in.read} unambiguously + * means the deadline — not merely one read — has elapsed; see the class Javadoc. + * + * 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 + * {@code null}-socket source — is skipped when {@code socket == null}. See + * {@link #clearDeadline()}'s Javadoc for why {@code null} is a legitimate, tested case, not + * a misuse. + */ + private int fillFromUnderlying(byte[] dst, int off, int len) throws IOException { + if (!deadlineActive) { + return in.read(dst, off, len); + } + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos <= 0) { + throw new SocketTimeoutException("Read deadline exceeded"); + } + if (socket != null) { + long remainingMillis = (remainingNanos + 999_999L) / 1_000_000L; // round up + int timeoutMs = (int) Math.max(1, Math.min(Integer.MAX_VALUE, remainingMillis)); + socket.setSoTimeout(timeoutMs); + } + return in.read(dst, off, len); + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/ConnectionContext.java b/flash/src/main/java/dev/relism/flash/transport/ConnectionContext.java new file mode 100644 index 0000000..6d9fafc --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/ConnectionContext.java @@ -0,0 +1,45 @@ +package dev.relism.flash.transport; + +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.routing.AbstractRouter; +import dev.relism.flash.routing.AbstractWsRouter; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.util.concurrent.ExecutorService; +import java.util.function.BooleanSupplier; +import javax.net.ssl.SSLSocket; + +/** + * Everything a {@link ConnectionProtocol} implementation needs to serve one connection, bundled + * into a single object instead of a long parameter list. + * + * @param socket the accepted socket — owns its lifecycle (closing it is the caller's, i.e. {@link + * ConnectionRunner}'s, responsibility, not the protocol's) + * @param sslSocket {@code socket} narrowed to {@link SSLSocket}, or {@code null} for a plaintext + * connection + * @param in the single buffered, deadline-aware source for this connection's inbound bytes + * @param out the buffered output stream — for header/body writes that benefit from userspace + * coalescing before a single syscall + * @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 router the HTTP router + * @param wsRouter the WebSocket router + * @param configuration the server configuration (timeouts, limits, feature flags) + * @param stopped {@code true} once the server has begun shutting down — a protocol implementation's + * request loop must check this and exit promptly + */ +public record ConnectionContext( + Socket socket, + SSLSocket sslSocket, + BufferedByteSource in, + OutputStream out, + OutputStream rawOut, + InetSocketAddress remoteAddress, + ConnectionScratch scratch, + AbstractRouter router, + AbstractWsRouter wsRouter, + FlashConfiguration configuration, + ExecutorService executor, + BooleanSupplier stopped) {} diff --git a/flash/src/main/java/dev/relism/flash/transport/ConnectionProtocol.java b/flash/src/main/java/dev/relism/flash/transport/ConnectionProtocol.java new file mode 100644 index 0000000..1b6547d --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/ConnectionProtocol.java @@ -0,0 +1,13 @@ +package dev.relism.flash.transport; + +import java.io.IOException; + +/** + * ALPN/preface detection ({@link ConnectionRunner}), and dispatches to one implementation of + * this interface. After that point neither implementation knows the other exists. + */ +public interface ConnectionProtocol { + + /** Runs this connection to completion. Returns when the connection should be closed. */ + void run(ConnectionContext ctx) throws IOException; +} diff --git a/flash/src/main/java/dev/relism/flash/transport/ConnectionRunner.java b/flash/src/main/java/dev/relism/flash/transport/ConnectionRunner.java new file mode 100644 index 0000000..d1e24e5 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/ConnectionRunner.java @@ -0,0 +1,170 @@ +package dev.relism.flash.transport; + +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.routing.AbstractRouter; +import dev.relism.flash.routing.AbstractWsRouter; +import java.io.BufferedOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.net.SocketException; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.RejectedExecutionException; +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; +import javax.net.ssl.SSLSocket; +import lombok.extern.slf4j.Slf4j; + +/** + * Owns one connection's socket lifecycle from accept to close: configures socket options, + * dispatches to the matching {@link ConnectionProtocol} — guaranteeing cleanup (scratch release, + * active-socket tracking) regardless of how the protocol implementation exits. + * + *

    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 + */ +@Slf4j +public final class ConnectionRunner { + + private final ExecutorService executorService; + private final Set activeSockets; + private final ScratchPool scratchPool; + private final AbstractRouter router; + private final AbstractWsRouter wsRouter; + private final FlashConfiguration configuration; + private final ConnectionProtocol http1Protocol; + private final Supplier http2ProtocolFactory; + + public ConnectionRunner( + ExecutorService executorService, + Set activeSockets, + ScratchPool scratchPool, + AbstractRouter router, + AbstractWsRouter wsRouter, + FlashConfiguration configuration, + ConnectionProtocol http1Protocol, + Supplier http2ProtocolFactory) { + this.executorService = executorService; + this.activeSockets = activeSockets; + this.scratchPool = scratchPool; + this.router = router; + this.wsRouter = wsRouter; + this.configuration = configuration; + this.http1Protocol = http1Protocol; + this.http2ProtocolFactory = http2ProtocolFactory; + } + + /** + * Submits {@code socket} to the virtual-thread executor for full connection handling. {@code + * stopped} is threaded through to the eventual {@link ConnectionContext} so the protocol + * implementation can observe an in-progress graceful shutdown. + * + *

    Rejects before any per-connection state exists — no TLS handshake, no protocol + * negotiation, no HPACK tables — once {@code activeSockets} reaches {@link + * FlashConfiguration#getMaxConnections()}. This is an approximate check (accept runs on up to + * {@link TransportTuning#ACCEPT_THREADS} concurrent threads, so a burst can briefly land a few + * connections past the limit), not an atomic guarantee; it only needs to bound worst-case + * growth, not enforce an exact count. + */ + public void accept(Socket socket, BooleanSupplier stopped) { + int max = configuration.getMaxConnections(); + if (max > 0 && activeSockets.size() >= max) { + closeQuietly(socket); + return; + } + activeSockets.add(socket); + try { + executorService.submit(() -> handle(socket, stopped)); + } catch (RejectedExecutionException ignored) { + activeSockets.remove(socket); + closeQuietly(socket); + } + } + + private static void closeQuietly(Socket socket) { + try { + socket.close(); + } catch (IOException e) { + log.debug("Error closing socket on shutdown", e); + } + } + + private void handle(Socket socket, BooleanSupplier stopped) { + ConnectionScratch scratch = scratchPool.acquire(); + try (socket; + OutputStream out = new BufferedOutputStream(socket.getOutputStream())) { + + // TCP_NODELAY: disable Nagle's algorithm. Small WS frames (< MSS) are sent + // immediately rather than waiting up to 200 ms for more data to coalesce. + socket.setTcpNoDelay(true); + socket.setSendBufferSize(TransportTuning.SOCKET_BUF_SIZE); + + SSLSocket sslSocket = socket instanceof SSLSocket ssl ? ssl : null; + if (sslSocket != null) { + // protocol decision — SSLSocket#getApplicationProtocol() (which + // ProtocolNegotiator relies on) returns null until the handshake has run. + socket.setSoTimeout(configuration.getHeaderReadTimeoutMs()); + sslSocket.startHandshake(); + socket.setSoTimeout(0); // BufferedByteSource's own deadline takes over below + } + + // rawOut is the unbuffered socket stream — passed to WebSocketSession directly. + // WS writes are already bulk; HTTP responses use the buffered `out` because + // Http1ResponseWriter does several small writes that benefit from coalescing. + OutputStream rawOut = socket.getOutputStream(); + BufferedByteSource in = new BufferedByteSource(socket.getInputStream(), socket); + + NegotiatedProtocol negotiated = negotiateProtocol(socket, in); + ConnectionContext ctx = + new ConnectionContext( + socket, + sslSocket, + in, + out, + rawOut, + (InetSocketAddress) socket.getRemoteSocketAddress(), + scratch, + router, + wsRouter, + configuration, + executorService, + stopped); + if (negotiated == NegotiatedProtocol.HTTP_2) http2ProtocolFactory.get().run(ctx); + else http1Protocol.run(ctx); + + } catch (IOException e) { + if (!stopped.getAsBoolean()) { + if (e instanceof SocketException) log.debug("Connection closed: {}", e.getMessage()); + else log.error("I/O error handling request", e); + } + } catch (Exception e) { + // Anything not an IOException here means a collaborator misbehaved on the TLS + // handshake path — most likely a custom TlsConfig#ofContext KeyManager/TrustManager + // throwing. That failure is isolated to this one virtual thread/connection. + if (!stopped.getAsBoolean()) log.error("Unexpected error handling connection", e); + } finally { + activeSockets.remove(socket); + scratchPool.release(scratch); + } + } + + /** Decides h1 vs h2 while keeping the TLS and cleartext rollout gates independent. */ + private NegotiatedProtocol negotiateProtocol(Socket socket, BufferedByteSource in) + throws IOException { + if (socket instanceof SSLSocket) { + return ProtocolNegotiator.negotiate(socket, in); // ALPN — already resolved, no I/O + } + if (!configuration.isHttp2CleartextEnabled()) { + return NegotiatedProtocol.HTTP_1_1; // skip the h2c peek entirely when disabled + } + in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L); + try { + return ProtocolNegotiator.negotiate(socket, in); + } finally { + in.clearDeadline(); + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/ConnectionScratch.java b/flash/src/main/java/dev/relism/flash/transport/ConnectionScratch.java new file mode 100644 index 0000000..5113d61 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/ConnectionScratch.java @@ -0,0 +1,70 @@ +package dev.relism.flash.transport; + +import dev.relism.flash.bytes.ByteWriter; + +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; + +/** + * {@link ThreadLocal} on {@code HttpServer}: the decimal-formatting scratch, the streaming + * relay buffer, and the WebSocket-handshake {@link MessageDigest}. + * + *

    Why not {@code ThreadLocal}

    + * {@code ThreadLocal} is the right idiom for a bounded platform-thread pool, where "one per + * thread" means "one per core". Flash runs one virtual thread per connection + * ({@code Executors.newVirtualThreadPerTaskExecutor()}), so a {@code ThreadLocal} here means + * one per connection, not one per core — with no upper bound. At 100 000 concurrent + * connections, an 8 KB relay buffer alone is ~800 MB of memory that a bounded pool would + * instead cap. {@code ConnectionScratch} is therefore explicit and pooled ({@link ScratchPool}), + * not thread-local. + * + *

    Lifetime and thread-safety contract

    + * Allocated once per connection (or reused from {@link ScratchPool}), owned exclusively by the + * single virtual thread driving that connection for its whole lifetime, and returned to the + * pool when the connection closes. Never shared between two connections at once — there is no + * synchronization here because none is needed. + * + * class: {@code routing} has no dependency on {@code transport} today, and folding the router's + * 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. + */ +public final class ConnectionScratch { + + /** Matches the relay-buffer size the {@code ThreadLocal} it replaces used. */ + public static final int RELAY_BUFFER_SIZE = 8192; + + /** Initial capacity for {@link #responseHead}; grows on demand like any {@link ByteWriter}. */ + public static final int RESPONSE_HEAD_INITIAL_SIZE = 1024; + + /** Scratch for relaying a streaming or chunked response body without allocating per response. */ + public final byte[] relayBuffer = new byte[RELAY_BUFFER_SIZE]; + + /** + * (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. + */ + public final ByteWriter responseHead = new ByteWriter(RESPONSE_HEAD_INITIAL_SIZE); + + /** Scratch for the WebSocket handshake's {@code Sec-WebSocket-Accept} SHA-1 digest. */ + public final MessageDigest sha1; + + ConnectionScratch() { + try { + this.sha1 = MessageDigest.getInstance("SHA-1"); + } catch (NoSuchAlgorithmException e) { + // Every JDK ships SHA-1 — this is a broken-runtime condition, not a request-time one. + throw new IllegalStateException("SHA-1 MessageDigest unavailable", e); + } + } + + /** Called by {@link ScratchPool} before handing a reused instance to a new connection. */ + void reset() { + sha1.reset(); + responseHead.reset(); + // relayBuffer needs no clearing: every reader only ever reads back exactly the region + // the immediately preceding relay() call reports it filled, so stale bytes from a + // previous connection are never observed. + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/ListenerBinder.java b/flash/src/main/java/dev/relism/flash/transport/ListenerBinder.java new file mode 100644 index 0000000..5aed7ba --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/ListenerBinder.java @@ -0,0 +1,43 @@ +package dev.relism.flash.transport; + +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.tls.TlsConfig; + +import javax.net.ssl.SSLServerSocket; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ServerSocket; + +/** + * Turns a {@link FlashConfiguration.Listener} into a bound {@link ServerSocket}. Sole + * responsibility: binding — not accepting, not connection handling. + * + *

    A TLS listener gets its {@link ServerSocket} from {@link TlsConfig#serverSocketFactory()} + * instead of {@code new ServerSocket()}, and its protocol/client-auth/cipher parameters from + * {@link TlsConfig#applyTo}; reuse-address, receive buffer size, backlog and the bind call + * itself are identical either way. TLS only changes which bytes come out of {@code accept()}; + * it never changes how the accept loop, or anything downstream of it, treats them. + */ +public final class ListenerBinder { + + private ListenerBinder() { + } + + public static BoundListener bind(FlashConfiguration.Listener spec) throws IOException { + TlsConfig tls = spec.tls(); + + ServerSocket socket = tls != null ? tls.serverSocketFactory().createServerSocket() : new ServerSocket(); + // setReuseAddress(true) must be called BEFORE bind(). + socket.setReuseAddress(true); + socket.setReceiveBufferSize(TransportTuning.SOCKET_BUF_SIZE); + if (tls != null) tls.applyTo((SSLServerSocket) socket); + + InetSocketAddress addr = spec.host() != null + ? new InetSocketAddress(spec.host(), spec.port()) + : new InetSocketAddress(spec.port()); + socket.bind(addr, TransportTuning.ACCEPT_BACKLOG); + + return new BoundListener(socket, tls != null); + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/NegotiatedProtocol.java b/flash/src/main/java/dev/relism/flash/transport/NegotiatedProtocol.java new file mode 100644 index 0000000..00fd106 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/NegotiatedProtocol.java @@ -0,0 +1,9 @@ +package dev.relism.flash.transport; + +/** + * The result of {@link ProtocolNegotiator#negotiate}: which protocol a connection will speak, + */ +public enum NegotiatedProtocol { + HTTP_1_1, + HTTP_2 +} diff --git a/flash/src/main/java/dev/relism/flash/transport/ProtocolNegotiator.java b/flash/src/main/java/dev/relism/flash/transport/ProtocolNegotiator.java new file mode 100644 index 0000000..4db8c6d --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/ProtocolNegotiator.java @@ -0,0 +1,35 @@ +package dev.relism.flash.transport; + +import java.io.IOException; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import javax.net.ssl.SSLSocket; + +/** Detects HTTP/1.1 or HTTP/2 once, before the connection parser is selected. */ +public final class ProtocolNegotiator { + private static final byte[] H2C_PREFACE = + "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + + private ProtocolNegotiator() {} + + /** + * Uses the completed TLS ALPN result for secure sockets and a non-consuming prior-knowledge + * preface probe for plaintext sockets. Configuration gates remain the caller's responsibility, + * which keeps detection deterministic and independently testable. + */ + public static NegotiatedProtocol negotiate(Socket socket, BufferedByteSource source) + throws IOException { + if (socket instanceof SSLSocket ssl) { + return "h2".equals(ssl.getApplicationProtocol()) + ? NegotiatedProtocol.HTTP_2 + : NegotiatedProtocol.HTTP_1_1; + } + + byte[] probe = new byte[H2C_PREFACE.length]; + int read = source.peek(probe, 0, probe.length); + return read == H2C_PREFACE.length && Arrays.equals(probe, H2C_PREFACE) + ? NegotiatedProtocol.HTTP_2 + : NegotiatedProtocol.HTTP_1_1; + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/ScratchPool.java b/flash/src/main/java/dev/relism/flash/transport/ScratchPool.java new file mode 100644 index 0000000..df2ed27 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/ScratchPool.java @@ -0,0 +1,61 @@ +package dev.relism.flash.transport; + +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * A bounded cache of {@link ConnectionScratch} instances, reused across connections instead of + * being allocated and garbage-collected per connection. + * + *

    This is a cache, not a leak-free arena: a burst of 100 000 concurrent connections + * still allocates 100 000 {@link ConnectionScratch} instances (one per connection, since each + * connection needs its own for as long as it is open), but only {@link #bound} of them survive + * being released back to the pool afterward — the rest are simply dropped for the garbage + * collector, exactly as they would have been without this class. What the pool buys is avoiding + * repeated allocation for the common case of many short-lived or sequential connections sharing + * a bounded set of scratch objects. + * + *

    Thread-safety

    + * {@link #acquire()} and {@link #release} are safe to call concurrently from any number of + * threads — the underlying queue and size guard are lock-free. + */ +public final class ScratchPool { + + /** Default bound: generous enough that a real workload rarely misses, small enough that it + * is not itself a meaningful memory commitment (a few hundred KB at most). */ + public static final int DEFAULT_BOUND = Math.min(Runtime.getRuntime().availableProcessors() * 64, 4096); + + private final ConcurrentLinkedQueue pool = new ConcurrentLinkedQueue<>(); + private final AtomicInteger size = new AtomicInteger(); + private final int bound; + + public ScratchPool() { + this(DEFAULT_BOUND); + } + + public ScratchPool(int bound) { + this.bound = bound; + } + + /** Returns a reset, ready-to-use scratch — either reused from the pool or freshly allocated. */ + public ConnectionScratch acquire() { + ConnectionScratch scratch = pool.poll(); + if (scratch != null) { + size.decrementAndGet(); + scratch.reset(); + return scratch; + } + return new ConnectionScratch(); + } + + /** + * Returns {@code scratch} to the pool for reuse, unless the pool is already at its bound — + * in which case it is simply dropped, for the garbage collector, so an unusually large burst + * of connections cannot grow this cache without limit. + */ + public void release(ConnectionScratch scratch) { + if (size.get() >= bound) return; + size.incrementAndGet(); + pool.offer(scratch); + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/ServerLifecycle.java b/flash/src/main/java/dev/relism/flash/transport/ServerLifecycle.java new file mode 100644 index 0000000..d0536dc --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/ServerLifecycle.java @@ -0,0 +1,113 @@ +package dev.relism.flash.transport; + +import dev.relism.flash.ServerHandle; +import dev.relism.flash.extension.FlashConfiguration; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.net.Socket; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +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 + * {@code shutdownDrainTimeoutMs} (during which {@code Http1Connection} forces + * {@code Connection: close} on the next response once it observes {@link #isStopped()}), then + * force-close whatever remains. + * + *

    Implements {@link ServerHandle} directly — its three methods already match that contract + * exactly, so no separate wrapper class is needed. + */ +@Slf4j +public final class ServerLifecycle implements ServerHandle { + + private final List listeners; + private final ConnectionRunner runner; + private final FlashConfiguration configuration; + private final ExecutorService executorService; + private final Set activeSockets; + private final CountDownLatch acceptLatch; + private volatile boolean stopped = false; + + public ServerLifecycle(List listeners, ConnectionRunner runner, + FlashConfiguration configuration, ExecutorService executorService, + Set activeSockets) { + this.listeners = listeners; + this.runner = runner; + this.configuration = configuration; + this.executorService = executorService; + this.activeSockets = activeSockets; + this.acceptLatch = new CountDownLatch(TransportTuning.ACCEPT_THREADS * listeners.size()); + } + + /** Whether the server has begun shutting down. Passed down to every connection as a + * {@link java.util.function.BooleanSupplier} so in-flight request loops can drain + * promptly instead of waiting for their next keep-alive request. */ + public boolean isStopped() { + return stopped; + } + + @Override + public void start() { + for (int li = 0; li < listeners.size(); li++) { + BoundListener listener = listeners.get(li); + for (int i = 0; i < TransportTuning.ACCEPT_THREADS; i++) { + Thread.ofPlatform() + .name("flash-accept-" + li + "-" + i) + .daemon(false) + .start(() -> { + try { + AcceptLoop.run(listener, runner, this::isStopped); + } finally { + acceptLatch.countDown(); + } + }); + } + } + } + + @Override + public void startAndBlock() { + start(); + try { acceptLatch.await(); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); } + } + + @Override + public CompletableFuture stop() { + return CompletableFuture.runAsync(() -> { + stopped = true; + for (BoundListener bl : listeners) { + try { bl.socket().close(); } catch (IOException e) { log.error("Error closing server socket", e); } + } + + // 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; + while (!activeSockets.isEmpty() && System.nanoTime() < deadlineNanos) { + try { + Thread.sleep(50); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + + activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) { } }); + executorService.shutdown(); + try { + if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) + executorService.shutdownNow(); + } catch (InterruptedException e) { + executorService.shutdownNow(); + Thread.currentThread().interrupt(); + } + }); + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/TransportFactory.java b/flash/src/main/java/dev/relism/flash/transport/TransportFactory.java new file mode 100644 index 0000000..bfe8424 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/TransportFactory.java @@ -0,0 +1,83 @@ +package dev.relism.flash.transport; + +import dev.relism.flash.ServerHandle; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.http1.Http1Connection; +import dev.relism.flash.http2.Http2Connection; +import dev.relism.flash.routing.AbstractRouter; +import dev.relism.flash.routing.AbstractWsRouter; +import java.io.IOException; +import java.net.Socket; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import lombok.extern.slf4j.Slf4j; + +/** + * Composes the whole transport: binds every configured listener, wires the connection runner and + * the h1 protocol, and returns the {@link ServerHandle} implementation ({@link ServerLifecycle}) + * that {@link dev.relism.flash.ServerHandle#create} exposes publicly. + * + *

    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. + */ +@Slf4j +public final class TransportFactory { + + private TransportFactory() {} + + public static ServerHandle create( + FlashConfiguration configuration, AbstractRouter router, AbstractWsRouter wsRouter) + throws IOException { + List specs = + configuration.getListeners().isEmpty() + ? List.of( + new FlashConfiguration.Listener( + configuration.getPort(), configuration.getHost(), configuration.getTls())) + : configuration.getListeners(); + + List bound = new ArrayList<>(specs.size()); + for (FlashConfiguration.Listener original : specs) { + FlashConfiguration.Listener spec = original; + if (configuration.isHttp2Enabled() && original.tls() != null) { + spec = + new FlashConfiguration.Listener( + original.port(), original.host(), original.tls().enableHttp2Alpn()); + } + bound.add(ListenerBinder.bind(spec)); + } + List boundListeners = List.copyOf(bound); + + for (BoundListener bl : boundListeners) { + log.info( + "HTTP server bound on {}:{} (tls={}, backlog={}, acceptThreads={})", + bl.socket().getInetAddress(), + bl.socket().getLocalPort(), + bl.secure(), + TransportTuning.ACCEPT_BACKLOG, + TransportTuning.ACCEPT_THREADS); + } + + ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor(); + Set activeSockets = ConcurrentHashMap.newKeySet(); + ScratchPool scratchPool = new ScratchPool(); + + ConnectionRunner runner = + new ConnectionRunner( + executorService, + activeSockets, + scratchPool, + router, + wsRouter, + configuration, + new Http1Connection(), + Http2Connection::new); + + return new ServerLifecycle( + boundListeners, runner, configuration, executorService, activeSockets); + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/TransportLimits.java b/flash/src/main/java/dev/relism/flash/transport/TransportLimits.java new file mode 100644 index 0000000..b3fa5fa --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/TransportLimits.java @@ -0,0 +1,50 @@ +package dev.relism.flash.transport; + +/** + * Transport-level bound on concurrent connections, enforced by {@link ConnectionRunner} before + * any per-connection state (TLS handshake, protocol negotiation, HPACK tables, buffers) is set + * up. Unlike {@link dev.relism.flash.http2.Http2Limits} (bounds on what one already-admitted + * connection may do), this bounds how many connections are admitted at all — the guard a stress + * test found completely absent: {@code AcceptLoop} accepted unconditionally, so a connection + * flood ran the JVM out of heap rather than being turned away. + */ +public final class TransportLimits { + + private TransportLimits() {} + + /** + * Deliberately conservative estimate of one connection's worst-case retained heap (HPACK + * tables, stream table, in-flight response batches, up to {@code + * Http2Limits#MAX_CONCURRENT_STREAMS} concurrent streams), used only to size {@link + * #defaultMaxConnections()}'s auto-scaled budget — not an enforced per-connection cap. + * + *

    Not a precise per-byte accounting. A stress test on this codebase (h2load, 20 concurrent + * HTTP/2 streams per connection) observed {@code OutOfMemoryError} somewhere between 200 and + * 400 concurrent connections on a 1.5 GiB heap. This constant is chosen so {@link + * #defaultMaxConnections()} lands comfortably below that observed floor (~150 connections at + * 1.5 GiB) rather than hugging it. A heap-dump-derived precise figure is a natural follow-up; + * until then this trades some throughput headroom for a real safety margin. + */ + static final long ASSUMED_WORST_CASE_BYTES_PER_CONNECTION = 5L * 1024 * 1024; + + /** + * Fraction of the JVM's max heap set aside for connection-admission accounting; the rest is + * left for GC headroom, response buffers, and everything else the server needs. + */ + static final double HEAP_FRACTION_FOR_CONNECTIONS = 0.5; + + /** Floor so a tiny heap (dev/test containers) still gets a usable, non-degenerate limit. */ + static final int MIN_MAX_CONNECTIONS = 64; + + /** + * Auto-scaled default for {@code FlashConfiguration#getMaxConnections()}. Computed from {@link + * Runtime#maxMemory()} so the same default protects a 256 MiB container and an 8 GiB one + * without operator input; set {@code maxConnections} explicitly to override it, or to {@code 0} + * to disable the check (unlimited — the behavior every version before this had unconditionally). + */ + public static int defaultMaxConnections() { + long heapBudget = (long) (Runtime.getRuntime().maxMemory() * HEAP_FRACTION_FOR_CONNECTIONS); + long computed = heapBudget / ASSUMED_WORST_CASE_BYTES_PER_CONNECTION; + return (int) Math.max(MIN_MAX_CONNECTIONS, Math.min(Integer.MAX_VALUE, computed)); + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/TransportTuning.java b/flash/src/main/java/dev/relism/flash/transport/TransportTuning.java new file mode 100644 index 0000000..51e1d8a --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/TransportTuning.java @@ -0,0 +1,39 @@ +package dev.relism.flash.transport; + +/** Tuning constants shared by {@link ListenerBinder}, {@link ServerLifecycle}, and + * {@link ConnectionRunner} — grouped here so the accept-side and connection-side constants + * that must stay consistent with each other (e.g. the socket buffer size applied at bind time + * and at accept time) are declared exactly once. */ +final class TransportTuning { + + private TransportTuning() { + } + + /** + * Number of platform threads competing on {@code serverSocket.accept()}. + * Rule of thumb: number of available CPU cores, capped at 8. + * More than this rarely helps — accept is cheap; the bottleneck is usually + * the virtual-thread executor dispatching the connection handler. + */ + static final int ACCEPT_THREADS = Math.min(Runtime.getRuntime().availableProcessors(), 8); + + /** + * TCP listen backlog. The kernel holds up to this many fully-established + * (SYN+ACK sent, ACK received) connections waiting for accept(). + * 4096 is safe on Linux; /proc/sys/net/core/somaxconn must be >= this value, + * or the kernel silently caps it. Raise somaxconn if needed: + * sysctl -w net.core.somaxconn=4096 + */ + static final int ACCEPT_BACKLOG = 4096; + + /** + * Socket send/receive buffer sizes. Matched to the WS frame read buffer + * ({@code FlashConfiguration#getWsFrameBufferSize()}) so the kernel never + * needs to fragment a full frame into multiple TCP segments on the receive + * side, and never blocks a write waiting for the send buffer to drain. + * + * Linux default is 87380 bytes (rmem) / 16384 bytes (wmem). We raise both + * to 256 KB — a good fit for up to ~250 KB WS frames with no partial reads. + */ + static final int SOCKET_BUF_SIZE = 256 * 1024; +} diff --git a/flash/src/main/java/dev/relism/flash/websocket/WebSocketLoop.java b/flash/src/main/java/dev/relism/flash/websocket/WebSocketLoop.java new file mode 100644 index 0000000..df388b6 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/websocket/WebSocketLoop.java @@ -0,0 +1,46 @@ +package dev.relism.flash.websocket; + +import java.io.IOException; + +/** + * Drives one {@link WebSocketSession}'s read loop until the session closes. The handshake and + * upgrade detection live in {@link WebSocketUpgrade}. + */ +public final class WebSocketLoop { + + private WebSocketLoop() {} + + public static void run(WebSocketSession session, WebSocketHandler handler) { + WebSocketFrame frame = new WebSocketFrame(); + try { + handler.onOpen(session); + while (session.isOpen()) { + if (!session.readFrame(frame)) break; + switch (frame.opcode()) { + case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY -> + handler.onMessage(session, frame); + case WebSocketFrame.OP_CLOSE -> session.closeFromPeer(frame); + case WebSocketFrame.OP_PING -> session.sendPong(frame); + case WebSocketFrame.OP_PONG -> { + // Heartbeat acknowledgement; no action is required. + } + } + } + } catch (WebSocketProtocolException failure) { + try { + session.close(failure.closeCode()); + } catch (IOException ignored) { + // The peer may already have closed the transport. + } + handler.onError(session, failure); + } catch (IOException | RuntimeException failure) { + handler.onError(session, failure); + } finally { + try { + handler.onClose(session, session.closeCode()); + } finally { + session.forceClose(); + } + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/websocket/WebSocketProtocolException.java b/flash/src/main/java/dev/relism/flash/websocket/WebSocketProtocolException.java new file mode 100644 index 0000000..0ce014b --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/websocket/WebSocketProtocolException.java @@ -0,0 +1,23 @@ +package dev.relism.flash.websocket; + +import java.io.IOException; + +/** + * A WebSocket frame violated RFC 6455 (bad opcode, wrong masking direction, oversized control + * frame, fragmented control frame, or a message exceeding the session's buffer). Carries the + * close code ({@code 1002} protocol error, {@code 1009} message too big) the session must send + * before closing — see {@code WebSocketLoop}, the single site that catches this. + */ +public final class WebSocketProtocolException extends IOException { + + private final int closeCode; + + public WebSocketProtocolException(int closeCode, String message) { + super(message); + this.closeCode = closeCode; + } + + public int closeCode() { + return closeCode; + } +} diff --git a/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java b/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java index 9e47442..dcf663d 100644 --- a/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java +++ b/flash/src/main/java/dev/relism/flash/websocket/WebSocketSession.java @@ -10,13 +10,14 @@ import java.io.InputStream; import java.io.OutputStream; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.ReentrantLock; /** * Per-connection WebSocket I/O state. One instance per virtual thread. * *

      *
    • - *

      With {@code TCP_NODELAY} enabled on the socket (set in {@code HttpServer}), + *

      With {@code TCP_NODELAY} enabled on the socket (set by the connection runner), * Nagle's algorithm is disabled: the kernel sends data as soon as it lands in * the send buffer, without waiting. {@link java.io.BufferedOutputStream} will * still batch multiple small writes into one syscall when they happen in the @@ -26,7 +27,7 @@ import java.util.concurrent.atomic.AtomicBoolean; *

      The only place an explicit flush is still needed is after the WS * handshake (one-time, not on the hot path) and after the CLOSE frame * (end of session). Both are handled in {@link #close} and in - * {@code HttpServer#performHandshake}.

    • + * {@code WebSocketUpgrade#performHandshake}. * *
    • Flush on CLOSE frame: {@link #close} still flushes explicitly * because the CLOSE frame is the last thing written before the stream is @@ -34,10 +35,22 @@ import java.util.concurrent.atomic.AtomicBoolean; *
    * *

    Thread safety

    - * {@link #sendText}, {@link #send}, and {@link #close} are synchronized on - * {@code out} and safe to call from threads other than the session loop. - * {@link #close} uses a CAS on {@code open} to guarantee exactly-once CLOSE + * {@link #sendText}, {@link #send}, and {@link #close} are serialized on a + * 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 + * session loop. {@link #close} uses a CAS on {@code open} to guarantee exactly-once CLOSE * frame emission under concurrent calls. + * + *

    Fragmentation, masking, and control frames (RFC 6455 §5)

    + * {@link #readFrame} reassembles continuation frames into one logical message (bounded by the + * read buffer's capacity — the same bound a single unfragmented frame already had), enforces + * that incoming frames are masked exactly when this session's role requires it (server sessions + * require masked frames from the client; client-mode sessions require unmasked frames from the + * server), validates the opcode against RFC 6455's defined set, and enforces the control-frame + * constraints (FIN must be set, payload ≤ 125 bytes). A violation throws + * {@link WebSocketProtocolException} carrying the correct close code (1002 protocol error, 1009 + * message too big) for the caller to send before closing. */ public final class WebSocketSession { @@ -47,12 +60,27 @@ public final class WebSocketSession { private final Request request; private final boolean maskOutgoing; + /** Server sessions (the common case) require every incoming frame to be masked, per RFC + * 6455 §5.1 ("a server MUST close the connection upon receiving a frame that is not + * masked"). A client-mode session ({@link #maskOutgoing} true) requires the opposite. */ + private final boolean requireMaskedIncoming; + private final AtomicBoolean open = new AtomicBoolean(true); private int closeCode = 1000; + private final ReentrantLock writeLock = new ReentrantLock(); /** 1 opcode byte + up to 8 extended-length bytes + up to 4 mask-key bytes (masked mode only). */ private final byte[] hdrScratch = new byte[14]; + /** Scratch for control-frame payloads (RFC 6455 §5.5: at most 125 bytes), kept separate + * from {@link #readBuf} so a control frame arriving mid-fragmentation (RFC 6455 §5.4 + * permits this) never disturbs the data message being reassembled there. */ + private final byte[] controlBuf = new byte[125]; + + // Fragmentation state (RFC 6455 §5.4). fragmentLength == 0 means "no message in progress". + private byte fragmentOpcode; + private int fragmentLength; + public WebSocketSession(InputStream in, OutputStream out, int bufferSize) { this(in, out, bufferSize, null, false); } @@ -64,14 +92,17 @@ public final class WebSocketSession { * @param maskOutgoing {@code true} if this session is acting as a WS client — RFC 6455 * requires client-to-server frames to be masked, unlike the server-to-client * direction {@link #writeFrame} originally only supported. See {@link - * #writeFrame} for how masking is applied without allocating. + * #writeFrame} for how masking is applied without allocating. Also + * determines the expected masking of *incoming* frames — see + * {@link #requireMaskedIncoming}. */ public WebSocketSession(InputStream in, OutputStream out, int bufferSize, Request request, boolean maskOutgoing) { - this.in = in; - this.out = out; - this.readBuf = new byte[bufferSize]; - this.request = request; - this.maskOutgoing = maskOutgoing; + this.in = in; + this.out = out; + this.readBuf = new byte[bufferSize]; + this.request = request; + this.maskOutgoing = maskOutgoing; + this.requireMaskedIncoming = !maskOutgoing; } public boolean isOpen() { return open.get(); } @@ -109,50 +140,128 @@ public final class WebSocketSession { */ public void close(int code) throws IOException { if (!open.compareAndSet(true, false)) return; - synchronized (out) { + writeLock.lock(); + try { out.write(0x88); out.write(0x02); out.write((code >> 8) & 0xFF); out.write(code & 0xFF); + } finally { + writeLock.unlock(); } } // ── Session loop internals ───────────────────────────────────────────── + /** + * Reads the next complete message, reassembling continuation frames and delivering control + * frames (CLOSE/PING/PONG) as soon as they arrive — RFC 6455 §5.4 explicitly permits a + * control frame to interleave with a fragmented data message, and this must not disturb the + * data message's in-progress reassembly. + * + * @return {@code false} only on a clean EOF between messages (the peer closed the TCP + * connection without sending a CLOSE frame); an EOF in the middle of a frame is a + * protocol violation and throws, it is not reported as {@code false}. + * @throws WebSocketProtocolException on any RFC 6455 violation (bad opcode, unmasked/masked + * frame when the opposite was required, oversized control frame, fragmented control + * frame, message exceeding the buffer) — carries the correct close code. + */ public boolean readFrame(WebSocketFrame frame) throws IOException { - int b0 = in.read(); - if (b0 < 0) return false; - int b1 = in.read(); - if (b1 < 0) return false; + while (true) { + int b0 = in.read(); + if (b0 < 0) return false; // clean EOF between messages + int b1 = in.read(); + if (b1 < 0) throw new EOFException("WebSocket stream closed mid-frame"); - boolean fin = (b0 & 0x80) != 0; - byte opcode = (byte) (b0 & 0x0F); - boolean masked = (b1 & 0x80) != 0; - long payLen = (b1 & 0x7F); + boolean fin = (b0 & 0x80) != 0; + byte opcode = (byte) (b0 & 0x0F); + boolean masked = (b1 & 0x80) != 0; + int lenBits = b1 & 0x7F; - if (payLen == 126) { - payLen = ((in.read() & 0xFF) << 8) | (in.read() & 0xFF); - } else if (payLen == 127) { - payLen = 0; - for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (in.read() & 0xFF); + validateOpcode(opcode); + + if (masked != requireMaskedIncoming) { + throw new WebSocketProtocolException(1002, + requireMaskedIncoming ? "client frame must be masked" : "server frame must not be masked"); + } + + int extLenBytes = lenBits == 127 ? 8 : lenBits == 126 ? 2 : 0; + int maskBytes = masked ? 4 : 0; + int extraLen = extLenBytes + maskBytes; + if (extraLen > 0) readFullyHeader(extraLen); + + long payLen; + int pos; + if (extLenBytes == 2) { + payLen = ((hdrScratch[0] & 0xFFL) << 8) | (hdrScratch[1] & 0xFFL); + pos = 2; + } else if (extLenBytes == 8) { + // RFC 6455 §5.2: the most significant bit of the 64-bit length MUST be 0. + if ((hdrScratch[0] & 0x80) != 0) { + throw new WebSocketProtocolException(1002, "extended payload length MSB must be 0"); + } + payLen = 0; + for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (hdrScratch[i] & 0xFFL); + pos = 8; + } else { + payLen = lenBits; + pos = 0; + } + + boolean isControl = opcode == WebSocketFrame.OP_CLOSE + || opcode == WebSocketFrame.OP_PING || opcode == WebSocketFrame.OP_PONG; + + if (isControl) { + if (!fin) throw new WebSocketProtocolException(1002, "control frame must not be fragmented"); + if (payLen > controlBuf.length) throw new WebSocketProtocolException(1002, "control frame payload exceeds 125 bytes"); + } else if (opcode == WebSocketFrame.OP_CONTINUATION) { + if (fragmentLength == 0) throw new WebSocketProtocolException(1002, "continuation frame without an initiated message"); + } else { // TEXT or BINARY + if (fragmentLength != 0) throw new WebSocketProtocolException(1002, "new data frame while a fragmented message is in progress"); + } + + byte m0 = 0, m1 = 0, m2 = 0, m3 = 0; + if (masked) { + m0 = hdrScratch[pos]; m1 = hdrScratch[pos + 1]; m2 = hdrScratch[pos + 2]; m3 = hdrScratch[pos + 3]; + } + + int len = (int) payLen; + + if (isControl) { + readFully(controlBuf, 0, len); + if (masked) unmaskInPlace(controlBuf, 0, len, m0, m1, m2, m3); + frame.reset(controlBuf, 0, len, opcode, true); + return true; + } + + // Data frame (fresh TEXT/BINARY, or a CONTINUATION of one already in progress): + // accumulate into readBuf, bounded by its capacity — the same bound a single + // unfragmented frame already had before this fix. + if (fragmentLength + (long) len > readBuf.length) { + throw new WebSocketProtocolException(1009, "message exceeds " + readBuf.length + " bytes"); + } + readFully(readBuf, fragmentLength, len); + if (masked) unmaskInPlace(readBuf, fragmentLength, len, m0, m1, m2, m3); + + byte messageOpcode = opcode == WebSocketFrame.OP_CONTINUATION ? fragmentOpcode : opcode; + if (opcode != WebSocketFrame.OP_CONTINUATION) fragmentOpcode = opcode; + fragmentLength += len; + + if (fin) { + frame.reset(readBuf, 0, fragmentLength, messageOpcode, true); + fragmentLength = 0; + return true; + } + // Not FIN: loop to read the next continuation frame (or an interleaved control frame). } + } - if (payLen > readBuf.length) throw new IOException( - "WS frame payload " + payLen + " bytes exceeds buffer " + readBuf.length); - - byte m0 = 0, m1 = 0, m2 = 0, m3 = 0; - if (masked) { - m0 = (byte) in.read(); m1 = (byte) in.read(); - m2 = (byte) in.read(); m3 = (byte) in.read(); + private static void validateOpcode(byte opcode) throws WebSocketProtocolException { + switch (opcode) { + case WebSocketFrame.OP_CONTINUATION, WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY, + WebSocketFrame.OP_CLOSE, WebSocketFrame.OP_PING, WebSocketFrame.OP_PONG -> { /* valid */ } + default -> throw new WebSocketProtocolException(1002, "reserved/invalid opcode " + opcode); } - - int len = (int) payLen; - readFully(readBuf, 0, len); - - if (masked) unmaskInPlace(readBuf, 0, len, m0, m1, m2, m3); - - frame.reset(readBuf, 0, len, opcode, fin); - return true; } public void sendPong(WebSocketFrame ping) throws IOException { @@ -187,13 +296,12 @@ public final class WebSocketSession { * extended-length + up to 4 mask-key), then writes header + payload in two bulk calls to the * raw socket stream. * - *

    No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream} - * (see {@code HttpServer#process}). Each {@code write()} lands directly in the - * kernel send buffer. With {@code TCP_NODELAY} set on the socket, the kernel - * transmits the segment immediately without Nagle coalescing. The two writes - * (header then payload) will be merged into a single TCP segment by the kernel - * because they arrive faster than the ACK from the peer — exactly the coalescing - * we want, at zero cost. + *

    No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}. + * Each {@code write()} lands directly in the kernel send buffer. With {@code TCP_NODELAY} + * set on the socket, the kernel transmits the segment immediately without Nagle coalescing. + * The two writes (header then payload) will be merged into a single TCP segment by the + * kernel because they arrive faster than the ACK from the peer — exactly the coalescing we + * want, at zero cost. * *

    {@link #maskOutgoing} (client mode): RFC 6455 requires every client-to-server frame * to be masked. The mask key is generated into {@link #hdrScratch} (no new allocation — same @@ -204,7 +312,8 @@ public final class WebSocketSession { * masked mode must not reuse that buffer expecting it unchanged after the call. */ private void writeFrame(byte opcode, byte[] payload, int off, int len) throws IOException { - synchronized (out) { + writeLock.lock(); + try { int hlen = 0; hdrScratch[hlen++] = (byte) (0x80 | opcode); int maskBit = maskOutgoing ? 0x80 : 0x00; @@ -237,6 +346,18 @@ public final class WebSocketSession { out.write(hdrScratch, 0, hlen); out.write(payload, off, len); // No flush — TCP_NODELAY handles delivery. See Javadoc above. + } finally { + writeLock.unlock(); + } + } + + /** 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) { + int n = in.read(hdrScratch, len - remaining, remaining); + if (n < 0) throw new EOFException("WebSocket stream closed mid-frame"); + remaining -= n; } } @@ -265,4 +386,4 @@ public final class WebSocketSession { if (i < end) { buf[i++] ^= m1; } if (i < end) { buf[i] ^= m2; } } -} \ No newline at end of file +} diff --git a/flash/src/main/java/dev/relism/flash/websocket/WebSocketUpgrade.java b/flash/src/main/java/dev/relism/flash/websocket/WebSocketUpgrade.java new file mode 100644 index 0000000..beb38ac --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/websocket/WebSocketUpgrade.java @@ -0,0 +1,69 @@ +package dev.relism.flash.websocket; + +import dev.relism.flash.http1.Http1KeepAlive; +import dev.relism.flash.models.Request; +import dev.relism.flash.transport.ConnectionScratch; +import dev.relism.fpr.core.ByteView; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Base64; + +/** + * WebSocket upgrade detection (RFC 6455 §4.2.1) and handshake response. Extracted from + * upgrade request and, if so, answering the {@code 101 Switching Protocols} handshake. The + * session loop itself lives in {@link WebSocketLoop}. + */ +public final class WebSocketUpgrade { + + private WebSocketUpgrade() { + } + + private static final byte[] WS_HANDSHAKE_PREFIX = + ("HTTP/1.1 101 Switching Protocols\r\n" + + "Upgrade: websocket\r\n" + + "Connection: Upgrade\r\n" + + "Sec-WebSocket-Accept: ") + .getBytes(StandardCharsets.ISO_8859_1); + private static final byte[] WS_HANDSHAKE_SUFFIX = + "\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1); + + public static final byte[] REJECT_400 = + "HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + .getBytes(StandardCharsets.ISO_8859_1); + + private static final byte[] WS_GUID_BYTES = + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1); + + /** + * Whether {@code request} is a WebSocket upgrade request: {@code Upgrade: websocket} and a + * shared token-list scanner in {@link Http1KeepAlive} is what fixed the whole-value compare + * bug this check used to have too). + */ + public static boolean isWebSocketUpgrade(Request request) { + ByteView upgrade = request.getRequestLine().getHeaders().view("Upgrade"); + if (upgrade == null) return false; + if (!Http1KeepAlive.tokenEqualsIgnoreCase(upgrade, 0, upgrade.length(), "websocket")) return false; + return Http1KeepAlive.connectionContainsToken(request, "upgrade"); + } + + /** Writes and flushes the {@code 101 Switching Protocols} handshake response. */ + public static void performHandshake(OutputStream out, Request request, ConnectionScratch scratch) throws IOException { + ByteView keyView = request.getRequestLine().getHeaders().view("Sec-WebSocket-Key"); + if (keyView == null) throw new IOException("Missing Sec-WebSocket-Key header"); + + MessageDigest sha1 = scratch.sha1; + sha1.reset(); + for (int i = 0, len = keyView.length(); i < len; i++) sha1.update(keyView.byteAt(i)); + sha1.update(WS_GUID_BYTES); + + byte[] accept = Base64.getEncoder().encode(sha1.digest()); + + out.write(WS_HANDSHAKE_PREFIX); + out.write(accept); + out.write(WS_HANDSHAKE_SUFFIX); + out.flush(); + } +} diff --git a/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java b/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java index 632e4ae..206312a 100644 --- a/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java +++ b/flash/src/test/java/dev/relism/flash/ChunkedInputStreamTest.java @@ -1,5 +1,8 @@ package dev.relism.flash; +import dev.relism.flash.exceptions.MalformedRequestException; +import dev.relism.flash.http.Http1Limits; +import dev.relism.flash.transport.BufferedByteSource; import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; @@ -10,9 +13,15 @@ import static org.junit.jupiter.api.Assertions.*; class ChunkedInputStreamTest { + // BufferedByteSource's Socket reference is only touched when a deadline is set — none of + // these tests set one, so `null` is safe here. + private static BufferedByteSource source(byte[] bytes) { + return new BufferedByteSource(new ByteArrayInputStream(bytes), null); + } + private static ChunkedInputStream wrap(String chunkedEncoded) { byte[] bytes = chunkedEncoded.getBytes(StandardCharsets.UTF_8); - return new ChunkedInputStream(new ByteArrayInputStream(bytes), null, 0, 0); + return new ChunkedInputStream(source(bytes), null, 0, 0); } private static String readAll(ChunkedInputStream in) throws IOException { @@ -59,7 +68,7 @@ class ChunkedInputStreamTest { @Test void trailers_consumed() throws IOException { // trailing headers after 0-chunk must be consumed - assertEquals("hi", readAll(wrap("2\r\nhi\r\n0\r\nTrailer: value\r\n\r\n"))); + assertEquals("hi", readAll(wrap("2\r\nhi\r\n0\r\nX-Trailer: value\r\n\r\n"))); } // --- byte-by-byte read --- @@ -105,7 +114,7 @@ class ChunkedInputStreamTest { // "5\r\nhello" in preBuf, "\r\n0\r\n\r\n" in socket byte[] preBuf = "5\r\nhello".getBytes(StandardCharsets.UTF_8); byte[] socket = "\r\n0\r\n\r\n".getBytes(StandardCharsets.UTF_8); - ChunkedInputStream in = new ChunkedInputStream(new ByteArrayInputStream(socket), preBuf, 0, preBuf.length); + ChunkedInputStream in = new ChunkedInputStream(source(socket), preBuf, 0, preBuf.length); assertEquals("hello", new String(in.readAllBytes(), StandardCharsets.UTF_8)); } @@ -114,7 +123,101 @@ class ChunkedInputStreamTest { byte[] preBuf = "XX2\r\nhi\r\n0\r\n\r\n".getBytes(StandardCharsets.UTF_8); // offset=2, len=preBuf.length-2 — skip "XX" ChunkedInputStream in = new ChunkedInputStream( - new ByteArrayInputStream(new byte[0]), preBuf, 2, preBuf.length - 2); + source(new byte[0]), preBuf, 2, preBuf.length - 2); assertEquals("hi", new String(in.readAllBytes(), StandardCharsets.UTF_8)); } + + + /** Counts every {@code read} call that reaches the wrapped stream — i.e. every syscall. */ + private static final class CountingInputStream extends ByteArrayInputStream { + int reads = 0; + CountingInputStream(byte[] buf) { super(buf); } + @Override public synchronized int read() { reads++; return super.read(); } + @Override public synchronized int read(byte[] b, int off, int len) { reads++; return super.read(b, off, len); } + } + + @Test + void byteByByteRead_doesNotSyscallPerByte() throws IOException { + // 100 one-byte chunks — the pre-fix implementation would have issued one read() call + // per payload byte PLUS one per chunk-size digit PLUS two per chunk terminator PLUS + // two for the final trailer-section terminator: hundreds of underlying reads for 100 + // bytes of payload. Buffered, this must collapse to a small, buffer-size-bound count. + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < 100; i++) sb.append("1\r\nx\r\n"); + sb.append("0\r\n\r\n"); + CountingInputStream counting = new CountingInputStream(sb.toString().getBytes(StandardCharsets.UTF_8)); + BufferedByteSource src = new BufferedByteSource(counting, null); + ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0); + + int total = 0; + while (in.read() != -1) total++; + + assertEquals(100, total); + // The whole message (700 bytes) fits in BufferedByteSource's default 8 KB buffer, so + // this must be exactly one underlying read — nowhere near "one per byte". + assertEquals(1, counting.reads); + } + + + @Test + void chunkSizeAboveLimit_rejected() { + // MAX_CHUNK_SIZE is 16 MiB (0x1000000); one hex digit past that overflows the bound. + BufferedByteSource src = source("10000000\r\n".getBytes(StandardCharsets.UTF_8)); + ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0); + assertThrows(MalformedRequestException.class, in::read); + } + + @Test + void tooManyHexDigits_rejected() { + BufferedByteSource src = source("00000000000000001\r\n".getBytes(StandardCharsets.UTF_8)); // 17 digits + ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0); + assertThrows(MalformedRequestException.class, in::read); + } + + @Test + void chunkExtensionTooLong_rejected() { + String ext = ";" + "a".repeat(300); + BufferedByteSource src = source(("5" + ext + "\r\nhello\r\n0\r\n\r\n").getBytes(StandardCharsets.UTF_8)); + ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0); + assertThrows(MalformedRequestException.class, in::read); + } + + @Test + void malformedChunkSize_rejected() { + BufferedByteSource src = source(";novalue\r\n".getBytes(StandardCharsets.UTF_8)); + ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0); + assertThrows(MalformedRequestException.class, in::read); + } + + @Test + void bareChunkTerminator_rejected() { + // Declares 5 bytes but the terminator after them is not CRLF. + BufferedByteSource src = source("5\r\nhelloXX0\r\n\r\n".getBytes(StandardCharsets.UTF_8)); + ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0); + assertThrows(MalformedRequestException.class, () -> in.readAllBytes()); + } + + @Test + void tooManyChunks_rejected413() { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < Http1Limits.MAX_CHUNKS_PER_BODY + 5; i++) sb.append("1\r\nx\r\n"); + sb.append("0\r\n\r\n"); + BufferedByteSource src = source(sb.toString().getBytes(StandardCharsets.UTF_8)); + ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0); + MalformedRequestException e = assertThrows(MalformedRequestException.class, () -> { + while (in.read() != -1) { /* drain */ } + }); + assertEquals(413, e.status()); + } + + @Test + void tooManyTrailers_rejected431() { + StringBuilder sb = new StringBuilder("2\r\nhi\r\n0\r\n"); + for (int i = 0; i < Http1Limits.MAX_TRAILER_COUNT + 5; i++) sb.append("X-").append(i).append(": v\r\n"); + sb.append("\r\n"); + BufferedByteSource src = source(sb.toString().getBytes(StandardCharsets.UTF_8)); + ChunkedInputStream in = new ChunkedInputStream(src, null, 0, 0); + MalformedRequestException e = assertThrows(MalformedRequestException.class, in::readAllBytes); + assertEquals(431, e.status()); + } } diff --git a/flash/src/test/java/dev/relism/flash/Http1TrailersTest.java b/flash/src/test/java/dev/relism/flash/Http1TrailersTest.java new file mode 100644 index 0000000..b06e493 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/Http1TrailersTest.java @@ -0,0 +1,68 @@ +package dev.relism.flash; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.http1.Http1ResponseWriter; +import dev.relism.flash.models.Request; +import dev.relism.flash.models.Response; +import dev.relism.flash.transport.BufferedByteSource; +import dev.relism.flash.transport.ConnectionScratch; +import dev.relism.flash.transport.ScratchPool; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class Http1TrailersTest { + @Test + void requestTrailersBecomeVisibleOnlyAfterBodyEof() throws Exception { + byte[] wire = ("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n" + + "3\r\nabc\r\n0\r\nGrpc-Status: 0\r\nX-Trace: done\r\n\r\n") + .getBytes(StandardCharsets.US_ASCII); + Request request = new RequestParser().parse( + new BufferedByteSource(new ByteArrayInputStream(wire), null)); + + assertThrows(IllegalStateException.class, request::trailers); + assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII)); + assertEquals("0", request.trailers().first("grpc-status")); + assertEquals("done", request.trailers().first("x-trace")); + } + + @Test + void responseTrailersUseChunkedRendering() throws Exception { + Response response = new Response(200, "hello", ContentType.TEXT_PLAIN) + .trailer("grpc-status", "0"); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + + Http1ResponseWriter.writeResponse( + output, response, HttpMethod.GET, true, false, new ScratchPool().acquire()); + + String wire = output.toString(StandardCharsets.US_ASCII); + assertEquals(true, wire.contains("Transfer-Encoding: chunked\r\n")); + assertEquals(true, wire.endsWith("5\r\nhello\r\n0\r\ngrpc-status: 0\r\n\r\n")); + } + + @Test + void pushStreamingAndTrailersShareTheSameHttp1Writer() throws Exception { + Response response = new Response(200, ContentType.BINARY).streaming(stream -> { + try { + stream.write("one".getBytes(StandardCharsets.US_ASCII), 0, 3); + stream.write("two".getBytes(StandardCharsets.US_ASCII), 0, 3); + stream.trailer("grpc-status", "0"); + } catch (Exception failure) { + throw new RuntimeException(failure); + } + }); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Http1ResponseWriter.writeResponse( + output, response, HttpMethod.GET, true, false, new ScratchPool().acquire()); + + String wire = output.toString(StandardCharsets.US_ASCII); + assertEquals(true, wire.contains("one")); + assertEquals(true, wire.contains("two")); + assertEquals(true, wire.endsWith("0\r\ngrpc-status: 0\r\n\r\n")); + } +} diff --git a/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java b/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java new file mode 100644 index 0000000..a354a90 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/HttpServerTimeoutTest.java @@ -0,0 +1,183 @@ +package dev.relism.flash; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.tls.TestKeystores; +import dev.relism.flash.tls.TlsConfig; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 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. + */ +class HttpServerTimeoutTest { + + private FlashApp app; + + @AfterEach + void tearDown() { + if (app != null) app.stop(); + } + + private int freePort() throws Exception { + try (ServerSocket s = new ServerSocket(0)) { + return s.getLocalPort(); + } + } + + @Test + void slowlorisHeaderDribble_disconnectedWithinHeaderReadTimeout() throws Exception { + int headerTimeoutMs = 300; + int port = freePort(); + app = FlashApp.create(FlashConfiguration.builder() + .port(port).host("127.0.0.1") + .headerReadTimeoutMs(headerTimeoutMs) + .idleKeepAliveTimeoutMs(60_000) + .build()); + app.get("/", (req, res) -> "ok"); + app.start(); + + long start = System.nanoTime(); + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + OutputStream out = socket.getOutputStream(); + // One byte of a request line, then silence — never completes the header block. + out.write('G'); + out.flush(); + + // The server must close its side within headerReadTimeoutMs (+ generous slack for + // scheduling). Detected as EOF (-1) or a reset when the client tries to read. + int result = socket.getInputStream().read(); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + assertEquals(-1, result, "server must close, not hang, after the header deadline"); + assertTrue(elapsedMs < 5_000, "must not have fallen back to the client's own SO_TIMEOUT"); + assertTrue(elapsedMs >= headerTimeoutMs - 50, + "must not close before the configured deadline (was " + elapsedMs + "ms)"); + } + } + + @Test + void idleKeepAliveConnection_disconnectedWithinIdleTimeout() throws Exception { + int idleTimeoutMs = 300; + int port = freePort(); + app = FlashApp.create(FlashConfiguration.builder() + .port(port).host("127.0.0.1") + .headerReadTimeoutMs(10_000) + .idleKeepAliveTimeoutMs(idleTimeoutMs) + .build()); + app.get("/", (req, res) -> "ok"); + app.start(); + + long start = System.nanoTime(); + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + // Send nothing at all — a connection accepted and then left idle. + int result = socket.getInputStream().read(); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + assertEquals(-1, result); + assertTrue(elapsedMs < 5_000); + assertTrue(elapsedMs >= idleTimeoutMs - 50, "was " + elapsedMs + "ms"); + } + } + + @Test + void slowBodyDribble_disconnectedWithinBodyReadTimeout() throws Exception { + int bodyTimeoutMs = 300; + int port = freePort(); + app = FlashApp.create(FlashConfiguration.builder() + .port(port).host("127.0.0.1") + .headerReadTimeoutMs(10_000) + .idleKeepAliveTimeoutMs(10_000) + .bodyReadTimeoutMs(bodyTimeoutMs) + .build()); + app.post("/echo", (req, res) -> req.body().bytes()); + app.start(); + + long start = System.nanoTime(); + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + OutputStream out = socket.getOutputStream(); + out.write(("POST /echo HTTP/1.1\r\nHost: localhost\r\nContent-Length: 100\r\n\r\n" + "x".repeat(5)) + .getBytes(StandardCharsets.UTF_8)); + out.flush(); + // Only 5 of the declared 100 bytes were sent; the remaining 95 never arrive. Whether + // the server responds with an error before closing or simply closes, *something* + // must happen within the body deadline rather than a hang until the client's own + // (much longer) timeout. + socket.getInputStream().read(); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + assertTrue(elapsedMs < 5_000, "must not have fallen back to the client's own SO_TIMEOUT"); + assertTrue(elapsedMs >= bodyTimeoutMs - 50, "was " + elapsedMs + "ms"); + } + } + + @Test + void tlsHandshakeNeverStarted_disconnectedWithinHeaderReadTimeout(@TempDir Path dir) throws Exception { + int headerTimeoutMs = 300; + Path ks = TestKeystores.build(dir, "timeout.p12", "changeit", + TestKeystores.Entry.of("only", "timeout.test")); + int port = freePort(); + app = FlashApp.create(FlashConfiguration.builder() + .port(port).host("127.0.0.1") + .tls(TlsConfig.keystore(ks, "changeit")) + .headerReadTimeoutMs(headerTimeoutMs) + .build()); + app.get("/", (req, res) -> "ok"); + app.start(); + + long start = System.nanoTime(); + // A plain socket that never speaks TLS at all — the server's explicit + // 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 + // property under test is purely the bound on wall-clock time. + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + socket.getInputStream().read(); + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + assertTrue(elapsedMs < 5_000, "must not have fallen back to the client's own SO_TIMEOUT"); + assertTrue(elapsedMs >= headerTimeoutMs - 50, "was " + elapsedMs + "ms"); + } + } + + @Test + void wellBehavedRequest_wellWithinTimeouts_unaffected() throws Exception { + int port = freePort(); + app = FlashApp.create(FlashConfiguration.builder() + .port(port).host("127.0.0.1") + .headerReadTimeoutMs(300) + .idleKeepAliveTimeoutMs(300) + .bodyReadTimeoutMs(300) + .build()); + app.get("/ping", (req, res) -> "pong"); + app.start(); + + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + socket.getOutputStream().write( + "GET /ping HTTP/1.1\r\nHost: localhost\r\n\r\n".getBytes(StandardCharsets.UTF_8)); + socket.getOutputStream().flush(); + byte[] buf = new byte[4096]; + int n = socket.getInputStream().read(buf); + assertTrue(n > 0); + String response = new String(buf, 0, n, StandardCharsets.UTF_8); + assertTrue(response.startsWith("HTTP/1.1 200 OK")); + assertTrue(response.contains("pong")); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/RequestParserFuzzTest.java b/flash/src/test/java/dev/relism/flash/RequestParserFuzzTest.java new file mode 100644 index 0000000..ca18f5d --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/RequestParserFuzzTest.java @@ -0,0 +1,54 @@ +package dev.relism.flash; + +import static org.junit.jupiter.api.Assertions.assertTimeout; +import static org.junit.jupiter.api.Assertions.fail; + +import dev.relism.flash.exceptions.MalformedRequestException; +import dev.relism.flash.transport.BufferedByteSource; +import dev.relism.flash.testing.FuzzMemory; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class RequestParserFuzzTest { + private static final int CASES = 25_000; + + @Test + void arbitraryWireBytesHaveBoundedTypedOutcomes() { + assertTimeout( + Duration.ofSeconds(20), + () -> { + byte[] input = new byte[256]; + long state = 0x9112_4854_5450_314CL; + long baseline = FuzzMemory.snapshot(); + for (int iteration = 0; iteration < CASES; iteration++) { + state = next(state); + int length = (int) (state & 255); + for (int i = 0; i < length; i++) { + state = next(state); + input[i] = (byte) state; + } + try { + new RequestParser(512) + .parse( + new BufferedByteSource( + new ByteArrayInputStream(input, 0, length), null, 256)); + } catch (MalformedRequestException expected) { + // Hostile HTTP/1 syntax is rejected with an explicit response status. + } catch (IOException unexpected) { + fail("in-memory input produced I/O failure at case " + iteration, unexpected); + } catch (Throwable unexpected) { + fail("unexpected failure at case " + iteration + ", length " + length, unexpected); + } + } + FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024); + }); + } + + private static long next(long value) { + value ^= value << 13; + value ^= value >>> 7; + return value ^ (value << 17); + } +} diff --git a/flash/src/test/java/dev/relism/flash/RequestParserSecurityTest.java b/flash/src/test/java/dev/relism/flash/RequestParserSecurityTest.java new file mode 100644 index 0000000..1a4dbe3 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/RequestParserSecurityTest.java @@ -0,0 +1,187 @@ +package dev.relism.flash; + +import dev.relism.flash.exceptions.MalformedRequestException; +import dev.relism.flash.http.Http1Limits; +import dev.relism.flash.models.Request; +import dev.relism.flash.transport.BufferedByteSource; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 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}. + */ +class RequestParserSecurityTest { + + private static BufferedByteSource source(byte[] bytes) { + return new BufferedByteSource(new ByteArrayInputStream(bytes), null); + } + + private static Request parse(String raw) throws IOException { + byte[] bytes = raw.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8); + return new RequestParser().parse(source(bytes)); + } + + private static MalformedRequestException expect(String raw) { + return assertThrows(MalformedRequestException.class, () -> parse(raw)); + } + + + @Test + void contentLengthAndTransferEncodingBothPresent_rejected400() { + MalformedRequestException e = expect( + "POST / HTTP/1.1\nHost: h\nContent-Length: 5\nTransfer-Encoding: chunked\n\nhello"); + assertEquals(400, e.status()); + } + + @Test + void contentLengthAndTransferEncodingBothPresent_rejectedRegardlessOfOrder() { + // The check must not be bypassable by which header appears first. + MalformedRequestException e = expect( + "POST / HTTP/1.1\nHost: h\nTransfer-Encoding: chunked\nContent-Length: 5\n\nhello"); + assertEquals(400, e.status()); + } + + @Test + void duplicateContentLength_conflictingValues_rejected400() { + MalformedRequestException e = expect( + "POST / HTTP/1.1\nHost: h\nContent-Length: 5\nContent-Length: 6\n\nhello!"); + assertEquals(400, e.status()); + } + + @Test + void duplicateContentLength_identicalValues_accepted() throws IOException { + Request r = parse("POST / HTTP/1.1\nHost: h\nContent-Length: 5\nContent-Length: 5\n\nhello"); + assertEquals(5L, r.body().contentLength()); + } + + @Test + void transferEncoding_finalCodingNotChunked_rejected501() { + MalformedRequestException e = expect( + "POST / HTTP/1.1\nHost: h\nTransfer-Encoding: gzip\n\n"); + assertEquals(501, e.status()); + } + + @Test + void transferEncoding_chunkedNotFinal_rejected501() { + // "chunked, gzip" — chunked must be the LAST coding (RFC 9112 §6.1). + MalformedRequestException e = expect( + "POST / HTTP/1.1\nHost: h\nTransfer-Encoding: chunked, gzip\n\n"); + assertEquals(501, e.status()); + } + + + @Test + void contentLength_nonDigitSuffix_rejected400() { + assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: 5abc\n\n").status()); + } + + @Test + void contentLength_leadingPlus_rejected400() { + assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: +5\n\n").status()); + } + + @Test + void contentLength_leadingMinus_rejected400() { + assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: -1\n\n").status()); + } + + @Test + void contentLength_empty_rejected400() { + assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: \n\n").status()); + } + + @Test + void contentLength_overflowsLong_rejected400() { + assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nContent-Length: 99999999999999999999\n\n").status()); + } + + @Test + void contentLength_aboveConfiguredMax_rejected413() { + long tooLarge = Http1Limits.MAX_CONTENT_LENGTH + 1; + assertEquals(413, expect("POST / HTTP/1.1\nHost: h\nContent-Length: " + tooLarge + "\n\n").status()); + } + + + @Test + void tooManyHeaders_rejected431() { + StringBuilder sb = new StringBuilder("GET / HTTP/1.1\nHost: h\n"); + for (int i = 0; i < Http1Limits.MAX_HEADER_COUNT + 5; i++) sb.append("X-").append(i).append(": v\n"); + sb.append("\n"); + assertEquals(431, expect(sb.toString()).status()); + } + + @Test + void headerNameTooLong_rejected431() { + String name = "X-" + "a".repeat(Http1Limits.MAX_HEADER_NAME_LENGTH + 1); + assertEquals(431, expect("GET / HTTP/1.1\nHost: h\n" + name + ": v\n\n").status()); + } + + @Test + void headerValueTooLong_rejected431() { + String value = "a".repeat(Http1Limits.MAX_HEADER_VALUE_LENGTH + 1); + assertEquals(431, expect("GET / HTTP/1.1\nHost: h\nX-Big: " + value + "\n\n").status()); + } + + @Test + void requestLineTooLong_rejected431() { + String path = "/" + "a".repeat(Http1Limits.MAX_REQUEST_LINE_LENGTH + 1); + assertEquals(431, expect("GET " + path + " HTTP/1.1\nHost: h\n\n").status()); + } + + + @Test + void bareLfInsteadOfCrlf_headerLine_rejected() { + // A '\r' not immediately followed by '\n' desynchronizes the parse. + byte[] raw = "GET / HTTP/1.1\r\nHost: h\r\r\n\r\n".getBytes(StandardCharsets.UTF_8); + assertThrows(MalformedRequestException.class, () -> new RequestParser().parse(source(raw))); + } + + @Test + void obsFold_leadingWhitespaceContinuation_rejected400() { + MalformedRequestException e = assertThrows(MalformedRequestException.class, () -> + new RequestParser().parse(source( + "GET / HTTP/1.1\r\nHost: h\r\n Folded: continuation\r\n\r\n" + .getBytes(StandardCharsets.UTF_8)))); + assertEquals(400, e.status()); + } + + // --- header name tchar validation -------------------------------------------- + + @Test + void headerNameWithSpace_rejected400() { + assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nBad Name: v\n\n").status()); + } + + @Test + void headerNameWithControlChar_rejected400() { + String prefix = "GET / HTTP/1.1\r\nHost: h\r\nBad"; + String suffix = "Name: v\r\n\r\n"; + byte[] prefixBytes = prefix.getBytes(StandardCharsets.ISO_8859_1); + byte[] suffixBytes = suffix.getBytes(StandardCharsets.ISO_8859_1); + byte[] raw = new byte[prefixBytes.length + 1 + suffixBytes.length]; + System.arraycopy(prefixBytes, 0, raw, 0, prefixBytes.length); + raw[prefixBytes.length] = 0x01; // control character -- not a valid tchar + System.arraycopy(suffixBytes, 0, raw, prefixBytes.length + 1, suffixBytes.length); + assertThrows(MalformedRequestException.class, () -> new RequestParser().parse(source(raw))); + } + + + @Test + void headerLineMissingColon_rejected400() { + assertEquals(400, expect("GET / HTTP/1.1\nHost: h\nNotAHeader\n\n").status()); + } + + // --- request-line rejections still carry the right status -------------------- + + @Test + void emptyMethod_rejected400() { + assertEquals(400, expect(" / HTTP/1.1\nHost: h\n\n").status()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/RequestParserTest.java b/flash/src/test/java/dev/relism/flash/RequestParserTest.java index 2c8dd79..89484df 100644 --- a/flash/src/test/java/dev/relism/flash/RequestParserTest.java +++ b/flash/src/test/java/dev/relism/flash/RequestParserTest.java @@ -1,6 +1,8 @@ package dev.relism.flash; +import dev.relism.flash.exceptions.MalformedRequestException; import dev.relism.flash.models.Request; +import dev.relism.flash.transport.BufferedByteSource; import org.junit.jupiter.api.Test; import java.io.ByteArrayInputStream; @@ -14,9 +16,15 @@ class RequestParserTest { // --- helpers --- + // BufferedByteSource's Socket reference is only touched when a deadline is set — none of + // these tests set one, so `null` is safe here. + private static BufferedByteSource source(byte[] bytes) { + return new BufferedByteSource(new ByteArrayInputStream(bytes), null); + } + private static Request parse(String raw) throws IOException { byte[] bytes = raw.replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8); - return new RequestParser().parse(new ByteArrayInputStream(bytes)); + return new RequestParser().parse(source(bytes)); } private static String req(String requestLine, String... headers) { @@ -48,6 +56,31 @@ class RequestParserTest { assertEquals("2", r.query("page")); } + + @Test + void samePooledParser_secondRequestWithoutQuery_doesNotLeakFirstRequestsQuery() throws IOException { + RequestParser parser = new RequestParser(); + byte[] first = req("GET /search?token=super-secret HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8); + Request r1 = parser.parse(source(first)); + assertEquals("token=super-secret", r1.getRequestLine().getQuery().toString()); + + byte[] second = req("GET /health HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8); + Request r2 = parser.parse(source(second)); + assertNull(r2.getRequestLine().getQuery(), "the second request must not see the first request's leftover query view"); + assertEquals("/health", r2.getRequestLine().getPath().toString()); + } + + @Test + void samePooledParser_secondRequest_seesOnlyItsOwnPathAndProtocol() throws IOException { + RequestParser parser = new RequestParser(); + Request r1 = parser.parse(source(req("GET /first HTTP/1.1", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8))); + assertEquals("/first", r1.getRequestLine().getPath().toString()); + + Request r2 = parser.parse(source(req("POST /second HTTP/1.0", "Host: a").replace("\n", "\r\n").getBytes(StandardCharsets.UTF_8))); + assertEquals("/second", r2.getRequestLine().getPath().toString()); + assertEquals("HTTP/1.0", r2.getRequestLine().getProtocol().toString()); + } + // --- headers --- @Test @@ -70,7 +103,7 @@ class RequestParserTest { void body_parsed() throws IOException { String body = "hello body"; String raw = "POST / HTTP/1.1\r\nContent-Length: " + body.length() + "\r\n\r\n" + body; - Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8))); + Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8))); assertNotNull(r); assertEquals(body, new String(r.body().bytes(), StandardCharsets.UTF_8)); } @@ -79,7 +112,7 @@ class RequestParserTest { void body_parsed_forQueryMethod() throws IOException { String body = "{\"filter\":\"active\"}"; String raw = "QUERY / HTTP/1.1\r\nContent-Type: application/json\r\nContent-Length: " + body.length() + "\r\n\r\n" + body; - Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8))); + Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8))); assertNotNull(r); assertEquals(dev.relism.flash.http.HttpMethod.QUERY, r.method()); assertEquals(body, new String(r.body().bytes(), StandardCharsets.UTF_8)); @@ -95,34 +128,40 @@ class RequestParserTest { @Test void emptyInputStream_returnsNull() throws IOException { - assertNull(new RequestParser().parse(new ByteArrayInputStream(new byte[0]))); + assertNull(new RequestParser().parse(source(new byte[0]))); } @Test - void missingHeaderTerminator_throwsIOException() { - // Valid request line but stream ends before \r\n\r\n + void missingHeaderTerminator_throwsMalformedRequestException() { + // Valid request line but stream ends before \r\n\r\n. Previously a generic IOException; byte[] raw = "GET / HTTP/1.1\r\nHost: localhost\r\n".getBytes(StandardCharsets.UTF_8); - assertThrows(IOException.class, () -> new RequestParser().parse(new ByteArrayInputStream(raw))); + assertThrows(MalformedRequestException.class, () -> new RequestParser().parse(source(raw))); } @Test - void unknownHttpMethod_throwsIOException() { - assertThrows(IOException.class, () -> parse(req("BREW /coffee HTTP/1.1", "Host: localhost"))); + void unknownHttpMethod_throwsMalformedRequestException() { + // RFC 9110 §9.1 SHOULD 501 an unrecognised method. + MalformedRequestException e = assertThrows(MalformedRequestException.class, + () -> parse(req("BREW /coffee HTTP/1.1", "Host: localhost"))); + assertEquals(501, e.status()); } @Test - void requestLine_noProtocol_throwsIOException() { + void requestLine_noProtocol_throwsMalformedRequestException() { // No space after path, parser cannot find protocol boundary - assertThrows(IOException.class, () -> parse(req("GET /noproto"))); + MalformedRequestException e = assertThrows(MalformedRequestException.class, + () -> parse(req("GET /noproto"))); + assertEquals(400, e.status()); } @Test - void headers_exceedingMaxBufferSize_throwsIOException() { - // Feed more bytes than the configured cap with no \r\n\r\n : must throw + void headers_exceedingMaxBufferSize_throwsMalformedRequestException() { int cap = 16 * 1024; byte[] giant = new byte[cap + 1]; Arrays.fill(giant, (byte) 'A'); - assertThrows(IOException.class, () -> new RequestParser(cap).parse(new ByteArrayInputStream(giant))); + MalformedRequestException e = assertThrows(MalformedRequestException.class, + () -> new RequestParser(cap).parse(source(giant))); + assertEquals(431, e.status()); } @Test @@ -132,22 +171,37 @@ class RequestParserTest { "Transfer-Encoding: chunked\r\n" + "\r\n" + "5\r\nhello\r\n6\r\n world\r\n0\r\n\r\n"; - Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8))); + Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8))); assertNotNull(r); assertEquals(-1L, r.body().contentLength()); // -1 = chunked assertArrayEquals("hello world".getBytes(StandardCharsets.UTF_8), r.body().bytes()); } @Test - void contentLength_parsedAsLong() throws IOException { - // 5 GB — too large to materialize, but contentLength must be a long + void transferEncoding_multiValueEndingInChunked_recognised() throws IOException { + // old whole-value comparison misclassified this as not chunked at all. String raw = "POST / HTTP/1.1\r\n" + "Host: localhost\r\n" + - "Content-Length: 5000000000\r\n" + - "\r\n"; - Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8))); + "Transfer-Encoding: gzip, chunked\r\n" + + "\r\n" + + "2\r\nhi\r\n0\r\n\r\n"; + Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8))); assertNotNull(r); - assertEquals(5_000_000_000L, r.body().contentLength()); + assertEquals(-1L, r.body().contentLength()); + assertArrayEquals("hi".getBytes(StandardCharsets.UTF_8), r.body().bytes()); + } + + @Test + void contentLength_parsedAsLong() throws IOException { + // ~3 GB — comfortably above Integer.MAX_VALUE (proving the value is a genuine long, not + // silently truncated) while staying within Http1Limits.MAX_CONTENT_LENGTH (4 GiB). + String raw = "POST / HTTP/1.1\r\n" + + "Host: localhost\r\n" + + "Content-Length: 3000000000\r\n" + + "\r\n"; + Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8))); + assertNotNull(r); + assertEquals(3_000_000_000L, r.body().contentLength()); assertThrows(IllegalStateException.class, r.body()::bytes); } @@ -156,7 +210,7 @@ class RequestParserTest { // Content-Length claims 50 but stream ends after 5 bytes String body = "hello"; String raw = "POST / HTTP/1.1\r\nContent-Length: 50\r\n\r\n" + body; - Request r = new RequestParser().parse(new ByteArrayInputStream(raw.getBytes(StandardCharsets.UTF_8))); + Request r = new RequestParser().parse(source(raw.getBytes(StandardCharsets.UTF_8))); assertNotNull(r); assertEquals(50, r.body().bytes().length); assertEquals(body, new String(r.body().bytes(), 0, body.length(), StandardCharsets.UTF_8)); diff --git a/flash/src/test/java/dev/relism/flash/api/multipart/MultipartTest.java b/flash/src/test/java/dev/relism/flash/api/multipart/MultipartTest.java index 475267e..7e77496 100644 --- a/flash/src/test/java/dev/relism/flash/api/multipart/MultipartTest.java +++ b/flash/src/test/java/dev/relism/flash/api/multipart/MultipartTest.java @@ -2,7 +2,7 @@ package dev.relism.flash.api.multipart; import dev.relism.fpr.core.ByteView; import dev.relism.flash.http.HttpMethod; -import dev.relism.flash.models.HeaderMap; +import dev.relism.flash.models.Http1HeaderMap; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestLine; import org.junit.jupiter.api.Test; @@ -41,7 +41,7 @@ class MultipartTest { private static Request request(byte[] bodyBytes) { String ct = "multipart/form-data; boundary=" + BOUNDARY; byte[] headerBuf = ("Content-Type: " + ct).getBytes(StandardCharsets.US_ASCII); - HeaderMap headers = new HeaderMap(); + Http1HeaderMap headers = new Http1HeaderMap(); headers.reset(headerBuf, 0, headerBuf.length); RequestLine line = new RequestLine(HttpMethod.POST, viewOf("/upload"), null, viewOf("HTTP/1.1"), headers); return new Request(line, bodyBytes); @@ -236,11 +236,64 @@ class MultipartTest { @Test void of_notMultipart_throws() { byte[] headerBuf = "Content-Type: application/json".getBytes(StandardCharsets.US_ASCII); - HeaderMap headers = new HeaderMap(); + Http1HeaderMap headers = new Http1HeaderMap(); headers.reset(headerBuf, 0, headerBuf.length); RequestLine line = new RequestLine(HttpMethod.POST, viewOf("/"), null, viewOf("HTTP/1.1"), headers); Request req = new Request(line, new byte[0]); assertThrows(IllegalArgumentException.class, () -> Multipart.of(req)); } + + // ------------------------------------------------------------------------- + // ------------------------------------------------------------------------- + + @Test + void field_bodyAboveMaxBufferedSize_throws() throws IOException { + // MAX_MULTIPART_BUFFERED_PART_SIZE is 10 MiB — one byte over must be rejected, not + // buffered whole into a single byte[]. + String tooBig = "z".repeat((int) dev.relism.flash.http.Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + 1); + Multipart mp = Multipart.of(request(body(textPart("huge", tooBig)))); + assertThrows(IOException.class, () -> mp.field("huge")); + } + + @Test + void file_materializedDuringFullScan_aboveMaxBufferedSize_throws() throws IOException { + String tooBig = "z".repeat((int) dev.relism.flash.http.Http1Limits.MAX_MULTIPART_BUFFERED_PART_SIZE + 1); + Multipart mp = Multipart.of(request(body(filePart("f", "f.bin", "application/octet-stream", tooBig)))); + assertThrows(IOException.class, mp::parts); + } + + @Test + void scan_tooManyParts_throws() throws IOException { + String[] parts = new String[dev.relism.flash.http.Http1Limits.MAX_MULTIPART_PARTS + 1]; + for (int i = 0; i < parts.length; i++) parts[i] = textPart("f" + i, "v"); + Multipart mp = Multipart.of(request(body(parts))); + assertThrows(IOException.class, mp::parts); + } + + @Test + void partHeaders_tooManyHeaderLines_throws() throws IOException { + StringBuilder part = new StringBuilder("Content-Disposition: form-data; name=\"x\"\r\n"); + for (int i = 0; i <= dev.relism.flash.http.Http1Limits.MAX_MULTIPART_PART_HEADER_COUNT; i++) { + part.append("X-Extra-").append(i).append(": v\r\n"); + } + part.append("\r\nbody"); + Multipart mp = Multipart.of(request(body(part.toString()))); + assertThrows(IOException.class, () -> mp.field("x")); + } + + @Test + void partHeaderLine_tooLong_throws() throws IOException { + String longValue = "v".repeat(dev.relism.flash.http.Http1Limits.MAX_MULTIPART_HEADER_LINE_LENGTH + 1); + String part = "Content-Disposition: form-data; name=\"x\"\r\n" + + "X-Long: " + longValue + "\r\n\r\nbody"; + Multipart mp = Multipart.of(request(body(part))); + assertThrows(IOException.class, () -> mp.field("x")); + } + + @Test + void withinAllLimits_stillWorksNormally() throws IOException { + // Sanity check the bounds above don't false-positive on a normal small request. + assertEquals("alice", Multipart.of(request(body(textPart("username", "alice")))).field("username")); + } } diff --git a/flash/src/test/java/dev/relism/flash/architecture/PackageBoundaryTest.java b/flash/src/test/java/dev/relism/flash/architecture/PackageBoundaryTest.java new file mode 100644 index 0000000..d963914 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/architecture/PackageBoundaryTest.java @@ -0,0 +1,52 @@ +package dev.relism.flash.architecture; + +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.fail; + +/** Ensures that the HTTP/1.1 and HTTP/2 implementations remain independent peers. */ +class PackageBoundaryTest { + + @Test + void http1DoesNotImportHttp2() throws IOException { + assertNoImportOfPackage("dev/relism/flash/http1", "dev.relism.flash.http2"); + } + + @Test + 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); + if (root == null) return; + + try (Stream files = Files.walk(root)) { + List javaFiles = files.filter(p -> p.toString().endsWith(".java")).toList(); + for (Path file : javaFiles) { + for (String line : Files.readAllLines(file)) { + String trimmed = line.strip(); + if (trimmed.startsWith("import " + forbiddenImportPrefix + ".") + || trimmed.startsWith("import " + forbiddenImportPrefix + ";")) { + fail(file + " imports " + forbiddenImportPrefix + + " and violates the HTTP protocol package boundary: " + trimmed); + } + } + } + } + } + + private static Path findSourceRoot(String packageRelativePath) { + for (String base : List.of("flash/src/main/java", "src/main/java")) { + Path candidate = Path.of(base).resolve(packageRelativePath); + if (Files.isDirectory(candidate)) return candidate; + } + return null; + } +} diff --git a/flash/src/test/java/dev/relism/flash/bytes/ByteScanFuzzTest.java b/flash/src/test/java/dev/relism/flash/bytes/ByteScanFuzzTest.java new file mode 100644 index 0000000..ff4b866 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/bytes/ByteScanFuzzTest.java @@ -0,0 +1,66 @@ +package dev.relism.flash.bytes; + +import org.junit.jupiter.api.Test; + +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +/** + * Randomized agreement testing for {@link ByteScan}'s SWAR methods against their scalar + * 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 + * CI failures, purely to catch any interaction between random byte content and the SWAR bit + * tricks that a hand-picked boundary test would miss. + */ +class ByteScanFuzzTest { + + private static final int TRIALS = 20_000; + private static final int MAX_LEN = 300; + + @Test + void indexOf_agreesWithScalar_onFullyRandomInputs() { + Random rnd = new Random(1234567); + for (int t = 0; t < TRIALS; t++) { + int len = rnd.nextInt(MAX_LEN + 1); + byte[] buf = new byte[len]; + rnd.nextBytes(buf); + byte target = (byte) rnd.nextInt(256); + int from = len == 0 ? 0 : rnd.nextInt(len + 1); + int to = from == len ? len : from + rnd.nextInt(len - from + 1); + + int expected = ByteScan.indexOfScalar(buf, from, to, target); + int actual = assertDoesNotThrow(() -> ByteScan.indexOf(buf, from, to, target)); + int trial = t; + assertEquals(expected, actual, + () -> "mismatch at trial " + trial + ", len=" + len + ", from=" + from + ", to=" + to); + } + } + + @Test + void indexOfCrLfCrLf_agreesWithScalar_onFullyRandomInputs() { + Random rnd = new Random(9876543); + for (int t = 0; t < TRIALS; t++) { + int len = rnd.nextInt(MAX_LEN + 1); + byte[] buf = new byte[len]; + rnd.nextBytes(buf); + // Occasionally bias toward CR/LF bytes so real matches (and near-matches) show up, + // not just "no CR anywhere" cases. + if (rnd.nextInt(3) == 0) { + for (int i = 0; i < len; i++) { + if (rnd.nextInt(4) == 0) buf[i] = rnd.nextBoolean() ? (byte) '\r' : (byte) '\n'; + } + } + int from = len == 0 ? 0 : rnd.nextInt(len + 1); + int to = from == len ? len : from + rnd.nextInt(len - from + 1); + + int expected = ByteScan.indexOfCrLfCrLfScalar(buf, from, to); + int actual = assertDoesNotThrow(() -> ByteScan.indexOfCrLfCrLf(buf, from, to)); + int trial = t; + assertEquals(expected, actual, + () -> "mismatch at trial " + trial + ", len=" + len + ", from=" + from + ", to=" + to); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/bytes/ByteScanTest.java b/flash/src/test/java/dev/relism/flash/bytes/ByteScanTest.java new file mode 100644 index 0000000..17bf9f1 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/bytes/ByteScanTest.java @@ -0,0 +1,247 @@ +package dev.relism.flash.bytes; + +import dev.relism.fpr.core.ByteView; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.*; + +class ByteScanTest { + + private static final class ArrayView implements ByteView { + final byte[] buf; + ArrayView(String s) { this.buf = s.getBytes(StandardCharsets.US_ASCII); } + @Override public int length() { return buf.length; } + @Override public byte byteAt(int i) { return buf[i]; } + } + + // ── isTChar ────────────────────────────────────────────────────────────── + + @Test + void isTChar_acceptsRfc9110TCharSet() { + for (char c = '0'; c <= '9'; c++) assertTrue(ByteScan.isTChar((byte) c)); + for (char c = 'A'; c <= 'Z'; c++) assertTrue(ByteScan.isTChar((byte) c)); + for (char c = 'a'; c <= 'z'; c++) assertTrue(ByteScan.isTChar((byte) c)); + for (byte b : "!#$%&'*+-.^_`|~".getBytes(StandardCharsets.US_ASCII)) assertTrue(ByteScan.isTChar(b)); + } + + @Test + void isTChar_rejectsDelimitersAndControlAndHighBytes() { + for (byte b : " \t\":;,()<>[]{}=?@\\/".getBytes(StandardCharsets.US_ASCII)) { + assertFalse(ByteScan.isTChar(b), "byte '" + (char) b + "' must not be a tchar"); + } + assertFalse(ByteScan.isTChar((byte) 0)); + assertFalse(ByteScan.isTChar((byte) 127)); + assertFalse(ByteScan.isTChar((byte) -1)); // high-bit byte, e.g. UTF-8 continuation + } + + // ── indexOf: SWAR vs scalar, every boundary ───────────────────────────── + + @Test + void indexOf_swarAgreesWithScalar_everyLengthAndPosition() { + Random rnd = new Random(42); + for (int len = 0; len <= 256; len++) { + byte[] buf = new byte[len]; + rnd.nextBytes(buf); + // Ensure the target byte value (7) doesn't appear anywhere except where we plant it. + for (int i = 0; i < len; i++) if (buf[i] == 7) buf[i] = 8; + + assertEquals(-1, ByteScan.indexOfScalar(buf, 0, len, (byte) 7)); + assertEquals(ByteScan.indexOfScalar(buf, 0, len, (byte) 7), ByteScan.indexOf(buf, 0, len, (byte) 7)); + + for (int pos = 0; pos < len; pos++) { + byte[] planted = buf.clone(); + planted[pos] = 7; + int expected = ByteScan.indexOfScalar(planted, 0, len, (byte) 7); + assertEquals(pos, expected, "scalar oracle disagrees with itself at pos " + pos); + assertEquals(expected, ByteScan.indexOf(planted, 0, len, (byte) 7), + "SWAR disagrees with scalar at len=" + len + " pos=" + pos); + } + } + } + + @Test + void indexOf_unalignedStart_agreesWithScalar() { + Random rnd = new Random(7); + byte[] buf = new byte[64]; + rnd.nextBytes(buf); + for (int i = 0; i < buf.length; i++) if (buf[i] == 9) buf[i] = 10; + buf[40] = 9; + for (int from = 0; from < 8; from++) { + assertEquals(ByteScan.indexOfScalar(buf, from, buf.length, (byte) 9), + ByteScan.indexOf(buf, from, buf.length, (byte) 9)); + } + } + + // ── indexOfCrLfCrLf: SWAR vs scalar, every boundary ───────────────────── + + @Test + void indexOfCrLfCrLf_swarAgreesWithScalar_everyLengthAndPosition() { + Random rnd = new Random(99); + for (int len = 4; len <= 128; len++) { + byte[] base = new byte[len]; + rnd.nextBytes(base); + // Strip any accidental \r or \n so only the planted match exists. + for (int i = 0; i < len; i++) { + if (base[i] == '\r' || base[i] == '\n') base[i] = 'x'; + } + assertEquals(-1, ByteScan.indexOfCrLfCrLfScalar(base, 0, len)); + assertEquals(-1, ByteScan.indexOfCrLfCrLf(base, 0, len)); + + for (int pos = 0; pos <= len - 4; pos++) { + byte[] planted = base.clone(); + planted[pos] = '\r'; planted[pos + 1] = '\n'; planted[pos + 2] = '\r'; planted[pos + 3] = '\n'; + int expected = ByteScan.indexOfCrLfCrLfScalar(planted, 0, len); + assertEquals(pos, expected); + assertEquals(expected, ByteScan.indexOfCrLfCrLf(planted, 0, len), + "SWAR disagrees with scalar at len=" + len + " pos=" + pos); + } + } + } + + @Test + void indexOfCrLfCrLf_matchAtVeryLastPossiblePosition() { + byte[] buf = "GET / HTTP/1.1\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + int expected = buf.length - 4; + assertEquals(expected, ByteScan.indexOfCrLfCrLf(buf, 0, buf.length)); + } + + @Test + void indexOfCrLfCrLf_bareCrNotFollowedByLf_isNotAMatch() { + byte[] buf = "a\r\rb\r\n\r\nc".getBytes(StandardCharsets.US_ASCII); + int expected = ByteScan.indexOfCrLfCrLfScalar(buf, 0, buf.length); + assertEquals(expected, ByteScan.indexOfCrLfCrLf(buf, 0, buf.length)); + assertTrue(expected >= 0); + } + + @Test + void indexOfCrLfCrLf_lengthNotMultipleOfEight_doesNotOverrun() { + for (int len = 4; len <= 20; len++) { + byte[] buf = new byte[len]; + for (int i = 0; i < len; i++) buf[i] = 'x'; + assertEquals(-1, ByteScan.indexOfCrLfCrLf(buf, 0, len)); + if (len >= 4) { + buf[len - 4] = '\r'; buf[len - 3] = '\n'; buf[len - 2] = '\r'; buf[len - 1] = '\n'; + assertEquals(len - 4, ByteScan.indexOfCrLfCrLf(buf, 0, len)); + } + } + } + + // ── Case-insensitive comparison ───────────────────────────────────────── + + @Test + void equalsIgnoreCaseAscii_array_matchesRegardlessOfCase() { + byte[] buf = "Content-Type".getBytes(StandardCharsets.US_ASCII); + assertTrue(ByteScan.equalsIgnoreCaseAscii(buf, 0, buf.length, "content-type")); + assertTrue(ByteScan.equalsIgnoreCaseAscii(buf, 0, buf.length, "CONTENT-TYPE")); + assertFalse(ByteScan.equalsIgnoreCaseAscii(buf, 0, buf.length, "content-length")); + } + + @Test + void equalsIgnoreCaseAscii_twoArrays() { + byte[] a = "Accept".getBytes(StandardCharsets.US_ASCII); + byte[] b = "aCCEPT".getBytes(StandardCharsets.US_ASCII); + assertTrue(ByteScan.equalsIgnoreCaseAscii(a, 0, a.length, b, 0, b.length)); + byte[] c = "Accept-X".getBytes(StandardCharsets.US_ASCII); + assertFalse(ByteScan.equalsIgnoreCaseAscii(a, 0, a.length, c, 0, c.length)); + } + + @Test + void equalsIgnoreCase_view() { + ArrayView v = new ArrayView("Keep-Alive"); + assertTrue(ByteScan.equalsIgnoreCase(v, 0, v.length(), "keep-alive")); + assertFalse(ByteScan.equalsIgnoreCase(v, 0, v.length(), "close")); + } + + // ── Token lists ────────────────────────────────────────────────────────── + + @Test + void tokenListContains_findsTokenAmongMultiple() { + ArrayView v = new ArrayView("keep-alive, Upgrade"); + assertTrue(ByteScan.tokenListContains(v, "upgrade")); + assertTrue(ByteScan.tokenListContains(v, "keep-alive")); + assertFalse(ByteScan.tokenListContains(v, "close")); + } + + @Test + void tokenListContains_singleToken() { + ArrayView v = new ArrayView("close"); + assertTrue(ByteScan.tokenListContains(v, "close")); + } + + @Test + void tokenListContains_emptyList() { + ArrayView v = new ArrayView(""); + assertFalse(ByteScan.tokenListContains(v, "close")); + } + + // ── Header-name hash ───────────────────────────────────────────────────── + + @Test + void hashNameIgnoreCaseAscii_isCaseInsensitive() { + byte[] lower = "content-length".getBytes(StandardCharsets.US_ASCII); + byte[] mixed = "Content-Length".getBytes(StandardCharsets.US_ASCII); + byte[] upper = "CONTENT-LENGTH".getBytes(StandardCharsets.US_ASCII); + int h1 = ByteScan.hashNameIgnoreCaseAscii(lower, 0, lower.length); + int h2 = ByteScan.hashNameIgnoreCaseAscii(mixed, 0, mixed.length); + int h3 = ByteScan.hashNameIgnoreCaseAscii(upper, 0, upper.length); + assertEquals(h1, h2); + assertEquals(h2, h3); + } + + @Test + void hashNameIgnoreCaseAscii_stringOverloadAgreesWithByteArrayOverload() { + for (String name : new String[]{"content-length", "Content-Length", "CONTENT-LENGTH", "x", ""}) { + byte[] b = name.getBytes(StandardCharsets.US_ASCII); + assertEquals(ByteScan.hashNameIgnoreCaseAscii(b, 0, b.length), ByteScan.hashNameIgnoreCaseAscii(name)); + } + } + + @Test + void hashNameIgnoreCaseAscii_differentNamesUsuallyDiffer() { + String[] names = {"content-length", "content-type", "authorization", "cookie", "accept", + "host", "user-agent", "x-forwarded-for", "connection", "upgrade"}; + java.util.Set hashes = new java.util.HashSet<>(); + for (String n : names) { + byte[] b = n.getBytes(StandardCharsets.US_ASCII); + hashes.add(ByteScan.hashNameIgnoreCaseAscii(b, 0, b.length)); + } + assertEquals(names.length, hashes.size(), "expected no collisions among common header names"); + } + + // ── Decimal / hex parsing ──────────────────────────────────────────────── + + @Test + void parseDecimalStrict_validAndInvalidCases() { + assertEquals(0L, parse("0")); + assertEquals(12345L, parse("12345")); + assertEquals(Long.MAX_VALUE, parse(Long.toString(Long.MAX_VALUE))); + assertEquals(ByteScan.PARSE_INVALID, parse("")); + assertEquals(ByteScan.PARSE_INVALID, parse("12a45")); + assertEquals(ByteScan.PARSE_INVALID, parse("-1")); + assertEquals(ByteScan.PARSE_INVALID, parse("+1")); + assertEquals(ByteScan.PARSE_INVALID, parse("99999999999999999999")); // overflow + assertEquals(ByteScan.PARSE_INVALID, parse("10000000000000000000")); // > Long.MAX_VALUE, 20 digits already rejected by length + } + + private static long parse(String s) { + byte[] b = s.getBytes(StandardCharsets.US_ASCII); + return ByteScan.parseDecimalStrict(b, 0, b.length); + } + + @Test + void parseHexStrict_validAndInvalidCases() { + assertEquals(0xFFL, hex("ff", 8)); + assertEquals(0xABCDL, hex("aBcD", 8)); + assertEquals(ByteScan.PARSE_INVALID, hex("", 8)); + assertEquals(ByteScan.PARSE_INVALID, hex("xyz", 8)); + assertEquals(ByteScan.PARSE_INVALID, hex("123456789", 8)); // too many digits + } + + private static long hex(String s, int maxDigits) { + byte[] b = s.getBytes(StandardCharsets.US_ASCII); + return ByteScan.parseHexStrict(b, 0, b.length, maxDigits); + } +} diff --git a/flash/src/test/java/dev/relism/flash/bytes/ByteWriterTest.java b/flash/src/test/java/dev/relism/flash/bytes/ByteWriterTest.java new file mode 100644 index 0000000..2b978d0 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/bytes/ByteWriterTest.java @@ -0,0 +1,131 @@ +package dev.relism.flash.bytes; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class ByteWriterTest { + + private static String asString(ByteWriter w) { + return new String(w.array(), 0, w.length(), StandardCharsets.US_ASCII); + } + + @Test + void writeByte_and_writeBytes() { + ByteWriter w = new ByteWriter(4); + w.writeByte((byte) 'H'); + w.writeBytes("ello".getBytes(StandardCharsets.US_ASCII)); + assertEquals("Hello", asString(w)); + } + + @Test + void writeBytes_offsetAndLength() { + ByteWriter w = new ByteWriter(4); + byte[] src = "xxHELLOxx".getBytes(StandardCharsets.US_ASCII); + w.writeBytes(src, 2, 5); + assertEquals("HELLO", asString(w)); + } + + @Test + void growsPastInitialCapacity_withoutLosingData() { + ByteWriter w = new ByteWriter(2); + StringBuilder expected = new StringBuilder(); + for (int i = 0; i < 1000; i++) { + w.writeByte((byte) ('a' + (i % 26))); + expected.append((char) ('a' + (i % 26))); + } + assertEquals(expected.toString(), asString(w)); + } + + @Test + void reset_reusesBufferFromScratch() { + ByteWriter w = new ByteWriter(16); + w.writeBytes("first".getBytes(StandardCharsets.US_ASCII)); + byte[] bufBeforeReset = w.array(); + w.reset(); + assertEquals(0, w.length()); + w.writeBytes("second".getBytes(StandardCharsets.US_ASCII)); + assertEquals("second", asString(w)); + assertSame(bufBeforeReset, w.array(), "reset() must not reallocate when capacity already suffices"); + } + + @Test + void writeDecimal_variousValues() { + assertDecimal("0", 0); + assertDecimal("7", 7); + assertDecimal("42", 42); + assertDecimal("1000000", 1_000_000); + assertDecimal(Long.toString(Long.MAX_VALUE), Long.MAX_VALUE); + } + + private static void assertDecimal(String expected, long value) { + ByteWriter w = new ByteWriter(4); + w.writeDecimal(value); + assertEquals(expected, asString(w)); + } + + @Test + void writeDecimal_rejectsNegative() { + ByteWriter w = new ByteWriter(4); + assertThrows(IllegalArgumentException.class, () -> w.writeDecimal(-1)); + } + + @Test + void writeHex_variousValues() { + assertHex("0", 0); + assertHex("ff", 0xFF); + assertHex("1a2b3c", 0x1A2B3C); + assertHex("ffffffff", 0xFFFFFFFF); + } + + private static void assertHex(String expected, int value) { + ByteWriter w = new ByteWriter(4); + w.writeHex(value); + assertEquals(expected, asString(w)); + } + + @Test + void writeAsciiLower_lowersUppercaseOnly() { + ByteWriter w = new ByteWriter(4); + w.writeAsciiLower("Content-TYPE"); + assertEquals("content-type", asString(w)); + } + + @Test + void writeAscii_preservesCase() { + ByteWriter w = new ByteWriter(4); + w.writeAscii("Content-TYPE"); + assertEquals("Content-TYPE", asString(w)); + } + + @Test + void writeUInt16_bigEndian() { + ByteWriter w = new ByteWriter(4); + w.writeUInt16(0x1234); + assertArrayEquals(new byte[]{0x12, 0x34}, java.util.Arrays.copyOf(w.array(), w.length())); + } + + @Test + void writeUInt24_bigEndian() { + ByteWriter w = new ByteWriter(4); + w.writeUInt24(0x123456); + assertArrayEquals(new byte[]{0x12, 0x34, 0x56}, java.util.Arrays.copyOf(w.array(), w.length())); + } + + @Test + void writeUInt31_masksTopBit() { + ByteWriter w = new ByteWriter(4); + w.writeUInt31(0xFFFFFFFF); // all bits set -> top bit must be cleared + assertArrayEquals(new byte[]{0x7F, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF}, + java.util.Arrays.copyOf(w.array(), w.length())); + } + + @Test + void writeUInt32_bigEndian() { + ByteWriter w = new ByteWriter(4); + w.writeUInt32(0x01020304); + assertArrayEquals(new byte[]{0x01, 0x02, 0x03, 0x04}, java.util.Arrays.copyOf(w.array(), w.length())); + } +} diff --git a/flash/src/test/java/dev/relism/flash/bytes/PairsTest.java b/flash/src/test/java/dev/relism/flash/bytes/PairsTest.java new file mode 100644 index 0000000..bfc100e --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/bytes/PairsTest.java @@ -0,0 +1,37 @@ +package dev.relism.flash.bytes; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class PairsTest { + + @Test + void packAndUnpack_roundTrip() { + long p = Pairs.pack(1234, 5678); + assertEquals(1234, Pairs.hi(p)); + assertEquals(5678, Pairs.lo(p)); + } + + @Test + void packAndUnpack_zero() { + long p = Pairs.pack(0, 0); + assertEquals(0, Pairs.hi(p)); + assertEquals(0, Pairs.lo(p)); + } + + @Test + void packAndUnpack_maxInts() { + long p = Pairs.pack(Integer.MAX_VALUE, Integer.MAX_VALUE); + assertEquals(Integer.MAX_VALUE, Pairs.hi(p)); + assertEquals(Integer.MAX_VALUE, Pairs.lo(p)); + } + + @Test + void lo_doesNotSignExtendFromHi() { + // hi negative-looking bit pattern must not bleed into lo after unpack. + long p = Pairs.pack(-1, 42); + assertEquals(-1, Pairs.hi(p)); + assertEquals(42, Pairs.lo(p)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/bytes/SegmentedByteViewTest.java b/flash/src/test/java/dev/relism/flash/bytes/SegmentedByteViewTest.java new file mode 100644 index 0000000..2622e8f --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/bytes/SegmentedByteViewTest.java @@ -0,0 +1,71 @@ +package dev.relism.flash.bytes; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class SegmentedByteViewTest { + + @Test + void reset_presentsSegmentsAsOneLogicalSequence() { + byte[][] segments = { + "Hello, ".getBytes(StandardCharsets.US_ASCII), + "World".getBytes(StandardCharsets.US_ASCII), + "!".getBytes(StandardCharsets.US_ASCII), + }; + int[] offsets = {0, 0, 0}; + int[] lengths = {segments[0].length, segments[1].length, segments[2].length}; + + SegmentedByteView view = new SegmentedByteView(); + view.reset(segments, offsets, lengths, 3); + + assertEquals(13, view.length()); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < view.length(); i++) sb.append((char) view.byteAt(i)); + assertEquals("Hello, World!", sb.toString()); + } + + @Test + void reset_honorsPerSegmentOffsetAndLength() { + byte[][] segments = { "xxABCxx".getBytes(StandardCharsets.US_ASCII) }; + SegmentedByteView view = new SegmentedByteView(); + view.reset(segments, new int[]{2}, new int[]{3}, 1); + assertEquals(3, view.length()); + assertEquals('A', (char) view.byteAt(0)); + assertEquals('C', (char) view.byteAt(2)); + } + + @Test + void reset_isReusableAcrossCalls() { + SegmentedByteView view = new SegmentedByteView(); + view.reset(new byte[][]{"abc".getBytes(StandardCharsets.US_ASCII)}, new int[]{0}, new int[]{3}, 1); + assertEquals(3, view.length()); + view.reset(new byte[][]{"de".getBytes(StandardCharsets.US_ASCII)}, new int[]{0}, new int[]{2}, 1); + assertEquals(2, view.length()); + assertEquals('d', (char) view.byteAt(0)); + } + + @Test + void byteAt_outOfBoundsThrows() { + SegmentedByteView view = new SegmentedByteView(); + view.reset(new byte[][]{"ab".getBytes(StandardCharsets.US_ASCII)}, new int[]{0}, new int[]{2}, 1); + assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(2)); + assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(-1)); + } + + @Test + void supportsLong_alwaysFalse() { + SegmentedByteView view = new SegmentedByteView(); + view.reset(new byte[][]{"12345678".getBytes(StandardCharsets.US_ASCII)}, new int[]{0}, new int[]{8}, 1); + assertFalse(view.supportsLong()); + } + + @Test + void emptySegmentCount_isZeroLength() { + SegmentedByteView view = new SegmentedByteView(); + view.reset(new byte[][]{}, new int[]{}, new int[]{}, 0); + assertEquals(0, view.length()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/bytes/SlicePoolTest.java b/flash/src/test/java/dev/relism/flash/bytes/SlicePoolTest.java new file mode 100644 index 0000000..9333a82 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/bytes/SlicePoolTest.java @@ -0,0 +1,62 @@ +package dev.relism.flash.bytes; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +class SlicePoolTest { + + private static byte[] bytes(String s) { return s.getBytes(StandardCharsets.US_ASCII); } + + @Test + void acquire_repositionsAndReturnsRequestedRange() { + SlicePool pool = new SlicePool(4); + byte[] buf = bytes("hello world"); + PooledSlice slice = pool.acquire(buf, 6, 5); + assertEquals(5, slice.length()); + assertEquals('w', (char) slice.byteAt(0)); + assertEquals('d', (char) slice.byteAt(4)); + } + + @Test + void acquire_withinPoolSize_returnsDistinctLiveSlices() { + SlicePool pool = new SlicePool(4); + byte[] buf = bytes("abcdefgh"); + PooledSlice a = pool.acquire(buf, 0, 1); // 'a' + PooledSlice b = pool.acquire(buf, 1, 1); // 'b' + PooledSlice c = pool.acquire(buf, 2, 1); // 'c' + // All three still valid simultaneously — the pool hasn't wrapped yet (size 4). + assertEquals('a', (char) a.byteAt(0)); + assertEquals('b', (char) b.byteAt(0)); + assertEquals('c', (char) c.byteAt(0)); + } + + @Test + void wraparoundAliasesThePreviouslyReturnedSlice() { + // Demonstrates the documented hazard: retaining a slice past `size` further acquire() + // calls observes it silently repositioned to unrelated data. + SlicePool pool = new SlicePool(2); + byte[] buf = bytes("AABB"); + PooledSlice first = pool.acquire(buf, 0, 2); // "AA" + assertEquals('A', (char) first.byteAt(0)); + + pool.acquire(buf, 2, 2); // "BB" — slot 2, pool size 2 so this is still a fresh slot + PooledSlice thirdCall = pool.acquire(buf, 2, 2); // wraps back to `first`'s slot + assertSame(first, thirdCall, "pool of size 2 must reuse the first slot on the 3rd acquire()"); + // `first` is now silently "BB", not "AA" — the documented lifetime contract in action. + assertEquals('B', (char) first.byteAt(0)); + } + + @Test + void constructor_rejectsNonPositiveSize() { + assertThrows(IllegalArgumentException.class, () -> new SlicePool(0)); + assertThrows(IllegalArgumentException.class, () -> new SlicePool(-1)); + } + + @Test + void size_reportsConstructedCapacity() { + assertEquals(4, new SlicePool(4).size()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http/ContentTypeHpackTest.java b/flash/src/test/java/dev/relism/flash/http/ContentTypeHpackTest.java new file mode 100644 index 0000000..c4ffea5 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http/ContentTypeHpackTest.java @@ -0,0 +1,34 @@ +package dev.relism.flash.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.fpr.core.ByteView; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class ContentTypeHpackTest { + @Test + void everyNonEmptyTypeHasAValidPrecompiledField() { + for (ContentType type : ContentType.values()) { + if (type == ContentType.NONE) continue; + AtomicReference decoded = new AtomicReference<>(); + byte[] block = type.getHpackBytes(); + new HpackDecoder() + .decode( + block, + 0, + block.length, + (name, value, never) -> decoded.set(text(name) + "=" + text(value))); + assertEquals( + "content-type=" + new String(type.getBytes(), StandardCharsets.US_ASCII), decoded.get()); + } + } + + private static String text(ByteView view) { + byte[] bytes = new byte[view.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http/DateHeaderTest.java b/flash/src/test/java/dev/relism/flash/http/DateHeaderTest.java new file mode 100644 index 0000000..7d26051 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http/DateHeaderTest.java @@ -0,0 +1,15 @@ +package dev.relism.flash.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.ZoneOffset; +import java.time.ZonedDateTime; +import org.junit.jupiter.api.Test; + +class DateHeaderTest { + @Test + void imfFixdateAlwaysUsesTwoDigitDayOfMonth() { + ZonedDateTime thirdOfMonth = ZonedDateTime.of(2026, 8, 3, 7, 5, 9, 0, ZoneOffset.UTC); + assertEquals("Mon, 03 Aug 2026 07:05:09 GMT", DateHeader.format(thirdOfMonth)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http/HopByHopHeaderTest.java b/flash/src/test/java/dev/relism/flash/http/HopByHopHeaderTest.java new file mode 100644 index 0000000..1683e17 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http/HopByHopHeaderTest.java @@ -0,0 +1,55 @@ +package dev.relism.flash.http; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.flash.http.HopByHopHeaders.Protocol; +import dev.relism.flash.models.MutableHeaderMap; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class HopByHopHeaderTest { + @Test + void sharedPolicyCoversAllFourProtocolConversions() { + for (Protocol sourceProtocol : Protocol.values()) { + for (Protocol targetProtocol : Protocol.values()) { + MutableHeaderMap source = new MutableHeaderMap(); + add(source, "connection", "x-private, keep-alive"); + add(source, "x-private", "secret"); + add(source, "upgrade", "websocket"); + add(source, "te", "trailers"); + add(source, "x-end-to-end", "yes"); + + assertFalse(forward(source, "connection", "x-private", sourceProtocol, targetProtocol)); + assertFalse(forward(source, "x-private", "secret", sourceProtocol, targetProtocol)); + assertFalse(forward(source, "upgrade", "websocket", sourceProtocol, targetProtocol)); + assertTrue(forward(source, "x-end-to-end", "yes", sourceProtocol, targetProtocol)); + assertTrue(forward(source, "te", "trailers", sourceProtocol, Protocol.HTTP_2)); + assertFalse(forward(source, "te", "trailers", sourceProtocol, Protocol.HTTP_1_1)); + } + } + } + + private static boolean forward( + MutableHeaderMap source, + String name, + String value, + Protocol sourceProtocol, + Protocol targetProtocol) { + byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII); + byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII); + PooledSlice nameView = new PooledSlice(); + PooledSlice valueView = new PooledSlice(); + nameView.reset(nameBytes, 0, nameBytes.length); + valueView.reset(valueBytes, 0, valueBytes.length); + return HopByHopHeaders.shouldForward( + source, nameView, valueView, sourceProtocol, targetProtocol); + } + + private static void add(MutableHeaderMap headers, String name, String value) { + byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII); + byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII); + headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http/Http1LimitsTest.java b/flash/src/test/java/dev/relism/flash/http/Http1LimitsTest.java new file mode 100644 index 0000000..24da349 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http/Http1LimitsTest.java @@ -0,0 +1,24 @@ +package dev.relism.flash.http; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class Http1LimitsTest { + + @Test + void everyLimitIsPositive() { + assertTrue(Http1Limits.MAX_CONTENT_LENGTH > 0); + assertTrue(Http1Limits.MAX_HEADER_COUNT > 0); + assertTrue(Http1Limits.MAX_HEADER_NAME_LENGTH > 0); + assertTrue(Http1Limits.MAX_HEADER_VALUE_LENGTH > 0); + assertTrue(Http1Limits.MAX_REQUEST_LINE_LENGTH > 0); + } + + @Test + void requestLineFitsInsideMaxHeaderValueOrderOfMagnitude() { + // Sanity: the request-line bound should not dwarf the total per-header bound to the + // point of being meaningless as a distinct limit. + assertTrue(Http1Limits.MAX_REQUEST_LINE_LENGTH <= Http1Limits.MAX_CONTENT_LENGTH); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http/HttpStatusHpackTest.java b/flash/src/test/java/dev/relism/flash/http/HttpStatusHpackTest.java new file mode 100644 index 0000000..c77af34 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http/HttpStatusHpackTest.java @@ -0,0 +1,38 @@ +package dev.relism.flash.http; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.fpr.core.ByteView; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class HttpStatusHpackTest { + @Test + void everyStatusHasAValidPrecompiledField() { + for (HttpStatus status : HttpStatus.values()) { + AtomicReference decoded = new AtomicReference<>(); + byte[] block = status.hpackBytes(); + new HpackDecoder() + .decode( + block, + 0, + block.length, + (name, value, never) -> decoded.set(text(name) + "=" + text(value))); + assertEquals(":status=" + status.code(), decoded.get()); + } + } + + @Test + void commonStaticStatusIsOneByte() { + assertEquals(1, HttpStatus.OK.hpackBytes().length); + assertEquals(0x88, HttpStatus.OK.hpackBytes()[0] & 0xff); + } + + private static String text(ByteView view) { + byte[] bytes = new byte[view.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java b/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java index 9256ce0..87a61b1 100644 --- a/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java +++ b/flash/src/test/java/dev/relism/flash/http/HttpStatusTest.java @@ -40,4 +40,25 @@ class HttpStatusTest { assertNull(HttpStatus.reasonForCode(999)); assertNull(HttpStatus.reasonForCode(0)); } + + + @Test + void statusesAboveThePreviousHandMaintainedBound_workCorrectly() { + // The bound used to be hardcoded at 504; any of these (all >504, all needed by h1 + // hardening or h2) used to throw ArrayIndexOutOfBoundsException from the static + // initializer at class-load time. + assertArrayEquals("421 Misdirected Request".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(421)); + assertArrayEquals("431 Request Header Fields Too Large".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(431)); + assertArrayEquals("505 HTTP Version Not Supported".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(505)); + assertArrayEquals("507 Insufficient Storage".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(507)); + assertArrayEquals("511 Network Authentication Required".getBytes(StandardCharsets.UTF_8), HttpStatus.bytesForCode(511)); + } + + @Test + void everyEnumConstant_hasAWorkingBytesForCodeEntry() { + for (HttpStatus s : HttpStatus.values()) { + assertNotNull(HttpStatus.bytesForCode(s.code()), s.name()); + assertNotNull(HttpStatus.reasonForCode(s.code()), s.name()); + } + } } diff --git a/flash/src/test/java/dev/relism/flash/http1/Http1ResponseWriterTest.java b/flash/src/test/java/dev/relism/flash/http1/Http1ResponseWriterTest.java new file mode 100644 index 0000000..fd885e4 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http1/Http1ResponseWriterTest.java @@ -0,0 +1,253 @@ +package dev.relism.flash.http1; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.models.Response; +import dev.relism.flash.transport.ConnectionScratch; +import dev.relism.flash.transport.ScratchPool; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; + +import static org.junit.jupiter.api.Assertions.*; + +class Http1ResponseWriterTest { + + private static ConnectionScratch scratch() { + return new ScratchPool().acquire(); + } + + private static String write(Response response, HttpMethod method, boolean keepAlive, boolean sendDate) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Http1ResponseWriter.writeResponse(out, response, method, keepAlive, sendDate, scratch()); + return out.toString(StandardCharsets.UTF_8); + } + + + @Test + void head_reportsContentLengthButWritesNoBody() throws IOException { + Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN); + String raw = write(response, HttpMethod.HEAD, true, false); + + assertTrue(raw.contains("Content-Length: 11\r\n"), raw); + assertFalse(raw.contains("hello world"), raw); + } + + @Test + void get_writesTheBody_forComparison() throws IOException { + Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN); + String raw = write(response, HttpMethod.GET, true, false); + + assertTrue(raw.contains("Content-Length: 11\r\n"), raw); + assertTrue(raw.endsWith("hello world"), raw); + } + + + @Test + void status204_omitsContentLengthAndBody() throws IOException { + Response response = new Response(204, ContentType.NONE); + response.setBody("should never appear"); + String raw = write(response, HttpMethod.GET, true, false); + + assertFalse(raw.contains("Content-Length"), raw); + assertFalse(raw.contains("should never appear"), raw); + } + + @Test + void status304_omitsContentLengthAndBody() throws IOException { + Response response = new Response(304, ContentType.NONE); + response.setBody("should never appear"); + String raw = write(response, HttpMethod.GET, true, false); + + assertFalse(raw.contains("Content-Length"), raw); + assertFalse(raw.contains("should never appear"), raw); + } + + @Test + void status1xx_omitsContentLengthAndBody() throws IOException { + Response response = new Response(103, ContentType.NONE); + response.setBody("should never appear"); + String raw = write(response, HttpMethod.GET, true, false); + + assertFalse(raw.contains("Content-Length"), raw); + assertFalse(raw.contains("should never appear"), raw); + } + + @Test + void status200_stillCarriesContentLength_forComparison() throws IOException { + Response response = new Response(200, "x", ContentType.TEXT_PLAIN); + String raw = write(response, HttpMethod.GET, true, false); + assertTrue(raw.contains("Content-Length: 1\r\n"), raw); + } + + + @Test + void contentTypeNone_omitsContentTypeLine() throws IOException { + Response response = new Response(200, ContentType.NONE); + String raw = write(response, HttpMethod.GET, true, false); + assertFalse(raw.contains("Content-Type"), raw); + } + + @Test + void contentTypeTextPlain_includesContentTypeLine() throws IOException { + Response response = new Response(200, "x", ContentType.TEXT_PLAIN); + String raw = write(response, HttpMethod.GET, true, false); + assertTrue(raw.contains("Content-Type: text/plain\r\n"), raw); + } + + + @Test + void sendDateTrue_includesDateHeader() throws IOException { + Response response = new Response(200, "x", ContentType.TEXT_PLAIN); + String raw = write(response, HttpMethod.GET, true, true); + assertTrue(raw.contains("Date: "), raw); + // RFC 9110 IMF-fixdate, e.g. "Date: Tue, 03 Jun 2008 11:05:30 GMT\r\n" + assertTrue(raw.matches("(?s).*Date: [A-Za-z]{3}, \\d{2} [A-Za-z]{3} \\d{4} \\d{2}:\\d{2}:\\d{2} GMT\\r\\n.*"), raw); + } + + @Test + void sendDateFalse_omitsDateHeader() throws IOException { + Response response = new Response(200, "x", ContentType.TEXT_PLAIN); + String raw = write(response, HttpMethod.GET, true, false); + assertFalse(raw.contains("Date: "), raw); + } + + // --- Connection header -------------------------------------------------------- + + @Test + void keepAlive_writesKeepAliveConnectionHeader() throws IOException { + Response response = new Response(200, "x", ContentType.TEXT_PLAIN); + String raw = write(response, HttpMethod.GET, true, false); + assertTrue(raw.contains("Connection: keep-alive\r\n"), raw); + } + + @Test + void notKeepAlive_writesCloseConnectionHeader() throws IOException { + Response response = new Response(200, "x", ContentType.TEXT_PLAIN); + String raw = write(response, HttpMethod.GET, false, false); + assertTrue(raw.contains("Connection: close\r\n"), raw); + } + + + /** Counts calls to {@code write(byte[], int, int)} — the only overload {@link Http1ResponseWriter} uses. */ + private static final class CountingOutputStream extends java.io.OutputStream { + final ByteArrayOutputStream sink = new ByteArrayOutputStream(); + int arrayWriteCalls; + + @Override public void write(int b) { sink.write(b); } + + @Override + public void write(byte[] b, int off, int len) { + arrayWriteCalls++; + sink.write(b, off, len); + } + } + + @Test + void smallFixedBody_isWrittenInExactlyOneCall() throws IOException { + CountingOutputStream out = new CountingOutputStream(); + Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN); + Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch()); + + assertEquals(1, out.arrayWriteCalls, "head + small body must leave in a single write() call"); + assertTrue(out.sink.toString(StandardCharsets.UTF_8).endsWith("hello world")); + } + + @Test + void bodyAboveInlineThreshold_isWrittenInTwoCalls() throws IOException { + CountingOutputStream out = new CountingOutputStream(); + byte[] bigBody = new byte[dev.relism.flash.http.Http1Limits.INLINE_BODY_THRESHOLD + 1]; + Arrays.fill(bigBody, (byte) 'x'); + Response response = new Response(200, bigBody, ContentType.BINARY); + Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch()); + + assertEquals(2, out.arrayWriteCalls, "head and an over-threshold body are written separately"); + assertTrue(out.sink.toString(StandardCharsets.UTF_8).endsWith("x".repeat(bigBody.length))); + } + + @Test + void headResponse_stillOneCall_noBodyBytes() throws IOException { + CountingOutputStream out = new CountingOutputStream(); + Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN); + Http1ResponseWriter.writeResponse(out, response, HttpMethod.HEAD, true, false, scratch()); + + assertEquals(1, out.arrayWriteCalls); + assertFalse(out.sink.toString(StandardCharsets.UTF_8).contains("hello world")); + } + + // --- Streaming body close-on-every-exit ----------------------------------------- + + /** Tracks whether {@code close()} was called, regardless of how the stream was read. */ + private static final class TrackingInputStream extends ByteArrayInputStream { + boolean closed; + + TrackingInputStream(byte[] buf) { + super(buf); + } + + @Override + public void close() throws IOException { + closed = true; + super.close(); + } + } + + /** Simulates a client disconnecting mid-transfer: the Nth write() call throws. */ + private static final class FailingOutputStream extends OutputStream { + private final int failAfterCalls; + private int calls; + + FailingOutputStream(int failAfterCalls) { + this.failAfterCalls = failAfterCalls; + } + + @Override public void write(int b) {} + + @Override + public void write(byte[] b, int off, int len) throws IOException { + calls++; + if (calls > failAfterCalls) throw new IOException("simulated client disconnect"); + } + } + + @Test + void chunkedStreamingBody_isClosed_onCleanCompletion() throws IOException { + TrackingInputStream stream = new TrackingInputStream("hello world".getBytes(StandardCharsets.UTF_8)); + Response response = new Response(200, ContentType.TEXT_PLAIN).chunked(stream); + Http1ResponseWriter.writeResponse(new ByteArrayOutputStream(), response, HttpMethod.GET, true, false, scratch()); + + assertTrue(stream.closed, "a fully-relayed streaming body must be closed"); + } + + @Test + void fixedLengthStreamingBody_isClosed_evenWhenTheClientDisconnectsMidTransfer() { + // Regression test: a handler's streaming body (e.g. a reverse proxy relaying a pooled + // upstream connection's response) must have close() called even when the downstream + // write fails partway through — otherwise a resource that's only released from close(), + // not from observing EOF on a read() the failed write means it never reaches, leaks. + TrackingInputStream stream = new TrackingInputStream("hello world".getBytes(StandardCharsets.UTF_8)); + Response response = new Response(200, ContentType.TEXT_PLAIN).stream(stream, 11); + FailingOutputStream out = new FailingOutputStream(1); // 1st call writes the head, 2nd (body) fails + + assertThrows(IOException.class, () -> + Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch())); + assertTrue(stream.closed, "the streaming body must be closed even when the write to the client fails"); + } + + @Test + void chunkedStreamingBody_isClosed_evenWhenTheClientDisconnectsMidTransfer() { + TrackingInputStream stream = new TrackingInputStream("hello world".getBytes(StandardCharsets.UTF_8)); + Response response = new Response(200, ContentType.TEXT_PLAIN).chunked(stream); + FailingOutputStream out = new FailingOutputStream(1); // 1st call writes the head, 2nd (body) fails + + assertThrows(IOException.class, () -> + Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch())); + assertTrue(stream.closed, "the streaming body must be closed even when the write to the client fails"); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/CurlInteropTest.java b/flash/src/test/java/dev/relism/flash/http2/CurlInteropTest.java new file mode 100644 index 0000000..d269a74 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/CurlInteropTest.java @@ -0,0 +1,120 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.tls.TestKeystores; +import dev.relism.flash.tls.TlsConfig; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; + +@EnabledIfSystemProperty(named = "curl.executable", matches = ".+") +class CurlInteropTest { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void tlsGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception { + int port = freePort(); + Path keystore = + TestKeystores.build( + directory, + "curl.p12", + "changeit", + TestKeystores.Entry.of("server", "localhost", "localhost")); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .tls(TlsConfig.keystore(keystore, "changeit")) + .http2Enabled(true) + .build()); + exercise(directory, "https://localhost:" + port, "--http2", "--insecure"); + } + + @Test + void cleartextGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .build()); + exercise(directory, "http://127.0.0.1:" + port, "--http2-prior-knowledge"); + } + + private void exercise(Path directory, String origin, String... mode) throws Exception { + byte[] large = new byte[2 * 1024 * 1024 + 17]; + for (int i = 0; i < large.length; i++) large[i] = (byte) (i * 31); + app.get("/get", (request, response) -> "curl-get"); + app.post("/post", (request, response) -> request.body().bytes()); + app.get("/large", (request, response) -> response.body(large)); + app.start(); + + Path upload = directory.resolve("upload.bin"); + Path output = directory.resolve("output.bin"); + Files.write(upload, large); + assertArrayEquals( + "curl-get".getBytes(StandardCharsets.US_ASCII), + runCurl(output, origin + "/get", mode)); + assertArrayEquals( + "small-post".getBytes(StandardCharsets.US_ASCII), + runCurl(output, origin + "/post", append(mode, "--data-binary", "small-post"))); + assertArrayEquals( + large, + runCurl(output, origin + "/post", append(mode, "--data-binary", "@" + upload))); + assertArrayEquals(large, runCurl(output, origin + "/large", mode)); + } + + private static byte[] runCurl(Path output, String url, String... options) throws Exception { + String executable = System.getProperty("curl.executable"); + List command = new ArrayList<>(); + command.add(executable); + command.add("--silent"); + command.add("--show-error"); + command.add("--fail"); + command.addAll(List.of(options)); + command.add("--output"); + command.add(output.toString()); + command.add(url); + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + assertTrue(process.waitFor(Duration.ofSeconds(30).toMillis(), TimeUnit.MILLISECONDS)); + String diagnostics = + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.exitValue(), diagnostics); + return Files.readAllBytes(output); + } + + private static String[] append(String[] values, String... suffix) { + String[] result = new String[values.length + suffix.length]; + System.arraycopy(values, 0, result, 0, values.length); + System.arraycopy(suffix, 0, result, values.length, suffix.length); + return result; + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/GrpcInteropTest.java b/flash/src/test/java/dev/relism/flash/http2/GrpcInteropTest.java new file mode 100644 index 0000000..1d06280 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/GrpcInteropTest.java @@ -0,0 +1,166 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; + +@Tag("interop") +@EnabledIfSystemProperty(named = "grpcurl.executable", matches = ".+") +class GrpcInteropTest { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void grpcurlCompletesUnaryStreamingAndErrorCalls(@TempDir Path directory) throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .build()); + app.post("/flash.test.Echo/Unary", (request, response) -> + response.type("application/grpc") + .body(request.body().bytes()) + .trailer("grpc-status", "0")); + app.post("/flash.test.Echo/Stream", (request, response) -> { + byte[] message = request.body().bytes(); + return response.type("application/grpc").streaming(stream -> { + try { + for (int i = 0; i < 3; i++) stream.write(message, 0, message.length); + stream.trailer("grpc-status", "0"); + } catch (Exception failure) { + throw new RuntimeException(failure); + } + }); + }); + app.post("/flash.test.Echo/Fail", (request, response) -> + response.type("application/grpc") + .trailer("grpc-status", "3") + .trailer("grpc-message", "invalid request")); + app.post("/flash.test.Echo/ClientStream", (request, response) -> + response.type("application/grpc") + .body(firstGrpcMessage(request.body().bytes())) + .trailer("grpc-status", "0")); + app.post("/flash.test.Echo/Bidi", (request, response) -> + response.type("application/grpc") + .body(request.body().bytes()) + .trailer("grpc-status", "0")); + app.start(); + + Path proto = directory.resolve("echo.proto"); + Files.writeString(proto, """ + syntax = "proto3"; + package flash.test; + service Echo { + rpc Unary (Message) returns (Message); + rpc Stream (Message) returns (stream Message); + rpc ClientStream (stream Message) returns (Message); + rpc Bidi (stream Message) returns (stream Message); + rpc Fail (Message) returns (Message); + } + message Message { string value = 1; } + """); + + Result unary = call(directory, port, "Unary"); + assertEquals(0, unary.exitCode); + assertTrue(unary.output.contains("hello"), unary.output); + + Result streaming = call(directory, port, "Stream"); + assertEquals(0, streaming.exitCode); + assertEquals(3, occurrences(streaming.output, "hello"), streaming.output); + + Result clientStreaming = streamCall(directory, port, "ClientStream"); + assertEquals(0, clientStreaming.exitCode, clientStreaming.output); + assertEquals(1, occurrences(clientStreaming.output, "hello"), clientStreaming.output); + + Result bidi = streamCall(directory, port, "Bidi"); + assertEquals(0, bidi.exitCode, bidi.output); + assertEquals(2, occurrences(bidi.output, "hello"), bidi.output); + + Result error = call(directory, port, "Fail"); + assertTrue(error.exitCode != 0); + assertTrue(error.output.contains("InvalidArgument"), error.output); + assertTrue(error.output.contains("invalid request"), error.output); + } + + private static Result call(Path directory, int port, String method) throws Exception { + return call(directory, port, method, "{\"value\":\"hello\"}", false); + } + + private static Result streamCall(Path directory, int port, String method) throws Exception { + return call( + directory, + port, + method, + "{\"value\":\"hello\"}\n{\"value\":\"hello\"}\n", + true); + } + + private static Result call( + Path directory, int port, String method, String input, boolean stdin) throws Exception { + Process process = new ProcessBuilder( + System.getProperty("grpcurl.executable"), + "-plaintext", + "-import-path", directory.toString(), + "-proto", "echo.proto", + "-d", stdin ? "@" : input, + "127.0.0.1:" + port, + "flash.test.Echo/" + method) + .redirectErrorStream(true) + .start(); + if (stdin) { + process.getOutputStream().write(input.getBytes(StandardCharsets.UTF_8)); + } + process.getOutputStream().close(); + assertTrue(process.waitFor(10, TimeUnit.SECONDS), "grpcurl timed out"); + return new Result(process.exitValue(), + new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8)); + } + + private static byte[] firstGrpcMessage(byte[] body) { + if (body.length < 5) return body; + int length = + ((body[1] & 0xff) << 24) + | ((body[2] & 0xff) << 16) + | ((body[3] & 0xff) << 8) + | (body[4] & 0xff); + int end = Math.min(body.length, 5 + length); + return java.util.Arrays.copyOf(body, end); + } + + private static int occurrences(String text, String needle) { + int count = 0; + int position = 0; + while ((position = text.indexOf(needle, position)) >= 0) { + count++; + position += needle.length(); + } + return count; + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private record Result(int exitCode, String output) {} +} diff --git a/flash/src/test/java/dev/relism/flash/http2/H2LoadMeasurementTest.java b/flash/src/test/java/dev/relism/flash/http2/H2LoadMeasurementTest.java new file mode 100644 index 0000000..f0dee81 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/H2LoadMeasurementTest.java @@ -0,0 +1,141 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; + +@Tag("benchmark") +@EnabledIfSystemProperty(named = "h2load.executable", matches = ".+") +@EnabledIfSystemProperty(named = "nghttpd.executable", matches = ".+") +class H2LoadMeasurementTest { + private static final int[] CONNECTIONS = {1, 10, 100, 1_000}; + private static final int[] STREAMS = {1, 10, 100}; + private static final Pattern RATE = Pattern.compile("([0-9.]+) req/s"); + private static final Pattern REQUESTS = + Pattern.compile("requests: (\\d+) total, .*? (\\d+) succeeded, (\\d+) failed"); + + private FlashApp app; + private Process reference; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + if (reference != null) reference.destroyForcibly(); + } + + @Test + void measureFlashAndNghttpdMatrix(@TempDir Path directory) throws Exception { + int flashPort = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(flashPort) + .http2CleartextEnabled(true) + .h2MaxStreamsCreatedPerInterval(Integer.MAX_VALUE) + .h2MaxStreamsPerConnection(0) + .build()); + app.get("/index.html", (request, response) -> "flash-load"); + app.start(); + + int referencePort = freePort(); + Files.writeString(directory.resolve("index.html"), "flash-load"); + ProcessBuilder server = + new ProcessBuilder( + System.getProperty("nghttpd.executable"), + "--no-tls", + "--max-concurrent-streams=128", + "-d", + directory.toString(), + Integer.toString(referencePort)); + applyLibraryPath(server); + reference = server.redirectErrorStream(true).start(); + Thread.sleep(200); + + System.out.println( + "implementation,connections,requested_streams,effective_streams,requests,requests_per_second"); + for (int connections : CONNECTIONS) { + for (int streams : STREAMS) { + int requests = Math.max(1_000, connections * streams); + int effectiveStreams = + Math.max( + 1, + Math.min( + Math.min(streams, Http2Limits.MAX_CONCURRENT_STREAMS), + 4_096 / connections)); + measure("flash", flashPort, connections, streams, effectiveStreams, requests); + measure("nghttpd", referencePort, connections, streams, effectiveStreams, requests); + } + } + } + + private static void measure( + String implementation, + int port, + int connections, + int requestedStreams, + int effectiveStreams, + int requests) + throws Exception { + List command = new ArrayList<>(); + command.add(System.getProperty("h2load.executable")); + command.add("-n"); + command.add(Integer.toString(requests)); + command.add("-c"); + command.add(Integer.toString(connections)); + command.add("-m"); + command.add(Integer.toString(effectiveStreams)); + command.add("-t"); + command.add(Integer.toString(Math.min(8, connections))); + command.add("http://127.0.0.1:" + port + "/index.html"); + ProcessBuilder builder = new ProcessBuilder(command).redirectErrorStream(true); + applyLibraryPath(builder); + Process process = builder.start(); + assertTrue(process.waitFor(Duration.ofMinutes(2).toMillis(), TimeUnit.MILLISECONDS)); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.exitValue(), output); + Matcher requestsResult = REQUESTS.matcher(output); + assertTrue(requestsResult.find(), output); + assertEquals(requests, Integer.parseInt(requestsResult.group(1)), output); + assertEquals(requests, Integer.parseInt(requestsResult.group(2)), output); + assertEquals(0, Integer.parseInt(requestsResult.group(3)), output); + Matcher rate = RATE.matcher(output); + assertTrue(rate.find(), output); + System.out.printf( + "%s,%d,%d,%d,%d,%s%n", + implementation, + connections, + requestedStreams, + effectiveStreams, + requests, + rate.group(1)); + } + + private static void applyLibraryPath(ProcessBuilder builder) { + String path = System.getProperty("nghttp.library.path"); + if (path != null) builder.environment().put("LD_LIBRARY_PATH", path); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/H2SpecComplianceTest.java b/flash/src/test/java/dev/relism/flash/http2/H2SpecComplianceTest.java new file mode 100644 index 0000000..bbd29cb --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/H2SpecComplianceTest.java @@ -0,0 +1,155 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.tls.TestKeystores; +import dev.relism.flash.tls.TlsConfig; +import java.nio.charset.StandardCharsets; +import java.net.ServerSocket; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.LockSupport; +import javax.xml.parsers.DocumentBuilderFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; + +@EnabledIfSystemProperty(named = "h2spec.executable", matches = ".+") +class H2SpecComplianceTest { + private static final String VERSION = "2.6.0"; + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void cleartextSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .build()); + registerProbeRoutes(); + app.start(); + + runH2Spec(port, false, directory.resolve("h2spec-h2c.xml")); + } + + @Test + void tlsSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception { + int port = freePort(); + Path keystore = + TestKeystores.build( + directory, + "h2spec.p12", + "changeit", + TestKeystores.Entry.of("server", "localhost", "localhost")); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .tls(TlsConfig.keystore(keystore, "changeit")) + .http2Enabled(true) + .build()); + registerProbeRoutes(); + app.start(); + + runH2Spec(port, true, directory.resolve("h2spec-tls.xml")); + } + + private void registerProbeRoutes() { + app.get("/", (request, response) -> probeResponse()); + app.post("/", (request, response) -> probeResponse()); + } + + private static String probeResponse() { + // h2spec deliberately writes illegal follow-up frames immediately after END_STREAM. Keep the + // ordinary response from winning that wire race so the suite can observe the required reset. + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(20)); + return "flash-compliance"; + } + + private static void runH2Spec(int port, boolean tls, Path report) throws Exception { + String executable = System.getProperty("h2spec.executable"); + ProcessResult version = run(List.of(executable, "--version"), Duration.ofSeconds(5)); + assertEquals(0, version.exitCode, version.output); + assertTrue(version.output.contains(VERSION), "unexpected h2spec version: " + version.output); + + List command = new ArrayList<>(); + command.add(executable); + command.add("--host"); + command.add(tls ? "localhost" : "127.0.0.1"); + command.add("--port"); + command.add(Integer.toString(port)); + command.add("--timeout"); + command.add("5"); + command.add("--junit-report"); + command.add(report.toString()); + if (tls) { + command.add("--tls"); + command.add("--insecure"); + } else { + command.add("generic"); + command.add("hpack"); + command.add("http2/3.5/1"); + command.add("http2/4"); + command.add("http2/5"); + command.add("http2/6"); + command.add("http2/7"); + command.add("http2/8"); + } + + ProcessResult result = run(command, Duration.ofMinutes(3)); + assertEquals(0, result.exitCode, result.output); + assertReportHasNoFailuresOrSkips(report, result.output); + } + + private static ProcessResult run(List command, Duration timeout) throws Exception { + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + boolean completed = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS); + if (!completed) { + process.destroyForcibly(); + throw new AssertionError("external command timed out: " + String.join(" ", command)); + } + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + return new ProcessResult(process.exitValue(), output); + } + + private static void assertReportHasNoFailuresOrSkips(Path report, String output) + throws Exception { + assertTrue(Files.isRegularFile(report), "h2spec did not create its JUnit report\n" + output); + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + Document document = factory.newDocumentBuilder().parse(report.toFile()); + NodeList failures = document.getElementsByTagName("failure"); + NodeList errors = document.getElementsByTagName("error"); + NodeList skipped = document.getElementsByTagName("skipped"); + assertEquals(0, failures.getLength(), output); + assertEquals(0, errors.getLength(), output); + assertEquals(0, skipped.getLength(), output); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private record ProcessResult(int exitCode, String output) {} +} diff --git a/flash/src/test/java/dev/relism/flash/http2/H2WebSocketTestClient.java b/flash/src/test/java/dev/relism/flash/http2/H2WebSocketTestClient.java new file mode 100644 index 0000000..179c743 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/H2WebSocketTestClient.java @@ -0,0 +1,292 @@ +package dev.relism.flash.http2; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.frame.FrameWriteBuffer; +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.flash.http2.hpack.HpackEncoder; +import dev.relism.flash.websocket.WebSocketFrame; +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.Socket; +import java.nio.charset.StandardCharsets; + +/** Minimal RFC 8441 peer used only by the live WebSocket-over-h2 tests. */ +final class H2WebSocketTestClient implements Closeable { + private static final int WINDOW = 2 * 1024 * 1024; + + private final Socket socket; + private final InputStream input; + private final OutputStream output; + private final ByteArrayOutputStream responseData = new ByteArrayOutputStream(); + private int connectionWindow = 65_535; + private int streamWindow = 65_535; + private int peerMaxFrame = 16_384; + private boolean connectProtocolAdvertised; + private boolean responseEnded; + + H2WebSocketTestClient(String host, int port, String path) throws Exception { + socket = new Socket(host, port); + socket.setSoTimeout(5_000); + input = socket.getInputStream(); + output = socket.getOutputStream(); + writePreface(); + awaitSettings(); + writeConnect(host + ":" + port, path); + int status = awaitStatus(); + if (status != 200) throw new IOException("extended CONNECT returned " + status); + } + + boolean connectProtocolAdvertised() { + return connectProtocolAdvertised; + } + + void sendText(String value) throws Exception { + sendWebSocketFrame(true, WebSocketFrame.OP_TEXT, value.getBytes(StandardCharsets.UTF_8), false); + } + + void sendFragmentedText(String first, String second) throws Exception { + sendWebSocketFrame( + false, WebSocketFrame.OP_TEXT, first.getBytes(StandardCharsets.UTF_8), false); + sendWebSocketFrame( + true, WebSocketFrame.OP_CONTINUATION, second.getBytes(StandardCharsets.UTF_8), false); + } + + void sendBinary(byte[] value) throws Exception { + sendWebSocketFrame(true, WebSocketFrame.OP_BINARY, value, false); + } + + byte[] readMessage(byte expectedOpcode) throws Exception { + responseData.reset(); + while (true) { + readAndHandleFrame(); + byte[] bytes = responseData.toByteArray(); + if (bytes.length < 2) continue; + int opcode = bytes[0] & 0x0f; + int marker = bytes[1] & 0x7f; + int headerLength; + long payloadLength; + if (marker < 126) { + headerLength = 2; + payloadLength = marker; + } else if (marker == 126) { + if (bytes.length < 4) continue; + headerLength = 4; + payloadLength = ((bytes[2] & 0xff) << 8) | (bytes[3] & 0xff); + } else { + if (bytes.length < 10) continue; + headerLength = 10; + payloadLength = 0; + for (int i = 2; i < 10; i++) payloadLength = (payloadLength << 8) | (bytes[i] & 0xffL); + } + if (payloadLength > Integer.MAX_VALUE || bytes.length < headerLength + payloadLength) { + continue; + } + if (opcode != expectedOpcode) throw new IOException("unexpected WebSocket opcode " + opcode); + byte[] payload = new byte[(int) payloadLength]; + System.arraycopy(bytes, headerLength, payload, 0, payload.length); + return payload; + } + } + + void closeGracefully() throws Exception { + sendWebSocketFrame(true, WebSocketFrame.OP_CLOSE, new byte[] {3, (byte) 232}, true); + while (!responseEnded) readAndHandleFrame(); + } + + @Override + public void close() throws IOException { + socket.close(); + } + + private void writePreface() throws IOException { + output.write(Http2Preface.clientPreface()); + ByteWriter bytes = new ByteWriter(64); + FrameWriteBuffer frames = new FrameWriteBuffer(bytes); + frames.beginFrame(FrameType.SETTINGS, 0, 0); + bytes.writeUInt16(Http2Settings.ENABLE_PUSH); + bytes.writeUInt32(0); + bytes.writeUInt16(Http2Settings.INITIAL_WINDOW_SIZE); + bytes.writeUInt32(WINDOW); + frames.endFrame(); + frames.beginFrame(FrameType.WINDOW_UPDATE, 0, 0); + bytes.writeUInt31(WINDOW - 65_535); + frames.endFrame(); + output.write(bytes.array(), 0, bytes.length()); + } + + private void awaitSettings() throws Exception { + while (!connectProtocolAdvertised) { + WireFrame frame = readFrame(); + if (frame.type == FrameType.SETTINGS.code() && (frame.flags & FrameFlags.ACK) == 0) { + for (int offset = 0; offset < frame.payload.length; offset += 6) { + int id = ((frame.payload[offset] & 0xff) << 8) | (frame.payload[offset + 1] & 0xff); + int value = readInt(frame.payload, offset + 2); + if (id == Http2Settings.ENABLE_CONNECT_PROTOCOL && value == 1) { + connectProtocolAdvertised = true; + } else if (id == Http2Settings.INITIAL_WINDOW_SIZE) { + streamWindow = value; + } else if (id == Http2Settings.MAX_FRAME_SIZE) { + peerMaxFrame = value; + } + } + writeEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0); + } else { + handle(frame); + } + } + } + + private void writeConnect(String authority, String path) throws IOException { + ByteWriter bytes = new ByteWriter(256); + FrameWriteBuffer frame = new FrameWriteBuffer(bytes); + frame.beginFrame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1); + HpackEncoder.writeLiteralWithNameIndex( + bytes, 2, "CONNECT".getBytes(StandardCharsets.US_ASCII), false); + HpackEncoder.writeIndexed(bytes, 6); + HpackEncoder.writeLiteralWithNameIndex( + bytes, 1, authority.getBytes(StandardCharsets.US_ASCII), false); + HpackEncoder.writeLiteralWithNameIndex( + bytes, 4, path.getBytes(StandardCharsets.US_ASCII), false); + HpackEncoder.writeLiteral( + bytes, + ":protocol".getBytes(StandardCharsets.US_ASCII), + "websocket".getBytes(StandardCharsets.US_ASCII)); + frame.endFrame(); + output.write(bytes.array(), 0, bytes.length()); + } + + private int awaitStatus() throws Exception { + HpackDecoder decoder = new HpackDecoder(); + while (true) { + WireFrame frame = readFrame(); + if (frame.type != FrameType.HEADERS.code() || frame.streamId != 1) { + handle(frame); + continue; + } + int[] status = {0}; + decoder.decode( + frame.payload, + 0, + frame.payload.length, + (name, value, never) -> { + if (name.length() == 7 && name.byteAt(0) == ':') { + status[0] = + (value.byteAt(0) - '0') * 100 + + (value.byteAt(1) - '0') * 10 + + value.byteAt(2) + - '0'; + } + }); + return status[0]; + } + } + + private void sendWebSocketFrame(boolean fin, byte opcode, byte[] payload, boolean endStream) + throws Exception { + byte[] encoded = maskedFrame(fin, opcode, payload); + int offset = 0; + while (offset < encoded.length) { + while (connectionWindow <= 0 || streamWindow <= 0) readAndHandleFrame(); + int count = + Math.min( + encoded.length - offset, + Math.min(peerMaxFrame, Math.min(connectionWindow, streamWindow))); + writeData(encoded, offset, count, endStream && offset + count == encoded.length); + offset += count; + connectionWindow -= count; + streamWindow -= count; + } + } + + private void readAndHandleFrame() throws Exception { + handle(readFrame()); + } + + private void handle(WireFrame frame) throws IOException { + if (frame.type == FrameType.WINDOW_UPDATE.code()) { + int increment = readInt(frame.payload, 0) & 0x7fff_ffff; + if (frame.streamId == 0) connectionWindow += increment; + else if (frame.streamId == 1) streamWindow += increment; + } else if (frame.type == FrameType.DATA.code() && frame.streamId == 1) { + responseData.write(frame.payload); + responseEnded = (frame.flags & FrameFlags.END_STREAM) != 0; + } else if (frame.type == FrameType.SETTINGS.code() && (frame.flags & FrameFlags.ACK) == 0) { + writeEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0); + } else if (frame.type == FrameType.RST_STREAM.code() && frame.streamId == 1) { + throw new IOException("WebSocket stream reset with " + readInt(frame.payload, 0)); + } else if (frame.type == FrameType.GOAWAY.code()) { + throw new IOException("HTTP/2 connection closed with " + readInt(frame.payload, 4)); + } + } + + private void writeData(byte[] payload, int offset, int length, boolean endStream) + throws IOException { + ByteWriter bytes = new ByteWriter(length + 9); + FrameWriteBuffer frame = new FrameWriteBuffer(bytes); + frame.beginFrame(FrameType.DATA, endStream ? FrameFlags.END_STREAM : 0, 1); + bytes.writeBytes(payload, offset, length); + frame.endFrame(); + output.write(bytes.array(), 0, bytes.length()); + } + + private void writeEmpty(FrameType type, int flags, int streamId) throws IOException { + byte[] frame = {0, 0, 0, (byte) type.code(), (byte) flags, 0, 0, 0, (byte) streamId}; + output.write(frame); + } + + private WireFrame readFrame() throws IOException { + byte[] header = input.readNBytes(9); + if (header.length != 9) throw new EOFException("HTTP/2 connection closed between frames"); + int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + int streamId = + ((header[5] & 0x7f) << 24) + | ((header[6] & 0xff) << 16) + | ((header[7] & 0xff) << 8) + | (header[8] & 0xff); + byte[] payload = input.readNBytes(length); + if (payload.length != length) throw new EOFException("HTTP/2 frame truncated"); + return new WireFrame(header[3] & 0xff, header[4] & 0xff, streamId, payload); + } + + private static byte[] maskedFrame(boolean fin, byte opcode, byte[] payload) { + int lengthBytes = payload.length <= 125 ? 0 : payload.length <= 0xffff ? 2 : 8; + byte[] frame = new byte[2 + lengthBytes + 4 + payload.length]; + int position = 0; + frame[position++] = (byte) ((fin ? 0x80 : 0) | opcode); + if (lengthBytes == 0) { + frame[position++] = (byte) (0x80 | payload.length); + } else if (lengthBytes == 2) { + frame[position++] = (byte) (0x80 | 126); + frame[position++] = (byte) (payload.length >>> 8); + frame[position++] = (byte) payload.length; + } else { + frame[position++] = (byte) (0x80 | 127); + long payloadLength = payload.length; + for (int shift = 56; shift >= 0; shift -= 8) { + frame[position++] = (byte) (payloadLength >>> shift); + } + } + byte[] mask = {1, 2, 3, 4}; + System.arraycopy(mask, 0, frame, position, mask.length); + position += mask.length; + for (int i = 0; i < payload.length; i++) { + frame[position + i] = (byte) (payload[i] ^ mask[i & 3]); + } + return frame; + } + + private static int readInt(byte[] bytes, int offset) { + return ((bytes[offset] & 0xff) << 24) + | ((bytes[offset + 1] & 0xff) << 16) + | ((bytes[offset + 2] & 0xff) << 8) + | (bytes[offset + 3] & 0xff); + } + + private record WireFrame(int type, int flags, int streamId, byte[] payload) {} +} diff --git a/flash/src/test/java/dev/relism/flash/http2/H2cPriorKnowledgeTest.java b/flash/src/test/java/dev/relism/flash/http2/H2cPriorKnowledgeTest.java new file mode 100644 index 0000000..fdd6186 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/H2cPriorKnowledgeTest.java @@ -0,0 +1,133 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.flash.http2.hpack.HpackEncoder; +import java.io.InputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class H2cPriorKnowledgeTest { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void priorKnowledgeRequiresItsIndependentOptIn() throws Exception { + int disabledPort = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(disabledPort) + .http2Enabled(true) + .build()); + app.get("/", (request, response) -> "wrong protocol"); + app.start(); + + try (Socket socket = new Socket("127.0.0.1", disabledPort)) { + socket.setSoTimeout(2_000); + socket.getOutputStream().write(Http2Preface.clientPreface()); + byte[] prefix = socket.getInputStream().readNBytes(5); + assertArrayEquals("HTTP/".getBytes(StandardCharsets.US_ASCII), prefix); + } + app.stop().join(); + + int enabledPort = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(enabledPort) + .http2CleartextEnabled(true) + .build()); + app.get("/", (request, response) -> "h2c"); + app.start(); + + try (Socket socket = new Socket("127.0.0.1", enabledPort)) { + socket.setSoTimeout(5_000); + ByteWriter block = new ByteWriter(32); + HpackEncoder.writeIndexed(block, 2); // :method GET + HpackEncoder.writeIndexed(block, 6); // :scheme http + HpackEncoder.writeIndexed(block, 4); // :path / + HpackEncoder.writeLiteralWithNameIndex( + block, 1, ("127.0.0.1:" + enabledPort).getBytes(StandardCharsets.US_ASCII), false); + socket + .getOutputStream() + .write( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame( + FrameType.HEADERS, + FrameFlags.END_HEADERS | FrameFlags.END_STREAM, + 1, + Arrays.copyOf(block.array(), block.length())))); + socket.getOutputStream().flush(); + + assertEquals(200, readStatus(socket.getInputStream())); + assertEquals("h2c", new String(readData(socket.getInputStream()), StandardCharsets.UTF_8)); + } + } + + private static int readStatus(InputStream input) throws Exception { + HpackDecoder decoder = new HpackDecoder(); + while (true) { + Http2TestFrames.WireFrame frame = readFrame(input); + if (frame.type() != FrameType.HEADERS.code() || frame.streamId() != 1) continue; + int[] status = {0}; + decoder.decode( + frame.payload(), + 0, + frame.payload().length, + (name, value, never) -> { + if (name.length() == 7 && name.byteAt(0) == ':') { + status[0] = + (value.byteAt(0) - '0') * 100 + + (value.byteAt(1) - '0') * 10 + + value.byteAt(2) + - '0'; + } + }); + return status[0]; + } + } + + private static byte[] readData(InputStream input) throws Exception { + for (int i = 0; i < 12; i++) { + Http2TestFrames.WireFrame frame = readFrame(input); + if (frame.streamId() == 1 && frame.type() == FrameType.DATA.code() + && frame.payload().length != 0) return frame.payload(); + } + throw new AssertionError("missing h2c response DATA"); + } + + private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception { + byte[] header = input.readNBytes(9); + int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + byte[] payload = input.readNBytes(length); + return new Http2TestFrames.WireFrame( + header[3] & 0xff, header[4] & 0xff, Http2TestFrames.readInt(header, 5) & 0x7fff_ffff, + payload); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java new file mode 100644 index 0000000..c44a589 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2AbuseTest.java @@ -0,0 +1,315 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.hpack.HeaderListSizeException; +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.flash.http2.hpack.HpackEncoder; +import dev.relism.flash.http2.hpack.HpackHeaderBlock; +import dev.relism.flash.http2.message.PseudoHeaders; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.http2.frame.Http2FrameWriter; +import dev.relism.flash.transport.BufferedByteSource; +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.InputStream; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.io.ByteArrayOutputStream; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class Http2AbuseTest { + @Test + void rapidResetClosesConnectionWithEnhanceYourCalm() throws Exception { + List frames = new ArrayList<>(); + frames.add(Http2TestFrames.PREFACE); + frames.add(Http2TestFrames.settings()); + for (int i = 0; i <= Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL; i++) { + int streamId = i * 2 + 1; + frames.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, streamId, + new byte[0])); + frames.add(Http2TestFrames.frame(FrameType.RST_STREAM, 0, streamId, new byte[4])); + } + assertCalm(run(frames)); + } + + @Test + void streamCreationFloodIsBoundedIndependentlyOfResets() throws Exception { + List frames = new ArrayList<>(); + frames.add(Http2TestFrames.PREFACE); + frames.add(Http2TestFrames.settings()); + for (int i = 0; i <= Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL; i++) { + frames.add(Http2TestFrames.frame( + FrameType.HEADERS, FrameFlags.END_HEADERS, i * 2 + 1, new byte[0])); + } + assertCalm(run(frames)); + } + + @Test + void settingsAndPingFloodsAreRateLimited() throws Exception { + List settings = base(); + for (int i = 0; i <= Http2Limits.MAX_SETTINGS_PER_INTERVAL; i++) { + settings.add(Http2TestFrames.settings()); + } + assertCalm(run(settings)); + + List pings = base(); + for (int i = 0; i <= Http2Limits.MAX_PINGS_PER_INTERVAL; i++) { + pings.add(Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8])); + } + assertCalm(run(pings)); + } + + @Test + void aggregateNonProgressFrameFloodIsRateLimited() throws Exception { + List frames = base(); + byte[] priority = new byte[5]; + for (int i = 0; i <= Http2Limits.MAX_USELESS_FRAMES_PER_INTERVAL; i++) { + frames.add(Http2TestFrames.frame(FrameType.PRIORITY, 0, 1, priority)); + } + assertCalm(run(frames)); + } + + @Test + void operatorConnectionStreamAndByteBudgetsAreEnforced() throws Exception { + FlashConfiguration oneStream = FlashConfiguration.builder() + .h2MaxStreamsPerConnection(1).build(); + List streams = base(); + streams.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0])); + streams.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 3, new byte[0])); + assertCalm(runConfigured(streams, oneStream)); + + FlashConfiguration nineBytes = FlashConfiguration.builder() + .h2MaxBytesPerConnection(9).build(); + List bytes = base(); + bytes.add(Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8])); + assertCalm(runConfigured(bytes, nineBytes)); + } + + @Test + void optionalConnectionLifetimeBudgetRotatesTheConnection() throws Exception { + byte[] initial = Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings()); + ByteArrayInputStream delegate = new ByteArrayInputStream(initial); + InputStream stalled = new InputStream() { + @Override + public int read(byte[] target, int offset, int length) throws IOException { + if (delegate.available() > 0) return delegate.read(target, offset, length); + try { + Thread.sleep(5); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IOException(interrupted); + } + throw new SocketTimeoutException("idle"); + } + + @Override + public int read() throws IOException { + byte[] one = new byte[1]; + int count = read(one, 0, 1); + return count < 0 ? -1 : one[0] & 0xff; + } + }; + Http2Connection connection = new Http2Connection(); + connection.configure(FlashConfiguration.builder().h2MaxConnectionLifetimeMs(1).build()); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000); + try { + connection.run(new BufferedByteSource(stalled, null), writer, () -> false); + } finally { + writer.close(); + } + + assertCalm(new Run(Http2TestFrames.parse(output.toByteArray()))); + } + + @Test + void continuationFloodDiesBeforeMaterializingAttack() throws Exception { + List frames = base(); + frames.add(Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82})); + byte[] continuation = Http2TestFrames.frame(FrameType.CONTINUATION, 0, 1, new byte[0]); + for (int i = 0; i < 100_000; i++) frames.add(continuation); + byte[] input = Http2TestFrames.concat(frames.toArray(byte[][]::new)); + long before = usedHeap(); + + Run result = org.junit.jupiter.api.Assertions.assertTimeoutPreemptively( + Duration.ofSeconds(2), () -> run(input)); + + assertEquals(Http2ErrorCode.PROTOCOL_ERROR.code(), result.lastGoAwayError()); + assertTrue(usedHeap() - before < 8L * 1024 * 1024, "attack processing retained too much heap"); + } + + @Test + void hpackBombStopsPublishingFieldsAtTheConfiguredBound() { + ByteWriter block = new ByteWriter(4096); + byte[] name = "x".getBytes(java.nio.charset.StandardCharsets.US_ASCII); + byte[] value = new byte[1024]; + for (int i = 0; i < 100; i++) HpackEncoder.writeLiteral(block, name, value); + int[] published = {0}; + + assertThrows( + HeaderListSizeException.class, + () -> new HpackDecoder(4096, 4096).decode( + block.array(), 0, block.length(), (n, v, sensitive) -> published[0]++)); + + assertTrue(published[0] <= 3, "fields beyond the list bound reached stream storage"); + } + + @Test + void incompleteHeaderBlockHasAnAbsoluteAssemblyDeadline() throws Exception { + byte[] wire = Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82}); + dev.relism.flash.http2.frame.Http2FrameReader reader = + new dev.relism.flash.http2.frame.Http2FrameReader( + new BufferedByteSource(new ByteArrayInputStream(wire), null)); + Http2HeaderBlockDecoder decoder = new Http2HeaderBlockDecoder(1); + decoder.accept(reader.readFrame(), (name, value, sensitive) -> {}); + Thread.sleep(5); + + Http2Exception failure = assertThrows(Http2Exception.class, decoder::checkTimeout); + assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, failure.errorCode()); + } + + @Test + void idleOpenStreamIsCancelledWithinConfiguredDeadline() throws Exception { + int port = freePort(); + FlashApp app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .h2StreamIdleTimeoutMs(20) + .build()); + app.post("/idle", (request, response) -> request.body().bytes()); + app.start(); + ByteWriter headers = new ByteWriter(64); + HpackEncoder.writeIndexed(headers, 3); + HpackEncoder.writeIndexed(headers, 6); + HpackEncoder.writeLiteralWithNameIndex(headers, 4, ascii("/idle"), false); + HpackEncoder.writeLiteralWithNameIndex(headers, 1, ascii("localhost"), false); + + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(2_000); + socket.getOutputStream().write(Http2TestFrames.concat( + Http2TestFrames.PREFACE, Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]), + Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, + java.util.Arrays.copyOf(headers.array(), headers.length())))); + socket.getOutputStream().flush(); + Http2TestFrames.WireFrame rst = readUntil(socket.getInputStream(), FrameType.RST_STREAM); + assertEquals(Http2ErrorCode.CANCEL.code(), Http2TestFrames.readInt(rst.payload(), 0)); + } finally { + app.stop().join(); + } + } + + @Test + void zeroNameDuplicatePseudoAndOversizedFieldAreRejected() { + HpackHeaderBlock emptyName = new HpackHeaderBlock(); + new HpackDecoder().decode(new byte[] {0, 0, 0}, 0, 3, emptyName); + assertThrows(Http2StreamException.class, + () -> new PseudoHeaders().validate(emptyName, 1)); + + HpackHeaderBlock duplicate = new HpackHeaderBlock(); + new HpackDecoder().decode(new byte[] {(byte) 0x82, (byte) 0x82}, 0, 2, duplicate); + assertThrows(Http2StreamException.class, + () -> new PseudoHeaders().validate(duplicate, 1)); + + assertThrows(Http2Exception.class, + () -> new HpackDecoder().decode(new byte[] {0, 0x7f, (byte) 0x81, 0x3f}, 0, 4, + (n, v, s) -> {})); + } + + private static List base() { + List frames = new ArrayList<>(); + frames.add(Http2TestFrames.PREFACE); + frames.add(Http2TestFrames.settings()); + return frames; + } + + private static Run run(List frames) throws Exception { + return run(Http2TestFrames.concat(frames.toArray(byte[][]::new))); + } + + private static Run run(byte[] input) throws Exception { + Http2ConnectionHandshakeTest.RunResult result = Http2ConnectionHandshakeTest.run(input); + return new Run(Http2TestFrames.parse(result.output())); + } + + private static Run runConfigured(List frames, FlashConfiguration configuration) + throws Exception { + Http2Connection connection = new Http2Connection(); + connection.configure(configuration); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000); + try { + connection.run( + new BufferedByteSource( + new ByteArrayInputStream(Http2TestFrames.concat(frames.toArray(byte[][]::new))), null), + writer, + () -> false); + writer.drain(); + } finally { + writer.close(); + } + return new Run(Http2TestFrames.parse(output.toByteArray())); + } + + private static void assertCalm(Run result) { + assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM.code(), result.lastGoAwayError()); + } + + private static long usedHeap() { + Runtime runtime = Runtime.getRuntime(); + return runtime.totalMemory() - runtime.freeMemory(); + } + + private static byte[] ascii(String text) { + return text.getBytes(StandardCharsets.US_ASCII); + } + + private static Http2TestFrames.WireFrame readUntil(InputStream input, FrameType expected) + throws Exception { + for (int i = 0; i < 12; i++) { + byte[] header = input.readNBytes(9); + if (header.length != 9) throw new EOFException(); + int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + byte[] payload = input.readNBytes(length); + Http2TestFrames.WireFrame frame = new Http2TestFrames.WireFrame( + header[3] & 0xff, header[4] & 0xff, + Http2TestFrames.readInt(header, 5) & 0x7fff_ffff, payload); + if (frame.type() == expected.code()) return frame; + } + throw new AssertionError("missing " + expected); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private record Run(List frames) { + int lastGoAwayError() { + for (int i = frames.size() - 1; i >= 0; i--) { + Http2TestFrames.WireFrame frame = frames.get(i); + if (frame.type() == FrameType.GOAWAY.code()) { + return Http2TestFrames.readInt(frame.payload(), 4); + } + } + throw new AssertionError("missing GOAWAY"); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2AuthorityTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2AuthorityTest.java new file mode 100644 index 0000000..ae583af --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2AuthorityTest.java @@ -0,0 +1,18 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class Http2AuthorityTest { + @Test + void matchesExactIpPortAndSingleLabelWildcardAuthorities() { + assertTrue(Http2Authority.matches("api.example.com:443", "api.example.com")); + assertTrue(Http2Authority.matches("127.0.0.1:8443", "127.0.0.1")); + assertTrue(Http2Authority.matches("one.example.com", "*.example.com")); + assertFalse(Http2Authority.matches("example.com", "*.example.com")); + assertFalse(Http2Authority.matches("two.one.example.com", "*.example.com")); + assertFalse(Http2Authority.matches("other.example.net", "*.example.com")); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2BackpressureTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2BackpressureTest.java new file mode 100644 index 0000000..4471098 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2BackpressureTest.java @@ -0,0 +1,37 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.http2.message.DataBufferPool; +import dev.relism.flash.http2.message.Http2RequestBody; +import dev.relism.flash.http2.stream.Http2FlowController; +import dev.relism.flash.http2.stream.Http2Stream; +import dev.relism.flash.http2.stream.Http2StreamTable; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class Http2BackpressureTest { + @Test + void windowUpdatesAreWithheldUntilTheHandlerConsumesQueuedData() throws Exception { + AtomicInteger updates = new AtomicInteger(); + Http2FlowController flow = + new Http2FlowController((streamId, increment) -> updates.addAndGet(increment)); + Http2Stream stream = new Http2StreamTable(1).acquire(1); + DataBufferPool pool = new DataBufferPool(Http2Limits.MAX_FRAME_SIZE_LOCAL, 32); + Http2RequestBody body = new Http2RequestBody(pool); + body.begin(-1, false, bytes -> flow.consumed(stream, bytes)); + byte[] frame = new byte[Http2Limits.MAX_FRAME_SIZE_LOCAL]; + + for (int i = 0; i < 32; i++) { + flow.receiveConnectionBytes(frame.length); + flow.receiveStreamBytes(stream, frame.length); + body.offer(1, frame, 0, frame.length, frame.length); + } + assertEquals(0, updates.get(), "receiving alone must not reopen either window"); + + body.finish(1); + assertEquals(32L * frame.length, body.readAllBytes().length); + assertEquals(2 * 32 * frame.length, updates.get()); + assertEquals(32, pool.availableCount()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ConcurrencyTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ConcurrencyTest.java new file mode 100644 index 0000000..9793bf2 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ConcurrencyTest.java @@ -0,0 +1,118 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.hpack.HpackEncoder; +import java.io.EOFException; +import java.io.InputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class Http2ConcurrencyTest { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void thousandMultiplexedStreamsCompleteOnOneConnection() throws Exception { + int port = freePort(); + AtomicInteger handled = new AtomicInteger(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .h2MaxStreamsCreatedPerInterval(2_000) + .build()); + app.get("/work", (request, response) -> Integer.toString(handled.incrementAndGet())); + app.start(); + + byte[] headers = requestHeaders(); + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(10_000); + socket + .getOutputStream() + .write( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]))); + + int sent = 0; + while (sent < 1_000) { + int batch = Math.min(Http2Limits.MAX_CONCURRENT_STREAMS, 1_000 - sent); + for (int i = 0; i < batch; i++) { + int streamId = (sent + i) * 2 + 1; + socket + .getOutputStream() + .write( + Http2TestFrames.frame( + FrameType.HEADERS, + FrameFlags.END_HEADERS | FrameFlags.END_STREAM, + streamId, + headers)); + } + socket.getOutputStream().flush(); + + int completed = 0; + while (completed < batch) { + Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream()); + if (frame.type() == FrameType.GOAWAY.code() + || frame.type() == FrameType.RST_STREAM.code()) { + fail("server rejected stream " + frame.streamId() + " with frame " + frame.type()); + } + if (frame.streamId() != 0 && (frame.flags() & FrameFlags.END_STREAM) != 0) completed++; + } + sent += batch; + } + } + + assertEquals(1_000, handled.get()); + } + + private static byte[] requestHeaders() { + ByteWriter block = new ByteWriter(64); + HpackEncoder.writeIndexed(block, 2); + HpackEncoder.writeIndexed(block, 6); + HpackEncoder.writeLiteralWithNameIndex( + block, 4, "/work".getBytes(StandardCharsets.US_ASCII), false); + HpackEncoder.writeLiteralWithNameIndex( + block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false); + return Arrays.copyOf(block.array(), block.length()); + } + + private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception { + byte[] header = input.readNBytes(9); + if (header.length != 9) throw new EOFException("truncated frame header"); + int length = + ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + byte[] payload = input.readNBytes(length); + if (payload.length != length) throw new EOFException("truncated frame payload"); + return new Http2TestFrames.WireFrame( + header[3] & 0xff, + header[4] & 0xff, + Http2TestFrames.readInt(header, 5) & Integer.MAX_VALUE, + payload); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ConnectTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectTest.java new file mode 100644 index 0000000..733a9fb --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectTest.java @@ -0,0 +1,108 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.hpack.HpackEncoder; +import java.io.EOFException; +import java.io.InputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class Http2ConnectTest { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void connectTunnelCanExchangeDataBeforeEitherSideCloses() throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .build()); + app.connect("tunnel", (request, response) -> + response.type(ContentType.NONE).streaming(output -> { + byte[] bytes = new byte[16]; + try { + int count; + InputStream input = request.body().stream(); + while ((count = input.read(bytes)) >= 0) { + output.write(bytes, 0, count); + output.flush(); + } + } catch (Exception failure) { + throw new RuntimeException(failure); + } + })); + app.start(); + + ByteWriter block = new ByteWriter(32); + HpackEncoder.writeLiteralWithNameIndex(block, 2, ascii("CONNECT"), false); + HpackEncoder.writeLiteralWithNameIndex(block, 1, ascii("tunnel"), false); + + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + socket.getOutputStream().write(Http2TestFrames.concat( + Http2TestFrames.PREFACE, Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]), + Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, + Arrays.copyOf(block.array(), block.length())), + Http2TestFrames.frame(FrameType.DATA, 0, 1, ascii("one")))); + socket.getOutputStream().flush(); + + assertEquals("one", new String(readData(socket.getInputStream()).payload(), + StandardCharsets.US_ASCII)); + + socket.getOutputStream().write(Http2TestFrames.frame( + FrameType.DATA, FrameFlags.END_STREAM, 1, ascii("two"))); + socket.getOutputStream().flush(); + assertEquals("two", new String(readData(socket.getInputStream()).payload(), + StandardCharsets.US_ASCII)); + } + } + + private static Http2TestFrames.WireFrame readData(InputStream input) throws Exception { + for (int i = 0; i < 12; i++) { + Http2TestFrames.WireFrame frame = readFrame(input); + if (frame.streamId() == 1 && frame.type() == FrameType.DATA.code() + && frame.payload().length != 0) return frame; + } + throw new AssertionError("missing tunnel DATA"); + } + + private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception { + byte[] header = input.readNBytes(9); + if (header.length != 9) throw new EOFException(); + int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + byte[] payload = input.readNBytes(length); + return new Http2TestFrames.WireFrame( + header[3] & 0xff, header[4] & 0xff, Http2TestFrames.readInt(header, 5) & 0x7fff_ffff, + payload); + } + + private static byte[] ascii(String text) { + return text.getBytes(StandardCharsets.US_ASCII); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionHandshakeTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionHandshakeTest.java new file mode 100644 index 0000000..b0fe036 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionHandshakeTest.java @@ -0,0 +1,204 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.frame.Http2FrameWriter; +import dev.relism.flash.transport.BufferedByteSource; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.net.SocketTimeoutException; +import java.util.List; +import org.junit.jupiter.api.Test; + +class Http2ConnectionHandshakeTest { + @Test + void exactPrefaceExchangesSettingsAndAcknowledgesPeerSettings() throws Exception { + byte[] input = + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(Http2Settings.MAX_FRAME_SIZE, 32_768)); + + RunResult result = run(input); + List frames = Http2TestFrames.parse(result.output()); + + assertEquals(3, frames.size()); + assertEquals(FrameType.SETTINGS.code(), frames.get(0).type()); + assertEquals(0, frames.get(0).flags()); + assertEquals(FrameType.WINDOW_UPDATE.code(), frames.get(1).type()); + assertEquals(FrameType.SETTINGS.code(), frames.get(2).type()); + assertEquals(FrameFlags.ACK, frames.get(2).flags()); + assertEquals(0, frames.get(2).payload().length); + assertEquals(32_768, result.connection().peerSettings().maxFrameSize()); + } + + @Test + void mismatchedPrefaceSendsProtocolErrorButTruncatedPrefaceClosesSilently() throws Exception { + byte[] mismatched = Http2TestFrames.PREFACE.clone(); + mismatched[10] ^= 1; + + List frames = Http2TestFrames.parse(run(mismatched).output()); + assertEquals(1, frames.size()); + assertEquals(FrameType.GOAWAY.code(), frames.get(0).type()); + assertEquals( + Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(frames.get(0).payload(), 4)); + assertEquals(0, run(java.util.Arrays.copyOf(Http2TestFrames.PREFACE, 12)).output().length); + } + + @Test + void unopenedLowerStreamIdentifierProducesConnectionProtocolError() throws Exception { + byte[] request = {(byte) 0x82, (byte) 0x86, (byte) 0x84, (byte) 0x81}; + byte[] streamThree = + Http2TestFrames.frame( + FrameType.HEADERS, + FrameFlags.END_HEADERS | FrameFlags.END_STREAM, + 3, + request); + byte[] lowerStream = + Http2TestFrames.frame( + FrameType.HEADERS, + FrameFlags.END_HEADERS | FrameFlags.END_STREAM, + 1, + request); + + List frames = + Http2TestFrames.parse( + run( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + streamThree, + lowerStream)) + .output()); + Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1); + assertEquals(FrameType.GOAWAY.code(), goAway.type()); + assertEquals( + Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4)); + } + + @Test + void firstPeerFrameMustBeSettings() throws Exception { + byte[] ping = Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]); + List frames = + Http2TestFrames.parse(run(Http2TestFrames.concat(Http2TestFrames.PREFACE, ping)).output()); + + Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1); + assertEquals(FrameType.GOAWAY.code(), goAway.type()); + assertEquals( + Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4)); + } + + @Test + void settingsAcknowledgementCannotReplaceInitialPeerSettings() throws Exception { + byte[] ack = Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]); + List frames = + Http2TestFrames.parse(run(Http2TestFrames.concat(Http2TestFrames.PREFACE, ack)).output()); + + Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1); + assertEquals( + Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4)); + } + + @Test + void invalidHpackBlockProducesCompressionError() throws Exception { + byte[] headers = + Http2TestFrames.frame( + FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[] {(byte) 0x80}); + List frames = + Http2TestFrames.parse( + run(Http2TestFrames.concat( + Http2TestFrames.PREFACE, Http2TestFrames.settings(), headers)) + .output()); + + Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1); + assertEquals( + Http2ErrorCode.COMPRESSION_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4)); + } + + @Test + void frameInterleavingDuringContinuationSequenceIsProtocolError() throws Exception { + byte[] incompleteHeaders = + Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82}); + byte[] ping = Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]); + List frames = + Http2TestFrames.parse( + run(Http2TestFrames.concat( + Http2TestFrames.PREFACE, Http2TestFrames.settings(), incompleteHeaders, ping)) + .output()); + + Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1); + assertEquals( + Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4)); + } + + @Test + void settingsAckWithPayloadIsFrameSizeError() throws Exception { + byte[] badAck = Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[6]); + List frames = + Http2TestFrames.parse( + run(Http2TestFrames.concat(Http2TestFrames.PREFACE, badAck)).output()); + + Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1); + assertEquals( + Http2ErrorCode.FRAME_SIZE_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4)); + } + + @Test + void missingSettingsAcknowledgementTimesOutWithDedicatedErrorCode() throws Exception { + byte[] initial = Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings()); + InputStream stallsAfterInput = + new InputStream() { + private final ByteArrayInputStream delegate = new ByteArrayInputStream(initial); + + @Override + public int read() throws java.io.IOException { + byte[] one = new byte[1]; + int n = read(one, 0, 1); + return n < 0 ? -1 : one[0] & 0xff; + } + + @Override + public int read(byte[] target, int off, int len) throws java.io.IOException { + if (delegate.available() > 0) return delegate.read(target, off, len); + try { + Thread.sleep(15); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new java.io.IOException(e); + } + throw new SocketTimeoutException("simulated idle peer"); + } + }; + Http2Connection connection = new Http2Connection(delta -> {}, 5); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000); + try { + connection.run(new BufferedByteSource(stallsAfterInput, null), writer, () -> false); + } finally { + writer.close(); + } + + List frames = Http2TestFrames.parse(output.toByteArray()); + Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1); + assertEquals( + Http2ErrorCode.SETTINGS_TIMEOUT.code(), Http2TestFrames.readInt(goAway.payload(), 4)); + } + + static RunResult run(byte[] input) throws Exception { + Http2Connection connection = new Http2Connection(); + ByteArrayOutputStream output = new ByteArrayOutputStream(); + Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000); + try { + connection.run( + new BufferedByteSource(new ByteArrayInputStream(input), null), writer, () -> false); + writer.drain(); + } finally { + writer.close(); + } + return new RunResult(connection, output.toByteArray()); + } + + record RunResult(Http2Connection connection, byte[] output) {} +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionIntegrationTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionIntegrationTest.java new file mode 100644 index 0000000..febbb76 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ConnectionIntegrationTest.java @@ -0,0 +1,644 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.flash.http2.hpack.HpackEncoder; +import dev.relism.flash.tls.TestKeystores; +import dev.relism.flash.tls.TlsConfig; +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.InputStream; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class Http2ConnectionIntegrationTest { + private FlashApp app; + + @AfterEach + void stopApp() { + if (app != null) app.stop().join(); + } + + @Test + void javaHttpClientUsesHttp2AgainstAnExistingParameterizedRoute(@TempDir Path directory) + throws Exception { + int port = freePort(); + Path keystore = + TestKeystores.build( + directory, + "http2-route.p12", + "changeit", + TestKeystores.Entry.of("server", "localhost", "localhost")); + app = + FlashApp.create( + FlashConfiguration.builder() + .port(port) + .host("127.0.0.1") + .tls(TlsConfig.keystore(keystore, "changeit")) + .http2Enabled(true) + .build()); + app.get( + "/users/{id}", (request, response) -> request.param("id") + ":" + request.header("host")); + app.start(); + + HttpClient client = + HttpClient.newBuilder() + .sslContext(TestKeystores.trustAllClientContext()) + .version(HttpClient.Version.HTTP_2) + .build(); + HttpResponse response = + client.send( + HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/users/42")) + .GET() + .build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(HttpClient.Version.HTTP_2, response.version()); + assertEquals(200, response.statusCode()); + assertEquals("42:localhost:" + port, response.body()); + } + + @Test + void javaHttpClientUploadsAndDownloadsFlowControlledBodies(@TempDir Path directory) + throws Exception { + int port = freePort(); + Path keystore = + TestKeystores.build( + directory, + "http2-bodies.p12", + "changeit", + TestKeystores.Entry.of("server", "localhost", "localhost")); + byte[] upload = new byte[2 * 1024 * 1024]; + for (int i = 0; i < upload.length; i++) upload[i] = (byte) (i * 31); + byte[] download = new byte[2 * 1024 * 1024 + 17]; + for (int i = 0; i < download.length; i++) download[i] = (byte) (i * 17); + + app = + FlashApp.create( + FlashConfiguration.builder() + .port(port) + .host("127.0.0.1") + .tls(TlsConfig.keystore(keystore, "changeit")) + .http2Enabled(true) + .build()); + app.post("/echo", (request, response) -> request.body().bytes()); + app.get("/fixed", (request, response) -> response.body(download)); + app.get( + "/stream", + (request, response) -> response.chunked(new ByteArrayInputStream(download))); + app.start(); + + HttpClient client = + HttpClient.newBuilder() + .sslContext(TestKeystores.trustAllClientContext()) + .version(HttpClient.Version.HTTP_2) + .build(); + HttpResponse echoed = + client.send( + HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/echo")) + .POST(HttpRequest.BodyPublishers.ofByteArray(upload)) + .build(), + HttpResponse.BodyHandlers.ofByteArray()); + HttpResponse fixed = + client.send( + HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/fixed")).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + HttpResponse streamed = + client.send( + HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/stream")).GET().build(), + HttpResponse.BodyHandlers.ofByteArray()); + + assertArrayEquals(upload, echoed.body()); + assertArrayEquals(download, fixed.body()); + assertArrayEquals(download, streamed.body()); + assertTrue(streamed.headers().firstValue("transfer-encoding").isEmpty()); + } + + @Test + void pushStreamingAppliesBackpressureAcrossMultipleWindows(@TempDir Path directory) + throws Exception { + int port = freePort(); + int length = 2 * 1024 * 1024 + 31; + Path keystore = + TestKeystores.build( + directory, + "http2-push-stream.p12", + "changeit", + TestKeystores.Entry.of("server", "localhost", "localhost")); + app = + FlashApp.create( + FlashConfiguration.builder() + .port(port) + .host("127.0.0.1") + .tls(TlsConfig.keystore(keystore, "changeit")) + .http2Enabled(true) + .build()); + app.get( + "/push", + (request, response) -> + response.type(ContentType.BINARY).streaming(stream -> { + byte[] block = new byte[8192]; + int written = 0; + try { + while (written < length) { + int count = Math.min(block.length, length - written); + for (int i = 0; i < count; i++) block[i] = (byte) ((written + i) * 31); + stream.write(block, 0, count); + written += count; + } + stream.trailer("grpc-status", "0"); + } catch (IOException failure) { + throw new RuntimeException(failure); + } + })); + app.start(); + + HttpClient client = + HttpClient.newBuilder() + .sslContext(TestKeystores.trustAllClientContext()) + .version(HttpClient.Version.HTTP_2) + .build(); + HttpResponse response = + client.send( + HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/push")).GET().build(), + HttpResponse.BodyHandlers.ofInputStream()); + + assertEquals(HttpClient.Version.HTTP_2, response.version()); + try (InputStream body = response.body()) { + assertEquals(length, verifyPattern(body)); + } + } + + @Test + void hundredMegabyteUploadAndDownloadRemainStreaming(@TempDir Path directory) throws Exception { + int port = freePort(); + long length = 100L * 1024 * 1024; + Path keystore = + TestKeystores.build( + directory, + "http2-large-bodies.p12", + "changeit", + TestKeystores.Entry.of("server", "localhost", "localhost")); + app = + FlashApp.create( + FlashConfiguration.builder() + .port(port) + .host("127.0.0.1") + .tls(TlsConfig.keystore(keystore, "changeit")) + .http2Enabled(true) + .build()); + app.post( + "/upload", + (request, response) -> { + long count = verifyPattern(request.body().stream()); + return Long.toString(count); + }); + app.get( + "/download", + (request, response) -> response.stream(new PatternInputStream(length), length)); + app.start(); + + HttpClient client = + HttpClient.newBuilder() + .sslContext(TestKeystores.trustAllClientContext()) + .version(HttpClient.Version.HTTP_2) + .build(); + HttpResponse upload = + client.send( + HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/upload")) + .POST(HttpRequest.BodyPublishers.ofInputStream(() -> new PatternInputStream(length))) + .build(), + HttpResponse.BodyHandlers.ofString()); + HttpResponse download = + client.send( + HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/download")) + .GET() + .build(), + HttpResponse.BodyHandlers.ofInputStream()); + + assertEquals(Long.toString(length), upload.body()); + try (InputStream body = download.body()) { + assertEquals(length, verifyPattern(body)); + } + } + + @Test + void bodylessGetRunsExistingRouteAndReturnsHeadersAndData() throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .port(port) + .host("127.0.0.1") + .http2CleartextEnabled(true) + .build()); + app.get("/api/ping", (request, response) -> "pong"); + app.start(); + + ByteWriter block = new ByteWriter(64); + HpackEncoder.writeIndexed(block, 2); + HpackEncoder.writeIndexed(block, 6); + HpackEncoder.writeLiteralWithNameIndex( + block, 4, "/api/ping".getBytes(StandardCharsets.US_ASCII), false); + HpackEncoder.writeLiteralWithNameIndex( + block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false); + + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + socket + .getOutputStream() + .write( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]), + Http2TestFrames.frame( + FrameType.HEADERS, + FrameFlags.END_HEADERS | FrameFlags.END_STREAM, + 1, + Arrays.copyOf(block.array(), block.length())))); + socket.getOutputStream().flush(); + + ByteWriter responseBlock = new ByteWriter(128); + byte[] body = null; + for (int i = 0; i < 10 && body == null; i++) { + Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream()); + if (frame.streamId() != 1) continue; + if (frame.type() == FrameType.HEADERS.code() + || frame.type() == FrameType.CONTINUATION.code()) { + responseBlock.writeBytes(frame.payload()); + } else if (frame.type() == FrameType.DATA.code()) { + body = frame.payload(); + } + } + + List fields = new ArrayList<>(); + new HpackDecoder() + .decode( + responseBlock.array(), + 0, + responseBlock.length(), + (name, value, never) -> fields.add(ascii(name) + "=" + ascii(value))); + assertTrue(fields.contains(":status=200")); + assertTrue(fields.contains("content-length=4")); + assertEquals("pong", new String(body, StandardCharsets.US_ASCII)); + } + } + + @Test + void resetQueuedStreamCannotReleaseOrReuseItBeforeDispatchObservesCancellation() + throws Exception { + int port = freePort(); + AtomicInteger calls = new AtomicInteger(); + app = + FlashApp.create( + FlashConfiguration.builder() + .port(port) + .host("127.0.0.1") + .http2CleartextEnabled(true) + .build()); + app.get( + "/queued", + (request, response) -> { + calls.incrementAndGet(); + return "ok"; + }); + app.start(); + + ByteWriter block = new ByteWriter(64); + HpackEncoder.writeIndexed(block, 2); + HpackEncoder.writeIndexed(block, 6); + HpackEncoder.writeLiteralWithNameIndex( + block, 4, "/queued".getBytes(StandardCharsets.US_ASCII), false); + HpackEncoder.writeLiteralWithNameIndex( + block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false); + byte[] headers = Arrays.copyOf(block.array(), block.length()); + byte[] cancel = {0, 0, 0, 8}; + + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + socket + .getOutputStream() + .write( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]), + Http2TestFrames.frame( + FrameType.HEADERS, + FrameFlags.END_HEADERS | FrameFlags.END_STREAM, + 1, + headers), + Http2TestFrames.frame(FrameType.RST_STREAM, 0, 1, cancel), + Http2TestFrames.frame( + FrameType.HEADERS, + FrameFlags.END_HEADERS | FrameFlags.END_STREAM, + 3, + headers))); + socket.getOutputStream().flush(); + + Http2TestFrames.WireFrame response = null; + for (int i = 0; i < 10; i++) { + Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream()); + assertFalse( + frame.streamId() == 1 + && (frame.type() == FrameType.HEADERS.code() + || frame.type() == FrameType.DATA.code()), + "a reset request must not produce a response"); + if (frame.streamId() == 3 && frame.type() == FrameType.DATA.code()) { + response = frame; + break; + } + } + assertNotNull(response); + assertEquals("ok", new String(response.payload(), StandardCharsets.US_ASCII)); + assertEquals(1, calls.get()); + } + } + + @Test + void h2cTransportDispatchesControlFramesWithoutRunningApplicationWork() throws Exception { + int port = freePort(); + AtomicBoolean handlerEntered = new AtomicBoolean(); + app = + FlashApp.create( + FlashConfiguration.builder() + .port(port) + .host("127.0.0.1") + .http2CleartextEnabled(true) + .build()); + app.get( + "/", + (request, response) -> { + handlerEntered.set(true); + Thread.sleep(5_000); + return "late"; + }); + app.start(); + + byte[] clientPing = "client!!".getBytes(StandardCharsets.US_ASCII); + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + socket + .getOutputStream() + .write( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]), + Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0]), + Http2TestFrames.frame(FrameType.PING, 0, 0, clientPing))); + socket.getOutputStream().flush(); + + byte[] shutdownPing = null; + boolean sawClientPong = false; + for (int i = 0; i < 8 && !sawClientPong; i++) { + Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream()); + if (frame.type() == FrameType.PING.code()) { + if ((frame.flags() & FrameFlags.ACK) != 0 && Arrays.equals(clientPing, frame.payload())) { + sawClientPong = true; + } else if ((frame.flags() & FrameFlags.ACK) == 0) { + shutdownPing = frame.payload(); + } + } + } + + assertTrue(sawClientPong, "PING must be processed while a route exists"); + assertFalse(handlerEntered.get(), "the connection demux must not execute handlers"); + if (shutdownPing != null) { + socket + .getOutputStream() + .write(Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, shutdownPing)); + socket.getOutputStream().flush(); + } + } + } + + @Test + void serverStopInitiatesTwoStageGoAwayOnIdleConnection() throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .port(port) + .host("127.0.0.1") + .http2CleartextEnabled(true) + .shutdownDrainTimeoutMs(5_000) + .build()); + app.start(); + + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + socket + .getOutputStream() + .write( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]))); + socket.getOutputStream().flush(); + + readFrame(socket.getInputStream()); // server SETTINGS + readFrame(socket.getInputStream()); // initial connection WINDOW_UPDATE + readFrame(socket.getInputStream()); // SETTINGS ACK + + CompletableFuture stopped = app.stop(); + app = null; + Http2TestFrames.WireFrame firstGoAway = readUntil(socket, FrameType.GOAWAY); + assertEquals(Integer.MAX_VALUE, Http2TestFrames.readInt(firstGoAway.payload(), 0)); + Http2TestFrames.WireFrame ping = readUntil(socket, FrameType.PING); + socket + .getOutputStream() + .write(Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, ping.payload())); + socket.getOutputStream().flush(); + Http2TestFrames.WireFrame finalGoAway = readUntil(socket, FrameType.GOAWAY); + assertEquals(0, Http2TestFrames.readInt(finalGoAway.payload(), 0)); + stopped.get(5, TimeUnit.SECONDS); + } + } + + @Test + void protocolStateDoesNotLeakAcrossConsecutiveConnections() throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .port(port) + .host("127.0.0.1") + .http2CleartextEnabled(true) + .build()); + app.start(); + + try (Socket first = new Socket("127.0.0.1", port)) { + first.setSoTimeout(5_000); + first + .getOutputStream() + .write( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]))); + first.getOutputStream().flush(); + readUntil(first, FrameType.GOAWAY); + } + + byte[] opaque = "isolated".getBytes(StandardCharsets.US_ASCII); + try (Socket second = new Socket("127.0.0.1", port)) { + second.setSoTimeout(5_000); + second + .getOutputStream() + .write( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]), + Http2TestFrames.frame(FrameType.PING, 0, 0, opaque))); + second.getOutputStream().flush(); + + Http2TestFrames.WireFrame pong = null; + for (int i = 0; i < 6; i++) { + Http2TestFrames.WireFrame frame = readFrame(second.getInputStream()); + if (frame.type() == FrameType.PING.code() + && (frame.flags() & FrameFlags.ACK) != 0 + && Arrays.equals(opaque, frame.payload())) { + pong = frame; + break; + } + } + assertNotNull(pong, "a fresh connection must start with fresh SETTINGS/GOAWAY state"); + } + } + + @Test + void tlsListenerOffersAndNegotiatesH2WhenHttp2IsEnabled(@TempDir Path directory) + throws Exception { + int port = freePort(); + Path keystore = + TestKeystores.build( + directory, + "http2.p12", + "changeit", + TestKeystores.Entry.of("server", "localhost", "localhost")); + app = + FlashApp.create( + FlashConfiguration.builder() + .port(port) + .host("127.0.0.1") + .tls(TlsConfig.keystore(keystore, "changeit")) + .http2Enabled(true) + .build()); + app.start(); + + try (SSLSocket socket = + (SSLSocket) + TestKeystores.trustAllClientContext() + .getSocketFactory() + .createSocket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + SSLParameters parameters = socket.getSSLParameters(); + parameters.setApplicationProtocols(new String[] {"h2", "http/1.1"}); + socket.setSSLParameters(parameters); + socket.startHandshake(); + assertEquals("h2", socket.getApplicationProtocol()); + + socket + .getOutputStream() + .write(Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings())); + socket.getOutputStream().flush(); + assertEquals(FrameType.SETTINGS.code(), readFrame(socket.getInputStream()).type()); + } + } + + private static Http2TestFrames.WireFrame readUntil(Socket socket, FrameType type) + throws Exception { + for (int i = 0; i < 8; i++) { + Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream()); + if (frame.type() == type.code()) return frame; + } + fail("did not receive " + type); + throw new AssertionError(); + } + + private static long verifyPattern(InputStream input) throws Exception { + byte[] buffer = new byte[64 * 1024]; + long position = 0; + int count; + while ((count = input.read(buffer)) >= 0) { + for (int i = 0; i < count; i++) assertEquals((byte) (position++ * 31), buffer[i]); + } + return position; + } + + private static final class PatternInputStream extends InputStream { + private final long length; + private long position; + + PatternInputStream(long length) { + this.length = length; + } + + @Override + public int read() { + if (position == length) return -1; + return (byte) (position++ * 31) & 0xff; + } + + @Override + public int read(byte[] target, int offset, int requested) { + if (position == length) return -1; + int count = (int) Math.min(requested, length - position); + for (int i = 0; i < count; i++) target[offset + i] = (byte) (position++ * 31); + return count; + } + } + + private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception { + byte[] header = input.readNBytes(9); + if (header.length != 9) throw new EOFException("truncated frame header"); + int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + byte[] payload = input.readNBytes(length); + if (payload.length != length) throw new EOFException("truncated frame payload"); + return new Http2TestFrames.WireFrame( + header[3] & 0xff, + header[4] & 0xff, + Http2TestFrames.readInt(header, 5) & Integer.MAX_VALUE, + payload); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } + + private static String ascii(dev.relism.fpr.core.ByteView view) { + byte[] bytes = new byte[view.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ErrorCodeTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ErrorCodeTest.java new file mode 100644 index 0000000..cad8af3 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ErrorCodeTest.java @@ -0,0 +1,58 @@ +package dev.relism.flash.http2; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class Http2ErrorCodeTest { + + @Test + void everyCodeRoundTripsThroughFromCode() { + for (Http2ErrorCode code : Http2ErrorCode.values()) { + assertSame(code, Http2ErrorCode.fromCode(code.code())); + } + } + + @Test + void bytesAreFourByteBigEndian() { + for (Http2ErrorCode code : Http2ErrorCode.values()) { + assertEquals(4, code.bytes().length, code.name()); + int decoded = ((code.bytes()[0] & 0xFF) << 24) + | ((code.bytes()[1] & 0xFF) << 16) + | ((code.bytes()[2] & 0xFF) << 8) + | (code.bytes()[3] & 0xFF); + assertEquals(code.code(), decoded, code.name()); + } + } + + @Test + void allFourteenRfc9113CodesArePresent() { + assertEquals(14, Http2ErrorCode.values().length); + assertEquals(0x00, Http2ErrorCode.NO_ERROR.code()); + assertEquals(0x01, Http2ErrorCode.PROTOCOL_ERROR.code()); + assertEquals(0x02, Http2ErrorCode.INTERNAL_ERROR.code()); + assertEquals(0x03, Http2ErrorCode.FLOW_CONTROL_ERROR.code()); + assertEquals(0x04, Http2ErrorCode.SETTINGS_TIMEOUT.code()); + assertEquals(0x05, Http2ErrorCode.STREAM_CLOSED.code()); + assertEquals(0x06, Http2ErrorCode.FRAME_SIZE_ERROR.code()); + assertEquals(0x07, Http2ErrorCode.REFUSED_STREAM.code()); + assertEquals(0x08, Http2ErrorCode.CANCEL.code()); + assertEquals(0x09, Http2ErrorCode.COMPRESSION_ERROR.code()); + assertEquals(0x0a, Http2ErrorCode.CONNECT_ERROR.code()); + assertEquals(0x0b, Http2ErrorCode.ENHANCE_YOUR_CALM.code()); + assertEquals(0x0c, Http2ErrorCode.INADEQUATE_SECURITY.code()); + assertEquals(0x0d, Http2ErrorCode.HTTP_1_1_REQUIRED.code()); + } + + @Test + void unknownCodeReturnsNull() { + assertNull(Http2ErrorCode.fromCode(0x0e)); + assertNull(Http2ErrorCode.fromCode(-1)); + assertNull(Http2ErrorCode.fromCode(Integer.MAX_VALUE)); + } + + @Test + void bytesInstanceIsStablePerConstant() { + assertSame(Http2ErrorCode.PROTOCOL_ERROR.bytes(), Http2ErrorCode.PROTOCOL_ERROR.bytes()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2ExceptionTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2ExceptionTest.java new file mode 100644 index 0000000..ef8c06f --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2ExceptionTest.java @@ -0,0 +1,36 @@ +package dev.relism.flash.http2; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class Http2ExceptionTest { + + @Test + void ofCarriesTheGivenCodeAndMessage() { + Http2Exception e = Http2Exception.of(Http2ErrorCode.FLOW_CONTROL_ERROR, "window exceeded"); + assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, e.errorCode()); + assertEquals("window exceeded", e.getMessage()); + } + + @Test + void stackTraceCaptureIsDisabled() { + Http2Exception e = Http2Exception.of(Http2ErrorCode.PROTOCOL_ERROR, "bad frame"); + assertEquals(0, e.getStackTrace().length); + } + + @Test + void singletonsCarryTheAdvertisedCode() { + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, Http2Exception.PROTOCOL_ERROR.errorCode()); + assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, Http2Exception.FRAME_SIZE_ERROR.errorCode()); + assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, Http2Exception.FLOW_CONTROL_ERROR.errorCode()); + assertEquals(Http2ErrorCode.COMPRESSION_ERROR, Http2Exception.COMPRESSION_ERROR.errorCode()); + assertEquals(Http2ErrorCode.INTERNAL_ERROR, Http2Exception.INTERNAL_ERROR.errorCode()); + assertEquals(Http2ErrorCode.SETTINGS_TIMEOUT, Http2Exception.SETTINGS_TIMEOUT.errorCode()); + } + + @Test + void doesNotExtendIoException() { + assertFalse(java.io.IOException.class.isAssignableFrom(Http2Exception.class)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2GoAwayTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2GoAwayTest.java new file mode 100644 index 0000000..b33186a --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2GoAwayTest.java @@ -0,0 +1,67 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import java.util.List; +import org.junit.jupiter.api.Test; + +class Http2GoAwayTest { + private static final byte[] SHUTDOWN_PING = { + (byte) 0x46, (byte) 0x4c, (byte) 0x41, (byte) 0x53, + (byte) 0x48, (byte) 0x47, (byte) 0x4f, (byte) 0x21 + }; + + @Test + void gracefulShutdownUsesTwoGoAwayStagesSeparatedByPingRoundTrip() throws Exception { + byte[] input = + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0]), + Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, SHUTDOWN_PING)); + + List frames = + Http2TestFrames.parse(Http2ConnectionHandshakeTest.run(input).output()); + List goAways = + frames.stream().filter(frame -> frame.type() == FrameType.GOAWAY.code()).toList(); + + assertEquals(2, goAways.size()); + assertEquals(Integer.MAX_VALUE, Http2TestFrames.readInt(goAways.get(0).payload(), 0)); + assertEquals(1, Http2TestFrames.readInt(goAways.get(1).payload(), 0)); + assertEquals( + Http2ErrorCode.NO_ERROR.code(), Http2TestFrames.readInt(goAways.get(0).payload(), 4)); + assertEquals( + Http2ErrorCode.NO_ERROR.code(), Http2TestFrames.readInt(goAways.get(1).payload(), 4)); + + int firstGoAway = indexOf(frames, FrameType.GOAWAY.code(), 0); + int ping = indexOf(frames, FrameType.PING.code(), firstGoAway + 1); + int secondGoAway = indexOf(frames, FrameType.GOAWAY.code(), firstGoAway + 1); + assertTrue(firstGoAway < ping && ping < secondGoAway); + assertArrayEquals(SHUTDOWN_PING, frames.get(ping).payload()); + } + + @Test + void receivedGoAwayRecordsPeerState() throws Exception { + byte[] payload = new byte[8]; + payload[3] = 7; + payload[7] = (byte) Http2ErrorCode.ENHANCE_YOUR_CALM.code(); + Http2ConnectionHandshakeTest.RunResult result = + Http2ConnectionHandshakeTest.run( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.GOAWAY, 0, 0, payload))); + + assertEquals(7, result.connection().peerLastStreamId()); + assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM.code(), result.connection().peerErrorCode()); + } + + private static int indexOf(List frames, int type, int from) { + for (int i = from; i < frames.size(); i++) { + if (frames.get(i).type() == type) return i; + } + return -1; + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2HalfCloseTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2HalfCloseTest.java new file mode 100644 index 0000000..a0be7f6 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2HalfCloseTest.java @@ -0,0 +1,50 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.http2.stream.Http2StreamState; +import org.junit.jupiter.api.Test; + +class Http2HalfCloseTest { + @Test + void remoteMayCloseBeforeLocalResponseCompletes() { + Http2StreamState state = Http2StreamState.IDLE; + state = state.transition(1, Http2StreamState.Event.RECV_HEADERS_ES); + assertEquals(Http2StreamState.HALF_CLOSED_REMOTE, state); + state = state.transition(1, Http2StreamState.Event.SEND_HEADERS); + state = state.transition(1, Http2StreamState.Event.SEND_DATA); + state = state.transition(1, Http2StreamState.Event.SEND_DATA_ES); + assertEquals(Http2StreamState.CLOSED, state); + } + + @Test + void localMayCloseWhileRemoteBodyContinues() { + Http2StreamState state = Http2StreamState.IDLE; + state = state.transition(1, Http2StreamState.Event.RECV_HEADERS); + state = state.transition(1, Http2StreamState.Event.SEND_HEADERS_ES); + assertEquals(Http2StreamState.HALF_CLOSED_LOCAL, state); + state = state.transition(1, Http2StreamState.Event.RECV_DATA); + state = state.transition(1, Http2StreamState.Event.RECV_DATA_ES); + assertEquals(Http2StreamState.CLOSED, state); + } + + @Test + void bothSidesRemainOpenDuringBidirectionalData() { + Http2StreamState state = Http2StreamState.IDLE; + state = state.transition(1, Http2StreamState.Event.RECV_HEADERS); + state = state.transition(1, Http2StreamState.Event.SEND_HEADERS); + state = state.transition(1, Http2StreamState.Event.RECV_DATA); + state = state.transition(1, Http2StreamState.Event.SEND_DATA); + assertEquals(Http2StreamState.OPEN, state); + } + + @Test + void trailingHeadersCanCloseRemoteAfterLocalHalfClose() { + Http2StreamState state = Http2StreamState.IDLE; + state = state.transition(1, Http2StreamState.Event.RECV_HEADERS); + state = state.transition(1, Http2StreamState.Event.SEND_HEADERS_ES); + state = state.transition(1, Http2StreamState.Event.RECV_DATA); + state = state.transition(1, Http2StreamState.Event.RECV_HEADERS_ES); + assertEquals(Http2StreamState.CLOSED, state); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2LimitsTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2LimitsTest.java new file mode 100644 index 0000000..5b908ba --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2LimitsTest.java @@ -0,0 +1,60 @@ +package dev.relism.flash.http2; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class Http2LimitsTest { + + @Test + void maxFrameSizeLocalWithinRfcBounds() { + // RFC 9113 §6.5.2 — SETTINGS_MAX_FRAME_SIZE must be within 16384..16777215. + assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL >= 16_384); + assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL <= 16_777_215); + } + + @Test + void everyLimitIsPositive() { + assertTrue(Http2Limits.MAX_CONCURRENT_STREAMS > 0); + assertTrue(Http2Limits.MAX_FRAME_SIZE_LOCAL > 0); + assertTrue(Http2Limits.MAX_HEADER_LIST_SIZE > 0); + assertTrue(Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK > 0); + assertTrue(Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL > 0); + assertTrue(Http2Limits.RESET_RATE_INTERVAL_MS > 0); + assertTrue(Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL > 0); + assertTrue(Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME > 0); + assertTrue(Http2Limits.MAX_PING_QUEUE_DEPTH > 0); + assertTrue(Http2Limits.MAX_SETTINGS_PER_INTERVAL > 0); + assertTrue(Http2Limits.MAX_PINGS_PER_INTERVAL > 0); + assertTrue(Http2Limits.MAX_USELESS_FRAMES_PER_INTERVAL > 0); + assertTrue(Http2Limits.MAX_STREAMS_PER_CONNECTION > 0); + assertTrue(Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM > 0); + assertTrue(Http2Limits.INITIAL_WINDOW_SIZE_LOCAL > 0); + assertTrue(Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL > 0); + assertTrue(Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL > 0); + assertTrue(Http2Limits.MAX_HPACK_STRING_LENGTH > 0); + assertTrue(Http2Limits.HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS > 0); + assertTrue(Http2Limits.STREAM_IDLE_TIMEOUT_MS > 0); + } + + @Test + void streamCreationBoundIsAtLeastTheResetBound() { + // A Rapid Reset defence that only counts resets can be bypassed by a peer that creates + // streams fast enough that the reset counter never saturates within a window boundary; + // the creation bound must be at least as tight. + assertTrue(Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL >= Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL); + } + + @Test + void connectionWindowIsAtLeastAsLargeAsAStreamWindow() { + // Otherwise a single active stream would be bottlenecked by the connection window + // before it ever reaches its own (larger) per-stream window. + assertTrue(Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL >= Http2Limits.INITIAL_WINDOW_SIZE_LOCAL); + } + + @Test + void hpackDynamicTableSizeMatchesRfcDefault() { + // RFC 7541 §4.1 default is 4096; nothing in this codebase should silently diverge. + assertEquals(4_096, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2MisdirectedRequestTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2MisdirectedRequestTest.java new file mode 100644 index 0000000..e8f04c2 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2MisdirectedRequestTest.java @@ -0,0 +1,122 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.frame.FrameWriteBuffer; +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.flash.http2.hpack.HpackEncoder; +import dev.relism.flash.tls.TestKeystores; +import dev.relism.flash.tls.TlsConfig; +import java.io.InputStream; +import java.net.ServerSocket; +import java.nio.file.Path; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLSocket; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class Http2MisdirectedRequestTest { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void authorityOutsideSelectedCertificateReceives421(@TempDir Path directory) throws Exception { + int port = freePort(); + Path keystore = + TestKeystores.build( + directory, + "misdirected.p12", + "changeit", + TestKeystores.Entry.of("server", "localhost", "localhost")); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .tls(TlsConfig.keystore(keystore, "changeit")) + .http2Enabled(true) + .build()); + app.get("/", (request, response) -> "must not run"); + app.start(); + + try (SSLSocket socket = + (SSLSocket) + TestKeystores.trustAllClientContext() + .getSocketFactory() + .createSocket("localhost", port)) { + SSLParameters parameters = socket.getSSLParameters(); + parameters.setApplicationProtocols(new String[] {"h2"}); + socket.setSSLParameters(parameters); + socket.startHandshake(); + socket.getOutputStream().write(request("other.example")); + assertEquals(421, readStatus(socket.getInputStream())); + } + } + + private static byte[] request(String authority) { + ByteWriter bytes = new ByteWriter(128); + bytes.writeBytes(Http2Preface.clientPreface()); + FrameWriteBuffer frames = new FrameWriteBuffer(bytes); + frames.beginFrame(FrameType.SETTINGS, 0, 0); + frames.endFrame(); + frames.beginFrame( + FrameType.HEADERS, FrameFlags.END_HEADERS | FrameFlags.END_STREAM, 1); + HpackEncoder.writeIndexed(bytes, 2); + HpackEncoder.writeIndexed(bytes, 7); + HpackEncoder.writeLiteralWithNameIndex( + bytes, 1, authority.getBytes(java.nio.charset.StandardCharsets.US_ASCII), false); + HpackEncoder.writeIndexed(bytes, 4); + frames.endFrame(); + byte[] result = new byte[bytes.length()]; + System.arraycopy(bytes.array(), 0, result, 0, result.length); + return result; + } + + private static int readStatus(InputStream input) throws Exception { + HpackDecoder decoder = new HpackDecoder(); + byte[] header = new byte[9]; + while (true) { + input.readNBytes(header, 0, header.length); + int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + int type = header[3] & 0xff; + int streamId = + ((header[5] & 0x7f) << 24) + | ((header[6] & 0xff) << 16) + | ((header[7] & 0xff) << 8) + | (header[8] & 0xff); + byte[] payload = input.readNBytes(length); + if (type != FrameType.HEADERS.code() || streamId != 1) continue; + int[] status = {0}; + decoder.decode( + payload, + 0, + payload.length, + (name, value, never) -> { + if (name.length() == 7 && name.byteAt(0) == ':') { + status[0] = + (value.byteAt(0) - '0') * 100 + + (value.byteAt(1) - '0') * 10 + + value.byteAt(2) + - '0'; + } + }); + return status[0]; + } + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2PingTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2PingTest.java new file mode 100644 index 0000000..2423948 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2PingTest.java @@ -0,0 +1,46 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.http2.Http2ConnectionScratch.ControlIntent; +import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class Http2PingTest { + @Test + void pingResponseEchoesOpaqueBytesExactly() throws Exception { + byte[] opaque = "12345678".getBytes(StandardCharsets.US_ASCII); + byte[] input = + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.PING, 0, 0, opaque)); + + List frames = + Http2TestFrames.parse(Http2ConnectionHandshakeTest.run(input).output()); + Http2TestFrames.WireFrame pong = frames.get(frames.size() - 1); + + assertEquals(FrameType.PING.code(), pong.type()); + assertEquals(FrameFlags.ACK, pong.flags()); + assertArrayEquals(opaque, pong.payload()); + } + + @Test + void pingQueueIsStrictlyBounded() { + Http2ConnectionScratch scratch = new Http2ConnectionScratch(); + List claimed = new ArrayList<>(); + for (int i = 0; i < Http2Limits.MAX_PING_QUEUE_DEPTH; i++) { + claimed.add(scratch.acquire(ControlKind.PING)); + } + + Http2Exception error = + assertThrows(Http2Exception.class, () -> scratch.acquire(ControlKind.PING)); + assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, error.errorCode()); + claimed.forEach(ControlIntent::completed); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2RegressionCorpusTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2RegressionCorpusTest.java new file mode 100644 index 0000000..9cfcce0 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2RegressionCorpusTest.java @@ -0,0 +1,109 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.http2.frame.FrameType; +import java.io.EOFException; +import java.io.InputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.List; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class Http2RegressionCorpusTest { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @ParameterizedTest + @CsvSource({ + "invalid-preface.hex,GOAWAY,PROTOCOL_ERROR", + "lower-unopened-stream.hex,GOAWAY,PROTOCOL_ERROR" + }) + void exactWireCorpusProducesRequiredProtocolOutcome( + String resource, String expectedFrame, String expectedError) throws Exception { + byte[] input = load(resource); + List frames = + Http2TestFrames.parse(Http2ConnectionHandshakeTest.run(input).output()); + Http2TestFrames.WireFrame terminal = frames.get(frames.size() - 1); + + FrameType type = FrameType.valueOf(expectedFrame); + assertEquals(type.code(), terminal.type()); + int errorOffset = type == FrameType.GOAWAY ? 4 : 0; + assertEquals( + Http2ErrorCode.valueOf(expectedError).code(), + Http2TestFrames.readInt(terminal.payload(), errorOffset)); + } + + @Test + void headersOnHalfClosedRemoteStreamUseStreamErrorBeforeDispatch() throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .build()); + app.get("/", (request, response) -> "ok"); + app.start(); + + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + socket.getOutputStream().write(load("headers-after-end-stream.hex")); + socket.getOutputStream().flush(); + for (int i = 0; i < 10; i++) { + Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream()); + if (frame.type() != FrameType.RST_STREAM.code()) continue; + assertEquals(1, frame.streamId()); + assertEquals(Http2ErrorCode.STREAM_CLOSED.code(), Http2TestFrames.readInt(frame.payload(), 0)); + return; + } + throw new AssertionError("missing RST_STREAM(STREAM_CLOSED)"); + } + } + + private static byte[] load(String name) throws Exception { + String path = "/http2/regressions/" + name; + try (InputStream input = Http2RegressionCorpusTest.class.getResourceAsStream(path)) { + if (input == null) throw new AssertionError("missing regression resource " + path); + String text = new String(input.readAllBytes(), StandardCharsets.US_ASCII); + StringBuilder hex = new StringBuilder(); + for (String line : text.split("\\R")) { + String data = line.strip(); + if (!data.isEmpty() && !data.startsWith("#")) hex.append(data); + } + return HexFormat.of().parseHex(hex); + } + } + + private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception { + byte[] header = input.readNBytes(9); + if (header.length != 9) throw new EOFException("truncated frame header"); + int length = + ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + byte[] payload = input.readNBytes(length); + if (payload.length != length) throw new EOFException("truncated frame payload"); + return new Http2TestFrames.WireFrame( + header[3] & 0xff, + header[4] & 0xff, + Http2TestFrames.readInt(header, 5) & Integer.MAX_VALUE, + payload); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2SettingsTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2SettingsTest.java new file mode 100644 index 0000000..38eb658 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2SettingsTest.java @@ -0,0 +1,118 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import org.junit.jupiter.api.Test; + +class Http2SettingsTest { + @Test + void appliesEveryKnownSettingAndIgnoresUnknownIdentifiers() { + Http2Settings settings = new Http2Settings(); + byte[] payload = + payload( + Http2Settings.HEADER_TABLE_SIZE, + 8_192, + Http2Settings.ENABLE_PUSH, + 0, + Http2Settings.MAX_CONCURRENT_STREAMS, + 123, + Http2Settings.INITIAL_WINDOW_SIZE, + 70_000, + Http2Settings.MAX_FRAME_SIZE, + 32_768, + Http2Settings.MAX_HEADER_LIST_SIZE, + 99_999, + 0xf00d, + 42); + int[] delta = new int[1]; + + settings.apply(payload, 0, payload.length, value -> delta[0] = value); + + assertEquals(Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL, settings.headerTableSize()); + assertFalse(settings.pushEnabled()); + assertEquals(123, settings.maxConcurrentStreams()); + assertEquals(70_000, settings.initialWindowSize()); + assertEquals(32_768, settings.maxFrameSize()); + assertEquals(99_999, settings.maxHeaderListSize()); + assertEquals(70_000 - 65_535, delta[0]); + } + + @Test + void validatesBooleanSettingsInitialWindowAndFrameSize() { + assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.ENABLE_PUSH, 2)); + assertCode( + Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.ENABLE_CONNECT_PROTOCOL, 2)); + assertCode( + Http2ErrorCode.FLOW_CONTROL_ERROR, payload(Http2Settings.INITIAL_WINDOW_SIZE, 0x8000_0000)); + assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.MAX_FRAME_SIZE, 16_383)); + assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.MAX_FRAME_SIZE, 16_777_216)); + } + + @Test + void initialWindowDeltaMayMakeOpenStreamsNegative() { + Http2Settings settings = new Http2Settings(); + long[] windows = {10, 100, 65_535}; + + settings.apply( + payload(Http2Settings.INITIAL_WINDOW_SIZE, 1), + 0, + 6, + delta -> { + for (int i = 0; i < windows.length; i++) windows[i] += delta; + }); + + assertArrayEquals(new long[] {-65_524, -65_434, 1}, windows); + } + + @Test + void streamWindowOverflowRejectsWholeSettingsPayloadTransactionally() { + Http2Settings settings = new Http2Settings(); + byte[] payload = + payload( + Http2Settings.ENABLE_PUSH, 0, + Http2Settings.INITIAL_WINDOW_SIZE, 100_000); + + Http2Exception error = + assertThrows( + Http2Exception.class, + () -> + settings.apply( + payload, + 0, + payload.length, + delta -> { + throw Http2Exception.FLOW_CONTROL_ERROR; + })); + + assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, error.errorCode()); + assertTrue(settings.pushEnabled(), "no earlier setting may leak through a failed update"); + assertEquals(65_535, settings.initialWindowSize()); + } + + @Test + void malformedLengthAndEntryFloodAreRejected() { + Http2Settings settings = new Http2Settings(); + assertSame( + Http2Exception.FRAME_SIZE_ERROR, + assertThrows(Http2Exception.class, () -> settings.apply(new byte[5], 0, 5, d -> {}))); + + byte[] flood = new byte[(Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME + 1) * 6]; + Http2Exception error = + assertThrows(Http2Exception.class, () -> settings.apply(flood, 0, flood.length, d -> {})); + assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, error.errorCode()); + } + + private static void assertCode(Http2ErrorCode code, byte[] payload) { + Http2Settings settings = new Http2Settings(); + Http2Exception error = + assertThrows( + Http2Exception.class, () -> settings.apply(payload, 0, payload.length, d -> {})); + assertEquals(code, error.errorCode()); + } + + private static byte[] payload(int... pairs) { + byte[] settingsFrame = Http2TestFrames.settings(pairs); + return Arrays.copyOfRange(settingsFrame, 9, settingsFrame.length); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2SoakTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2SoakTest.java new file mode 100644 index 0000000..cb8f827 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2SoakTest.java @@ -0,0 +1,190 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.hpack.HpackEncoder; +import dev.relism.flash.testing.FuzzMemory; +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.InputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Arrays; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +@Tag("nightly") +@EnabledIfSystemProperty(named = "flash.http2.soak", matches = "true") +class Http2SoakTest { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void sustainedMixedTrafficRetainsBoundedHeapAndCompletesRequests() throws Exception { + long seconds = Long.getLong("flash.http2.soak.seconds", 600L); + int port = freePort(); + byte[] streamBody = new byte[8 * 1024]; + Arrays.fill(streamBody, (byte) 's'); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .h2MaxStreamsCreatedPerInterval(100_000) + .h2MaxStreamsPerConnection(0) + .build()); + app.get("/get", (request, response) -> "get"); + app.post("/post", (request, response) -> request.body().bytes()); + app.get("/stream", (request, response) -> response.chunked(new ByteArrayInputStream(streamBody))); + app.start(); + + long baseline = FuzzMemory.snapshot(); + long deadline = System.nanoTime() + Duration.ofSeconds(seconds).toNanos(); + int completed = 0; + int streamId = 1; + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(10_000); + socket + .getOutputStream() + .write( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]))); + socket.getOutputStream().flush(); + + for (int operation = 0; System.nanoTime() < deadline; operation++, streamId += 2) { + int kind = operation % 5; + if (kind == 3) { + byte[] opaque = new byte[8]; + opaque[7] = (byte) operation; + socket.getOutputStream().write(Http2TestFrames.frame(FrameType.PING, 0, 0, opaque)); + socket.getOutputStream().flush(); + awaitPing(socket.getInputStream()); + continue; + } + if (kind == 4) { + socket + .getOutputStream() + .write( + Http2TestFrames.concat( + Http2TestFrames.frame( + FrameType.HEADERS, + FrameFlags.END_HEADERS, + streamId, + requestHeaders("/post", false)), + Http2TestFrames.frame( + FrameType.RST_STREAM, + 0, + streamId, + Http2ErrorCode.CANCEL.bytes()))); + socket.getOutputStream().flush(); + continue; + } + + boolean post = kind == 1; + String path = kind == 2 ? "/stream" : (post ? "/post" : "/get"); + byte[] head = + Http2TestFrames.frame( + FrameType.HEADERS, + FrameFlags.END_HEADERS | (post ? 0 : FrameFlags.END_STREAM), + streamId, + requestHeaders(path, post)); + if (post) { + byte[] data = ("body-" + operation).getBytes(StandardCharsets.US_ASCII); + socket + .getOutputStream() + .write( + Http2TestFrames.concat( + head, + Http2TestFrames.frame( + FrameType.DATA, FrameFlags.END_STREAM, streamId, data))); + } else { + socket.getOutputStream().write(head); + } + socket.getOutputStream().flush(); + awaitResponse(socket, streamId); + completed++; + } + } + + assertTrue(completed > 0); + FuzzMemory.assertGrowthBelow(baseline, 32L * 1024 * 1024); + } + + private static byte[] requestHeaders(String path, boolean post) { + ByteWriter block = new ByteWriter(64); + HpackEncoder.writeIndexed(block, post ? 3 : 2); + HpackEncoder.writeIndexed(block, 6); + HpackEncoder.writeLiteralWithNameIndex( + block, 4, path.getBytes(StandardCharsets.US_ASCII), false); + HpackEncoder.writeLiteralWithNameIndex( + block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false); + return Arrays.copyOf(block.array(), block.length()); + } + + private static void awaitResponse(Socket socket, int streamId) throws Exception { + while (true) { + Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream()); + if (frame.type() == FrameType.GOAWAY.code()) { + throw new AssertionError("unexpected GOAWAY during soak"); + } + if (frame.type() == FrameType.DATA.code() && frame.payload().length > 0) { + byte[] increment = intBytes(frame.payload().length); + socket + .getOutputStream() + .write(Http2TestFrames.frame(FrameType.WINDOW_UPDATE, 0, 0, increment)); + socket + .getOutputStream() + .write(Http2TestFrames.frame(FrameType.WINDOW_UPDATE, 0, streamId, increment)); + socket.getOutputStream().flush(); + } + if (frame.streamId() == streamId && (frame.flags() & FrameFlags.END_STREAM) != 0) return; + } + } + + private static void awaitPing(InputStream input) throws Exception { + while (true) { + Http2TestFrames.WireFrame frame = readFrame(input); + if (frame.type() == FrameType.PING.code() && (frame.flags() & FrameFlags.ACK) != 0) return; + } + } + + private static byte[] intBytes(int value) { + return new byte[] {(byte) (value >>> 24), (byte) (value >>> 16), (byte) (value >>> 8), (byte) value}; + } + + private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception { + byte[] header = input.readNBytes(9); + if (header.length != 9) throw new EOFException("truncated frame header"); + int length = + ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + byte[] payload = input.readNBytes(length); + if (payload.length != length) throw new EOFException("truncated frame payload"); + return new Http2TestFrames.WireFrame( + header[3] & 0xff, + header[4] & 0xff, + Http2TestFrames.readInt(header, 5) & Integer.MAX_VALUE, + payload); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2StreamExceptionTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2StreamExceptionTest.java new file mode 100644 index 0000000..741399c --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2StreamExceptionTest.java @@ -0,0 +1,27 @@ +package dev.relism.flash.http2; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class Http2StreamExceptionTest { + + @Test + void carriesStreamIdAndErrorCode() { + Http2StreamException e = new Http2StreamException(7, Http2ErrorCode.STREAM_CLOSED, "closed"); + assertEquals(7, e.streamId()); + assertEquals(Http2ErrorCode.STREAM_CLOSED, e.errorCode()); + assertEquals("closed", e.getMessage()); + } + + @Test + void stackTraceCaptureIsDisabled() { + Http2StreamException e = new Http2StreamException(3, Http2ErrorCode.CANCEL, "cancelled"); + assertEquals(0, e.getStackTrace().length); + } + + @Test + void doesNotExtendIoException() { + assertFalse(java.io.IOException.class.isAssignableFrom(Http2StreamException.class)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2TestFrames.java b/flash/src/test/java/dev/relism/flash/http2/Http2TestFrames.java new file mode 100644 index 0000000..140d1c1 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2TestFrames.java @@ -0,0 +1,70 @@ +package dev.relism.flash.http2; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.frame.FrameWriteBuffer; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +final class Http2TestFrames { + static final byte[] PREFACE = + "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII); + + private Http2TestFrames() {} + + static byte[] frame(FrameType type, int flags, int streamId, byte[] payload) { + ByteWriter bytes = new ByteWriter(32); + FrameWriteBuffer frame = new FrameWriteBuffer(bytes); + frame.beginFrame(type, flags, streamId); + bytes.writeBytes(payload); + frame.endFrame(); + byte[] result = new byte[bytes.length()]; + System.arraycopy(bytes.array(), 0, result, 0, result.length); + return result; + } + + static byte[] settings(int... idValuePairs) { + ByteWriter payload = new ByteWriter(Math.max(16, idValuePairs.length * 3)); + for (int i = 0; i < idValuePairs.length; i += 2) { + payload.writeUInt16(idValuePairs[i]); + payload.writeUInt32(idValuePairs[i + 1]); + } + byte[] body = new byte[payload.length()]; + System.arraycopy(payload.array(), 0, body, 0, body.length); + return frame(FrameType.SETTINGS, 0, 0, body); + } + + static byte[] concat(byte[]... parts) { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] part : parts) out.writeBytes(part); + return out.toByteArray(); + } + + static List parse(byte[] bytes) { + List frames = new ArrayList<>(); + int pos = 0; + while (pos < bytes.length) { + int length = + ((bytes[pos] & 0xFF) << 16) | ((bytes[pos + 1] & 0xFF) << 8) | (bytes[pos + 2] & 0xFF); + int type = bytes[pos + 3] & 0xFF; + int flags = bytes[pos + 4] & 0xFF; + int streamId = readInt(bytes, pos + 5) & 0x7FFF_FFFF; + byte[] payload = new byte[length]; + System.arraycopy(bytes, pos + 9, payload, 0, length); + frames.add(new WireFrame(type, flags, streamId, payload)); + pos += 9 + length; + } + return frames; + } + + static int readInt(byte[] bytes, int off) { + return ((bytes[off] & 0xFF) << 24) + | ((bytes[off + 1] & 0xFF) << 16) + | ((bytes[off + 2] & 0xFF) << 8) + | (bytes[off + 3] & 0xFF); + } + + record WireFrame(int type, int flags, int streamId, byte[] payload) {} +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2TrailersTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2TrailersTest.java new file mode 100644 index 0000000..880c2ae --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2TrailersTest.java @@ -0,0 +1,160 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.hpack.HpackEncoder; +import java.io.EOFException; +import java.io.InputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class Http2TrailersTest { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void requestTrailersReachHandlerAfterBodyEof() throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .build()); + app.post("/trailers", (request, response) -> { + assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII)); + return request.trailers().first("grpc-status"); + }); + app.start(); + + byte[] initial = requestHeaders("/trailers"); + ByteWriter trailer = new ByteWriter(32); + HpackEncoder.writeLiteral( + trailer, "grpc-status".getBytes(StandardCharsets.US_ASCII), + "7".getBytes(StandardCharsets.US_ASCII)); + + try (Socket socket = connect(port)) { + socket.getOutputStream().write(Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]), + Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, initial), + Http2TestFrames.frame(FrameType.DATA, 0, 1, "abc".getBytes(StandardCharsets.US_ASCII)), + Http2TestFrames.frame(FrameType.HEADERS, + FrameFlags.END_HEADERS | FrameFlags.END_STREAM, 1, + Arrays.copyOf(trailer.array(), trailer.length())))); + socket.getOutputStream().flush(); + + Http2TestFrames.WireFrame data = frameOfType(socket.getInputStream(), 1, FrameType.DATA); + assertEquals("7", new String(data.payload(), StandardCharsets.US_ASCII)); + } + } + + @Test + void trailersWithoutEndStreamAreRejected() throws Exception { + int port = startBlockingRoute(); + ByteWriter trailer = new ByteWriter(32); + HpackEncoder.writeLiteral(trailer, ascii("x-end"), ascii("no")); + try (Socket socket = connect(port)) { + socket.getOutputStream().write(Http2TestFrames.concat( + Http2TestFrames.PREFACE, Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]), + Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, requestHeaders("/trailers")), + Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, + Arrays.copyOf(trailer.array(), trailer.length())))); + socket.getOutputStream().flush(); + Http2TestFrames.WireFrame rst = frameOfType(socket.getInputStream(), 1, FrameType.RST_STREAM); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(rst.payload(), 0)); + } + } + + @Test + void pseudoHeaderInTrailersIsRejected() throws Exception { + int port = startBlockingRoute(); + try (Socket socket = connect(port)) { + socket.getOutputStream().write(Http2TestFrames.concat( + Http2TestFrames.PREFACE, Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]), + Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, requestHeaders("/trailers")), + Http2TestFrames.frame(FrameType.HEADERS, + FrameFlags.END_HEADERS | FrameFlags.END_STREAM, 1, new byte[] {(byte) 0x88}))); + socket.getOutputStream().flush(); + Http2TestFrames.WireFrame rst = frameOfType(socket.getInputStream(), 1, FrameType.RST_STREAM); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(rst.payload(), 0)); + } + } + + private int startBlockingRoute() throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .build()); + app.post("/trailers", (request, response) -> request.body().bytes()); + app.start(); + return port; + } + + private static byte[] requestHeaders(String path) { + ByteWriter block = new ByteWriter(64); + HpackEncoder.writeIndexed(block, 3); + HpackEncoder.writeIndexed(block, 6); + HpackEncoder.writeLiteralWithNameIndex(block, 4, ascii(path), false); + HpackEncoder.writeLiteralWithNameIndex(block, 1, ascii("localhost"), false); + HpackEncoder.writeLiteralWithNameIndex(block, 59, ascii("trailers"), false); + return Arrays.copyOf(block.array(), block.length()); + } + + private static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + private static Socket connect(int port) throws Exception { + Socket socket = new Socket("127.0.0.1", port); + socket.setSoTimeout(5_000); + return socket; + } + + private static Http2TestFrames.WireFrame frameOfType( + InputStream input, int streamId, FrameType type) throws Exception { + for (int i = 0; i < 12; i++) { + Http2TestFrames.WireFrame frame = readFrame(input); + if (frame.streamId() == streamId && frame.type() == type.code()) return frame; + } + throw new AssertionError("missing " + type + " frame"); + } + + private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception { + byte[] header = input.readNBytes(9); + if (header.length != 9) throw new EOFException(); + int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff); + byte[] payload = input.readNBytes(length); + if (payload.length != length) throw new EOFException(); + return new Http2TestFrames.WireFrame( + header[3] & 0xff, header[4] & 0xff, Http2TestFrames.readInt(header, 5) & 0x7fff_ffff, + payload); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/Http2WindowUpdateTest.java b/flash/src/test/java/dev/relism/flash/http2/Http2WindowUpdateTest.java new file mode 100644 index 0000000..e0ece77 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/Http2WindowUpdateTest.java @@ -0,0 +1,48 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.http2.frame.FrameType; +import java.util.List; +import org.junit.jupiter.api.Test; + +class Http2WindowUpdateTest { + @Test + void connectionWindowUpdateIncreasesSendWindow() throws Exception { + Http2ConnectionHandshakeTest.RunResult result = runWindowUpdate(10_000); + assertEquals(75_535, result.connection().connectionSendWindow()); + } + + @Test + void zeroIncrementIsProtocolError() throws Exception { + assertGoAwayCode(Http2ErrorCode.PROTOCOL_ERROR, runWindowUpdate(0).output()); + } + + @Test + void connectionWindowOverflowIsFlowControlError() throws Exception { + assertGoAwayCode( + Http2ErrorCode.FLOW_CONTROL_ERROR, runWindowUpdate(Integer.MAX_VALUE).output()); + } + + private static Http2ConnectionHandshakeTest.RunResult runWindowUpdate(int increment) + throws Exception { + byte[] payload = { + (byte) (increment >>> 24), + (byte) (increment >>> 16), + (byte) (increment >>> 8), + (byte) increment + }; + return Http2ConnectionHandshakeTest.run( + Http2TestFrames.concat( + Http2TestFrames.PREFACE, + Http2TestFrames.settings(), + Http2TestFrames.frame(FrameType.WINDOW_UPDATE, 0, 0, payload))); + } + + private static void assertGoAwayCode(Http2ErrorCode expected, byte[] output) { + List frames = Http2TestFrames.parse(output); + Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1); + assertEquals(FrameType.GOAWAY.code(), goAway.type()); + assertEquals(expected.code(), Http2TestFrames.readInt(goAway.payload(), 4)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/NghttpInteropTest.java b/flash/src/test/java/dev/relism/flash/http2/NghttpInteropTest.java new file mode 100644 index 0000000..46fc5d7 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/NghttpInteropTest.java @@ -0,0 +1,101 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.tls.TestKeystores; +import dev.relism.flash.tls.TlsConfig; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; +import org.junit.jupiter.api.io.TempDir; + +@EnabledIfSystemProperty(named = "nghttp.executable", matches = ".+") +class NghttpInteropTest { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void verboseFrameTraceIsCorrectForTlsAndCleartext(@TempDir Path directory) throws Exception { + exercise(directory, true); + stop(); + app = null; + exercise(directory, false); + } + + private void exercise(Path directory, boolean tls) throws Exception { + int port = freePort(); + FlashConfiguration.FlashConfigurationBuilder builder = + FlashConfiguration.builder().host("127.0.0.1").port(port); + if (tls) { + Path keystore = + TestKeystores.build( + directory, + "nghttp.p12", + "changeit", + TestKeystores.Entry.of("server", "localhost", "localhost")); + builder.tls(TlsConfig.keystore(keystore, "changeit")).http2Enabled(true); + } else { + builder.http2CleartextEnabled(true); + } + byte[] large = new byte[2 * 1024 * 1024 + 29]; + app = FlashApp.create(builder.build()); + app.get("/get", (request, response) -> "nghttp-get"); + app.post("/post", (request, response) -> "uploaded-" + request.body().bytes().length); + app.get("/large", (request, response) -> response.body(large)); + app.start(); + + String origin = (tls ? "https://localhost:" : "http://127.0.0.1:") + port; + Path upload = directory.resolve("nghttp-upload.bin"); + Files.write(upload, large); + assertTrace(run(origin + "/get", tls)); + assertTrace(run(origin + "/post", tls, "-d", upload.toString())); + assertTrace(run(origin + "/large", tls, "-n")); + } + + private static String run(String uri, boolean tls, String... extra) throws Exception { + List command = new ArrayList<>(); + command.add(System.getProperty("nghttp.executable")); + command.add("-v"); + command.add("-t"); + command.add("30s"); + if (tls) command.add("-y"); + command.addAll(List.of(extra)); + command.add(uri); + ProcessBuilder builder = new ProcessBuilder(command).redirectErrorStream(true); + String libraryPath = System.getProperty("nghttp.library.path"); + if (libraryPath != null) builder.environment().put("LD_LIBRARY_PATH", libraryPath); + Process process = builder.start(); + assertTrue(process.waitFor(Duration.ofSeconds(40).toMillis(), TimeUnit.MILLISECONDS)); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertEquals(0, process.exitValue(), output); + return output; + } + + private static void assertTrace(String trace) { + assertTrue(trace.contains("recv SETTINGS frame"), trace); + assertTrue(trace.contains("recv HEADERS frame"), trace); + assertTrue(trace.contains(":status: 200"), trace); + assertTrue(trace.contains("recv DATA frame"), trace); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/RollingWindowCounterTest.java b/flash/src/test/java/dev/relism/flash/http2/RollingWindowCounterTest.java new file mode 100644 index 0000000..e8fa589 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/RollingWindowCounterTest.java @@ -0,0 +1,24 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class RollingWindowCounterTest { + @Test + void retainsOnlyCurrentAndImmediatelyPreviousHalfWindow() { + RollingWindowCounter counter = new RollingWindowCounter(1_000); + assertFalse(counter.incrementExceeded(2, 500_000_000L)); + assertFalse(counter.incrementExceeded(2, 999_000_000L)); + assertTrue(counter.incrementExceeded(2, 1_000_000_000L)); + assertFalse(counter.incrementExceeded(2, 1_500_000_000L)); + } + + @Test + void longIdleGapClearsBothBuckets() { + RollingWindowCounter counter = new RollingWindowCounter(1_000); + assertFalse(counter.incrementExceeded(1, 500_000_000L)); + assertFalse(counter.incrementExceeded(1, 2_000_000_000L)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/WebSocketOverH2Test.java b/flash/src/test/java/dev/relism/flash/http2/WebSocketOverH2Test.java new file mode 100644 index 0000000..d269f51 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/WebSocketOverH2Test.java @@ -0,0 +1,75 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.websocket.WebSocketFrame; +import dev.relism.flash.websocket.WebSocketHandler; +import dev.relism.flash.websocket.WebSocketSession; +import java.net.ServerSocket; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class WebSocketOverH2Test { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void opensEchoesFragmentsAndCarriesAMessageLargerThanTheFlowWindow() throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .wsFrameBufferSize(2 * 1024 * 1024) + .build()); + app.ws( + "/chat", + new WebSocketHandler() { + @Override + public void onOpen(WebSocketSession session) {} + + @Override + public void onMessage(WebSocketSession session, WebSocketFrame frame) { + try { + session.echo(frame); + } catch (Exception failure) { + throw new RuntimeException(failure); + } + } + }); + app.start(); + + try (H2WebSocketTestClient client = + new H2WebSocketTestClient("127.0.0.1", port, "/chat")) { + assertTrue(client.connectProtocolAdvertised()); + + client.sendFragmentedText("hel", "lo"); + assertArrayEquals( + "hello".getBytes(StandardCharsets.UTF_8), + client.readMessage(WebSocketFrame.OP_TEXT)); + + byte[] large = new byte[Http2Limits.INITIAL_WINDOW_SIZE_LOCAL + 128 * 1024 + 17]; + for (int i = 0; i < large.length; i++) large[i] = (byte) (i * 31); + client.sendBinary(large); + assertArrayEquals(large, client.readMessage(WebSocketFrame.OP_BINARY)); + + client.closeGracefully(); + } + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/WebSocketParityTest.java b/flash/src/test/java/dev/relism/flash/http2/WebSocketParityTest.java new file mode 100644 index 0000000..fb120ae --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/WebSocketParityTest.java @@ -0,0 +1,143 @@ +package dev.relism.flash.http2; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.websocket.WebSocketFrame; +import dev.relism.flash.websocket.WebSocketHandler; +import dev.relism.flash.websocket.WebSocketSession; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class WebSocketParityTest { + private FlashApp app; + + @AfterEach + void stop() { + if (app != null) app.stop().join(); + } + + @Test + void oneRouteAndHandlerEchoTheSameMessageOverHttp1AndHttp2() throws Exception { + int port = freePort(); + app = + FlashApp.create( + FlashConfiguration.builder() + .host("127.0.0.1") + .port(port) + .http2CleartextEnabled(true) + .build()); + app.ws( + "/parity", + new WebSocketHandler() { + @Override + public void onOpen(WebSocketSession session) {} + + @Override + public void onMessage(WebSocketSession session, WebSocketFrame frame) { + try { + session.echo(frame); + } catch (Exception failure) { + throw new RuntimeException(failure); + } + } + }); + app.start(); + + byte[] expected = "same-handler".getBytes(StandardCharsets.UTF_8); + byte[] overHttp1 = exchangeOverHttp1(port, expected); + byte[] overHttp2; + try (H2WebSocketTestClient client = + new H2WebSocketTestClient("127.0.0.1", port, "/parity")) { + client.sendText(new String(expected, StandardCharsets.UTF_8)); + overHttp2 = client.readMessage(WebSocketFrame.OP_TEXT); + client.closeGracefully(); + } + + assertArrayEquals(expected, overHttp1); + assertArrayEquals(overHttp1, overHttp2); + } + + private static byte[] exchangeOverHttp1(int port, byte[] payload) throws Exception { + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + InputStream input = socket.getInputStream(); + OutputStream output = socket.getOutputStream(); + String key = + Base64.getEncoder() + .encodeToString("flash-parity-key".getBytes(StandardCharsets.US_ASCII)); + String request = + "GET /parity HTTP/1.1\r\n" + + "Host: 127.0.0.1:" + + port + + "\r\nUpgrade: websocket\r\n" + + "Connection: Upgrade\r\nSec-WebSocket-Key: " + + key + + "\r\nSec-WebSocket-Version: 13\r\n\r\n"; + output.write(request.getBytes(StandardCharsets.US_ASCII)); + output.flush(); + assertTrue(readHeaders(input).startsWith("HTTP/1.1 101 Switching Protocols")); + + output.write(maskedFrame(WebSocketFrame.OP_TEXT, payload)); + output.flush(); + byte[] echoed = readServerFrame(input, WebSocketFrame.OP_TEXT); + output.write(maskedFrame(WebSocketFrame.OP_CLOSE, new byte[] {3, (byte) 232})); + output.flush(); + return echoed; + } + } + + private static String readHeaders(InputStream input) throws Exception { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + int previous3 = -1; + int previous2 = -1; + int previous1 = -1; + int current; + while ((current = input.read()) >= 0) { + bytes.write(current); + if (previous3 == '\r' && previous2 == '\n' && previous1 == '\r' && current == '\n') { + break; + } + previous3 = previous2; + previous2 = previous1; + previous1 = current; + } + return bytes.toString(StandardCharsets.US_ASCII); + } + + private static byte[] maskedFrame(byte opcode, byte[] payload) { + byte[] encoded = new byte[6 + payload.length]; + encoded[0] = (byte) (0x80 | opcode); + encoded[1] = (byte) (0x80 | payload.length); + byte[] mask = {1, 2, 3, 4}; + System.arraycopy(mask, 0, encoded, 2, mask.length); + for (int i = 0; i < payload.length; i++) { + encoded[6 + i] = (byte) (payload[i] ^ mask[i & 3]); + } + return encoded; + } + + private static byte[] readServerFrame(InputStream input, byte expectedOpcode) throws Exception { + byte[] header = input.readNBytes(2); + if (header.length != 2 || (header[0] & 0x0f) != expectedOpcode) { + throw new AssertionError("unexpected WebSocket response frame"); + } + int length = header[1] & 0x7f; + return input.readNBytes(length); + } + + private static int freePort() throws Exception { + try (ServerSocket socket = new ServerSocket(0)) { + return socket.getLocalPort(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/frame/FrameValidatorTest.java b/flash/src/test/java/dev/relism/flash/http2/frame/FrameValidatorTest.java new file mode 100644 index 0000000..68a67ad --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/frame/FrameValidatorTest.java @@ -0,0 +1,181 @@ +package dev.relism.flash.http2.frame; + +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.*; + +/** One test per RFC-mandated rejection, asserting the specific {@link Http2ErrorCode} — not merely that {@link Http2Exception} was thrown. */ +class FrameValidatorTest { + + private static byte[] rawFrame(int length, int typeCode, int flags, int streamId) { + byte[] buf = new byte[9]; + buf[0] = (byte) (length >>> 16); + buf[1] = (byte) (length >>> 8); + buf[2] = (byte) length; + buf[3] = (byte) typeCode; + buf[4] = (byte) flags; + buf[5] = (byte) (streamId >>> 24); + buf[6] = (byte) (streamId >>> 16); + buf[7] = (byte) (streamId >>> 8); + buf[8] = (byte) streamId; + return buf; + } + + private static FrameHeader headerOf(int length, FrameType type, int flags, int streamId) { + byte[] buf = rawFrame(length, type.code(), flags, streamId); + FrameHeader header = new FrameHeader(); + // reset() is package-private; same package as this test. + header.reset(buf, 0); + return header; + } + + private static Http2ErrorCode codeOf(FrameHeader header, boolean insideHeaderBlock) { + Http2Exception ex = assertThrows(Http2Exception.class, () -> FrameValidator.validate(header, insideHeaderBlock)); + return ex.errorCode(); + } + + // ── Length bounds, per type ────────────────────────────────────────────── + + @Test + void ping_wrongLength_isFrameSizeError() { + FrameHeader h = headerOf(7, FrameType.PING, 0, 0); + assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false)); + } + + @Test + void rstStream_wrongLength_isFrameSizeError() { + FrameHeader h = headerOf(3, FrameType.RST_STREAM, 0, 1); + assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false)); + } + + @Test + void windowUpdate_wrongLength_isFrameSizeError() { + FrameHeader h = headerOf(5, FrameType.WINDOW_UPDATE, 0, 1); + assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false)); + } + + @Test + void priority_wrongLength_isFrameSizeError() { + FrameHeader h = headerOf(4, FrameType.PRIORITY, 0, 1); + assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false)); + } + + @Test + void goaway_tooShort_isFrameSizeError() { + FrameHeader h = headerOf(7, FrameType.GOAWAY, 0, 0); + assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false)); + } + + @Test + void goaway_exactlyEightBytes_isValid() { + FrameHeader h = headerOf(8, FrameType.GOAWAY, 0, 0); + assertDoesNotThrow(() -> FrameValidator.validate(h, false)); + } + + @Test + void settings_notMultipleOfSix_isFrameSizeError() { + FrameHeader h = headerOf(7, FrameType.SETTINGS, 0, 0); + assertEquals(Http2ErrorCode.FRAME_SIZE_ERROR, codeOf(h, false)); + } + + @Test + void settings_multipleOfSix_isValid() { + FrameHeader h = headerOf(12, FrameType.SETTINGS, 0, 0); + assertDoesNotThrow(() -> FrameValidator.validate(h, false)); + } + + @Test + void settings_zeroLength_isValid() { + // An empty SETTINGS frame (0 entries) is legal -- e.g. the initial connection SETTINGS + // with no non-default values, or a SETTINGS ACK. + FrameHeader h = headerOf(0, FrameType.SETTINGS, FrameFlags.ACK, 0); + assertDoesNotThrow(() -> FrameValidator.validate(h, false)); + } + + // ── Stream id rules ────────────────────────────────────────────────────── + + @Test + void settings_nonZeroStreamId_isProtocolError() { + FrameHeader h = headerOf(0, FrameType.SETTINGS, 0, 1); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false)); + } + + @Test + void ping_nonZeroStreamId_isProtocolError() { + FrameHeader h = headerOf(8, FrameType.PING, 0, 3); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false)); + } + + @Test + void goaway_nonZeroStreamId_isProtocolError() { + FrameHeader h = headerOf(8, FrameType.GOAWAY, 0, 5); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false)); + } + + @Test + void data_zeroStreamId_isProtocolError() { + FrameHeader h = headerOf(0, FrameType.DATA, 0, 0); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false)); + } + + @Test + void headers_zeroStreamId_isProtocolError() { + FrameHeader h = headerOf(0, FrameType.HEADERS, 0, 0); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false)); + } + + @Test + void rstStream_zeroStreamId_isProtocolError() { + FrameHeader h = headerOf(4, FrameType.RST_STREAM, 0, 0); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false)); + } + + @Test + void windowUpdate_zeroStreamId_isValid_connectionWindow() { + FrameHeader h = headerOf(4, FrameType.WINDOW_UPDATE, 0, 0); + assertDoesNotThrow(() -> FrameValidator.validate(h, false)); + } + + @Test + void windowUpdate_nonZeroStreamId_isValid_streamWindow() { + FrameHeader h = headerOf(4, FrameType.WINDOW_UPDATE, 0, 9); + assertDoesNotThrow(() -> FrameValidator.validate(h, false)); + } + + // ── PUSH_PROMISE from a client ─────────────────────────────────────────── + + @Test + void pushPromise_fromClient_isAlwaysProtocolError() { + FrameHeader h = headerOf(4, FrameType.PUSH_PROMISE, 0, 1); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, false)); + } + + // ── Unknown frame types ────────────────────────────────────────────────── + + @Test + void unknownType_outsideHeaderBlock_isIgnoredNotRejected() { + byte[] buf = rawFrame(3, 0x20, 0, 1); // 0x20 is not a recognised type + FrameHeader h = new FrameHeader(); + h.reset(buf, 0); + assertNull(h.type()); + assertDoesNotThrow(() -> FrameValidator.validate(h, false)); + } + + @Test + void unknownType_insideHeaderBlock_isProtocolError() { + byte[] buf = rawFrame(3, 0x20, 0, 1); + FrameHeader h = new FrameHeader(); + h.reset(buf, 0); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, codeOf(h, true)); + } + + // ── Frame-size ceiling ──────────────────────────────────────────────────── + + @Test + void declaredLengthAboveMaxFrameSize_isFrameSizeError() { + 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)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameReaderFuzzTest.java b/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameReaderFuzzTest.java new file mode 100644 index 0000000..a14ac66 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameReaderFuzzTest.java @@ -0,0 +1,67 @@ +package dev.relism.flash.http2.frame; + +import dev.relism.flash.http2.Http2Exception; +import dev.relism.flash.transport.BufferedByteSource; +import dev.relism.flash.testing.FuzzMemory; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.time.Duration; +import java.util.Random; + +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertTimeout; + +/** + * {@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 + * exceed 16384), an {@link EOFException} (the random input ran out before a full frame arrived + * — the second most common outcome, since fuzz inputs are deliberately small), or a + * {@link SocketTimeoutException} (never actually expected here — no deadline is short enough to + * trip against an in-memory stream — but a legal outcome of the API's own contract). Anything + * else escaping — {@code ArrayIndexOutOfBoundsException}, {@code NegativeArraySizeException}, + * {@code OutOfMemoryError}, or simply never returning — fails the test. + */ +class Http2FrameReaderFuzzTest { + + private static final int TRIALS = 10_000_000; + private static final int MAX_INPUT_LEN = 64; + + @Test + void fuzz_10MillionRandomInputs_onlyTypedOutcomesEscape() { + assertTimeout(Duration.ofSeconds(30), this::runFuzzCases); + } + + private void runFuzzCases() { + Random rnd = new Random(0x4855_3244_5F46_5A32L); + byte[] data = new byte[MAX_INPUT_LEN]; + long baseline = FuzzMemory.snapshot(); + + for (int trial = 0; trial < TRIALS; trial++) { + int len = rnd.nextInt(MAX_INPUT_LEN + 1); + for (int i = 0; i < len; i++) data[i] = (byte) rnd.nextInt(256); + + BufferedByteSource src = new BufferedByteSource( + new ByteArrayInputStream(data, 0, len), null, 128); + Http2FrameReader reader = new Http2FrameReader(src, 128); + + try { + FrameHeader header = reader.readFrame(); + if (header != null) { + reader.consumeFrame(); + } + } catch (Http2Exception | EOFException | SocketTimeoutException expected) { + // any of these three is a correctly-typed rejection of malformed/truncated input + } catch (IOException e) { + fail("unexpected IOException at trial " + trial + " (len=" + len + "): " + e, e); + } catch (RuntimeException e) { + fail("unexpected RuntimeException at trial " + trial + " (len=" + len + "): " + e, e); + } + } + FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameReaderTest.java b/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameReaderTest.java new file mode 100644 index 0000000..b8df2a6 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameReaderTest.java @@ -0,0 +1,209 @@ +package dev.relism.flash.http2.frame; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.Http2Exception; +import dev.relism.flash.transport.BufferedByteSource; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; + +import static org.junit.jupiter.api.Assertions.*; + +class Http2FrameReaderTest { + + private static BufferedByteSource sourceOf(byte[] bytes) { + return new BufferedByteSource(new ByteArrayInputStream(bytes), null); + } + + private static byte[] buildFrame(FrameType type, int flags, int streamId, byte[] payload) { + FrameWriteBuffer out = new FrameWriteBuffer(new ByteWriter(32)); + out.beginFrame(type, flags, streamId); + out.writer().writeBytes(payload); + out.endFrame(); + byte[] result = new byte[out.writer().length()]; + System.arraycopy(out.writer().array(), 0, result, 0, result.length); + return result; + } + + // ── Round trip every frame type ───────────────────────────────────────── + + @Test + void roundTrip_everyFrameType() throws IOException { + for (FrameType type : FrameType.values()) { + int payloadLen = switch (type) { + case PING -> 8; + case RST_STREAM, WINDOW_UPDATE -> 4; + case PRIORITY -> 5; + case GOAWAY -> 8; + default -> 10; + }; + byte[] payload = new byte[payloadLen]; + for (int i = 0; i < payloadLen; i++) payload[i] = (byte) (i + 1); + int streamId = type.streamIdRule() == FrameType.StreamIdRule.FORBIDDEN ? 0 : 7; + + byte[] wire = buildFrame(type, 0x1, streamId, payload); + Http2FrameReader reader = new Http2FrameReader(sourceOf(wire)); + FrameHeader header = reader.readFrame(); + + assertNotNull(header, "type=" + type); + assertEquals(type, header.type()); + assertEquals(type.code(), header.typeCode()); + assertEquals(payloadLen, header.length()); + assertEquals(streamId, header.streamId()); + assertEquals(0x1, header.flags()); + for (int i = 0; i < payloadLen; i++) { + assertEquals(payload[i], header.buffer()[header.payloadOffset() + i], "byte " + i + " of type " + type); + } + reader.consumeFrame(); + } + } + + // ── Boundary lengths ───────────────────────────────────────────────────── + + @Test + void boundaryLengths_0_1_16383_16384_16385() throws IOException { + int[] lengths = {0, 1, 16383, 16384, 16385}; + for (int len : lengths) { + byte[] payload = new byte[len]; + byte[] wire = buildFrame(FrameType.DATA, 0, 1, payload); + Http2FrameReader reader = new Http2FrameReader(sourceOf(wire)); + if (len > dev.relism.flash.http2.Http2Limits.MAX_FRAME_SIZE_LOCAL) { + Http2Exception ex = assertThrows(Http2Exception.class, reader::readFrame); + assertEquals(dev.relism.flash.http2.Http2ErrorCode.FRAME_SIZE_ERROR, ex.errorCode()); + } else { + FrameHeader header = reader.readFrame(); + assertNotNull(header); + assertEquals(len, header.length()); + } + } + } + + // ── A frame split across multiple socket reads ───────────────────────── + + private static final class DribblingInputStream extends InputStream { + private final byte[] data; + private int pos; + private final int chunkSize; + + DribblingInputStream(byte[] data, int chunkSize) { + this.data = data; + this.chunkSize = chunkSize; + } + + @Override + public int read() { + return pos < data.length ? (data[pos++] & 0xFF) : -1; + } + + @Override + public int read(byte[] dst, int off, int len) { + if (pos >= data.length) return -1; + int n = Math.min(chunkSize, Math.min(len, data.length - pos)); + System.arraycopy(data, pos, dst, off, n); + pos += n; + return n; + } + } + + @Test + void frameSplitAcrossThreeSocketReads() throws IOException { + byte[] payload = new byte[300]; + for (int i = 0; i < payload.length; i++) payload[i] = (byte) i; + byte[] wire = buildFrame(FrameType.DATA, 0, 3, payload); + + // 9(header) + 300(payload) = 309 bytes, dribbled in chunks of 103 -> 3 reads. + int chunk = (wire.length + 2) / 3; + BufferedByteSource src = new BufferedByteSource(new DribblingInputStream(wire, chunk), null); + Http2FrameReader reader = new Http2FrameReader(src); + FrameHeader header = reader.readFrame(); + + assertNotNull(header); + assertEquals(300, header.length()); + for (int i = 0; i < 300; i++) { + assertEquals(payload[i], header.buffer()[header.payloadOffset() + i]); + } + } + + // ── A frame exactly filling the initial buffer ────────────────────────── + + @Test + void frameExactlyFillingInitialBuffer() throws IOException { + int bufSize = 64; + byte[] payload = new byte[bufSize - 9]; // header + payload == bufSize exactly + byte[] wire = buildFrame(FrameType.DATA, 0, 1, payload); + assertEquals(bufSize, wire.length); + + Http2FrameReader reader = new Http2FrameReader(sourceOf(wire), bufSize); + FrameHeader header = reader.readFrame(); + assertNotNull(header); + assertEquals(payload.length, header.length()); + } + + // ── Multiple frames on one connection, sequential reads ───────────────── + + @Test + void multipleFramesSequentially() throws IOException { + ByteWriter w = new ByteWriter(64); + FrameWriteBuffer out = new FrameWriteBuffer(w); + 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.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()]; + System.arraycopy(w.array(), 0, wire, 0, wire.length); + + Http2FrameReader reader = new Http2FrameReader(sourceOf(wire)); + FrameHeader first = reader.readFrame(); + assertEquals(1, first.buffer()[first.payloadOffset()]); + assertEquals(0, first.flags()); + reader.consumeFrame(); + + FrameHeader second = reader.readFrame(); + assertEquals(8, second.buffer()[second.payloadOffset()]); + assertEquals(FrameFlags.ACK, second.flags()); + reader.consumeFrame(); + + assertNull(reader.readFrame()); // clean EOF after both frames consumed + } + + // ── EOF handling ───────────────────────────────────────────────────────── + + @Test + void cleanEofBetweenFrames_returnsNull() throws IOException { + Http2FrameReader reader = new Http2FrameReader(sourceOf(new byte[0])); + assertNull(reader.readFrame()); + } + + @Test + void eofMidFrame_throwsEOFException() { + byte[] wire = buildFrame(FrameType.DATA, 0, 1, new byte[100]); + byte[] truncated = new byte[50]; // header + partial payload + System.arraycopy(wire, 0, truncated, 0, 50); + + Http2FrameReader reader = new Http2FrameReader(sourceOf(truncated)); + assertThrows(EOFException.class, reader::readFrame); + } + + @Test + void eofMidHeader_throwsEOFException() { + byte[] truncated = new byte[5]; // fewer than the 9 header bytes + Http2FrameReader reader = new Http2FrameReader(sourceOf(truncated)); + assertThrows(EOFException.class, reader::readFrame); + } + + // ── Reserved bit masking ───────────────────────────────────────────────── + + @Test + void reservedBitInStreamId_isMaskedNotRejected() throws IOException { + byte[] wire = buildFrame(FrameType.DATA, 0, 5, new byte[]{1, 2, 3}); + wire[5] |= (byte) 0x80; // set the reserved high bit of the stream-id field + Http2FrameReader reader = new Http2FrameReader(sourceOf(wire)); + FrameHeader header = reader.readFrame(); + assertEquals(5, header.streamId(), "reserved bit must be masked, not folded into the stream id"); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameWriterStressTest.java b/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameWriterStressTest.java new file mode 100644 index 0000000..11f209d --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameWriterStressTest.java @@ -0,0 +1,155 @@ +package dev.relism.flash.http2.frame; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@code N} producer virtual threads each write {@code M} distinguishable frames into a mock + * sink; every byte of every frame must arrive, in valid frame-boundary order (frames from + * different producers may interleave with each other, but a single frame's own bytes must never + * be split by another frame's bytes — proven here because a torn frame corrupts the parser + * below in a way the assertions catch), with no duplication and no loss, and each producer's + * own frames must arrive in the order that producer submitted them. + * + *

    This suite runs at reduced iteration counts for a fast default {@code mvn test} run. The + * 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 + */ +class Http2FrameWriterStressTest { + + private static final class TestIntent implements WriteIntent { + final byte[] buf; + WriteIntent next; + TestIntent(byte[] buf) { this.buf = buf; } + @Override public byte[] buffer() { return buf; } + @Override public int offset() { return 0; } + @Override public int length() { return buf.length; } + @Override public WriteIntent mpscNext() { return next; } + @Override public void setMpscNext(WriteIntent next) { this.next = next; } + } + + /** Collects everything written; fails loudly if it is ever entered re-entrantly/concurrently + * — which would mean {@link Http2FrameWriter}'s mutual exclusion is broken. */ + private static final class RecordingSink implements Http2FrameWriter.Sink { + private final ByteArrayOutputStream out = new ByteArrayOutputStream(); + private final AtomicBoolean writing = new AtomicBoolean(false); + volatile boolean concurrentWriteDetected = false; + + @Override + public void write(byte[] buf, int off, int len) { + if (!writing.compareAndSet(false, true)) { + concurrentWriteDetected = true; + } + out.write(buf, off, len); + writing.set(false); + } + + byte[] bytes() { + return out.toByteArray(); + } + } + + // Frame layout: [producerId:int][seq:int][marker byte, repeated payloadLen times] + private static int payloadLenFor(int producerId, int seq) { + return 4 + ((producerId + seq) % 20); + } + + private static byte[] buildFrame(int producerId, int seq) { + int payloadLen = payloadLenFor(producerId, seq); + byte[] b = new byte[8 + payloadLen]; + writeInt(b, 0, producerId); + writeInt(b, 4, seq); + byte marker = (byte) (producerId ^ seq); + for (int i = 0; i < payloadLen; i++) b[8 + i] = marker; + return b; + } + + private static void writeInt(byte[] b, int off, int v) { + b[off] = (byte) (v >>> 24); + b[off + 1] = (byte) (v >>> 16); + b[off + 2] = (byte) (v >>> 8); + b[off + 3] = (byte) v; + } + + private static int readInt(byte[] b, int off) { + return ((b[off] & 0xFF) << 24) | ((b[off + 1] & 0xFF) << 16) | ((b[off + 2] & 0xFF) << 8) | (b[off + 3] & 0xFF); + } + + private void runStress(int producers, int framesPerProducer) throws Exception { + RecordingSink sink = new RecordingSink(); + Http2FrameWriter writer = new Http2FrameWriter(sink, 30_000); + try { + try (ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor()) { + List> futures = new ArrayList<>(); + for (int p = 0; p < producers; p++) { + int producerId = p; + futures.add(exec.submit(() -> { + for (int seq = 0; seq < framesPerProducer; seq++) { + TestIntent intent = new TestIntent(buildFrame(producerId, seq)); + try { + writer.write(intent); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + })); + } + for (Future f : futures) f.get(60, TimeUnit.SECONDS); + } + // No concurrent producers remain past this point; one drain deterministically + // flushes anything a fire-and-forget contended write left queued. + writer.drain(); + } finally { + writer.close(); + } + + assertFalse(sink.concurrentWriteDetected, "writer allowed two threads to write concurrently"); + validate(sink.bytes(), producers, framesPerProducer); + } + + private static void validate(byte[] all, int producers, int framesPerProducer) { + int[] expectedSeq = new int[producers]; + int pos = 0; + int frameCount = 0; + while (pos < all.length) { + assertTrue(pos + 8 <= all.length, "truncated frame header at byte " + pos); + int producerId = readInt(all, pos); + int seq = readInt(all, pos + 4); + assertTrue(producerId >= 0 && producerId < producers, "corrupt producerId " + producerId + " at byte " + pos); + assertEquals(expectedSeq[producerId], seq, + "producer " + producerId + "'s frames arrived out of order at byte " + pos); + int payloadLen = payloadLenFor(producerId, seq); + assertTrue(pos + 8 + payloadLen <= all.length, "truncated frame payload at byte " + pos); + byte marker = (byte) (producerId ^ seq); + for (int i = 0; i < payloadLen; i++) { + assertEquals(marker, all[pos + 8 + i], + "corrupted or torn payload byte in frame (producer=" + producerId + ", seq=" + seq + ") at index " + i); + } + expectedSeq[producerId]++; + pos += 8 + payloadLen; + frameCount++; + } + assertEquals(producers * framesPerProducer, frameCount, "wrong total frame count"); + for (int p = 0; p < producers; p++) { + assertEquals(framesPerProducer, expectedSeq[p], "producer " + p + " is missing frames"); + } + } + + @Test void stress_n1() throws Exception { runStress(1, 500); } + @Test void stress_n2() throws Exception { runStress(2, 300); } + @Test void stress_n8() throws Exception { runStress(8, 150); } + @Test void stress_n64() throws Exception { runStress(64, 40); } + @Test void stress_n256() throws Exception { runStress(256, 15); } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameWriterTest.java b/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameWriterTest.java new file mode 100644 index 0000000..d9ae508 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/frame/Http2FrameWriterTest.java @@ -0,0 +1,170 @@ +package dev.relism.flash.http2.frame; + +import static org.junit.jupiter.api.Assertions.*; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class Http2FrameWriterTest { + + private static final class TestIntent implements WriteIntent { + final byte[] buf; + WriteIntent next; + + TestIntent(byte[] buf) { + this.buf = buf; + } + + TestIntent(String s) { + this(s.getBytes()); + } + + @Override + public byte[] buffer() { + return buf; + } + + @Override + public int offset() { + return 0; + } + + @Override + public int length() { + return buf.length; + } + + @Override + public WriteIntent mpscNext() { + return next; + } + + @Override + public void setMpscNext(WriteIntent next) { + this.next = next; + } + } + + private static final class RecordingSink implements Http2FrameWriter.Sink { + final List calls = new ArrayList<>(); + + @Override + public void write(byte[] buf, int off, int len) { + byte[] copy = new byte[len]; + System.arraycopy(buf, off, copy, 0, len); + calls.add(copy); + } + } + + @Test + void singleWrite_deliversBytesImmediately() throws IOException { + RecordingSink sink = new RecordingSink(); + Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000); + writer.write(new TestIntent("hello")); + assertEquals(1, sink.calls.size()); + assertArrayEquals("hello".getBytes(), sink.calls.get(0)); + writer.close(); + } + + @Test + void sequentialWrites_fromOneThread_preserveOrderAndAreNotSplitOrMerged() throws IOException { + RecordingSink sink = new RecordingSink(); + Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000); + writer.write(new TestIntent("one")); + writer.write(new TestIntent("two")); + writer.write(new TestIntent("three")); + assertEquals(List.of("one", "two", "three"), sink.calls.stream().map(String::new).toList()); + writer.close(); + } + + @Test + void exceptionFromSink_doesNotLeaveTheLockHeld() throws IOException { + Http2FrameWriter.Sink failingOnce = + new Http2FrameWriter.Sink() { + boolean thrown = false; + + @Override + public void write(byte[] buf, int off, int len) throws IOException { + if (!thrown) { + thrown = true; + throw new IOException("simulated sink failure"); + } + } + }; + Http2FrameWriter writer = new Http2FrameWriter(failingOnce, 5_000); + + assertThrows(IOException.class, () -> writer.write(new TestIntent("boom"))); + // If the lock were left held by the failed write, this would hang (tryLock() would + // keep failing forever) rather than complete promptly. + assertDoesNotThrow(() -> writer.write(new TestIntent("recovered"))); + writer.close(); + } + + @Test + void drain_withNothingQueued_isANoOp() throws IOException { + RecordingSink sink = new RecordingSink(); + Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000); + writer.drain(); + assertTrue(sink.calls.isEmpty()); + writer.close(); + } + + @Test + void emptyIntent_writesZeroBytesWithoutError() throws IOException { + RecordingSink sink = new RecordingSink(); + Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000); + writer.write(new TestIntent(new byte[0])); + assertEquals(1, sink.calls.size()); + assertEquals(0, sink.calls.get(0).length); + writer.close(); + } + + @Test + void priorityFrameOvertakesQueuedOrdinaryFrame() throws Exception { + CountDownLatch firstWriteEntered = new CountDownLatch(1); + CountDownLatch releaseFirstWrite = new CountDownLatch(1); + RecordingSink recording = new RecordingSink(); + Http2FrameWriter.Sink blocking = + (buf, off, len) -> { + if (firstWriteEntered.getCount() != 0) { + firstWriteEntered.countDown(); + try { + if (!releaseFirstWrite.await(5, TimeUnit.SECONDS)) { + throw new IOException("timed out waiting to release first write"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException(e); + } + } + recording.write(buf, off, len); + }; + Http2FrameWriter writer = new Http2FrameWriter(blocking, 5_000); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var first = + executor.submit( + () -> { + writer.write(new TestIntent("in-flight")); + return null; + }); + assertTrue(firstWriteEntered.await(5, TimeUnit.SECONDS)); + writer.write(new TestIntent("ordinary")); + writer.writePriority(new TestIntent("priority")); + releaseFirstWrite.countDown(); + first.get(5, TimeUnit.SECONDS); + writer.drain(); + } finally { + writer.close(); + } + + assertEquals( + List.of("in-flight", "priority", "ordinary"), + recording.calls.stream().map(String::new).toList()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/frame/PaddingTest.java b/flash/src/test/java/dev/relism/flash/http2/frame/PaddingTest.java new file mode 100644 index 0000000..f810ce0 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/frame/PaddingTest.java @@ -0,0 +1,88 @@ +package dev.relism.flash.http2.frame; + +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.*; + +class PaddingTest { + + @Test + void notPadded_returnsWholePayloadUnchanged() { + byte[] buf = {1, 2, 3, 4, 5}; + long r = Padding.unpad(buf, 1, 4, false); + assertEquals(1, Padding.dataOffset(r)); + assertEquals(4, Padding.dataLength(r)); + } + + @Test + void padded_zeroPadLength_allBytesAreData() { + // [padLength=0][data...] + byte[] buf = {0, 10, 20, 30}; + long r = Padding.unpad(buf, 0, 4, true); + assertEquals(1, Padding.dataOffset(r)); + assertEquals(3, Padding.dataLength(r)); + assertEquals(10, buf[Padding.dataOffset(r)]); + } + + @Test + void padded_someData_somePadding() { + // [padLength=2][data: 3 bytes][padding: 2 bytes] -> payload length 6 + byte[] buf = {2, 7, 8, 9, 0, 0}; + long r = Padding.unpad(buf, 0, 6, true); + assertEquals(1, Padding.dataOffset(r)); + assertEquals(3, Padding.dataLength(r)); + assertEquals(7, buf[Padding.dataOffset(r)]); + assertEquals(9, buf[Padding.dataOffset(r) + 2]); + } + + @Test + void padded_allPaddingNoData() { + // [padLength=3][padding x3] -> payload length 4, dataLength 0 + byte[] buf = {3, 0, 0, 0}; + long r = Padding.unpad(buf, 0, 4, true); + assertEquals(0, Padding.dataLength(r)); + } + + @Test + void padded_atNonZeroOffset_withinLargerBuffer() { + byte[] buf = {(byte) 0xFF, (byte) 0xFF, 1, 5, 6, 0, (byte) 0xFF}; + // payload starts at index 2, length 4: [padLength=1][data:5,6][padding:1] + long r = Padding.unpad(buf, 2, 4, true); + assertEquals(3, Padding.dataOffset(r)); + assertEquals(2, Padding.dataLength(r)); + assertEquals(5, buf[Padding.dataOffset(r)]); + assertEquals(6, buf[Padding.dataOffset(r) + 1]); + } + + @Test + void padded_zeroPayloadLength_isProtocolError() { + byte[] buf = {}; + Http2Exception ex = assertThrows(Http2Exception.class, () -> Padding.unpad(buf, 0, 0, true)); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, ex.errorCode()); + } + + @Test + void padded_padLengthEqualsPayloadLength_isProtocolError() { + // payloadLength=3, claimed padLength=3 -- leaves -1 bytes for data, invalid. + byte[] buf = {3, 0, 0}; + Http2Exception ex = assertThrows(Http2Exception.class, () -> Padding.unpad(buf, 0, 3, true)); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, ex.errorCode()); + } + + @Test + void padded_padLengthGreaterThanPayloadLength_isProtocolError() { + byte[] buf = {(byte) 255, 0, 0}; + Http2Exception ex = assertThrows(Http2Exception.class, () -> Padding.unpad(buf, 0, 3, true)); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, ex.errorCode()); + } + + @Test + void padded_maxValidPadLength_leavesZeroData() { + // payloadLength=5: [padLength=4][padding x4] -- valid, dataLength 0. + byte[] buf = {4, 0, 0, 0, 0}; + long r = Padding.unpad(buf, 0, 5, true); + assertEquals(0, Padding.dataLength(r)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/ContinuationAssemblerTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/ContinuationAssemblerTest.java new file mode 100644 index 0000000..b02cf80 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/ContinuationAssemblerTest.java @@ -0,0 +1,40 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.http2.Http2Exception; +import dev.relism.flash.http2.Http2Limits; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class ContinuationAssemblerTest { + @Test + void assemblesContiguousBlock() { + ContinuationAssembler assembler = new ContinuationAssembler(16); + assembler.begin(3, "abc".getBytes(StandardCharsets.US_ASCII), 0, 3, false); + assembler.continuation(3, "def".getBytes(StandardCharsets.US_ASCII), 0, 3, true); + assertTrue(assembler.isComplete()); + assertFalse(assembler.isActive()); + assertEquals( + "abcdef", new String(assembler.buffer(), 0, assembler.length(), StandardCharsets.US_ASCII)); + } + + @Test + void rejectsInterleavingWrongStreamAndOversizedBlocks() { + ContinuationAssembler assembler = new ContinuationAssembler(4); + assembler.begin(1, new byte[] {1}, 0, 1, false); + assertThrows(Http2Exception.class, () -> assembler.begin(3, new byte[0], 0, 0, true)); + assertThrows(Http2Exception.class, () -> assembler.continuation(3, new byte[0], 0, 0, true)); + assertThrows(Http2Exception.class, () -> assembler.continuation(1, new byte[4], 0, 4, true)); + } + + @Test + void boundsContinuationCount() { + ContinuationAssembler assembler = new ContinuationAssembler(32); + assembler.begin(1, new byte[0], 0, 0, false); + for (int i = 0; i < Http2Limits.MAX_CONTINUATION_FRAMES_PER_BLOCK; i++) { + assembler.continuation(1, new byte[0], 0, 0, false); + } + assertThrows(Http2Exception.class, () -> assembler.continuation(1, new byte[0], 0, 0, false)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderFuzzTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderFuzzTest.java new file mode 100644 index 0000000..f9cb132 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderFuzzTest.java @@ -0,0 +1,48 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.fail; +import static org.junit.jupiter.api.Assertions.assertTimeout; + +import dev.relism.flash.http2.Http2Exception; +import dev.relism.flash.testing.FuzzMemory; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class HpackDecoderFuzzTest { + private static final int CASES = 10_000_000; + private static final HeaderSink DISCARD = (name, value, never) -> {}; + + @Test + void tenMillionRandomBlocksOnlyProduceTypedRejections() { + assertTimeout(Duration.ofSeconds(20), this::runFuzzCases); + } + + private void runFuzzCases() { + HpackDecoder decoder = new HpackDecoder(256, 1024); + byte[] input = new byte[64]; + long state = 0x7541_9113_C0DEL; + long baseline = FuzzMemory.snapshot(); + for (int iteration = 0; iteration < CASES; iteration++) { + state = next(state); + int length = (int) state & 63; + for (int i = 0; i < length; i++) { + state = next(state); + input[i] = (byte) state; + } + try { + decoder.decode(input, 0, length, DISCARD); + } catch (Http2Exception | HeaderListSizeException expected) { + // Typed protocol rejection. + } catch (Throwable unexpected) { + fail("unexpected failure at iteration " + iteration + ", length " + length, unexpected); + } + } + FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024); + } + + private static long next(long value) { + value ^= value << 13; + value ^= value >>> 7; + return value ^ (value << 17); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderSecurityTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderSecurityTest.java new file mode 100644 index 0000000..9781321 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderSecurityTest.java @@ -0,0 +1,68 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.Http2Exception; +import org.junit.jupiter.api.Test; + +class HpackDecoderSecurityTest { + private static final HeaderSink DISCARD = (name, value, never) -> {}; + + @Test + void rejectsZeroAndOutOfRangeIndices() { + HpackDecoder decoder = new HpackDecoder(); + assertThrows( + Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0x80}, 0, 1, DISCARD)); + assertThrows( + Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0xff, 0}, 0, 2, DISCARD)); + } + + @Test + void rejectsLateAndOversizedTableUpdates() { + HpackDecoder decoder = new HpackDecoder(128, 1024); + assertThrows( + Http2Exception.class, () -> decoder.decode(new byte[] {(byte) 0x82, 0x20}, 0, 2, DISCARD)); + + ByteWriter update = new ByteWriter(8); + HpackIntegers.encode(update, 0x20, 5, 129); + assertThrows( + Http2Exception.class, () -> decoder.decode(update.array(), 0, update.length(), DISCARD)); + } + + @Test + void headerListLimitIsReportedOnlyAfterDynamicStateIsUpdated() { + HpackDecoder decoder = new HpackDecoder(256, 40); + byte[] block = java.util.HexFormat.of().parseHex("40016101624001630164"); + HeaderListSizeException error = + assertThrows( + HeaderListSizeException.class, () -> decoder.decode(block, 0, block.length, DISCARD)); + assertTrue(error.decodedSize() > 40); + assertEquals(2, decoder.dynamicTable().count()); + } + + @Test + void malformedStringsAndIntegerBombsAreCompressionErrors() { + HpackDecoder decoder = new HpackDecoder(); + assertThrows( + Http2Exception.class, () -> decoder.decode(new byte[] {0x40, 0x01}, 0, 2, DISCARD)); + byte[] bomb = {0x3f, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0xff, (byte) 0x80}; + assertThrows(Http2Exception.class, () -> decoder.decode(bomb, 0, bomb.length, DISCARD)); + } + + @Test + void indexedDynamicNameSurvivesEvictionDuringInsertion() { + HpackDecoder decoder = new HpackDecoder(48, 1024); + byte[] first = java.util.HexFormat.of().parseHex("4001610d31323334353637383930313233"); + decoder.decode(first, 0, first.length, DISCARD); + + // Dynamic index 62 supplies the name "a". Adding the new value evicts the referenced entry. + byte[] second = java.util.HexFormat.of().parseHex("7e0d6162636465666768696a6b6c6d"); + decoder.decode(second, 0, second.length, DISCARD); + + dev.relism.flash.bytes.PooledSlice name = new dev.relism.flash.bytes.PooledSlice(); + dev.relism.flash.bytes.PooledSlice value = new dev.relism.flash.bytes.PooledSlice(); + decoder.dynamicTable().get(1, name, value); + assertEquals('a', name.byteAt(0)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderTest.java new file mode 100644 index 0000000..fd96b12 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDecoderTest.java @@ -0,0 +1,162 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.fpr.core.ByteView; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import org.junit.jupiter.api.Test; + +class HpackDecoderTest { + private static final HexFormat HEX = HexFormat.of(); + + private static final class CollectingSink implements HeaderSink { + final List fields = new ArrayList<>(); + final List neverIndexed = new ArrayList<>(); + + @Override + public void accept(ByteView name, ByteView value, boolean never) { + fields.add(text(name) + ": " + text(value)); + neverIndexed.add(never); + } + } + + @Test + void appendixC2IndependentRepresentations() { + HpackDecoder decoder = new HpackDecoder(); + CollectingSink sink = decode(decoder, "400a637573746f6d2d6b65790d637573746f6d2d686561646572"); + assertEquals(List.of("custom-key: custom-header"), sink.fields); + assertDynamic(decoder, 1, "custom-key", "custom-header", 55); + + decoder = new HpackDecoder(); + sink = decode(decoder, "040c2f73616d706c652f70617468"); + assertEquals(List.of(":path: /sample/path"), sink.fields); + assertEquals(0, decoder.dynamicTable().count()); + + sink = decode(decoder, "100870617373776f726406736563726574"); + assertEquals(List.of("password: secret"), sink.fields); + assertEquals(List.of(true), sink.neverIndexed); + assertEquals(0, decoder.dynamicTable().count()); + + sink = decode(decoder, "82"); + assertEquals(List.of(":method: GET"), sink.fields); + } + + @Test + void appendixC3RequestsWithoutHuffman() { + verifyRequestSequence( + "828684410f7777772e6578616d706c652e636f6d", + "828684be58086e6f2d6361636865", + "828785bf400a637573746f6d2d6b65790c637573746f6d2d76616c7565"); + } + + @Test + void appendixC4RequestsWithHuffman() { + verifyRequestSequence( + "828684418cf1e3c2e5f23a6ba0ab90f4ff", + "828684be5886a8eb10649cbf", + "828785bf408825a849e95ba97d7f8925a849e95bb8e8b4bf"); + } + + @Test + void appendixC5ResponsesWithoutHuffman() { + verifyResponseSequence( + "4803333032580770726976617465611d4d6f6e2c203231204f637420323031332032303a31333a323120474d546e1768747470733a2f2f7777772e6578616d706c652e636f6d", + "4803333037c1c0bf", + "88c1611d4d6f6e2c203231204f637420323031332032303a31333a323220474d54c05a04677a69707738666f6f3d4153444a4b48514b425a584f5157454f50495541585157454f49553b206d61782d6167653d333630303b2076657273696f6e3d31"); + } + + @Test + void appendixC6ResponsesWithHuffman() { + verifyResponseSequence( + "488264025885aec3771a4b6196d07abe941054d444a8200595040b8166e082a62d1bff6e919d29ad171863c78f0b97c8e9ae82ae43d3", + "4883640effc1c0bf", + "88c16196d07abe941054d444a8200595040b8166e084a62d1bffc05a839bd9ab77ad94e7821dd7f2e6c7b335dfdfcd5b3960d5af27087f3672c1ab270fb5291f9587316065c003ed4ee5b1063d5007"); + } + + private static void verifyRequestSequence(String first, String second, String third) { + HpackDecoder decoder = new HpackDecoder(); + assertEquals( + List.of(":method: GET", ":scheme: http", ":path: /", ":authority: www.example.com"), + decode(decoder, first).fields); + assertDynamic(decoder, 1, ":authority", "www.example.com", 57); + + assertEquals( + List.of( + ":method: GET", + ":scheme: http", + ":path: /", + ":authority: www.example.com", + "cache-control: no-cache"), + decode(decoder, second).fields); + assertDynamic(decoder, 1, "cache-control", "no-cache", 110); + assertDynamic(decoder, 2, ":authority", "www.example.com", 110); + + assertEquals( + List.of( + ":method: GET", + ":scheme: https", + ":path: /index.html", + ":authority: www.example.com", + "custom-key: custom-value"), + decode(decoder, third).fields); + assertDynamic(decoder, 1, "custom-key", "custom-value", 164); + assertDynamic(decoder, 2, "cache-control", "no-cache", 164); + assertDynamic(decoder, 3, ":authority", "www.example.com", 164); + } + + private static void verifyResponseSequence(String first, String second, String third) { + HpackDecoder decoder = new HpackDecoder(256, 32_768); + assertEquals(responseFields("302", "21"), decode(decoder, first).fields); + assertDynamic(decoder, 1, "location", "https://www.example.com", 222); + assertDynamic(decoder, 4, ":status", "302", 222); + + assertEquals(responseFields("307", "21"), decode(decoder, second).fields); + assertDynamic(decoder, 1, ":status", "307", 222); + assertDynamic(decoder, 4, "cache-control", "private", 222); + + List expected = new ArrayList<>(responseFields("200", "22")); + expected.add("content-encoding: gzip"); + expected.add("set-cookie: foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1"); + assertEquals(expected, decode(decoder, third).fields); + assertEquals(3, decoder.dynamicTable().count()); + assertDynamic( + decoder, 1, "set-cookie", "foo=ASDJKHQKBZXOQWEOPIUAXQWEOIU; max-age=3600; version=1", 215); + assertDynamic(decoder, 2, "content-encoding", "gzip", 215); + assertDynamic(decoder, 3, "date", "Mon, 21 Oct 2013 20:13:22 GMT", 215); + } + + private static List responseFields(String status, String second) { + return List.of( + ":status: " + status, + "cache-control: private", + "date: Mon, 21 Oct 2013 20:13:" + second + " GMT", + "location: https://www.example.com"); + } + + private static CollectingSink decode(HpackDecoder decoder, String hex) { + CollectingSink sink = new CollectingSink(); + byte[] block = HEX.parseHex(hex); + decoder.decode(block, 0, block.length, sink); + return sink; + } + + private static void assertDynamic( + HpackDecoder decoder, int index, String expectedName, String expectedValue, int size) { + PooledSlice name = new PooledSlice(); + PooledSlice value = new PooledSlice(); + decoder.dynamicTable().get(index, name, value); + assertEquals(expectedName, text(name)); + assertEquals(expectedValue, text(value)); + assertEquals(size, decoder.dynamicTable().size()); + } + + private static String text(ByteView value) { + byte[] bytes = new byte[value.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = value.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDynamicTableTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDynamicTableTest.java new file mode 100644 index 0000000..e68d83e --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackDynamicTableTest.java @@ -0,0 +1,75 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.flash.http2.Http2Exception; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class HpackDynamicTableTest { + private static PooledSlice view(String text) { + byte[] bytes = text.getBytes(StandardCharsets.US_ASCII); + PooledSlice result = new PooledSlice(); + result.reset(bytes, 0, bytes.length); + return result; + } + + private static String text(PooledSlice value) { + return new String(value.array(), value.offset(), value.length(), StandardCharsets.US_ASCII); + } + + @Test + void newestEntryHasLowestDynamicIndex() { + HpackDynamicTable table = new HpackDynamicTable(256); + table.add(view("a"), view("one")); + table.add(view("b"), view("two")); + PooledSlice name = new PooledSlice(); + PooledSlice value = new PooledSlice(); + table.get(1, name, value); + assertEquals("b", text(name)); + assertEquals("two", text(value)); + table.get(2, name, value); + assertEquals("a", text(name)); + } + + @Test + void evictsOldestEntriesByRfcSize() { + HpackDynamicTable table = new HpackDynamicTable(70); + table.add(view("a"), view("1")); // 34 + table.add(view("b"), view("2")); // 34 + table.add(view("c"), view("3")); // evicts a + assertEquals(2, table.count()); + PooledSlice name = new PooledSlice(); + table.get(2, name, new PooledSlice()); + assertEquals("b", text(name)); + } + + @Test + void oversizedEntryClearsTableWithoutInsertion() { + HpackDynamicTable table = new HpackDynamicTable(40); + table.add(view("a"), view("1")); + table.add(view("long-name"), view("long-value")); + assertEquals(0, table.count()); + assertEquals(0, table.size()); + } + + @Test + void sizeUpdateCannotExceedAdvertisedMaximum() { + HpackDynamicTable table = new HpackDynamicTable(128); + assertThrows(Http2Exception.class, () -> table.setMaximumSize(129)); + table.setMaximumSize(0); + assertEquals(0, table.count()); + } + + @Test + void compactionPreservesLiveEntries() { + HpackDynamicTable table = new HpackDynamicTable(96); + for (int i = 0; i < 30; i++) table.add(view("name" + i), view("v" + i)); + PooledSlice name = new PooledSlice(); + PooledSlice value = new PooledSlice(); + table.get(1, name, value); + assertEquals("name29", text(name)); + assertEquals("v29", text(value)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEncoderTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEncoderTest.java new file mode 100644 index 0000000..d652f36 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEncoderTest.java @@ -0,0 +1,80 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.fpr.core.ByteView; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.Test; + +class HpackEncoderTest { + @Test + void status200IsOneIndexedByte() { + ByteWriter out = new ByteWriter(16); + HpackEncoder.writeIndexed(out, 8); + assertEquals(1, out.length()); + assertEquals(0x88, out.array()[0] & 0xff); + } + + @Test + void representationsRoundTripThroughDecoder() { + ByteWriter out = new ByteWriter(128); + HpackEncoder.writeDynamicTableSizeUpdateZero(out); + HpackEncoder.writeIndexed(out, 8); + HpackEncoder.writeLiteralWithNameIndex(out, 31, ascii("application/json"), true); + HpackEncoder.writeLiteral(out, ascii("X-Trace"), ascii("abc123")); + HpackEncoder.writeLiteralNeverIndexed(out, ascii("authorization"), ascii("secret"), false); + + List fields = new ArrayList<>(); + List sensitive = new ArrayList<>(); + new HpackDecoder() + .decode( + out.array(), + 0, + out.length(), + (name, value, never) -> { + fields.add(text(name) + "=" + text(value)); + sensitive.add(never); + }); + + assertEquals( + List.of( + ":status=200", + "content-type=application/json", + "x-trace=abc123", + "authorization=secret"), + fields); + assertEquals(List.of(false, false, false, true), sensitive); + } + + @Test + void tableSizeUpdateZeroIsCanonical() { + ByteWriter out = new ByteWriter(16); + HpackEncoder.writeDynamicTableSizeUpdateZero(out); + assertArrayEquals(new byte[] {0x20}, java.util.Arrays.copyOf(out.array(), out.length())); + } + + @Test + void huffmanLiteralIsSmallerForTypicalValue() { + byte[] value = ascii("application/json"); + ByteWriter raw = new ByteWriter(32); + ByteWriter compressed = new ByteWriter(32); + HpackEncoder.writeLiteralWithNameIndex(raw, 31, value, false); + HpackEncoder.writeLiteralWithNameIndex(compressed, 31, value, true); + assertTrue(compressed.length() < raw.length()); + } + + private static byte[] ascii(String value) { + return value.getBytes(StandardCharsets.US_ASCII); + } + + private static String text(ByteView value) { + byte[] bytes = new byte[value.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = value.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEvictionRaceTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEvictionRaceTest.java new file mode 100644 index 0000000..ae78218 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackEvictionRaceTest.java @@ -0,0 +1,87 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.bytes.PooledSlice; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.junit.jupiter.api.Test; + +class HpackEvictionRaceTest { + @Test + void perStreamCopySurvivesConcurrentDynamicTableEviction() throws Exception { + HpackDecoder decoder = new HpackDecoder(64, 1024); + HpackHeaderBlock stream = new HpackHeaderBlock(1024, 16); + + byte[] first = HexFormat.of().parseHex("40046e616d650b66697273742d76616c7565"); + decoder.decode(first, 0, first.length, stream); + assertField(stream, 0, "name", "first-value"); + + byte[] replacement = + HexFormat.of().parseHex("400a6f746865722d6e616d650c7365636f6e642d76616c7565"); + CountDownLatch start = new CountDownLatch(1); + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + @SuppressWarnings("unchecked") + Future[] readers = new Future[8]; + for (int reader = 0; reader < readers.length; reader++) { + readers[reader] = + executor.submit( + () -> { + start.await(); + for (int i = 0; i < 10_000; i++) { + assertField(stream, 0, "name", "first-value"); + } + return null; + }); + } + start.countDown(); + for (int i = 0; i < 10_000; i++) { + decoder.decode(replacement, 0, replacement.length, (n, v, x) -> {}); + } + for (Future reader : readers) reader.get(); + } + + assertField(stream, 0, "name", "first-value"); + } + + @Test + void directDynamicTableViewDemonstratesTheEvictionHazard() { + HpackDynamicTable table = new HpackDynamicTable(64); + PooledSlice firstName = view("name"); + PooledSlice firstValue = view("first-value"); + table.add(firstName, firstValue); + + PooledSlice borrowedName = new PooledSlice(); + PooledSlice borrowedValue = new PooledSlice(); + table.get(1, borrowedName, borrowedValue); + String before = text(borrowedValue); + + table.add(view("other-name"), view("second-value")); + table.add(view("other-name"), view("second-value")); + table.add(view("other-name"), view("second-value")); + assertNotEquals(before, text(borrowedValue)); + } + + private static void assertField( + HpackHeaderBlock block, int index, String expectedName, String expectedValue) { + PooledSlice name = new PooledSlice(); + PooledSlice value = new PooledSlice(); + block.get(index, name, value); + assertEquals(expectedName, text(name)); + assertEquals(expectedValue, text(value)); + } + + private static PooledSlice view(String text) { + byte[] bytes = text.getBytes(StandardCharsets.US_ASCII); + PooledSlice view = new PooledSlice(); + view.reset(bytes, 0, bytes.length); + return view; + } + + private static String text(PooledSlice value) { + return new String(value.array(), value.offset(), value.length(), StandardCharsets.US_ASCII); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackIntegersTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackIntegersTest.java new file mode 100644 index 0000000..39b3fc4 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackIntegersTest.java @@ -0,0 +1,130 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.bytes.Pairs; +import dev.relism.flash.http2.Http2Exception; +import org.junit.jupiter.api.Test; + +class HpackIntegersTest { + + // --- RFC 7541 Appendix C.1: official vectors --- + + @Test + void appendixC11_10With5BitPrefix() { + byte[] buf = {0x0a}; + long packed = HpackIntegers.decode(buf, 0, buf.length, 5); + assertEquals(10, Pairs.hi(packed)); + assertEquals(1, Pairs.lo(packed)); + } + + @Test + void appendixC12_1337With5BitPrefix() { + byte[] buf = {(byte) 0x1f, (byte) 0x9a, 0x0a}; + long packed = HpackIntegers.decode(buf, 0, buf.length, 5); + assertEquals(1337, Pairs.hi(packed)); + assertEquals(3, Pairs.lo(packed)); + } + + @Test + void appendixC13_42With8BitPrefix() { + byte[] buf = {0x2a}; + long packed = HpackIntegers.decode(buf, 0, buf.length, 8); + assertEquals(42, Pairs.hi(packed)); + assertEquals(1, Pairs.lo(packed)); + } + + // --- position handling --- + + @Test + void decode_startsAtNonZeroPosition_leavesPrecedingBytesUntouched() { + byte[] buf = {(byte) 0xFF, 0x0a}; // garbage, then "10" with 5-bit prefix + long packed = HpackIntegers.decode(buf, 1, buf.length, 5); + assertEquals(10, Pairs.hi(packed)); + assertEquals(2, Pairs.lo(packed)); + } + + @Test + void decode_ignoresHighBitsAboveThePrefix() { + // High 3 bits simulate a representation's leading flag bits (e.g. 0xA0 = 101xxxxx); + // only the low 5 bits are the integer's prefix. + byte[] buf = {(byte) 0b101_01010}; // flags=101, prefix value=01010=10 + long packed = HpackIntegers.decode(buf, 0, buf.length, 5); + assertEquals(10, Pairs.hi(packed)); + } + + // --- round-trip via encode() --- + + @Test + void encode_thenDecode_roundTrips_acrossBoundaryValues() { + int[] values = {0, 1, 30, 31, 32, 1337, 268_435_455}; + for (int prefixBits : new int[] {4, 5, 7, 8}) { + for (int value : values) { + ByteWriter out = new ByteWriter(16); + HpackIntegers.encode(out, 0, prefixBits, value); + long packed = HpackIntegers.decode(out.array(), 0, out.length(), prefixBits); + assertEquals(value, Pairs.hi(packed), "prefixBits=" + prefixBits + " value=" + value); + assertEquals(out.length(), Pairs.lo(packed)); + } + } + } + + @Test + void encode_matchesRfcVector_1337With5BitPrefix() { + ByteWriter out = new ByteWriter(16); + HpackIntegers.encode(out, 0, 5, 1337); + assertArrayEquals( + new byte[] {(byte) 0x1f, (byte) 0x9a, 0x0a}, + java.util.Arrays.copyOf(out.array(), out.length())); + } + + @Test + void encode_preservesPrefixByteFlags() { + ByteWriter out = new ByteWriter(16); + HpackIntegers.encode(out, 0x80, 7, 5); // Indexed Header Field, index 5 + assertEquals((byte) 0x85, out.array()[0]); + } + + // --- overflow / hostile-input safety (HPACK bomb) --- + + @Test + void decode_exceedingMaxContinuationOctets_throwsCompressionError() { + // 5-bit prefix all-ones (31), then 5 continuation octets all with the continue bit set + // (0xFF) -- one more than MAX_CONTINUATION_OCTETS(4) tolerates. + byte[] buf = {0x1f, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00}; + Http2Exception ex = + assertThrows(Http2Exception.class, () -> HpackIntegers.decode(buf, 0, buf.length, 5)); + assertSame(dev.relism.flash.http2.Http2ErrorCode.COMPRESSION_ERROR, ex.errorCode()); + } + + @Test + void decode_exactlyMaxContinuationOctets_succeeds() { + // 4 continuation octets is the tolerated boundary -- must not throw. + byte[] buf = {0x1f, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF, 0x00}; + long packed = HpackIntegers.decode(buf, 0, buf.length, 5); + assertTrue(Pairs.hi(packed) > 0); + } + + @Test + void decode_truncatedAtPrefixByte_throws() { + byte[] buf = {}; + assertThrows(Http2Exception.class, () -> HpackIntegers.decode(buf, 0, buf.length, 5)); + } + + @Test + void decode_truncatedMidContinuation_throws() { + // prefix says "keep reading" but the buffer ends immediately after. + byte[] buf = {0x1f}; + assertThrows(Http2Exception.class, () -> HpackIntegers.decode(buf, 0, buf.length, 5)); + } + + @Test + void decode_truncatedByLimit_notByBufferLength_throws() { + // The buffer itself has more bytes, but `limit` (the current block's end) cuts it off -- + // decode must respect limit, not buf.length, since HPACK scratch buffers are reused and + // may contain trailing bytes from a previous, larger block. + byte[] buf = {0x1f, (byte) 0x9a, 0x0a, 0x00, 0x00}; + assertThrows(Http2Exception.class, () -> HpackIntegers.decode(buf, 0, 2, 5)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HpackStaticTableTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackStaticTableTest.java new file mode 100644 index 0000000..4d822f7 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HpackStaticTableTest.java @@ -0,0 +1,42 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.bytes.PooledSlice; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class HpackStaticTableTest { + private static PooledSlice view(String value) { + byte[] bytes = value.getBytes(StandardCharsets.US_ASCII); + PooledSlice view = new PooledSlice(); + view.reset(bytes, 0, bytes.length); + return view; + } + + @Test + void containsAllRfcEntriesAndUsesOneBasedIndices() { + assertEquals(61, HpackStaticTable.LENGTH); + assertArrayEquals(":authority".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.name(1)); + assertArrayEquals( + "gzip, deflate".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.value(16)); + assertArrayEquals( + "www-authenticate".getBytes(StandardCharsets.US_ASCII), HpackStaticTable.name(61)); + assertThrows(IndexOutOfBoundsException.class, () -> HpackStaticTable.name(0)); + assertThrows(IndexOutOfBoundsException.class, () -> HpackStaticTable.value(62)); + } + + @Test + void findNameReturnsLowestIndexForRepeatedNames() { + assertEquals(2, HpackStaticTable.findName(view(":method"))); + assertEquals(8, HpackStaticTable.findName(view(":status"))); + assertEquals(0, HpackStaticTable.findName(view("missing"))); + } + + @Test + void findPairMatchesExactBytes() { + assertEquals(2, HpackStaticTable.findPair(view(":method"), view("GET"))); + assertEquals(14, HpackStaticTable.findPair(view(":status"), view("500"))); + assertEquals(0, HpackStaticTable.findPair(view(":method"), view("get"))); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HuffmanFuzzTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HuffmanFuzzTest.java new file mode 100644 index 0000000..8b458fb --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HuffmanFuzzTest.java @@ -0,0 +1,47 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.assertTimeout; +import static org.junit.jupiter.api.Assertions.fail; + +import dev.relism.flash.http2.Http2Exception; +import dev.relism.flash.testing.FuzzMemory; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class HuffmanFuzzTest { + private static final int CASES = 1_000_000; + + @Test + void arbitraryInputHasBoundedTypedOutcomes() { + assertTimeout( + Duration.ofSeconds(20), + () -> { + byte[] input = new byte[64]; + byte[] output = new byte[128]; + long state = 0x7541_4855_4646_4D4EL; + long baseline = FuzzMemory.snapshot(); + for (int iteration = 0; iteration < CASES; iteration++) { + state = next(state); + int length = (int) (state & 63); + for (int i = 0; i < length; i++) { + state = next(state); + input[i] = (byte) state; + } + try { + Huffman.decode(input, 0, length, output, 0, output.length); + } catch (Http2Exception expected) { + // Malformed Huffman input has one typed protocol outcome. + } catch (Throwable unexpected) { + fail("unexpected failure at case " + iteration + ", length " + length, unexpected); + } + } + FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024); + }); + } + + private static long next(long value) { + value ^= value << 13; + value ^= value >>> 7; + return value ^ (value << 17); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/hpack/HuffmanTest.java b/flash/src/test/java/dev/relism/flash/http2/hpack/HuffmanTest.java new file mode 100644 index 0000000..b86473b --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/hpack/HuffmanTest.java @@ -0,0 +1,240 @@ +package dev.relism.flash.http2.hpack; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.bytes.ByteWriter; +import dev.relism.flash.http2.Http2Exception; +import java.nio.charset.StandardCharsets; +import java.util.HexFormat; +import java.util.Random; +import org.junit.jupiter.api.Test; + +class HuffmanTest { + + private static byte[] hex(String s) { + return HexFormat.of().parseHex(s); + } + + private static byte[] decode(byte[] encoded, int maxOut) { + byte[] dst = new byte[maxOut]; + int n = Huffman.decode(encoded, 0, encoded.length, dst, 0, dst.length); + byte[] result = new byte[n]; + System.arraycopy(dst, 0, result, 0, n); + return result; + } + + // --- RFC 7541 Appendix C.4 / C.6: official Huffman vectors --- + + @Test + void appendixC41_wwwExampleCom() { + byte[] encoded = hex("f1e3c2e5f23a6ba0ab90f4ff"); + assertEquals("www.example.com", new String(decode(encoded, 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC42_noCache() { + byte[] encoded = hex("a8eb10649cbf"); + assertEquals("no-cache", new String(decode(encoded, 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC43_customKeyAndValue() { + assertEquals( + "custom-key", new String(decode(hex("25a849e95ba97d7f"), 64), StandardCharsets.UTF_8)); + assertEquals( + "custom-value", new String(decode(hex("25a849e95bb8e8b4bf"), 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC61_status302() { + assertEquals("302", new String(decode(hex("6402"), 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC61_private() { + assertEquals("private", new String(decode(hex("aec3771a4b"), 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC61_dateHeader() { + byte[] encoded = hex("d07abe941054d444a8200595040b8166e082a62d1bff"); + assertEquals( + "Mon, 21 Oct 2013 20:13:21 GMT", new String(decode(encoded, 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC61_locationHeader() { + byte[] encoded = hex("9d29ad171863c78f0b97c8e9ae82ae43d3"); + assertEquals( + "https://www.example.com", new String(decode(encoded, 64), StandardCharsets.UTF_8)); + } + + @Test + void appendixC62_status307() { + assertEquals("307", new String(decode(hex("640eff"), 64), StandardCharsets.UTF_8)); + } + + // --- encode() matches the RFC's own bytes --- + + @Test + void encode_matchesRfcVector_wwwExampleCom() { + ByteWriter out = new ByteWriter(32); + byte[] src = "www.example.com".getBytes(StandardCharsets.UTF_8); + Huffman.encode(out, src, 0, src.length); + assertArrayEquals( + hex("f1e3c2e5f23a6ba0ab90f4ff"), java.util.Arrays.copyOf(out.array(), out.length())); + } + + @Test + void encode_matchesRfcVector_noCache() { + ByteWriter out = new ByteWriter(32); + byte[] src = "no-cache".getBytes(StandardCharsets.UTF_8); + Huffman.encode(out, src, 0, src.length); + assertArrayEquals(hex("a8eb10649cbf"), java.util.Arrays.copyOf(out.array(), out.length())); + } + + @Test + void encodedLength_matchesActualEncodedSize() { + byte[] src = "www.example.com".getBytes(StandardCharsets.UTF_8); + assertEquals(12, Huffman.encodedLength(src, 0, src.length)); + } + + // --- round trip: every byte value individually --- + + @Test + void everyByteValue_roundTrips() { + for (int v = 0; v <= 255; v++) { + byte[] src = {(byte) v}; + ByteWriter out = new ByteWriter(8); + Huffman.encode(out, src, 0, 1); + byte[] decoded = decode(java.util.Arrays.copyOf(out.array(), out.length()), 4); + assertArrayEquals(src, decoded, "byte value " + v); + } + } + + // --- round trip: random strings --- + + @Test + void randomStrings_roundTrip() { + Random rnd = new Random(42); + for (int trial = 0; trial < 500; trial++) { + int len = rnd.nextInt(200); + byte[] src = new byte[len]; + rnd.nextBytes(src); + ByteWriter out = new ByteWriter(64); + Huffman.encode(out, src, 0, len); + byte[] encoded = java.util.Arrays.copyOf(out.array(), out.length()); + byte[] decoded = decode(encoded, len + 8); + assertArrayEquals(src, decoded, "trial " + trial + " len " + len); + } + } + + @Test + void asciiHeaderLikeStrings_roundTrip() { + String[] samples = { + "", + "a", + "GET", + "POST", + "application/json", + "text/html; charset=utf-8", + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", + "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", + "0", + "12345", + "!!!???...", + }; + for (String s : samples) { + byte[] src = s.getBytes(StandardCharsets.UTF_8); + ByteWriter out = new ByteWriter(64); + Huffman.encode(out, src, 0, src.length); + byte[] encoded = java.util.Arrays.copyOf(out.array(), out.length()); + byte[] decoded = decode(encoded, src.length + 8); + assertArrayEquals(src, decoded, "sample: " + s); + } + } + + // --- invalid padding --- + + @Test + void decode_paddingLongerThanSevenBits_throws() { + // "no-cache" is 6 bytes Huffman-encoded (a8eb10649cbf); appending a full extra byte of + // all-1s padding (8+ bits of padding total) must be rejected. + byte[] encoded = hex("a8eb10649cbfff"); + assertThrows(Http2Exception.class, () -> decode(encoded, 64)); + } + + @Test + void decode_paddingNotAllOnes_throws() { + // '0' (symbol 48) is the 5-bit code 00000; the correct padding to fill the remaining 3 + // bits of the byte is 111 (0x07), decoding cleanly to "0". Replacing that padding with + // 000 (0x00) leaves the walk 3 bits into the "000..." region of the trie (shared by + // '0'/'1'/'2'/'a', none of which complete in exactly 3 bits) -- not the root, and not on + // the all-1s padding spine, so it must be rejected. + byte[] validPadding = {0x07}; + assertEquals("0", new String(decode(validPadding, 8), StandardCharsets.UTF_8)); + + byte[] invalidPadding = {0x00}; + assertThrows(Http2Exception.class, () -> decode(invalidPadding, 8)); + } + + @Test + void decode_incompleteCodeAtEnd_throws() { + // Truncate "no-cache"'s encoding mid-code (not a valid prefix of the ones-spine). + byte[] full = hex("a8eb10649cbf"); + byte[] truncated = java.util.Arrays.copyOf(full, full.length - 1); + assertThrows(Http2Exception.class, () -> decode(truncated, 64)); + } + + // --- EOS symbol in input --- + + @Test + void decode_eosSymbolInInput_throws() { + // EOS is 30 ones: 0x3fffffff -- encode it directly as 4 bytes, left-aligned to a byte boundary. + // 30 ones followed by 2 padding ones = 0xFF 0xFF 0xFF 0xFF. + byte[] encoded = {(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF}; + assertThrows(Http2Exception.class, () -> decode(encoded, 64)); + } + + // --- output bound enforced during decode --- + + @Test + void decode_outputExceedingDstLimit_throws() { + byte[] encoded = hex("f1e3c2e5f23a6ba0ab90f4ff"); // "www.example.com", 16 bytes decoded + assertThrows(Http2Exception.class, () -> decode(encoded, 10)); + } + + @Test + void decode_outputExactlyAtDstLimit_succeeds() { + byte[] encoded = hex("f1e3c2e5f23a6ba0ab90f4ff"); + byte[] result = decode(encoded, 16); + assertEquals("www.example.com", new String(result, StandardCharsets.UTF_8)); + } + + // --- empty string --- + + @Test + void decode_emptyInput_producesEmptyOutput() { + byte[] result = decode(new byte[0], 8); + assertEquals(0, result.length); + } + + @Test + void encode_emptyInput_producesEmptyOutput() { + ByteWriter out = new ByteWriter(8); + Huffman.encode(out, new byte[0], 0, 0); + assertEquals(0, out.length()); + } + + // --- structural invariant the nibble-FSM's "at most one symbol per nibble" design relies on --- + + @Test + void everyRealCodeIsAtLeastFiveBitsLong() throws Exception { + var lengthsField = Huffman.class.getDeclaredField("LENGTHS"); + lengthsField.setAccessible(true); + int[] lengths = (int[]) lengthsField.get(null); + for (int i = 0; i < 256; i++) { + assertTrue(lengths[i] >= 5, "symbol " + i + " has length " + lengths[i] + " < 5"); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/message/Http2LargeResponseTest.java b/flash/src/test/java/dev/relism/flash/http2/message/Http2LargeResponseTest.java new file mode 100644 index 0000000..b55735c --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/message/Http2LargeResponseTest.java @@ -0,0 +1,54 @@ +package dev.relism.flash.http2.message; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.models.Response; +import java.io.InputStream; +import org.junit.jupiter.api.Test; + +class Http2LargeResponseTest { + @Test + void hundredMegabyteStreamUsesOneBoundedReusableFrameBuffer() throws Exception { + long length = 100L * 1024 * 1024; + Response response = + new Response(200, ContentType.BINARY).stream(new RepeatingInputStream(length), length); + Http2ResponseWriter writer = new Http2ResponseWriter(); + long written = + writer.startFlowControlled( + response, 1, false, false, true, false, false, 16_384, 32_768, 16_384); + int largestBuffer = writer.buffer().length; + while (!writer.finished()) { + written += writer.resume(16_384, 16_384); + largestBuffer = Math.max(largestBuffer, writer.buffer().length); + } + + assertEquals(length, written); + assertTrue(largestBuffer <= 65_536, "serialized storage must not scale with body length"); + } + + private static final class RepeatingInputStream extends InputStream { + private long remaining; + + RepeatingInputStream(long remaining) { + this.remaining = remaining; + } + + @Override + public int read() { + if (remaining == 0) return -1; + remaining--; + return 0x5a; + } + + @Override + public int read(byte[] target, int offset, int length) { + if (remaining == 0) return -1; + int count = (int) Math.min(length, remaining); + java.util.Arrays.fill(target, offset, offset + count, (byte) 0x5a); + remaining -= count; + return count; + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/message/Http2RequestBodyTest.java b/flash/src/test/java/dev/relism/flash/http2/message/Http2RequestBodyTest.java new file mode 100644 index 0000000..1b9b57b --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/message/Http2RequestBodyTest.java @@ -0,0 +1,86 @@ +package dev.relism.flash.http2.message; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.relism.flash.http2.Http2ErrorCode; +import dev.relism.flash.http2.Http2Limits; +import dev.relism.flash.http2.Http2StreamException; +import dev.relism.flash.models.RequestBody; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class Http2RequestBodyTest { + @Test + void inlineBodyFeedsTheProtocolNeutralRequestBodyWithOneMaterialization() { + AtomicInteger consumed = new AtomicInteger(); + Http2RequestBody source = new Http2RequestBody(new DataBufferPool(16, 1)); + source.begin(3, true, consumed::addAndGet); + source.offer(1, "abc".getBytes(StandardCharsets.US_ASCII), 0, 3, 5); + source.finish(1); + RequestBody body = new RequestBody(); + body.reset(source, 3, null, 0, 0); + + assertArrayEquals("abc".getBytes(StandardCharsets.US_ASCII), body.bytes()); + assertEquals(5, consumed.get()); + assertEquals(3, body.contentLength()); + } + + @Test + void streamingBodyReusesAndReturnsPooledBuffers() throws Exception { + DataBufferPool pool = new DataBufferPool(8, 2); + AtomicInteger consumed = new AtomicInteger(); + Http2RequestBody source = new Http2RequestBody(pool); + source.begin(-1, false, consumed::addAndGet); + source.offer(1, new byte[] {1, 2, 3, 4, 5, 6, 7, 8}, 0, 8, 8); + source.offer(1, new byte[] {9, 10}, 0, 2, 2); + source.finish(1); + + assertArrayEquals(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, source.readAllBytes()); + assertEquals(10, consumed.get()); + assertEquals(2, pool.createdCount()); + assertEquals(2, pool.availableCount()); + } + + @Test + void contentLengthMismatchIsAProtocolStreamError() { + Http2RequestBody source = new Http2RequestBody(new DataBufferPool(8, 1)); + source.begin(4, true, bytes -> {}); + source.offer(3, new byte[] {1, 2, 3}, 0, 3, 3); + + Http2StreamException failure = + assertThrows(Http2StreamException.class, () -> source.finish(3)); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, failure.errorCode()); + } + + @Test + void boundedPoolNeverAllocatesPastItsCapacity() { + DataBufferPool pool = new DataBufferPool(4, 1); + Http2RequestBody source = new Http2RequestBody(pool); + source.begin(-1, false, bytes -> {}); + source.offer(1, new byte[] {1, 2, 3, 4}, 0, 4, 4); + + Http2StreamException failure = + assertThrows( + Http2StreamException.class, + () -> source.offer(1, new byte[] {2}, 0, 1, 1)); + assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, failure.errorCode()); + assertEquals(1, pool.createdCount()); + } + + @Test + void unknownLengthBodyCannotExceedTheConfiguredMaximum() { + Http2RequestBody source = new Http2RequestBody(new DataBufferPool(8, 1)); + source.begin(-1, false, bytes -> {}); + + Http2StreamException failure = + assertThrows( + Http2StreamException.class, + () -> + source.offer( + 1, new byte[1], 0, Http2Limits.MAX_REQUEST_BODY_SIZE + 1, 1)); + assertEquals(Http2ErrorCode.PROTOCOL_ERROR, failure.errorCode()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/message/Http2ResponseWriterTest.java b/flash/src/test/java/dev/relism/flash/http2/message/Http2ResponseWriterTest.java new file mode 100644 index 0000000..181ef01 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/message/Http2ResponseWriterTest.java @@ -0,0 +1,209 @@ +package dev.relism.flash.http2.message; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http2.Http2StreamException; +import dev.relism.flash.http2.frame.FrameFlags; +import dev.relism.flash.http2.frame.FrameType; +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.flash.models.Response; +import dev.relism.fpr.core.ByteView; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import org.junit.jupiter.api.Test; + +class Http2ResponseWriterTest { + @Test + void serializesOrderedHeadersAndOneDataFrame() { + Response response = + new Response(200, "hello", ContentType.TEXT_PLAIN) + .header("X-Trace", "abc") + .header("Connection", "close") + .header("Upgrade", "websocket"); + Http2ResponseWriter writer = new Http2ResponseWriter(); + + assertTrue(writer.prepare(response, 3, false, false, true, false, true, 16_384, 4096, 65_535)); + Parsed parsed = parse(writer); + + assertEquals(List.of(FrameType.HEADERS, FrameType.DATA), parsed.types); + assertEquals(FrameFlags.END_HEADERS, parsed.flags.get(0)); + assertEquals(FrameFlags.END_STREAM, parsed.flags.get(1)); + assertEquals("hello", new String(parsed.data, StandardCharsets.US_ASCII)); + assertEquals( + List.of(":status=200", "content-type=text/plain", "content-length=5", "x-trace=abc"), + decode(parsed.headerBlock)); + } + + @Test + void splitsHeaderBlockIntoAdjacentContinuationFrames() { + Response response = + new Response(200, ContentType.NONE) + .header("x-long", "abcdefghijklmnopqrstuvwxyz0123456789"); + Http2ResponseWriter writer = new Http2ResponseWriter(); + + assertTrue(writer.prepare(response, 1, false, false, false, false, false, 12, 4096, 65_535)); + Parsed parsed = parse(writer); + + assertTrue(parsed.types.size() > 1); + assertEquals(FrameType.HEADERS, parsed.types.get(0)); + for (int i = 1; i < parsed.types.size(); i++) { + assertEquals(FrameType.CONTINUATION, parsed.types.get(i)); + } + assertEquals(0, parsed.flags.get(0) & FrameFlags.END_HEADERS); + assertTrue((parsed.flags.get(parsed.flags.size() - 1) & FrameFlags.END_HEADERS) != 0); + assertEquals( + List.of(":status=200", "x-long=abcdefghijklmnopqrstuvwxyz0123456789"), + decode(parsed.headerBlock)); + } + + @Test + void headAndBodyForbiddenStatusesEndOnHeaders() { + for (Response response : + List.of( + new Response(200, "body", ContentType.TEXT_PLAIN), + new Response(204, "body", ContentType.TEXT_PLAIN), + new Response(304, "body", ContentType.TEXT_PLAIN))) { + boolean head = response.getStatusCode() == 200; + Http2ResponseWriter writer = new Http2ResponseWriter(); + assertTrue( + writer.prepare(response, 1, head, false, true, false, false, 16_384, 4096, 65_535)); + Parsed parsed = parse(writer); + assertEquals(List.of(FrameType.HEADERS), parsed.types); + assertTrue((parsed.flags.get(0) & FrameFlags.END_STREAM) != 0); + List fields = decode(parsed.headerBlock); + if (head) assertTrue(fields.contains("content-length=4")); + else assertFalse(fields.stream().anyMatch(value -> value.startsWith("content-length="))); + } + } + + @Test + void insufficientWindowDefersWithoutProducingPartialResponse() { + Response response = new Response(200, "body", ContentType.TEXT_PLAIN); + Http2ResponseWriter writer = new Http2ResponseWriter(); + assertFalse(writer.prepare(response, 1, false, false, true, false, false, 16_384, 3, 3)); + assertEquals(0, writer.length()); + } + + @Test + void peerHeaderListLimitFailsTheStream() { + Response response = new Response(200, ContentType.TEXT_PLAIN); + Http2ResponseWriter writer = new Http2ResponseWriter(); + assertThrows( + Http2StreamException.class, + () -> writer.prepare(response, 5, false, false, true, false, false, 16_384, 41, 65_535)); + } + + @Test + void flowControlledHeadPreservesKnownRepresentationLength() throws Exception { + Response response = + new Response(200, ContentType.BINARY) + .stream(new ByteArrayInputStream(new byte[] {1, 2, 3, 4}), 4); + Http2ResponseWriter writer = new Http2ResponseWriter(); + + writer.startFlowControlled( + response, 1, true, false, true, false, false, 16_384, 4096, 16_384); + Parsed parsed = parse(writer); + + assertEquals(List.of(FrameType.HEADERS), parsed.types); + assertTrue(decode(parsed.headerBlock).contains("content-length=4")); + } + + @Test + void unknownLengthStreamUsesNativeDataWithoutTransferEncoding() throws Exception { + Response response = + new Response(200, ContentType.BINARY) + .chunked(new ByteArrayInputStream(new byte[] {1, 2, 3, 4})); + Http2ResponseWriter writer = new Http2ResponseWriter(); + + writer.startFlowControlled( + response, 1, false, false, true, false, false, 16_384, 4096, 16_384); + Parsed parsed = parse(writer); + List fields = decode(parsed.headerBlock); + + assertFalse(fields.stream().anyMatch(field -> field.startsWith("content-length="))); + assertFalse(fields.stream().anyMatch(field -> field.startsWith("transfer-encoding="))); + assertEquals(List.of(FrameType.HEADERS, FrameType.DATA), parsed.types); + } + + @Test + void finalDataDoesNotEndStreamWhenTrailingHeadersFollow() throws Exception { + Response response = new Response(200, "ok", ContentType.TEXT_PLAIN) + .trailer("grpc-status", "0"); + Http2ResponseWriter writer = new Http2ResponseWriter(); + + writer.startFlowControlled( + response, 1, false, false, true, false, false, 16_384, 4096, 16_384); + Parsed parsed = parse(writer); + + assertEquals(List.of(FrameType.HEADERS, FrameType.DATA, FrameType.HEADERS), parsed.types); + assertEquals(0, parsed.flags.get(1) & FrameFlags.END_STREAM); + assertTrue((parsed.flags.get(2) & FrameFlags.END_STREAM) != 0); + assertTrue(decode(parsed.headerBlock).contains("grpc-status=0")); + assertTrue(writer.trailerHeadersInBatch()); + } + + private static Parsed parse(Http2ResponseWriter writer) { + Parsed parsed = new Parsed(); + byte[] wire = writer.buffer(); + int position = 0; + while (position < writer.length()) { + int length = + ((wire[position] & 0xff) << 16) + | ((wire[position + 1] & 0xff) << 8) + | (wire[position + 2] & 0xff); + FrameType type = FrameType.fromCode(wire[position + 3] & 0xff); + int flags = wire[position + 4] & 0xff; + byte[] payload = Arrays.copyOfRange(wire, position + 9, position + 9 + length); + parsed.types.add(type); + parsed.flags.add(flags); + if (type == FrameType.HEADERS || type == FrameType.CONTINUATION) { + parsed.appendHeaders(payload); + } else if (type == FrameType.DATA) { + parsed.data = payload; + } + position += 9 + length; + } + parsed.headerBlock = Arrays.copyOf(parsed.headerBlock, parsed.headerLength); + return parsed; + } + + private static List decode(byte[] block) { + List fields = new ArrayList<>(); + new HpackDecoder() + .decode( + block, + 0, + block.length, + (name, value, never) -> fields.add(text(name) + "=" + text(value))); + return fields; + } + + private static String text(ByteView view) { + byte[] bytes = new byte[view.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } + + private static final class Parsed { + final List types = new ArrayList<>(); + final List flags = new ArrayList<>(); + byte[] headerBlock = new byte[64]; + int headerLength; + byte[] data = new byte[0]; + + void appendHeaders(byte[] fragment) { + if (headerLength + fragment.length > headerBlock.length) { + headerBlock = Arrays.copyOf(headerBlock, (headerLength + fragment.length) * 2); + } + System.arraycopy(fragment, 0, headerBlock, headerLength, fragment.length); + headerLength += fragment.length; + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/message/PseudoHeaderValidationTest.java b/flash/src/test/java/dev/relism/flash/http2/message/PseudoHeaderValidationTest.java new file mode 100644 index 0000000..a724323 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/message/PseudoHeaderValidationTest.java @@ -0,0 +1,108 @@ +package dev.relism.flash.http2.message; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.flash.http2.Http2StreamException; +import dev.relism.flash.http2.hpack.HpackHeaderBlock; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class PseudoHeaderValidationTest { + @Test + void validRequest() { + assertDoesNotThrow( + () -> + validate( + ":method", "GET", ":scheme", "https", ":path", "/", ":authority", "example.com")); + } + + @Test + void rejectsPseudoAfterRegular() { + rejects("x", "1", ":method", "GET", ":scheme", "https", ":path", "/", ":authority", "x"); + } + + @Test + void rejectsUnknownAndDuplicatePseudoHeaders() { + rejects(":method", "GET", ":scheme", "https", ":path", "/", ":authority", "x", ":other", "x"); + rejects( + ":method", "GET", ":method", "POST", ":scheme", "https", ":path", "/", ":authority", "x"); + } + + @Test + void rejectsMissingAndEmptyPseudoHeaders() { + rejects(":method", "GET", ":scheme", "https", ":path", "/"); + rejects(":method", "GET", ":scheme", "https", ":path", "", ":authority", "x"); + } + + @Test + void validatesConnectShape() { + assertDoesNotThrow(() -> validate(":method", "CONNECT", ":authority", "example.com:443")); + rejects(":method", "CONNECT", ":scheme", "https", ":authority", "example.com:443"); + } + + @Test + void validatesExtendedConnectShape() { + assertDoesNotThrow( + () -> + validate( + ":method", "CONNECT", + ":protocol", "websocket", + ":scheme", "https", + ":path", "/chat", + ":authority", "example.com")); + rejects( + ":method", "GET", + ":protocol", "websocket", + ":scheme", "https", + ":path", "/chat", + ":authority", "example.com"); + rejects( + ":method", "CONNECT", + ":protocol", "websocket", + ":authority", "example.com"); + } + + @Test + void rejectsUppercaseForbiddenAndInvalidTeFields() { + rejects(validWith("X-Test", "1")); + rejects(validWith("connection", "close")); + rejects(validWith("keep-alive", "timeout=5")); + rejects(validWith("proxy-connection", "close")); + rejects(validWith("transfer-encoding", "chunked")); + rejects(validWith("upgrade", "websocket")); + rejects(validWith("te", "gzip")); + assertDoesNotThrow(() -> validate(validWith("te", "trailers"))); + } + + @Test + void rejectsHostAuthorityConflict() { + rejects(validWith("host", "other.example")); + assertDoesNotThrow(() -> validate(validWith("host", "example.com"))); + } + + private static String[] validWith(String name, String value) { + return new String[] { + ":method", "GET", ":scheme", "https", ":path", "/", ":authority", "example.com", name, value + }; + } + + private static void rejects(String... fields) { + assertThrows(Http2StreamException.class, () -> validate(fields)); + } + + private static void validate(String... fields) { + HpackHeaderBlock block = new HpackHeaderBlock(); + PooledSlice name = new PooledSlice(); + PooledSlice value = new PooledSlice(); + for (int i = 0; i < fields.length; i += 2) { + byte[] nameBytes = fields[i].getBytes(StandardCharsets.US_ASCII); + byte[] valueBytes = fields[i + 1].getBytes(StandardCharsets.US_ASCII); + name.reset(nameBytes, 0, nameBytes.length); + value.reset(valueBytes, 0, valueBytes.length); + block.accept(name, value, false); + } + new PseudoHeaders().validate(block, 1); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/message/PseudoHeadersFuzzTest.java b/flash/src/test/java/dev/relism/flash/http2/message/PseudoHeadersFuzzTest.java new file mode 100644 index 0000000..834121b --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/message/PseudoHeadersFuzzTest.java @@ -0,0 +1,66 @@ +package dev.relism.flash.http2.message; + +import static org.junit.jupiter.api.Assertions.assertTimeout; +import static org.junit.jupiter.api.Assertions.fail; + +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.flash.http2.Http2StreamException; +import dev.relism.flash.http2.hpack.HpackHeaderBlock; +import dev.relism.flash.testing.FuzzMemory; +import java.time.Duration; +import org.junit.jupiter.api.Test; + +class PseudoHeadersFuzzTest { + private static final int CASES = 250_000; + + @Test + void arbitraryFieldSectionsHaveBoundedTypedOutcomes() { + assertTimeout( + Duration.ofSeconds(20), + () -> { + PseudoHeaders validator = new PseudoHeaders(); + HpackHeaderBlock block = new HpackHeaderBlock(); + PooledSlice name = new PooledSlice(); + PooledSlice value = new PooledSlice(); + byte[] bytes = new byte[512]; + long state = 0x9113_5053_4555_444FL; + long baseline = FuzzMemory.snapshot(); + for (int iteration = 0; iteration < CASES; iteration++) { + block.reset(); + state = next(state); + int fields = (int) (state & 15); + int cursor = 0; + for (int field = 0; field < fields; field++) { + state = next(state); + int nameLength = (int) (state & 15); + state = next(state); + int valueLength = (int) (state & 31); + for (int i = 0; i < nameLength + valueLength; i++) { + state = next(state); + bytes[cursor + i] = (byte) state; + } + name.reset(bytes, cursor, nameLength); + cursor += nameLength; + value.reset(bytes, cursor, valueLength); + cursor += valueLength; + block.accept(name, value, false); + } + try { + if ((iteration & 1) == 0) validator.validate(block, 1); + else PseudoHeaders.validateTrailers(block, 1); + } catch (Http2StreamException expected) { + // Invalid field sections are rejected at stream scope. + } catch (Throwable unexpected) { + fail("unexpected failure at case " + iteration, unexpected); + } + } + FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024); + }); + } + + private static long next(long value) { + value ^= value << 13; + value ^= value >>> 7; + return value ^ (value << 17); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/stream/Http2FlowControlTest.java b/flash/src/test/java/dev/relism/flash/http2/stream/Http2FlowControlTest.java new file mode 100644 index 0000000..2d5fe4b --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/stream/Http2FlowControlTest.java @@ -0,0 +1,81 @@ +package dev.relism.flash.http2.stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.relism.flash.http2.Http2Exception; +import dev.relism.flash.http2.Http2Limits; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +class Http2FlowControlTest { + @Test + void receiveWindowsReopenAtHalfWindowAtBothLevels() throws Exception { + AtomicInteger connectionUpdates = new AtomicInteger(); + AtomicInteger streamUpdates = new AtomicInteger(); + Http2FlowController controller = + new Http2FlowController( + (streamId, increment) -> { + if (streamId == 0) connectionUpdates.addAndGet(increment); + else streamUpdates.addAndGet(increment); + }); + Http2Stream stream = new Http2StreamTable(1).acquire(1); + int half = Http2Limits.INITIAL_WINDOW_SIZE_LOCAL / 2; + + controller.receiveConnectionBytes(half); + controller.receiveStreamBytes(stream, half); + controller.consumed(stream, half); + + assertEquals(half, connectionUpdates.get()); + assertEquals(half, streamUpdates.get()); + assertEquals(Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL, controller.connectionReceiveWindow()); + } + + @Test + void connectionAndStreamUnderflowUseTheirCorrectErrorScope() { + Http2FlowController controller = new Http2FlowController((streamId, increment) -> {}); + Http2Stream stream = new Http2StreamTable(1).acquire(1); + + assertSame( + Http2Exception.FLOW_CONTROL_ERROR, + assertThrows( + Http2Exception.class, + () -> + controller.receiveConnectionBytes( + Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL + 1))); + assertEquals( + dev.relism.flash.http2.Http2ErrorCode.FLOW_CONTROL_ERROR, + assertThrows( + dev.relism.flash.http2.Http2StreamException.class, + () -> + controller.receiveStreamBytes( + stream, Http2Limits.INITIAL_WINDOW_SIZE_LOCAL + 1)) + .errorCode()); + } + + @Test + void sendReservationHonoursBothWindowsAndRejectsOverflow() { + Http2FlowController controller = new Http2FlowController((streamId, increment) -> {}); + Http2Stream stream = new Http2StreamTable(1).acquire(1); + + assertEquals(65_535, controller.reserveSend(stream, 100_000)); + assertEquals(0, controller.reserveSend(stream, 1)); + controller.increaseConnectionSendWindow(Integer.MAX_VALUE); + assertSame( + Http2Exception.FLOW_CONTROL_ERROR, + assertThrows(Http2Exception.class, () -> controller.increaseConnectionSendWindow(1))); + } + + @Test + void emptyDataFrameCounterCrossesTheConfiguredLimitDeterministically() { + Http2Stream stream = new Http2StreamTable(1).acquire(1); + for (int i = 1; i <= Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM; i++) { + assertEquals(i, stream.incrementEmptyDataFrames()); + } + assertEquals( + Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM + 1, stream.incrementEmptyDataFrames()); + stream.resetEmptyDataFrames(); + assertEquals(1, stream.incrementEmptyDataFrames()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/stream/Http2RequestAssemblyTest.java b/flash/src/test/java/dev/relism/flash/http2/stream/Http2RequestAssemblyTest.java new file mode 100644 index 0000000..d2b3bf0 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/stream/Http2RequestAssemblyTest.java @@ -0,0 +1,43 @@ +package dev.relism.flash.http2.stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import dev.relism.flash.bytes.PooledSlice; +import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.models.Request; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; + +class Http2RequestAssemblyTest { + @Test + void assemblesProtocolNeutralRequestWithQueryAndAuthorityAlias() { + Http2StreamTable table = new Http2StreamTable(1); + Http2Stream stream = table.acquire(1); + field(stream, ":method", "GET"); + field(stream, ":scheme", "https"); + field(stream, ":path", "/users/42?verbose=true"); + field(stream, ":authority", "example.com"); + field(stream, "x-trace", "abc"); + + Request request = stream.assembleRequest(null, null); + + assertEquals(HttpMethod.GET, request.method()); + assertEquals("/users/42", request.path()); + assertEquals("true", request.query("verbose")); + assertEquals("example.com", request.header("host")); + assertEquals("example.com", request.header(":authority")); + assertEquals("abc", request.header("X-Trace")); + assertNull(request.remoteAddress()); + } + + private static void field(Http2Stream stream, String name, String value) { + byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII); + byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII); + PooledSlice nameView = new PooledSlice(); + PooledSlice valueView = new PooledSlice(); + nameView.reset(nameBytes, 0, nameBytes.length); + valueView.reset(valueBytes, 0, valueBytes.length); + stream.headerBlock().accept(nameView, valueView, false); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamLeakTest.java b/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamLeakTest.java new file mode 100644 index 0000000..15d8449 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamLeakTest.java @@ -0,0 +1,20 @@ +package dev.relism.flash.http2.stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class Http2StreamLeakTest { + @Test + void oneHundredThousandAcquireReleaseCyclesReuseOneStream() { + Http2StreamTable table = new Http2StreamTable(100); + for (int i = 0; i < 100_000; i++) { + Http2Stream stream = table.acquire((i << 1) | 1); + table.remove(stream.id()); + table.release(stream); + } + assertEquals(1, table.createdCount()); + assertEquals(1, table.freeCount()); + assertEquals(0, table.size()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamStateTest.java b/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamStateTest.java new file mode 100644 index 0000000..284da45 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamStateTest.java @@ -0,0 +1,32 @@ +package dev.relism.flash.http2.stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import dev.relism.flash.http2.Http2StreamException; +import org.junit.jupiter.api.Test; + +class Http2StreamStateTest { + @Test + void everyTransitionCellIsExecutableOrTypedError() { + for (Http2StreamState state : Http2StreamState.values()) { + for (Http2StreamState.Event event : Http2StreamState.Event.values()) { + if (Http2StreamState.isValid(state, event)) { + Http2StreamState next = state.transition(1, event); + assertEquals(true, next != null); + } else { + assertThrows(Http2StreamException.class, () -> state.transition(1, event)); + } + } + } + } + + @Test + void bodylessRequestAndResponseCloseStream() { + Http2StreamState state = + Http2StreamState.IDLE.transition(1, Http2StreamState.Event.RECV_HEADERS_ES); + assertEquals(Http2StreamState.HALF_CLOSED_REMOTE, state); + assertEquals( + Http2StreamState.CLOSED, state.transition(1, Http2StreamState.Event.SEND_HEADERS_ES)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamTableTest.java b/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamTableTest.java new file mode 100644 index 0000000..a62452f --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/http2/stream/Http2StreamTableTest.java @@ -0,0 +1,73 @@ +package dev.relism.flash.http2.stream; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class Http2StreamTableTest { + @Test + void insertLookupRemoveAtCapacityAndAcrossProbeClusters() { + Http2StreamTable table = new Http2StreamTable(8); + Http2Stream[] streams = new Http2Stream[8]; + for (int i = 0; i < streams.length; i++) { + streams[i] = table.acquire(i * 2 + 1); + assertSame(streams[i], table.get(i * 2 + 1)); + } + assertNull(table.acquire(99)); + for (int i = 0; i < streams.length; i += 2) { + assertSame(streams[i], table.remove(streams[i].id())); + table.release(streams[i]); + } + for (int i = 1; i < streams.length; i += 2) { + assertSame(streams[i], table.get(streams[i].id())); + } + } + + @Test + void staleRetirementCannotRemoveAReusedPooledStream() { + Http2StreamTable table = new Http2StreamTable(1); + Http2Stream firstGeneration = table.acquire(1); + + assertTrue(table.retire(firstGeneration, 1)); + Http2Stream secondGeneration = table.acquire(3); + assertSame(firstGeneration, secondGeneration); + + assertFalse(table.retire(firstGeneration, 1)); + assertSame(secondGeneration, table.get(3)); + } + + @Test + void boundedTombstonesDistinguishNormalClosureFromReset() { + Http2StreamTable table = new Http2StreamTable(2); + Http2Stream stream = table.acquire(1); + + assertTrue(table.retire(stream, 1)); + table.rememberReset(3); + + assertEquals(Http2StreamTable.CLOSED_NORMALLY, table.closedKind(1)); + assertEquals(Http2StreamTable.CLOSED_BY_RESET, table.closedKind(3)); + assertEquals(Http2StreamTable.CLOSED_UNKNOWN, table.closedKind(5)); + } + + @Test + void detachedFinalWriteDoesNotConsumeLiveStreamCapacity() { + Http2StreamTable table = new Http2StreamTable(1); + Http2Stream first = table.acquire(1); + + assertTrue(table.detach(first, 1)); + Http2Stream second = table.acquire(3); + assertNotNull(second); + assertNotSame(first, second); + + table.release(first); + assertTrue(table.retire(second, 3)); + assertEquals(2, table.createdCount()); + assertEquals(2, table.freeCount()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/models/Http1HeaderMapIndexTest.java b/flash/src/test/java/dev/relism/flash/models/Http1HeaderMapIndexTest.java new file mode 100644 index 0000000..bb47ea9 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/models/Http1HeaderMapIndexTest.java @@ -0,0 +1,148 @@ +package dev.relism.flash.models; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 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. + */ +class Http1HeaderMapIndexTest { + + private static Http1HeaderMap parse(String... headers) { + StringBuilder sb = new StringBuilder(); + for (String h : headers) sb.append(h).append("\r\n"); + byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8); + Http1HeaderMap map = new Http1HeaderMap(); + map.reset(buffer, 0, buffer.length); + return map; + } + + @Test + void zeroHeaders_everyLookupIsEmpty() { + Http1HeaderMap map = parse(); + assertNull(map.first("Host")); + assertTrue(map.all("Host").isEmpty()); + assertTrue(map.all().isEmpty()); + assertNull(map.view("Host")); + assertFalse(map.valueEqualsIgnoreCase("Connection", "close")); + } + + @Test + void duplicateHeaderNames_firstReturnsTheFirstOne_allReturnsAllInOrder() { + Http1HeaderMap map = parse("X-Trace: a", "X-Trace: b", "X-Trace: c"); + assertEquals("a", map.first("X-Trace")); + assertEquals(List.of("a", "b", "c"), map.all("X-Trace")); + } + + @Test + void caseVariation_indexHashAndCompareBothIgnoreCase() { + Http1HeaderMap map = parse("X-Custom-Header: value1"); + assertEquals("value1", map.first("x-custom-header")); + assertEquals("value1", map.first("X-CUSTOM-HEADER")); + assertEquals("value1", map.first("X-cUsToM-hEaDeR")); + } + + @Test + void similarButDistinctNames_doNotCollideInTheIndex() { + // Names sharing a hash-prefix-adjacent shape must still resolve independently. + Http1HeaderMap map = parse("Accept: a", "Accept-Encoding: b", "Accept-Language: c"); + assertEquals("a", map.first("Accept")); + assertEquals("b", map.first("Accept-Encoding")); + assertEquals("c", map.first("Accept-Language")); + } + + @Test + void growsPastInitialIndexCapacity_upToMaxHeaderCount_andStaysCorrect() { + int n = dev.relism.flash.http.Http1Limits.MAX_HEADER_COUNT; + String[] headers = new String[n]; + for (int i = 0; i < n; i++) headers[i] = "X-Header-" + i + ": value-" + i; + Http1HeaderMap map = parse(headers); + + assertEquals("value-0", map.first("X-Header-0")); + assertEquals("value-" + (n - 1), map.first("X-Header-" + (n - 1))); + assertEquals("value-" + (n / 2), map.first("X-Header-" + (n / 2))); + assertEquals(n, map.all().size()); + } + + @Test + void reset_rebuildsIndexFromScratch_noStaleEntriesFromPreviousRequest() { + Http1HeaderMap map = parse("Host: first-request"); + assertEquals("first-request", map.first("Host")); + assertNull(map.first("X-Only-In-Second")); + + byte[] second = "Host: second-request\r\nX-Only-In-Second: yes\r\n".getBytes(StandardCharsets.UTF_8); + map.reset(second, 0, second.length); + + assertEquals("second-request", map.first("Host")); + assertEquals("yes", map.first("X-Only-In-Second")); + } + + @Test + void repeatedResetsAcrossVaryingHeaderCounts_shrinkAndGrowSafely() { + // A connection whose successive keep-alive requests have very different header counts + // must never see stale entries from a larger previous request bleed into a smaller one. + Http1HeaderMap map = new Http1HeaderMap(); + for (int round = 0; round < 5; round++) { + int n = (round % 2 == 0) ? 20 : 2; + String[] headers = new String[n]; + for (int i = 0; i < n; i++) headers[i] = "H" + i + ": v" + i + "-" + round; + byte[] buf = String.join("\r\n", headers).concat("\r\n").getBytes(StandardCharsets.UTF_8); + map.reset(buf, 0, buf.length); + + assertEquals(n, map.all().size(), "round " + round); + assertEquals("v0-" + round, map.first("H0")); + if (n < 20) assertNull(map.first("H19"), "round " + round + " must not see a stale H19"); + } + } + + @Test + void allocation_indexArraysAreNotReallocatedOnceWarm() { + // 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 + // backing array references must be the exact same objects before and after 100k lookups. + Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4"); + int[] namesBefore = arrayFieldValue(map, "nameOffsets"); + + for (int i = 0; i < 100_000; i++) { + assertEquals("2", map.first("B")); + assertNotNull(map.view("C")); + assertFalse(map.all("D").isEmpty()); + } + + int[] namesAfter = arrayFieldValue(map, "nameOffsets"); + assertSame(namesBefore, namesAfter, "lookups alone must never reallocate the index arrays"); + } + + @Test + void view_poolWraparound_aliasesAnEarlierReturnedView() { + // 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; + Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3", "D: 4", "E: 5"); + for (String name : new String[]{"A", "B", "C", "D"}) { + dev.relism.fpr.core.ByteView v = map.view(name); + if (v1 == null) v1 = v; + } + assertEquals('1', v1.byteAt(0)); // still "A"'s value — pool has not wrapped yet + dev.relism.fpr.core.ByteView v5 = map.view("E"); // 5th call — wraps back to v1's slot + assertSame(v1, v5, "the 5th view() call must reuse the 1st call's slice instance"); + assertEquals('5', v1.byteAt(0)); // v1 is now silently "E"'s value, not "A"'s + } + + private static int[] arrayFieldValue(Http1HeaderMap map, String fieldName) { + try { + var field = Http1HeaderMap.class.getDeclaredField(fieldName); + field.setAccessible(true); + return (int[]) field.get(map); + } catch (ReflectiveOperationException e) { + throw new AssertionError(e); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/models/HeaderMapTest.java b/flash/src/test/java/dev/relism/flash/models/Http1HeaderMapTest.java similarity index 78% rename from flash/src/test/java/dev/relism/flash/models/HeaderMapTest.java rename to flash/src/test/java/dev/relism/flash/models/Http1HeaderMapTest.java index 08a6ad0..140378a 100644 --- a/flash/src/test/java/dev/relism/flash/models/HeaderMapTest.java +++ b/flash/src/test/java/dev/relism/flash/models/Http1HeaderMapTest.java @@ -8,15 +8,15 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.*; -class HeaderMapTest { +class Http1HeaderMapTest { // --- helpers --- - private static HeaderMap parse(String... headers) { + private static Http1HeaderMap parse(String... headers) { StringBuilder sb = new StringBuilder(); for (String h : headers) sb.append(h).append("\r\n"); byte[] buffer = sb.toString().getBytes(StandardCharsets.UTF_8); - HeaderMap map = new HeaderMap(); + Http1HeaderMap map = new Http1HeaderMap(); map.reset(buffer, 0, buffer.length); return map; } @@ -25,21 +25,21 @@ class HeaderMapTest { @Test void first_existingHeader() { - HeaderMap map = parse("Host: localhost", "Accept: text/plain"); + Http1HeaderMap map = parse("Host: localhost", "Accept: text/plain"); assertEquals("localhost", map.first("Host")); assertEquals("text/plain", map.first("Accept")); } @Test void first_caseInsensitive() { - HeaderMap map = parse("ConteNT-tYPe: application/json"); + Http1HeaderMap map = parse("ConteNT-tYPe: application/json"); assertEquals("application/json", map.first("content-type")); assertEquals("application/json", map.first("CONTENT-TYPE")); } @Test void first_missingHeader_returnsNull() { - HeaderMap map = parse("Host: localhost"); + Http1HeaderMap map = parse("Host: localhost"); assertNull(map.first("Accept")); } @@ -47,19 +47,19 @@ class HeaderMapTest { @Test void all_multipleValuesByName() { - HeaderMap map = parse("Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2"); + Http1HeaderMap map = parse("Cookie: a=1", "Set-Cookie: token=123", "Cookie: b=2"); assertEquals(List.of("a=1", "b=2"), map.all("Cookie")); } @Test void all_missingHeader_returnsEmptyList() { - HeaderMap map = parse("Host: localhost"); + Http1HeaderMap map = parse("Host: localhost"); assertTrue(map.all("Cookie").isEmpty()); } @Test void all_returnsAllHeaders() { - HeaderMap map = parse("A: 1", "B: 2"); + Http1HeaderMap map = parse("A: 1", "B: 2"); assertEquals(List.of("1", "2"), map.all()); } @@ -67,7 +67,7 @@ class HeaderMapTest { @Test void view_returnsZeroCopyView() { - HeaderMap map = parse("Host: localhost"); + Http1HeaderMap map = parse("Host: localhost"); ByteView view = map.view("Host"); assertNotNull(view); assertEquals(9, view.length()); @@ -77,7 +77,7 @@ class HeaderMapTest { @Test void view_missingHeader_returnsNull() { - HeaderMap map = parse("Host: localhost"); + Http1HeaderMap map = parse("Host: localhost"); assertNull(map.view("Accept")); } @@ -85,7 +85,7 @@ class HeaderMapTest { @Test void emptyMap_returnsNullAndEmptyList() { - HeaderMap map = new HeaderMap(); + Http1HeaderMap map = new Http1HeaderMap(); assertNull(map.first("Host")); assertTrue(map.all("Host").isEmpty()); assertTrue(map.all().isEmpty()); @@ -95,7 +95,7 @@ class HeaderMapTest { @Test void forEach_visitsEveryHeaderInDeclarationOrder() { - HeaderMap map = parse("Host: localhost", "Accept: text/plain", "Cookie: a=1"); + Http1HeaderMap map = parse("Host: localhost", "Accept: text/plain", "Cookie: a=1"); List seen = new java.util.ArrayList<>(); map.forEach((name, value) -> seen.add(toStr(name) + "=" + toStr(value))); assertEquals(List.of("Host=localhost", "Accept=text/plain", "Cookie=a=1"), seen); @@ -103,7 +103,7 @@ class HeaderMapTest { @Test void forEach_emptyMap_neverInvokesConsumer() { - HeaderMap map = new HeaderMap(); + Http1HeaderMap map = new Http1HeaderMap(); map.forEach((name, value) -> fail("must not be called on an empty map")); } @@ -111,7 +111,7 @@ class HeaderMapTest { void forEach_reusesTheSameTwoViewInstancesAcrossEveryHeader() { // The zero-allocation contract: forEach must reposition two ByteViews in place, not // allocate a fresh pair per header — same instances across all three calls here. - HeaderMap map = parse("A: 1", "B: 2", "C: 3"); + Http1HeaderMap map = parse("A: 1", "B: 2", "C: 3"); List names = new java.util.ArrayList<>(); List values = new java.util.ArrayList<>(); map.forEach((name, value) -> { names.add(name); values.add(value); }); diff --git a/flash/src/test/java/dev/relism/flash/models/PathParamsTest.java b/flash/src/test/java/dev/relism/flash/models/PathParamsTest.java index f6299eb..09f1386 100644 --- a/flash/src/test/java/dev/relism/flash/models/PathParamsTest.java +++ b/flash/src/test/java/dev/relism/flash/models/PathParamsTest.java @@ -1,5 +1,6 @@ package dev.relism.flash.models; +import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews; import dev.relism.fpr.core.ByteView; import org.junit.jupiter.api.Test; @@ -64,4 +65,32 @@ class PathParamsTest { PathParams params = of("/users/123", "userId", "123"); assertNull(params.view("unknown")); } + + @Test + void view_poolWraparound_aliasesAnEarlierReturnedView() { + // 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. + String path = "/a/1/b/2/c/3/d/4/e/5"; + byte[] bytes = path.getBytes(StandardCharsets.UTF_8); + ByteView source = new FastPathViews.RequestByteView(bytes, 0, bytes.length); + String[] names = {"a", "b", "c", "d", "e"}; + int[] starts = new int[names.length]; + int[] lens = new int[names.length]; + String[] values = {"1", "2", "3", "4", "5"}; + for (int i = 0; i < names.length; i++) { + starts[i] = path.indexOf(values[i]); + lens[i] = values[i].length(); + } + PathParams params = new PathParams(source, names, starts, lens); + + ByteView v1 = params.view("a"); + params.view("b"); + params.view("c"); + params.view("d"); // pool size 4 — not wrapped yet + assertEquals('1', v1.byteAt(0)); + ByteView v5 = params.view("e"); // 5th call wraps back to v1's slot + assertSame(v1, v5); + assertEquals('5', v1.byteAt(0)); + } } diff --git a/flash/src/test/java/dev/relism/flash/models/QueryParamsFastPathTest.java b/flash/src/test/java/dev/relism/flash/models/QueryParamsFastPathTest.java new file mode 100644 index 0000000..13f2cc1 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/models/QueryParamsFastPathTest.java @@ -0,0 +1,92 @@ +package dev.relism.flash.models; + +import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews; +import dev.relism.fpr.core.ByteView; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 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 + */ +class QueryParamsFastPathTest { + + private static QueryParams of(String query) { + byte[] bytes = query.getBytes(StandardCharsets.US_ASCII); + return new QueryParams(new FastPathViews.RequestByteView(bytes, 0, bytes.length)); + } + + @Test + void cleanValue_noPercentOrPlus_decodesToItself() { + QueryParams qp = of("name=hello&city=NewYork"); + assertEquals("hello", qp.get("name")); + assertEquals("NewYork", qp.get("city")); + } + + @Test + void valueWithPlus_decodesToSpace_takesSlowPath() { + QueryParams qp = of("q=hello+world"); + assertEquals("hello world", qp.get("q")); + } + + @Test + void valueWithPercentEscape_decodesCorrectly_takesSlowPath() { + QueryParams qp = of("q=hello%20world"); + assertEquals("hello world", qp.get("q")); + } + + @Test + void valueWithInvalidPercentEscape_keepsLiteralPercent() { + QueryParams qp = of("q=100%25off"); + assertEquals("100%off", qp.get("q")); + QueryParams qp2 = of("q=trailing%2"); + assertEquals("trailing%2", qp2.get("q")); + } + + @Test + void emptyValue_isClean_decodesToEmptyString() { + QueryParams qp = of("a=&b=1"); + assertEquals("", qp.get("a")); + assertEquals("1", qp.get("b")); + } + + @Test + void mixedCleanAndEncodedValues_inSameQueryString() { + QueryParams qp = of("clean=abc&encoded=a%20b&plussed=a+b"); + assertEquals("abc", qp.get("clean")); + assertEquals("a b", qp.get("encoded")); + assertEquals("a b", qp.get("plussed")); + } + + + @Test + void view_returnsRawUndecodedBytes() { + QueryParams qp = of("q=a+b"); + ByteView v = qp.view("q"); + assertNotNull(v); + assertEquals(3, v.length()); + assertEquals('+', (char) v.byteAt(1)); // raw, not percent/plus-decoded + } + + @Test + void view_missingKey_returnsNull() { + QueryParams qp = of("q=1"); + assertNull(qp.view("missing")); + } + + @Test + void view_poolWraparound_aliasesAnEarlierReturnedView() { + QueryParams qp = of("a=1&b=2&c=3&d=4&e=5"); + ByteView v1 = qp.view("a"); + qp.view("b"); + qp.view("c"); + qp.view("d"); // pool size 4 — not wrapped yet + assertEquals('1', v1.byteAt(0)); + ByteView v5 = qp.view("e"); // 5th call wraps back to v1's slot + assertSame(v1, v5); + assertEquals('5', v1.byteAt(0)); + } +} diff --git a/flash/src/test/java/dev/relism/flash/models/RequestBodyTest.java b/flash/src/test/java/dev/relism/flash/models/RequestBodyTest.java index ea7f2b4..afd1f3b 100644 --- a/flash/src/test/java/dev/relism/flash/models/RequestBodyTest.java +++ b/flash/src/test/java/dev/relism/flash/models/RequestBodyTest.java @@ -161,4 +161,44 @@ class RequestBodyTest { body.drain(); assertEquals(0, socket.available()); } + + + @Test + void reset_repositionsSamePooledInstance_overSuccessiveRequests() throws IOException { + RequestBody body = new RequestBody(); // pooled ctor — no I/O configured yet + + byte[] first = "first".getBytes(StandardCharsets.UTF_8); + body.reset(new ByteArrayInputStream(first), 5, new byte[0], 0, 0); + assertArrayEquals(first, body.bytes()); + + byte[] second = "second-request".getBytes(StandardCharsets.UTF_8); + body.reset(new ByteArrayInputStream(second), second.length, new byte[0], 0, 0); + assertArrayEquals(second, body.bytes(), "reset() must not leak the previous request's resolved body"); + } + + @Test + void stream_reusesTheSameBoundedStreamInstance_acrossResets() throws IOException { + RequestBody body = new RequestBody(); + + body.reset(new ByteArrayInputStream("one".getBytes(StandardCharsets.UTF_8)), 3, new byte[0], 0, 0); + InputStream stream1 = body.stream(); + assertEquals("one", new String(stream1.readAllBytes(), StandardCharsets.UTF_8)); + + body.reset(new ByteArrayInputStream("two".getBytes(StandardCharsets.UTF_8)), 3, new byte[0], 0, 0); + InputStream stream2 = body.stream(); + assertEquals("two", new String(stream2.readAllBytes(), StandardCharsets.UTF_8)); + } + + @Test + void drain_reusesTheSameDrainBuffer_acrossChunkedResets() throws IOException { + RequestBody body = new RequestBody(); + + body.reset(new ByteArrayInputStream("chunk one".getBytes(StandardCharsets.UTF_8)), -1L, null, 0, 0); + body.drain(); + + ByteArrayInputStream secondSocket = new ByteArrayInputStream("chunk two".getBytes(StandardCharsets.UTF_8)); + body.reset(secondSocket, -1L, null, 0, 0); + body.drain(); + assertEquals(0, secondSocket.available(), "drain() must fully consume the second request's chunked body too"); + } } diff --git a/flash/src/test/java/dev/relism/flash/models/RequestLineTest.java b/flash/src/test/java/dev/relism/flash/models/RequestLineTest.java index bccb764..572ed4d 100644 --- a/flash/src/test/java/dev/relism/flash/models/RequestLineTest.java +++ b/flash/src/test/java/dev/relism/flash/models/RequestLineTest.java @@ -28,7 +28,7 @@ class RequestLineTest { ByteView path = viewOf("/api"); ByteView query = viewOf("q=1"); ByteView proto = viewOf("HTTP/1.1"); - HeaderMap headers = new HeaderMap(); + Http1HeaderMap headers = new Http1HeaderMap(); RequestLine rl = new RequestLine(HttpMethod.GET, path, query, proto, headers); diff --git a/flash/src/test/java/dev/relism/flash/models/RequestPoolingTest.java b/flash/src/test/java/dev/relism/flash/models/RequestPoolingTest.java new file mode 100644 index 0000000..d2db875 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/models/RequestPoolingTest.java @@ -0,0 +1,101 @@ +package dev.relism.flash.models; + +import dev.relism.flash.http.HttpMethod; +import dev.relism.fpr.core.ByteView; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@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 + * follows from. The real, applicable threat this class actually tests: request N+1 on + * the *same* keep-alive connection must never see stale data left over from request N, + * since those two requests genuinely do share one {@code Request} instance. + */ +class RequestPoolingTest { + + private static ByteView viewOf(String s) { + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + return new ByteView() { + public int length() { return bytes.length; } + public byte byteAt(int idx) { return bytes[idx]; } + }; + } + + private static Http1HeaderMap headersOf(String... rawLines) { + StringBuilder sb = new StringBuilder(); + for (String line : rawLines) sb.append(line).append("\r\n"); + byte[] buf = sb.toString().getBytes(StandardCharsets.UTF_8); + Http1HeaderMap map = new Http1HeaderMap(); + map.reset(buf, 0, buf.length); + return map; + } + + @Test + void forParsed_reusesTheSamePooledInstance_neverAllocatesANewOne() { + Request pooled = new Request(); + RequestLine line1 = new RequestLine(HttpMethod.GET, viewOf("/a"), null, viewOf("HTTP/1.1"), headersOf()); + Request r1 = Request.forParsed(pooled, line1, RequestBody.empty(), null, null); + assertSame(pooled, r1); + + RequestLine line2 = new RequestLine(HttpMethod.POST, viewOf("/b"), null, viewOf("HTTP/1.1"), headersOf()); + Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null); + assertSame(pooled, r2); + assertSame(r1, r2, "the same pooled instance must be returned for every request on one connection"); + } + + @Test + void secondRequest_onSameConnection_doesNotSeeFirstRequestsAuthorizationHeader() { + Request pooled = new Request(); + + RequestLine first = new RequestLine(HttpMethod.GET, viewOf("/secure"), null, viewOf("HTTP/1.1"), + headersOf("Authorization: Bearer super-secret-token-A")); + Request r1 = Request.forParsed(pooled, first, RequestBody.empty(), null, null); + assertEquals("Bearer super-secret-token-A", r1.header("Authorization")); + + // A second request on the same keep-alive connection, with no Authorization header at all. + RequestLine second = new RequestLine(HttpMethod.GET, viewOf("/public"), null, viewOf("HTTP/1.1"), + headersOf("Host: example.com")); + Request r2 = Request.forParsed(pooled, second, RequestBody.empty(), null, null); + + assertNull(r2.header("Authorization"), "the second request must not see the first request's Authorization header"); + assertNull(r2.header("authorization")); + for (String value : r2.headers()) { + assertFalse(value.contains("super-secret-token-A"), "leaked secret found in: " + value); + } + } + + @Test + void secondRequest_doesNotSeeFirstRequestsPathParams() { + Request pooled = new Request(); + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/users/123"), null, viewOf("HTTP/1.1"), headersOf()); + Request r1 = Request.forParsed(pooled, line, RequestBody.empty(), null, null); + PathParams.inject(r1, new PathParams(viewOf("/users/123"), new String[]{"id"}, new int[]{7}, new int[]{3})); + assertEquals("123", r1.param("id")); + + RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/health"), null, viewOf("HTTP/1.1"), headersOf()); + Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null); + assertNull(r2.param("id"), "path params from the previous request on this connection must not leak"); + assertNull(r2.getPathParams()); + } + + @Test + void secondRequest_doesNotSeeFirstRequestsCachedPathOrQueryParams() { + Request pooled = new Request(); + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/first"), viewOf("token=abc"), viewOf("HTTP/1.1"), headersOf()); + Request r1 = Request.forParsed(pooled, line, RequestBody.empty(), null, null); + assertEquals("/first", r1.path()); + assertEquals("abc", r1.query("token")); + + RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/second"), null, viewOf("HTTP/1.1"), headersOf()); + Request r2 = Request.forParsed(pooled, line2, RequestBody.empty(), null, null); + assertEquals("/second", r2.path(), "cachedPath from the previous request must not leak"); + assertNull(r2.query("token"), "query params from the previous request must not leak"); + } +} diff --git a/flash/src/test/java/dev/relism/flash/models/RequestRecycleGuardTest.java b/flash/src/test/java/dev/relism/flash/models/RequestRecycleGuardTest.java new file mode 100644 index 0000000..7e77181 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/models/RequestRecycleGuardTest.java @@ -0,0 +1,127 @@ +package dev.relism.flash.models; + +import dev.relism.flash.http.HttpMethod; +import dev.relism.fpr.core.ByteView; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@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}. + */ +class RequestRecycleGuardTest { + + @AfterEach + void restoreProductionDefault() { + // Never leak the test override into other test classes sharing this JVM/fork. + Request.setPoisoningEnabledForTesting(false); + } + + private static ByteView viewOf(String s) { + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + return new ByteView() { + public int length() { return bytes.length; } + public byte byteAt(int idx) { return bytes[idx]; } + }; + } + + private static Request active() { + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/x"), null, viewOf("HTTP/1.1"), new Http1HeaderMap()); + return new Request(line, new byte[0]); + } + + @Test + void poisoningDisabled_recycledRequestStillAccessible() { + Request.setPoisoningEnabledForTesting(false); + Request r = active(); + r.recycle(); + assertDoesNotThrow(r::method, "poisoning disabled (production default) must never throw"); + } + + @Test + void poisoningEnabled_freshRequest_accessibleNormally() { + Request.setPoisoningEnabledForTesting(true); + Request r = active(); + assertDoesNotThrow(r::path); + assertDoesNotThrow(() -> r.header("Host")); + assertDoesNotThrow(r::method); + } + + @Test + void poisoningEnabled_afterRecycle_methodThrows() { + Request.setPoisoningEnabledForTesting(true); + Request r = active(); + r.recycle(); + assertThrows(IllegalStateException.class, r::method); + } + + @Test + void poisoningEnabled_afterRecycle_pathThrows() { + Request.setPoisoningEnabledForTesting(true); + Request r = active(); + r.recycle(); + assertThrows(IllegalStateException.class, r::path); + } + + @Test + void poisoningEnabled_afterRecycle_headerThrows() { + Request.setPoisoningEnabledForTesting(true); + Request r = active(); + r.recycle(); + assertThrows(IllegalStateException.class, () -> r.header("Host")); + } + + @Test + void poisoningEnabled_afterRecycle_paramThrows() { + Request.setPoisoningEnabledForTesting(true); + Request r = active(); + r.recycle(); + assertThrows(IllegalStateException.class, () -> r.param("id")); + } + + @Test + void poisoningEnabled_afterRecycle_queryThrows() { + Request.setPoisoningEnabledForTesting(true); + Request r = active(); + r.recycle(); + assertThrows(IllegalStateException.class, () -> r.query("q")); + } + + @Test + void poisoningEnabled_afterRecycle_remoteAddressThrows() { + Request.setPoisoningEnabledForTesting(true); + Request r = active(); + r.recycle(); + assertThrows(IllegalStateException.class, r::remoteAddress); + } + + @Test + void poisoningEnabled_afterRecycle_isSecureThrows() { + Request.setPoisoningEnabledForTesting(true); + Request r = active(); + r.recycle(); + assertThrows(IllegalStateException.class, r::isSecure); + } + + @Test + void reusedAfterReset_becomesAccessibleAgain() { + Request.setPoisoningEnabledForTesting(true); + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/first"), null, viewOf("HTTP/1.1"), new Http1HeaderMap()); + Request r = new Request(line, new byte[0]); + r.recycle(); + assertThrows(IllegalStateException.class, r::path); + + // Simulate the connection loop pulling this pooled instance back out for the next + // request: Request.forParsed's reset() call re-activates it. + RequestLine line2 = new RequestLine(HttpMethod.GET, viewOf("/second"), null, viewOf("HTTP/1.1"), new Http1HeaderMap()); + Request reused = Request.forParsed(r, line2, RequestBody.empty(), null, null); + assertSame(r, reused, "forParsed must reposition the same pooled instance, not allocate a new one"); + assertDoesNotThrow(reused::path); + assertEquals("/second", reused.path()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/models/RequestTest.java b/flash/src/test/java/dev/relism/flash/models/RequestTest.java index 908079e..354417e 100644 --- a/flash/src/test/java/dev/relism/flash/models/RequestTest.java +++ b/flash/src/test/java/dev/relism/flash/models/RequestTest.java @@ -26,7 +26,7 @@ class RequestTest { @Test void request_creationAndAccessors() { - HeaderMap headers = new HeaderMap(); + Http1HeaderMap headers = new Http1HeaderMap(); RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/path"), viewOf("q=1"), viewOf("HTTP/1.1"), headers); byte[] body = "body".getBytes(StandardCharsets.UTF_8); @@ -43,7 +43,7 @@ class RequestTest { @Test void header_delegatesToRequestLine() { byte[] buffer = "Host: localhost\r\n".getBytes(StandardCharsets.UTF_8); - HeaderMap headers = new HeaderMap(); + Http1HeaderMap headers = new Http1HeaderMap(); headers.reset(buffer, 0, buffer.length); RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), headers); Request r = new Request(line, new byte[0]); @@ -57,7 +57,7 @@ class RequestTest { @Test void param_lazyGet() { - RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap()); + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new Http1HeaderMap()); Request r = new Request(line, new byte[0]); assertNull(r.param("id")); @@ -70,7 +70,7 @@ class RequestTest { @Test void query_lazyGet_fromQueryString() { - RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new HeaderMap()); + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), viewOf("a=1&b=2&b=3"), viewOf("HTTP/1.1"), new Http1HeaderMap()); Request r = new Request(line, new byte[0]); assertEquals("1", r.query("a")); @@ -80,7 +80,7 @@ class RequestTest { @Test void query_lazyGet_nullQueryString() { - RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new HeaderMap()); + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/"), null, viewOf("HTTP/1.1"), new Http1HeaderMap()); Request r = new Request(line, new byte[0]); assertNull(r.query("a")); diff --git a/flash/src/test/java/dev/relism/flash/models/ResponsePoolingTest.java b/flash/src/test/java/dev/relism/flash/models/ResponsePoolingTest.java new file mode 100644 index 0000000..1be6746 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/models/ResponsePoolingTest.java @@ -0,0 +1,74 @@ +package dev.relism.flash.models; + +import dev.relism.flash.http.ContentType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ResponsePoolingTest { + + @Test + void reset_returnsSameInstanceAndClearsPreviousState() { + Response r = new Response(200, ContentType.TEXT_PLAIN); + r.header("X-Trace", "abc123").status(201).body("first body"); + assertEquals(1, r.getHeaders().size()); + + Response reset = r.reset(200, ContentType.JSON); + assertSame(r, reset, "reset() must reposition the same instance, not allocate a new one"); + assertEquals(200, reset.getStatusCode()); + assertNull(reset.getBody(), "body from the previous cycle must not leak"); + assertTrue(reset.getHeaders().isEmpty(), "headers from the previous cycle must not leak"); + assertArrayEquals(ContentType.JSON.getBytes(), reset.getContentType()); + } + + @Test + void secondCycle_doesNotSeeFirstCyclesCustomHeader() { + Response r = new Response(200, ContentType.TEXT_PLAIN); + r.header("X-Secret", "leaked-if-broken"); + assertEquals(1, r.getHeaders().size()); + + r.reset(200, ContentType.TEXT_PLAIN); + r.header("X-Public", "fine"); + + assertEquals(1, r.getHeaders().size()); + String only = new String(r.getHeaders().get(0)); + assertTrue(only.contains("X-Public")); + assertFalse(only.contains("X-Secret"), "stale header from the previous cycle leaked: " + only); + } + + @Test + void secondCycle_reusesHeaderRegionAcrossManyHeaders_staysCorrect() { + Response r = new Response(200, ContentType.TEXT_PLAIN); + for (int cycle = 0; cycle < 5; cycle++) { + r.reset(200, ContentType.TEXT_PLAIN); + for (int i = 0; i < 10; i++) { + r.header("X-Cycle" + cycle + "-H" + i, "v" + i); + } + assertEquals(10, r.getHeaders().size(), "cycle " + cycle); + String last = new String(r.getHeaders().get(9)); + assertTrue(last.contains("X-Cycle" + cycle + "-H9: v9"), "cycle " + cycle + ": " + last); + } + } + + @Test + void mixedStructuredAndRawHeaders_preserveInsertionOrder() { + Response r = new Response(200, ContentType.TEXT_PLAIN); + r.header("A", "1"); + r.header("B-raw: 2\r\n".getBytes()); + r.header("C", "3"); + + var headers = r.getHeaders(); + assertEquals(3, headers.size()); + assertEquals("A: 1\r\n", new String(headers.get(0))); + assertEquals("B-raw: 2\r\n", new String(headers.get(1))); + assertEquals("C: 3\r\n", new String(headers.get(2))); + } + + @Test + void preEncodedHeader_roundTripsThroughGetHeaders() { + Response r = new Response(200, ContentType.TEXT_PLAIN); + PreEncodedHeader h = new PreEncodedHeader("X-Static", "value"); + r.header(h); + assertEquals("X-Static: value\r\n", new String(r.getHeaders().get(0))); + } +} diff --git a/flash/src/test/java/dev/relism/flash/models/ResponseRecycleGuardTest.java b/flash/src/test/java/dev/relism/flash/models/ResponseRecycleGuardTest.java new file mode 100644 index 0000000..ffa3e9d --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/models/ResponseRecycleGuardTest.java @@ -0,0 +1,57 @@ +package dev.relism.flash.models; + +import dev.relism.flash.http.ContentType; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class ResponseRecycleGuardTest { + + @AfterEach + void restoreProductionDefault() { + Response.setPoisoningEnabledForTesting(false); + } + + @Test + void poisoningDisabled_recycledResponseStillAccessible() { + Response.setPoisoningEnabledForTesting(false); + Response r = new Response(200, ContentType.TEXT_PLAIN); + r.recycle(); + assertDoesNotThrow(r::getStatusCode); + } + + @Test + void poisoningEnabled_afterRecycle_getStatusCodeThrows() { + Response.setPoisoningEnabledForTesting(true); + Response r = new Response(200, ContentType.TEXT_PLAIN); + r.recycle(); + assertThrows(IllegalStateException.class, r::getStatusCode); + } + + @Test + void poisoningEnabled_afterRecycle_headerThrows() { + Response.setPoisoningEnabledForTesting(true); + Response r = new Response(200, ContentType.TEXT_PLAIN); + r.recycle(); + assertThrows(IllegalStateException.class, () -> r.header("X", "Y")); + } + + @Test + void poisoningEnabled_afterRecycle_bodyThrows() { + Response.setPoisoningEnabledForTesting(true); + Response r = new Response(200, ContentType.TEXT_PLAIN); + r.recycle(); + assertThrows(IllegalStateException.class, () -> r.body("x")); + } + + @Test + void poisoningEnabled_afterReset_accessibleAgain() { + Response.setPoisoningEnabledForTesting(true); + Response r = new Response(200, ContentType.TEXT_PLAIN); + r.recycle(); + assertThrows(IllegalStateException.class, r::getStatusCode); + r.reset(200, ContentType.TEXT_PLAIN); + assertDoesNotThrow(r::getStatusCode); + } +} diff --git a/flash/src/test/java/dev/relism/flash/models/ResponseSerializerParityTest.java b/flash/src/test/java/dev/relism/flash/models/ResponseSerializerParityTest.java new file mode 100644 index 0000000..0689964 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/models/ResponseSerializerParityTest.java @@ -0,0 +1,66 @@ +package dev.relism.flash.models; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.http.ContentType; +import dev.relism.flash.http.HttpMethod; +import dev.relism.flash.http1.Http1ResponseWriter; +import dev.relism.flash.http2.hpack.HpackDecoder; +import dev.relism.flash.http2.message.Http2ResponseWriter; +import dev.relism.flash.transport.ScratchPool; +import dev.relism.fpr.core.ByteView; +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class ResponseSerializerParityTest { + @Test + void bothProtocolsRenderTheSameResponseFields() throws Exception { + Response response = + new Response(201, "created", ContentType.JSON) + .header("Cache-Control", "no-store") + .header(new PreEncodedHeader("X-Trace", "abc")); + + ByteArrayOutputStream http1 = new ByteArrayOutputStream(); + Http1ResponseWriter.writeResponse( + http1, response, HttpMethod.GET, true, false, new ScratchPool().acquire()); + Map http1Fields = parseHttp1(http1.toString(StandardCharsets.US_ASCII)); + http1Fields.remove("connection"); + + Http2ResponseWriter writer = new Http2ResponseWriter(); + writer.prepare(response, 1, false, false, true, false, false, 16_384, 4096, 65_535); + int headerLength = + ((writer.buffer()[0] & 0xff) << 16) + | ((writer.buffer()[1] & 0xff) << 8) + | (writer.buffer()[2] & 0xff); + Map http2Fields = new LinkedHashMap<>(); + new HpackDecoder() + .decode( + writer.buffer(), + 9, + headerLength, + (name, value, never) -> http2Fields.put(text(name), text(value))); + http2Fields.remove(":status"); + + assertEquals(http1Fields, http2Fields); + } + + private static Map parseHttp1(String message) { + Map fields = new LinkedHashMap<>(); + int end = message.indexOf("\r\n\r\n"); + String[] lines = message.substring(0, end).split("\r\n"); + for (int i = 1; i < lines.length; i++) { + int colon = lines[i].indexOf(':'); + fields.put(lines[i].substring(0, colon).toLowerCase(), lines[i].substring(colon + 2)); + } + return fields; + } + + private static String text(ByteView view) { + byte[] bytes = new byte[view.length()]; + for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i); + return new String(bytes, StandardCharsets.US_ASCII); + } +} diff --git a/flash/src/test/java/dev/relism/flash/models/ResponseSerializerTest.java b/flash/src/test/java/dev/relism/flash/models/ResponseSerializerTest.java new file mode 100644 index 0000000..bcfc2b0 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/models/ResponseSerializerTest.java @@ -0,0 +1,73 @@ +package dev.relism.flash.models; + +import dev.relism.flash.http.ContentType; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +class ResponseSerializerTest { + + private static List collect(Response r) { + List fields = new ArrayList<>(); + ResponseSerializer.forEachField(r, (nameBuf, nameOff, nameLen, valueBuf, valueOff, valueLen) -> + fields.add(new String(nameBuf, nameOff, nameLen, StandardCharsets.US_ASCII) + + "=" + new String(valueBuf, valueOff, valueLen, StandardCharsets.US_ASCII))); + return fields; + } + + @Test + void contentTypeFirst_thenCustomHeadersInOrder() { + Response r = new Response(200, ContentType.JSON); + r.header("X-A", "1").header("X-B", "2"); + assertEquals(List.of("Content-Type=application/json", "X-A=1", "X-B=2"), collect(r)); + } + + @Test + void contentTypeNone_isSkipped_notEmptyValue() { + Response r = new Response(200, ContentType.NONE); + r.header("X-Only", "here"); + assertEquals(List.of("X-Only=here"), collect(r)); + } + + @Test + void noHeadersAtAll_onlyContentType() { + Response r = new Response(200, ContentType.TEXT_PLAIN); + assertEquals(List.of("Content-Type=text/plain"), collect(r)); + } + + @Test + void rawPreEncodedHeaderBytes_areExcludedFromEnumeration() { + // header(byte[]) has no recoverable (name, value) structure -- ResponseSerializer must + // skip it (Http1ResponseWriter still renders it, via writeHeaders, just not through this + // protocol-neutral path). + Response r = new Response(200, ContentType.NONE); + r.header("X-Structured", "yes"); + r.header("X-Raw: no-structure\r\n".getBytes()); + assertEquals(List.of("X-Structured=yes"), collect(r)); + } + + @Test + void preEncodedHeaderObject_isIncluded_withStructure() { + Response r = new Response(200, ContentType.NONE); + r.header(new PreEncodedHeader("X-Boot", "constant")); + assertEquals(List.of("X-Boot=constant"), collect(r)); + } + + @Test + void zeroAllocation_byteRangesAreSlicesOfResponsesOwnBuffers_notCopies() { + Response r = new Response(200, ContentType.NONE); + r.header("X-A", "value-a"); + byte[][] captured = new byte[2][]; + ResponseSerializer.forEachField(r, (nameBuf, nameOff, nameLen, valueBuf, valueOff, valueLen) -> { + captured[0] = nameBuf; + captured[1] = valueBuf; + }); + // Both slices must reference the SAME backing array (the response's own header region) -- + // proves no copy was made to hand the field to the consumer. + assertSame(captured[0], captured[1]); + } +} diff --git a/flash/src/test/java/dev/relism/flash/models/ResponseStreamTest.java b/flash/src/test/java/dev/relism/flash/models/ResponseStreamTest.java new file mode 100644 index 0000000..68d1319 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/models/ResponseStreamTest.java @@ -0,0 +1,50 @@ +package dev.relism.flash.models; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.relism.flash.http.ContentType; +import java.io.IOException; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +class ResponseStreamTest { + @Test + void producerWritesBodyAndTrailersThroughBoundedBridge() throws Exception { + Response response = new Response(200, ContentType.BINARY); + response.streaming(stream -> { + try { + stream.write(new byte[] {1, 2, 3}, 0, 3); + stream.trailer("grpc-status", "0"); + } catch (IOException failure) { + throw new RuntimeException(failure); + } + }); + + assertArrayEquals(new byte[] {1, 2, 3}, response.getStream().readAllBytes()); + assertEquals(true, response.hasTrailers()); + } + + @Test + void writeAfterCloseFailsWithoutWritingMoreBytes() throws Exception { + AtomicReference failure = new AtomicReference<>(); + CountDownLatch attempted = new CountDownLatch(1); + Response response = new Response(200, ContentType.BINARY); + response.streaming(stream -> { + try { + stream.close(); + stream.write(new byte[] {1}, 0, 1); + } catch (IOException expected) { + failure.set(expected); + } finally { + attempted.countDown(); + } + }); + + assertArrayEquals(new byte[0], response.getStream().readAllBytes()); + assertEquals(true, attempted.await(1, TimeUnit.SECONDS)); + assertEquals("response stream is closed", failure.get().getMessage()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/models/ResponseTest.java b/flash/src/test/java/dev/relism/flash/models/ResponseTest.java index 33ffb73..61218ed 100644 --- a/flash/src/test/java/dev/relism/flash/models/ResponseTest.java +++ b/flash/src/test/java/dev/relism/flash/models/ResponseTest.java @@ -134,4 +134,34 @@ class ResponseTest { void getHeaders_emptyWhenNoneAdded() { assertTrue(new Response(200, new byte[0], ContentType.TEXT_PLAIN).getHeaders().isEmpty()); } + + + @Test + void header_exceedingMaxCount_throws() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + for (int i = 0; i < dev.relism.flash.http.Http1Limits.MAX_RESPONSE_HEADER_COUNT; i++) { + r.header("X-" + i, "v"); + } + assertThrows(IllegalStateException.class, () -> r.header("one-too-many", "v")); + } + + @Test + void header_exceedingMaxRegionBytes_throws() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + String bigValue = "v".repeat(1024); + assertThrows(IllegalStateException.class, () -> { + // Each call adds ~1024 bytes; comfortably crosses MAX_RESPONSE_HEADER_BYTES well + // before MAX_RESPONSE_HEADER_COUNT would trigger first. + for (int i = 0; i < dev.relism.flash.http.Http1Limits.MAX_RESPONSE_HEADER_COUNT; i++) { + r.header("X-" + i, bigValue); + } + }); + } + + @Test + void header_withinBudget_stillWorksNormally() { + Response r = new Response(200, new byte[0], ContentType.TEXT_PLAIN); + r.header("X-Foo", "bar"); + assertEquals(1, r.getHeaders().size()); + } } diff --git a/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java b/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java index 2acbfba..ede5000 100644 --- a/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java +++ b/flash/src/test/java/dev/relism/flash/routing/AbstractRouterTest.java @@ -17,7 +17,7 @@ class AbstractRouterTest { String lastAddedPath; @Override - public RequestHandler route(Request request) { return null; } + public RequestHandler route(Request request, Object scratch) { return null; } @Override protected AbstractRouter addRoute(HttpMethod method, String path, RequestHandler handler) { diff --git a/flash/src/test/java/dev/relism/flash/routing/AbstractWsRouterTest.java b/flash/src/test/java/dev/relism/flash/routing/AbstractWsRouterTest.java index 091f251..4105cc5 100644 --- a/flash/src/test/java/dev/relism/flash/routing/AbstractWsRouterTest.java +++ b/flash/src/test/java/dev/relism/flash/routing/AbstractWsRouterTest.java @@ -15,7 +15,7 @@ class AbstractWsRouterTest { String lastPath; @Override - public WebSocketHandler route(Request request) { return null; } + public WebSocketHandler route(Request request, Object scratch) { return null; } @Override protected AbstractWsRouter addRoute(HttpMethod method, String path, WebSocketHandler handler) { diff --git a/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImplTest.java b/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImplTest.java index 5c97c17..450d25d 100644 --- a/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImplTest.java +++ b/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathRouterImplTest.java @@ -1,7 +1,7 @@ package dev.relism.flash.routing.routers.fastpathrouter; import dev.relism.flash.http.HttpMethod; -import dev.relism.flash.models.HeaderMap; +import dev.relism.flash.models.Http1HeaderMap; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestHandler; import dev.relism.flash.models.RequestLine; @@ -23,7 +23,7 @@ class FastPathRouterImplTest { RequestLine line = new RequestLine( method, pathView, null, new FastPathViews.RequestByteView("HTTP/1.1".getBytes(StandardCharsets.UTF_8), 0, 8), - new HeaderMap() + new Http1HeaderMap() ); return new Request(line, new byte[0]); } @@ -33,12 +33,13 @@ class FastPathRouterImplTest { FastPathRouterImpl router = new FastPathRouterImpl(); router.doRegister(HttpMethod.GET, "/a", new SimpleHandler((req, res) -> "A"), NO_MW); router.doRegister(HttpMethod.POST, "/b", new SimpleHandler((req, res) -> "B"), NO_MW); + Object scratch = router.newScratch(); - RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a")); + RequestHandler res1 = router.route(mockRequest(HttpMethod.GET, "/a"), scratch); assertNotNull(res1); assertEquals("A", res1.handle(null, null)); - RequestHandler res2 = router.route(mockRequest(HttpMethod.POST, "/b")); + RequestHandler res2 = router.route(mockRequest(HttpMethod.POST, "/b"), scratch); assertNotNull(res2); assertEquals("B", res2.handle(null, null)); } @@ -47,9 +48,10 @@ class FastPathRouterImplTest { void route_noMatch_returnsNull() { FastPathRouterImpl router = new FastPathRouterImpl(); router.doRegister(HttpMethod.GET, "/a", new SimpleHandler((req, res) -> "A"), NO_MW); + Object scratch = router.newScratch(); - assertNull(router.route(mockRequest(HttpMethod.GET, "/b"))); - assertNull(router.route(mockRequest(HttpMethod.POST, "/a"))); + assertNull(router.route(mockRequest(HttpMethod.GET, "/b"), scratch)); + assertNull(router.route(mockRequest(HttpMethod.POST, "/a"), scratch)); } @Test @@ -59,7 +61,7 @@ class FastPathRouterImplTest { new SimpleHandler((req, res) -> "Extract"), NO_MW); Request request = mockRequest(HttpMethod.GET, "/users/123/items/456"); - RequestHandler handler = router.route(request); + RequestHandler handler = router.route(request, router.newScratch()); assertNotNull(handler); assertEquals("Extract", handler.handle(request, null)); @@ -67,4 +69,34 @@ class FastPathRouterImplTest { assertEquals("123", request.param("id")); assertEquals("456", request.param("itemId")); } + + @Test + void route_reusesScratchAcrossManyRequests_includingGrowingParamCapacity() throws Exception { + // 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}", + new SimpleHandler((req, res) -> "many"), NO_MW); + router.doRegister(HttpMethod.GET, "/b/{id}", new SimpleHandler((req, res) -> "one"), NO_MW); + Object scratch = router.newScratch(); + + for (int i = 0; i < 3; i++) { + Request oneParam = mockRequest(HttpMethod.GET, "/b/123"); + assertEquals("one", router.route(oneParam, scratch).handle(oneParam, null)); + assertEquals("123", oneParam.param("id")); + + Request tenParams = mockRequest(HttpMethod.GET, "/a/1/2/3/4/5/6/7/8/9/10"); + assertEquals("many", router.route(tenParams, scratch).handle(tenParams, null)); + assertEquals("10", tenParams.param("p10")); + assertEquals("1", tenParams.param("p1")); + + // The 1-param request that follows a 10-param one must not see stale params left + // over from the larger match in the shared, oversized arrays. + Request oneParamAgain = mockRequest(HttpMethod.GET, "/b/456"); + RequestHandler h = router.route(oneParamAgain, scratch); + assertNotNull(h); + h.handle(oneParamAgain, null); + assertEquals("456", oneParamAgain.param("id")); + assertNull(oneParamAgain.param("p10")); + } + } } diff --git a/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViewsLongAtTest.java b/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViewsLongAtTest.java new file mode 100644 index 0000000..469960a --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViewsLongAtTest.java @@ -0,0 +1,114 @@ +package dev.relism.flash.routing.routers.fastpathrouter; + +import dev.relism.fpr.core.FastPathRouter; +import dev.relism.fpr.core.MatchResult; +import dev.relism.fpr.core.RouterBuilder; +import dev.relism.fpr.core.dsl.StringRouteParser; +import dev.relism.fpr.core.internal.runtime.ByteCompare; +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@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 + * 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. + */ +class FastPathViewsLongAtTest { + + // ── Raw longAt() vs. a hand-assembled little-endian expectation ──────── + + @Test + void longAt_assemblesLittleEndian() { + byte[] buf = {1, 2, 3, 4, 5, 6, 7, 8}; + FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(buf, 0, 8); + assertTrue(view.supportsLong()); + long expected = 0x0807060504030201L; // byte 0 -> least significant byte + assertEquals(expected, view.longAt(0)); + } + + @Test + void longAt_respectsViewOffset_notJustArrayOffset() { + byte[] buf = {(byte) 0xFF, (byte) 0xFF, 1, 2, 3, 4, 5, 6, 7, 8, (byte) 0xFF}; + FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(buf, 2, 8); + long expected = 0x0807060504030201L; + assertEquals(expected, view.longAt(0)); + } + + // ── Cross-checked against fpr-core's own ByteCompare, the actual consumer of longAt() ── + + @Test + void byteCompareEquals_agreesBetweenLongPathAndByteAtATimePath_onIdenticalContent() { + byte[] content = "GET/users/1234567890/profile".getBytes(StandardCharsets.US_ASCII); + FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(content, 0, content.length); + byte[] other = content.clone(); + + assertTrue(ByteCompare.equals(view, 0, other, 0, content.length, true)); + assertTrue(ByteCompare.equals(view, 0, other, 0, content.length, false)); + } + + @Test + void byteCompareEquals_agreesBetweenLongPathAndByteAtATimePath_onDivergingContent() { + // Diverge at every position across an 8+-byte range, including inside a word, at a word + // boundary, and in the scalar tail — a wrong longAt() would only show up at some of these. + byte[] base = "abcdefghijklmnopqrstuvwxyz012345".getBytes(StandardCharsets.US_ASCII); + FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(base, 0, base.length); + for (int diffAt = 0; diffAt < base.length; diffAt++) { + byte[] other = base.clone(); + other[diffAt] = (byte) (other[diffAt] + 1); + boolean withLong = ByteCompare.equals(view, 0, other, 0, base.length, true); + boolean withoutLong = ByteCompare.equals(view, 0, other, 0, base.length, false); + assertFalse(withLong, "long path failed to detect divergence at " + diffAt); + assertEquals(withoutLong, withLong, "long/byte-at-a-time paths disagree at diffAt=" + diffAt); + } + } + + @Test + void byteCompareIndexOf_agreesBetweenLongPathAndByteAtATimePath() { + byte[] haystack = "xxxxxxxxxxxxxxxxxTARGETxxxxxxxxxxxxxxxxxx".getBytes(StandardCharsets.US_ASCII); + byte[] needle = "TARGET".getBytes(StandardCharsets.US_ASCII); + FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(haystack, 0, haystack.length); + + int withLong = ByteCompare.indexOf(view, 0, haystack.length, needle, 0, needle.length, true); + int withoutLong = ByteCompare.indexOf(view, 0, haystack.length, needle, 0, needle.length, false); + assertEquals(withoutLong, withLong); + assertTrue(withLong >= 0); + } + + // ── End-to-end: a real router, literal routes >= 8 bytes, long path actually engaged ──── + + @Test + void router_matchesCorrectly_withLongLiteralSegmentsAndTheLongPathEnabled() { + RouterBuilder builder = new RouterBuilder<>(); + builder.add(StringRouteParser.parse("GET/aaaaaaaaaaaaaaaaaaaa"), "route-a"); + builder.add(StringRouteParser.parse("GET/bbbbbbbbbbbbbbbbbbbb"), "route-b"); + builder.add(StringRouteParser.parse("GET/aaaaaaaaaaaaaaaaaaab"), "route-a-near-miss"); + FastPathRouter router = builder.compile(); + + assertEquals("route-a", matchOne(router, "GET", "/aaaaaaaaaaaaaaaaaaaa")); + assertEquals("route-b", matchOne(router, "GET", "/bbbbbbbbbbbbbbbbbbbb")); + // Differs only in the very last byte — must not be conflated with route-a by a + // word-at-a-time comparison that got the tail handling wrong. + assertEquals("route-a-near-miss", matchOne(router, "GET", "/aaaaaaaaaaaaaaaaaaab")); + assertNull(matchOne(router, "GET", "/aaaaaaaaaaaaaaaaaaac")); + assertNull(matchOne(router, "GET", "/ccccccccccccccccccccc")); + } + + private static String matchOne(FastPathRouter router, + String method, String path) { + byte[] methodBytes = method.getBytes(StandardCharsets.US_ASCII); + FastPathViews.RequestByteView pathView = + new FastPathViews.RequestByteView(path.getBytes(StandardCharsets.US_ASCII), 0, path.length()); + FastPathViews.MethodPathByteView combined = new FastPathViews.MethodPathByteView(); + combined.reset(methodBytes, pathView); + + MatchResult result = new MatchResult<>(8, 32); + int labelId = router.match(combined, result); + return labelId == FastPathRouter.NO_MATCH ? null : result.handler(); + } +} diff --git a/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViewsTest.java b/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViewsTest.java index f8b7093..e5256be 100644 --- a/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViewsTest.java +++ b/flash/src/test/java/dev/relism/flash/routing/routers/fastpathrouter/FastPathViewsTest.java @@ -28,6 +28,30 @@ class FastPathViewsTest { assertThrows(IndexOutOfBoundsException.class, () -> view.byteAt(10)); } + + @Test + void requestByteView_reset_repositionsSameInstance() { + FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 4, 10); + assertEquals("/api/users", view.toString()); + + byte[] other = "PUT /orders/9 HTTP/1.1".getBytes(StandardCharsets.UTF_8); + view.reset(other, 4, 8); + assertEquals(8, view.length()); + assertEquals("/orders/", view.toString()); + } + + @Test + void requestByteView_reset_updatesArrayBackedByteViewAccessors() { + FastPathViews.RequestByteView view = new FastPathViews.RequestByteView(SHARED_BUFFER, 0, 3); + byte[] other = "zzHELLOzz".getBytes(StandardCharsets.UTF_8); + view.reset(other, 2, 5); + + assertSame(other, view.array()); + assertEquals(2, view.offset()); + assertEquals(5, view.length()); + assertEquals("HELLO", view.toString()); + } + // --- MethodPathByteView --- @Test diff --git a/flash/src/test/java/dev/relism/flash/template/ByteTemplateTest.java b/flash/src/test/java/dev/relism/flash/template/ByteTemplateTest.java index 1fb3070..62e16e4 100644 --- a/flash/src/test/java/dev/relism/flash/template/ByteTemplateTest.java +++ b/flash/src/test/java/dev/relism/flash/template/ByteTemplateTest.java @@ -55,4 +55,37 @@ class ByteTemplateTest { byte[] result = tpl.render("v1", "1", "v2", "2"); assertEquals("A12B", new String(result, StandardCharsets.UTF_8)); } + + + @Test + void renderInto_writesAtOffset_andReturnsLength() { + ByteTemplate tpl = new ByteTemplate("Hello {{name}}!"); + byte[] buffer = new byte[64]; + int len = tpl.renderInto(buffer, 5, "name", "World"); + + assertEquals("Hello World!".length(), len); + assertEquals("Hello World!", new String(buffer, 5, len, StandardCharsets.UTF_8)); + } + + @Test + void renderInto_repeatedPlaceholder_fillsEveryOccurrence() { + ByteTemplate tpl = new ByteTemplate("{{var}} == {{var}}"); + byte[] buffer = new byte[32]; + int len = tpl.renderInto(buffer, 0, "var", "test"); + assertEquals("test == test", new String(buffer, 0, len, StandardCharsets.UTF_8)); + } + + @Test + void renderInto_bufferTooSmall_throws() { + ByteTemplate tpl = new ByteTemplate("Hello {{name}}!"); + byte[] buffer = new byte[5]; + assertThrows(IndexOutOfBoundsException.class, () -> tpl.renderInto(buffer, 0, "name", "World")); + } + + @Test + void renderInto_negativeOffset_throws() { + ByteTemplate tpl = new ByteTemplate("Hi {{name}}"); + byte[] buffer = new byte[32]; + assertThrows(IndexOutOfBoundsException.class, () -> tpl.renderInto(buffer, -1, "name", "X")); + } } diff --git a/flash/src/test/java/dev/relism/flash/template/ErrorPagesTest.java b/flash/src/test/java/dev/relism/flash/template/ErrorPagesTest.java index 36f5876..ae52a6c 100644 --- a/flash/src/test/java/dev/relism/flash/template/ErrorPagesTest.java +++ b/flash/src/test/java/dev/relism/flash/template/ErrorPagesTest.java @@ -1,7 +1,7 @@ package dev.relism.flash.template; import dev.relism.flash.http.HttpMethod; -import dev.relism.flash.models.HeaderMap; +import dev.relism.flash.models.Http1HeaderMap; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestLine; import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews; @@ -24,7 +24,7 @@ class ErrorPagesTest { byte[] protoBytes = protocol.getBytes(StandardCharsets.UTF_8); FastPathViews.RequestByteView protoView = new FastPathViews.RequestByteView(protoBytes, 0, protoBytes.length); - RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new HeaderMap()); + RequestLine line = new RequestLine(HttpMethod.GET, pathView, null, protoView, new Http1HeaderMap()); return new Request(line, new byte[0]); } diff --git a/flash/src/test/java/dev/relism/flash/testing/FuzzMemory.java b/flash/src/test/java/dev/relism/flash/testing/FuzzMemory.java new file mode 100644 index 0000000..c640fc8 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/testing/FuzzMemory.java @@ -0,0 +1,22 @@ +package dev.relism.flash.testing; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Retained-heap assertion shared by deterministic hostile-input tests. */ +public final class FuzzMemory { + private FuzzMemory() {} + + public static long snapshot() { + System.gc(); + System.gc(); + Runtime runtime = Runtime.getRuntime(); + return runtime.totalMemory() - runtime.freeMemory(); + } + + public static void assertGrowthBelow(long baseline, long maximumBytes) { + long growth = Math.max(0, snapshot() - baseline); + assertTrue( + growth <= maximumBytes, + () -> "fuzz target retained " + growth + " bytes; limit is " + maximumBytes); + } +} diff --git a/flash/src/test/java/dev/relism/flash/tls/TlsConfigTest.java b/flash/src/test/java/dev/relism/flash/tls/TlsConfigTest.java index 9fbd56a..5c3cb60 100644 --- a/flash/src/test/java/dev/relism/flash/tls/TlsConfigTest.java +++ b/flash/src/test/java/dev/relism/flash/tls/TlsConfigTest.java @@ -1,123 +1,213 @@ package dev.relism.flash.tls; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import javax.net.ssl.SSLContext; -import javax.net.ssl.SSLParameters; -import javax.net.ssl.SSLServerSocket; +import static org.junit.jupiter.api.Assertions.*; import java.io.IOException; import java.nio.file.Path; import java.util.Arrays; import java.util.List; - -import static org.junit.jupiter.api.Assertions.*; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLParameters; +import javax.net.ssl.SSLServerSocket; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class TlsConfigTest { - private static SSLServerSocket unboundSocket(TlsConfig tls) throws IOException { - return (SSLServerSocket) tls.serverSocketFactory().createServerSocket(); + private static SSLServerSocket unboundSocket(TlsConfig tls) throws IOException { + return (SSLServerSocket) tls.serverSocketFactory().createServerSocket(); + } + + @Test + void keystore_pinsProtocolsToTls12And13(@TempDir Path dir) throws Exception { + Path ks = + TestKeystores.build( + dir, "id.p12", "changeit", TestKeystores.Entry.of("only", "single.test")); + TlsConfig tls = TlsConfig.keystore(ks, "changeit"); + + try (SSLServerSocket socket = unboundSocket(tls)) { + tls.applyTo(socket); + List protocols = Arrays.asList(socket.getSSLParameters().getProtocols()); + assertTrue(protocols.contains("TLSv1.2")); + assertTrue(protocols.contains("TLSv1.3")); + assertFalse(protocols.contains("SSLv3")); + assertFalse(protocols.contains("TLSv1")); + assertFalse(protocols.contains("TLSv1.1")); } + } - @Test - void keystore_pinsProtocolsToTls12And13(@TempDir Path dir) throws Exception { - Path ks = TestKeystores.build(dir, "id.p12", "changeit", - TestKeystores.Entry.of("only", "single.test")); - TlsConfig tls = TlsConfig.keystore(ks, "changeit"); + @Test + void ofContext_appliesNoParameterOverlay() throws Exception { + SSLContext ctx = TestKeystores.trustAllClientContext(); // any valid SSLContext will do here + TlsConfig tls = TlsConfig.ofContext(ctx); - try (SSLServerSocket socket = unboundSocket(tls)) { - tls.applyTo(socket); - List protocols = Arrays.asList(socket.getSSLParameters().getProtocols()); - assertTrue(protocols.contains("TLSv1.2")); - assertTrue(protocols.contains("TLSv1.3")); - assertFalse(protocols.contains("SSLv3")); - assertFalse(protocols.contains("TLSv1")); - assertFalse(protocols.contains("TLSv1.1")); - } + try (SSLServerSocket socket = unboundSocket(tls)) { + SSLParameters before = socket.getSSLParameters(); + String[] protocolsBefore = before.getProtocols(); + + tls.applyTo(socket); + + assertArrayEquals( + protocolsBefore, + socket.getSSLParameters().getProtocols(), + "ofContext must not narrow/override protocols set on the caller's SSLContext"); + assertFalse(socket.getNeedClientAuth()); + assertFalse(socket.getWantClientAuth()); } + } - @Test - void ofContext_appliesNoParameterOverlay() throws Exception { - SSLContext ctx = TestKeystores.trustAllClientContext(); // any valid SSLContext will do here - TlsConfig tls = TlsConfig.ofContext(ctx); + @Test + void ofContext_preservesCallerConfiguredAlpnAndProtocols() throws Exception { + // Mirrors the real use case this escape hatch exists for (TLS-ALPN-01 / RFC 8737): + // the caller sets its own ALPN protocol list — and, to make the point unambiguous, + // a protocol list *narrower* than what Flash's own keystore() path would pin — directly + // on the socket. applyTo() must not touch either. There is no SSLContext#setDefault- + // SSLParameters in the public JSSE API, so this per-socket SSLParameters is the only + // place such configuration can live; this test is the contract that makes it safe to + // rely on. + SSLContext ctx = TestKeystores.trustAllClientContext(); + TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL); - try (SSLServerSocket socket = unboundSocket(tls)) { - SSLParameters before = socket.getSSLParameters(); - String[] protocolsBefore = before.getProtocols(); + try (SSLServerSocket socket = unboundSocket(tls)) { + SSLParameters custom = socket.getSSLParameters(); + custom.setApplicationProtocols(new String[] {"acme-tls/1", "http/1.1"}); + custom.setProtocols(new String[] {"TLSv1.3"}); + socket.setSSLParameters(custom); - tls.applyTo(socket); + tls.applyTo(socket); - assertArrayEquals(protocolsBefore, socket.getSSLParameters().getProtocols(), - "ofContext must not narrow/override protocols set on the caller's SSLContext"); - assertFalse(socket.getNeedClientAuth()); - assertFalse(socket.getWantClientAuth()); - } + SSLParameters after = socket.getSSLParameters(); + assertArrayEquals( + new String[] {"acme-tls/1", "http/1.1"}, + after.getApplicationProtocols(), + "ofContext must not touch ALPN protocols the caller configured on its own socket"); + assertArrayEquals( + new String[] {"TLSv1.3"}, + after.getProtocols(), + "ofContext must not widen/override the caller's own protocol list"); + // clientAuth still applies — it is the caller's own explicit instruction through + // this API, not a Flash-imposed default. See TlsConfig's class Javadoc. + assertTrue(socket.getWantClientAuth()); } + } - @Test - void ofContext_preservesCallerConfiguredAlpnAndProtocols() throws Exception { - // Mirrors the real use case this escape hatch exists for (TLS-ALPN-01 / RFC 8737): - // the caller sets its own ALPN protocol list — and, to make the point unambiguous, - // a protocol list *narrower* than what Flash's own keystore() path would pin — directly - // on the socket. applyTo() must not touch either. There is no SSLContext#setDefault- - // SSLParameters in the public JSSE API, so this per-socket SSLParameters is the only - // place such configuration can live; this test is the contract that makes it safe to - // rely on. - SSLContext ctx = TestKeystores.trustAllClientContext(); - TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL); + @Test + void clientAuth_none_makesNoClientAuthCall() throws Exception { + SSLContext ctx = TestKeystores.trustAllClientContext(); + TlsConfig tls = TlsConfig.ofContext(ctx); - try (SSLServerSocket socket = unboundSocket(tls)) { - SSLParameters custom = socket.getSSLParameters(); - custom.setApplicationProtocols(new String[] { "acme-tls/1", "http/1.1" }); - custom.setProtocols(new String[] { "TLSv1.3" }); - socket.setSSLParameters(custom); - - tls.applyTo(socket); - - SSLParameters after = socket.getSSLParameters(); - assertArrayEquals(new String[] { "acme-tls/1", "http/1.1" }, after.getApplicationProtocols(), - "ofContext must not touch ALPN protocols the caller configured on its own socket"); - assertArrayEquals(new String[] { "TLSv1.3" }, after.getProtocols(), - "ofContext must not widen/override the caller's own protocol list"); - // clientAuth still applies — it is the caller's own explicit instruction through - // this API, not a Flash-imposed default. See TlsConfig's class Javadoc. - assertTrue(socket.getWantClientAuth()); - } + try (SSLServerSocket socket = unboundSocket(tls)) { + tls.applyTo(socket); + assertFalse(socket.getNeedClientAuth()); + assertFalse(socket.getWantClientAuth()); } + } - @Test - void clientAuth_none_makesNoClientAuthCall() throws Exception { - SSLContext ctx = TestKeystores.trustAllClientContext(); - TlsConfig tls = TlsConfig.ofContext(ctx); + @Test + void clientAuth_require_setsNeedClientAuth() throws Exception { + SSLContext ctx = TestKeystores.trustAllClientContext(); + TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.REQUIRE); - try (SSLServerSocket socket = unboundSocket(tls)) { - tls.applyTo(socket); - assertFalse(socket.getNeedClientAuth()); - assertFalse(socket.getWantClientAuth()); - } + try (SSLServerSocket socket = unboundSocket(tls)) { + tls.applyTo(socket); + assertTrue(socket.getNeedClientAuth()); } + } - @Test - void clientAuth_require_setsNeedClientAuth() throws Exception { - SSLContext ctx = TestKeystores.trustAllClientContext(); - TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.REQUIRE); + @Test + void clientAuth_optional_setsWantClientAuth() throws Exception { + SSLContext ctx = TestKeystores.trustAllClientContext(); + TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL); - try (SSLServerSocket socket = unboundSocket(tls)) { - tls.applyTo(socket); - assertTrue(socket.getNeedClientAuth()); - } + try (SSLServerSocket socket = unboundSocket(tls)) { + tls.applyTo(socket); + assertTrue(socket.getWantClientAuth()); + assertFalse(socket.getNeedClientAuth()); } + } - @Test - void clientAuth_optional_setsWantClientAuth() throws Exception { - SSLContext ctx = TestKeystores.trustAllClientContext(); - TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL); + @Test + void negotiatesH2_trueOnlyWhenH2IsInTheOfferedList() throws Exception { + SSLContext ctx = TestKeystores.trustAllClientContext(); + assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1").negotiatesH2()); + assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2").negotiatesH2()); + assertFalse(TlsConfig.ofContext(ctx).applicationProtocols("http/1.1").negotiatesH2()); + assertFalse(TlsConfig.ofContext(ctx).negotiatesH2()); // no applicationProtocols call at all + } - try (SSLServerSocket socket = unboundSocket(tls)) { - tls.applyTo(socket); - assertTrue(socket.getWantClientAuth()); - assertFalse(socket.getNeedClientAuth()); - } + @Test + void applyTo_withH2Offered_removesBlockedTls12CipherSuites() throws Exception { + SSLContext ctx = TestKeystores.trustAllClientContext(); + TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1"); + + try (SSLServerSocket socket = unboundSocket(tls)) { + tls.applyTo(socket); + List enabled = Arrays.asList(socket.getEnabledCipherSuites()); + // Spot-check a handful of RFC 9113 Appendix A entries across different families + // (RSA key exchange, 3DES, plain ECDHE-CBC) rather than the full ~280-entry list — + // TLS12_H2_BLOCKED_CIPHERS itself is the source of truth for the complete set. + assertFalse(enabled.contains("TLS_RSA_WITH_AES_128_CBC_SHA")); + assertFalse(enabled.contains("TLS_RSA_WITH_3DES_EDE_CBC_SHA")); + assertFalse(enabled.contains("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA")); + assertFalse(enabled.contains("TLS_NULL_WITH_NULL_NULL")); } + } + + @Test + void applyTo_withH2Offered_keepsTheRequiredCipherSuiteWhenTheJdkEnabledIt() throws Exception { + SSLContext ctx = TestKeystores.trustAllClientContext(); + TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2"); + + try (SSLServerSocket socket = unboundSocket(tls)) { + boolean jdkEnabledItByDefault = + Arrays.asList(socket.getEnabledCipherSuites()) + .contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE); + tls.applyTo(socket); + if (jdkEnabledItByDefault) { + assertTrue( + Arrays.asList(socket.getEnabledCipherSuites()) + .contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE), + "RFC 9113 §9.2.2 requires supporting this suite — filtering must never remove it"); + } + } + } + + @Test + void applyTo_withoutH2Offered_leavesCipherSuitesUntouched() throws Exception { + SSLContext ctx = TestKeystores.trustAllClientContext(); + TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("http/1.1"); + + try (SSLServerSocket socket = unboundSocket(tls)) { + List before = Arrays.asList(socket.getEnabledCipherSuites()); + tls.applyTo(socket); + assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites())); + } + } + + @Test + void applyTo_noApplicationProtocolsAtAll_leavesCipherSuitesUntouched() throws Exception { + SSLContext ctx = TestKeystores.trustAllClientContext(); + TlsConfig tls = TlsConfig.ofContext(ctx); + + try (SSLServerSocket socket = unboundSocket(tls)) { + List before = Arrays.asList(socket.getEnabledCipherSuites()); + tls.applyTo(socket); + assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites())); + } + } + + @Test + void enableHttp2AlpnPreservesCustomPriorityAndRetainsHttp1Fallback() throws Exception { + SSLContext ctx = SSLContext.getDefault(); + TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("acme-tls/1"); + SSLServerSocket socket = + (SSLServerSocket) tls.enableHttp2Alpn().serverSocketFactory().createServerSocket(); + + tls.enableHttp2Alpn().applyTo(socket); + + assertArrayEquals( + new String[] {"acme-tls/1", "h2", "http/1.1"}, + socket.getSSLParameters().getApplicationProtocols()); + socket.close(); + } } diff --git a/flash/src/test/java/dev/relism/flash/transport/BufferedByteSourceTest.java b/flash/src/test/java/dev/relism/flash/transport/BufferedByteSourceTest.java new file mode 100644 index 0000000..b546e3c --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/transport/BufferedByteSourceTest.java @@ -0,0 +1,160 @@ +package dev.relism.flash.transport; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * end-to-end tests, which never hit the {@code null}-socket path every isolated unit test in + * this codebase actually uses. Found and fixed while building {@code Http2FrameReaderTest} + */ +class BufferedByteSourceTest { + + private static BufferedByteSource sourceOf(String s) { + return new BufferedByteSource(new ByteArrayInputStream(s.getBytes(StandardCharsets.US_ASCII)), null); + } + + // ── Plain InputStream passthrough ─────────────────────────────────────── + + @Test + void read_singleByte() throws IOException { + BufferedByteSource src = sourceOf("AB"); + assertEquals('A', src.read()); + assertEquals('B', src.read()); + assertEquals(-1, src.read()); + } + + @Test + void read_intoArray() throws IOException { + BufferedByteSource src = sourceOf("hello world"); + byte[] buf = new byte[5]; + int n = src.read(buf, 0, 5); + assertEquals(5, n); + assertEquals("hello", new String(buf, StandardCharsets.US_ASCII)); + } + + @Test + void read_largerThanInternalBuffer_bypassesBufferCorrectly() throws IOException { + String big = "x".repeat(20_000); + BufferedByteSource src = new BufferedByteSource( + new ByteArrayInputStream(big.getBytes(StandardCharsets.US_ASCII)), null, 4096); + byte[] out = new byte[20_000]; + int total = 0; + while (total < out.length) { + int n = src.read(out, total, out.length - total); + if (n < 0) break; + total += n; + } + assertEquals(20_000, total); + } + + // ── peek / prependOnce ─────────────────────────────────────────────────── + + @Test + void peek_doesNotConsume() throws IOException { + BufferedByteSource src = sourceOf("abcdef"); + byte[] dst = new byte[3]; + int n = src.peek(dst, 0, 3); + assertEquals(3, n); + assertEquals("abc", new String(dst, StandardCharsets.US_ASCII)); + // Still readable from the start — peek must not have advanced the position. + assertEquals('a', src.read()); + assertEquals('b', src.read()); + } + + @Test + void peek_rejectsLengthAboveBufferCapacity() { + BufferedByteSource src = new BufferedByteSource(new ByteArrayInputStream(new byte[0]), null, 16); + assertThrows(IllegalArgumentException.class, () -> src.peek(new byte[20], 0, 20)); + } + + @Test + void prependOnce_servedBeforeUnderlyingBytes() throws IOException { + BufferedByteSource src = sourceOf("world"); + byte[] prefix = "hello ".getBytes(StandardCharsets.US_ASCII); + src.prependOnce(prefix, 0, prefix.length); + + byte[] out = new byte[11]; + int total = 0; + while (total < out.length) { + int n = src.read(out, total, out.length - total); + if (n < 0) break; + total += n; + } + assertEquals("hello world", new String(out, 0, total, StandardCharsets.US_ASCII)); + } + + @Test + void prependOnce_rejectsSecondCallBeforeFirstIsConsumed() { + BufferedByteSource src = sourceOf("x"); + byte[] a = "a".getBytes(StandardCharsets.US_ASCII); + src.prependOnce(a, 0, 1); + assertThrows(IllegalStateException.class, () -> src.prependOnce(a, 0, 1)); + } + + + @Test + void clearDeadline_withNullSocket_doesNotThrow() throws IOException { + BufferedByteSource src = sourceOf("data"); + src.setDeadline(System.nanoTime() + 1_000_000_000L); + assertDoesNotThrow(src::clearDeadline); + } + + @Test + void deadlineAlreadyExpired_throwsSocketTimeoutException_evenWithNullSocket() { + BufferedByteSource src = sourceOf(""); // empty: forces fillFromUnderlying on the next read + src.setDeadline(System.nanoTime() - 1_000_000_000L); // already in the past + assertThrows(SocketTimeoutException.class, () -> src.read(new byte[1], 0, 1)); + } + + @Test + void deadlineNotYetExpired_readsNormally_withNullSocket() throws IOException { + BufferedByteSource src = sourceOf("z"); + src.setDeadline(System.nanoTime() + 30_000_000_000L); // 30s in the future + assertEquals('z', src.read()); + } + + @Test + void bytesAlreadyBuffered_areServedRegardlessOfDeadline() throws IOException { + // peek() fills the internal buffer without a deadline; a since-expired deadline must not + // block already-buffered bytes from being read (only underlying-stream reads are bounded). + BufferedByteSource src = sourceOf("buffered"); + src.peek(new byte[8], 0, 8); + src.setDeadline(System.nanoTime() - 1); // already expired + assertEquals('b', src.read()); // served from the buffer — no underlying read needed + } + + @Test + void clearDeadline_thenRead_neverThrowsTimeoutAfterward() throws IOException { + BufferedByteSource src = sourceOf("ok"); + src.setDeadline(System.nanoTime() - 1); // expired + src.clearDeadline(); + assertEquals('o', src.read()); // deadline cleared — must not time out + } + + // ── available / skip / close ───────────────────────────────────────────── + + @Test + void skip_advancesPastBufferedAndUnderlyingBytes() throws IOException { + BufferedByteSource src = sourceOf("abcdef"); + long skipped = src.skip(3); + assertEquals(3, skipped); + assertEquals('d', src.read()); + } + + @Test + void close_delegatesToUnderlyingStream() { + java.io.InputStream[] closed = new java.io.InputStream[1]; + java.io.InputStream in = new ByteArrayInputStream(new byte[0]) { + @Override public void close() throws IOException { closed[0] = this; super.close(); } + }; + BufferedByteSource src = new BufferedByteSource(in, null); + assertDoesNotThrow(src::close); + assertSame(in, closed[0]); + } +} diff --git a/flash/src/test/java/dev/relism/flash/transport/ConnectionRunnerTest.java b/flash/src/test/java/dev/relism/flash/transport/ConnectionRunnerTest.java new file mode 100644 index 0000000..e38f71c --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/transport/ConnectionRunnerTest.java @@ -0,0 +1,140 @@ +package dev.relism.flash.transport; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.relism.flash.extension.FlashConfiguration; +import dev.relism.flash.routing.AbstractRouter; +import dev.relism.flash.routing.AbstractWsRouter; +import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl; +import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; + +/** + * A scratch is always released — including on an exception path — and a socket is always removed + * from {@code activeSockets}, regardless of how the dispatched checks list), verified here with a + * protocol implementation that deliberately throws. + */ +class ConnectionRunnerTest { + + @Test + void scratchAndActiveSocketEntry_alwaysReleased_evenWhenTheProtocolThrows() throws Exception { + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + Set activeSockets = ConcurrentHashMap.newKeySet(); + ScratchPool scratchPool = new ScratchPool(); + AbstractRouter router = new FastPathRouterImpl(); + AbstractWsRouter wsRouter = new FastPathWsRouterImpl(); + FlashConfiguration configuration = FlashConfiguration.builder().port(0).build(); + + ConnectionProtocol throwingProtocol = + ctx -> { + throw new IOException("simulated protocol failure"); + }; + + ConnectionRunner runner = + new ConnectionRunner( + executor, + activeSockets, + scratchPool, + router, + wsRouter, + configuration, + throwingProtocol, + () -> throwingProtocol); + + try (ServerSocket serverSocket = new ServerSocket(0)) { + int port = serverSocket.getLocalPort(); + CountDownLatch accepted = new CountDownLatch(1); + + Thread acceptThread = + new Thread( + () -> { + try (Socket serverSide = serverSocket.accept()) { + runner.accept(serverSide, () -> false); + accepted.countDown(); + Thread.sleep(300); // give the submitted virtual-thread task time to run + } catch (Exception ignored) { + } + }); + acceptThread.start(); + + try (Socket client = new Socket("127.0.0.1", port)) { + assertTrue(accepted.await(2, TimeUnit.SECONDS)); + Thread.sleep(300); // let ConnectionRunner's virtual thread finish + + assertTrue( + activeSockets.isEmpty(), + "socket must be removed from activeSockets on every exit path"); + } + acceptThread.join(2000); + } finally { + executor.shutdownNow(); + } + } + + @Test + void connectionsBeyondMaxConnections_areClosedImmediately_beforeAnyProtocolWork() + throws Exception { + ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor(); + Set activeSockets = ConcurrentHashMap.newKeySet(); + ScratchPool scratchPool = new ScratchPool(); + AbstractRouter router = new FastPathRouterImpl(); + AbstractWsRouter wsRouter = new FastPathWsRouterImpl(); + FlashConfiguration configuration = + FlashConfiguration.builder().port(0).maxConnections(1).build(); + + AtomicInteger protocolInvocations = new AtomicInteger(); + ConnectionProtocol countingProtocol = + ctx -> { + protocolInvocations.incrementAndGet(); + throw new IOException("simulated protocol failure"); + }; + + ConnectionRunner runner = + new ConnectionRunner( + executor, + activeSockets, + scratchPool, + router, + wsRouter, + configuration, + countingProtocol, + () -> countingProtocol); + + try (ServerSocket serverSocket = new ServerSocket(0)) { + int port = serverSocket.getLocalPort(); + + // First connection: admitted (activeSockets is empty, limit is 1). Held open by never + // closing the client socket, so it still counts toward the limit for the second attempt. + Socket firstClient = new Socket("127.0.0.1", port); + Socket firstServerSide = serverSocket.accept(); + activeSockets.add(firstServerSide); // simulate an in-flight, still-admitted connection + + // Second connection: activeSockets.size() (1) >= maxConnections (1) -> must be + // rejected at accept() time, before the executor or protocol ever run. + try (Socket secondClient = new Socket("127.0.0.1", port); + Socket secondServerSide = serverSocket.accept()) { + runner.accept(secondServerSide, () -> false); + Thread.sleep(300); // give any (incorrectly) submitted virtual-thread task time to run + + assertEquals(0, protocolInvocations.get(), "rejected connection must not reach the protocol"); + assertEquals(1, activeSockets.size(), "rejected connection must not be added to activeSockets"); + assertTrue(secondServerSide.isClosed(), "rejected connection's socket must be closed"); + } finally { + firstServerSide.close(); + firstClient.close(); + } + } finally { + executor.shutdownNow(); + } + } +} diff --git a/flash/src/test/java/dev/relism/flash/transport/ProtocolNegotiatorTest.java b/flash/src/test/java/dev/relism/flash/transport/ProtocolNegotiatorTest.java new file mode 100644 index 0000000..8fe0ec2 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/transport/ProtocolNegotiatorTest.java @@ -0,0 +1,153 @@ +package dev.relism.flash.transport; + +import dev.relism.flash.tls.TestKeystores; +import dev.relism.flash.tls.TlsConfig; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.SSLSocketFactory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.concurrent.CompletableFuture; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * {@link ProtocolNegotiator#negotiate} is a pure, directly-testable detector (see its Javadoc + * for why it does not itself consult {@code FlashConfiguration}) — every case here + * calls it directly rather than through {@code Http1Connection}/{@code ConnectionRunner}. + */ +class ProtocolNegotiatorTest { + + // ── Plaintext (h2c prior knowledge) — no real networking needed ──────────── + + private static BufferedByteSource plaintextSource(String bytes) { + return new BufferedByteSource( + new ByteArrayInputStream(bytes.getBytes(StandardCharsets.US_ASCII)), new Socket()); + } + + @Test + void h2cPrefaceExact_negotiatesH2() throws IOException { + BufferedByteSource src = plaintextSource("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"); + assertEquals(NegotiatedProtocol.HTTP_2, ProtocolNegotiator.negotiate(new Socket(), src)); + } + + @Test + void h2cPrefaceFollowedByMoreData_stillNegotiatesH2_andDoesNotConsume() throws IOException { + BufferedByteSource src = plaintextSource("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\nEXTRA"); + assertEquals(NegotiatedProtocol.HTTP_2, ProtocolNegotiator.negotiate(new Socket(), src)); + // peek() must not have consumed anything — the full 24-byte preface is still there for + // whatever reads next (Http2Connection, once it exists). + byte[] readBack = new byte[24]; + assertEquals(24, src.read(readBack, 0, 24)); + assertEquals("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n", + new String(readBack, StandardCharsets.US_ASCII)); + } + + @Test + void partialPreface_thenEof_negotiatesHttp1_notConsumed() throws IOException { + // Fewer than 24 bytes total, then EOF — not a match, and RequestParser must still see + // every byte that was actually sent. + BufferedByteSource src = plaintextSource("PRI * HTTP/2.0\r\n"); + assertEquals(NegotiatedProtocol.HTTP_1_1, ProtocolNegotiator.negotiate(new Socket(), src)); + byte[] readBack = new byte[16]; + assertEquals(16, src.read(readBack, 0, 16)); + assertEquals("PRI * HTTP/2.0\r\n", new String(readBack, StandardCharsets.US_ASCII)); + } + + @Test + void prefaceLookalike_divergesPartway_negotiatesHttp1() throws IOException { + // "PRI " matches, then diverges — must not be misdetected as h2c. + BufferedByteSource src = plaintextSource("PRI * HTTP/9.9\r\n\r\nXX\r\n\r\n"); + assertEquals(NegotiatedProtocol.HTTP_1_1, ProtocolNegotiator.negotiate(new Socket(), src)); + } + + @Test + void plainGetRequest_negotiatesHttp1() throws IOException { + BufferedByteSource src = plaintextSource("GET / HTTP/1.1\r\nHost: localhost\r\n\r\n"); + assertEquals(NegotiatedProtocol.HTTP_1_1, ProtocolNegotiator.negotiate(new Socket(), src)); + byte[] readBack = new byte[15]; + assertEquals(15, src.read(readBack, 0, 15)); + assertEquals("GET / HTTP/1.1\r", new String(readBack, StandardCharsets.US_ASCII)); + } + + // ── TLS/ALPN — a real loopback handshake, since ALPN is resolved during it ─ + + private interface ThrowingConsumer { void accept(T t) throws Exception; } + + /** + * Binds a real TLS listener offering {@code serverAlpn}, connects a client offering + * {@code clientAlpn}, forces the handshake on both sides (mirroring {@code Http1Connection}/{@code ConnectionRunner}'s + */ + private static void withNegotiatedAlpn(Path dir, String[] serverAlpn, String[] clientAlpn, + ThrowingConsumer assertion) throws Exception { + Path ks = TestKeystores.build(dir, "negotiator.p12", "changeit", + TestKeystores.Entry.of("only", "negotiator.test")); + TlsConfig serverTls = TlsConfig.keystore(ks, "changeit"); + if (serverAlpn != null) serverTls = serverTls.applicationProtocols(serverAlpn); + + try (SSLServerSocket serverSocket = (SSLServerSocket) serverTls.serverSocketFactory().createServerSocket()) { + serverTls.applyTo(serverSocket); + serverSocket.bind(new InetSocketAddress("127.0.0.1", 0)); + int port = serverSocket.getLocalPort(); + + CompletableFuture accepted = CompletableFuture.supplyAsync(() -> { + try { + SSLSocket s = (SSLSocket) serverSocket.accept(); + s.startHandshake(); + return s; + } catch (IOException e) { + throw new RuntimeException(e); + } + }); + + SSLSocketFactory clientFactory = TestKeystores.trustAllClientContext().getSocketFactory(); + try (SSLSocket client = (SSLSocket) clientFactory.createSocket("127.0.0.1", port)) { + if (clientAlpn != null) { + javax.net.ssl.SSLParameters params = client.getSSLParameters(); + params.setApplicationProtocols(clientAlpn); + client.setSSLParameters(params); + } + client.startHandshake(); + + try (SSLSocket server = accepted.get()) { + assertion.accept(server); + } + } + } + } + + @Test + void alpnH2_negotiatesH2(@TempDir Path dir) throws Exception { + withNegotiatedAlpn(dir, new String[]{"h2", "http/1.1"}, new String[]{"h2", "http/1.1"}, server -> { + assertEquals("h2", server.getApplicationProtocol()); + assertEquals(NegotiatedProtocol.HTTP_2, + ProtocolNegotiator.negotiate(server, new BufferedByteSource(server.getInputStream(), server))); + }); + } + + @Test + void alpnHttp11_negotiatesHttp1(@TempDir Path dir) throws Exception { + withNegotiatedAlpn(dir, new String[]{"h2", "http/1.1"}, new String[]{"http/1.1"}, server -> { + assertEquals("http/1.1", server.getApplicationProtocol()); + assertEquals(NegotiatedProtocol.HTTP_1_1, + ProtocolNegotiator.negotiate(server, new BufferedByteSource(server.getInputStream(), server))); + }); + } + + @Test + void alpnAbsent_negotiatesHttp1(@TempDir Path dir) throws Exception { + // Neither side offers ALPN at all — the common case today. + withNegotiatedAlpn(dir, null, null, server -> { + assertEquals(NegotiatedProtocol.HTTP_1_1, + ProtocolNegotiator.negotiate(server, new BufferedByteSource(server.getInputStream(), server))); + }); + } +} diff --git a/flash/src/test/java/dev/relism/flash/transport/ScratchPoolTest.java b/flash/src/test/java/dev/relism/flash/transport/ScratchPoolTest.java new file mode 100644 index 0000000..337d3a9 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/transport/ScratchPoolTest.java @@ -0,0 +1,73 @@ +package dev.relism.flash.transport; + +import org.junit.jupiter.api.Test; + +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class ScratchPoolTest { + + @Test + void acquire_withEmptyPool_returnsFreshInstance() { + ScratchPool pool = new ScratchPool(); + ConnectionScratch scratch = pool.acquire(); + assertNotNull(scratch); + assertNotNull(scratch.sha1); + assertEquals(ConnectionScratch.RELAY_BUFFER_SIZE, scratch.relayBuffer.length); + assertNotNull(scratch.responseHead); + assertEquals(0, scratch.responseHead.length()); + } + + @Test + void release_thenAcquire_reusesTheSameInstance() { + ScratchPool pool = new ScratchPool(); + ConnectionScratch first = pool.acquire(); + pool.release(first); + ConnectionScratch second = pool.acquire(); + assertSame(first, second); + } + + @Test + void bound_isRespected_excessReleasesAreDropped() { + ScratchPool pool = new ScratchPool(2); + ConnectionScratch a = pool.acquire(); + ConnectionScratch b = pool.acquire(); + ConnectionScratch c = pool.acquire(); + pool.release(a); + pool.release(b); + pool.release(c); // pool already has 2 -- this one is dropped, not queued + + Set reacquired = new HashSet<>(); + reacquired.add(pool.acquire()); + reacquired.add(pool.acquire()); + ConnectionScratch third = pool.acquire(); // freshly allocated, pool was exhausted at 2 + assertFalse(reacquired.contains(third)); + assertEquals(2, reacquired.size()); + } + + @Test + void reset_clearsTheMessageDigestState() { + // A dirty digest (mid-update, not yet digested) must not leak into the next connection + // that reuses this scratch -- the classic cross-connection-leak hazard for pooled state. + ScratchPool pool = new ScratchPool(); + ConnectionScratch scratch = pool.acquire(); + scratch.sha1.update((byte) 'x'); + pool.release(scratch); + + ConnectionScratch reused = pool.acquire(); + assertSame(scratch, reused); + // If reset() had not run, digesting an empty input now would still reflect the earlier + // update. A byte array is not the actual assertion here (MessageDigest doesn't expose + // "reset happened") - the practical proof is that digest() with no further updates + // matches the well-known empty-input SHA-1 digest. + byte[] emptyDigest = reused.sha1.digest(); + byte[] expected = { + (byte) 0xda, 0x39, (byte) 0xa3, (byte) 0xee, 0x5e, 0x6b, 0x4b, 0x0d, + 0x32, 0x55, (byte) 0xbf, (byte) 0xef, (byte) 0x95, 0x60, 0x18, (byte) 0x90, + (byte) 0xaf, (byte) 0xd8, 0x07, 0x09 + }; + assertArrayEquals(expected, emptyDigest); + } +} diff --git a/flash/src/test/java/dev/relism/flash/transport/ServerLifecycleGracefulShutdownTest.java b/flash/src/test/java/dev/relism/flash/transport/ServerLifecycleGracefulShutdownTest.java new file mode 100644 index 0000000..e148835 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/transport/ServerLifecycleGracefulShutdownTest.java @@ -0,0 +1,114 @@ +package dev.relism.flash.transport; + +import dev.relism.flash.extension.FlashApp; +import dev.relism.flash.extension.FlashConfiguration; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import java.io.OutputStream; +import java.net.ServerSocket; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * finish (forced to {@code Connection: close}), then force-close whatever remains after + * {@code shutdownDrainTimeoutMs}. + */ +class ServerLifecycleGracefulShutdownTest { + + private FlashApp app; + + @AfterEach + void tearDown() { + if (app != null) app.stop(); + } + + private static int freePort() throws Exception { + try (ServerSocket s = new ServerSocket(0)) { + return s.getLocalPort(); + } + } + + @Test + void inFlightRequest_completesWithConnectionClose_duringShutdown() throws Exception { + int port = freePort(); + CountDownLatch handlerStarted = new CountDownLatch(1); + CountDownLatch releaseHandler = new CountDownLatch(1); + + app = FlashApp.create(FlashConfiguration.builder() + .port(port).host("127.0.0.1") + .shutdownDrainTimeoutMs(5_000) + .build()); + app.get("/slow", (req, res) -> { + handlerStarted.countDown(); + assertTrue(releaseHandler.await(5, TimeUnit.SECONDS)); + return "done"; + }); + app.start(); + + try (Socket socket = new Socket("127.0.0.1", port)) { + socket.setSoTimeout(5_000); + OutputStream out = socket.getOutputStream(); + out.write("GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n".getBytes(StandardCharsets.UTF_8)); + out.flush(); + + assertTrue(handlerStarted.await(2, TimeUnit.SECONDS)); + + // Begin shutdown while the handler is still running. + CompletableFuture stopping = app.stop(); + // Give stop() a moment to mark the server as stopping and close the listener. + Thread.sleep(100); + releaseHandler.countDown(); + + byte[] buf = new byte[4096]; + int n = socket.getInputStream().read(buf); + String response = new String(buf, 0, n, StandardCharsets.UTF_8); + + assertTrue(response.startsWith("HTTP/1.1 200 OK"), response); + assertTrue(response.contains("done"), response); + // though the client asked for HTTP/1.1's default keep-alive. + assertTrue(response.contains("Connection: close"), response); + + stopping.get(5, TimeUnit.SECONDS); + } + } + + @Test + void stop_closesListener_soNewConnectionsAreRefused() throws Exception { + int port = freePort(); + app = FlashApp.create(FlashConfiguration.builder() + .port(port).host("127.0.0.1") + .shutdownDrainTimeoutMs(500) + .build()); + app.get("/ping", (req, res) -> "pong"); + app.start(); + + // Confirm the server actually answers before stopping it. + try (Socket probe = new Socket("127.0.0.1", port)) { + probe.setSoTimeout(2_000); + probe.getOutputStream().write("GET /ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n" + .getBytes(StandardCharsets.UTF_8)); + probe.getOutputStream().flush(); + assertTrue(probe.getInputStream().read() != -1); + } + + app.stop().get(5, TimeUnit.SECONDS); + + assertThrows(Exception.class, () -> { + try (Socket socket = new Socket()) { + socket.connect(new java.net.InetSocketAddress("127.0.0.1", port), 500); + socket.setSoTimeout(500); + socket.getOutputStream().write("GET /ping HTTP/1.1\r\nHost: localhost\r\n\r\n" + .getBytes(StandardCharsets.UTF_8)); + socket.getOutputStream().flush(); + int result = socket.getInputStream().read(); + if (result == -1) throw new java.io.IOException("connection refused/closed, as expected"); + } + }); + } +} diff --git a/flash/src/test/java/dev/relism/flash/websocket/WebSocketFragmentationAndValidationTest.java b/flash/src/test/java/dev/relism/flash/websocket/WebSocketFragmentationAndValidationTest.java new file mode 100644 index 0000000..9d0c172 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/websocket/WebSocketFragmentationAndValidationTest.java @@ -0,0 +1,214 @@ +package dev.relism.flash.websocket; + +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * masking, opcode validation, control-frame constraints, correct close codes) coverage for + * {@link WebSocketSession#readFrame}. + */ +class WebSocketFragmentationAndValidationTest { + + private static final byte[] MASK = {1, 2, 3, 4}; + + private static byte[] frame(int opcode, boolean fin, boolean masked, byte[] payload) throws IOException { + ByteArrayOutputStream buf = new ByteArrayOutputStream(); + buf.write((fin ? 0x80 : 0) | opcode); + int len = payload.length; + int maskBit = masked ? 0x80 : 0x00; + if (len <= 125) { + buf.write(maskBit | len); + } else { + buf.write(maskBit | 126); + buf.write((len >> 8) & 0xFF); + buf.write(len & 0xFF); + } + byte[] out = payload; + if (masked) { + buf.write(MASK); + out = payload.clone(); + for (int i = 0; i < out.length; i++) out[i] ^= MASK[i % 4]; + } + buf.write(out); + return buf.toByteArray(); + } + + private static byte[] concat(byte[]... arrays) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + for (byte[] a : arrays) out.write(a); + return out.toByteArray(); + } + + /** Server-mode session (masked incoming required) over the given raw bytes. */ + private static WebSocketSession serverSession(byte[] raw, int bufferSize) { + return new WebSocketSession(new ByteArrayInputStream(raw), new ByteArrayOutputStream(), bufferSize); + } + + + @Test + void continuationFrames_reassembleIntoOneMessage() throws IOException { + byte[] raw = concat( + frame(WebSocketFrame.OP_TEXT, false, true, "hel".getBytes(StandardCharsets.UTF_8)), + frame(WebSocketFrame.OP_CONTINUATION, false, true, "lo ".getBytes(StandardCharsets.UTF_8)), + frame(WebSocketFrame.OP_CONTINUATION, true, true, "world".getBytes(StandardCharsets.UTF_8))); + WebSocketSession session = serverSession(raw, 64); + WebSocketFrame frame = new WebSocketFrame(); + + assertTrue(session.readFrame(frame)); + assertEquals(WebSocketFrame.OP_TEXT, frame.opcode()); + assertTrue(frame.isFin()); + assertEquals("hello world", new String(frame.copyPayload(), StandardCharsets.UTF_8)); + } + + @Test + void controlFrame_interleavedDuringFragmentation_deliveredWithoutDisturbingReassembly() throws IOException { + byte[] raw = concat( + frame(WebSocketFrame.OP_TEXT, false, true, "AB".getBytes(StandardCharsets.UTF_8)), + frame(WebSocketFrame.OP_PING, true, true, "ping".getBytes(StandardCharsets.UTF_8)), + frame(WebSocketFrame.OP_CONTINUATION, true, true, "CD".getBytes(StandardCharsets.UTF_8))); + WebSocketSession session = serverSession(raw, 64); + WebSocketFrame frame = new WebSocketFrame(); + + assertTrue(session.readFrame(frame)); + assertEquals(WebSocketFrame.OP_PING, frame.opcode()); + assertEquals("ping", new String(frame.copyPayload(), StandardCharsets.UTF_8)); + + assertTrue(session.readFrame(frame)); + assertEquals(WebSocketFrame.OP_TEXT, frame.opcode()); + assertEquals("ABCD", new String(frame.copyPayload(), StandardCharsets.UTF_8)); + } + + @Test + void continuationWithoutInitiatedMessage_rejected1002() throws IOException { + byte[] raw = frame(WebSocketFrame.OP_CONTINUATION, true, true, "x".getBytes(StandardCharsets.UTF_8)); + WebSocketSession session = serverSession(raw, 64); + WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class, + () -> session.readFrame(new WebSocketFrame())); + assertEquals(1002, e.closeCode()); + } + + @Test + void newDataFrameWhileFragmenting_rejected1002() throws IOException { + byte[] raw = concat( + frame(WebSocketFrame.OP_TEXT, false, true, "a".getBytes(StandardCharsets.UTF_8)), + frame(WebSocketFrame.OP_TEXT, true, true, "b".getBytes(StandardCharsets.UTF_8))); + WebSocketSession session = serverSession(raw, 64); + WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class, + () -> session.readFrame(new WebSocketFrame())); + assertEquals(1002, e.closeCode()); + } + + @Test + void reassembledMessageExceedingBuffer_rejected1009() throws IOException { + byte[] raw = concat( + frame(WebSocketFrame.OP_TEXT, false, true, new byte[5]), + frame(WebSocketFrame.OP_CONTINUATION, true, true, new byte[5])); + WebSocketSession session = serverSession(raw, 8); // 5 + 5 = 10 > 8 + WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class, + () -> session.readFrame(new WebSocketFrame())); + assertEquals(1009, e.closeCode()); + } + + + @Test + void serverSession_unmaskedIncomingFrame_rejected1002() throws IOException { + byte[] raw = frame(WebSocketFrame.OP_TEXT, true, false, "hi".getBytes(StandardCharsets.UTF_8)); + WebSocketSession session = serverSession(raw, 64); + WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class, + () -> session.readFrame(new WebSocketFrame())); + assertEquals(1002, e.closeCode()); + } + + @Test + void clientSession_maskedIncomingFrame_rejected1002() throws IOException { + byte[] raw = frame(WebSocketFrame.OP_TEXT, true, true, "hi".getBytes(StandardCharsets.UTF_8)); + WebSocketSession session = new WebSocketSession( + new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 64, null, true); + WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class, + () -> session.readFrame(new WebSocketFrame())); + assertEquals(1002, e.closeCode()); + } + + @Test + void clientSession_unmaskedIncomingFrame_accepted() throws IOException { + byte[] raw = frame(WebSocketFrame.OP_TEXT, true, false, "hi".getBytes(StandardCharsets.UTF_8)); + WebSocketSession session = new WebSocketSession( + new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 64, null, true); + WebSocketFrame frame = new WebSocketFrame(); + assertTrue(session.readFrame(frame)); + assertEquals("hi", new String(frame.copyPayload(), StandardCharsets.UTF_8)); + } + + + @Test + void reservedOpcode_rejected1002() throws IOException { + byte[] raw = frame(0x3, true, true, new byte[0]); // 0x3 is reserved + WebSocketSession session = serverSession(raw, 64); + WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class, + () -> session.readFrame(new WebSocketFrame())); + assertEquals(1002, e.closeCode()); + } + + + @Test + void fragmentedControlFrame_rejected1002() throws IOException { + byte[] raw = frame(WebSocketFrame.OP_PING, false, true, "x".getBytes(StandardCharsets.UTF_8)); + WebSocketSession session = serverSession(raw, 64); + WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class, + () -> session.readFrame(new WebSocketFrame())); + assertEquals(1002, e.closeCode()); + } + + @Test + void oversizedControlFramePayload_rejected1002() throws IOException { + byte[] raw = frame(WebSocketFrame.OP_PING, true, true, new byte[126]); + WebSocketSession session = serverSession(raw, 200); + WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class, + () -> session.readFrame(new WebSocketFrame())); + assertEquals(1002, e.closeCode()); + } + + @Test + void controlFrameAtTheMaxAllowedSize_accepted() throws IOException { + byte[] raw = frame(WebSocketFrame.OP_PING, true, true, new byte[125]); + WebSocketSession session = serverSession(raw, 200); + WebSocketFrame frame = new WebSocketFrame(); + assertTrue(session.readFrame(frame)); + assertEquals(125, frame.payloadLength()); + } + + + private static final class CountingInputStream extends InputStream { + private final InputStream delegate; + int reads = 0; + CountingInputStream(InputStream delegate) { this.delegate = delegate; } + @Override public int read() throws IOException { reads++; return delegate.read(); } + @Override public int read(byte[] b, int off, int len) throws IOException { reads++; return delegate.read(b, off, len); } + } + + @Test + void readFrame_withExtendedLengthAndMask_doesNotReadOneByteAtATime() throws IOException { + // 200-byte payload forces the 16-bit extended length; masked, so 4 mask bytes too. + // Pre-fix: 1 (b0) + 1 (b1) + 2 (extended length, read one at a time originally via two + // separate in.read() calls — already bulk-free in that part) + 4 (mask, one at a time) + // = several individual reads for the header alone, on top of one per payload byte if + // the underlying stream were unbuffered. Post-fix: the header's variable remainder + // (length + mask) is exactly one readFully call. + byte[] raw = frame(WebSocketFrame.OP_BINARY, true, true, new byte[200]); + CountingInputStream counting = new CountingInputStream(new ByteArrayInputStream(raw)); + WebSocketSession session = new WebSocketSession(counting, new ByteArrayOutputStream(), 256); + + assertTrue(session.readFrame(new WebSocketFrame())); + + // b0, b1, one bulk read for (2 extended-length + 4 mask) bytes, one bulk read for the + // 200-byte payload: 4 total, independent of the payload size. + assertTrue(counting.reads <= 4, "expected at most 4 underlying reads, was " + counting.reads); + } +} diff --git a/flash/src/test/java/dev/relism/flash/websocket/WebSocketLoopTest.java b/flash/src/test/java/dev/relism/flash/websocket/WebSocketLoopTest.java new file mode 100644 index 0000000..bb87a59 --- /dev/null +++ b/flash/src/test/java/dev/relism/flash/websocket/WebSocketLoopTest.java @@ -0,0 +1,92 @@ +package dev.relism.flash.websocket; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.InputStream; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; + +class WebSocketLoopTest { + @Test + void onOpenFailureStillReportsErrorClosesAndReleasesSession() { + RuntimeException failure = new RuntimeException("open failed"); + AtomicReference reported = new AtomicReference<>(); + AtomicInteger closes = new AtomicInteger(); + WebSocketSession session = + new WebSocketSession( + new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 128); + + WebSocketLoop.run( + session, + new WebSocketHandler() { + @Override + public void onOpen(WebSocketSession opened) { + throw failure; + } + + @Override + public void onMessage(WebSocketSession opened, WebSocketFrame frame) {} + + @Override + public void onError(WebSocketSession opened, Throwable error) { + reported.set(error); + } + + @Override + public void onClose(WebSocketSession opened, int code) { + closes.incrementAndGet(); + } + }); + + assertSame(failure, reported.get()); + assertEquals(1, closes.get()); + assertFalse(session.isOpen()); + } + + @Test + void onCloseFailureCannotPreventTransportRelease() { + AtomicInteger inputCloses = new AtomicInteger(); + WebSocketSession session = + new WebSocketSession( + new InputStream() { + @Override + public int read() { + return -1; + } + + @Override + public void close() { + inputCloses.incrementAndGet(); + } + }, + new ByteArrayOutputStream(), + 128); + + assertThrows( + RuntimeException.class, + () -> + WebSocketLoop.run( + session, + new WebSocketHandler() { + @Override + public void onOpen(WebSocketSession opened) {} + + @Override + public void onMessage(WebSocketSession opened, WebSocketFrame frame) {} + + @Override + public void onClose(WebSocketSession opened, int code) { + throw new RuntimeException("close failed"); + } + })); + + assertEquals(1, inputCloses.get()); + assertFalse(session.isOpen()); + } +} diff --git a/flash/src/test/java/dev/relism/flash/websocket/WebSocketSessionTest.java b/flash/src/test/java/dev/relism/flash/websocket/WebSocketSessionTest.java index 21f202a..e1d0b9c 100644 --- a/flash/src/test/java/dev/relism/flash/websocket/WebSocketSessionTest.java +++ b/flash/src/test/java/dev/relism/flash/websocket/WebSocketSessionTest.java @@ -1,7 +1,7 @@ package dev.relism.flash.websocket; import dev.relism.flash.http.HttpMethod; -import dev.relism.flash.models.HeaderMap; +import dev.relism.flash.models.Http1HeaderMap; import dev.relism.flash.models.Request; import dev.relism.flash.models.RequestLine; import dev.relism.fpr.core.ByteView; @@ -25,7 +25,7 @@ class WebSocketSessionTest { @Test void request_returnsWhatWasPassedToConstructor() { - RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/chat"), null, viewOf("HTTP/1.1"), new HeaderMap()); + RequestLine line = new RequestLine(HttpMethod.GET, viewOf("/chat"), null, viewOf("HTTP/1.1"), new Http1HeaderMap()); Request req = new Request(line, new byte[0]); WebSocketSession session = new WebSocketSession( new ByteArrayInputStream(new byte[0]), new ByteArrayOutputStream(), 64, req, false); diff --git a/flash/src/test/resources/http2/regressions/headers-after-end-stream.hex b/flash/src/test/resources/http2/regressions/headers-after-end-stream.hex new file mode 100644 index 0000000..6fcf912 --- /dev/null +++ b/flash/src/test/resources/http2/regressions/headers-after-end-stream.hex @@ -0,0 +1,5 @@ +# Preface, empty SETTINGS, then two request HEADERS sections on stream 1 after END_STREAM. +505249202a20485454502f322e300d0a0d0a534d0d0a0d0a +000000040000000000 +00000401050000000182868481 +00000401050000000182868481 diff --git a/flash/src/test/resources/http2/regressions/invalid-preface.hex b/flash/src/test/resources/http2/regressions/invalid-preface.hex new file mode 100644 index 0000000..c0429e2 --- /dev/null +++ b/flash/src/test/resources/http2/regressions/invalid-preface.hex @@ -0,0 +1,2 @@ +# Complete client preface with byte 10 changed from '/' (2f) to '.' (2e). +505249202a20485454502e322e300d0a0d0a534d0d0a0d0a diff --git a/flash/src/test/resources/http2/regressions/lower-unopened-stream.hex b/flash/src/test/resources/http2/regressions/lower-unopened-stream.hex new file mode 100644 index 0000000..a021e51 --- /dev/null +++ b/flash/src/test/resources/http2/regressions/lower-unopened-stream.hex @@ -0,0 +1,5 @@ +# Preface, empty SETTINGS, valid request on stream 3, then a never-opened lower stream 1. +505249202a20485454502f322e300d0a0d0a534d0d0a0d0a +000000040000000000 +00000401050000000382868481 +00000401050000000182868481 diff --git a/pom.xml b/pom.xml index 1cad048..f626004 100644 --- a/pom.xml +++ b/pom.xml @@ -35,6 +35,8 @@ 3.3.1 3.2.8 2.18.0 + 1.37 + 3.6.0