Compare commits
24
Commits
8f1f30b973
...
2078dea54f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2078dea54f | ||
|
|
a0dda8e47a | ||
|
|
cf16be08c0 | ||
|
|
825bdfc942 | ||
|
|
3679eed74a | ||
|
|
6386264a1e | ||
|
|
f3011ffdf6 | ||
|
|
3c1eb0d0df | ||
|
|
5755ef77fe | ||
|
|
ee90ac44ff | ||
|
|
8d5340a0b4 | ||
|
|
c96d51f7ea | ||
|
|
9391f80f76 | ||
|
|
cfa192e689 | ||
|
|
95c33e7bf2 | ||
|
|
f47f53c355 | ||
|
|
885c450f6b | ||
|
|
d882ea255c | ||
|
|
0e1bbed42c | ||
|
|
704a00a551 | ||
|
|
2bf261e4e2 | ||
|
|
a315e1df8b | ||
|
|
5a2aaf5a07 | ||
|
|
db6e4a4d0c |
@@ -32,8 +32,40 @@ jobs:
|
|||||||
server-username: MAVEN_USERNAME
|
server-username: MAVEN_USERNAME
|
||||||
server-password: MAVEN_PASSWORD
|
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
|
- 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:
|
env:
|
||||||
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||||
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
# Flash
|
# 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
|
## Modules
|
||||||
|
|
||||||
| Module | Description |
|
| 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-jackson` | Jackson JSON integration |
|
||||||
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
| `flash-extensions/flash-ext-openapi` | OpenAPI 3.0 spec + Swagger UI |
|
||||||
| `flash-extensions/flash-ext-oidc` | OIDC Authorization Code + PKCE flow |
|
| `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-core` | Minimal shared SSR runtime primitives |
|
||||||
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
| `flash-extensions/flash-ext-view-jte` | Opinionated jte SSR extension |
|
||||||
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
| `flash-extensions/flash-ext-view-thymeleaf` | Opinionated Thymeleaf SSR extension |
|
||||||
| `flash-bench` | Demo harness (OIDC + OpenAPI + Jackson) |
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ app.post("/echo", (req, res) -> {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.get("/users/{id}", (req, res) -> {
|
app.get("/users/{id}", (req, res) -> {
|
||||||
String id = req.pathParam("id");
|
String id = req.param("id");
|
||||||
return "user:" + id;
|
return "user:" + id;
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -167,13 +167,64 @@ app.onException((ex, req, res) -> {
|
|||||||
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
|
| `tls` | `null` | TLS for the default listener — see [TLS](#tls) |
|
||||||
| `listeners` | `[]` | Multiple bind targets (port + host + optional TLS) on one app — 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) |
|
| `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
|
## TLS
|
||||||
|
|
||||||
HTTPS and WSS are a transport-layer concern only: once a listener is bound, the accepted
|
HTTPS and WSS are a transport-layer concern only. Once a listener is bound, the accepted socket
|
||||||
`Socket` is either plain or an `SSLSocket` indistinguishably from `HttpServer`'s point of view
|
is plain or TLS; the selected HTTP connection implementation then performs either the HTTP/1.1
|
||||||
onward — the request parser, router, and WebSocket upgrade never branch on it. WSS is therefore
|
upgrade or the HTTP/2 extended CONNECT. WSS does not require a separate route or handler API.
|
||||||
not a separate feature; it's a WebSocket upgrade running over whatever transport it was handed.
|
|
||||||
|
|
||||||
### Quick start
|
### 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
|
`WebSocketSession` mirrors this exactly (`isSecure()`, `sslSession()`) by delegating to the
|
||||||
upgrading `Request` — no separate TLS state is tracked for WS.
|
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
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
ServerSocket.accept()
|
TransportFactory.create() # binds every listener, wires the connection runner
|
||||||
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
|
→ AcceptLoop # one per listener × accept thread; hands sockets off
|
||||||
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
|
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
|
||||||
→ RequestHandler.handle() # user handler; return value sets body
|
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
|
||||||
→ Request.drain() # consume unread body for keep-alive
|
├─ Http1Connection.run() # request parser, router, handler, h1 response writer
|
||||||
→ HttpServer writes response # status line, headers, then fixed or chunked body
|
└─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
|
||||||
→ loop or close socket # based on Connection header
|
→ 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.
|
- **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.
|
- **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.
|
- **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.
|
- **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
|
## Build & test
|
||||||
|
|
||||||
@@ -283,7 +419,4 @@ mvn test
|
|||||||
|
|
||||||
# Run a single test class
|
# Run a single test class
|
||||||
mvn test -pl flash -Dtest=RequestParserTest
|
mvn test -pl flash -Dtest=RequestParserTest
|
||||||
|
|
||||||
# Run the benchmark demo server
|
|
||||||
java -jar flash-bench/target/flash-bench-1.0-SNAPSHOT.jar
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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<MatchResult>`/`ThreadLocal<MethodPathByteView>` 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.
|
||||||
@@ -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.
|
||||||
@@ -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<byte[]>` — 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<byte[]>`) 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").
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -37,4 +37,82 @@
|
|||||||
</dependency>
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Phase 3 (flash/docs/http2/IMPLEMENTATION-PLAN.md): the JMH benchmark gate for the h2
|
||||||
|
serialized frame writer. Not bound to the default build — activate explicitly with
|
||||||
|
`-Pjmh`. Benchmarks live in src/jmh/java, a source root distinct from src/test/java
|
||||||
|
(a `jmh` profile on this module, per the plan's own suggestion, rather than a new
|
||||||
|
flash-bench submodule — recorded as DEC-09), specifically so that `mvn test` with no
|
||||||
|
profile never even *compiles* them: src/jmh/java is registered as a test-source root
|
||||||
|
only inside this profile (build-helper-maven-plugin's add-test-source), and the JMH
|
||||||
|
dependencies it imports are likewise profile-scoped. An earlier revision put the
|
||||||
|
benchmark directly in src/test/java, relying on Surefire's JUnit filtering (JMH classes
|
||||||
|
carry no JUnit annotations) to skip it at *run* time — but Surefire's test discovery
|
||||||
|
loads every compiled test class regardless, so a plain `mvn test` without `-Pjmh` failed
|
||||||
|
the whole module at test-compile with "package org.openjdk.jmh.annotations does not
|
||||||
|
exist", since jmh-core is not on the classpath outside this profile. The separate source
|
||||||
|
root fixes that at the root: with the profile inactive, the benchmark source is not on
|
||||||
|
any compiler's input at all. Run: `mvn -Pjmh -pl flash test-compile` then
|
||||||
|
`java -cp ... org.openjdk.jmh.Main` (see FrameWriterBenchmark's own Javadoc for the full
|
||||||
|
classpath incantation).
|
||||||
|
-->
|
||||||
|
<profiles>
|
||||||
|
<profile>
|
||||||
|
<id>jmh</id>
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjdk.jmh</groupId>
|
||||||
|
<artifactId>jmh-core</artifactId>
|
||||||
|
<version>${jmh.version}</version>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.openjdk.jmh</groupId>
|
||||||
|
<artifactId>jmh-generator-annprocess</artifactId>
|
||||||
|
<version>${jmh.version}</version>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.codehaus.mojo</groupId>
|
||||||
|
<artifactId>build-helper-maven-plugin</artifactId>
|
||||||
|
<version>${build.helper.plugin.version}</version>
|
||||||
|
<executions>
|
||||||
|
<execution>
|
||||||
|
<id>add-jmh-source</id>
|
||||||
|
<phase>generate-test-sources</phase>
|
||||||
|
<goals>
|
||||||
|
<goal>add-test-source</goal>
|
||||||
|
</goals>
|
||||||
|
<configuration>
|
||||||
|
<sources>
|
||||||
|
<source>src/jmh/java</source>
|
||||||
|
</sources>
|
||||||
|
</configuration>
|
||||||
|
</execution>
|
||||||
|
</executions>
|
||||||
|
</plugin>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.openjdk.jmh</groupId>
|
||||||
|
<artifactId>jmh-generator-annprocess</artifactId>
|
||||||
|
<version>${jmh.version}</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</profile>
|
||||||
|
</profiles>
|
||||||
|
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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<LoadResult> runAllScenarios(URI target, Duration warmup, Duration measurement)
|
||||||
|
throws InterruptedException {
|
||||||
|
List<LoadResult> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<Void> response =
|
||||||
|
client.send(request, HttpResponse.BodyHandlers.discarding());
|
||||||
|
if (response.statusCode() != 200) {
|
||||||
|
throw new IllegalStateException("status " + response.statusCode());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LatencyRecorder> 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<LatencyRecorder> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoadResult> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LatencyRecorder> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<RunResult> 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<RunResult> 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<String, Double> 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <b>virtual-thread</b>
|
||||||
|
* writers:
|
||||||
|
*
|
||||||
|
* <ul>
|
||||||
|
* <li>{@code trylock_mpsc} — the design that ships as {@link Http2FrameWriter}: {@code tryLock()}
|
||||||
|
* fast path, intrusive MPSC fallback.
|
||||||
|
* <li>{@code plain_lock} — every write blocks on {@code ReentrantLock#lock()}, unconditionally.
|
||||||
|
* <li>{@code dedicated_thread} — every write hands off to a single dedicated platform thread via
|
||||||
|
* the same {@link IntrusiveMpscQueue}, parked/unparked (never a busy poll).
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <h3>Why this benchmark drives its own concurrency instead of JMH's {@code @Threads}</h3>
|
||||||
|
*
|
||||||
|
* {@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.
|
||||||
|
*
|
||||||
|
* <h3>Why {@code runBurst} waits on a write counter, not just thread completion</h3>
|
||||||
|
*
|
||||||
|
* {@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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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<T> {
|
||||||
|
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<WriteIntent> 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<String> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+119
@@ -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.
|
||||||
|
*
|
||||||
|
* <p><b>{@code router_*}</b> exercises the shipped {@link FastPathRouterImpl#route} end to end,
|
||||||
|
* including lazy-compiled route-table lookup, {@link FastPathRouterImpl.RouteScratch} reuse and
|
||||||
|
* path-parameter extraction.
|
||||||
|
*
|
||||||
|
* <p><b>{@code byteCompare_*}</b> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,24 +1,49 @@
|
|||||||
package dev.relism.flash;
|
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.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.SequenceInputStream;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* De-chunking {@link InputStream} for HTTP/1.1 {@code Transfer-Encoding: chunked} request bodies.
|
* 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.
|
* 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.
|
* 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 {
|
final class ChunkedInputStream extends InputStream implements BodyCompletion {
|
||||||
private final InputStream src;
|
private final BufferedByteSource src;
|
||||||
private int chunkRemaining = 0;
|
private int chunkRemaining = 0;
|
||||||
private boolean done = false;
|
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) {
|
ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen,
|
||||||
src = preBufLen > 0
|
MutableHeaderMap trailers) {
|
||||||
? new SequenceInputStream(new ByteArrayInputStream(preBuf, preBufOff, preBufLen), socket)
|
this.src = src;
|
||||||
: socket;
|
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
|
@Override
|
||||||
@@ -29,7 +54,7 @@ final class ChunkedInputStream extends InputStream {
|
|||||||
if (chunkRemaining == 0) { consumeTrailers(); done = true; return -1; }
|
if (chunkRemaining == 0) { consumeTrailers(); done = true; return -1; }
|
||||||
}
|
}
|
||||||
int b = src.read();
|
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;
|
return b;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,30 +68,154 @@ final class ChunkedInputStream extends InputStream {
|
|||||||
int n = src.read(buf, off, Math.min(len, chunkRemaining));
|
int n = src.read(buf, off, Math.min(len, chunkRemaining));
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
chunkRemaining -= n;
|
chunkRemaining -= n;
|
||||||
if (chunkRemaining == 0) { src.read(); src.read(); } // consume trailing \r\n
|
if (chunkRemaining == 0) consumeChunkTerminator();
|
||||||
}
|
}
|
||||||
return n;
|
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 {
|
private int readChunkSize() throws IOException {
|
||||||
|
if (++chunksSeen > Http1Limits.MAX_CHUNKS_PER_BODY) {
|
||||||
|
throw new MalformedRequestException(413, "Too many chunks");
|
||||||
|
}
|
||||||
|
|
||||||
long size = 0;
|
long size = 0;
|
||||||
int b;
|
int digits = 0;
|
||||||
while ((b = src.read()) != -1) {
|
int b = src.read();
|
||||||
if (b >= '0' && b <= '9') size = (size << 4) | (b - '0');
|
while (isHexDigit(b)) {
|
||||||
else if (b >= 'a' && b <= 'f') size = (size << 4) | (b - 'a' + 10);
|
if (++digits > 16) throw new MalformedRequestException(400, "Chunk size line too long");
|
||||||
else if (b >= 'A' && b <= 'F') size = (size << 4) | (b - 'A' + 10);
|
size = (size << 4) | hexValue(b);
|
||||||
else { while ((b = src.read()) != -1 && b != '\n'); break; } // ext or \r\n
|
if (size > Http1Limits.MAX_CHUNK_SIZE) {
|
||||||
if (size > Integer.MAX_VALUE) throw new IOException("Chunk size exceeds 2 GB limit");
|
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;
|
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 {
|
private void consumeTrailers() throws IOException {
|
||||||
|
int trailerCount = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
int b = src.read();
|
int b = src.read();
|
||||||
if (b == -1 || b == '\r') { src.read(); return; } // empty line — done
|
if (b == -1) throw new MalformedRequestException(400, "Truncated trailer section");
|
||||||
while ((b = src.read()) != -1 && b != '\n'); // skip non-empty trailer line
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
|
||||||
*
|
|
||||||
* <p>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.
|
|
||||||
*
|
|
||||||
* <h3>Allocation model</h3>
|
|
||||||
* <ul>
|
|
||||||
* <li>{@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.</li>
|
|
||||||
* <li>WS handshake SHA-1: {@link ThreadLocal}<{@link MessageDigest}> — one per
|
|
||||||
* accept thread (there are now {@code ACCEPT_THREADS} of them, not one).</li>
|
|
||||||
* </ul>
|
|
||||||
*/
|
|
||||||
@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<BoundListener> boundListeners;
|
|
||||||
private final AbstractRouter router;
|
|
||||||
private final AbstractWsRouter wsRouter;
|
|
||||||
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
|
||||||
private final Set<Socket> 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<MessageDigest> SHA1 =
|
|
||||||
ThreadLocal.withInitial(() -> {
|
|
||||||
try { return MessageDigest.getInstance("SHA-1"); }
|
|
||||||
catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); }
|
|
||||||
});
|
|
||||||
|
|
||||||
private static final ThreadLocal<byte[]> 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<byte[]> 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<FlashConfiguration.Listener> specs = configuration.getListeners().isEmpty()
|
|
||||||
? List.of(new FlashConfiguration.Listener(
|
|
||||||
configuration.getPort(), configuration.getHost(), configuration.getTls()))
|
|
||||||
: configuration.getListeners();
|
|
||||||
|
|
||||||
List<BoundListener> 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<Void> 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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
package dev.relism.flash;
|
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.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.Request;
|
||||||
|
import dev.relism.flash.models.RequestBody;
|
||||||
import dev.relism.flash.models.RequestLine;
|
import dev.relism.flash.models.RequestLine;
|
||||||
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
import dev.relism.flash.routing.routers.fastpathrouter.FastPathViews;
|
||||||
|
import dev.relism.flash.transport.BufferedByteSource;
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
import javax.net.ssl.SSLSocket;
|
import javax.net.ssl.SSLSocket;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
@@ -35,6 +40,15 @@ import java.util.Arrays;
|
|||||||
* to the <em>next</em> request. They are snapshotted at the top of {@link #parse}
|
* to the <em>next</em> 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
|
* 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.
|
* 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
|
@Slf4j
|
||||||
public class RequestParser {
|
public class RequestParser {
|
||||||
@@ -43,7 +57,17 @@ public class RequestParser {
|
|||||||
private final int maxHeaderBufferSize;
|
private final int maxHeaderBufferSize;
|
||||||
private final InetSocketAddress remoteAddress;
|
private final InetSocketAddress remoteAddress;
|
||||||
private final SSLSocket sslSocket;
|
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;
|
private byte[] buffer;
|
||||||
|
|
||||||
// Unconsumed bytes belonging to the NEXT request.
|
// Unconsumed bytes belonging to the NEXT request.
|
||||||
@@ -65,6 +89,19 @@ public class RequestParser {
|
|||||||
this.buffer = new byte[Math.min(INITIAL_BUFFER_SIZE, maxHeaderBufferSize)];
|
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}.
|
* Parses the next HTTP request from {@code in}.
|
||||||
*
|
*
|
||||||
@@ -75,9 +112,12 @@ public class RequestParser {
|
|||||||
* the same parser instance is reused after an error.
|
* the same parser instance is reused after an error.
|
||||||
*
|
*
|
||||||
* @return the parsed {@link Request}, or {@code null} on clean EOF.
|
* @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.
|
// Snapshot leftover bytes from the previous request, then reset immediately.
|
||||||
// Any exception thrown below leaves bufBase/bufLen at 0 — safe state.
|
// Any exception thrown below leaves bufBase/bufLen at 0 — safe state.
|
||||||
int base = bufBase;
|
int base = bufBase;
|
||||||
@@ -85,7 +125,7 @@ public class RequestParser {
|
|||||||
bufBase = 0;
|
bufBase = 0;
|
||||||
bufLen = 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) {
|
while (headerEndIdx == -1) {
|
||||||
if (base + totalRead == buffer.length) {
|
if (base + totalRead == buffer.length) {
|
||||||
if (base > 0) {
|
if (base > 0) {
|
||||||
@@ -94,7 +134,8 @@ public class RequestParser {
|
|||||||
System.arraycopy(buffer, base, buffer, 0, totalRead);
|
System.arraycopy(buffer, base, buffer, 0, totalRead);
|
||||||
base = 0;
|
base = 0;
|
||||||
} else if (buffer.length >= maxHeaderBufferSize) {
|
} else if (buffer.length >= maxHeaderBufferSize) {
|
||||||
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
|
throw new MalformedRequestException(431,
|
||||||
|
"Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||||
} else {
|
} else {
|
||||||
buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize));
|
buffer = Arrays.copyOf(buffer, Math.min(buffer.length * 2, maxHeaderBufferSize));
|
||||||
}
|
}
|
||||||
@@ -103,65 +144,133 @@ public class RequestParser {
|
|||||||
if (n <= 0) break;
|
if (n <= 0) break;
|
||||||
int prevTotal = totalRead;
|
int prevTotal = totalRead;
|
||||||
totalRead += n;
|
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 (totalRead <= 0) return null;
|
||||||
if (headerEndIdx == -1) {
|
if (headerEndIdx == -1) {
|
||||||
throw new IOException("Request headers exceed " + maxHeaderBufferSize + " bytes");
|
throw new MalformedRequestException(431,
|
||||||
|
"Request headers exceed " + maxHeaderBufferSize + " bytes");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Request line ─────────────────────────────────────────────────────
|
// ── Request line ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
int methodEnd = find(buffer, base, headerEndIdx, (byte) ' ');
|
int methodEnd = ByteScan.indexOf(buffer, base, headerEndIdx, (byte) ' ');
|
||||||
if (methodEnd == -1) throw new IOException("Invalid request line (method)");
|
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);
|
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 pathStart = methodEnd + 1;
|
||||||
int pathEnd = find(buffer, pathStart, headerEndIdx, (byte) ' ');
|
int pathEnd = ByteScan.indexOf(buffer, pathStart, headerEndIdx, (byte) ' ');
|
||||||
if (pathEnd == -1) throw new IOException("Invalid request line (path)");
|
if (pathEnd == -1) throw new MalformedRequestException(400, "Invalid request line (path)");
|
||||||
|
|
||||||
int queryMark = find(buffer, pathStart, pathEnd, (byte) '?');
|
int queryMark = ByteScan.indexOf(buffer, pathStart, pathEnd, (byte) '?');
|
||||||
FastPathViews.RequestByteView pathView = new FastPathViews.RequestByteView(buffer, pathStart,
|
pathView.reset(buffer, pathStart, queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
|
||||||
queryMark != -1 ? queryMark - pathStart : pathEnd - pathStart);
|
if (queryMark != -1) queryView.reset(buffer, queryMark + 1, pathEnd - queryMark - 1);
|
||||||
FastPathViews.RequestByteView queryView = queryMark != -1
|
|
||||||
? new FastPathViews.RequestByteView(buffer, queryMark + 1, pathEnd - queryMark - 1)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
int protocolStart = pathEnd + 1;
|
int protocolStart = pathEnd + 1;
|
||||||
int protocolEnd = find(buffer, protocolStart, headerEndIdx, (byte) '\r');
|
int protocolEnd = ByteScan.indexOf(buffer, protocolStart, headerEndIdx, (byte) '\r');
|
||||||
if (protocolEnd == -1) throw new IOException("Invalid request line (protocol)");
|
if (protocolEnd == -1) throw new MalformedRequestException(400, "Invalid request line (protocol)");
|
||||||
|
|
||||||
FastPathViews.RequestByteView protocolView =
|
// from the overall header-block size, so an oversized request line gets its own,
|
||||||
new FastPathViews.RequestByteView(buffer, protocolStart, protocolEnd - protocolStart);
|
// 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 ──────────────────────────────────────────────────────────
|
// ── Headers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
int sectionStart = find(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
|
int sectionStart = ByteScan.indexOf(buffer, protocolEnd, headerEndIdx, (byte) '\n') + 1;
|
||||||
int current = sectionStart;
|
int current = sectionStart;
|
||||||
long contentLength = 0;
|
long contentLength = -1;
|
||||||
boolean isChunked = false;
|
boolean contentLengthSeen = false;
|
||||||
|
boolean transferEncodingSeen = false;
|
||||||
|
boolean transferEncodingChunked = false;
|
||||||
|
int headerCount = 0;
|
||||||
|
headerMap.beginParsed(buffer, sectionStart, headerEndIdx);
|
||||||
|
|
||||||
while (current < 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;
|
if (lineEnd == -1 || lineEnd == current) break;
|
||||||
|
|
||||||
int colon = find(buffer, current, lineEnd, (byte) ':');
|
// advancing past two bytes — a bare '\r' not followed by '\n' desynchronizes the
|
||||||
if (colon != -1) {
|
// parse and is a known bare-CR smuggling surface. Safe to read lineEnd+1: lineEnd
|
||||||
int valueStart = colon + 1;
|
// is at most headerEndIdx, and findEndOfHeader already guaranteed 4 readable bytes
|
||||||
while (valueStart < lineEnd && buffer[valueStart] == ' ') valueStart++;
|
// (\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")) {
|
if (++headerCount > Http1Limits.MAX_HEADER_COUNT) {
|
||||||
contentLength = parseLong(buffer, valueStart, lineEnd);
|
throw new MalformedRequestException(431, "Too many headers");
|
||||||
} else if (equalsIgnoreCase(buffer, current, colon, "transfer-encoding")) {
|
}
|
||||||
isChunked = equalsIgnoreCase(buffer, valueStart, lineEnd, "chunked");
|
|
||||||
|
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;
|
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 ─────────────────────────────────────
|
// ── Body / pipelining accounting ─────────────────────────────────────
|
||||||
|
|
||||||
@@ -181,50 +290,69 @@ public class RequestParser {
|
|||||||
preBufLen = (int) contentLength;
|
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) {
|
if (isChunked) {
|
||||||
return Request.forParsed(requestLine,
|
requestBody.reset(
|
||||||
new ChunkedInputStream(in, buffer, bodyStart, preBufLen),
|
new ChunkedInputStream(in, buffer, bodyStart, preBufLen, trailerMap),
|
||||||
-1L, null, 0, 0, remoteAddress, sslSocket);
|
-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) ──
|
/**
|
||||||
|
* value, any non-digit byte (including a leading {@code +}/{@code -}, which are not
|
||||||
private static int findEndOfHeader(byte[] buf, int from, int len) {
|
* digits), more than 19 digits (the longest possible {@code Long.MAX_VALUE}), arithmetic
|
||||||
for (int i = from; i <= len - 4; i++) {
|
* overflow past {@code Long.MAX_VALUE}, and a value above
|
||||||
if (buf[i] == '\r' && buf[i+1] == '\n' && buf[i+2] == '\r' && buf[i+3] == '\n')
|
* {@link Http1Limits#MAX_CONTENT_LENGTH}. The pre-existing {@code parseLong} silently
|
||||||
return i;
|
* skipped any non-digit character instead of rejecting it — {@code "5abc"} parsed as
|
||||||
}
|
* {@code 5} and {@code "-1"} parsed as {@code 1}.
|
||||||
return -1;
|
*/
|
||||||
}
|
private static long parseContentLengthStrict(byte[] buf, int start, int end) throws MalformedRequestException {
|
||||||
|
|
||||||
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) {
|
|
||||||
int len = end - start;
|
int len = end - start;
|
||||||
if (len != target.length()) return false;
|
if (len == 0) throw new MalformedRequestException(400, "Empty Content-Length value");
|
||||||
for (int i = 0; i < len; i++) {
|
if (len > 19) throw new MalformedRequestException(400, "Content-Length value too long");
|
||||||
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) {
|
|
||||||
long value = 0;
|
long value = 0;
|
||||||
for (int i = start; i < end; i++) {
|
for (int i = start; i < end; i++) {
|
||||||
byte c = buf[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;
|
return value;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import dev.relism.flash.extension.FlashApp;
|
|||||||
import dev.relism.flash.extension.FlashConfiguration;
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
import dev.relism.flash.routing.AbstractRouter;
|
import dev.relism.flash.routing.AbstractRouter;
|
||||||
import dev.relism.flash.routing.AbstractWsRouter;
|
import dev.relism.flash.routing.AbstractWsRouter;
|
||||||
|
import dev.relism.flash.transport.TransportFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.concurrent.CompletableFuture;
|
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}
|
* Public handle to the underlying HTTP transport. Returned by {@link #create}
|
||||||
* so that {@link FlashApp} can start and stop the server
|
* 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 {
|
public interface ServerHandle {
|
||||||
|
|
||||||
@@ -30,6 +31,6 @@ public interface ServerHandle {
|
|||||||
static ServerHandle create(FlashConfiguration config,
|
static ServerHandle create(FlashConfiguration config,
|
||||||
AbstractRouter httpRouter,
|
AbstractRouter httpRouter,
|
||||||
AbstractWsRouter wsRouter) throws IOException {
|
AbstractWsRouter wsRouter) throws IOException {
|
||||||
return new HttpServer(config, httpRouter, wsRouter);
|
return TransportFactory.create(config, httpRouter, wsRouter);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package dev.relism.flash.api.multipart;
|
package dev.relism.flash.api.multipart;
|
||||||
|
|
||||||
|
import dev.relism.flash.http.Http1Limits;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -41,6 +43,7 @@ import java.util.*;
|
|||||||
* <p><b>Thread safety:</b> not thread-safe; one instance per request.
|
* <p><b>Thread safety:</b> not thread-safe; one instance per request.
|
||||||
*/
|
*/
|
||||||
public final class Multipart {
|
public final class Multipart {
|
||||||
|
private int partCount;
|
||||||
|
|
||||||
private static final int BUF_CAP = 8192;
|
private static final int BUF_CAP = 8192;
|
||||||
|
|
||||||
@@ -156,6 +159,11 @@ public final class Multipart {
|
|||||||
Map<String, String> headers = readPartHeaders();
|
Map<String, String> headers = readPartHeaders();
|
||||||
if (headers == null) { done = true; return null; }
|
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 disp = headers.get("content-disposition");
|
||||||
String name = extractParam(disp, "name");
|
String name = extractParam(disp, "name");
|
||||||
String filename = extractParam(disp, "filename");
|
String filename = extractParam(disp, "filename");
|
||||||
@@ -168,8 +176,9 @@ public final class Multipart {
|
|||||||
// File part — expose streaming body; not cached (stream is consumed once)
|
// File part — expose streaming body; not cached (stream is consumed once)
|
||||||
p = Part.streaming(name, filename, ct, active);
|
p = Part.streaming(name, filename, ct, active);
|
||||||
} else {
|
} else {
|
||||||
// Text part, or full-scan path: buffer body now
|
// InputStream.readAllBytes() — an unbounded field/file body would otherwise let a
|
||||||
byte[] body = active.readAllBytes();
|
// hostile peer force an arbitrarily large single heap allocation.
|
||||||
|
byte[] body = readBoundedBody(active);
|
||||||
active = null;
|
active = null;
|
||||||
p = Part.buffered(name, filename, ct, body);
|
p = Part.buffered(name, filename, ct, body);
|
||||||
scanned.add(p);
|
scanned.add(p);
|
||||||
@@ -177,6 +186,27 @@ public final class Multipart {
|
|||||||
return p;
|
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
|
// PartBodyStream — inner class sharing the window buffer
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
@@ -266,9 +296,15 @@ public final class Multipart {
|
|||||||
|
|
||||||
private Map<String, String> readPartHeaders() throws IOException {
|
private Map<String, String> readPartHeaders() throws IOException {
|
||||||
Map<String, String> map = new HashMap<>();
|
Map<String, String> map = new HashMap<>();
|
||||||
|
int count = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
String line = readLine();
|
String line = readLine();
|
||||||
if (line == null || line.isEmpty()) break;
|
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(':');
|
int colon = line.indexOf(':');
|
||||||
if (colon > 0)
|
if (colon > 0)
|
||||||
map.put(line.substring(0, colon).trim().toLowerCase(Locale.ROOT),
|
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));
|
sb.append(new String(win, wPos, i - wPos, StandardCharsets.UTF_8));
|
||||||
int consumed = i - wPos + 2;
|
int consumed = i - wPos + 2;
|
||||||
wPos += consumed; wLen -= consumed;
|
wPos += consumed; wLen -= consumed;
|
||||||
|
checkHeaderLineLength(sb.length());
|
||||||
return sb.toString();
|
return sb.toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -298,14 +335,26 @@ public final class Multipart {
|
|||||||
sb.append(new String(win, wPos, append, StandardCharsets.UTF_8));
|
sb.append(new String(win, wPos, append, StandardCharsets.UTF_8));
|
||||||
wPos += append; wLen -= append;
|
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) {
|
if (srcEof && wLen > 0) {
|
||||||
sb.append(new String(win, wPos, wLen, StandardCharsets.UTF_8));
|
sb.append(new String(win, wPos, wLen, StandardCharsets.UTF_8));
|
||||||
wPos += wLen; wLen = 0;
|
wPos += wLen; wLen = 0;
|
||||||
|
checkHeaderLineLength(sb.length());
|
||||||
return sb.toString();
|
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
|
// Utilities
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <h3>What this enables</h3>
|
||||||
|
* 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:
|
||||||
|
* <ul>
|
||||||
|
* <li>Single-allocation {@code String} construction —
|
||||||
|
* {@code new String(view.array(), view.offset(), view.length(), UTF_8)} instead of a
|
||||||
|
* byte-at-a-time copy into a scratch {@code byte[]} followed by a second allocation for
|
||||||
|
* <li>A single {@code System.arraycopy} instead of a manual loop wherever a view's bytes need
|
||||||
|
* to be copied.</li>
|
||||||
|
* </ul>
|
||||||
|
* 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();
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
*
|
||||||
|
* <p>Every method here is {@code static} and allocates nothing. Every SWAR method has a plain
|
||||||
|
* scalar counterpart ({@code *Scalar}) that exists for two reasons: it is what the tests use as
|
||||||
|
* the correctness oracle (property-tested against the SWAR version on randomized inputs — see
|
||||||
|
* {@code ByteScanTest}/{@code ByteScanFuzzTest}), and it is the documented fallback if a future
|
||||||
|
* measurement ever shows the SWAR path is not worth its complexity on some path (none has been
|
||||||
|
*
|
||||||
|
* <h3>The SWAR technique used throughout</h3>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
* <b>Position extraction does depend on it</b>: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <h3>Lifetime and thread-safety contract</h3>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <h3>Why this works</h3>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <h3>Encoding convention used across this codebase</h3>
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <h3>Lifetime contract</h3>
|
||||||
|
* 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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <h3>Deliberately not array-backed</h3>
|
||||||
|
* This does not implement {@link ArrayBackedByteView} — there is no single {@code (array,
|
||||||
|
* offset)} pair that describes it — and {@link #supportsLong()} returns {@code false}
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* <h3>Reusable, not allocated per block</h3>
|
||||||
|
* {@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}).
|
||||||
|
*
|
||||||
|
* <h3>Cost model</h3>
|
||||||
|
* {@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}).
|
||||||
|
*
|
||||||
|
* <h3>Why a ring, not a single reused slice</h3>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <h3>Lifetime contract</h3>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
*
|
||||||
|
* <p>Distinct from {@link HttpException}, which a <em>handler</em> throws to describe an
|
||||||
|
* application-level failure and which is routed through the user's configured exception
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import java.util.List;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Inspects a handler class at registration time and returns zero or more
|
* 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.
|
||||||
*
|
*
|
||||||
* <p>Processors are called once per register call, before
|
* <p>Processors are called once per register call, before
|
||||||
* the handler is compiled into the router. Returning an empty list is always
|
* the handler is compiled into the router. Returning an empty list is always
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
package dev.relism.flash.extension;
|
package dev.relism.flash.extension;
|
||||||
|
|
||||||
import dev.relism.flash.tls.TlsConfig;
|
import dev.relism.flash.tls.TlsConfig;
|
||||||
|
import java.util.List;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Singular;
|
import lombok.Singular;
|
||||||
import lombok.Value;
|
import lombok.Value;
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Configuration for a {@link FlashApp} instance.
|
* Configuration for a {@link FlashApp} instance.
|
||||||
*
|
*
|
||||||
@@ -39,27 +37,128 @@ import java.util.List;
|
|||||||
@Builder
|
@Builder
|
||||||
public class FlashConfiguration {
|
public class FlashConfiguration {
|
||||||
|
|
||||||
int port;
|
int port;
|
||||||
String host;
|
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
|
* One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link
|
||||||
List<Listener> listeners;
|
* #host}/{@link #tls}.
|
||||||
|
*/
|
||||||
|
@Singular List<Listener> listeners;
|
||||||
|
|
||||||
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
|
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
|
||||||
@Builder.Default
|
@Builder.Default int maxHeaderBufferSize = 64 * 1024;
|
||||||
int maxHeaderBufferSize = 64 * 1024;
|
|
||||||
|
|
||||||
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
|
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
|
||||||
@Builder.Default
|
@Builder.Default int wsFrameBufferSize = 64 * 1024;
|
||||||
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) {
|
* Maximum time, in milliseconds, allowed for a request's headers to be fully read once the first
|
||||||
public Listener(int port) { this(port, null, null); }
|
* byte of it has arrived. Bounds the classic slowloris attack: a peer that trickles one header
|
||||||
public Listener(int port, TlsConfig tls) { this(port, null, tls); }
|
* 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ package dev.relism.flash.extension;
|
|||||||
|
|
||||||
import dev.relism.flash.exceptions.InitializationException;
|
import dev.relism.flash.exceptions.InitializationException;
|
||||||
import dev.relism.flash.models.RequestHandler;
|
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.Routes;
|
||||||
|
import dev.relism.flash.routing.Ws;
|
||||||
import dev.relism.flash.websocket.WebSocketEndpoint;
|
import dev.relism.flash.websocket.WebSocketEndpoint;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
|
|||||||
@@ -1,65 +1,88 @@
|
|||||||
package dev.relism.flash.http;
|
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 lombok.Getter;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-compiled byte representations of common HTTP {@code Content-Type} values.
|
* Pre-compiled byte representations of common HTTP {@code Content-Type} values. {@code getBytes()}
|
||||||
* {@link #getBytes()} returns the pre-computed array directly, never allocates.
|
* returns the pre-computed array directly, never allocates.
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
public enum ContentType {
|
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
|
// Application
|
||||||
TEXT_PLAIN ("text/plain"),
|
JSON("application/json"),
|
||||||
TEXT_HTML ("text/html"),
|
XML("application/xml"),
|
||||||
TEXT_CSS ("text/css"),
|
BINARY("application/octet-stream"),
|
||||||
TEXT_JAVASCRIPT ("text/javascript"),
|
PDF("application/pdf"),
|
||||||
TEXT_XML ("text/xml"),
|
ZIP("application/zip"),
|
||||||
TEXT_CSV ("text/csv"),
|
GZIP("application/gzip"),
|
||||||
TEXT_MARKDOWN ("text/markdown"),
|
FORM_URLENCODED("application/x-www-form-urlencoded"),
|
||||||
TEXT_EVENT_STREAM ("text/event-stream"),
|
MULTIPART_FORM("multipart/form-data"),
|
||||||
|
GRAPHQL("application/graphql"),
|
||||||
|
NDJSON("application/x-ndjson"),
|
||||||
|
MSGPACK("application/msgpack"),
|
||||||
|
CBOR("application/cbor"),
|
||||||
|
LD_JSON("application/ld+json"),
|
||||||
|
|
||||||
// Application
|
// Image
|
||||||
JSON ("application/json"),
|
IMAGE_PNG("image/png"),
|
||||||
XML ("application/xml"),
|
IMAGE_JPEG("image/jpeg"),
|
||||||
BINARY ("application/octet-stream"),
|
IMAGE_GIF("image/gif"),
|
||||||
PDF ("application/pdf"),
|
IMAGE_WEBP("image/webp"),
|
||||||
ZIP ("application/zip"),
|
IMAGE_SVG("image/svg+xml"),
|
||||||
GZIP ("application/gzip"),
|
IMAGE_ICO("image/x-icon"),
|
||||||
FORM_URLENCODED ("application/x-www-form-urlencoded"),
|
IMAGE_AVIF("image/avif"),
|
||||||
MULTIPART_FORM ("multipart/form-data"),
|
|
||||||
GRAPHQL ("application/graphql"),
|
|
||||||
NDJSON ("application/x-ndjson"),
|
|
||||||
MSGPACK ("application/msgpack"),
|
|
||||||
CBOR ("application/cbor"),
|
|
||||||
LD_JSON ("application/ld+json"),
|
|
||||||
|
|
||||||
// Image
|
// Font
|
||||||
IMAGE_PNG ("image/png"),
|
FONT_WOFF("font/woff"),
|
||||||
IMAGE_JPEG ("image/jpeg"),
|
FONT_WOFF2("font/woff2"),
|
||||||
IMAGE_GIF ("image/gif"),
|
|
||||||
IMAGE_WEBP ("image/webp"),
|
|
||||||
IMAGE_SVG ("image/svg+xml"),
|
|
||||||
IMAGE_ICO ("image/x-icon"),
|
|
||||||
IMAGE_AVIF ("image/avif"),
|
|
||||||
|
|
||||||
// Font
|
// Audio / Video
|
||||||
FONT_WOFF ("font/woff"),
|
AUDIO_MPEG("audio/mpeg"),
|
||||||
FONT_WOFF2 ("font/woff2"),
|
AUDIO_OGG("audio/ogg"),
|
||||||
|
VIDEO_MP4("video/mp4"),
|
||||||
|
VIDEO_WEBM("video/webm");
|
||||||
|
|
||||||
// Audio / Video
|
private final byte[] bytes;
|
||||||
AUDIO_MPEG ("audio/mpeg"),
|
private final byte[] hpackBytes;
|
||||||
AUDIO_OGG ("audio/ogg"),
|
private static final ContentType[] ALL = values();
|
||||||
VIDEO_MP4 ("video/mp4"),
|
|
||||||
VIDEO_WEBM ("video/webm");
|
|
||||||
|
|
||||||
private final byte[] bytes;
|
ContentType(String value) {
|
||||||
|
this.bytes = value.getBytes(StandardCharsets.UTF_8);
|
||||||
ContentType(String value) {
|
if (bytes.length == 0) {
|
||||||
this.bytes = value.getBytes(StandardCharsets.UTF_8);
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p>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 <em>Flash's own caller</em> 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;
|
||||||
|
}
|
||||||
@@ -1,101 +1,164 @@
|
|||||||
package dev.relism.flash.http;
|
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.nio.charset.StandardCharsets;
|
||||||
import java.util.List;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-compiled byte representations of standard HTTP status lines.
|
* Pre-compiled byte representations of standard HTTP status lines. Uses a direct-access array for
|
||||||
* Uses a direct-access array for O(1) lookup with zero allocation.
|
* O(1) lookup with zero allocation.
|
||||||
*/
|
*/
|
||||||
public enum HttpStatus {
|
public enum HttpStatus {
|
||||||
|
|
||||||
// 1xx
|
// 1xx
|
||||||
CONTINUE (100, "Continue"),
|
CONTINUE(100, "Continue"),
|
||||||
SWITCHING_PROTOCOLS (101, "Switching Protocols"),
|
SWITCHING_PROTOCOLS(101, "Switching Protocols"),
|
||||||
|
|
||||||
// 2xx
|
// 2xx
|
||||||
OK (200, "OK"),
|
OK(200, "OK"),
|
||||||
CREATED (201, "Created"),
|
CREATED(201, "Created"),
|
||||||
ACCEPTED (202, "Accepted"),
|
ACCEPTED(202, "Accepted"),
|
||||||
NO_CONTENT (204, "No Content"),
|
NO_CONTENT(204, "No Content"),
|
||||||
PARTIAL_CONTENT (206, "Partial Content"),
|
PARTIAL_CONTENT(206, "Partial Content"),
|
||||||
|
|
||||||
// 3xx
|
// 3xx
|
||||||
MOVED_PERMANENTLY (301, "Moved Permanently"),
|
MOVED_PERMANENTLY(301, "Moved Permanently"),
|
||||||
FOUND (302, "Found"),
|
FOUND(302, "Found"),
|
||||||
NOT_MODIFIED (304, "Not Modified"),
|
NOT_MODIFIED(304, "Not Modified"),
|
||||||
TEMPORARY_REDIRECT (307, "Temporary Redirect"),
|
TEMPORARY_REDIRECT(307, "Temporary Redirect"),
|
||||||
PERMANENT_REDIRECT (308, "Permanent Redirect"),
|
PERMANENT_REDIRECT(308, "Permanent Redirect"),
|
||||||
|
|
||||||
// 4xx
|
// 4xx
|
||||||
BAD_REQUEST (400, "Bad Request"),
|
BAD_REQUEST(400, "Bad Request"),
|
||||||
UNAUTHORIZED (401, "Unauthorized"),
|
UNAUTHORIZED(401, "Unauthorized"),
|
||||||
FORBIDDEN (403, "Forbidden"),
|
FORBIDDEN(403, "Forbidden"),
|
||||||
NOT_FOUND (404, "Not Found"),
|
NOT_FOUND(404, "Not Found"),
|
||||||
METHOD_NOT_ALLOWED (405, "Method Not Allowed"),
|
METHOD_NOT_ALLOWED(405, "Method Not Allowed"),
|
||||||
NOT_ACCEPTABLE (406, "Not Acceptable"),
|
NOT_ACCEPTABLE(406, "Not Acceptable"),
|
||||||
CONFLICT (409, "Conflict"),
|
CONFLICT(409, "Conflict"),
|
||||||
GONE (410, "Gone"),
|
GONE(410, "Gone"),
|
||||||
LENGTH_REQUIRED (411, "Length Required"),
|
LENGTH_REQUIRED(411, "Length Required"),
|
||||||
PAYLOAD_TOO_LARGE (413, "Payload Too Large"),
|
PRECONDITION_FAILED(412, "Precondition Failed"),
|
||||||
URI_TOO_LONG (414, "URI Too Long"),
|
PAYLOAD_TOO_LARGE(413, "Payload Too Large"),
|
||||||
UNSUPPORTED_MEDIA_TYPE (415, "Unsupported Media Type"),
|
URI_TOO_LONG(414, "URI Too Long"),
|
||||||
UNPROCESSABLE_ENTITY (422, "Unprocessable Entity"),
|
UNSUPPORTED_MEDIA_TYPE(415, "Unsupported Media Type"),
|
||||||
TOO_MANY_REQUESTS (429, "Too Many Requests"),
|
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
|
// 5xx
|
||||||
INTERNAL_SERVER_ERROR (500, "Internal Server Error"),
|
INTERNAL_SERVER_ERROR(500, "Internal Server Error"),
|
||||||
NOT_IMPLEMENTED (501, "Not Implemented"),
|
NOT_IMPLEMENTED(501, "Not Implemented"),
|
||||||
BAD_GATEWAY (502, "Bad Gateway"),
|
BAD_GATEWAY(502, "Bad Gateway"),
|
||||||
SERVICE_UNAVAILABLE (503, "Service Unavailable"),
|
SERVICE_UNAVAILABLE(503, "Service Unavailable"),
|
||||||
GATEWAY_TIMEOUT (504, "Gateway Timeout");
|
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;
|
// ArrayIndexOutOfBoundsException from this static initializer the moment any constant
|
||||||
private static final byte[][] INDEX = new byte[MAX_STATUS_CODE + 1][];
|
// above it (421, 431, 505, 507, 511 — several of which HTTP/2 needs, see MISDIRECTED_REQUEST
|
||||||
private static final String[] REASONS = new String[MAX_STATUS_CODE + 1];
|
// 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 {
|
static {
|
||||||
for (HttpStatus s : values()) {
|
int max = 0;
|
||||||
INDEX[s.code] = s.bytes;
|
for (HttpStatus s : values()) max = Math.max(max, s.code);
|
||||||
REASONS[s.code] = s.reason;
|
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 int code;
|
||||||
private final String reason;
|
private final String reason;
|
||||||
private final byte[] bytes;
|
private final byte[] bytes;
|
||||||
|
private final byte[] hpackBytes;
|
||||||
|
|
||||||
HttpStatus(int code, String reason) {
|
HttpStatus(int code, String reason) {
|
||||||
this.code = code;
|
this.code = code;
|
||||||
this.reason = reason;
|
this.reason = reason;
|
||||||
this.bytes = (code + " " + reason).getBytes(StandardCharsets.UTF_8);
|
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}). */
|
/** Returns reason phrase for the given code, or null if unknown. */
|
||||||
public int code() { return code; }
|
public static String reasonForCode(int code) {
|
||||||
|
if (code >= 0 && code <= MAX_STATUS_CODE) {
|
||||||
/** Pre-encoded {@code "200 OK"} bytes — zero allocation on the write path. */
|
return REASONS[code];
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Returns reason phrase for the given code, or null if unknown. */
|
/** Returns the precompiled HPACK status field for a known code, or {@code null}. */
|
||||||
public static String reasonForCode(int code) {
|
public static byte[] hpackBytesForCode(int code) {
|
||||||
if (code >= 0 && code <= MAX_STATUS_CODE) {
|
return code >= 0 && code <= MAX_STATUS_CODE ? HPACK_INDEX[code] : null;
|
||||||
return REASONS[code];
|
}
|
||||||
}
|
|
||||||
return 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);
|
||||||
}
|
}
|
||||||
}
|
return java.util.Arrays.copyOf(out.array(), out.length());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<List<?>> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The 14 HTTP/2 error codes defined by RFC 9113 §7.
|
||||||
|
*
|
||||||
|
* <p>Each constant carries its 4-byte big-endian wire encoding, precomputed once at class
|
||||||
|
* load (RFC 9113 §6.4 {@code RST_STREAM} and §6.8 {@code GOAWAY} both carry the error code as
|
||||||
|
* a raw 32-bit field — there is no framing around it to build). Callers write
|
||||||
|
*
|
||||||
|
* <p>{@code Http2ErrorCode} is used to reject a peer <em>and</em> to interpret what a peer
|
||||||
|
* sends us: {@link #fromCode(int)} decodes a received 32-bit value. RFC 9113 does not reserve
|
||||||
|
* unknown codes for future use in a way that requires us to accept them silently as one of the
|
||||||
|
* 14 — an endpoint that receives an error code it does not recognise treats it as
|
||||||
|
* {@code INTERNAL_ERROR}-equivalent for logging purposes; {@link #fromCode(int)} returns
|
||||||
|
* {@code null} for that case and callers log the raw integer rather than guessing a mapping.
|
||||||
|
*/
|
||||||
|
public enum Http2ErrorCode {
|
||||||
|
|
||||||
|
/** Graceful shutdown or successful completion; not an error. RFC 9113 §7. */
|
||||||
|
NO_ERROR(0x00),
|
||||||
|
/** The peer violated the protocol in a way not covered by a more specific code. */
|
||||||
|
PROTOCOL_ERROR(0x01),
|
||||||
|
/** Unexpected internal condition on our side (e.g. an uncaught exception on the demux loop). */
|
||||||
|
INTERNAL_ERROR(0x02),
|
||||||
|
/** A flow-control window was violated: overflow past 2^31-1, or a peer exceeded its window. */
|
||||||
|
FLOW_CONTROL_ERROR(0x03),
|
||||||
|
/** The peer did not acknowledge our SETTINGS within {@code SETTINGS_ACK_TIMEOUT_MS}. */
|
||||||
|
SETTINGS_TIMEOUT(0x04),
|
||||||
|
/** A frame was received for a stream that is already closed. */
|
||||||
|
STREAM_CLOSED(0x05),
|
||||||
|
/** A frame's length did not match what its type requires (RFC 9113 §4.2, per-type rules). */
|
||||||
|
FRAME_SIZE_ERROR(0x06),
|
||||||
|
/** The stream was refused before any processing; safe for the client to retry elsewhere. */
|
||||||
|
REFUSED_STREAM(0x07),
|
||||||
|
/** Used by clients to cancel a stream; Flash never sends it, only receives it. */
|
||||||
|
CANCEL(0x08),
|
||||||
|
/** An HPACK decoding failure. Terminates the connection because the dynamic table state is lost. */
|
||||||
|
COMPRESSION_ERROR(0x09),
|
||||||
|
/** A CONNECT-tunnelled stream failed. */
|
||||||
|
CONNECT_ERROR(0x0a),
|
||||||
|
/** The peer is generating excessive load (rate-limit rejection: Rapid Reset, PING/SETTINGS floods). */
|
||||||
|
ENHANCE_YOUR_CALM(0x0b),
|
||||||
|
/** The negotiated TLS parameters fall below RFC 9113 §9.2's minimum security requirements. */
|
||||||
|
INADEQUATE_SECURITY(0x0c),
|
||||||
|
/** Defined by RFC 9113 for HTTP/1.1-only resources; Flash serves everything over h2, so unused. */
|
||||||
|
HTTP_1_1_REQUIRED(0x0d);
|
||||||
|
|
||||||
|
private static final Http2ErrorCode[] BY_CODE = new Http2ErrorCode[values().length];
|
||||||
|
|
||||||
|
static {
|
||||||
|
for (Http2ErrorCode c : values()) {
|
||||||
|
BY_CODE[c.code] = c;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final int code;
|
||||||
|
private final byte[] bytes;
|
||||||
|
|
||||||
|
Http2ErrorCode(int code) {
|
||||||
|
this.code = code;
|
||||||
|
this.bytes = new byte[]{
|
||||||
|
(byte) (code >>> 24),
|
||||||
|
(byte) (code >>> 16),
|
||||||
|
(byte) (code >>> 8),
|
||||||
|
(byte) code
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The numeric error code as it appears on the wire. */
|
||||||
|
public int code() {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The pre-encoded 4-byte big-endian wire form. Safe to write directly into a
|
||||||
|
* {@code RST_STREAM} or {@code GOAWAY} payload with a single {@code System.arraycopy} —
|
||||||
|
* never allocated or formatted per use.
|
||||||
|
*/
|
||||||
|
public byte[] bytes() {
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decodes a 32-bit error code received from a peer. Returns {@code null} for a value
|
||||||
|
* outside the 14 defined codes; the caller should log the raw integer rather than assume
|
||||||
|
* a mapping, since RFC 9113 permits future extension codes we do not yet know about.
|
||||||
|
*/
|
||||||
|
public static Http2ErrorCode fromCode(int code) {
|
||||||
|
if (code >= 0 && code < BY_CODE.length) {
|
||||||
|
return BY_CODE[code];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A <b>connection-level</b> HTTP/2 error. Thrown anywhere a peer's frame, HPACK block, or
|
||||||
|
* SETTINGS value violates the protocol in a way that leaves the connection's state (the HPACK
|
||||||
|
* dynamic table, a flow-control window, the stream table) unrecoverable.
|
||||||
|
*
|
||||||
|
* single site: it sends {@code GOAWAY} with {@link #errorCode()} and closes the connection.
|
||||||
|
* Compare {@link Http2StreamException}, whose scope is one stream and which results in
|
||||||
|
* {@code RST_STREAM} while the connection survives.
|
||||||
|
*
|
||||||
|
* <p>Deliberately does <b>not</b> extend {@link java.io.IOException}: the connection loop
|
||||||
|
* distinguishes a protocol violation (a decision Flash made about the peer's bytes) from a
|
||||||
|
* socket failure (the peer went away) by catching these as unrelated types. Conflating them
|
||||||
|
* would make it possible to accidentally treat a hostile peer's malformed frame as a harmless
|
||||||
|
* disconnect, or vice versa.
|
||||||
|
*
|
||||||
|
* <h3>Why stack traces are disabled</h3>
|
||||||
|
* This exception is thrown on the connection's hot rejection path — a single malformed byte
|
||||||
|
* from a hostile or buggy peer can trigger it, and under a scripted attack that can happen many
|
||||||
|
* times per second across many connections. JVM stack trace capture ({@code fillInStackTrace})
|
||||||
|
* is by far the most expensive part of constructing a {@code Throwable}, and it buys nothing
|
||||||
|
* here: the call site is exactly where {@code errorCode()} says it is, and the debug message
|
||||||
|
* already names the specific violation. The 4-argument {@link RuntimeException} constructor
|
||||||
|
* disables both suppression and stack-trace writing.
|
||||||
|
*
|
||||||
|
* <h3>Preallocated singletons</h3>
|
||||||
|
* For the common, message-less rejections (frame validation failures, HPACK structural errors)
|
||||||
|
* this class exposes shared singleton instances. Reusing one instance across threads and across
|
||||||
|
* many throws is safe <em>only because</em> the instance carries no per-throw mutable state and
|
||||||
|
* writable-stack-trace is disabled — nothing about a throw mutates the exception object.
|
||||||
|
*/
|
||||||
|
public final class Http2Exception extends RuntimeException {
|
||||||
|
|
||||||
|
private final Http2ErrorCode errorCode;
|
||||||
|
|
||||||
|
private Http2Exception(Http2ErrorCode errorCode, String message) {
|
||||||
|
super(message, null, false, false);
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The RFC 9113 §7 error code to send in the {@code GOAWAY} frame. */
|
||||||
|
public Http2ErrorCode errorCode() {
|
||||||
|
return errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a connection error carrying a caller-supplied debug message. Allocates a new
|
||||||
|
* message carries information specific to this occurrence (e.g. the offending stream id or
|
||||||
|
* a decoded value); use one of the preallocated singletons below when it does not.
|
||||||
|
*/
|
||||||
|
public static Http2Exception of(Http2ErrorCode code, String message) {
|
||||||
|
return new Http2Exception(code, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Preallocated, message-less singletons for the hot rejection paths ──────────────────
|
||||||
|
|
||||||
|
public static final Http2Exception PROTOCOL_ERROR =
|
||||||
|
new Http2Exception(Http2ErrorCode.PROTOCOL_ERROR, "protocol error");
|
||||||
|
public static final Http2Exception FRAME_SIZE_ERROR =
|
||||||
|
new Http2Exception(Http2ErrorCode.FRAME_SIZE_ERROR, "frame size error");
|
||||||
|
public static final Http2Exception FLOW_CONTROL_ERROR =
|
||||||
|
new Http2Exception(Http2ErrorCode.FLOW_CONTROL_ERROR, "flow control error");
|
||||||
|
public static final Http2Exception COMPRESSION_ERROR =
|
||||||
|
new Http2Exception(Http2ErrorCode.COMPRESSION_ERROR, "compression error");
|
||||||
|
public static final Http2Exception INTERNAL_ERROR =
|
||||||
|
new Http2Exception(Http2ErrorCode.INTERNAL_ERROR, "internal error");
|
||||||
|
public static final Http2Exception SETTINGS_TIMEOUT =
|
||||||
|
new Http2Exception(Http2ErrorCode.SETTINGS_TIMEOUT, "settings ack timeout");
|
||||||
|
}
|
||||||
@@ -0,0 +1,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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every bound the HTTP/2 implementation enforces against a peer's input, in one place.
|
||||||
|
*
|
||||||
|
* <p>Every wire-derived length, index, count, or size is checked against a named constant here —
|
||||||
|
* never against an ad-hoc literal, and never by letting the underlying array or buffer throw on
|
||||||
|
* overrun. Each field's Javadoc names the specific attack or resource it bounds and, where one
|
||||||
|
* exists, the CVE.
|
||||||
|
*
|
||||||
|
* <p>These are compile-time defaults, not runtime configuration. A limit becomes configurable only
|
||||||
|
* when the operational need and its safe range are established.
|
||||||
|
*
|
||||||
|
* <p>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.
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A <b>stream-level</b> HTTP/2 error, scoped to one stream id. Results in an {@code RST_STREAM}
|
||||||
|
* frame for {@link #streamId()} with {@link #errorCode()}; the connection and every other
|
||||||
|
* stream on it are unaffected. Compare {@link Http2Exception}, whose scope is the whole
|
||||||
|
* connection.
|
||||||
|
*
|
||||||
|
* <p>Deliberately does <b>not</b> extend {@link java.io.IOException}, for the same reason as
|
||||||
|
* {@link Http2Exception}: the connection loop must be able to distinguish "we decided to reject
|
||||||
|
* this stream" from "the socket failed" by catching unrelated exception types.
|
||||||
|
*
|
||||||
|
* <h3>Why this allocates, unlike {@code Http2Exception}'s singletons</h3>
|
||||||
|
* Every instance carries a distinct {@link #streamId()}, so it cannot be a shared singleton the
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <p>Stack trace capture is disabled for the same cost reason as {@link Http2Exception}.
|
||||||
|
*/
|
||||||
|
public final class Http2StreamException extends RuntimeException {
|
||||||
|
|
||||||
|
private final Http2ErrorCode errorCode;
|
||||||
|
private final int streamId;
|
||||||
|
|
||||||
|
public Http2StreamException(int streamId, Http2ErrorCode errorCode, String message) {
|
||||||
|
super(message, null, false, false);
|
||||||
|
this.streamId = streamId;
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The id of the stream this error terminates. */
|
||||||
|
public int streamId() {
|
||||||
|
return streamId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The RFC 9113 §7 error code to send in the {@code RST_STREAM} frame. */
|
||||||
|
public Http2ErrorCode errorCode() {
|
||||||
|
return errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package dev.relism.flash.http2.frame;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The frame-header flag bits (RFC 9113 §6), as bitwise constants plus predicate helpers.
|
||||||
|
*
|
||||||
|
* <h3>The deliberate collision</h3>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <p>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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
package dev.relism.flash.http2.frame;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A <b>flyweight</b> 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}).
|
||||||
|
*
|
||||||
|
* <h3>Lifetime contract</h3>
|
||||||
|
* 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
|
||||||
|
*
|
||||||
|
* <h3>Reserved bit and unknown types</h3>
|
||||||
|
* {@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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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").
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <p><b>The error code is not uniform</b> — 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <em>before</em> the first byte goes out, which back-patching
|
||||||
|
* deliberately avoids needing.
|
||||||
|
*
|
||||||
|
* <h3>Usage</h3>
|
||||||
|
* <pre>{@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
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <h3>Multiple frames, one buffer</h3>
|
||||||
|
* {@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).
|
||||||
|
*
|
||||||
|
* <h3>Thread-safety</h3>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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".
|
||||||
|
*
|
||||||
|
* <h3>Buffer discipline</h3>
|
||||||
|
*
|
||||||
|
* One growable {@code byte[]} per connection, reused across every frame — the same
|
||||||
|
* compact-before-grow discipline {@code RequestParser}'s own buffer uses. A frame's declared length
|
||||||
|
* is checked against {@link Http2Limits#MAX_FRAME_SIZE_LOCAL} <em>before</em> the buffer
|
||||||
|
* length-check, not after an allocation already paid for it.
|
||||||
|
*
|
||||||
|
* <h3>Usage</h3>
|
||||||
|
*
|
||||||
|
* <pre>{@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()
|
||||||
|
* }</pre>
|
||||||
|
*
|
||||||
|
* <h3>Thread-safety</h3>
|
||||||
|
*
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* <p>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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
*
|
||||||
|
* <h2>The design, three layers</h2>
|
||||||
|
*
|
||||||
|
* <p><b>Layer 1 — serialize outside the lock.</b> 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."
|
||||||
|
*
|
||||||
|
* <p><b>Layer 2 — {@link ReentrantLock}, never {@code synchronized}.</b> On Java 21, a virtual
|
||||||
|
* thread blocking inside {@code synchronized} pins its carrier platform thread; blocking on a
|
||||||
|
* {@link ReentrantLock} unmounts it instead (JEP 491, which removes the {@code synchronized} {@code
|
||||||
|
* ReentrantLock} is also load-bearing here for a second reason {@code synchronized} cannot offer:
|
||||||
|
* {@link ReentrantLock#tryLock()}.
|
||||||
|
*
|
||||||
|
* <p><b>Layer 3 — {@code tryLock()} fast path, intrusive MPSC fallback.</b> 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.
|
||||||
|
*
|
||||||
|
* <h3>Lost-wakeup avoidance</h3>
|
||||||
|
*
|
||||||
|
* 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):
|
||||||
|
*
|
||||||
|
* <pre>
|
||||||
|
* 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()
|
||||||
|
* </pre>
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <h3>Write timeout</h3>
|
||||||
|
*
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <p><b>Why the fast path is gated on {@code !queue.hasWork()}, not just {@code tryLock()}</b>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <p>Deliberately does <em>not</em> 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
|
||||||
|
* <em>consecutive scans</em> 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<Http2FrameWriter> ACTIVE = ConcurrentHashMap.newKeySet();
|
||||||
|
// Touched only by the single reaper thread -- no synchronization needed.
|
||||||
|
private static final java.util.Map<Http2FrameWriter, Integer> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <em>is</em> 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.
|
||||||
|
*
|
||||||
|
* <h3>Only {@link Http2FrameWriter} calls {@link #poll()}</h3>
|
||||||
|
* 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.
|
||||||
|
*
|
||||||
|
* <h3>The stub node and the "inconsistent" result</h3>
|
||||||
|
* 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<WriteIntent> 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 <em>or</em> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).
|
||||||
|
*
|
||||||
|
* <p>Padding is <b>not optional to support</b>: any client may send it on DATA or HEADERS
|
||||||
|
* regardless of whether the server ever sends padded frames itself.
|
||||||
|
*
|
||||||
|
* <h3>Flow control (forward note, not implemented here)</h3>
|
||||||
|
* RFC 9113 §6.9.1: padding bytes count against the DATA flow-control window even though they
|
||||||
|
* carry no data — the <em>whole</em> frame payload (pad-length byte + data + padding) is what a
|
||||||
|
* #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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}.
|
||||||
|
*
|
||||||
|
* <h3>Layer 1 — serialize outside the lock</h3>
|
||||||
|
*
|
||||||
|
* 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).
|
||||||
|
*
|
||||||
|
* <h3>Intrusive queue linkage</h3>
|
||||||
|
*
|
||||||
|
* {@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() {}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user