Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0dda8e47a | ||
|
|
cf16be08c0 | ||
|
|
825bdfc942 | ||
|
|
3679eed74a | ||
|
|
6386264a1e | ||
|
|
f3011ffdf6 | ||
|
|
3c1eb0d0df | ||
|
|
5755ef77fe | ||
|
|
ee90ac44ff | ||
|
|
8d5340a0b4 |
@@ -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;
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
@@ -168,18 +168,63 @@ app.onException((ex, req, res) -> {
|
|||||||
| `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) |
|
| `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/http2/HTTP1-HARDENING.md). |
|
| `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. |
|
| `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. |
|
| `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. |
|
| `shutdownDrainTimeoutMs` | `15000` | How long graceful shutdown waits for in-flight requests before force-closing. |
|
||||||
| `http2Enabled` | `false` | Whether this server will ever negotiate HTTP/2. Off by default until the HTTP/2 connection state machine lands (see `flash/docs/http2/IMPLEMENTATION-PLAN.md`). |
|
| `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
|
||||||
|
|
||||||
@@ -265,7 +310,7 @@ upgrading `Request` — no separate TLS state is tracked for WS.
|
|||||||
`Request` and `Response` are **pooled per connection**, not allocated per request: one instance is
|
`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
|
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
|
same idiom Java NIO buffers use, applied to the whole request/response model
|
||||||
(`flash/docs/http2/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1
|
(`flash/docs/core/MESSAGE-MODEL.md` has the full design record). This is what makes a warm h1
|
||||||
request/response cycle 0 B/op.
|
request/response cycle 0 B/op.
|
||||||
|
|
||||||
**Do not retain a `Request` or `Response` past the handler that received it.** A reference kept in
|
**Do not retain a `Request` or `Response` past the handler that received it.** A reference kept in
|
||||||
@@ -316,6 +361,34 @@ app.get("/health", (req, res) -> res.header(NO_STORE).body("ok"));
|
|||||||
HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared
|
HTTP/1-only; HPACK needs the name and value as separate fields. Prefer `PreEncodedHeader` for shared
|
||||||
application and middleware code.
|
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
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -323,13 +396,9 @@ TransportFactory.create() # binds every listener, wires the connection
|
|||||||
→ AcceptLoop # one per listener × accept thread; hands sockets off
|
→ AcceptLoop # one per listener × accept thread; hands sockets off
|
||||||
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
|
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
|
||||||
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
|
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
|
||||||
→ Http1Connection.run() # the ConnectionProtocol seam; HTTP/2 plugs in here later
|
├─ Http1Connection.run() # request parser, router, handler, h1 response writer
|
||||||
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
|
└─ Http2Connection.run() # frame demux, HPACK, stream dispatch, flow control
|
||||||
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
|
→ RequestHandler.handle() # the same protocol-neutral request/response API
|
||||||
→ RequestHandler.handle() # user handler; return value sets body
|
|
||||||
→ Request.drain() # consume unread body for keep-alive
|
|
||||||
→ Http1ResponseWriter.write() # status line, headers, then fixed or chunked body
|
|
||||||
→ loop or close socket # based on Connection header, or ServerLifecycle draining
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
|
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
|
||||||
@@ -337,7 +406,7 @@ TransportFactory.create() # binds every listener, wires the connection
|
|||||||
- **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** — h1 and h2 (in progress, see `flash/docs/http2/`) are peers behind this interface, decided once per connection by `ProtocolNegotiator`, never by an `if` inside shared code. See `flash/docs/http2/TRANSPORT.md` for the full component breakdown.
|
- **`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
|
||||||
|
|
||||||
@@ -350,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
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# The Byte Layer (Phase 4)
|
# The byte layer
|
||||||
|
|
||||||
Audience: contributors. This is the design record for `dev.relism.flash.bytes` — the
|
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
|
protocol-neutral byte primitives both HTTP/1.1 and HTTP/2 build on — and for the Phase 4
|
||||||
@@ -38,7 +38,7 @@ ByteView (fpr-core)
|
|||||||
│ ├── FastPathViews.StringByteView a String's UTF-8 bytes
|
│ ├── FastPathViews.StringByteView a String's UTF-8 bytes
|
||||||
│ └── PooledSlice the EX-05 reusable, pool-issued slice
|
│ └── PooledSlice the EX-05 reusable, pool-issued slice
|
||||||
└── (bare ByteView, not array-backed)
|
└── (bare ByteView, not array-backed)
|
||||||
├── SegmentedByteView K discontiguous segments (future HPACK CONTINUATION)
|
├── SegmentedByteView K discontiguous segments (general-purpose; HPACK stays contiguous)
|
||||||
└── FastPathViews.MethodPathByteView method bytes + another ByteView, composed
|
└── FastPathViews.MethodPathByteView method bytes + another ByteView, composed
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -101,26 +101,25 @@ and every match position (including unaligned starts and matches at the very las
|
|||||||
and `ByteScanFuzzTest` throws 20 000 fully-random trials at each, per the plan's task 1. All
|
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.
|
green — see the class's own Javadoc for the full technique writeup.
|
||||||
|
|
||||||
## `EX-09`: `HeaderMap`'s index
|
## `Http1HeaderMap`'s index
|
||||||
|
|
||||||
Before this phase, every `HeaderMap` lookup (`first`, `all`, `view`, `valueEqualsIgnoreCase`)
|
Originally, every `Http1HeaderMap` lookup (`first`, `all`, `view`, `valueEqualsIgnoreCase`)
|
||||||
rescanned the entire header section from scratch — O(n·m) for a realistic middleware chain
|
rescanned the entire header section from scratch — O(n·m) for a realistic middleware chain
|
||||||
performing 6–10 lookups per request. `HeaderMap.reset()` now scans the section exactly once,
|
performing 6–10 lookups per request. `RequestParser` now populates the index while it validates
|
||||||
recording per-header `(nameOffset, nameLength, valueOffset, valueLength)` and a case-insensitive
|
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
|
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`
|
(never shrunk) to the connection's high-water mark, capped by `Http1Limits.MAX_HEADER_COUNT`
|
||||||
(asserted, not silently truncated — Phase 1 already rejects any request that would exceed it).
|
(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)`,
|
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
|
computed once) against the index's hashes before ever falling back to a full case-insensitive
|
||||||
name comparison. Net effect: the header section is scanned once per request (at `reset()`) plus
|
name comparison. Production therefore performs one combined validation/index pass rather than
|
||||||
once at parse time (`RequestParser`'s own validation pass) — two scans total, replacing "one scan
|
one parse-time pass plus one rescan per lookup. `forEach` uses the same index rather than keeping
|
||||||
at parse time plus one rescan per lookup" — strictly less work even for a single lookup, and much
|
an independent scanner.
|
||||||
less for the realistic multi-lookup case. `forEach` was unified onto the same index rather than
|
|
||||||
keeping its own independent scan, removing a second, easily-diverging scanning implementation.
|
|
||||||
|
|
||||||
## `EX-05`: pooled slices
|
## `EX-05`: pooled slices
|
||||||
|
|
||||||
`HeaderMap.view`, `QueryParams.view`, and `PathParams.view` used to allocate a fresh anonymous
|
`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
|
`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
|
(`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,
|
contract, restated on each method: **a returned view stays valid until either the request ends,
|
||||||
@@ -128,15 +127,15 @@ or the same `view()` method is called `VIEW_POOL_SIZE` more times on the same in
|
|||||||
whichever comes first** — at which point the ring silently repositions the same object over
|
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:
|
different bytes. This is a real, demonstrated hazard, not a hypothetical one:
|
||||||
`SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice` and the analogous tests in
|
`SlicePoolTest#wraparoundAliasesThePreviouslyReturnedSlice` and the analogous tests in
|
||||||
`HeaderMapIndexTest`, `QueryParamsFastPathTest`, and `PathParamsTest` all show a 5th call
|
`Http1HeaderMapIndexTest`, `QueryParamsFastPathTest`, and `PathParamsTest` all show a 5th call
|
||||||
returning the exact same object instance the 1st call did, now aliased to different content.
|
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
|
`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
|
— 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
|
request (or, for `PathParams`'s `FastPathRouterImpl`-owned reusable instance, once per
|
||||||
connection) regardless of whether `view()` is ever invoked; an eager pool would add
|
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; `HeaderMap`'s
|
`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 `HeaderMap` handles headers) and is
|
pool, by contrast, is unconditionally useful (every request's map handles headers) and is
|
||||||
constructed eagerly for simplicity.
|
constructed eagerly for simplicity.
|
||||||
|
|
||||||
**Two documented, deliberately-kept exceptions to "no `new ByteView()` remains"**: `QueryParams.view`
|
**Two documented, deliberately-kept exceptions to "no `new ByteView()` remains"**: `QueryParams.view`
|
||||||
@@ -145,15 +144,15 @@ backing source is *not* `ArrayBackedByteView` — structurally unreachable on th
|
|||||||
today (`RequestParser` only ever constructs array-backed views), kept because both constructors
|
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,
|
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
|
allocating fallback was judged preferable to either crashing on a technically-valid input or
|
||||||
deleting a case that only test/future code could exercise. `HeaderMap.view` has no such fallback
|
deleting a case that only test code could exercise. `Http1HeaderMap.view` has no such fallback
|
||||||
— it is always buffer-backed by construction.
|
— it is always buffer-backed by construction.
|
||||||
|
|
||||||
## `EX-19`/`EX-06` (router half): the `FastPathRouterImpl` scratch
|
## `EX-19`/`EX-06` (router half): the `FastPathRouterImpl` scratch
|
||||||
|
|
||||||
`FastPathRouterImpl.RouteScratch` (created once per connection via `AbstractRouter#newScratch`,
|
`FastPathRouterImpl.RouteScratch` (created once per connection via `AbstractRouter#newScratch`,
|
||||||
replacing the `ThreadLocal<MatchResult>`/`ThreadLocal<MethodPathByteView>` pair — see
|
replacing the `ThreadLocal<MatchResult>`/`ThreadLocal<MethodPathByteView>` pair, as an opaque
|
||||||
`DECISIONS.md`, `DEC-19`, for why this is an opaque caller-owned object rather than an extension
|
caller-owned object rather than an extension of `ConnectionScratch`) also owns the reusable
|
||||||
of `ConnectionScratch`) also owns the reusable path-param arrays and a single long-lived
|
path-param arrays and a single long-lived
|
||||||
`PathParams` instance, grown (via `ensureParamCapacity`, doubling) to the largest param count any
|
`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
|
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
|
reallocated on every match. `PathParams` gained a second, count-explicit constructor and a public
|
||||||
@@ -173,7 +172,6 @@ escapes, and mixed queries (`QueryParamsFastPathTest`).
|
|||||||
|
|
||||||
## Performance measurement
|
## Performance measurement
|
||||||
|
|
||||||
`EX-04` (the router's word-at-a-time path) and `EX-33` (the SWAR header-end scan) both carry an
|
`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" instruction in the plan. Both are measured
|
explicit "measure, and keep only if it doesn't cost" requirement. Both were measured together
|
||||||
together with the phase's overall zero-allocation contract in one JMH pass — see `DECISIONS.md`,
|
with the phase's overall zero-allocation contract in one JMH pass, and both were kept.
|
||||||
`DEC-20`, for the numbers and the keep/revert decision for each.
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# HTTP/1.1 Hardening (Phase 1)
|
# HTTP/1.1 hardening
|
||||||
|
|
||||||
Audience: operators. This is the document to read when a `400`/`413`/`414`/`431`/`501` shows up
|
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
|
in the logs and it isn't obvious why. Every rejection rule Flash's HTTP/1.1 parser enforces is
|
||||||
@@ -62,9 +62,8 @@ as `1`) instead of rejecting them — this is the fix.
|
|||||||
| `MAX_TRAILER_COUNT` | 50 | `431` |
|
| `MAX_TRAILER_COUNT` | 50 | `431` |
|
||||||
| A chunk's data not followed by `\r\n`, or a malformed chunk-size/trailer terminator | — | `400` |
|
| A chunk's data not followed by `\r\n`, or a malformed chunk-size/trailer terminator | — | `400` |
|
||||||
|
|
||||||
Trailers are consumed (safely, within the bounds above) but discarded, not exposed to the
|
Trailers are consumed within the bounds above and exposed through `Request.trailers()` on both
|
||||||
handler, on HTTP/1.1 today — exposing them via `Request.trailers()` on both protocols is Phase
|
HTTP/1.1 and HTTP/2.
|
||||||
12 scope.
|
|
||||||
|
|
||||||
## Timeouts (`FlashConfiguration`)
|
## Timeouts (`FlashConfiguration`)
|
||||||
|
|
||||||
@@ -73,7 +72,7 @@ handler, on HTTP/1.1 today — exposing them via `Request.trailers()` on both pr
|
|||||||
| `idleKeepAliveTimeoutMs` | 60 000 | How long a keep-alive connection may sit idle waiting for its next request. |
|
| `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. |
|
| `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. |
|
| `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 (wired up starting Phase 2). |
|
| `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
|
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
|
socket timeout alone never trips against a peer that sends one byte just often enough to keep
|
||||||
@@ -89,6 +88,5 @@ implemented on top of the JDK's per-read-only timeout API.
|
|||||||
suite list is filtered against the RFC 9113 Appendix A blocklist
|
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
|
(`TlsConfig.TLS12_H2_BLOCKED_CIPHERS`, ~280 entries). TLS 1.3 is never affected — none of its
|
||||||
cipher suites are on that list.
|
cipher suites are on that list.
|
||||||
- HTTP/2 itself is not yet served in this phase (lands in Phase 8): a connection that negotiates
|
- `FlashConfiguration.http2Enabled` advertises `h2` on TLS listeners. The independent
|
||||||
`h2` via ALPN, or that opens with the h2c prior-knowledge preface while
|
`http2CleartextEnabled` switch accepts the h2c prior-knowledge preface on plaintext listeners.
|
||||||
`FlashConfiguration.http2Enabled` is set, is currently closed cleanly rather than served.
|
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
# The Message Model (Phase 6)
|
# The message model
|
||||||
|
|
||||||
Audience: contributors. This is the design record for `dev.relism.flash.models`'s request/response
|
Audience: contributors. This is the design record for `dev.relism.flash.models`'s shared
|
||||||
object model after Phase 6's refactor — what is pooled, what that pooling actually means for code
|
request/response model: what is pooled, what that pooling means for callers, and how HTTP/1.1 and
|
||||||
that touches these objects, and the allocation fixes (`EX-20`–`EX-24`, `EX-27`, `EX-28`, `EX-29`,
|
HTTP/2 retain the same public contract.
|
||||||
`EX-42`) that got the h1 request/response cycle to the zero-alloc contract Phase 4 (`DEC-20`) left
|
|
||||||
open.
|
|
||||||
|
|
||||||
## Why this exists
|
## Why this exists
|
||||||
|
|
||||||
@@ -103,15 +101,14 @@ sequence (`headerTags`/`headerRefs`) interleaves the two stores back into declar
|
|||||||
serialized, so mixing `header(String,String)` and `header(byte[])` calls on the same response still
|
serialized, so mixing `header(String,String)` and `header(byte[])` calls on the same response still
|
||||||
produces headers in the order they were added.
|
produces headers in the order they were added.
|
||||||
|
|
||||||
`PreEncodedHeader` precomputes a header's name+value ASCII bytes once (e.g. for a constant response
|
`PreEncodedHeader` precomputes a header's name and value ASCII bytes once (for example, a constant
|
||||||
header set at boot) — deliberately does **not** yet expose HPACK-encoded bytes, since HPACK does
|
response header set at boot). Preserving the boundary lets HTTP/1.1 render a field line and HTTP/2
|
||||||
not exist until Phase 9; that scope boundary is recorded in the class's own Javadoc rather than
|
encode the same pair through HPACK without a second public header type.
|
||||||
building untested, speculative API surface now.
|
|
||||||
|
|
||||||
`ResponseSerializer.forEachField(Response, FieldConsumer)` is the **one source of truth for what
|
`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
|
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
|
header, in order, and is the only place that knowledge lives. `Http1ResponseWriter` renders that
|
||||||
sequence as `Name: Value\r\n` lines; the Phase 9 h2 encoder will render the same sequence as HPACK.
|
sequence as `Name: Value\r\n` lines; `Http2ResponseWriter` renders the same sequence as HPACK.
|
||||||
Deliberately excluded: `Content-Length`/`Connection`/`Date` (connection framing, not response
|
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
|
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).
|
`header(byte[])` entries (no recoverable name/value structure to hand the h2 encoder).
|
||||||
@@ -138,11 +135,11 @@ back down between requests. Both checks throw `IllegalStateException`, not
|
|||||||
`HeaderMap` split into `HeaderView` (the protocol-neutral read contract: `first`, `all`, `view`,
|
`HeaderMap` split into `HeaderView` (the protocol-neutral read contract: `first`, `all`, `view`,
|
||||||
`valueEqualsIgnoreCase`, `contains`, `count`, `forEach`) and `Http1HeaderMap` (the existing
|
`valueEqualsIgnoreCase`, `contains`, `count`, `forEach`) and `Http1HeaderMap` (the existing
|
||||||
byte-buffer-backed implementation, kept in `dev.relism.flash.models` rather than moved to
|
byte-buffer-backed implementation, kept in `dev.relism.flash.models` rather than moved to
|
||||||
`dev.relism.flash.http1` — see `DECISIONS.md`, `DEC-22`, for why: `RequestParser` (root package)
|
`dev.relism.flash.http1`: `RequestParser` (root package) owns and constructs it, and
|
||||||
owns and constructs it, and `http1`→root already exists via `Http1Connection`, so moving it to
|
`http1`→root already exists via `Http1Connection`, so moving it to `http1` would create a
|
||||||
`http1` would create a `models`↔`http1` package cycle). `RequestLine.headers` is typed as the
|
`models`↔`http1` package cycle). `RequestLine.headers` is typed as the
|
||||||
interface, so a future `Http2HeaderMap` (Phase 10, HPACK-backed) is a drop-in second
|
interface; `Http2HeaderMap` is the HPACK-backed second implementation without requiring a
|
||||||
implementation, not a `Request`/`RequestLine` API change.
|
`Request` or `RequestLine` API split.
|
||||||
|
|
||||||
## `ByteTemplate` (`EX-28`)
|
## `ByteTemplate` (`EX-28`)
|
||||||
|
|
||||||
@@ -181,7 +178,7 @@ call — pre-existing since at least Phase 4, invisible until the larger `Reques
|
|||||||
`RequestLine` cost sitting on top of them was removed. Fixed the same way as everything else in
|
`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
|
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,
|
pooled instance per role. `parseAndRoute` measures 0.008 B/op after the fix — JMH's noise floor,
|
||||||
effectively 0. Full numbers in `DECISIONS.md`, `DEC-23`.
|
effectively 0.
|
||||||
|
|
||||||
## The zero-alloc contract, closed
|
## The zero-alloc contract, closed
|
||||||
|
|
||||||
@@ -191,6 +188,4 @@ effectively 0. Full numbers in `DECISIONS.md`, `DEC-23`.
|
|||||||
`RequestPipelineBenchmark.parseAndRoute` (parse + route with a parametric match, no header/param
|
`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
|
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
|
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"). See
|
exempts ("except for the user-facing `String`s the handler explicitly asks for").
|
||||||
`DECISIONS.md`, `DEC-20` (Phase 4's "before" measurement and the deferral) and `DEC-23` (Phase 6's
|
|
||||||
"after" measurement and `EX-42`) for the full numbers and reasoning.
|
|
||||||
@@ -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.
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
# Transport Architecture (Phase 2)
|
# Transport architecture
|
||||||
|
|
||||||
Audience: contributors. This is the document Phase 3 onward extends as HTTP/2 grows a real
|
Audience: contributors. This document describes the shared listener and connection layer behind
|
||||||
connection state machine behind the seam described here.
|
the HTTP/1.1 and HTTP/2 implementations.
|
||||||
|
|
||||||
## Why this exists
|
## Why this exists
|
||||||
|
|
||||||
Before Phase 2, `HttpServer` (563 lines) did bind, accept, virtual-thread dispatch, WebSocket
|
The original `HttpServer` (563 lines) did bind, accept, virtual-thread dispatch, WebSocket
|
||||||
upgrade detection, WebSocket handshake, the WebSocket session loop, keep-alive detection, HTTP
|
upgrade detection, WebSocket handshake, the WebSocket session loop, keep-alive detection, HTTP
|
||||||
response serialization, chunked encoding, hex encoding, and decimal encoding — eleven reasons to
|
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
|
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
|
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`).
|
writes in `WebSocketSession`, which pins a virtual thread's carrier on Java 21 (`EX-01`).
|
||||||
|
|
||||||
Phase 2 replaces it with named, single-responsibility components and the `ConnectionProtocol`
|
The current design replaces it with named, single-responsibility components and a
|
||||||
seam HTTP/2 will plug into starting Phase 8.
|
`ConnectionProtocol` seam implemented by both wire protocols.
|
||||||
|
|
||||||
## Package layout
|
## Package layout
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ dev.relism.flash.websocket (existing package, extended)
|
|||||||
```
|
```
|
||||||
TransportFactory.create(configuration, router, wsRouter)
|
TransportFactory.create(configuration, router, wsRouter)
|
||||||
binds every configured listener (ListenerBinder)
|
binds every configured listener (ListenerBinder)
|
||||||
builds one ConnectionRunner (shared virtual-thread executor, ScratchPool, Http1Connection)
|
builds one ConnectionRunner (shared virtual-thread executor, ScratchPool, both protocols)
|
||||||
returns a ServerLifecycle (implements ServerHandle)
|
returns a ServerLifecycle (implements ServerHandle)
|
||||||
|
|
||||||
ServerLifecycle.start()
|
ServerLifecycle.start()
|
||||||
@@ -71,8 +71,8 @@ ConnectionRunner.handle(socket, stopped)
|
|||||||
if SSLSocket: force startHandshake() under headerReadTimeoutMs (EX-30)
|
if SSLSocket: force startHandshake() under headerReadTimeoutMs (EX-30)
|
||||||
wrap streams: BufferedByteSource in, buffered OutputStream out, raw OutputStream rawOut
|
wrap streams: BufferedByteSource in, buffered OutputStream out, raw OutputStream rawOut
|
||||||
negotiated = negotiateProtocol(socket, in) # ALPN or h2c preface
|
negotiated = negotiateProtocol(socket, in) # ALPN or h2c preface
|
||||||
if negotiated == H2: return # no Http2Connection yet (Phase 8) -- close cleanly
|
build ConnectionContext
|
||||||
build ConnectionContext, dispatch to http1Protocol.run(ctx)
|
dispatch to http1Protocol.run(ctx) or http2Protocol.run(ctx)
|
||||||
finally:
|
finally:
|
||||||
activeSockets.remove(socket); scratchPool.release(scratch)
|
activeSockets.remove(socket); scratchPool.release(scratch)
|
||||||
```
|
```
|
||||||
@@ -96,12 +96,8 @@ leak-free arena: above its bound (`min(availableProcessors * 64, 4096)` by defau
|
|||||||
scratch is simply dropped for the garbage collector rather than queued, so an unusually large
|
scratch is simply dropped for the garbage collector rather than queued, so an unusually large
|
||||||
burst of connections cannot grow it without limit.
|
burst of connections cannot grow it without limit.
|
||||||
|
|
||||||
The router's own `ThreadLocal`s (`FastPathRouterImpl`, `FastPathWsRouterImpl`) are **not**
|
The routers use an explicit per-connection scratch passed through `AbstractRouter.route`; neither
|
||||||
removed in this phase — `EX-06`'s registry entry explicitly phases that part of the fix to
|
`FastPathRouterImpl` nor `FastPathWsRouterImpl` retains connection state in a `ThreadLocal`.
|
||||||
Phase 4, where the router also gains the API surface change (a scratch parameter, or reading
|
|
||||||
from the request's context) needed to remove them correctly. See `DECISIONS.md` (`DEC-15`) for
|
|
||||||
why Phase 2's Definition of Done was corrected to say so explicitly rather than silently drift
|
|
||||||
from the registry.
|
|
||||||
|
|
||||||
## The `ConnectionProtocol` seam (R1 / `DEC-02`)
|
## The `ConnectionProtocol` seam (R1 / `DEC-02`)
|
||||||
|
|
||||||
@@ -112,10 +108,9 @@ public interface ConnectionProtocol {
|
|||||||
```
|
```
|
||||||
|
|
||||||
`ConnectionRunner` decides h1 vs h2 exactly once, immediately after ALPN/preface detection, and
|
`ConnectionRunner` decides h1 vs h2 exactly once, immediately after ALPN/preface detection, and
|
||||||
dispatches. Today only `Http1Connection` exists; an `H2` negotiation result is closed cleanly
|
dispatches to `Http1Connection` or `Http2Connection`. Neither implementation is aware the other
|
||||||
(there is no `Http2Connection` to hand off to until Phase 8). Neither implementation is aware
|
exists — `dev.relism.flash.http1` and `dev.relism.flash.http2` do not import each other, enforced
|
||||||
the other exists — `dev.relism.flash.http1` and `dev.relism.flash.http2` do not import each other,
|
by `PackageBoundaryTest`.
|
||||||
enforced by `PackageBoundaryTest`.
|
|
||||||
|
|
||||||
## Graceful shutdown (`EX-32`)
|
## Graceful shutdown (`EX-32`)
|
||||||
|
|
||||||
@@ -130,7 +125,8 @@ Two stages, driven by `ServerLifecycle.stop()`:
|
|||||||
(the common case). `ServerLifecycle.stop()` polls `activeSockets` for up to
|
(the common case). `ServerLifecycle.stop()` polls `activeSockets` for up to
|
||||||
`shutdownDrainTimeoutMs`, then force-closes whatever remains and shuts down the executor.
|
`shutdownDrainTimeoutMs`, then force-closes whatever remains and shuts down the executor.
|
||||||
|
|
||||||
HTTP/2's half of this fix (a `GOAWAY` frame, RFC 9113 §6.8) lands in Phase 8.
|
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`)
|
## What changed for WebSocket (`EX-01`, `EX-11`, `EX-12`, `EX-13`)
|
||||||
|
|
||||||
@@ -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.
|
||||||
@@ -1,953 +0,0 @@
|
|||||||
# Flash HTTP/2 — Decision Log
|
|
||||||
|
|
||||||
This is the living record of every non-obvious choice made while implementing
|
|
||||||
`flash/docs/http2/IMPLEMENTATION-PLAN.md`. It is not a changelog of what was built — the git
|
|
||||||
history is that — it is a record of *why*, for choices that were not forced by the RFC and that
|
|
||||||
a future reader would otherwise have to re-derive or, worse, silently re-litigate.
|
|
||||||
|
|
||||||
Every entry: **Context / Options / Decision / Consequence / Revisit when**.
|
|
||||||
|
|
||||||
Seeded at Phase 0 with `DEC-01`…`DEC-10` (the decisions already implied by the plan itself, per
|
|
||||||
Appendix A). Every subsequent non-obvious choice appends a new entry with the next free number.
|
|
||||||
Numbers are never reused, even if a decision is later reversed — the reversal gets its own entry
|
|
||||||
that supersedes the earlier one and says so explicitly.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-01 — HTTP/2 lives in `flash` core, package `dev.relism.flash.http2`, not an extension
|
|
||||||
|
|
||||||
**Context.** Flash has an extension mechanism (`flash-ext-*` modules) for optional
|
|
||||||
functionality. HTTP/2 could in principle be shipped as `flash-ext-h2`.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Ship as an extension, loaded optionally.
|
|
||||||
2. Ship in `flash` core, alongside HTTP/1.1.
|
|
||||||
|
|
||||||
**Decision.** Core (option 2).
|
|
||||||
|
|
||||||
**Consequence.** The protocol decision (h1 vs h2) is made once, immediately after
|
|
||||||
ALPN/preface detection, inside the transport layer. `HttpServer` (and its Phase 2 replacement)
|
|
||||||
is package-private to `flash` core; an extension cannot hook into ALPN negotiation or the
|
|
||||||
accept loop without core exposing seams it does not otherwise need. HTTP/2 is a transport
|
|
||||||
concern in the same sense HTTP/1.1 is — it cannot be optional in the way, say, an OpenAPI
|
|
||||||
generator is.
|
|
||||||
|
|
||||||
**Revisit when.** Never, absent a restructuring of the extension mechanism itself to support
|
|
||||||
transport-level extensions (not currently planned).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-02 — h1 and h2 are peers behind a `ConnectionProtocol` seam, never flags in shared code
|
|
||||||
|
|
||||||
**Context.** The obvious shortcut is `if (isHttp2) { ... } else { ... }` scattered through the
|
|
||||||
existing HTTP/1.1 code paths.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Flag-branch inside shared code.
|
|
||||||
2. A `ConnectionProtocol` interface with two implementations (`Http1Connection`,
|
|
||||||
`Http2Connection`), selected once per connection.
|
|
||||||
|
|
||||||
**Decision.** Option 2 (R1).
|
|
||||||
|
|
||||||
**Consequence.** Shared code (byte scanning, the writer discipline, `Request`/`Response`) is
|
|
||||||
extracted upward into protocol-neutral components (`dev.relism.flash.bytes`,
|
|
||||||
`ResponseSerializer`), never pushed sideways with a protocol flag. This is enforced by an
|
|
||||||
architecture test (Phase 2) asserting `dev.relism.flash.http1` never references
|
|
||||||
`dev.relism.flash.http2` and vice versa. The cost is more up-front extraction work in Phase 2 and
|
|
||||||
Phase 6; the benefit is that h1 throughput cannot regress from an `if` that the JIT fails to
|
|
||||||
eliminate, and that either implementation can be read in isolation.
|
|
||||||
|
|
||||||
**Revisit when.** Never — this is a structural invariant, not a tunable.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-03 — `ReentrantLock` everywhere, never `synchronized` around blocking I/O
|
|
||||||
|
|
||||||
**Context.** Java 21 (this project's baseline) has virtual threads (JEP 444) but not JEP 491
|
|
||||||
(which removes `synchronized` carrier-pinning); JEP 491 lands in JDK 24. A virtual thread that
|
|
||||||
blocks inside a `synchronized` block pins its carrier platform thread for the duration of the
|
|
||||||
block, including any blocking I/O inside it.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Keep `synchronized` where it already exists (`WebSocketSession`, `EX-01`) and accept the
|
|
||||||
pinning risk.
|
|
||||||
2. Replace every `synchronized` block that can block on I/O with `java.util.concurrent.locks
|
|
||||||
.ReentrantLock`, which unmounts a blocked virtual thread instead of pinning its carrier.
|
|
||||||
|
|
||||||
**Decision.** Option 2, applied retroactively to the existing WebSocket code (Phase 2) and as a
|
|
||||||
standing rule for every future connection-writer path, most importantly `Http2FrameWriter`
|
|
||||||
(Phase 3).
|
|
||||||
|
|
||||||
**Consequence.** One virtual thread blocking on a slow write no longer starves the carrier pool
|
|
||||||
for every other connection scheduled onto that carrier. The cost is that `ReentrantLock` is
|
|
||||||
slightly more expensive than an uncontended `synchronized` monitor in the*platform-thread* case
|
|
||||||
— irrelevant here, since every request-serving thread in this codebase is virtual.
|
|
||||||
|
|
||||||
**Revisit when.** The project's Java baseline moves to JDK 24+ and JEP 491 is confirmed to
|
|
||||||
remove pinning for `synchronized`. Even then, `ReentrantLock`'s explicit `tryLock()` — which
|
|
||||||
`synchronized` cannot offer — is load-bearing for Phase 3's writer design, so this decision
|
|
||||||
would only partially reverse.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-04 — The HPACK **encoder** uses the static table only; no dynamic table
|
|
||||||
|
|
||||||
**Context.** RFC 7541's dynamic table is optional for an encoder (a decoder must always
|
|
||||||
support the peer using one; nothing requires the encoder to use one itself). Using it on the
|
|
||||||
encode side would save bytes on repeated headers (e.g. a constant `server` value) but requires
|
|
||||||
mutable, connection-shared state: an insertion changes indices for every subsequent encode on
|
|
||||||
that connection.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Encoder uses the dynamic table, saving bytes on repeated custom headers.
|
|
||||||
2. Encoder emits only Indexed (static) and Literal-Without-Indexing representations; no dynamic
|
|
||||||
table, no mutable encoder state.
|
|
||||||
|
|
||||||
**Decision.** Option 2.
|
|
||||||
|
|
||||||
**Consequence.** The write path — already the project's largest architectural risk (Phase 3) —
|
|
||||||
needs no shared-table lock and no invalidation protocol across concurrently-writing streams.
|
|
||||||
The cost is a few extra bytes per response for headers that do not already have a static-table
|
|
||||||
entry (i.e. everything except the ~30 header names RFC 7541 Appendix A knows about). The
|
|
||||||
encoder still honours the peer's `SETTINGS_HEADER_TABLE_SIZE` by sending a Dynamic Table Size
|
|
||||||
Update of 0 at the start of the first header block, declaring "I will never use this table" —
|
|
||||||
a correctness detail, not optional politeness (Phase 9 task 1).
|
|
||||||
|
|
||||||
**Revisit when.** Benchmark evidence (Phase 17) shows the extra wire bytes materially hurt
|
|
||||||
throughput or latency on a realistic workload — not before. A shared dynamic table is a
|
|
||||||
non-trivial correctness surface (see `DEC-06`'s discussion of the analogous decode-side hazard)
|
|
||||||
and should only be taken on with a measured reason.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-05 — Huffman-encode constants at boot; emit runtime values as raw literals
|
|
||||||
|
|
||||||
**Context.** HPACK lets the encoder Huffman-code any string at its option. Constants (status
|
|
||||||
lines, `content-type` values) are a closed, known set and can be Huffman-encoded once, at class
|
|
||||||
initialization, for free at runtime. Runtime-generated values (a dynamic `ETag`, a user-set
|
|
||||||
custom header) would need to be Huffman-encoded on every response.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Huffman-encode everything, including runtime values, on every write.
|
|
||||||
2. Huffman-encode only boot-time constants; emit runtime values as raw (uncompressed) literals.
|
|
||||||
|
|
||||||
**Decision.** Option 2, with `FlashConfiguration.h2HuffmanDynamicValues` (default `false`) so
|
|
||||||
option 1's cost/benefit can actually be measured on real traffic rather than argued about in
|
|
||||||
the abstract.
|
|
||||||
|
|
||||||
**Consequence.** The response write path's critical section has no per-byte Huffman encode
|
|
||||||
loop for the common case. The cost is a few extra bytes on the wire for runtime header values,
|
|
||||||
which HPACK's other mechanisms (indexing on the receive side, if the receiver chooses to use
|
|
||||||
its dynamic table) can still partially recover.
|
|
||||||
|
|
||||||
**Revisit when.** Phase 17 benchmarks the flag both ways on a representative response shape.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-06 — Decoded headers are copied into a **per-stream** arena, not referenced in the dynamic table
|
|
||||||
|
|
||||||
**Context.** A `ByteView` into the HPACK dynamic table's arena is valid only while its entry is
|
|
||||||
still live. Under HTTP/1.1 this is trivially safe (one thread, one request at a time). Under
|
|
||||||
HTTP/2, the demux thread can decode a second stream's HEADERS — evicting and overwriting
|
|
||||||
dynamic-table arena bytes — while a handler on a different virtual thread is still reading a
|
|
||||||
view produced by an earlier decode. This is a genuine, silent data race: it does not manifest
|
|
||||||
in any test that decodes one block at a time, only under real multiplexed load.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Reference dynamic-table entries directly from decoded `ByteView`s, and protect them with an
|
|
||||||
epoch or reference-count scheme so an entry cannot be evicted while still referenced.
|
|
||||||
2. Copy every decoded header (name and value) into an arena owned by the stream being
|
|
||||||
assembled, at decode time. One `~30`-byte-average `memcpy` per header; correctness by
|
|
||||||
construction, no cross-thread coordination.
|
|
||||||
|
|
||||||
**Decision.** Option 2.
|
|
||||||
|
|
||||||
**Consequence.** Header decode is not zero-copy relative to the dynamic table (R3's "honest
|
|
||||||
naming" clause applies: HTTP/2 copies each novel header once per connection and references it
|
|
||||||
by index thereafter — the per-stream arena copy is that one copy). In exchange, no handler can
|
|
||||||
ever observe a torn or evicted header value, and the demux thread never needs to coordinate
|
|
||||||
with a handler thread to decode the next block. Per-stream arenas are pooled (returned on
|
|
||||||
stream close) so this is zero allocation at steady state despite the copy.
|
|
||||||
|
|
||||||
**Revisit when.** Profiling (Phase 17) shows the per-header copy is a measurable cost on a
|
|
||||||
realistic HPACK-heavy workload. Even then, option 1's concurrent bookkeeping is a large
|
|
||||||
correctness surface to take on to avoid a small `memcpy`, and should not be revisited casually.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-07 — `:authority` is exposed to user code as both `:authority` and `host`
|
|
||||||
|
|
||||||
**Context.** HTTP/2 requests carry authority information in the `:authority` pseudo-header
|
|
||||||
(RFC 9113 §8.3.1), not a `Host` header — `host` may optionally also be present and, if so, must
|
|
||||||
match `:authority`, but is not required. Existing Flash middleware (and most middleware in the
|
|
||||||
wild) reads `Host` by convention, inherited from HTTP/1.1.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Expose only `:authority`, under whatever name the h2 header map uses for pseudo-headers.
|
|
||||||
Middleware written against `Host` silently breaks on h2.
|
|
||||||
2. Expose `:authority`'s value under both keys: the literal `:authority` and `host`.
|
|
||||||
|
|
||||||
**Decision.** Option 2.
|
|
||||||
|
|
||||||
**Consequence.** A single small duplication (one extra index entry into the same per-stream
|
|
||||||
arena bytes — no extra copy) buys behavioural parity for existing and future middleware that
|
|
||||||
reads `Host`, without requiring every middleware author to special-case h2. Documented in
|
|
||||||
`flash/docs/http2/STREAMS.md`.
|
|
||||||
|
|
||||||
**Revisit when.** Not planned to be revisited; this is a compatibility shim with negligible
|
|
||||||
cost, not a design compromise under pressure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-08 — Flash ships HTTP/2, not a gRPC codec
|
|
||||||
|
|
||||||
**Context.** gRPC is one of the strongest motivations for HTTP/2 support (Pathway's upstream
|
|
||||||
use case), and it is tempting to let that motivation expand scope into shipping gRPC framing,
|
|
||||||
proto codecs, or a service-definition layer.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Ship a gRPC codec/framework alongside HTTP/2 transport support.
|
|
||||||
2. Ship HTTP/2 transport only; validate gRPC compatibility with an interop test, not a feature.
|
|
||||||
|
|
||||||
**Decision.** Option 2.
|
|
||||||
|
|
||||||
**Consequence.** Phase 12's `GrpcInteropTest` proves that the protocol features gRPC actually
|
|
||||||
needs — trailers, `content-type: application/grpc`, `te: trailers`, half-close, streaming — are
|
|
||||||
present and correct, using a real gRPC client against a hand-written Flash handler that speaks
|
|
||||||
the wire format directly. Flash does not gain a dependency on any gRPC/protobuf library, and
|
|
||||||
users who want a gRPC service framework build it on top of Flash rather than being handed one.
|
|
||||||
|
|
||||||
**Revisit when.** Not planned to be revisited; this is a scope boundary, not a temporary
|
|
||||||
limitation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-09 — The chosen `Http2FrameWriter` design, with its benchmark numbers
|
|
||||||
|
|
||||||
**Context.** Phase 3 is a GO/NO-GO gate: build and benchmark the connection-level serialized
|
|
||||||
frame writer, the one genuinely novel architectural risk in this codebase's HTTP/2 work (see
|
|
||||||
Part I's "one thread owns the socket" framing). Three candidate designs were built and compared
|
|
||||||
against the plan's numeric gate criteria: (a) `plain_lock` — unconditional
|
|
||||||
`ReentrantLock.lock()` per frame; (b) `trylock_mpsc` — `tryLock()` fast path with an intrusive
|
|
||||||
Vyukov-style MPSC queue fallback; (c) `dedicated_thread` — every write handed off via the same
|
|
||||||
MPSC queue to one dedicated, parked/unparked writer thread. A fourth harness,
|
|
||||||
`raw_unsynchronized` (no coordination at all — unsafe, not a candidate), establishes the N=1
|
|
||||||
baseline the 50 ns budget is measured against.
|
|
||||||
|
|
||||||
**Options.** (a), (b), (c) as above — full description, JMH methodology, and raw numbers in
|
|
||||||
`flash/docs/http2/WRITER.md`.
|
|
||||||
|
|
||||||
**Decision.** (b), `trylock_mpsc` — matching the plan's own proposed design. Measured against
|
|
||||||
every gate criterion (JDK 21.0.11, JMH 1.37; see `WRITER.md` for the complete methodology
|
|
||||||
including its two stated caveats — an in-memory counting sink rather than a real loopback
|
|
||||||
socket, and one JMH "op" being a 4 000-write burst rather than a single write):
|
|
||||||
|
|
||||||
| Criterion | Result | Verdict |
|
|
||||||
|---|---|---|
|
|
||||||
| N=1: 0 B/op | 0.0015 B/write differential vs. `raw_unsynchronized`, within measurement noise | PASS |
|
|
||||||
| N=1: ≤50 ns overhead vs. raw unsynchronized | 42.6 ns point estimate, ≤47.9 ns at the 99.9% CI's worst case | PASS |
|
|
||||||
| N=64: throughput ≥60% of N=1 per-thread rate | 65.5% | PASS |
|
|
||||||
| N=64: p999 <1 ms | 11.8–14.2 µs | PASS |
|
|
||||||
| No carrier pinning (`-Djdk.tracePinnedThreads=full`) | none observed | PASS |
|
|
||||||
| Stress test green at every N ∈ {1,2,8,64,256}, 1000 iterations, incl. parallelism=1 | 10 000/10 000 | PASS |
|
|
||||||
|
|
||||||
`plain_lock` was also measured for comparison (not merely asserted inferior): it retains only
|
|
||||||
58.1% of its own N=1 throughput at N=64 (below the 60% bar `trylock_mpsc` clears) and its p999
|
|
||||||
latency blows up to 1.6–2.0 ms under load — unfair blocking causing tail pile-up, exactly the
|
|
||||||
failure mode a naive per-frame lock predicts. `dedicated_thread` has the best tail latency of the
|
|
||||||
three (1.5–6.7 µs at N=64) but pays a ~3.3× throughput penalty at N=1, because every write —
|
|
||||||
even a genuinely uncontended one — pays a full park/unpark handoff; there is no fast path for
|
|
||||||
the dominant "one active writer" case. Neither alternative is a better shipped default than
|
|
||||||
`trylock_mpsc`.
|
|
||||||
|
|
||||||
**Consequence.** `Http2FrameWriter` ships exactly as designed in the plan: `tryLock()` fast path
|
|
||||||
(one uncontended CAS on the overwhelmingly common single-writer case), intrusive MPSC fallback
|
|
||||||
under genuine contention (the `WriteIntent` itself is the queue node — zero allocation to
|
|
||||||
enqueue), `ReentrantLock` throughout (never `synchronized` — `EX-01`'s carrier-pinning fix
|
|
||||||
generalized to the connection writer), and a scan-based write-timeout reaper
|
|
||||||
(`Http2Limits.WRITE_TIMEOUT_MS`, 30 s) rather than a per-write `System.nanoTime()` deadline — an
|
|
||||||
earlier revision recorded a per-write deadline and this phase's own benchmark is what caught it
|
|
||||||
costing enough to threaten the 50 ns budget, which is itself part of why the reaper's
|
|
||||||
consecutive-scan design (documented on `Http2FrameWriter.WriteTimeoutReaper`) exists. Phase 4 may
|
|
||||||
proceed.
|
|
||||||
|
|
||||||
**Revisit when.** Not expected to be revisited — the three-candidate comparison is unlikely to
|
|
||||||
change qualitatively unless the JDK's virtual-thread scheduler or `ReentrantLock` implementation
|
|
||||||
changes materially. If a future JDK's `synchronized` stops pinning carriers (JEP 491, JDK 24+),
|
|
||||||
revisit whether `synchronized`'s simpler semantics become preferable now that its only drawback
|
|
||||||
here is removed — but `ReentrantLock` still uniquely offers `tryLock()`, which this design's fast
|
|
||||||
path depends on, so the revisit is not expected to change the outcome.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-10 — `Upgrade: h2c` is deliberately **not** implemented
|
|
||||||
|
|
||||||
**Context.** RFC 7540 §3.2 (the original HTTP/2 RFC) defined an `Upgrade: h2c` mechanism to
|
|
||||||
move a plaintext HTTP/1.1 connection to HTTP/2 mid-connection. RFC 9113 (which obsoletes
|
|
||||||
RFC 7540) §3.1 removes this mechanism entirely from the current specification.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Implement `Upgrade: h2c` for compatibility with any client that still relies on it.
|
|
||||||
2. Do not implement it; support cleartext HTTP/2 only via prior knowledge (RFC 9113 §3.4).
|
|
||||||
|
|
||||||
**Decision.** Option 2.
|
|
||||||
|
|
||||||
**Consequence.** Every h2c client that matters for Flash's use case (gRPC, and every modern h2c
|
|
||||||
implementation) uses prior knowledge, not the upgrade dance, so nothing is lost in practice.
|
|
||||||
Recorded explicitly so a future contributor who notices `Upgrade: h2c` is unhandled does not
|
|
||||||
assume it was an oversight and add it back.
|
|
||||||
|
|
||||||
**Revisit when.** A concrete client that requires `Upgrade: h2c` and cannot be changed is
|
|
||||||
identified. Not anticipated.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-11 — Commit scope stays `core`; `h2` is not added to `AGENTS.md`'s allowed-scope list
|
|
||||||
|
|
||||||
**Context.** `AGENTS.md` (§Commit Messages) enumerates the allowed Conventional Commits scopes.
|
|
||||||
`h2` is not among them. R9 leaves the choice open: either add `h2` as a new scope via a
|
|
||||||
`docs:` commit, or use `core` and record the decision here.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Add `h2` as a new allowed scope, so h2-specific commits are distinguishable in history from
|
|
||||||
other core work at a glance.
|
|
||||||
2. Use the existing `core` scope for all HTTP/2 work.
|
|
||||||
|
|
||||||
**Decision.** Option 2.
|
|
||||||
|
|
||||||
**Consequence.** All HTTP/2 commits use `feat(core): ...` / `fix(core): ...` /
|
|
||||||
`refactor(core): ...`, consistent with the branch name (`feature/core/http2`) and with `DEC-01`
|
|
||||||
(HTTP/2 is core, not a separate concern). A reader can still find every h2-related commit via
|
|
||||||
the file paths touched (`dev.relism.flash.http2/**`, `flash/docs/http2/**`) or via the commit body,
|
|
||||||
which is no worse than a scope label and avoids growing the scope list for what is, by `DEC-01`,
|
|
||||||
not actually a separate module.
|
|
||||||
|
|
||||||
**Revisit when.** The `h2` package's commit volume makes `core` too coarse to navigate in
|
|
||||||
`git log` — not expected before Phase 10 at the earliest, if ever.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-12 — Phase 1 plan corrections: two missing files, one corrected limit check
|
|
||||||
|
|
||||||
**Context.** While implementing Phase 1, two problems in the plan document itself surfaced
|
|
||||||
(distinct from problems in the *code*, which is what the `EX-nn` registry tracks).
|
|
||||||
|
|
||||||
1. Phase 1 task 8 requires "a `BufferedByteSource` owned by the connection that wraps the read
|
|
||||||
buffer plus the socket and exposes `readByte()`, `readFully(...)`, `skip(...)` and `peek()`",
|
|
||||||
and task 12 depends on it for h2c preface detection — but the Phase 1 **Files** list never
|
|
||||||
named the file. Likewise, the typed rejection `EX-02`/`EX-03`/`EX-08`/`EX-18` all need (a
|
|
||||||
specific HTTP status to respond with, as distinct from `HttpException`'s handler-routed
|
|
||||||
semantics — see `DEC-14`) was never named as a file either.
|
|
||||||
2. Task 4's exact wording — "Enforce `MAX_REQUEST_LINE_LENGTH` against `headerEndIdx - base` for
|
|
||||||
the request line specifically" — describes checking the length of the *entire header block*
|
|
||||||
(`headerEndIdx` is where the whole header section ends), not the request line. The request
|
|
||||||
line's own end is `protocolEnd` (or `sectionStart`), not `headerEndIdx`.
|
|
||||||
|
|
||||||
**Decision.**
|
|
||||||
1. Added `flash/src/main/java/dev/relism/flash/transport/BufferedByteSource.java` and
|
|
||||||
`flash/src/main/java/dev/relism/flash/exceptions/MalformedRequestException.java` to Phase 1's
|
|
||||||
Files list (see the phase section itself, now corrected in place).
|
|
||||||
2. Implemented the check as `protocolEnd - base > MAX_REQUEST_LINE_LENGTH` — the request line's
|
|
||||||
actual span — rather than the literal (and, read literally, incorrect) `headerEndIdx - base`.
|
|
||||||
|
|
||||||
**Consequence.** None beyond the plan text now matching what was actually built and why —
|
|
||||||
these are wording/omission fixes, not design trade-offs. Recorded per the plan's own rule that
|
|
||||||
corrections to the plan must be explicit and tracked, never silent.
|
|
||||||
|
|
||||||
**Revisit when.** N/A — already resolved.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-13 — `BufferedByteSource`'s deadline is enforced by computing the exact remaining `SO_TIMEOUT` per underlying read, not by a fixed poll-and-retry loop
|
|
||||||
|
|
||||||
**Context.** `EX-07` requires an *absolute* deadline across a sequence of socket reads (a
|
|
||||||
per-read `SO_TIMEOUT` alone never trips against a peer that keeps each individual read within
|
|
||||||
the window while never completing the whole message — the canonical slowloris shape). Two ways
|
|
||||||
to implement that on top of the blocking `Socket`/`SSLSocket` API, which only offers a per-read
|
|
||||||
timeout:
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Set `SO_TIMEOUT` to a fixed, short polling interval (e.g. 1 s); on each
|
|
||||||
`SocketTimeoutException`, re-check whether the absolute deadline has actually passed, and if
|
|
||||||
not, retry. Deadline precision is bounded by the poll interval (up to ~1 s of slop).
|
|
||||||
2. Before every underlying read, compute the exact remaining budget
|
|
||||||
(`deadlineNanos - System.nanoTime()`) and hand that exact value to `setSoTimeout`. A
|
|
||||||
`SocketTimeoutException` from that read then unambiguously means the deadline — not merely
|
|
||||||
one poll cycle — has elapsed, with no retry loop needed.
|
|
||||||
|
|
||||||
**Decision.** Option 2.
|
|
||||||
|
|
||||||
**Consequence.** Deadline precision is exact (modulo OS timer granularity) rather than
|
|
||||||
poll-interval-bounded, and the implementation is simpler — no retry loop, no distinction between
|
|
||||||
"timed out this poll" and "timed out for real". The cost is one `setSoTimeout` syscall per
|
|
||||||
underlying fill (not per byte, not per `read()` call served from the buffer) — negligible, since
|
|
||||||
fills already happen at buffer granularity (up to 8 KiB at a time), not per byte.
|
|
||||||
|
|
||||||
**Revisit when.** Not expected to be revisited; this is strictly better than option 1 on both
|
|
||||||
precision and simplicity.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-14 — `MalformedRequestException extends HttpException`; caught separately from the per-request handler try/catch, never routed through the user's exception handler
|
|
||||||
|
|
||||||
**Context.** `EX-02`/`EX-03`/`EX-08`/`EX-18` all need to reject a request with a specific HTTP
|
|
||||||
status before any handler or middleware runs. `HttpException` already exists in this codebase
|
|
||||||
for "carry a status code, get turned into a response" — but it is caught by
|
|
||||||
`router.getExceptionHandler()` inside the per-request try/catch, which is user-configurable
|
|
||||||
(e.g. `flash-ext-jackson` installs a JSON-formatting handler).
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Reuse `HttpException` directly, letting a malformed request flow through the same
|
|
||||||
user-configurable exception handler as an application-level failure.
|
|
||||||
2. A new type, `MalformedRequestException extends HttpException`, caught at a separate site —
|
|
||||||
around `parser.parse(in)` itself, before routing — with a fixed, minimal, non-customizable
|
|
||||||
response, always followed by closing the connection.
|
|
||||||
|
|
||||||
**Decision.** Option 2.
|
|
||||||
|
|
||||||
**Consequence.** A malformed or hostile request never reaches user code at all — not the
|
|
||||||
handler, not middleware, not a custom exception handler that might (reasonably, for its actual
|
|
||||||
purpose) try to look up a route, log structured JSON, or otherwise do work that assumes a
|
|
||||||
well-formed `Request`. The connection is always closed afterwards, never kept alive, which is
|
|
||||||
exactly the property `EX-02`'s smuggling defense depends on. Subclassing `HttpException` (rather
|
|
||||||
than an unrelated new hierarchy) keeps `status()`/message` access idiomatic with the rest of the
|
|
||||||
codebase's error-status convention, while the distinct type is what lets `HttpServer` catch it
|
|
||||||
at the parse site specifically.
|
|
||||||
|
|
||||||
**Revisit when.** Not expected to be revisited.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-15 — Phase 2 plan correction: the "no `ThreadLocal` anywhere" DoD line was inconsistent with `EX-06`'s own phasing
|
|
||||||
|
|
||||||
**Context.** Phase 2's DoD stated flatly: "No `ThreadLocal` remains anywhere in `flash` core."
|
|
||||||
`EX-06`'s registry entry — the fix this DoD line is checking — explicitly phases itself:
|
|
||||||
"**Phase**: 2 (introduce), 3 (h2 consumes it), 4 (router consumes it)." `FastPathRouterImpl` and
|
|
||||||
`FastPathWsRouterImpl`'s `ThreadLocal`s (`MatchResult`, `MethodPathByteView`) are the "router
|
|
||||||
consumes it" part, assigned to Phase 4 — where the router also gains the scratch-parameter (or
|
|
||||||
request-context) API surface change needed to remove them correctly, per `EX-06`'s own fix
|
|
||||||
description ("the router now takes the scratch as a parameter or reads it from the request's
|
|
||||||
context"). Taken literally, Phase 2's DoD line would have required either doing Phase 4's router
|
|
||||||
work two phases early (undermining the reason `EX-06` was split across phases in the first
|
|
||||||
place — the router-facing API change is more invasive and deserves its own phase) or leaving the
|
|
||||||
DoD unresolvable.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Do the full router `ThreadLocal` removal now, in Phase 2, to satisfy the DoD line literally.
|
|
||||||
2. Correct the DoD line to match `EX-06`'s already-considered phasing, and record why.
|
|
||||||
|
|
||||||
**Decision.** Option 2.
|
|
||||||
|
|
||||||
**Consequence.** Phase 2 removes every `ThreadLocal` `HttpServer` itself owned (`SHA1`,
|
|
||||||
`LONG_BUF`, `STREAM_RELAY_BUFFER` — all now fields on `ConnectionScratch`). The router's two
|
|
||||||
`ThreadLocal`s are explicitly left for Phase 4, tracked there, not silently dropped — this is
|
|
||||||
still R10-compliant (the defect is registered and scheduled, not ignored) and keeps Phase 2
|
|
||||||
scoped to what it already set out to do (kill the `HttpServer` god class), rather than absorbing
|
|
||||||
an unrelated API-surface change under deadline pressure.
|
|
||||||
|
|
||||||
**Revisit when.** N/A — resolved; Phase 4 closes the remaining `EX-06` scope.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-16 — No separate `WebSocketFrameCodec` class; the `EX-11`/`EX-12` fixes stay inside `WebSocketSession`
|
|
||||||
|
|
||||||
**Context.** Phase 2's file list named `dev.relism.flash.websocket.WebSocketFrameCodec.java`,
|
|
||||||
extracted from `WebSocketSession`, as a Phase 2 deliverable — motivated by R6 (no god classes)
|
|
||||||
and by a forward reference in Phase 15 ("this requires abstracting its InputStream/OutputStream
|
|
||||||
pair behind a small interface — which the Phase 2 WebSocketFrameCodec extraction should already
|
|
||||||
have made possible").
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Extract a `WebSocketFrameCodec` operating on byte arrays/scratch buffers, with
|
|
||||||
`WebSocketSession` calling into it for encode/decode and owning only the actual stream I/O.
|
|
||||||
2. Keep frame encode/decode inside `WebSocketSession`, where it already lived.
|
|
||||||
|
|
||||||
**Decision.** Option 2, for this phase.
|
|
||||||
|
|
||||||
**Consequence.** `WebSocketSession` after the `EX-01`/`EX-11`/`EX-12` fixes is ~360 lines — over
|
|
||||||
R6's soft ~250-line guidance, but R6 itself carves out exactly this case: "a 300-line class that
|
|
||||||
is one cohesive state machine ... is fine; a 150-line class doing two things is not." Frame
|
|
||||||
header decode, continuation reassembly, and masking are one state machine (RFC 6455 §5's frame
|
|
||||||
grammar), not two unrelated responsibilities glued together, so the soft guidance's exception
|
|
||||||
applies. Splitting it now, before any concrete second caller exists, risks the "artificial
|
|
||||||
split that doesn't reduce complexity" R6 also warns against implicitly — there is no code today
|
|
||||||
that would consume a standalone codec except `WebSocketSession` itself. Phase 15's forward
|
|
||||||
reference is noted and re-evaluated then: if RFC 8441 (WebSocket over h2) genuinely needs frame
|
|
||||||
encode/decode decoupled from a socket-backed `InputStream`/`OutputStream` pair (an h2 stream is
|
|
||||||
not one), the extraction happens at that point, with a real second shape driving the interface
|
|
||||||
instead of a speculative one.
|
|
||||||
|
|
||||||
**Revisit when.** Phase 15, when RFC 8441's transport requirements are concrete.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-17 — `FrameWriterBenchmark` lives in `src/jmh/java`, a source root registered only inside the `jmh` profile, not in `src/test/java`
|
|
||||||
|
|
||||||
**Context.** The Phase 3 JMH benchmark (`FrameWriterBenchmark`) was first placed directly in
|
|
||||||
`src/test/java/dev/relism/flash/http2/frame/`, on the theory recorded in `flash/pom.xml`'s comment
|
|
||||||
at the time: since the class carries only `@Benchmark`/JMH annotations and no JUnit annotations,
|
|
||||||
Surefire's JUnit-Jupiter engine would simply not select it as a test, so a plain `mvn test` (no
|
|
||||||
`-Pjmh`) would harmlessly ignore it. Verifying this assumption (`mvn -pl flash -am clean
|
|
||||||
test-compile`, no profile) showed it is false: Surefire's `junit-jupiter` engine performs test
|
|
||||||
*discovery* by loading every class under `target/test-classes`, regardless of whether it
|
|
||||||
ultimately selects it as a test — and `FrameWriterBenchmark` cannot even compile without
|
|
||||||
`jmh-core` on the classpath (it imports `org.openjdk.jmh.annotations.*` unconditionally), so with
|
|
||||||
the `jmh` profile inactive the module's test-compile step failed outright: "package
|
|
||||||
org.openjdk.jmh.annotations does not exist". A plain `mvn test` on `flash` — the command every
|
|
||||||
other phase's DoD, and CI itself, uses to verify "still green" — was broken for the entire
|
|
||||||
module, not merely silently skipping the benchmark as intended. This was caught only because
|
|
||||||
this phase's resume step re-ran `mvn test` (via the maven-wrapper distribution under
|
|
||||||
`~/.m2/wrapper/dists`, not a bare `mvn` on `PATH`) without `-Pjmh`, rather than re-running the
|
|
||||||
`-Pjmh`-scoped command the prior session had been using — the same class of gap R10 exists to
|
|
||||||
catch, just in the build graph rather than the source graph.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Keep the benchmark in `src/test/java`, and instead exclude it from the default Surefire test
|
|
||||||
set via `<excludes>` in the `maven-surefire-plugin` configuration, re-including it only when
|
|
||||||
`-Pjmh` is active. This still leaves it on the default `test-compile` classpath, so the
|
|
||||||
compile failure would remain — excludes only affect which already-compiled tests Surefire
|
|
||||||
*runs*, not what the compiler plugin *compiles*. Rejected: does not fix the actual failure.
|
|
||||||
2. Move it to its own source root, `src/jmh/java`, and register that root as a test-source
|
|
||||||
directory (`build-helper-maven-plugin`'s `add-test-source` goal) only inside the `jmh`
|
|
||||||
profile's `<build>`. With the profile inactive, the file is not handed to the compiler at
|
|
||||||
all, under any goal — not `test-compile`, not IDE indexing driven by the effective POM.
|
|
||||||
This is also what the plan itself already suggested (Phase 3's Files list: `flash/src/jmh/
|
|
||||||
java/dev/relism/flash/http2/FrameWriterBenchmark.java (or a flash-bench submodule...)`) — the
|
|
||||||
prior session's placement in `src/test/java` was itself a deviation from the plan's own
|
|
||||||
suggested layout, not a considered alternative.
|
|
||||||
3. A separate `flash-bench` submodule, depending on `flash` and always pulling in JMH. The
|
|
||||||
plan's own text offers this as the other option, rejected for the same reason a `jmh` profile
|
|
||||||
was chosen over it in the first place: a whole extra module (its own `pom.xml`, its own
|
|
||||||
`groupId:artifactId`, its own place in the reactor) for one benchmark class is disproportionate
|
|
||||||
machinery, and it does not obviously fix the underlying problem either — `mvn test` from the
|
|
||||||
repo root still touches every reactor module and would still need the module's own default
|
|
||||||
build to not require JMH.
|
|
||||||
|
|
||||||
**Decision.** Option 2 — matching the plan's original suggestion, which is exactly what should
|
|
||||||
have been done the first time.
|
|
||||||
|
|
||||||
**Consequence.** `mvn -pl flash -am test` (no profile) compiles and runs the ordinary unit/stress
|
|
||||||
tests only, exactly as every other phase's DoD assumes, and never touches JMH. `mvn -Pjmh -pl
|
|
||||||
flash test-compile` (or any goal at `generate-test-sources` or later, with the profile active)
|
|
||||||
additionally compiles `src/jmh/java` into `target/test-classes`, exactly where
|
|
||||||
`FrameWriterBenchmark`'s own Javadoc's run instructions already expected it, so that Javadoc
|
|
||||||
needed no change. `build-helper-maven-plugin` (`${build.helper.plugin.version}`, `3.6.0`) is a
|
|
||||||
new build-time-only dependency of the `flash` module, added to the root `pom.xml`'s
|
|
||||||
`<properties>` alongside `jmh.version`, consistent with how every other plugin version in this
|
|
||||||
reactor is centralized. No production code changed; this is a build-graph correction only.
|
|
||||||
|
|
||||||
**Revisit when.** Not expected to be revisited.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-18 — Phase 17 gains a second, explicitly non-gating category of benchmark: application-level, real-`HttpServer`, showcase/literature-only
|
|
||||||
|
|
||||||
**Context.** Raised while wrapping up Phase 3, after reviewing `FrameWriterBenchmark`'s results
|
|
||||||
with the project owner. Phase 3's benchmark is deliberately narrow — it exercises only
|
|
||||||
`Http2FrameWriter` against an in-memory `CountingSink`, isolating the writer's own lock/queue
|
|
||||||
cost from network variance (see `WRITER.md`'s stated caveats). That narrowness is correct for a
|
|
||||||
GO/NO-GO *component* gate, but it means nothing in the plan yet produces end-to-end, real-
|
|
||||||
`HttpServer` numbers — realistic traffic shapes, or deliberately extreme ones (thousands of
|
|
||||||
streams on one connection, pathological header blocks, slow/bursty clients, mixed h1+h2 on one
|
|
||||||
listener) — of the kind that make a project's performance claims concrete rather than asserted.
|
|
||||||
The project owner wants exactly this: **benchmark-driven development** as an ongoing practice,
|
|
||||||
not only a one-time gate, with results available for showcase and literature purposes
|
|
||||||
(illustrating real behavior under real and extreme conditions) independent of whether they pass
|
|
||||||
or fail anything.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Fold this into Phase 17's existing JMH suite (task 1) and its allocation/latency gates (tasks
|
|
||||||
2–3), i.e. make these new benchmarks part of the same pass/fail pipeline as the rest of
|
|
||||||
Phase 17.
|
|
||||||
2. Add it as a distinct, explicitly non-gating task within Phase 17 — same `src/jmh` source root
|
|
||||||
as the Phase 3 writer benchmark, same JMH tooling, but no threshold, no CI wiring, output
|
|
||||||
meant to be read by a human (or quoted in a doc/blog post), not consumed by a pass/fail check.
|
|
||||||
|
|
||||||
**Decision.** Option 2, recorded now as a scoped goal for Phase 17 (Phase 17's own Tasks list,
|
|
||||||
new task 8) — **not implemented as part of Phase 3 or this decision**. Phase 4 begins immediately
|
|
||||||
after this entry with a clean, unrelated scope.
|
|
||||||
|
|
||||||
**Consequence.** Phase 17, when it lands, produces two categories of benchmark under `src/jmh`,
|
|
||||||
and both must stay distinguishable at a glance (by class name, by package, or by a doc-comment
|
|
||||||
banner — decided when Phase 17 is actually implemented): (a) the gating suite — allocation-rate
|
|
||||||
and latency-regression checks that fail CI, matching this phase's existing tasks 1–3, run against
|
|
||||||
narrow, isolated scenarios exactly like `FrameWriterBenchmark`; and (b) the showcase suite —
|
|
||||||
real, end-to-end `HttpServer`/h2-connection scenarios, including deliberately extreme ones, that
|
|
||||||
only print results and never gate anything. Keeping (b) non-gating is deliberate: an "extreme
|
|
||||||
case" benchmark (e.g. 10 000 streams on one connection) is valuable precisely because it shows
|
|
||||||
*how* the system behaves under stress, including graceful degradation — turning that into a
|
|
||||||
pass/fail threshold would either be meaningless (no natural "correct" number for a pathological
|
|
||||||
case) or would quietly narrow what counts as an "extreme case" down to whatever currently passes.
|
|
||||||
|
|
||||||
**Revisit when.** Phase 17 is actually started — at that point this entry's task 8 becomes
|
|
||||||
concrete work with its own scenario list, harness design, and output format, rather than a
|
|
||||||
recorded intention.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-19 — `EX-06`'s router half is fixed with an opaque, caller-owned per-connection scratch object, not by extending `ConnectionScratch`
|
|
||||||
|
|
||||||
**Context.** `EX-06`'s registry entry phases itself: "Phase 2 (introduce), Phase 3 (h2 consumes
|
|
||||||
it), Phase 4 (router consumes it)" — Phase 4 is where `FastPathRouterImpl`'s and
|
|
||||||
`FastPathWsRouterImpl`'s `ThreadLocal<MatchResult>`/`ThreadLocal<MethodPathByteView>` (unbounded
|
|
||||||
under virtual threads, one per connection with no upper bound and no pooling — exactly the
|
|
||||||
failure mode `ConnectionScratch` exists to avoid for every other per-connection buffer) get
|
|
||||||
removed. `ConnectionScratch`'s own class Javadoc (written in Phase 2, in anticipation) already
|
|
||||||
commits to a specific mechanism: "Extended in Phase 4 with the router's reusable
|
|
||||||
{@code MatchResult}/path-view fields."
|
|
||||||
|
|
||||||
Attempting that literally surfaced a real problem: `ConnectionScratch` lives in
|
|
||||||
`dev.relism.flash.transport`; the router lives in `dev.relism.flash.routing` (and
|
|
||||||
`dev.relism.flash.routing.routers.fastpathrouter`). Today `transport` depends on `routing`
|
|
||||||
(`ConnectionContext` holds `AbstractRouter`/`AbstractWsRouter`) but **`routing` has zero imports
|
|
||||||
of `transport`** anywhere in this codebase (verified by grep, not assumed) — a clean one-way
|
|
||||||
dependency. Adding the router's scratch fields to `ConnectionScratch` and passing it into
|
|
||||||
`route()` would require `routing`'s classes to import `transport.ConnectionScratch`, creating the
|
|
||||||
first reverse edge and a genuine package cycle where none exists today.
|
|
||||||
|
|
||||||
**Options.**
|
|
||||||
1. Extend `ConnectionScratch` as its own Javadoc already describes, accepting the new
|
|
||||||
`routing → transport` edge (and the resulting cycle with the existing `transport → routing`
|
|
||||||
edge).
|
|
||||||
2. `AbstractRouter`/`AbstractWsRouter` gain a `newScratch()` method (default `null`) that each
|
|
||||||
router implementation overrides to return an opaque, implementation-specific object (kept as a
|
|
||||||
package-private nested class — `FastPathRouterImpl.RouteScratch`,
|
|
||||||
`FastPathWsRouterImpl.RouteScratch` — never a new public type). The connection driver
|
|
||||||
(`Http1Connection.run`) calls `newScratch()` **once per connection**, exactly the same
|
|
||||||
"created once, held by the loop, reused across every request" shape already used there for
|
|
||||||
`RequestParser`, and passes the opaque result into every `route(request, scratch)` call for
|
|
||||||
that connection's lifetime. No package outside `routing`/`routing.routers.fastpathrouter` ever
|
|
||||||
sees the concrete scratch type.
|
|
||||||
|
|
||||||
**Decision.** Option 2.
|
|
||||||
|
|
||||||
**Consequence.** Practically identical outcome to option 1 — one object per connection, created
|
|
||||||
once, reused across every request on that connection, replacing the `ThreadLocal`s — but without
|
|
||||||
introducing `routing`'s only dependency on `transport`. `ConnectionScratch`'s own Javadoc (which
|
|
||||||
predated this decision) is corrected in the same change to describe what was actually built
|
|
||||||
rather than the mechanism it originally assumed; `AbstractRouter.route`'s and
|
|
||||||
`AbstractWsRouter.route`'s signatures gain an `Object scratch` parameter, which is the one
|
|
||||||
API-surface cost of this approach (every router implementation, and every direct caller —
|
|
||||||
`Http1Connection` and the handful of tests that call `route()` directly — must now pass one).
|
|
||||||
`EX-19` (reusable `PathParams`/path-param arrays) piggybacks on the same `RouteScratch` object
|
|
||||||
for `FastPathRouterImpl`, since it needed an identical "created once per connection, grown to the
|
|
||||||
connection's high-water mark" lifetime — implemented together with `EX-06`'s router half rather
|
|
||||||
than as a separate pass over the same class.
|
|
||||||
|
|
||||||
**Revisit when.** Not expected to be revisited — the untyped `Object scratch` parameter is a
|
|
||||||
minor wart, but the alternative (a generic `AbstractRouter<S>` type parameter propagated through
|
|
||||||
`ConnectionContext`, `ServerHandle`, and every public router-registration API) is a far larger
|
|
||||||
API-surface change for one internal implementation detail, and is not justified unless a second
|
|
||||||
router implementation actually needs a differently-shaped scratch object — none exists today.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-20 — Phase 4 performance measurements: `EX-04`, `EX-33`, the router's own allocation profile, and the h1 zero-alloc contract's actual current number
|
|
||||||
|
|
||||||
**Context.** Phase 4's plan carries two explicit "measure, keep only if it earns its keep"
|
|
||||||
instructions (`EX-04`: revert if the win is negative or noise; `EX-33`: keep scalar if the SWAR
|
|
||||||
win is under 3%), plus a zero-alloc contract ("an h1 `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 `String`s the
|
|
||||||
handler explicitly asks for. Add this as a JMH allocation test now"). All three measured together
|
|
||||||
(JDK 21.0.11, JMH 1.37, `avgt` mode, `-prof gc`, `flash/src/jmh/java`) rather than as separate
|
|
||||||
passes, since they share the same request/route fixtures.
|
|
||||||
|
|
||||||
**Measurements.**
|
|
||||||
|
|
||||||
*`EX-33` — SWAR vs. scalar `\r\n\r\n` scan, realistic ~330-byte request (`ByteScanBenchmark`):*
|
|
||||||
|
|
||||||
| | ns/op |
|
|
||||||
|---|---|
|
|
||||||
| `headerEndScan_scalar` | 134.921 ± 5.558 |
|
|
||||||
| `headerEndScan_swar` | 87.116 ± 1.411 |
|
|
||||||
|
|
||||||
SWAR is **35.4 % faster** (47.8 ns absolute) — far above the 3 % keep-threshold. **Kept.**
|
|
||||||
|
|
||||||
*`EX-04` — the `longAt`/`ByteCompare` mechanism in isolation, and the real router
|
|
||||||
(`FastPathRouterBenchmark`):*
|
|
||||||
|
|
||||||
| | ns/op | B/op |
|
|
||||||
|---|---|---|
|
|
||||||
| `byteCompare_byteAtATime` (useLong=false) | 22.281 ± 1.021 | ≈0 |
|
|
||||||
| `byteCompare_longPath` (useLong=true) | 15.146 ± 1.090 | ≈0 |
|
|
||||||
| `router_staticRoute` (real `FastPathRouterImpl.route`) | 143.409 ± 14.992 | 0.001 |
|
|
||||||
| `router_parametricRoute` (real `FastPathRouterImpl.route`, 1 param extracted) | 284.433 ± 31.510 | 0.002 |
|
|
||||||
|
|
||||||
The long path is **32.1 % faster** (7.1 ns) than the byte-at-a-time comparison it replaces, at
|
|
||||||
the mechanism level — a clear, real win, confirming `EX-04` is worth keeping. **Honest caveat**,
|
|
||||||
not a failure of the measurement but a finding in its own right: `router_staticRoute`/
|
|
||||||
`router_parametricRoute` do **not** exercise this win today, because the actual value
|
|
||||||
`FastPathRouterImpl.route` passes to `router.match()` is always a
|
|
||||||
`FastPathViews.MethodPathByteView` — a deliberate composite of method bytes + path view, which
|
|
||||||
(per `EX-04`'s own registry text) correctly keeps `supportsLong() == false`, since a word-at-a-
|
|
||||||
time read across two independent sources is unsound, not merely unoptimized. `EX-04`'s win will
|
|
||||||
apply once a future phase (`HPACK` static-table matching, frame validation — Phase 5+) compares
|
|
||||||
two genuinely-contiguous array-backed ranges directly, which is exactly the shape
|
|
||||||
`byteCompare_longPath` measures. **Kept** — implemented correctly, verified correct
|
|
||||||
(`FastPathViewsLongAtTest`), and measured worthwhile for its actual future consumers; it was
|
|
||||||
never going to show up in today's router-benchmark numbers, and the plan's own text already
|
|
||||||
predicted this by excluding `MethodPathByteView` from the fix.
|
|
||||||
|
|
||||||
Separately: both router benchmarks show **≈0 B/op** — confirms `EX-06`/`EX-19`'s scratch reuse
|
|
||||||
(the `RouteScratch` object, its reused `MatchResult`, `MethodPathByteView`, and path-param
|
|
||||||
arrays/`PathParams` instance) is genuinely zero-allocation in practice, including on a
|
|
||||||
parametric route that extracts a param.
|
|
||||||
|
|
||||||
*The h1 zero-alloc contract, end to end (`RequestPipelineBenchmark`):*
|
|
||||||
|
|
||||||
| | ns/op | B/op |
|
|
||||||
|---|---|---|
|
|
||||||
| `parseAndRoute` (parse + route only, no header/param access) | 1135.125 ± 68.888 | 120.008 |
|
|
||||||
| `parseRouteAndExtractThreeFields` (+ 1 path param, 2 headers read) | 1335.965 ± 57.378 | 304.009 |
|
|
||||||
|
|
||||||
**Not literally 0 B/op** — and this is expected, not a Phase 4 regression: the 120.008 B/op in
|
|
||||||
`parseAndRoute` (which touches no header or path-param API at all) is entirely attributable to
|
|
||||||
`Request`/`RequestBody`/`RequestLine` construction, still allocated fresh per request. That is
|
|
||||||
`EX-21`/`EX-22`'s scope, explicitly assigned to **Phase 6** ("Request/Response model refactor"),
|
|
||||||
not Phase 4's. The delta to `parseRouteAndExtractThreeFields` — 304.009 − 120.008 = **184.001
|
|
||||||
B/op for exactly three explicit `String` reads** (one path param, two headers) — is precisely the
|
|
||||||
"user-facing `String`s the handler explicitly asks for" the contract's own text carves out as
|
|
||||||
acceptable, and confirms that *reading* those three fields (the header index lookup, the pooled
|
|
||||||
slice, the path-param array read) itself adds no allocation beyond the unavoidable `String`
|
|
||||||
objects themselves.
|
|
||||||
|
|
||||||
**Decision.** `EX-33`: keep the SWAR scan. `EX-04`: keep the `longAt`/`supportsLong`
|
|
||||||
implementation as built — correct, tested, and measured worthwhile for the array-backed
|
|
||||||
comparisons it was designed for, independent of whether today's single call site
|
|
||||||
(`MethodPathByteView`) happens to use it. The h1 zero-alloc DoD item is recorded as: **Phase 4's
|
|
||||||
own scope (`EX-05`/`EX-09`/`EX-19`/`EX-25`/`EX-26`/`EX-33`) is verified zero-allocation**
|
|
||||||
(`router_staticRoute`/`router_parametricRoute`'s ≈0 B/op, `HeaderMapIndexTest`'s identity-based
|
|
||||||
allocation check); the remaining 120.008 B/op is `Request`/`RequestBody`/`RequestLine`
|
|
||||||
construction, out of scope until Phase 6, and is not silently hidden — this benchmark now exists
|
|
||||||
specifically so Phase 6 has a "before" number to compare against and a regression gate once
|
|
||||||
Phase 17 wires `-prof gc` into CI.
|
|
||||||
|
|
||||||
**Consequence.** No code changes from this entry — it is a measurement record. Three new
|
|
||||||
benchmark classes ship under `src/jmh/java`: `ByteScan`Benchmark, `FastPathRouterBenchmark`,
|
|
||||||
`RequestPipelineBenchmark` — all component-level and gate-relevant (unlike the `DEC-18` showcase
|
|
||||||
category, these exist to answer the plan's own explicit measurement instructions, not for
|
|
||||||
literature/demo purposes).
|
|
||||||
|
|
||||||
**Revisit when.** `RequestPipelineBenchmark`'s `parseAndRoute` number should drop close to 0 B/op
|
|
||||||
once Phase 6 lands `Request`/`RequestBody` pooling — re-run this exact benchmark then and update
|
|
||||||
this entry (or add a new one) with the "after" number, closing the loop Phase 4 opened.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-21 — Phase 5's zero-alloc contract, measured
|
|
||||||
|
|
||||||
**Context.** Phase 5's plan states: "Reading, validating and discarding a frame: 0 B/op ...
|
|
||||||
Writing a frame header: 0 B/op." Measured with JMH `-prof gc` (JDK 21.0.11, JMH 1.37,
|
|
||||||
`FrameLayerBenchmark`, `src/jmh/java`) rather than left as an unverified assertion, per this
|
|
||||||
project's own standing practice of measuring every stated performance/allocation claim
|
|
||||||
(`DEC-09`, `DEC-20`).
|
|
||||||
|
|
||||||
**Measurement.** `readValidateAndDiscard` (`Http2FrameReader.readFrame` +
|
|
||||||
`FrameValidator.validate` + one byte read from the payload + `consumeFrame`, against a warm,
|
|
||||||
already-grown buffer, matching real keep-alive-connection steady state): 299.846 ± 19.722 ns/op,
|
|
||||||
**0.002 B/op** — indistinguishable from zero (compare `DEC-20`'s harness-floor discussion: even
|
|
||||||
this near-zero figure is most plausibly measurement noise around the true 0, not a real
|
|
||||||
allocation, since nothing in the read/validate/consume path can be shown by inspection to
|
|
||||||
allocate on the warm path). `writeFrame` (`FrameWriteBuffer.beginFrame` + one `writeBytes` call +
|
|
||||||
`endFrame`, against an already-grown `ByteWriter`): 14.262 ± 1.084 ns/op, **≈10⁻⁴ B/op** —
|
|
||||||
likewise indistinguishable from zero.
|
|
||||||
|
|
||||||
**Decision.** Contract verified as stated; no design change required. Both numbers are recorded
|
|
||||||
here as the baseline Phase 17's eventual CI allocation gate should hold this component to.
|
|
||||||
|
|
||||||
**Consequence.** None beyond the recorded numbers — this entry exists so a future regression
|
|
||||||
(e.g. a later phase accidentally introducing an allocation on this path while adding HPACK or
|
|
||||||
stream-state integration) has a concrete "was 0, now isn't" baseline to diff against, per this
|
|
||||||
project's standing insistence that every non-obvious performance claim trace to an actual number.
|
|
||||||
|
|
||||||
**Revisit when.** Not expected to be revisited; re-measure if `FrameHeader`, `Http2FrameReader`,
|
|
||||||
or `FrameWriteBuffer` are ever modified in a way that could plausibly affect their allocation
|
|
||||||
profile.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-22 — `HeaderMap` splits into `HeaderView` (interface) + `Http1HeaderMap` (impl, staying in `models`, not moving to `http1`)
|
|
||||||
|
|
||||||
**Context.** Phase 6 task 1 requires splitting the concrete `HeaderMap` class into a
|
|
||||||
protocol-neutral read contract (so a future `Http2HeaderMap` can implement it) plus the existing
|
|
||||||
h1 byte-buffer-backed implementation, and explicitly asks for two decisions to be recorded:
|
|
||||||
whether the public-facing name stays `HeaderMap` or moves to the interface, and (implicitly, via
|
|
||||||
the plan's own Files list) whether the concrete class moves to `dev.relism.flash.http1`.
|
|
||||||
|
|
||||||
**Decision 1 — naming.** Checked whether `HeaderMap` is actually part of `Request`'s public
|
|
||||||
surface first, since the task's hard constraint is "the public API of `Request` must not
|
|
||||||
change": `Request`'s own methods (`header`, `headers`, `param`, `query`) return `String`/
|
|
||||||
`List<String>`, never a `HeaderMap`/`HeaderView` — the only exposure is the transitive,
|
|
||||||
Javadoc'd-as-"Internal" `Request.getRequestLine().getHeaders()` path. Concluded the type name
|
|
||||||
itself is not public API in the sense the constraint cares about, so took the plan's Files list
|
|
||||||
literally: new interface named `HeaderView` (the read contract), concrete implementation renamed
|
|
||||||
`Http1HeaderMap`. `RequestLine.headers` (and its Lombok-generated `getHeaders()`) is now typed
|
|
||||||
`HeaderView`.
|
|
||||||
|
|
||||||
**Decision 2 — package placement.** The plan's Files list suggests `http1/Http1HeaderMap.java`.
|
|
||||||
Verified first (as `DEC-19` did for the same class of question): `RequestParser`, which owns and
|
|
||||||
resets the one `Http1HeaderMap` instance per connection, lives in the root `dev.relism.flash`
|
|
||||||
package, not `http1`. `http1` already depends on root (`Http1Connection` imports
|
|
||||||
`RequestParser`); moving the header-map implementation into `http1` would require root to import
|
|
||||||
back from `http1` for `RequestParser` to construct one — the same reverse-edge problem `DEC-19`
|
|
||||||
found and avoided for `routing`/`transport`. Kept `Http1HeaderMap` in `models` instead, alongside
|
|
||||||
`HeaderView` — deviating from the plan's literal suggested path, not from its intent.
|
|
||||||
|
|
||||||
**Consequence.** `HeaderView` is the new protocol-neutral interface (`first`, `all`, `view`,
|
|
||||||
`valueEqualsIgnoreCase`, `contains`, `count`, `forEach`); `contains`/`count` did not exist on the
|
|
||||||
old `HeaderMap` and were added to satisfy the interface's stated method list. `Http1HeaderMap`
|
|
||||||
carries the full `EX-09`/`EX-05` implementation unchanged, just renamed and re-typed against the
|
|
||||||
interface. Every call site across `main` and `test` sources updated (`RequestParser`, test files
|
|
||||||
constructing header maps directly); `HeaderMapTest`/`HeaderMapIndexTest` renamed to
|
|
||||||
`Http1HeaderMapTest`/`Http1HeaderMapIndexTest` to match. 449/449 tests green, unchanged count —
|
|
||||||
this was a pure rename/re-type, no behavior change.
|
|
||||||
|
|
||||||
**Revisit when.** Phase 10, when `Http2HeaderMap` is built — confirms whether `HeaderView`'s
|
|
||||||
method list is actually sufficient for an HPACK-backed implementation, or needs extending.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-23 — Phase 6 closes `DEC-20`'s revisit loop: the h1 zero-alloc contract, re-measured after `Request`/`RequestBody`/`RequestLine`/`Response` pooling, plus one more allocation found and fixed (`EX-42`)
|
|
||||||
|
|
||||||
**Context.** `DEC-20` (Phase 4) measured `RequestPipelineBenchmark.parseAndRoute` at 120.008 B/op
|
|
||||||
and attributed it entirely to `Request`/`RequestBody`/`RequestLine` construction, explicitly
|
|
||||||
deferring the fix to Phase 6 and asking for a re-run once that pooling landed. Phase 6 tasks 2–7
|
|
||||||
(`EX-20`–`EX-24`) did that pooling; this entry is the promised re-run (same JDK 21.0.11, JMH 1.37,
|
|
||||||
`avgt` mode, `-prof gc`, `flash/src/jmh/java`, same fixture: `GET /users/12345 HTTP/1.1` with
|
|
||||||
`Host`/`Accept`/`Authorization`).
|
|
||||||
|
|
||||||
**First re-run, after `EX-20`–`EX-24` alone:**
|
|
||||||
|
|
||||||
| | ns/op | B/op |
|
|
||||||
|---|---|---|
|
|
||||||
| `parseAndRoute` | 1194.105 ± 944.469 | 48.008 |
|
|
||||||
| `parseRouteAndExtractThreeFields` | 1324.679 ± 296.883 | 232.009 |
|
|
||||||
|
|
||||||
Down from 120.008 to 48.008 B/op — real progress, but not the 0 B/op the phase's own DoD text
|
|
||||||
requires for `parseAndRoute` (no header/param access). Investigated rather than accepted: reading
|
|
||||||
`RequestParser.parse` line by line turned up three `new FastPathViews.RequestByteView(...)`
|
|
||||||
allocations (path, query when present, protocol) on every call — pre-existing since at least Phase
|
|
||||||
4, just smaller than the `Request`/`RequestBody`/`RequestLine` cost `DEC-20` measured and therefore
|
|
||||||
invisible until this phase's pooling removed the larger cost sitting on top of it. Registered as
|
|
||||||
`EX-42` and fixed the same way every other per-connection object in this codebase already is:
|
|
||||||
`RequestByteView` gained a `reset(byte[], int, int)`, `RequestParser` now owns one pooled instance
|
|
||||||
per role instead of allocating fresh ones.
|
|
||||||
|
|
||||||
**Second re-run, after `EX-42`:**
|
|
||||||
|
|
||||||
| | ns/op | B/op |
|
|
||||||
|---|---|---|
|
|
||||||
| `parseAndRoute` | 1111.260 ± 104.692 | 0.008 |
|
|
||||||
| `parseRouteAndExtractThreeFields` | 1301.840 ± 228.068 | 184.009 |
|
|
||||||
|
|
||||||
`parseAndRoute` — 0.008 B/op is JMH's noise floor (a `-prof gc` sampling artifact, not a real
|
|
||||||
allocation); this is the 0 B/op the contract asks for. `parseRouteAndExtractThreeFields` dropped
|
|
||||||
from 232.009 to 184.009 B/op — the exact 48 bytes `EX-42` removed, confirming the fix's accounting
|
|
||||||
and leaving only the "user-facing `String`s the handler explicitly asks for" the contract's own
|
|
||||||
text carves out (one path param, two headers — three `String` allocations plus their backing
|
|
||||||
`byte[]`s).
|
|
||||||
|
|
||||||
**Decision.** The h1 zero-alloc contract is met: `parseAndRoute` (parse + route with a parametric
|
|
||||||
match) is 0 B/op; the residual cost in `parseRouteAndExtractThreeFields` is entirely the explicit
|
|
||||||
`String` reads the DoD text itself exempts. `DEC-20`'s revisit item is closed.
|
|
||||||
|
|
||||||
**Consequence.** `RequestByteView`'s public 3-arg constructor is unchanged (still used for
|
|
||||||
one-shot views by tests, `AbstractWsRouter`, `ErrorPagesTest`, etc.) — only `RequestParser`'s three
|
|
||||||
call sites moved to the pooled `reset()` path. `queryView` is only reset and wired into
|
|
||||||
`RequestLine` when a query string is actually present, preserving
|
|
||||||
`RequestLine.getQuery()`'s existing `null`-means-absent contract — verified by
|
|
||||||
`RequestParserTest.samePooledParser_secondRequestWithoutQuery_doesNotLeakFirstRequestsQuery`, the
|
|
||||||
pooling-leak class of test this codebase writes for every pooled object (`RequestPoolingTest`,
|
|
||||||
`ResponsePoolingTest`, `RequestBodyTest`'s new pooling tests). 500/500 tests green.
|
|
||||||
|
|
||||||
**Revisit when.** Never expected to — this closes the loop `DEC-20` opened. If a future phase adds
|
|
||||||
a fourth per-request view (e.g. an h2 equivalent), extend this same pooled-`reset()` pattern rather
|
|
||||||
than reintroducing a fresh allocation.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-24 — Compact the HPACK arena and copy decoded headers into stream-owned storage
|
|
||||||
|
|
||||||
**Context.** Dynamic-table entries must be contiguous for cheap indexed lookup, but FIFO eviction
|
|
||||||
leaves holes at the front of a bounded arena. Views into that arena also cannot outlive later
|
|
||||||
decodes on a multiplexed connection.
|
|
||||||
|
|
||||||
**Decision.** Compact live dynamic entries when the free tail cannot hold an insertion. Do not use
|
|
||||||
`SegmentedByteView` for wrapped entries or CONTINUATION fragments. At the decoder boundary,
|
|
||||||
`HpackHeaderBlock` copies fields into a reusable arena owned by the stream.
|
|
||||||
|
|
||||||
**Consequence.** Compaction is occasionally O(table size), bounded by the advertised table size,
|
|
||||||
while all ordinary lookups and consumer copies remain contiguous. Stream handlers never observe
|
|
||||||
dynamic-table eviction or compaction. The JMH decode benchmark remains at the allocation noise
|
|
||||||
floor (0.001 B/op).
|
|
||||||
|
|
||||||
**Revisit when.** Profiling shows compaction is material under realistic dynamic-table churn.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-25 — Keep response-dependent h2spec gates with the phases that own the response path
|
|
||||||
|
|
||||||
**Context.** The Phase 8 checklist names whole h2spec sections 4 and 6.9, but several tests in
|
|
||||||
those sections require a successful response HEADERS/DATA sequence or per-stream flow-control
|
|
||||||
state. Those mechanisms are explicitly introduced in Phases 9–11. Making the whole sections green
|
|
||||||
now would require a temporary response/stream implementation in the connection state machine and
|
|
||||||
then deleting it immediately.
|
|
||||||
|
|
||||||
**Decision.** Phase 8 closes on every connection-owned h2spec case plus the complete unit,
|
|
||||||
integration, curl and allocation gates. Response- and stream-dependent cases remain visibly
|
|
||||||
unchecked and move with their owning Phase 9–11 gates. No placeholder response path is added.
|
|
||||||
|
|
||||||
**Consequence.** The connection layer stays cohesive: it validates frames and HPACK composition but
|
|
||||||
does not acquire a second, short-lived implementation of response or stream semantics. The ledger
|
|
||||||
records the partial external gate rather than claiming whole-section conformance prematurely.
|
|
||||||
|
|
||||||
**Revisit when.** Close the remaining h2spec section 4 and 6.9 cases as Phases 9–11 land, then rerun
|
|
||||||
the combined selection without skips.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-26 — Keep one protocol-neutral `PreEncodedHeader` model
|
|
||||||
|
|
||||||
**Context.** The original work plan proposed a second HTTP/2-specific `PreEncodedHeader` carrying
|
|
||||||
complete HTTP/1 and HPACK renderings. The existing public model already preserves immutable name
|
|
||||||
and value bytes, which is the common information both writers need. Adding another type would
|
|
||||||
split one application concept across protocol packages and force callers or `Response` to retain
|
|
||||||
protocol-specific state.
|
|
||||||
|
|
||||||
**Decision.** Keep `models.PreEncodedHeader` as the only public type. HTTP/1 renders its bytes as a
|
|
||||||
field line; HTTP/2 feeds the same byte ranges to the stateless encoder. Closed framework constants
|
|
||||||
(status, content type and Date) retain their specialized precompiled HPACK forms because those are
|
|
||||||
owned internally and measurably avoid work on every response.
|
|
||||||
|
|
||||||
**Consequence.** Application and middleware code builds one reusable header constant that works on
|
|
||||||
both protocols. Custom constants still traverse the HPACK literal encoder, but the measured write
|
|
||||||
path remains allocation-free and avoids duplicating the response model.
|
|
||||||
|
|
||||||
**Revisit when.** Only if profiling shows custom constant encoding is material; optimize the
|
|
||||||
existing model internally without introducing a second public header abstraction.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## DEC-27 — Drain already-buffered frames before dispatching completed streams
|
|
||||||
|
|
||||||
**Context.** A client can write a burst of complete requests before the server schedules their
|
|
||||||
handlers. Dispatching after every individual HEADERS frame lets a very fast handler close and
|
|
||||||
release streams while the same inbound burst is still being decoded, making the advertised
|
|
||||||
concurrency limit dependent on virtual-thread scheduling. Waiting a fixed interval would make the
|
|
||||||
limit deterministic but would add latency to every ordinary request.
|
|
||||||
|
|
||||||
**Decision.** Completed bodyless streams enter a fixed queue bounded by
|
|
||||||
`MAX_CONCURRENT_STREAMS`. The demultiplexer continues only while its own frame reader already has
|
|
||||||
bytes buffered; as soon as consuming the next frame would require network input, it drains the
|
|
||||||
queue to the shared virtual-thread executor. The configured concurrent-stream limit is 64 and the
|
|
||||||
primitive stream table has exactly the same bound.
|
|
||||||
|
|
||||||
**Consequence.** One socket read's request burst is admitted and bounded as a unit, excess streams
|
|
||||||
receive `REFUSED_STREAM`, and a single request is dispatched immediately without a timer. The demux
|
|
||||||
still never executes application code or waits for a worker. h2spec's concurrency case passes and
|
|
||||||
the lifecycle benchmark remains at the allocation noise floor.
|
|
||||||
|
|
||||||
**Revisit when.** If production traces show a materially different batching pattern, tune the
|
|
||||||
advertised limit or reader size from measurements; do not add a sleep-based dispatch delay.
|
|
||||||
|
|
||||||
---
|
|
||||||
@@ -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.
|
||||||
+11
-12
@@ -1,4 +1,4 @@
|
|||||||
# The Frame Layer (Phase 5)
|
# The frame layer
|
||||||
|
|
||||||
Audience: contributors. This is the design record for `dev.relism.flash.http2.frame`'s frame
|
Audience: contributors. This is the design record for `dev.relism.flash.http2.frame`'s frame
|
||||||
reading, validation, and writing — the 9-byte header and payload boundary, with no connection
|
reading, validation, and writing — the 9-byte header and payload boundary, with no connection
|
||||||
@@ -40,9 +40,9 @@ dev.relism.flash.http2.frame
|
|||||||
├── FrameValidator table-driven per-type RFC validation, specific error code per rule
|
├── 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
|
├── Padding RFC 9113 §6.1/§6.2 pad-length byte + trailing padding, DATA/HEADERS
|
||||||
├── FrameWriteBuffer beginFrame()/endFrame() length back-patching over a ByteWriter
|
├── FrameWriteBuffer beginFrame()/endFrame() length back-patching over a ByteWriter
|
||||||
├── Http2FrameWriter (Phase 3) the connection's single serialized writer — unchanged here
|
├── Http2FrameWriter the connection's single serialized writer
|
||||||
├── WriteIntent (Phase 3) unchanged
|
├── WriteIntent caller-owned serialized frame batch
|
||||||
└── IntrusiveMpscQueue (Phase 3) unchanged
|
└── IntrusiveMpscQueue allocation-free contended-write queue
|
||||||
```
|
```
|
||||||
|
|
||||||
## The validation table
|
## The validation table
|
||||||
@@ -55,7 +55,7 @@ min/max length bounds, the `MAX_FRAME_SIZE_LOCAL` ceiling, the stream-id rule, t
|
|||||||
| Type | Code | Length | Stream id | Notes / RFC |
|
| Type | Code | Length | Stream id | Notes / RFC |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| DATA | 0x0 | 0..MAX_FRAME_SIZE | required (≠0) | §6.1. Padding via `Padding.unpad`. |
|
| 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 + PRIORITY fields (Phase 7+ parses the latter). |
|
| 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. |
|
| 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. |
|
| 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. |
|
| SETTINGS | 0x4 | multiple of 6 | forbidden (=0) | §6.5. Modulus checked before the generic bounds. |
|
||||||
@@ -82,7 +82,7 @@ The one exception (§6.10): if an unrecognised-type frame arrives **between** a
|
|||||||
PUSH_PROMISE frame that lacked `END_HEADERS` and the CONTINUATION that eventually sets it, the
|
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
|
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`
|
case *is* a `PROTOCOL_ERROR`, tracked by `FrameValidator.validate`'s `insideHeaderBlock`
|
||||||
parameter (owned and threaded through by the Phase 8 connection loop, which is the only caller
|
parameter (owned and threaded through by the connection loop, which is the only caller
|
||||||
that knows whether a header block is currently open).
|
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
|
`PRIORITY` frames are a different kind of "ignore": they are a recognised, well-formed type that
|
||||||
@@ -112,8 +112,8 @@ pad-length, then data, then that many padding bytes (whose contents carry no mea
|
|||||||
only to obscure payload size from network observers). A pad length greater than or equal to the
|
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
|
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
|
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 Phase 11 scope — `Padding` only
|
*whole* payload counts against the window, not just the data) is applied by
|
||||||
locates the data range, it performs no window bookkeeping itself.
|
`Http2FlowController`; `Padding` only locates the data range.
|
||||||
|
|
||||||
## Writing: `FrameWriteBuffer`'s back-patching
|
## Writing: `FrameWriteBuffer`'s back-patching
|
||||||
|
|
||||||
@@ -121,18 +121,17 @@ A frame's length is rarely known before its payload is serialized (an HPACK-enco
|
|||||||
in particular, has no cheap way to be measured in advance). `FrameWriteBuffer.beginFrame` writes
|
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
|
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
|
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` (Phase 3) serializes a
|
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
|
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
|
produced — streaming would need the length upfront, which back-patching deliberately avoids
|
||||||
needing.
|
needing.
|
||||||
|
|
||||||
## `EX-37`, found while building this phase's tests
|
## Buffered-source deadline regression
|
||||||
|
|
||||||
`BufferedByteSource`'s deadline mechanism (`EX-07`'s actual fix) turned out to have zero dedicated
|
`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`
|
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
|
socket every isolated unit test in this codebase uses. Found while writing
|
||||||
`Http2FrameReaderTest`, fixed, and given its own regression suite (`BufferedByteSourceTest`) —
|
`Http2FrameReaderTest`, fixed, and given its own regression suite (`BufferedByteSourceTest`).
|
||||||
full writeup in the plan's registry, `EX-37`.
|
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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.
|
||||||
@@ -40,10 +40,9 @@ while dispatch is pending.
|
|||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
- Clean Maven build with JMH sources: 618 tests, no failures.
|
- The complete clean Maven/JMH suite is recorded in `COMPLIANCE.md` and `PERFORMANCE.md`.
|
||||||
- h2spec sections 5 and 8: 37/39. The two remaining cases require request DATA byte accounting and
|
- h2spec sections 5 and 8 pass after request DATA byte accounting landed.
|
||||||
are completed with body flow control.
|
|
||||||
- Java `HttpClient` negotiates HTTP/2 over TLS and runs an existing parameterized route unchanged.
|
- 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.
|
- curl prior-knowledge h2c receives a valid `200` response and body.
|
||||||
- JMH pooled lifecycle (HPACK decode, request assembly, response write and release):
|
- The pooled lifecycle (HPACK decode, request assembly, response write and release) remains a
|
||||||
458.499 ns/op, 0.003 B/op, no GC.
|
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.
|
||||||
@@ -1,13 +1,10 @@
|
|||||||
# The Serialized Frame Writer (Phase 3 — GO/NO-GO gate)
|
# The serialized frame writer
|
||||||
|
|
||||||
Audience: contributors. This is the design record and benchmark evidence for
|
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
|
`dev.relism.flash.http2.frame.Http2FrameWriter`, the one component every HTTP/2 write in this
|
||||||
codebase passes through. Phase 3 of `IMPLEMENTATION-PLAN.md` treats this component as the
|
codebase passes through. It was the central architectural risk: frames, HPACK and flow control are
|
||||||
single genuinely novel architectural risk in the whole project — everything downstream (frames,
|
table-driven, but multiplexed streams require concurrent producers to share one socket without
|
||||||
HPACK, flow control) is table-driven work with known cost, but nothing in Flash today
|
interleaving bytes or pinning carrier threads. The measured gate is recorded below.
|
||||||
coordinates concurrent writers onto one socket. If this component could not deliver, the plan
|
|
||||||
says to stop here having spent one phase, not ten. It delivered: **GO**, see the gate table at
|
|
||||||
the end of this document.
|
|
||||||
|
|
||||||
## The problem, precisely
|
## The problem, precisely
|
||||||
|
|
||||||
@@ -27,7 +24,7 @@ has already built its complete frame — header, HPACK block, payload — into a
|
|||||||
writer never serializes anything; it holds the lock only for the duration of one bulk
|
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
|
`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
|
`EX-27` (collapsing `HttpServer.writeResponse`'s ~10 small writes into one) is a prerequisite for
|
||||||
h1 too, landing in Phase 6.
|
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
|
**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,
|
inside a `synchronized` block pins its carrier platform thread (JEP 491, which removes this,
|
||||||
@@ -140,8 +137,8 @@ from the path this document's gate criteria are strictest about.
|
|||||||
## Benchmark methodology
|
## Benchmark methodology
|
||||||
|
|
||||||
`flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java` (a JMH source root
|
`flash/src/jmh/java/dev/relism/flash/http2/frame/FrameWriterBenchmark.java` (a JMH source root
|
||||||
registered only under the `jmh` Maven profile — see `DECISIONS.md`, `DEC-17`, for why it does not
|
registered only under the `jmh` Maven profile, not `src/test/java`) compares four harnesses at
|
||||||
live in `src/test/java`) compares four harnesses at `threads` ∈ {1, 2, 4, 8, 16, 64}:
|
`threads` ∈ {1, 2, 4, 8, 16, 64}:
|
||||||
|
|
||||||
- `trylock_mpsc` — the shipped `Http2FrameWriter` design.
|
- `trylock_mpsc` — the shipped `Http2FrameWriter` design.
|
||||||
- `plain_lock` — every write blocks on `ReentrantLock.lock()` unconditionally (candidate (a)).
|
- `plain_lock` — every write blocks on `ReentrantLock.lock()` unconditionally (candidate (a)).
|
||||||
@@ -260,9 +257,8 @@ design's exclusive use of `ReentrantLock` (never `synchronized`) on every path t
|
|||||||
| 3 | No carrier pinning under `-Djdk.tracePinnedThreads=full` | none observed | **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** |
|
| 4 | Stress test green at every N, 1000 iterations, incl. parallelism=1 | 10 000/10 000 | **PASS** |
|
||||||
|
|
||||||
**All four gate criteria are met. Verdict: GO.** `Http2FrameWriter` ships as designed —
|
**All four gate criteria are met.** `Http2FrameWriter` ships as designed: a `tryLock()` fast path
|
||||||
`tryLock()` fast path, intrusive MPSC fallback — and Phase 4 may proceed. See `DECISIONS.md`,
|
with an intrusive MPSC fallback.
|
||||||
`DEC-09`, for the decision-log entry recording this outcome alongside the plan's other decisions.
|
|
||||||
|
|
||||||
## What this design costs vs. what it saves
|
## What this design costs vs. what it saves
|
||||||
|
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
package dev.relism.flash.http2.hpack;
|
package dev.relism.flash.http2.hpack;
|
||||||
|
|
||||||
import java.util.concurrent.TimeUnit;
|
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.Benchmark;
|
||||||
import org.openjdk.jmh.annotations.BenchmarkMode;
|
import org.openjdk.jmh.annotations.BenchmarkMode;
|
||||||
import org.openjdk.jmh.annotations.Fork;
|
import org.openjdk.jmh.annotations.Fork;
|
||||||
@@ -22,6 +26,25 @@ public class HpackDecoderBenchmark {
|
|||||||
private final HpackDecoder decoder = new HpackDecoder();
|
private final HpackDecoder decoder = new HpackDecoder();
|
||||||
private final HpackHeaderBlock headers = new HpackHeaderBlock();
|
private final HpackHeaderBlock headers = new HpackHeaderBlock();
|
||||||
private final byte[] block = {(byte) 0x82, (byte) 0x87, (byte) 0x84, (byte) 0x88};
|
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
|
@Benchmark
|
||||||
public int decodeStaticRequest() {
|
public int decodeStaticRequest() {
|
||||||
@@ -29,4 +52,11 @@ public class HpackDecoderBenchmark {
|
|||||||
decoder.decode(block, 0, block.length, headers);
|
decoder.decode(block, 0, block.length, headers);
|
||||||
return headers.count();
|
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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,11 +26,14 @@ import org.openjdk.jmh.annotations.Warmup;
|
|||||||
@Measurement(iterations = 5, time = 1)
|
@Measurement(iterations = 5, time = 1)
|
||||||
public class Http2StreamBenchmark {
|
public class Http2StreamBenchmark {
|
||||||
private static final byte[] BODY = "pong".getBytes(StandardCharsets.US_ASCII);
|
private static final byte[] BODY = "pong".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
private static final byte[] POST_BODY = new byte[1024];
|
||||||
|
|
||||||
private Http2StreamTable streams;
|
private Http2StreamTable streams;
|
||||||
private HpackDecoder decoder;
|
private HpackDecoder decoder;
|
||||||
private byte[] requestBlock;
|
private byte[] requestBlock;
|
||||||
private int requestLength;
|
private int requestLength;
|
||||||
|
private byte[] postBlock;
|
||||||
|
private int postLength;
|
||||||
|
|
||||||
@Setup
|
@Setup
|
||||||
public void setup() {
|
public void setup() {
|
||||||
@@ -45,7 +48,19 @@ public class Http2StreamBenchmark {
|
|||||||
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
requestBlock = block.array();
|
requestBlock = block.array();
|
||||||
requestLength = block.length();
|
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();
|
lifecycle();
|
||||||
|
postOneKiB();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Benchmark
|
@Benchmark
|
||||||
@@ -62,4 +77,32 @@ public class Http2StreamBenchmark {
|
|||||||
streams.release(stream);
|
streams.release(stream);
|
||||||
return bytes;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ package dev.relism.flash;
|
|||||||
|
|
||||||
import dev.relism.flash.exceptions.MalformedRequestException;
|
import dev.relism.flash.exceptions.MalformedRequestException;
|
||||||
import dev.relism.flash.http.Http1Limits;
|
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 dev.relism.flash.transport.BufferedByteSource;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
@@ -20,15 +22,28 @@ import java.io.InputStream;
|
|||||||
* {@link BufferedByteSource#prependOnce}, replacing the {@code SequenceInputStream}/
|
* {@link BufferedByteSource#prependOnce}, replacing the {@code SequenceInputStream}/
|
||||||
* {@code ByteArrayInputStream} pair the previous implementation allocated per chunked request.
|
* {@code ByteArrayInputStream} pair the previous implementation allocated per chunked request.
|
||||||
*/
|
*/
|
||||||
final class ChunkedInputStream extends InputStream {
|
final class ChunkedInputStream extends InputStream implements BodyCompletion {
|
||||||
private final BufferedByteSource 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 int chunksSeen = 0;
|
||||||
|
private final MutableHeaderMap trailers;
|
||||||
|
private final byte[] trailerLine = new byte[Http1Limits.MAX_HEADER_VALUE_LENGTH];
|
||||||
|
|
||||||
|
ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen,
|
||||||
|
MutableHeaderMap trailers) {
|
||||||
|
this.src = src;
|
||||||
|
this.trailers = trailers;
|
||||||
|
if (preBufLen > 0) src.prependOnce(preBuf, preBufOff, preBufLen);
|
||||||
|
}
|
||||||
|
|
||||||
ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen) {
|
ChunkedInputStream(BufferedByteSource src, byte[] preBuf, int preBufOff, int preBufLen) {
|
||||||
this.src = src;
|
this(src, preBuf, preBufOff, preBufLen, new MutableHeaderMap());
|
||||||
if (preBufLen > 0) src.prependOnce(preBuf, preBufOff, preBufLen);
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean fullyRead() {
|
||||||
|
return done;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -129,7 +144,7 @@ final class ChunkedInputStream extends InputStream {
|
|||||||
int trailerCount = 0;
|
int trailerCount = 0;
|
||||||
while (true) {
|
while (true) {
|
||||||
int b = src.read();
|
int b = src.read();
|
||||||
if (b == -1) return; // EOF mid-trailers — nothing left to bound.
|
if (b == -1) throw new MalformedRequestException(400, "Truncated trailer section");
|
||||||
if (b == '\r') {
|
if (b == '\r') {
|
||||||
if (src.read() != '\n') {
|
if (src.read() != '\n') {
|
||||||
throw new MalformedRequestException(400, "Malformed trailer section terminator");
|
throw new MalformedRequestException(400, "Malformed trailer section terminator");
|
||||||
@@ -139,12 +154,68 @@ final class ChunkedInputStream extends InputStream {
|
|||||||
if (++trailerCount > Http1Limits.MAX_TRAILER_COUNT) {
|
if (++trailerCount > Http1Limits.MAX_TRAILER_COUNT) {
|
||||||
throw new MalformedRequestException(431, "Too many trailers");
|
throw new MalformedRequestException(431, "Too many trailers");
|
||||||
}
|
}
|
||||||
int lineLen = 1;
|
int lineLen = 0;
|
||||||
|
trailerLine[lineLen++] = (byte) b;
|
||||||
while ((b = src.read()) != -1 && b != '\n') {
|
while ((b = src.read()) != -1 && b != '\n') {
|
||||||
if (++lineLen > Http1Limits.MAX_HEADER_VALUE_LENGTH) {
|
if (lineLen == trailerLine.length) {
|
||||||
throw new MalformedRequestException(431, "Trailer line too long");
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import dev.relism.flash.exceptions.MalformedRequestException;
|
|||||||
import dev.relism.flash.http.Http1Limits;
|
import dev.relism.flash.http.Http1Limits;
|
||||||
import dev.relism.flash.http.HttpMethod;
|
import dev.relism.flash.http.HttpMethod;
|
||||||
import dev.relism.flash.models.Http1HeaderMap;
|
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.RequestBody;
|
||||||
import dev.relism.flash.models.RequestLine;
|
import dev.relism.flash.models.RequestLine;
|
||||||
@@ -57,6 +58,7 @@ public class RequestParser {
|
|||||||
private final InetSocketAddress remoteAddress;
|
private final InetSocketAddress remoteAddress;
|
||||||
private final SSLSocket sslSocket;
|
private final SSLSocket sslSocket;
|
||||||
private final Http1HeaderMap headerMap = new Http1HeaderMap();
|
private final Http1HeaderMap headerMap = new Http1HeaderMap();
|
||||||
|
private final MutableHeaderMap trailerMap = new MutableHeaderMap();
|
||||||
// request — same idiom as headerMap above.
|
// request — same idiom as headerMap above.
|
||||||
private final RequestLine requestLine = new RequestLine();
|
private final RequestLine requestLine = new RequestLine();
|
||||||
private final Request request = new Request();
|
private final Request request = new Request();
|
||||||
@@ -115,6 +117,7 @@ public class RequestParser {
|
|||||||
* @throws IOException on genuine I/O failure (socket reset, timeout).
|
* @throws IOException on genuine I/O failure (socket reset, timeout).
|
||||||
*/
|
*/
|
||||||
public Request parse(BufferedByteSource 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;
|
||||||
@@ -187,6 +190,7 @@ public class RequestParser {
|
|||||||
boolean transferEncodingSeen = false;
|
boolean transferEncodingSeen = false;
|
||||||
boolean transferEncodingChunked = false;
|
boolean transferEncodingChunked = false;
|
||||||
int headerCount = 0;
|
int headerCount = 0;
|
||||||
|
headerMap.beginParsed(buffer, sectionStart, headerEndIdx);
|
||||||
|
|
||||||
while (current < headerEndIdx) {
|
while (current < headerEndIdx) {
|
||||||
// deprecates line folding and treating a folded continuation as part of the
|
// deprecates line folding and treating a folded continuation as part of the
|
||||||
@@ -229,6 +233,7 @@ public class RequestParser {
|
|||||||
if (lineEnd - valueStart > Http1Limits.MAX_HEADER_VALUE_LENGTH) {
|
if (lineEnd - valueStart > Http1Limits.MAX_HEADER_VALUE_LENGTH) {
|
||||||
throw new MalformedRequestException(431, "Header value exceeds " + Http1Limits.MAX_HEADER_VALUE_LENGTH + " bytes");
|
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")) {
|
if (ByteScan.equalsIgnoreCaseAscii(buffer, current, colon, "content-length")) {
|
||||||
// parseLong, which silently accepted "5abc" as 5 and "-1" as 1.
|
// parseLong, which silently accepted "5abc" as 5 and "-1" as 1.
|
||||||
@@ -267,8 +272,6 @@ public class RequestParser {
|
|||||||
if (!contentLengthSeen) contentLength = 0;
|
if (!contentLengthSeen) contentLength = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
headerMap.reset(buffer, sectionStart, headerEndIdx);
|
|
||||||
|
|
||||||
// ── Body / pipelining accounting ─────────────────────────────────────
|
// ── Body / pipelining accounting ─────────────────────────────────────
|
||||||
|
|
||||||
int bodyStart = headerEndIdx + 4;
|
int bodyStart = headerEndIdx + 4;
|
||||||
@@ -293,11 +296,15 @@ public class RequestParser {
|
|||||||
// is handled by the same call: preBufLen is already forced to 0 for it above) or the
|
// is handled by the same call: preBufLen is already forced to 0 for it above) or the
|
||||||
// chunked case, never reallocated.
|
// chunked case, never reallocated.
|
||||||
if (isChunked) {
|
if (isChunked) {
|
||||||
requestBody.reset(new ChunkedInputStream(in, buffer, bodyStart, preBufLen), -1L, null, 0, 0);
|
requestBody.reset(
|
||||||
|
new ChunkedInputStream(in, buffer, bodyStart, preBufLen, trailerMap),
|
||||||
|
-1L, null, 0, 0);
|
||||||
} else {
|
} else {
|
||||||
requestBody.reset(in, contentLength, buffer, bodyStart, preBufLen);
|
requestBody.reset(in, contentLength, buffer, bodyStart, preBufLen);
|
||||||
}
|
}
|
||||||
return Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
|
Request parsed = Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
|
||||||
|
parsed.setTrailers(trailerMap);
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import dev.relism.fpr.core.ByteView;
|
|||||||
* to be copied.</li>
|
* to be copied.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
* Code that only has a bare {@link ByteView} (e.g. because it received one across the
|
* Code that only has a bare {@link ByteView} (e.g. because it received one across the
|
||||||
* {@link SegmentedByteView} boundary, from a future HPACK CONTINUATION-spanning block) keeps the
|
* {@link SegmentedByteView} boundary) keeps the
|
||||||
* byte-at-a-time fallback — this interface is an opportunistic fast path, never a requirement.
|
* byte-at-a-time fallback — this interface is an opportunistic fast path, never a requirement.
|
||||||
*/
|
*/
|
||||||
public interface ArrayBackedByteView extends ByteView {
|
public interface ArrayBackedByteView extends ByteView {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -89,18 +89,59 @@ public class FlashConfiguration {
|
|||||||
@Builder.Default int shutdownDrainTimeoutMs = 15_000;
|
@Builder.Default int shutdownDrainTimeoutMs = 15_000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Whether this server negotiates HTTP/2. When enabled, plaintext listeners recognize h2c prior
|
* Maximum connections admitted across all listeners before new connections are closed
|
||||||
* knowledge and TLS listeners advertise {@code h2} followed by HTTP/1.1 through ALPN. Disabled by
|
* immediately at accept time, before any per-connection state (TLS handshake, protocol
|
||||||
* default until the HTTP/2 request/response path is complete.
|
* 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;
|
@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
|
* 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.
|
* compressed once at startup; leaving this disabled avoids a per-byte encode pass on responses.
|
||||||
*/
|
*/
|
||||||
@Builder.Default boolean h2HuffmanDynamicValues = false;
|
@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};
|
* 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
|
* set {@code false} if Flash sits behind a reverse proxy that already adds one, to skip the
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import java.util.Arrays;
|
|||||||
import lombok.Getter;
|
import lombok.Getter;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pre-compiled byte representations of common HTTP {@code Content-Type} values. {@link #getBytes()}
|
* Pre-compiled byte representations of common HTTP {@code Content-Type} values. {@code getBytes()}
|
||||||
* returns the pre-computed array directly, never allocates.
|
* returns the pre-computed array directly, never allocates.
|
||||||
*/
|
*/
|
||||||
@Getter
|
@Getter
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,9 @@ public final class Http1ResponseWriter {
|
|||||||
|
|
||||||
response.writeHeadersInto(head);
|
response.writeHeadersInto(head);
|
||||||
|
|
||||||
if (response.isStreaming()) {
|
if (response.hasTrailers()) {
|
||||||
|
writeTrailerBody(out, head, response, keepAlive, suppressBody, scratch);
|
||||||
|
} else if (response.isStreaming()) {
|
||||||
writeStreamingBody(out, head, response, keepAlive, noContentAllowed, suppressBody, scratch);
|
writeStreamingBody(out, head, response, keepAlive, noContentAllowed, suppressBody, scratch);
|
||||||
} else {
|
} else {
|
||||||
byte[] body = response.getBody();
|
byte[] body = response.getBody();
|
||||||
@@ -109,6 +111,28 @@ public final class Http1ResponseWriter {
|
|||||||
out.flush();
|
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,
|
private static void writeStreamingBody(OutputStream out, ByteWriter head, Response response, boolean keepAlive,
|
||||||
boolean noContentAllowed, boolean suppressBody,
|
boolean noContentAllowed, boolean suppressBody,
|
||||||
ConnectionScratch scratch) throws IOException {
|
ConnectionScratch scratch) throws IOException {
|
||||||
@@ -121,7 +145,7 @@ public final class Http1ResponseWriter {
|
|||||||
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||||
head.writeBytes(CRLF);
|
head.writeBytes(CRLF);
|
||||||
out.write(head.array(), 0, head.length());
|
out.write(head.array(), 0, head.length());
|
||||||
if (!suppressBody) relay(response.getStream(), out, scratch);
|
if (!suppressBody) relayAndClose(response.getStream(), out, scratch);
|
||||||
} else {
|
} else {
|
||||||
head.writeBytes(TRANSFER_CHUNKED);
|
head.writeBytes(TRANSFER_CHUNKED);
|
||||||
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
head.writeBytes(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
|
||||||
@@ -130,7 +154,34 @@ public final class Http1ResponseWriter {
|
|||||||
// A HEAD response still declares the Transfer-Encoding GET would have used (RFC
|
// 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
|
// 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.
|
// there is no chunk framing at all for a message with no body.
|
||||||
if (!suppressBody) writeChunked(out, response.getStream(), scratch);
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,7 +201,8 @@ public final class Http1ResponseWriter {
|
|||||||
else { head.writeDecimal(statusCode); head.writeBytes(UNKNOWN_STATUS_SUFFIX); }
|
else { head.writeDecimal(statusCode); head.writeBytes(UNKNOWN_STATUS_SUFFIX); }
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void writeChunked(OutputStream out, InputStream stream, ConnectionScratch scratch) throws IOException {
|
private static void writeChunked(OutputStream out, InputStream stream, Response response,
|
||||||
|
ConnectionScratch scratch) throws IOException {
|
||||||
byte[] buf = scratch.relayBuffer;
|
byte[] buf = scratch.relayBuffer;
|
||||||
int n;
|
int n;
|
||||||
while ((n = stream.read(buf)) > 0) {
|
while ((n = stream.read(buf)) > 0) {
|
||||||
@@ -159,7 +211,15 @@ public final class Http1ResponseWriter {
|
|||||||
out.write(buf, 0, n);
|
out.write(buf, 0, n);
|
||||||
out.write(CRLF);
|
out.write(CRLF);
|
||||||
}
|
}
|
||||||
out.write(FINAL_CHUNK);
|
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 {
|
private static void writeHex(OutputStream out, int value) throws IOException {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.relism.flash.http2;
|
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.ControlIntent;
|
||||||
import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind;
|
import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind;
|
||||||
import dev.relism.flash.http2.frame.FrameFlags;
|
import dev.relism.flash.http2.frame.FrameFlags;
|
||||||
@@ -8,7 +10,10 @@ import dev.relism.flash.http2.frame.FrameType;
|
|||||||
import dev.relism.flash.http2.frame.FrameValidator;
|
import dev.relism.flash.http2.frame.FrameValidator;
|
||||||
import dev.relism.flash.http2.frame.Http2FrameReader;
|
import dev.relism.flash.http2.frame.Http2FrameReader;
|
||||||
import dev.relism.flash.http2.frame.Http2FrameWriter;
|
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.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.Http2Stream;
|
||||||
import dev.relism.flash.http2.stream.Http2StreamState;
|
import dev.relism.flash.http2.stream.Http2StreamState;
|
||||||
import dev.relism.flash.http2.stream.Http2StreamTable;
|
import dev.relism.flash.http2.stream.Http2StreamTable;
|
||||||
@@ -37,10 +42,13 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
private final Http2Settings.StreamWindowUpdater streamWindows;
|
private final Http2Settings.StreamWindowUpdater streamWindows;
|
||||||
private final long settingsAckTimeoutMs;
|
private final long settingsAckTimeoutMs;
|
||||||
private final Http2HeaderBlockDecoder headerBlocks = new Http2HeaderBlockDecoder();
|
private final Http2HeaderBlockDecoder headerBlocks = new Http2HeaderBlockDecoder();
|
||||||
private final Http2StreamTable streams = new Http2StreamTable(Http2Limits.MAX_CONCURRENT_STREAMS);
|
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 static final HeaderSink DISCARD_HEADERS = (name, value, never) -> {};
|
||||||
|
|
||||||
private long connectionSendWindow = 65_535;
|
private Http2FlowController flowController;
|
||||||
private int outstandingLocalSettings;
|
private int outstandingLocalSettings;
|
||||||
private long oldestSettingsSentNanos;
|
private long oldestSettingsSentNanos;
|
||||||
private int lastProcessedStreamId;
|
private int lastProcessedStreamId;
|
||||||
@@ -52,9 +60,13 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
private int highestClientStreamId;
|
private int highestClientStreamId;
|
||||||
private Http2Stream pendingHeaderStream;
|
private Http2Stream pendingHeaderStream;
|
||||||
private boolean refusingHeaderStream;
|
private boolean refusingHeaderStream;
|
||||||
|
private boolean pendingTrailers;
|
||||||
private Http2StreamDispatcher streamDispatcher;
|
private Http2StreamDispatcher streamDispatcher;
|
||||||
private final Http2Stream[] dispatchQueue = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
|
private final Http2Stream[] dispatchQueue = new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
|
||||||
private int dispatchCount;
|
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() {
|
public Http2Connection() {
|
||||||
this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
|
this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
|
||||||
@@ -68,7 +80,8 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
this.streamWindows =
|
this.streamWindows =
|
||||||
delta -> {
|
delta -> {
|
||||||
try {
|
try {
|
||||||
streams.adjustAllSendWindows(delta);
|
if (flowController == null) streams.adjustAllSendWindows(delta);
|
||||||
|
else flowController.applyInitialWindowDelta(streams, delta);
|
||||||
} catch (IllegalStateException overflow) {
|
} catch (IllegalStateException overflow) {
|
||||||
throw Http2Exception.FLOW_CONTROL_ERROR;
|
throw Http2Exception.FLOW_CONTROL_ERROR;
|
||||||
}
|
}
|
||||||
@@ -79,13 +92,18 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void run(ConnectionContext ctx) throws IOException {
|
public void run(ConnectionContext ctx) throws IOException {
|
||||||
|
configure(ctx.configuration());
|
||||||
Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write);
|
Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write);
|
||||||
|
flowController =
|
||||||
|
new Http2FlowController(
|
||||||
|
(streamId, increment) -> sendWindowUpdate(writer, streamId, increment));
|
||||||
streamDispatcher =
|
streamDispatcher =
|
||||||
new Http2StreamDispatcher(
|
new Http2StreamDispatcher(
|
||||||
ctx,
|
ctx,
|
||||||
writer,
|
writer,
|
||||||
peerSettings,
|
peerSettings,
|
||||||
streams,
|
streams,
|
||||||
|
flowController,
|
||||||
(streamId, error) -> sendRstStream(writer, streamId, error));
|
(streamId, error) -> sendRstStream(writer, streamId, error));
|
||||||
try {
|
try {
|
||||||
run(ctx.in(), writer, ctx.stopped());
|
run(ctx.in(), writer, ctx.stopped());
|
||||||
@@ -96,6 +114,11 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
|
|
||||||
void run(BufferedByteSource input, Http2FrameWriter writer, BooleanSupplier stopped)
|
void run(BufferedByteSource input, Http2FrameWriter writer, BooleanSupplier stopped)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
|
if (flowController == null) {
|
||||||
|
flowController =
|
||||||
|
new Http2FlowController(
|
||||||
|
(streamId, increment) -> sendWindowUpdate(writer, streamId, increment));
|
||||||
|
}
|
||||||
Http2FrameReader reader = new Http2FrameReader(input);
|
Http2FrameReader reader = new Http2FrameReader(input);
|
||||||
runPrepared(input, reader, writer, stopped);
|
runPrepared(input, reader, writer, stopped);
|
||||||
}
|
}
|
||||||
@@ -107,7 +130,14 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
Http2FrameWriter writer,
|
Http2FrameWriter writer,
|
||||||
BooleanSupplier stopped)
|
BooleanSupplier stopped)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
if (!verifyPreface(input)) return;
|
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.serverSettings());
|
||||||
sendConstant(writer, Http2Preface.initialConnectionWindow());
|
sendConstant(writer, Http2Preface.initialConnectionWindow());
|
||||||
@@ -117,12 +147,15 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
boolean firstFrame = true;
|
boolean firstFrame = true;
|
||||||
try {
|
try {
|
||||||
while (!gracefulFinished && !peerGoAway) {
|
while (!gracefulFinished && !peerGoAway) {
|
||||||
|
abuse.checkBudgets();
|
||||||
|
closeIdleStreams(writer);
|
||||||
if (stopped.getAsBoolean() && !gracefulStarted) startGracefulShutdown(writer);
|
if (stopped.getAsBoolean() && !gracefulStarted) startGracefulShutdown(writer);
|
||||||
FrameHeader frame;
|
FrameHeader frame;
|
||||||
try {
|
try {
|
||||||
frame = reader.readFrame(Math.min(100, nextReadTimeoutMs()));
|
frame = reader.readFrame(Math.min(100, nextReadTimeoutMs()));
|
||||||
} catch (SocketTimeoutException timeout) {
|
} catch (SocketTimeoutException timeout) {
|
||||||
checkSettingsTimeout();
|
checkSettingsTimeout();
|
||||||
|
headerBlocks.checkTimeout();
|
||||||
if (stopped.getAsBoolean() && !gracefulStarted) {
|
if (stopped.getAsBoolean() && !gracefulStarted) {
|
||||||
startGracefulShutdown(writer);
|
startGracefulShutdown(writer);
|
||||||
continue;
|
continue;
|
||||||
@@ -131,7 +164,9 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (frame == null) break;
|
if (frame == null) break;
|
||||||
|
abuse.receivedFrame(frame.length());
|
||||||
try {
|
try {
|
||||||
|
headerBlocks.checkTimeout();
|
||||||
FrameValidator.validate(frame, headerBlocks.insideHeaderBlock());
|
FrameValidator.validate(frame, headerBlocks.insideHeaderBlock());
|
||||||
if (headerBlocks.insideHeaderBlock() && frame.type() != FrameType.CONTINUATION) {
|
if (headerBlocks.insideHeaderBlock() && frame.type() != FrameType.CONTINUATION) {
|
||||||
throw Http2Exception.PROTOCOL_ERROR;
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
@@ -165,25 +200,36 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean verifyPreface(BufferedByteSource input) throws IOException {
|
private PrefaceResult verifyPreface(BufferedByteSource input) throws IOException {
|
||||||
byte[] preface = scratch.prefaceBuffer();
|
byte[] preface = scratch.prefaceBuffer();
|
||||||
int read = 0;
|
int read = 0;
|
||||||
input.setDeadline(System.nanoTime() + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L);
|
input.setDeadline(System.nanoTime() + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L);
|
||||||
try {
|
try {
|
||||||
while (read < preface.length) {
|
while (read < preface.length) {
|
||||||
int n = input.read(preface, read, preface.length - read);
|
int n = input.read(preface, read, preface.length - read);
|
||||||
if (n < 0) return false;
|
if (n < 0) return PrefaceResult.TRUNCATED;
|
||||||
read += n;
|
read += n;
|
||||||
}
|
}
|
||||||
return Http2Preface.matchesClientPreface(preface);
|
return Http2Preface.matchesClientPreface(preface)
|
||||||
|
? PrefaceResult.MATCHED
|
||||||
|
: PrefaceResult.INVALID;
|
||||||
} finally {
|
} finally {
|
||||||
input.clearDeadline();
|
input.clearDeadline();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum PrefaceResult {
|
||||||
|
MATCHED,
|
||||||
|
INVALID,
|
||||||
|
TRUNCATED
|
||||||
|
}
|
||||||
|
|
||||||
private void dispatch(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
private void dispatch(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
||||||
FrameType type = frame.type();
|
FrameType type = frame.type();
|
||||||
if (type == null) return;
|
if (type == null) {
|
||||||
|
abuse.uselessFrameReceived();
|
||||||
|
return;
|
||||||
|
}
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case SETTINGS -> receiveSettings(frame, writer);
|
case SETTINGS -> receiveSettings(frame, writer);
|
||||||
case PING -> receivePing(frame, writer);
|
case PING -> receivePing(frame, writer);
|
||||||
@@ -201,12 +247,50 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
private void receiveHeaders(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
private void receiveHeaders(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
||||||
int streamId = frame.streamId();
|
int streamId = frame.streamId();
|
||||||
if ((streamId & 1) == 0) throw Http2Exception.PROTOCOL_ERROR;
|
if ((streamId & 1) == 0) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
if (streamId <= highestClientStreamId) 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;
|
highestClientStreamId = streamId;
|
||||||
|
pendingTrailers = false;
|
||||||
|
|
||||||
pendingHeaderStream = streams.acquire(streamId);
|
pendingHeaderStream = streams.acquire(streamId);
|
||||||
refusingHeaderStream = pendingHeaderStream == null;
|
refusingHeaderStream = pendingHeaderStream == null;
|
||||||
HeaderSink sink = refusingHeaderStream ? DISCARD_HEADERS : pendingHeaderStream.headerBlock();
|
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());
|
if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,34 +298,55 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
if (pendingHeaderStream == null && !refusingHeaderStream) {
|
if (pendingHeaderStream == null && !refusingHeaderStream) {
|
||||||
throw Http2Exception.PROTOCOL_ERROR;
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
}
|
}
|
||||||
HeaderSink sink = refusingHeaderStream ? DISCARD_HEADERS : pendingHeaderStream.headerBlock();
|
HeaderSink sink =
|
||||||
|
refusingHeaderStream
|
||||||
|
? DISCARD_HEADERS
|
||||||
|
: (pendingTrailers
|
||||||
|
? pendingHeaderStream.trailerBlock()
|
||||||
|
: pendingHeaderStream.headerBlock());
|
||||||
if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId());
|
if (headerBlocks.accept(frame, sink)) completeHeaders(writer, frame.streamId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void completeHeaders(Http2FrameWriter writer, int streamId) throws IOException {
|
private void completeHeaders(Http2FrameWriter writer, int streamId) throws IOException {
|
||||||
if (refusingHeaderStream) {
|
try {
|
||||||
sendRstStream(writer, streamId, Http2ErrorCode.REFUSED_STREAM);
|
if (refusingHeaderStream) {
|
||||||
} else {
|
sendRstStream(writer, streamId, Http2ErrorCode.REFUSED_STREAM);
|
||||||
Http2Stream stream = pendingHeaderStream;
|
} else {
|
||||||
if (streamDispatcher != null) stream.validateHeaders();
|
Http2Stream stream = pendingHeaderStream;
|
||||||
stream.transition(
|
boolean dispatch;
|
||||||
headerBlocks.endStream()
|
if (pendingTrailers) {
|
||||||
? Http2StreamState.Event.RECV_HEADERS_ES
|
stream.validateTrailers();
|
||||||
: Http2StreamState.Event.RECV_HEADERS);
|
stream.finishRequestBody();
|
||||||
lastProcessedStreamId = Math.max(lastProcessedStreamId, streamId);
|
stream.transition(Http2StreamState.Event.RECV_HEADERS_ES);
|
||||||
if (streamDispatcher == null) {
|
dispatch = !stream.dispatched();
|
||||||
streams.remove(streamId);
|
} else {
|
||||||
streams.release(stream);
|
if (streamDispatcher != null) stream.validateHeaders();
|
||||||
if (!gracefulStarted) startGracefulShutdown(writer);
|
dispatch = stream.prepareRequestBody(flowController, headerBlocks.endStream());
|
||||||
} else if (headerBlocks.endStream()) {
|
stream.transition(
|
||||||
enqueueDispatch(stream);
|
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;
|
||||||
}
|
}
|
||||||
pendingHeaderStream = null;
|
|
||||||
refusingHeaderStream = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void receivePriority(FrameHeader frame) {
|
private void receivePriority(FrameHeader frame) {
|
||||||
|
abuse.uselessFrameReceived();
|
||||||
int dependency = readUInt31(frame.buffer(), frame.payloadOffset());
|
int dependency = readUInt31(frame.buffer(), frame.payloadOffset());
|
||||||
if (dependency == frame.streamId()) {
|
if (dependency == frame.streamId()) {
|
||||||
throw new Http2StreamException(
|
throw new Http2StreamException(
|
||||||
@@ -250,21 +355,59 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void receiveData(FrameHeader frame) {
|
private void receiveData(FrameHeader frame) {
|
||||||
Http2Stream stream = streamForFrame(frame.streamId());
|
if (frame.length() == 0) abuse.uselessFrameReceived();
|
||||||
stream.transition(
|
flowController.receiveConnectionBytes(frame.length());
|
||||||
FrameFlags.isEndStream(frame.flags())
|
Http2Stream stream = streams.get(frame.streamId());
|
||||||
? Http2StreamState.Event.RECV_DATA_ES
|
if (stream == null) {
|
||||||
: Http2StreamState.Event.RECV_DATA);
|
discardConnectionBytes(frame.length());
|
||||||
if (frame.length() != 0) {
|
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
throw new Http2StreamException(
|
throw new Http2StreamException(
|
||||||
frame.streamId(), Http2ErrorCode.INTERNAL_ERROR, "request DATA support is not active");
|
frame.streamId(), Http2ErrorCode.STREAM_CLOSED, "stream is closed");
|
||||||
}
|
}
|
||||||
if (FrameFlags.isEndStream(frame.flags()) && streamDispatcher != null) {
|
boolean bodyAccepted = false;
|
||||||
enqueueDispatch(stream);
|
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) {
|
private void receiveRstStream(FrameHeader frame) {
|
||||||
|
abuse.resetReceived();
|
||||||
Http2Stream stream = streams.get(frame.streamId());
|
Http2Stream stream = streams.get(frame.streamId());
|
||||||
if (stream == null) {
|
if (stream == null) {
|
||||||
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
if (frame.streamId() > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
@@ -273,9 +416,11 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
boolean releaseDeferred =
|
boolean releaseDeferred =
|
||||||
stream.dispatched() || stream.state() == Http2StreamState.HALF_CLOSED_REMOTE;
|
stream.dispatched() || stream.state() == Http2StreamState.HALF_CLOSED_REMOTE;
|
||||||
stream.transition(Http2StreamState.Event.RECV_RST);
|
stream.transition(Http2StreamState.Event.RECV_RST);
|
||||||
streams.remove(stream.id());
|
if (!streams.removeIfSame(stream, frame.streamId())) return;
|
||||||
|
streams.rememberReset(frame.streamId());
|
||||||
if (releaseDeferred) {
|
if (releaseDeferred) {
|
||||||
stream.cancel();
|
stream.cancel();
|
||||||
|
if (stream.responseStarted() && !stream.responseInFlight()) streams.release(stream);
|
||||||
} else {
|
} else {
|
||||||
streams.release(stream);
|
streams.release(stream);
|
||||||
}
|
}
|
||||||
@@ -286,6 +431,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
throw new Http2StreamException(
|
throw new Http2StreamException(
|
||||||
stream.id(), Http2ErrorCode.REFUSED_STREAM, "dispatch queue is full");
|
stream.id(), Http2ErrorCode.REFUSED_STREAM, "dispatch queue is full");
|
||||||
}
|
}
|
||||||
|
stream.markDispatched();
|
||||||
dispatchQueue[dispatchCount++] = stream;
|
dispatchQueue[dispatchCount++] = stream;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,13 +445,6 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private Http2Stream streamForFrame(int streamId) {
|
|
||||||
Http2Stream stream = streams.get(streamId);
|
|
||||||
if (stream != null) return stream;
|
|
||||||
if (streamId > highestClientStreamId) throw Http2Exception.PROTOCOL_ERROR;
|
|
||||||
throw new Http2StreamException(streamId, Http2ErrorCode.STREAM_CLOSED, "stream is closed");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void receiveSettings(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
private void receiveSettings(FrameHeader frame, Http2FrameWriter writer) throws IOException {
|
||||||
boolean ack = FrameFlags.isAck(frame.flags());
|
boolean ack = FrameFlags.isAck(frame.flags());
|
||||||
if (ack) {
|
if (ack) {
|
||||||
@@ -315,6 +454,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
if (outstandingLocalSettings == 0) oldestSettingsSentNanos = 0;
|
if (outstandingLocalSettings == 0) oldestSettingsSentNanos = 0;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
abuse.settingsReceived();
|
||||||
peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), streamWindows);
|
peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), streamWindows);
|
||||||
sendConstant(writer, Http2Preface.settingsAck());
|
sendConstant(writer, Http2Preface.settingsAck());
|
||||||
}
|
}
|
||||||
@@ -327,12 +467,14 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
abuse.pingReceived();
|
||||||
ControlIntent pong = scratch.acquire(ControlKind.PING);
|
ControlIntent pong = scratch.acquire(ControlKind.PING);
|
||||||
pong.frame(FrameType.PING, FrameFlags.ACK, 0, frame.buffer(), frame.payloadOffset(), 8);
|
pong.frame(FrameType.PING, FrameFlags.ACK, 0, frame.buffer(), frame.payloadOffset(), 8);
|
||||||
writer.writePriority(pong);
|
writer.writePriority(pong);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void receiveWindowUpdate(FrameHeader frame) {
|
private void receiveWindowUpdate(FrameHeader frame) {
|
||||||
|
abuse.uselessFrameReceived();
|
||||||
int increment = readUInt31(frame.buffer(), frame.payloadOffset());
|
int increment = readUInt31(frame.buffer(), frame.payloadOffset());
|
||||||
if (increment == 0) {
|
if (increment == 0) {
|
||||||
if (frame.streamId() == 0) throw Http2Exception.PROTOCOL_ERROR;
|
if (frame.streamId() == 0) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
@@ -346,16 +488,17 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
stream.adjustSendWindow(increment);
|
stream.touch();
|
||||||
|
flowController.increaseStreamSendWindow(stream, increment);
|
||||||
} catch (IllegalStateException overflow) {
|
} catch (IllegalStateException overflow) {
|
||||||
throw new Http2StreamException(
|
throw new Http2StreamException(
|
||||||
frame.streamId(), Http2ErrorCode.FLOW_CONTROL_ERROR, "stream window overflow");
|
frame.streamId(), Http2ErrorCode.FLOW_CONTROL_ERROR, "stream window overflow");
|
||||||
}
|
}
|
||||||
|
if (streamDispatcher != null) streamDispatcher.streamWindowUpdated(stream);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
long next = connectionSendWindow + increment;
|
flowController.increaseConnectionSendWindow(increment);
|
||||||
if (next > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR;
|
if (streamDispatcher != null) streamDispatcher.connectionWindowUpdated();
|
||||||
connectionSendWindow = next;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void receiveGoAway(FrameHeader frame) {
|
private void receiveGoAway(FrameHeader frame) {
|
||||||
@@ -364,6 +507,30 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
peerGoAway = true;
|
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 {
|
private void startGracefulShutdown(Http2FrameWriter writer) throws IOException {
|
||||||
gracefulStarted = true;
|
gracefulStarted = true;
|
||||||
sendGoAway(writer, Integer.MAX_VALUE, Http2ErrorCode.NO_ERROR, "server shutting down");
|
sendGoAway(writer, Integer.MAX_VALUE, Http2ErrorCode.NO_ERROR, "server shutting down");
|
||||||
@@ -386,9 +553,26 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
writer.writePriority(rst);
|
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) {
|
private void closeStreamAfterError(int streamId) {
|
||||||
Http2Stream stream = streams.remove(streamId);
|
Http2Stream stream = streams.get(streamId);
|
||||||
if (stream == null) return;
|
if (stream == null) return;
|
||||||
|
if (!streams.removeIfSame(stream, streamId)) return;
|
||||||
|
streams.rememberReset(streamId);
|
||||||
if (stream.dispatched()) stream.cancel();
|
if (stream.dispatched()) stream.cancel();
|
||||||
else streams.release(stream);
|
else streams.release(stream);
|
||||||
if (pendingHeaderStream == stream) pendingHeaderStream = null;
|
if (pendingHeaderStream == stream) pendingHeaderStream = null;
|
||||||
@@ -446,7 +630,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public long connectionSendWindow() {
|
public long connectionSendWindow() {
|
||||||
return connectionSendWindow;
|
return flowController == null ? 65_535 : flowController.connectionSendWindow();
|
||||||
}
|
}
|
||||||
|
|
||||||
public int peerLastStreamId() {
|
public int peerLastStreamId() {
|
||||||
@@ -459,7 +643,7 @@ public final class Http2Connection implements ConnectionProtocol {
|
|||||||
|
|
||||||
void reset() {
|
void reset() {
|
||||||
peerSettings.reset();
|
peerSettings.reset();
|
||||||
connectionSendWindow = 65_535;
|
flowController = null;
|
||||||
outstandingLocalSettings = 0;
|
outstandingLocalSettings = 0;
|
||||||
oldestSettingsSentNanos = 0;
|
oldestSettingsSentNanos = 0;
|
||||||
lastProcessedStreamId = 0;
|
lastProcessedStreamId = 0;
|
||||||
|
|||||||
@@ -119,6 +119,17 @@ final class Http2ConnectionScratch {
|
|||||||
length = 17 + debugLength;
|
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) {
|
private static void writeUInt31(byte[] target, int off, int value) {
|
||||||
writeUInt32(target, off, value & 0x7FFF_FFFF);
|
writeUInt32(target, off, value & 0x7FFF_FFFF);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,18 @@ final class Http2HeaderBlockDecoder {
|
|||||||
|
|
||||||
private final ContinuationAssembler assembler = new ContinuationAssembler();
|
private final ContinuationAssembler assembler = new ContinuationAssembler();
|
||||||
private final HpackDecoder decoder = new HpackDecoder();
|
private final HpackDecoder decoder = new HpackDecoder();
|
||||||
|
private final long assemblyTimeoutNanos;
|
||||||
private boolean endStream;
|
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() {
|
boolean insideHeaderBlock() {
|
||||||
return assembler.isActive();
|
return assembler.isActive();
|
||||||
@@ -24,6 +35,7 @@ final class Http2HeaderBlockDecoder {
|
|||||||
|
|
||||||
/** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */
|
/** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */
|
||||||
boolean accept(FrameHeader frame, HeaderSink sink) {
|
boolean accept(FrameHeader frame, HeaderSink sink) {
|
||||||
|
checkTimeout();
|
||||||
if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) {
|
if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) {
|
||||||
throw Http2Exception.PROTOCOL_ERROR;
|
throw Http2Exception.PROTOCOL_ERROR;
|
||||||
}
|
}
|
||||||
@@ -50,14 +62,26 @@ final class Http2HeaderBlockDecoder {
|
|||||||
streamId, Http2ErrorCode.ENHANCE_YOUR_CALM, tooLarge.getMessage());
|
streamId, Http2ErrorCode.ENHANCE_YOUR_CALM, tooLarge.getMessage());
|
||||||
}
|
}
|
||||||
assembler.reset();
|
assembler.reset();
|
||||||
|
assemblyStartedNanos = 0;
|
||||||
return true;
|
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() {
|
boolean endStream() {
|
||||||
return endStream;
|
return endStream;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void begin(FrameHeader frame) {
|
private void begin(FrameHeader frame) {
|
||||||
|
assemblyStartedNanos = System.nanoTime();
|
||||||
endStream = FrameFlags.isEndStream(frame.flags());
|
endStream = FrameFlags.isEndStream(frame.flags());
|
||||||
long unpadded =
|
long unpadded =
|
||||||
Padding.unpad(
|
Padding.unpad(
|
||||||
|
|||||||
@@ -70,8 +70,40 @@ public final class Http2Limits {
|
|||||||
* companion bound to {@link #MAX_RESET_STREAMS_PER_INTERVAL}: Rapid Reset defences that only
|
* 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
|
* 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.
|
* 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 = 400;
|
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
|
* Maximum number of SETTINGS parameter entries accepted in a single SETTINGS frame. A SETTINGS
|
||||||
@@ -111,6 +143,15 @@ public final class Http2Limits {
|
|||||||
*/
|
*/
|
||||||
public static final int MAX_EMPTY_DATA_FRAMES_PER_STREAM = 1_000;
|
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:
|
* 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
|
* deliberately large (1 MiB, versus the RFC default of 65535) so that a normal-sized
|
||||||
@@ -125,7 +166,7 @@ public final class Http2Limits {
|
|||||||
* windows, and sizing for that worst case would commit 100 MiB of receive window to every
|
* windows, and sizing for that worst case would commit 100 MiB of receive window to every
|
||||||
* connection regardless of load.
|
* connection regardless of load.
|
||||||
*/
|
*/
|
||||||
public static final int CONNECTION_WINDOW_SIZE_LOCAL = 16 * 1_048_576;
|
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
|
* The HPACK dynamic table size Flash's decoder honours, in bytes of RFC 7541 §4.1 accounting. RFC
|
||||||
@@ -170,7 +211,7 @@ public final class Http2Limits {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Maximum time, in milliseconds, {@code Http2FrameReader} may wait for a single frame's
|
* Maximum time, in milliseconds, {@code Http2FrameReader} may wait for a single frame's
|
||||||
* header and payload to fully arrive. Bounds the same slowloris-shaped hazard {@code
|
* 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
|
* 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.
|
* would hold this connection's frame reader waiting forever.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import dev.relism.flash.http2.frame.FrameWriteBuffer;
|
|||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
/** Byte-exact client preface and immutable server startup frames, compiled once at class load. */
|
/** Byte-exact client preface and immutable server startup frames, compiled once at class load. */
|
||||||
final class Http2Preface {
|
public final class Http2Preface {
|
||||||
private static final byte[] CLIENT_PREFACE =
|
private static final byte[] CLIENT_PREFACE =
|
||||||
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
|
"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[] SERVER_SETTINGS = buildServerSettings();
|
||||||
@@ -16,6 +16,11 @@ final class Http2Preface {
|
|||||||
|
|
||||||
private Http2Preface() {}
|
private Http2Preface() {}
|
||||||
|
|
||||||
|
/** Immutable client connection preface bytes. Callers must not modify the returned array. */
|
||||||
|
public static byte[] clientPreface() {
|
||||||
|
return CLIENT_PREFACE;
|
||||||
|
}
|
||||||
|
|
||||||
static int clientPrefaceLength() {
|
static int clientPrefaceLength() {
|
||||||
return CLIENT_PREFACE.length;
|
return CLIENT_PREFACE.length;
|
||||||
}
|
}
|
||||||
@@ -50,6 +55,7 @@ final class Http2Preface {
|
|||||||
setting(bytes, Http2Settings.MAX_CONCURRENT_STREAMS, Http2Limits.MAX_CONCURRENT_STREAMS);
|
setting(bytes, Http2Settings.MAX_CONCURRENT_STREAMS, Http2Limits.MAX_CONCURRENT_STREAMS);
|
||||||
setting(bytes, Http2Settings.INITIAL_WINDOW_SIZE, Http2Limits.INITIAL_WINDOW_SIZE_LOCAL);
|
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.MAX_HEADER_LIST_SIZE, Http2Limits.MAX_HEADER_LIST_SIZE);
|
||||||
|
setting(bytes, Http2Settings.ENABLE_CONNECT_PROTOCOL, 1);
|
||||||
frame.endFrame();
|
frame.endFrame();
|
||||||
return copy(bytes);
|
return copy(bytes);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public final class Http2Settings {
|
|||||||
public static final int INITIAL_WINDOW_SIZE = 0x4;
|
public static final int INITIAL_WINDOW_SIZE = 0x4;
|
||||||
public static final int MAX_FRAME_SIZE = 0x5;
|
public static final int MAX_FRAME_SIZE = 0x5;
|
||||||
public static final int MAX_HEADER_LIST_SIZE = 0x6;
|
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_HEADER_TABLE_SIZE = 4_096;
|
||||||
public static final int DEFAULT_INITIAL_WINDOW_SIZE = 65_535;
|
public static final int DEFAULT_INITIAL_WINDOW_SIZE = 65_535;
|
||||||
@@ -78,7 +79,7 @@ public final class Http2Settings {
|
|||||||
|
|
||||||
private static void validate(int id, long value) {
|
private static void validate(int id, long value) {
|
||||||
switch (id) {
|
switch (id) {
|
||||||
case ENABLE_PUSH -> {
|
case ENABLE_PUSH, ENABLE_CONNECT_PROTOCOL -> {
|
||||||
if (value > 1) throw Http2Exception.PROTOCOL_ERROR;
|
if (value > 1) throw Http2Exception.PROTOCOL_ERROR;
|
||||||
}
|
}
|
||||||
case INITIAL_WINDOW_SIZE -> {
|
case INITIAL_WINDOW_SIZE -> {
|
||||||
|
|||||||
@@ -1,22 +1,29 @@
|
|||||||
package dev.relism.flash.http2;
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
import dev.relism.flash.http.HttpMethod;
|
import dev.relism.flash.http.HttpMethod;
|
||||||
|
import dev.relism.flash.http.HttpStatus;
|
||||||
import dev.relism.flash.http2.frame.Http2FrameWriter;
|
import dev.relism.flash.http2.frame.Http2FrameWriter;
|
||||||
import dev.relism.flash.http2.message.Http2ResponseWriter;
|
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.Http2Stream;
|
||||||
import dev.relism.flash.http2.stream.Http2StreamState;
|
import dev.relism.flash.http2.stream.Http2StreamState;
|
||||||
import dev.relism.flash.http2.stream.Http2StreamTable;
|
import dev.relism.flash.http2.stream.Http2StreamTable;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
import dev.relism.flash.models.RequestHandler;
|
import dev.relism.flash.models.RequestHandler;
|
||||||
import dev.relism.flash.models.Response;
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.flash.models.ResponseStreamOutputStream;
|
||||||
import dev.relism.flash.transport.ConnectionContext;
|
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.io.IOException;
|
||||||
import java.util.concurrent.RejectedExecutionException;
|
import java.util.concurrent.RejectedExecutionException;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
|
||||||
/** Dispatches completed request streams without blocking the connection demultiplexer. */
|
/** Dispatches completed request streams without blocking the connection demultiplexer. */
|
||||||
@Slf4j
|
@Slf4j
|
||||||
final class Http2StreamDispatcher {
|
final class Http2StreamDispatcher implements Http2Stream.ResponseSink {
|
||||||
@FunctionalInterface
|
@FunctionalInterface
|
||||||
interface FailureSink {
|
interface FailureSink {
|
||||||
void fail(int streamId, Http2ErrorCode errorCode) throws IOException;
|
void fail(int streamId, Http2ErrorCode errorCode) throws IOException;
|
||||||
@@ -26,7 +33,10 @@ final class Http2StreamDispatcher {
|
|||||||
private final Http2FrameWriter frameWriter;
|
private final Http2FrameWriter frameWriter;
|
||||||
private final Http2Settings peerSettings;
|
private final Http2Settings peerSettings;
|
||||||
private final Http2StreamTable streams;
|
private final Http2StreamTable streams;
|
||||||
|
private final Http2FlowController flowController;
|
||||||
private final FailureSink failures;
|
private final FailureSink failures;
|
||||||
|
private final Http2Stream[] resumeScratch =
|
||||||
|
new Http2Stream[Http2Limits.MAX_CONCURRENT_STREAMS];
|
||||||
private volatile boolean firstResponse = true;
|
private volatile boolean firstResponse = true;
|
||||||
|
|
||||||
Http2StreamDispatcher(
|
Http2StreamDispatcher(
|
||||||
@@ -34,46 +44,109 @@ final class Http2StreamDispatcher {
|
|||||||
Http2FrameWriter frameWriter,
|
Http2FrameWriter frameWriter,
|
||||||
Http2Settings peerSettings,
|
Http2Settings peerSettings,
|
||||||
Http2StreamTable streams,
|
Http2StreamTable streams,
|
||||||
|
Http2FlowController flowController,
|
||||||
FailureSink failures) {
|
FailureSink failures) {
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.frameWriter = frameWriter;
|
this.frameWriter = frameWriter;
|
||||||
this.peerSettings = peerSettings;
|
this.peerSettings = peerSettings;
|
||||||
this.streams = streams;
|
this.streams = streams;
|
||||||
|
this.flowController = flowController;
|
||||||
this.failures = failures;
|
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) {
|
void dispatch(Http2Stream stream) {
|
||||||
if (stream.cancelled()) {
|
if (stream.cancelled()) {
|
||||||
streams.release(stream);
|
streams.release(stream);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
stream.markDispatched();
|
stream.markDispatched();
|
||||||
|
stream.responseSink(this);
|
||||||
try {
|
try {
|
||||||
context.executor().execute(() -> handle(stream));
|
context.executor().execute(stream);
|
||||||
} catch (RejectedExecutionException rejected) {
|
} catch (RejectedExecutionException rejected) {
|
||||||
failAndRelease(stream, Http2ErrorCode.REFUSED_STREAM, rejected);
|
failAndRelease(stream, Http2ErrorCode.REFUSED_STREAM, rejected);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void handleRequest(Http2Stream stream) {
|
||||||
|
handle(stream);
|
||||||
|
}
|
||||||
|
|
||||||
private void handle(Http2Stream stream) {
|
private void handle(Http2Stream stream) {
|
||||||
|
stream.touch();
|
||||||
|
if (stream.cancelled()) {
|
||||||
|
streams.release(stream);
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
Request request = stream.assembleRequest(context.remoteAddress(), context.sslSocket());
|
Request request = stream.assembleRequest(context.remoteAddress(), context.sslSocket());
|
||||||
Response pooled = stream.resetResponse();
|
Response pooled = stream.resetResponse();
|
||||||
Response response = pooled;
|
Response response = pooled;
|
||||||
Object routeScratch = stream.routeScratch(context.router());
|
if (!Http2Authority.isServed(request.header("host"), request.sslSession())) {
|
||||||
RequestHandler handler = context.router().route(request, routeScratch);
|
response.status(HttpStatus.MISDIRECTED_REQUEST);
|
||||||
if (handler == null) handler = context.router().getNotFoundHandler();
|
if (stream.websocketConnect()) response.type(ContentType.NONE).streaming(output -> {});
|
||||||
try {
|
} else if (stream.websocketConnect()) {
|
||||||
Object result = handler.handle(request, response);
|
WebSocketHandler handler =
|
||||||
if (result instanceof Response returned) response = returned;
|
context.wsRouter().route(request, stream.wsRouteScratch(context.wsRouter()));
|
||||||
else if (result != null) response.setBody(result);
|
response.type(ContentType.NONE);
|
||||||
} catch (Exception handlerFailure) {
|
if (handler == null) {
|
||||||
Object result =
|
response.status(HttpStatus.NOT_FOUND).streaming(output -> {});
|
||||||
context.router().getExceptionHandler().handle(handlerFailure, request, response);
|
} else {
|
||||||
if (result instanceof Response returned) response = returned;
|
response.streaming(
|
||||||
else if (result != null) response.setBody(result);
|
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();
|
Http2ResponseWriter responseWriter = stream.responseWriter();
|
||||||
if (stream.cancelled()) {
|
if (stream.cancelled()) {
|
||||||
request.recycle();
|
request.recycle();
|
||||||
@@ -81,48 +154,150 @@ final class Http2StreamDispatcher {
|
|||||||
streams.release(stream);
|
streams.release(stream);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
boolean prepared;
|
boolean headRequest = request.method() == HttpMethod.HEAD;
|
||||||
|
int reserved;
|
||||||
|
int used;
|
||||||
synchronized (this) {
|
synchronized (this) {
|
||||||
boolean tableUpdate = firstResponse;
|
boolean tableUpdate = firstResponse;
|
||||||
prepared =
|
reserved = flowController.reserveSend(stream, peerSettings.maxFrameSize());
|
||||||
responseWriter.prepare(
|
used = 0;
|
||||||
response,
|
try {
|
||||||
stream.id(),
|
used =
|
||||||
request.method() == HttpMethod.HEAD,
|
responseWriter.startFlowControlled(
|
||||||
context.configuration().isSendDate(),
|
response,
|
||||||
true,
|
stream.id(),
|
||||||
context.configuration().isH2HuffmanDynamicValues(),
|
headRequest,
|
||||||
tableUpdate,
|
context.configuration().isSendDate(),
|
||||||
peerSettings.maxFrameSize(),
|
true,
|
||||||
peerSettings.maxHeaderListSize(),
|
context.configuration().isH2HuffmanDynamicValues(),
|
||||||
(int) Math.min(stream.sendWindow(), Integer.MAX_VALUE));
|
tableUpdate,
|
||||||
if (prepared) {
|
peerSettings.maxFrameSize(),
|
||||||
firstResponse = false;
|
peerSettings.maxHeaderListSize(),
|
||||||
stream.transition(Http2StreamState.Event.SEND_HEADERS_ES);
|
reserved);
|
||||||
request.recycle();
|
} finally {
|
||||||
if (response == pooled) pooled.recycle();
|
flowController.refundSend(stream, reserved - used);
|
||||||
streams.remove(stream.id());
|
|
||||||
frameWriter.write(responseWriter);
|
|
||||||
}
|
}
|
||||||
|
firstResponse = false;
|
||||||
}
|
}
|
||||||
if (!prepared) {
|
if (!pushStreaming) request.recycle();
|
||||||
request.recycle();
|
stream.markResponseStarted();
|
||||||
if (response == pooled) pooled.recycle();
|
applyBatchTransition(stream, responseWriter);
|
||||||
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, null);
|
if (!stream.beginResponseBatch()) {
|
||||||
|
throw new IllegalStateException("response batch already in flight");
|
||||||
}
|
}
|
||||||
|
detachFinalBatch(stream, responseWriter);
|
||||||
|
frameWriter.write(responseWriter);
|
||||||
} catch (Exception failure) {
|
} catch (Exception failure) {
|
||||||
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure);
|
failAndRelease(stream, Http2ErrorCode.INTERNAL_ERROR, failure);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void failAndRelease(Http2Stream stream, Http2ErrorCode error, Exception cause) {
|
private void tryResumeResponse(Http2Stream stream) {
|
||||||
if (stream.id() == 0) return;
|
stream.touch();
|
||||||
if (cause != null) log.error("HTTP/2 stream {} failed", stream.id(), cause);
|
if (stream.cancelled()) {
|
||||||
streams.remove(stream.id());
|
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 {
|
try {
|
||||||
failures.fail(stream.id(), error);
|
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) {
|
} catch (IOException writeFailure) {
|
||||||
log.debug("Failed to write RST_STREAM for {}", stream.id(), writeFailure);
|
log.debug("Failed to write RST_STREAM for {}", streamId, writeFailure);
|
||||||
} finally {
|
} finally {
|
||||||
streams.release(stream);
|
streams.release(stream);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package dev.relism.flash.http2.hpack;
|
package dev.relism.flash.http2.hpack;
|
||||||
|
|
||||||
import dev.relism.flash.bytes.ByteWriter;
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
import dev.relism.fpr.core.ByteView;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stateless HPACK encoder for response header blocks. It uses the RFC 7541 static table and literal
|
* Stateless HPACK encoder for response header blocks. It uses the RFC 7541 static table and literal
|
||||||
@@ -25,6 +26,19 @@ public final class HpackEncoder {
|
|||||||
writeLiteral(out, name, 0, name.length, value, 0, value.length, false);
|
writeLiteral(out, name, 0, name.length, value, 0, value.length, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Writes a non-indexed literal directly from protocol-neutral byte views. */
|
||||||
|
public static void writeLiteral(ByteWriter out, ByteView name, ByteView value) {
|
||||||
|
HpackIntegers.encode(out, 0, 4, 0);
|
||||||
|
HpackIntegers.encode(out, 0, 7, name.length());
|
||||||
|
for (int i = 0; i < name.length(); i++) {
|
||||||
|
int octet = name.byteAt(i) & 0xff;
|
||||||
|
if (octet >= 'A' && octet <= 'Z') octet += 'a' - 'A';
|
||||||
|
out.writeByte((byte) octet);
|
||||||
|
}
|
||||||
|
HpackIntegers.encode(out, 0, 7, value.length());
|
||||||
|
for (int i = 0; i < value.length(); i++) out.writeByte(value.byteAt(i));
|
||||||
|
}
|
||||||
|
|
||||||
public static void writeLiteral(
|
public static void writeLiteral(
|
||||||
ByteWriter out,
|
ByteWriter out,
|
||||||
byte[] name,
|
byte[] name,
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package dev.relism.flash.http2.message;
|
||||||
|
|
||||||
|
/** Bounded connection-owned free list of frame-sized request-body buffers. */
|
||||||
|
public final class DataBufferPool {
|
||||||
|
static final class DataBuffer {
|
||||||
|
final byte[] bytes;
|
||||||
|
DataBuffer next;
|
||||||
|
int position;
|
||||||
|
int length;
|
||||||
|
int flowControlledBytes;
|
||||||
|
|
||||||
|
DataBuffer(int size) {
|
||||||
|
bytes = new byte[size];
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
next = null;
|
||||||
|
position = 0;
|
||||||
|
length = 0;
|
||||||
|
flowControlledBytes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final int bufferSize;
|
||||||
|
private final int maxBuffers;
|
||||||
|
private DataBuffer free;
|
||||||
|
private int created;
|
||||||
|
private int available;
|
||||||
|
|
||||||
|
public DataBufferPool(int bufferSize, int maxBuffers) {
|
||||||
|
if (bufferSize < 1 || maxBuffers < 1) {
|
||||||
|
throw new IllegalArgumentException("bufferSize and maxBuffers must be positive");
|
||||||
|
}
|
||||||
|
this.bufferSize = bufferSize;
|
||||||
|
this.maxBuffers = maxBuffers;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized DataBuffer acquire() {
|
||||||
|
DataBuffer buffer = free;
|
||||||
|
if (buffer != null) {
|
||||||
|
free = buffer.next;
|
||||||
|
available--;
|
||||||
|
buffer.reset();
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
if (created == maxBuffers) return null;
|
||||||
|
created++;
|
||||||
|
return new DataBuffer(bufferSize);
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized void release(DataBuffer buffer) {
|
||||||
|
buffer.reset();
|
||||||
|
buffer.next = free;
|
||||||
|
free = buffer;
|
||||||
|
available++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int createdCount() {
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int availableCount() {
|
||||||
|
return available;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int capacity() {
|
||||||
|
return maxBuffers;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ public final class Http2HeaderMap implements HeaderView {
|
|||||||
private HpackHeaderBlock block;
|
private HpackHeaderBlock block;
|
||||||
private PseudoHeaders pseudoHeaders;
|
private PseudoHeaders pseudoHeaders;
|
||||||
private int viewCursor;
|
private int viewCursor;
|
||||||
private int regularCount;
|
private int regularCount = -1;
|
||||||
|
|
||||||
public Http2HeaderMap() {
|
public Http2HeaderMap() {
|
||||||
for (int i = 0; i < views.length; i++) views[i] = new PooledSlice();
|
for (int i = 0; i < views.length; i++) views[i] = new PooledSlice();
|
||||||
@@ -28,11 +28,11 @@ public final class Http2HeaderMap implements HeaderView {
|
|||||||
this.block = block;
|
this.block = block;
|
||||||
this.pseudoHeaders = pseudoHeaders;
|
this.pseudoHeaders = pseudoHeaders;
|
||||||
viewCursor = 0;
|
viewCursor = 0;
|
||||||
regularCount = 0;
|
regularCount = -1;
|
||||||
for (int i = 0; i < block.count(); i++) {
|
}
|
||||||
block.get(i, scanName, scanValue);
|
|
||||||
if (scanName.byteAt(0) != ':') regularCount++;
|
public void reset(HpackHeaderBlock block) {
|
||||||
}
|
reset(block, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -54,7 +54,8 @@ public final class Http2HeaderMap implements HeaderView {
|
|||||||
new String(
|
new String(
|
||||||
scanValue.array(), scanValue.offset(), scanValue.length(), StandardCharsets.UTF_8));
|
scanValue.array(), scanValue.offset(), scanValue.length(), StandardCharsets.UTF_8));
|
||||||
}
|
}
|
||||||
if (result == null && isAuthorityAlias(name) && pseudoHeaders.authority().array() != null) {
|
if (result == null && pseudoHeaders != null
|
||||||
|
&& isAuthorityAlias(name) && pseudoHeaders.authority().array() != null) {
|
||||||
return List.of(string(pseudoHeaders.authority()));
|
return List.of(string(pseudoHeaders.authority()));
|
||||||
}
|
}
|
||||||
return result == null ? List.of() : result;
|
return result == null ? List.of() : result;
|
||||||
@@ -62,11 +63,16 @@ public final class Http2HeaderMap implements HeaderView {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<String> all() {
|
public List<String> all() {
|
||||||
List<String> result = new ArrayList<>(regularCount);
|
List<String> result = new ArrayList<>(regularCount < 0 ? block.count() : regularCount);
|
||||||
|
int found = 0;
|
||||||
for (int i = 0; i < block.count(); i++) {
|
for (int i = 0; i < block.count(); i++) {
|
||||||
block.get(i, scanName, scanValue);
|
block.get(i, scanName, scanValue);
|
||||||
if (scanName.byteAt(0) != ':') result.add(string(scanValue));
|
if (scanName.byteAt(0) != ':') {
|
||||||
|
result.add(string(scanValue));
|
||||||
|
found++;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
regularCount = found;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +95,14 @@ public final class Http2HeaderMap implements HeaderView {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int count() {
|
public int count() {
|
||||||
|
if (regularCount < 0) {
|
||||||
|
int found = 0;
|
||||||
|
for (int i = 0; i < block.count(); i++) {
|
||||||
|
block.get(i, scanName, scanValue);
|
||||||
|
if (scanName.byteAt(0) != ':') found++;
|
||||||
|
}
|
||||||
|
regularCount = found;
|
||||||
|
}
|
||||||
return regularCount;
|
return regularCount;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +122,8 @@ public final class Http2HeaderMap implements HeaderView {
|
|||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (isAuthorityAlias(requested) && pseudoHeaders.authority().array() != null) {
|
if (pseudoHeaders != null
|
||||||
|
&& isAuthorityAlias(requested) && pseudoHeaders.authority().array() != null) {
|
||||||
PooledSlice authority = pseudoHeaders.authority();
|
PooledSlice authority = pseudoHeaders.authority();
|
||||||
target.reset(authority.array(), authority.offset(), authority.length());
|
target.reset(authority.array(), authority.offset(), authority.length());
|
||||||
return target;
|
return target;
|
||||||
|
|||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package dev.relism.flash.http2.message;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.Http2ErrorCode;
|
||||||
|
import dev.relism.flash.http2.Http2Limits;
|
||||||
|
import dev.relism.flash.http2.Http2StreamException;
|
||||||
|
import dev.relism.flash.http2.message.DataBufferPool.DataBuffer;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import dev.relism.flash.models.BodyCompletion;
|
||||||
|
import java.util.concurrent.locks.Condition;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
|
/** Reusable request-body source fed by the connection demultiplexer. */
|
||||||
|
public final class Http2RequestBody extends InputStream implements BodyCompletion {
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface ConsumptionListener {
|
||||||
|
void consumed(int flowControlledBytes) throws IOException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final DataBufferPool pool;
|
||||||
|
private final ReentrantLock lock = new ReentrantLock();
|
||||||
|
private final Condition dataAvailable = lock.newCondition();
|
||||||
|
private final byte[] oneByte = new byte[1];
|
||||||
|
private byte[] inline;
|
||||||
|
private DataBuffer head;
|
||||||
|
private DataBuffer tail;
|
||||||
|
private ConsumptionListener listener;
|
||||||
|
private long declaredLength;
|
||||||
|
private long received;
|
||||||
|
private int inlinePosition;
|
||||||
|
private int inlineFlowControlledBytes;
|
||||||
|
private boolean inlineMode;
|
||||||
|
private boolean finished;
|
||||||
|
private boolean fullyRead;
|
||||||
|
|
||||||
|
public Http2RequestBody(DataBufferPool pool) {
|
||||||
|
this.pool = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void begin(long declaredLength, boolean inlineMode, ConsumptionListener listener) {
|
||||||
|
releaseQueued();
|
||||||
|
this.declaredLength = declaredLength;
|
||||||
|
this.inlineMode = inlineMode;
|
||||||
|
this.listener = listener;
|
||||||
|
received = 0;
|
||||||
|
inlinePosition = 0;
|
||||||
|
inlineFlowControlledBytes = 0;
|
||||||
|
finished = false;
|
||||||
|
fullyRead = false;
|
||||||
|
if (inlineMode && inline == null) inline = new byte[Http2Limits.INLINE_BODY_THRESHOLD];
|
||||||
|
}
|
||||||
|
|
||||||
|
public void offer(
|
||||||
|
int streamId, byte[] source, int offset, int length, int flowControlledBytes) {
|
||||||
|
long next = received + length;
|
||||||
|
if (next > Http2Limits.MAX_REQUEST_BODY_SIZE) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
streamId, Http2ErrorCode.PROTOCOL_ERROR, "request body exceeds configured limit");
|
||||||
|
}
|
||||||
|
if (declaredLength >= 0 && next > declaredLength) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
streamId, Http2ErrorCode.PROTOCOL_ERROR, "request body exceeds content-length");
|
||||||
|
}
|
||||||
|
if (length == 0) {
|
||||||
|
notifyConsumed(flowControlledBytes);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inlineMode) {
|
||||||
|
if (next > inline.length) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
streamId, Http2ErrorCode.PROTOCOL_ERROR, "inline request body exceeded its bound");
|
||||||
|
}
|
||||||
|
System.arraycopy(source, offset, inline, (int) received, length);
|
||||||
|
received = next;
|
||||||
|
inlineFlowControlledBytes += flowControlledBytes;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
int remaining = length;
|
||||||
|
int sourcePosition = offset;
|
||||||
|
while (remaining > 0) {
|
||||||
|
if (tail == null || tail.length == tail.bytes.length) {
|
||||||
|
DataBuffer buffer = pool.acquire();
|
||||||
|
if (buffer == null) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
streamId,
|
||||||
|
Http2ErrorCode.ENHANCE_YOUR_CALM,
|
||||||
|
"request body buffer pool exhausted");
|
||||||
|
}
|
||||||
|
if (tail == null) head = buffer;
|
||||||
|
else tail.next = buffer;
|
||||||
|
tail = buffer;
|
||||||
|
}
|
||||||
|
int copied = Math.min(remaining, tail.bytes.length - tail.length);
|
||||||
|
System.arraycopy(source, sourcePosition, tail.bytes, tail.length, copied);
|
||||||
|
tail.length += copied;
|
||||||
|
sourcePosition += copied;
|
||||||
|
remaining -= copied;
|
||||||
|
}
|
||||||
|
tail.flowControlledBytes += flowControlledBytes;
|
||||||
|
received = next;
|
||||||
|
dataAvailable.signal();
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void finish(int streamId) {
|
||||||
|
if (declaredLength >= 0 && received != declaredLength) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
streamId,
|
||||||
|
Http2ErrorCode.PROTOCOL_ERROR,
|
||||||
|
"content-length does not match received DATA bytes");
|
||||||
|
}
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
finished = true;
|
||||||
|
dataAvailable.signalAll();
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public int cancel() {
|
||||||
|
int discarded;
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
finished = true;
|
||||||
|
discarded = inlineFlowControlledBytes + releaseQueuedLocked();
|
||||||
|
inlineFlowControlledBytes = 0;
|
||||||
|
dataAvailable.signalAll();
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
return discarded;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long declaredLength() {
|
||||||
|
return declaredLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() throws IOException {
|
||||||
|
int count = read(oneByte, 0, 1);
|
||||||
|
return count < 0 ? -1 : oneByte[0] & 0xff;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read(byte[] target, int offset, int length) throws IOException {
|
||||||
|
if (length == 0) return 0;
|
||||||
|
if (inlineMode) return readInline(target, offset, length);
|
||||||
|
|
||||||
|
DataBuffer consumed = null;
|
||||||
|
int copied;
|
||||||
|
int flowControlled = 0;
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
while (head == null && !finished) {
|
||||||
|
try {
|
||||||
|
dataAvailable.await();
|
||||||
|
} catch (InterruptedException interrupted) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("interrupted while waiting for request DATA", interrupted);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (head == null) {
|
||||||
|
fullyRead = true;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
DataBuffer buffer = head;
|
||||||
|
copied = Math.min(length, buffer.length - buffer.position);
|
||||||
|
System.arraycopy(buffer.bytes, buffer.position, target, offset, copied);
|
||||||
|
buffer.position += copied;
|
||||||
|
if (buffer.position == buffer.length) {
|
||||||
|
head = buffer.next;
|
||||||
|
if (head == null) tail = null;
|
||||||
|
flowControlled = buffer.flowControlledBytes;
|
||||||
|
consumed = buffer;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
if (consumed != null) {
|
||||||
|
pool.release(consumed);
|
||||||
|
notifyConsumed(flowControlled);
|
||||||
|
}
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int readInline(byte[] target, int offset, int length) throws IOException {
|
||||||
|
if (!finished) {
|
||||||
|
throw new IOException("inline request body is not complete");
|
||||||
|
}
|
||||||
|
if (inlinePosition == received) {
|
||||||
|
fullyRead = true;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
int copied = (int) Math.min(length, received - inlinePosition);
|
||||||
|
System.arraycopy(inline, inlinePosition, target, offset, copied);
|
||||||
|
inlinePosition += copied;
|
||||||
|
if (inlinePosition == received && inlineFlowControlledBytes != 0) {
|
||||||
|
int flowControlled = inlineFlowControlledBytes;
|
||||||
|
inlineFlowControlledBytes = 0;
|
||||||
|
notifyConsumed(flowControlled);
|
||||||
|
}
|
||||||
|
return copied;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean fullyRead() {
|
||||||
|
return fullyRead || (finished && received == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void notifyConsumed(int bytes) {
|
||||||
|
if (bytes == 0 || listener == null) return;
|
||||||
|
try {
|
||||||
|
listener.consumed(bytes);
|
||||||
|
} catch (IOException failure) {
|
||||||
|
cancel();
|
||||||
|
throw new IllegalStateException("failed to update request flow-control window", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseQueued() {
|
||||||
|
lock.lock();
|
||||||
|
try {
|
||||||
|
releaseQueuedLocked();
|
||||||
|
} finally {
|
||||||
|
lock.unlock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int releaseQueuedLocked() {
|
||||||
|
int flowControlled = 0;
|
||||||
|
while (head != null) {
|
||||||
|
DataBuffer released = head;
|
||||||
|
head = released.next;
|
||||||
|
flowControlled += released.flowControlledBytes;
|
||||||
|
pool.release(released);
|
||||||
|
}
|
||||||
|
tail = null;
|
||||||
|
return flowControlled;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import dev.relism.flash.http.ContentType;
|
|||||||
import dev.relism.flash.http.DateHeader;
|
import dev.relism.flash.http.DateHeader;
|
||||||
import dev.relism.flash.http.HttpStatus;
|
import dev.relism.flash.http.HttpStatus;
|
||||||
import dev.relism.flash.http2.Http2ErrorCode;
|
import dev.relism.flash.http2.Http2ErrorCode;
|
||||||
|
import dev.relism.flash.http2.Http2Limits;
|
||||||
import dev.relism.flash.http2.Http2StreamException;
|
import dev.relism.flash.http2.Http2StreamException;
|
||||||
import dev.relism.flash.http2.frame.FrameFlags;
|
import dev.relism.flash.http2.frame.FrameFlags;
|
||||||
import dev.relism.flash.http2.frame.FrameType;
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
@@ -13,6 +14,8 @@ import dev.relism.flash.http2.frame.WriteIntent;
|
|||||||
import dev.relism.flash.http2.hpack.HpackEncoder;
|
import dev.relism.flash.http2.hpack.HpackEncoder;
|
||||||
import dev.relism.flash.models.Response;
|
import dev.relism.flash.models.Response;
|
||||||
import dev.relism.flash.models.ResponseSerializer;
|
import dev.relism.flash.models.ResponseSerializer;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable per-stream HTTP/2 response serializer. It prepares a complete small response outside the
|
* Reusable per-stream HTTP/2 response serializer. It prepares a complete small response outside the
|
||||||
@@ -30,13 +33,26 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
|
|||||||
private final ByteWriter headerBlock;
|
private final ByteWriter headerBlock;
|
||||||
private final ByteWriter output;
|
private final ByteWriter output;
|
||||||
private final FrameWriteBuffer frames;
|
private final FrameWriteBuffer frames;
|
||||||
private final byte[] decimalScratch = new byte[10];
|
private final byte[] decimalScratch = new byte[20];
|
||||||
|
private final byte[] relay = new byte[Http2Limits.MAX_FRAME_SIZE_LOCAL];
|
||||||
private WriteIntent next;
|
private WriteIntent next;
|
||||||
private boolean huffmanDynamicValues;
|
private boolean huffmanDynamicValues;
|
||||||
private int streamId;
|
private int streamId;
|
||||||
private long headerListSize;
|
private long headerListSize;
|
||||||
private long maxHeaderListSize;
|
private long maxHeaderListSize;
|
||||||
private Completion completion;
|
private Completion completion;
|
||||||
|
private byte[] fixedBody;
|
||||||
|
private InputStream streamBody;
|
||||||
|
private long bodyRemaining;
|
||||||
|
private int fixedPosition;
|
||||||
|
private boolean unknownLength;
|
||||||
|
private boolean pushBody;
|
||||||
|
private boolean finished;
|
||||||
|
private boolean headersInBatch;
|
||||||
|
private boolean endStreamInBatch;
|
||||||
|
private int dataBytesInBatch;
|
||||||
|
private boolean trailerHeadersInBatch;
|
||||||
|
private Response response;
|
||||||
|
|
||||||
public Http2ResponseWriter() {
|
public Http2ResponseWriter() {
|
||||||
this(1024, 2048);
|
this(1024, 2048);
|
||||||
@@ -91,6 +107,7 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
|
|||||||
this.streamId = streamId;
|
this.streamId = streamId;
|
||||||
this.huffmanDynamicValues = huffmanDynamicValues;
|
this.huffmanDynamicValues = huffmanDynamicValues;
|
||||||
this.maxHeaderListSize = maxHeaderListSize;
|
this.maxHeaderListSize = maxHeaderListSize;
|
||||||
|
this.response = response;
|
||||||
headerListSize = 0;
|
headerListSize = 0;
|
||||||
next = null;
|
next = null;
|
||||||
|
|
||||||
@@ -108,15 +125,208 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
|
|||||||
}
|
}
|
||||||
|
|
||||||
ResponseSerializer.forEachCustomField(response, this);
|
ResponseSerializer.forEachCustomField(response, this);
|
||||||
writeHeaderFrames(maxFrameSize, suppressBody || bodyLength == 0);
|
boolean hasTrailers = response.hasTrailers() && !suppressBody;
|
||||||
|
writeHeaderFrames(maxFrameSize, suppressBody || (bodyLength == 0 && !hasTrailers));
|
||||||
if (!suppressBody && bodyLength > 0) {
|
if (!suppressBody && bodyLength > 0) {
|
||||||
frames.beginFrame(FrameType.DATA, FrameFlags.END_STREAM, streamId);
|
frames.beginFrame(FrameType.DATA, hasTrailers ? 0 : FrameFlags.END_STREAM, streamId);
|
||||||
output.writeBytes(body);
|
output.writeBytes(body);
|
||||||
frames.endFrame();
|
frames.endFrame();
|
||||||
}
|
}
|
||||||
|
if (hasTrailers) appendTrailers(maxFrameSize);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Starts a response whose DATA may span multiple flow-control windows. */
|
||||||
|
public int startFlowControlled(
|
||||||
|
Response response,
|
||||||
|
int streamId,
|
||||||
|
boolean headRequest,
|
||||||
|
boolean sendDate,
|
||||||
|
boolean sendContentLength,
|
||||||
|
boolean huffmanDynamicValues,
|
||||||
|
boolean emitTableSizeUpdate,
|
||||||
|
int maxFrameSize,
|
||||||
|
long maxHeaderListSize,
|
||||||
|
int availableFlowWindow)
|
||||||
|
throws IOException {
|
||||||
|
if (streamId <= 0) throw new IllegalArgumentException("streamId must be positive");
|
||||||
|
if (maxFrameSize <= 0 || availableFlowWindow < 0) {
|
||||||
|
throw new IllegalArgumentException(
|
||||||
|
"frame size must be positive and flow window non-negative");
|
||||||
|
}
|
||||||
|
headerBlock.reset();
|
||||||
|
output.reset();
|
||||||
|
this.streamId = streamId;
|
||||||
|
this.huffmanDynamicValues = huffmanDynamicValues;
|
||||||
|
this.maxHeaderListSize = maxHeaderListSize;
|
||||||
|
headerListSize = 0;
|
||||||
|
next = null;
|
||||||
|
headersInBatch = true;
|
||||||
|
endStreamInBatch = false;
|
||||||
|
dataBytesInBatch = 0;
|
||||||
|
trailerHeadersInBatch = false;
|
||||||
|
this.response = response;
|
||||||
|
fixedPosition = 0;
|
||||||
|
fixedBody = response.isStreaming() ? null : response.getBody();
|
||||||
|
streamBody = response.isStreaming() ? response.getStream() : null;
|
||||||
|
unknownLength = response.isStreaming() && response.isChunked();
|
||||||
|
pushBody = response.isPushStreaming();
|
||||||
|
if (response.isStreaming() && !unknownLength && response.getStreamLength() < 0) {
|
||||||
|
throw new IllegalArgumentException("known response stream length must not be negative");
|
||||||
|
}
|
||||||
|
bodyRemaining =
|
||||||
|
response.isStreaming()
|
||||||
|
? (unknownLength ? -1 : response.getStreamLength())
|
||||||
|
: (fixedBody == null ? 0 : fixedBody.length);
|
||||||
|
long representationLength = bodyRemaining;
|
||||||
|
boolean representationUnknownLength = unknownLength;
|
||||||
|
|
||||||
|
int statusCode = response.getStatusCode();
|
||||||
|
boolean bodyForbidden =
|
||||||
|
statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200);
|
||||||
|
if (headRequest || bodyForbidden) {
|
||||||
|
fixedBody = null;
|
||||||
|
streamBody = null;
|
||||||
|
unknownLength = false;
|
||||||
|
bodyRemaining = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (emitTableSizeUpdate) HpackEncoder.writeDynamicTableSizeUpdateZero(headerBlock);
|
||||||
|
writeStatus(statusCode);
|
||||||
|
writeContentType(response.getContentType());
|
||||||
|
if (sendDate) {
|
||||||
|
addHeaderListSize(4, 29);
|
||||||
|
headerBlock.writeBytes(DateHeader.hpackBytes());
|
||||||
|
}
|
||||||
|
if (sendContentLength && !bodyForbidden && !representationUnknownLength) {
|
||||||
|
addHeaderListSize(CONTENT_LENGTH_NAME_LENGTH, decimalLength(representationLength));
|
||||||
|
writeDecimalLiteral(28, representationLength);
|
||||||
|
}
|
||||||
|
ResponseSerializer.forEachCustomField(response, this);
|
||||||
|
|
||||||
|
boolean hasBody = unknownLength || bodyRemaining > 0;
|
||||||
|
boolean hasTrailers = !headRequest && !bodyForbidden && response.hasTrailers();
|
||||||
|
writeHeaderFrames(maxFrameSize, !hasBody && !hasTrailers);
|
||||||
|
finished = !hasBody && !hasTrailers;
|
||||||
|
if (!hasBody && hasTrailers) {
|
||||||
|
appendTrailers(maxFrameSize);
|
||||||
|
finished = true;
|
||||||
|
endStreamInBatch = true;
|
||||||
|
}
|
||||||
|
if (hasBody && availableFlowWindow > 0 && !pushBody) {
|
||||||
|
appendData(maxFrameSize, availableFlowWindow);
|
||||||
|
}
|
||||||
|
return dataBytesInBatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serializes the next DATA batch after a WINDOW_UPDATE or previous write completion. */
|
||||||
|
public int resume(int maxFrameSize, int availableFlowWindow) throws IOException {
|
||||||
|
if (finished || availableFlowWindow <= 0) return 0;
|
||||||
|
output.reset();
|
||||||
|
next = null;
|
||||||
|
headersInBatch = false;
|
||||||
|
endStreamInBatch = false;
|
||||||
|
dataBytesInBatch = 0;
|
||||||
|
trailerHeadersInBatch = false;
|
||||||
|
appendData(maxFrameSize, availableFlowWindow);
|
||||||
|
return dataBytesInBatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendData(int maxFrameSize, int availableFlowWindow) throws IOException {
|
||||||
|
int target = Math.min(relay.length, Math.min(maxFrameSize, availableFlowWindow));
|
||||||
|
int count;
|
||||||
|
boolean end;
|
||||||
|
if (fixedBody != null) {
|
||||||
|
count = (int) Math.min(target, bodyRemaining);
|
||||||
|
boolean finalData = count == bodyRemaining;
|
||||||
|
boolean trailersFollow = finalData && response.hasTrailers();
|
||||||
|
frames.beginFrame(
|
||||||
|
FrameType.DATA, finalData && !trailersFollow ? FrameFlags.END_STREAM : 0, streamId);
|
||||||
|
output.writeBytes(fixedBody, fixedPosition, count);
|
||||||
|
frames.endFrame();
|
||||||
|
fixedPosition += count;
|
||||||
|
bodyRemaining -= count;
|
||||||
|
end = bodyRemaining == 0;
|
||||||
|
} else {
|
||||||
|
int limit = unknownLength ? target : (int) Math.min(target, bodyRemaining);
|
||||||
|
count = 0;
|
||||||
|
boolean eof = false;
|
||||||
|
if (pushBody) {
|
||||||
|
int read = streamBody.read(relay, 0, limit);
|
||||||
|
if (read < 0) eof = true;
|
||||||
|
else count = read;
|
||||||
|
} else {
|
||||||
|
while (count < limit) {
|
||||||
|
int read = streamBody.read(relay, count, limit - count);
|
||||||
|
if (read < 0) {
|
||||||
|
eof = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (read == 0) {
|
||||||
|
int one = streamBody.read();
|
||||||
|
if (one < 0) {
|
||||||
|
eof = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
relay[count++] = (byte) one;
|
||||||
|
} else {
|
||||||
|
count += read;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!unknownLength) {
|
||||||
|
bodyRemaining -= count;
|
||||||
|
if (eof && bodyRemaining != 0) {
|
||||||
|
throw new IOException("streaming response ended before its declared length");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
end = unknownLength ? eof : bodyRemaining == 0;
|
||||||
|
boolean trailersFollow = end && response.hasTrailers();
|
||||||
|
if (count != 0 || !trailersFollow) {
|
||||||
|
frames.beginFrame(
|
||||||
|
FrameType.DATA, end && !trailersFollow ? FrameFlags.END_STREAM : 0, streamId);
|
||||||
|
output.writeBytes(relay, 0, count);
|
||||||
|
frames.endFrame();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dataBytesInBatch = count;
|
||||||
|
if (end && response.hasTrailers()) {
|
||||||
|
appendTrailers(maxFrameSize);
|
||||||
|
endStreamInBatch = true;
|
||||||
|
} else {
|
||||||
|
endStreamInBatch = end;
|
||||||
|
}
|
||||||
|
finished = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendTrailers(int maxFrameSize) {
|
||||||
|
headerBlock.reset();
|
||||||
|
headerListSize = 0;
|
||||||
|
ResponseSerializer.forEachTrailerField(response, this);
|
||||||
|
writeHeaderFrames(maxFrameSize, true);
|
||||||
|
trailerHeadersInBatch = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean finished() {
|
||||||
|
return finished;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean headersInBatch() {
|
||||||
|
return headersInBatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean endStreamInBatch() {
|
||||||
|
return endStreamInBatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int dataBytesInBatch() {
|
||||||
|
return dataBytesInBatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean trailerHeadersInBatch() {
|
||||||
|
return trailerHeadersInBatch;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void accept(
|
public void accept(
|
||||||
byte[] name, int nameOff, int nameLen, byte[] value, int valueOff, int valueLen) {
|
byte[] name, int nameOff, int nameLen, byte[] value, int valueOff, int valueLen) {
|
||||||
@@ -151,10 +361,10 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void writeDecimalLiteral(int nameIndex, int value) {
|
private void writeDecimalLiteral(int nameIndex, long value) {
|
||||||
int length = decimalLength(value);
|
int length = decimalLength(value);
|
||||||
int offset = decimalScratch.length - length;
|
int offset = decimalScratch.length - length;
|
||||||
int current = value;
|
long current = value;
|
||||||
for (int i = decimalScratch.length - 1; i >= offset; i--) {
|
for (int i = decimalScratch.length - 1; i >= offset; i--) {
|
||||||
decimalScratch[i] = (byte) ('0' + current % 10);
|
decimalScratch[i] = (byte) ('0' + current % 10);
|
||||||
current /= 10;
|
current /= 10;
|
||||||
@@ -209,17 +419,13 @@ public final class Http2ResponseWriter implements WriteIntent, ResponseSerialize
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int decimalLength(int value) {
|
private static int decimalLength(long value) {
|
||||||
if (value < 10) return 1;
|
int length = 1;
|
||||||
if (value < 100) return 2;
|
while (value >= 10) {
|
||||||
if (value < 1000) return 3;
|
value /= 10;
|
||||||
if (value < 10000) return 4;
|
length++;
|
||||||
if (value < 100000) return 5;
|
}
|
||||||
if (value < 1000000) return 6;
|
return length;
|
||||||
if (value < 10000000) return 7;
|
|
||||||
if (value < 100000000) return 8;
|
|
||||||
if (value < 1000000000) return 9;
|
|
||||||
return 10;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public final class PseudoHeaders {
|
|||||||
private static final int SCHEME = 2;
|
private static final int SCHEME = 2;
|
||||||
private static final int PATH = 4;
|
private static final int PATH = 4;
|
||||||
private static final int AUTHORITY = 8;
|
private static final int AUTHORITY = 8;
|
||||||
|
private static final int PROTOCOL = 16;
|
||||||
|
|
||||||
private final PooledSlice name = new PooledSlice();
|
private final PooledSlice name = new PooledSlice();
|
||||||
private final PooledSlice value = new PooledSlice();
|
private final PooledSlice value = new PooledSlice();
|
||||||
@@ -19,6 +20,7 @@ public final class PseudoHeaders {
|
|||||||
private final PooledSlice scheme = new PooledSlice();
|
private final PooledSlice scheme = new PooledSlice();
|
||||||
private final PooledSlice path = new PooledSlice();
|
private final PooledSlice path = new PooledSlice();
|
||||||
private final PooledSlice authority = new PooledSlice();
|
private final PooledSlice authority = new PooledSlice();
|
||||||
|
private final PooledSlice protocol = new PooledSlice();
|
||||||
private final PooledSlice host = new PooledSlice();
|
private final PooledSlice host = new PooledSlice();
|
||||||
private int present;
|
private int present;
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ public final class PseudoHeaders {
|
|||||||
scheme.reset(null, 0, 0);
|
scheme.reset(null, 0, 0);
|
||||||
path.reset(null, 0, 0);
|
path.reset(null, 0, 0);
|
||||||
authority.reset(null, 0, 0);
|
authority.reset(null, 0, 0);
|
||||||
|
protocol.reset(null, 0, 0);
|
||||||
host.reset(null, 0, 0);
|
host.reset(null, 0, 0);
|
||||||
boolean regularSeen = false;
|
boolean regularSeen = false;
|
||||||
|
|
||||||
@@ -51,7 +54,15 @@ public final class PseudoHeaders {
|
|||||||
|
|
||||||
if ((present & METHOD) == 0) fail(streamId, "missing :method");
|
if ((present & METHOD) == 0) fail(streamId, "missing :method");
|
||||||
boolean connect = equals(method, "CONNECT");
|
boolean connect = equals(method, "CONNECT");
|
||||||
if (connect) {
|
boolean extendedConnect = (present & PROTOCOL) != 0;
|
||||||
|
if (extendedConnect) {
|
||||||
|
if (!connect) fail(streamId, ":protocol requires CONNECT");
|
||||||
|
int required = METHOD | SCHEME | PATH | AUTHORITY | PROTOCOL;
|
||||||
|
if ((present & required) != required) {
|
||||||
|
fail(streamId, "extended CONNECT missing pseudo-header");
|
||||||
|
}
|
||||||
|
if (path.length() == 0) fail(streamId, "empty :path");
|
||||||
|
} else if (connect) {
|
||||||
if ((present & AUTHORITY) == 0) fail(streamId, "CONNECT requires :authority");
|
if ((present & AUTHORITY) == 0) fail(streamId, "CONNECT requires :authority");
|
||||||
if ((present & (SCHEME | PATH)) != 0) fail(streamId, "CONNECT forbids :scheme and :path");
|
if ((present & (SCHEME | PATH)) != 0) fail(streamId, "CONNECT forbids :scheme and :path");
|
||||||
} else {
|
} else {
|
||||||
@@ -64,6 +75,20 @@ public final class PseudoHeaders {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validates a trailing field section, where pseudo-fields are never permitted. */
|
||||||
|
public static void validateTrailers(HpackHeaderBlock block, int streamId) {
|
||||||
|
PooledSlice name = new PooledSlice();
|
||||||
|
PooledSlice value = new PooledSlice();
|
||||||
|
for (int i = 0; i < block.count(); i++) {
|
||||||
|
block.get(i, name, value);
|
||||||
|
if (name.length() == 0 || name.byteAt(0) == ':') fail(streamId, "pseudo-header in trailers");
|
||||||
|
validateRegular(name, value, streamId);
|
||||||
|
if (equals(name, "content-length") || equals(name, "host") || equals(name, "te")) {
|
||||||
|
fail(streamId, "field is not permitted in trailers");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public PooledSlice method() {
|
public PooledSlice method() {
|
||||||
return method;
|
return method;
|
||||||
}
|
}
|
||||||
@@ -80,11 +105,16 @@ public final class PseudoHeaders {
|
|||||||
return authority;
|
return authority;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean websocket() {
|
||||||
|
return protocol.array() != null && equals(protocol, "websocket");
|
||||||
|
}
|
||||||
|
|
||||||
private void copySlice(int bit, PooledSlice source) {
|
private void copySlice(int bit, PooledSlice source) {
|
||||||
if (bit == METHOD) copy(source, method);
|
if (bit == METHOD) copy(source, method);
|
||||||
else if (bit == SCHEME) copy(source, scheme);
|
else if (bit == SCHEME) copy(source, scheme);
|
||||||
else if (bit == PATH) copy(source, path);
|
else if (bit == PATH) copy(source, path);
|
||||||
else copy(source, authority);
|
else if (bit == AUTHORITY) copy(source, authority);
|
||||||
|
else copy(source, protocol);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void copy(PooledSlice source, PooledSlice target) {
|
private static void copy(PooledSlice source, PooledSlice target) {
|
||||||
@@ -96,6 +126,7 @@ public final class PseudoHeaders {
|
|||||||
if (equals(name, ":scheme")) return SCHEME;
|
if (equals(name, ":scheme")) return SCHEME;
|
||||||
if (equals(name, ":path")) return PATH;
|
if (equals(name, ":path")) return PATH;
|
||||||
if (equals(name, ":authority")) return AUTHORITY;
|
if (equals(name, ":authority")) return AUTHORITY;
|
||||||
|
if (equals(name, ":protocol")) return PROTOCOL;
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package dev.relism.flash.http2.stream;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.Http2ErrorCode;
|
||||||
|
import dev.relism.flash.http2.Http2Exception;
|
||||||
|
import dev.relism.flash.http2.Http2Limits;
|
||||||
|
import dev.relism.flash.http2.Http2StreamException;
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/** Connection-level half of HTTP/2's two-level flow-control accounting. */
|
||||||
|
public final class Http2FlowController {
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface WindowUpdateSink {
|
||||||
|
void update(int streamId, int increment) throws IOException;
|
||||||
|
}
|
||||||
|
|
||||||
|
private final WindowUpdateSink updates;
|
||||||
|
private int receiveWindow = Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL;
|
||||||
|
private int consumedSinceUpdate;
|
||||||
|
private long sendWindow = 65_535;
|
||||||
|
|
||||||
|
public Http2FlowController(WindowUpdateSink updates) {
|
||||||
|
this.updates = updates;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void receiveConnectionBytes(int bytes) {
|
||||||
|
if (bytes < 0) throw new IllegalArgumentException("bytes must not be negative");
|
||||||
|
if (bytes > receiveWindow) throw Http2Exception.FLOW_CONTROL_ERROR;
|
||||||
|
receiveWindow -= bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void consumed(Http2Stream stream, int bytes) throws IOException {
|
||||||
|
int connectionIncrement = 0;
|
||||||
|
synchronized (this) {
|
||||||
|
consumedSinceUpdate += bytes;
|
||||||
|
if (consumedSinceUpdate >= Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL / 2) {
|
||||||
|
connectionIncrement = consumedSinceUpdate;
|
||||||
|
receiveWindow += connectionIncrement;
|
||||||
|
consumedSinceUpdate = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
int streamIncrement = stream.consumedReceiveBytes(bytes);
|
||||||
|
if (streamIncrement != 0) updates.update(stream.id(), streamIncrement);
|
||||||
|
if (connectionIncrement != 0) updates.update(0, connectionIncrement);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void discarded(int bytes) throws IOException {
|
||||||
|
int increment = 0;
|
||||||
|
synchronized (this) {
|
||||||
|
consumedSinceUpdate += bytes;
|
||||||
|
if (consumedSinceUpdate >= Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL / 2) {
|
||||||
|
increment = consumedSinceUpdate;
|
||||||
|
receiveWindow += increment;
|
||||||
|
consumedSinceUpdate = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (increment != 0) updates.update(0, increment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int reserveSend(Http2Stream stream, int requested) {
|
||||||
|
int streamWindow = stream.sendWindow();
|
||||||
|
if (requested <= 0 || sendWindow <= 0 || streamWindow <= 0) return 0;
|
||||||
|
int granted =
|
||||||
|
(int)
|
||||||
|
Math.min(requested, Math.min(sendWindow, Math.min(streamWindow, Integer.MAX_VALUE)));
|
||||||
|
sendWindow -= granted;
|
||||||
|
stream.adjustSendWindow(-granted);
|
||||||
|
return granted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void refundSend(Http2Stream stream, int bytes) {
|
||||||
|
if (bytes == 0) return;
|
||||||
|
sendWindow += bytes;
|
||||||
|
stream.adjustSendWindow(bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void increaseConnectionSendWindow(int increment) {
|
||||||
|
long next = sendWindow + increment;
|
||||||
|
if (next > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR;
|
||||||
|
sendWindow = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void increaseStreamSendWindow(Http2Stream stream, int increment) {
|
||||||
|
stream.adjustSendWindow(increment);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void initializeStreamSendWindow(Http2Stream stream, int initialWindow) {
|
||||||
|
stream.adjustSendWindow(initialWindow - 65_535);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void applyInitialWindowDelta(Http2StreamTable streams, int delta) {
|
||||||
|
streams.adjustAllSendWindows(delta);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void receiveStreamBytes(Http2Stream stream, int bytes) {
|
||||||
|
if (!stream.receiveBytes(bytes)) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
stream.id(), Http2ErrorCode.FLOW_CONTROL_ERROR, "stream receive window exceeded");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int connectionReceiveWindow() {
|
||||||
|
return receiveWindow;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized long connectionSendWindow() {
|
||||||
|
return sendWindow;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,12 @@ import dev.relism.flash.bytes.PooledSlice;
|
|||||||
import dev.relism.flash.http.ContentType;
|
import dev.relism.flash.http.ContentType;
|
||||||
import dev.relism.flash.http.HttpMethod;
|
import dev.relism.flash.http.HttpMethod;
|
||||||
import dev.relism.flash.http2.Http2ErrorCode;
|
import dev.relism.flash.http2.Http2ErrorCode;
|
||||||
|
import dev.relism.flash.http2.Http2Limits;
|
||||||
import dev.relism.flash.http2.Http2StreamException;
|
import dev.relism.flash.http2.Http2StreamException;
|
||||||
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
|
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
|
||||||
|
import dev.relism.flash.http2.message.DataBufferPool;
|
||||||
import dev.relism.flash.http2.message.Http2HeaderMap;
|
import dev.relism.flash.http2.message.Http2HeaderMap;
|
||||||
|
import dev.relism.flash.http2.message.Http2RequestBody;
|
||||||
import dev.relism.flash.http2.message.Http2ResponseWriter;
|
import dev.relism.flash.http2.message.Http2ResponseWriter;
|
||||||
import dev.relism.flash.http2.message.PseudoHeaders;
|
import dev.relism.flash.http2.message.PseudoHeaders;
|
||||||
import dev.relism.flash.models.Request;
|
import dev.relism.flash.models.Request;
|
||||||
@@ -14,36 +17,65 @@ import dev.relism.flash.models.RequestBody;
|
|||||||
import dev.relism.flash.models.RequestLine;
|
import dev.relism.flash.models.RequestLine;
|
||||||
import dev.relism.flash.models.Response;
|
import dev.relism.flash.models.Response;
|
||||||
import dev.relism.flash.routing.AbstractRouter;
|
import dev.relism.flash.routing.AbstractRouter;
|
||||||
|
import dev.relism.flash.routing.AbstractWsRouter;
|
||||||
|
import dev.relism.fpr.core.ByteView;
|
||||||
|
import java.io.IOException;
|
||||||
import java.net.InetSocketAddress;
|
import java.net.InetSocketAddress;
|
||||||
import javax.net.ssl.SSLSocket;
|
import javax.net.ssl.SSLSocket;
|
||||||
|
|
||||||
/** Per-stream request, response, decoded-header and write state. */
|
/** Per-stream request, response, decoded-header and write state. */
|
||||||
public final class Http2Stream implements Http2ResponseWriter.Completion {
|
public final class Http2Stream
|
||||||
|
implements Http2ResponseWriter.Completion, Http2RequestBody.ConsumptionListener, Runnable {
|
||||||
|
public interface ResponseSink {
|
||||||
|
void handleRequest(Http2Stream stream);
|
||||||
|
|
||||||
|
void responseBatchCompleted(Http2Stream stream);
|
||||||
|
|
||||||
|
void resumeResponse(Http2Stream stream);
|
||||||
|
}
|
||||||
|
|
||||||
private static final byte[] HTTP_2 = {'H', 'T', 'T', 'P', '/', '2'};
|
private static final byte[] HTTP_2 = {'H', 'T', 'T', 'P', '/', '2'};
|
||||||
|
|
||||||
private final HpackHeaderBlock headerBlock = new HpackHeaderBlock();
|
private final HpackHeaderBlock headerBlock = new HpackHeaderBlock();
|
||||||
|
private final HpackHeaderBlock trailerBlock = new HpackHeaderBlock();
|
||||||
private final PseudoHeaders pseudoHeaders = new PseudoHeaders();
|
private final PseudoHeaders pseudoHeaders = new PseudoHeaders();
|
||||||
private final Http2HeaderMap headers = new Http2HeaderMap();
|
private final Http2HeaderMap headers = new Http2HeaderMap();
|
||||||
|
private final Http2HeaderMap trailers = new Http2HeaderMap();
|
||||||
private final RequestLine requestLine = new RequestLine();
|
private final RequestLine requestLine = new RequestLine();
|
||||||
private final RequestBody requestBody = new RequestBody();
|
private final RequestBody requestBody = new RequestBody();
|
||||||
|
private final Http2RequestBody http2Body;
|
||||||
private final Request request = new Request();
|
private final Request request = new Request();
|
||||||
private final Response response = new Response(200, ContentType.TEXT_PLAIN);
|
private final Response response = new Response(200, ContentType.TEXT_PLAIN);
|
||||||
private final Http2ResponseWriter responseWriter = new Http2ResponseWriter();
|
private final Http2ResponseWriter responseWriter = new Http2ResponseWriter();
|
||||||
private final PooledSlice path = new PooledSlice();
|
private final PooledSlice path = new PooledSlice();
|
||||||
private final PooledSlice query = new PooledSlice();
|
private final PooledSlice query = new PooledSlice();
|
||||||
private final PooledSlice protocol = new PooledSlice();
|
private final PooledSlice protocol = new PooledSlice();
|
||||||
|
private final PooledSlice scanName = new PooledSlice();
|
||||||
|
private final PooledSlice scanValue = new PooledSlice();
|
||||||
|
|
||||||
private int id;
|
private int id;
|
||||||
private Http2StreamState state = Http2StreamState.IDLE;
|
private Http2StreamState state = Http2StreamState.IDLE;
|
||||||
private int sendWindow = 65_535;
|
private int sendWindow = 65_535;
|
||||||
|
private int receiveWindow = Http2Limits.INITIAL_WINDOW_SIZE_LOCAL;
|
||||||
|
private int consumedReceiveBytes;
|
||||||
|
private int emptyDataFrames;
|
||||||
private Http2StreamTable owner;
|
private Http2StreamTable owner;
|
||||||
private Object routeScratch;
|
private Object routeScratch;
|
||||||
|
private Object wsRouteScratch;
|
||||||
private volatile boolean dispatched;
|
private volatile boolean dispatched;
|
||||||
private volatile boolean cancelled;
|
private volatile boolean cancelled;
|
||||||
private boolean headersValidated;
|
private boolean headersValidated;
|
||||||
|
private Http2FlowController flowController;
|
||||||
|
private ResponseSink responseSink;
|
||||||
|
private volatile boolean responseInFlight;
|
||||||
|
private volatile boolean responseStarted;
|
||||||
|
private boolean releaseClaimed;
|
||||||
|
private volatile boolean resumeTask;
|
||||||
|
private volatile long lastActivityNanos;
|
||||||
Http2Stream poolNext;
|
Http2Stream poolNext;
|
||||||
|
|
||||||
Http2Stream() {
|
Http2Stream(DataBufferPool dataBuffers) {
|
||||||
|
http2Body = new Http2RequestBody(dataBuffers);
|
||||||
responseWriter.completion(this);
|
responseWriter.completion(this);
|
||||||
protocol.reset(HTTP_2, 0, HTTP_2.length);
|
protocol.reset(HTTP_2, 0, HTTP_2.length);
|
||||||
}
|
}
|
||||||
@@ -53,10 +85,21 @@ public final class Http2Stream implements Http2ResponseWriter.Completion {
|
|||||||
this.owner = owner;
|
this.owner = owner;
|
||||||
state = Http2StreamState.IDLE;
|
state = Http2StreamState.IDLE;
|
||||||
sendWindow = 65_535;
|
sendWindow = 65_535;
|
||||||
|
receiveWindow = Http2Limits.INITIAL_WINDOW_SIZE_LOCAL;
|
||||||
|
consumedReceiveBytes = 0;
|
||||||
|
emptyDataFrames = 0;
|
||||||
dispatched = false;
|
dispatched = false;
|
||||||
cancelled = false;
|
cancelled = false;
|
||||||
headersValidated = false;
|
headersValidated = false;
|
||||||
|
responseInFlight = false;
|
||||||
|
responseStarted = false;
|
||||||
|
releaseClaimed = false;
|
||||||
|
resumeTask = false;
|
||||||
|
responseSink = null;
|
||||||
headerBlock.reset();
|
headerBlock.reset();
|
||||||
|
trailerBlock.reset();
|
||||||
|
trailers.reset(trailerBlock);
|
||||||
|
touch();
|
||||||
}
|
}
|
||||||
|
|
||||||
void clear() {
|
void clear() {
|
||||||
@@ -94,9 +137,13 @@ public final class Http2Stream implements Http2ResponseWriter.Completion {
|
|||||||
throw new Http2StreamException(
|
throw new Http2StreamException(
|
||||||
id, Http2ErrorCode.PROTOCOL_ERROR, "unsupported request method");
|
id, Http2ErrorCode.PROTOCOL_ERROR, "unsupported request method");
|
||||||
}
|
}
|
||||||
|
if (pseudoHeaders.websocket()) method = HttpMethod.GET;
|
||||||
requestLine.reset(method, path, question < 0 ? null : query, protocol, headers);
|
requestLine.reset(method, path, question < 0 ? null : query, protocol, headers);
|
||||||
requestBody.reset(null, 0, null, 0, 0);
|
requestBody.reset(http2Body, http2Body.declaredLength(), null, 0, 0);
|
||||||
return Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
|
Request assembled =
|
||||||
|
Request.forParsed(request, requestLine, requestBody, remoteAddress, sslSocket);
|
||||||
|
assembled.setTrailers(trailers);
|
||||||
|
return assembled;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void validateHeaders() {
|
public void validateHeaders() {
|
||||||
@@ -105,6 +152,68 @@ public final class Http2Stream implements Http2ResponseWriter.Completion {
|
|||||||
headersValidated = true;
|
headersValidated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean prepareRequestBody(Http2FlowController flowController, boolean endStream) {
|
||||||
|
this.flowController = flowController;
|
||||||
|
long contentLength = parseContentLength();
|
||||||
|
if (contentLength < 0 && endStream) contentLength = 0;
|
||||||
|
boolean inline = contentLength >= 0 && contentLength <= Http2Limits.INLINE_BODY_THRESHOLD;
|
||||||
|
http2Body.begin(contentLength, inline, this);
|
||||||
|
if (endStream) http2Body.finish(id);
|
||||||
|
return endStream || !inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void receiveData(byte[] source, int offset, int length, int flowControlledBytes) {
|
||||||
|
http2Body.offer(id, source, offset, length, flowControlledBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void finishRequestBody() {
|
||||||
|
http2Body.finish(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void validateTrailers() {
|
||||||
|
PseudoHeaders.validateTrailers(trailerBlock, id);
|
||||||
|
trailers.reset(trailerBlock);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long parseContentLength() {
|
||||||
|
long parsed = -1;
|
||||||
|
for (int i = 0; i < headerBlock.count(); i++) {
|
||||||
|
headerBlock.get(i, scanName, scanValue);
|
||||||
|
if (!equals(scanName, "content-length")) continue;
|
||||||
|
long value = parseDecimal(scanValue);
|
||||||
|
if (parsed >= 0 && parsed != value) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
id, Http2ErrorCode.PROTOCOL_ERROR, "conflicting content-length fields");
|
||||||
|
}
|
||||||
|
parsed = value;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long parseDecimal(ByteView value) {
|
||||||
|
if (value.length() == 0) {
|
||||||
|
throw new Http2StreamException(id, Http2ErrorCode.PROTOCOL_ERROR, "empty content-length");
|
||||||
|
}
|
||||||
|
long parsed = 0;
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
int digit = (value.byteAt(i) & 0xff) - '0';
|
||||||
|
if (digit < 0 || digit > 9 || parsed > (Http2Limits.MAX_REQUEST_BODY_SIZE - digit) / 10L) {
|
||||||
|
throw new Http2StreamException(
|
||||||
|
id, Http2ErrorCode.PROTOCOL_ERROR, "invalid or oversized content-length");
|
||||||
|
}
|
||||||
|
parsed = parsed * 10 + digit;
|
||||||
|
}
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean equals(ByteView value, String expected) {
|
||||||
|
if (value.length() != expected.length()) return false;
|
||||||
|
for (int i = 0; i < value.length(); i++) {
|
||||||
|
if ((value.byteAt(i) & 0xff) != expected.charAt(i)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public Response resetResponse() {
|
public Response resetResponse() {
|
||||||
return response.reset(200, ContentType.TEXT_PLAIN);
|
return response.reset(200, ContentType.TEXT_PLAIN);
|
||||||
}
|
}
|
||||||
@@ -125,15 +234,64 @@ public final class Http2Stream implements Http2ResponseWriter.Completion {
|
|||||||
return headerBlock;
|
return headerBlock;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public HpackHeaderBlock trailerBlock() {
|
||||||
|
return trailerBlock;
|
||||||
|
}
|
||||||
|
|
||||||
public Http2ResponseWriter responseWriter() {
|
public Http2ResponseWriter responseWriter() {
|
||||||
return responseWriter;
|
return responseWriter;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void responseSink(ResponseSink responseSink) {
|
||||||
|
this.responseSink = responseSink;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markResponseStarted() {
|
||||||
|
responseStarted = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean responseStarted() {
|
||||||
|
return responseStarted;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markResumeTask() {
|
||||||
|
resumeTask = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean beginResponseBatch() {
|
||||||
|
if (responseInFlight) return false;
|
||||||
|
responseInFlight = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void endResponseBatch() {
|
||||||
|
responseInFlight = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized boolean responseInFlight() {
|
||||||
|
return responseInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
synchronized boolean claimRelease() {
|
||||||
|
if (releaseClaimed) return false;
|
||||||
|
releaseClaimed = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
public Object routeScratch(AbstractRouter router) {
|
public Object routeScratch(AbstractRouter router) {
|
||||||
if (routeScratch == null) routeScratch = router.newScratch();
|
if (routeScratch == null) routeScratch = router.newScratch();
|
||||||
return routeScratch;
|
return routeScratch;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Object wsRouteScratch(AbstractWsRouter router) {
|
||||||
|
if (wsRouteScratch == null) wsRouteScratch = router.newScratch();
|
||||||
|
return wsRouteScratch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean websocketConnect() {
|
||||||
|
return pseudoHeaders.websocket();
|
||||||
|
}
|
||||||
|
|
||||||
public void markDispatched() {
|
public void markDispatched() {
|
||||||
dispatched = true;
|
dispatched = true;
|
||||||
}
|
}
|
||||||
@@ -144,25 +302,79 @@ public final class Http2Stream implements Http2ResponseWriter.Completion {
|
|||||||
|
|
||||||
public void cancel() {
|
public void cancel() {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
int discarded = http2Body.cancel();
|
||||||
|
if (discarded != 0 && flowController != null) {
|
||||||
|
try {
|
||||||
|
flowController.discarded(discarded);
|
||||||
|
} catch (IOException failure) {
|
||||||
|
throw new IllegalStateException("failed to restore discarded flow-control bytes", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean cancelled() {
|
public boolean cancelled() {
|
||||||
return cancelled;
|
return cancelled;
|
||||||
}
|
}
|
||||||
|
|
||||||
public int sendWindow() {
|
public void touch() {
|
||||||
|
lastActivityNanos = System.nanoTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean idleExpired(long nowNanos, long timeoutNanos) {
|
||||||
|
return timeoutNanos > 0 && nowNanos - lastActivityNanos >= timeoutNanos;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int sendWindow() {
|
||||||
return sendWindow;
|
return sendWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void adjustSendWindow(int delta) {
|
public synchronized void adjustSendWindow(int delta) {
|
||||||
long adjusted = (long) sendWindow + delta;
|
long adjusted = (long) sendWindow + delta;
|
||||||
if (adjusted > Integer.MAX_VALUE) throw new IllegalStateException("stream window overflow");
|
if (adjusted > Integer.MAX_VALUE) throw new IllegalStateException("stream window overflow");
|
||||||
sendWindow = (int) adjusted;
|
sendWindow = (int) adjusted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public synchronized boolean receiveBytes(int bytes) {
|
||||||
|
if (bytes > receiveWindow) return false;
|
||||||
|
receiveWindow -= bytes;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int consumedReceiveBytes(int bytes) {
|
||||||
|
consumedReceiveBytes += bytes;
|
||||||
|
if (consumedReceiveBytes < Http2Limits.INITIAL_WINDOW_SIZE_LOCAL / 2) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
int increment = consumedReceiveBytes;
|
||||||
|
receiveWindow += increment;
|
||||||
|
consumedReceiveBytes = 0;
|
||||||
|
return increment;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int incrementEmptyDataFrames() {
|
||||||
|
return ++emptyDataFrames;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void resetEmptyDataFrames() {
|
||||||
|
emptyDataFrames = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void consumed(int flowControlledBytes) throws IOException {
|
||||||
|
flowController.consumed(this, flowControlledBytes);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void responseWriteCompleted() {
|
public void responseWriteCompleted() {
|
||||||
Http2StreamTable table = owner;
|
ResponseSink sink = responseSink;
|
||||||
if (table != null) table.release(this);
|
if (sink != null) sink.responseBatchCompleted(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
ResponseSink sink = responseSink;
|
||||||
|
if (sink == null) return;
|
||||||
|
if (resumeTask) sink.resumeResponse(this);
|
||||||
|
else sink.handleRequest(this);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ public enum Http2StreamState {
|
|||||||
|
|
||||||
private static final byte ERROR = -1;
|
private static final byte ERROR = -1;
|
||||||
private static final byte[][] TRANSITIONS = buildTransitions();
|
private static final byte[][] TRANSITIONS = buildTransitions();
|
||||||
|
// enum values() clones its backing array on every call; this is read-only and shared safely.
|
||||||
|
private static final Http2StreamState[] VALUES = values();
|
||||||
|
|
||||||
public Http2StreamState transition(int streamId, Event event) {
|
public Http2StreamState transition(int streamId, Event event) {
|
||||||
int next = TRANSITIONS[ordinal()][event.ordinal()];
|
int next = TRANSITIONS[ordinal()][event.ordinal()];
|
||||||
@@ -33,7 +35,7 @@ public enum Http2StreamState {
|
|||||||
throw new Http2StreamException(
|
throw new Http2StreamException(
|
||||||
streamId, errorFor(event), "invalid stream transition " + this + " + " + event);
|
streamId, errorFor(event), "invalid stream transition " + this + " + " + event);
|
||||||
}
|
}
|
||||||
return values()[next];
|
return VALUES[next];
|
||||||
}
|
}
|
||||||
|
|
||||||
private Http2ErrorCode errorFor(Event event) {
|
private Http2ErrorCode errorFor(Event event) {
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
package dev.relism.flash.http2.stream;
|
package dev.relism.flash.http2.stream;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.Http2Limits;
|
||||||
|
import dev.relism.flash.http2.message.DataBufferPool;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
/** Fixed-capacity primitive stream-id table using linear-probed open addressing. */
|
/** Fixed-capacity primitive stream-id table using linear-probed open addressing. */
|
||||||
@@ -14,11 +16,26 @@ public final class Http2StreamTable {
|
|||||||
private final Http2Stream[] values;
|
private final Http2Stream[] values;
|
||||||
private final int mask;
|
private final int mask;
|
||||||
private final int maxEntries;
|
private final int maxEntries;
|
||||||
|
private final int maxObjects;
|
||||||
|
private final int[] closedIds;
|
||||||
|
private final byte[] closedKinds;
|
||||||
private int size;
|
private int size;
|
||||||
private Http2Stream free;
|
private Http2Stream free;
|
||||||
private int created;
|
private int created;
|
||||||
|
private final DataBufferPool dataBuffers;
|
||||||
|
private int closedCursor;
|
||||||
|
|
||||||
|
public static final int CLOSED_UNKNOWN = 0;
|
||||||
|
public static final int CLOSED_NORMALLY = 1;
|
||||||
|
public static final int CLOSED_BY_RESET = 2;
|
||||||
|
|
||||||
public Http2StreamTable(int maxEntries) {
|
public Http2StreamTable(int maxEntries) {
|
||||||
|
this(
|
||||||
|
maxEntries,
|
||||||
|
new DataBufferPool(Http2Limits.MAX_FRAME_SIZE_LOCAL, Http2Limits.DATA_BUFFER_POOL_SIZE));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Http2StreamTable(int maxEntries, DataBufferPool dataBuffers) {
|
||||||
if (maxEntries < 1) throw new IllegalArgumentException("maxEntries must be positive");
|
if (maxEntries < 1) throw new IllegalArgumentException("maxEntries must be positive");
|
||||||
int capacity = 1;
|
int capacity = 1;
|
||||||
while (capacity < maxEntries * 2) capacity <<= 1;
|
while (capacity < maxEntries * 2) capacity <<= 1;
|
||||||
@@ -26,6 +43,10 @@ public final class Http2StreamTable {
|
|||||||
values = new Http2Stream[capacity];
|
values = new Http2Stream[capacity];
|
||||||
mask = capacity - 1;
|
mask = capacity - 1;
|
||||||
this.maxEntries = maxEntries;
|
this.maxEntries = maxEntries;
|
||||||
|
maxObjects = maxEntries * 2;
|
||||||
|
this.dataBuffers = dataBuffers;
|
||||||
|
closedIds = new int[maxEntries * 2];
|
||||||
|
closedKinds = new byte[closedIds.length];
|
||||||
}
|
}
|
||||||
|
|
||||||
public synchronized Http2Stream get(int streamId) {
|
public synchronized Http2Stream get(int streamId) {
|
||||||
@@ -50,8 +71,8 @@ public final class Http2StreamTable {
|
|||||||
free = stream.poolNext;
|
free = stream.poolNext;
|
||||||
stream.poolNext = null;
|
stream.poolNext = null;
|
||||||
} else {
|
} else {
|
||||||
if (created == maxEntries) return null;
|
if (created == maxObjects) return null;
|
||||||
stream = new Http2Stream();
|
stream = new Http2Stream(dataBuffers);
|
||||||
created++;
|
created++;
|
||||||
}
|
}
|
||||||
stream.reset(streamId, this);
|
stream.reset(streamId, this);
|
||||||
@@ -60,6 +81,7 @@ public final class Http2StreamTable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public synchronized void release(Http2Stream stream) {
|
public synchronized void release(Http2Stream stream) {
|
||||||
|
if (!stream.claimRelease()) return;
|
||||||
stream.clear();
|
stream.clear();
|
||||||
stream.poolNext = free;
|
stream.poolNext = free;
|
||||||
free = stream;
|
free = stream;
|
||||||
@@ -86,12 +108,55 @@ public final class Http2StreamTable {
|
|||||||
return removed;
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Removes a stream only when both its id and pooled-object identity still match. */
|
||||||
|
public synchronized boolean removeIfSame(Http2Stream stream, int streamId) {
|
||||||
|
if (streamId <= 0) return false;
|
||||||
|
int slot = find(streamId);
|
||||||
|
if (keys[slot] != streamId || values[slot] != stream) return false;
|
||||||
|
remove(streamId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Atomically removes and recycles the matching generation of a pooled stream. */
|
||||||
|
public synchronized boolean retire(Http2Stream stream, int streamId) {
|
||||||
|
if (!removeIfSame(stream, streamId)) return false;
|
||||||
|
rememberClosed(streamId, CLOSED_NORMALLY);
|
||||||
|
release(stream);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes a wire-closed stream from live concurrency while retaining its in-flight buffer. */
|
||||||
|
public synchronized boolean detach(Http2Stream stream, int streamId) {
|
||||||
|
if (!removeIfSame(stream, streamId)) return false;
|
||||||
|
rememberClosed(streamId, CLOSED_NORMALLY);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized void rememberReset(int streamId) {
|
||||||
|
rememberClosed(streamId, CLOSED_BY_RESET);
|
||||||
|
}
|
||||||
|
|
||||||
|
public synchronized int closedKind(int streamId) {
|
||||||
|
for (int i = 0; i < closedIds.length; i++) {
|
||||||
|
if (closedIds[i] == streamId) return closedKinds[i];
|
||||||
|
}
|
||||||
|
return CLOSED_UNKNOWN;
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized void forEach(StreamConsumer consumer) {
|
public synchronized void forEach(StreamConsumer consumer) {
|
||||||
for (int i = 0; i < keys.length; i++) {
|
for (int i = 0; i < keys.length; i++) {
|
||||||
if (keys[i] != 0) consumer.accept(values[i]);
|
if (keys[i] != 0) consumer.accept(values[i]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public synchronized int copyValues(Http2Stream[] target) {
|
||||||
|
int count = 0;
|
||||||
|
for (int i = 0; i < keys.length && count < target.length; i++) {
|
||||||
|
if (keys[i] != 0) target[count++] = values[i];
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
public synchronized void adjustAllSendWindows(int delta) {
|
public synchronized void adjustAllSendWindows(int delta) {
|
||||||
for (int i = 0; i < keys.length; i++) {
|
for (int i = 0; i < keys.length; i++) {
|
||||||
if (keys[i] == 0) continue;
|
if (keys[i] == 0) continue;
|
||||||
@@ -126,7 +191,16 @@ public final class Http2StreamTable {
|
|||||||
public synchronized void clear() {
|
public synchronized void clear() {
|
||||||
Arrays.fill(keys, 0);
|
Arrays.fill(keys, 0);
|
||||||
Arrays.fill(values, null);
|
Arrays.fill(values, null);
|
||||||
|
Arrays.fill(closedIds, 0);
|
||||||
|
Arrays.fill(closedKinds, (byte) 0);
|
||||||
size = 0;
|
size = 0;
|
||||||
|
closedCursor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void rememberClosed(int streamId, int kind) {
|
||||||
|
closedIds[closedCursor] = streamId;
|
||||||
|
closedKinds[closedCursor] = (byte) kind;
|
||||||
|
closedCursor = (closedCursor + 1) % closedIds.length;
|
||||||
}
|
}
|
||||||
|
|
||||||
private int find(int streamId) {
|
private int find(int streamId) {
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package dev.relism.flash.models;
|
||||||
|
|
||||||
|
/** Internal completion signal used to enforce request-trailer ordering. */
|
||||||
|
public interface BodyCompletion {
|
||||||
|
boolean fullyRead();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package dev.relism.flash.models;
|
||||||
|
|
||||||
|
import dev.relism.fpr.core.ByteView;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/** Immutable empty header collection shared by requests without trailers. */
|
||||||
|
public enum EmptyHeaderView implements HeaderView {
|
||||||
|
INSTANCE;
|
||||||
|
|
||||||
|
@Override public String first(String name) { return null; }
|
||||||
|
@Override public List<String> all(String name) { return List.of(); }
|
||||||
|
@Override public List<String> all() { return List.of(); }
|
||||||
|
@Override public ByteView view(String name) { return null; }
|
||||||
|
@Override public boolean valueEqualsIgnoreCase(String name, String value) { return false; }
|
||||||
|
@Override public boolean contains(String name) { return false; }
|
||||||
|
@Override public int count() { return 0; }
|
||||||
|
@Override public void forEach(HeaderConsumer consumer) {}
|
||||||
|
}
|
||||||
@@ -77,12 +77,33 @@ public class Http1HeaderMap implements HeaderView {
|
|||||||
private Slice valueSlice;
|
private Slice valueSlice;
|
||||||
|
|
||||||
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
|
public void reset(byte[] buffer, int sectionStart, int sectionEnd) {
|
||||||
this.buffer = buffer;
|
beginParsed(buffer, sectionStart, sectionEnd);
|
||||||
this.sectionStart = sectionStart;
|
|
||||||
this.sectionEnd = sectionEnd;
|
|
||||||
buildIndex();
|
buildIndex();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts an index populated by the request parser while it validates the same header lines.
|
||||||
|
* This avoids rescanning a validated section solely to recover offsets already known there.
|
||||||
|
*/
|
||||||
|
public void beginParsed(byte[] buffer, int sectionStart, int sectionEnd) {
|
||||||
|
this.buffer = buffer;
|
||||||
|
this.sectionStart = sectionStart;
|
||||||
|
this.sectionEnd = sectionEnd;
|
||||||
|
headerCount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds one already-validated header to the current zero-copy index. */
|
||||||
|
public void addParsed(int nameOffset, int nameLength, int valueOffset, int valueLength) {
|
||||||
|
ensureIndexCapacity(headerCount + 1);
|
||||||
|
nameOffsets[headerCount] = nameOffset;
|
||||||
|
nameLengths[headerCount] = nameLength;
|
||||||
|
valueOffsets[headerCount] = valueOffset;
|
||||||
|
valueLengths[headerCount] = valueLength;
|
||||||
|
nameHashes[headerCount] =
|
||||||
|
ByteScan.hashNameIgnoreCaseAscii(buffer, nameOffset, nameLength);
|
||||||
|
headerCount++;
|
||||||
|
}
|
||||||
|
|
||||||
private void buildIndex() {
|
private void buildIndex() {
|
||||||
headerCount = 0;
|
headerCount = 0;
|
||||||
if (buffer == null) return;
|
if (buffer == null) return;
|
||||||
@@ -92,13 +113,7 @@ public class Http1HeaderMap implements HeaderView {
|
|||||||
int colon = findColon(i, lineEnd);
|
int colon = findColon(i, lineEnd);
|
||||||
if (colon != -1) {
|
if (colon != -1) {
|
||||||
int vs = skipSpaces(colon + 1, lineEnd);
|
int vs = skipSpaces(colon + 1, lineEnd);
|
||||||
ensureIndexCapacity(headerCount + 1);
|
addParsed(i, colon - i, vs, lineEnd - vs);
|
||||||
nameOffsets[headerCount] = i;
|
|
||||||
nameLengths[headerCount] = colon - i;
|
|
||||||
valueOffsets[headerCount] = vs;
|
|
||||||
valueLengths[headerCount] = lineEnd - vs;
|
|
||||||
nameHashes[headerCount] = ByteScan.hashNameIgnoreCaseAscii(buffer, i, colon - i);
|
|
||||||
headerCount++;
|
|
||||||
}
|
}
|
||||||
i = lineEnd + 2;
|
i = lineEnd + 2;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package dev.relism.flash.models;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.ByteScan;
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
import dev.relism.flash.bytes.PooledSlice;
|
||||||
|
import dev.relism.fpr.core.ByteView;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
|
||||||
|
/** Reusable owned-byte header collection for sections parsed outside the request head buffer. */
|
||||||
|
public final class MutableHeaderMap implements HeaderView {
|
||||||
|
private final ByteWriter bytes = new ByteWriter(128);
|
||||||
|
private final PooledSlice view = new PooledSlice();
|
||||||
|
private final PooledSlice scanName = new PooledSlice();
|
||||||
|
private final PooledSlice scanValue = new PooledSlice();
|
||||||
|
private int[] fields = new int[16];
|
||||||
|
private int count;
|
||||||
|
|
||||||
|
public void reset() {
|
||||||
|
bytes.reset();
|
||||||
|
count = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void add(byte[] name, int nameOffset, int nameLength,
|
||||||
|
byte[] value, int valueOffset, int valueLength) {
|
||||||
|
ensure(count + 1);
|
||||||
|
int base = count * 4;
|
||||||
|
fields[base] = bytes.length();
|
||||||
|
fields[base + 1] = nameLength;
|
||||||
|
bytes.writeBytes(name, nameOffset, nameLength);
|
||||||
|
fields[base + 2] = bytes.length();
|
||||||
|
fields[base + 3] = valueLength;
|
||||||
|
bytes.writeBytes(value, valueOffset, valueLength);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeLines(OutputStream output) throws IOException {
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
int base = i * 4;
|
||||||
|
output.write(bytes.array(), fields[base], fields[base + 1]);
|
||||||
|
output.write(':');
|
||||||
|
output.write(' ');
|
||||||
|
output.write(bytes.array(), fields[base + 2], fields[base + 3]);
|
||||||
|
output.write('\r');
|
||||||
|
output.write('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void forEachStructured(ResponseSerializer.FieldConsumer consumer) {
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
int base = i * 4;
|
||||||
|
consumer.accept(bytes.array(), fields[base], fields[base + 1],
|
||||||
|
bytes.array(), fields[base + 2], fields[base + 3]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String first(String name) {
|
||||||
|
int index = indexOf(name, 0);
|
||||||
|
return index < 0 ? null : value(index);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> all(String name) {
|
||||||
|
List<String> result = null;
|
||||||
|
int from = 0;
|
||||||
|
int index;
|
||||||
|
while ((index = indexOf(name, from)) >= 0) {
|
||||||
|
if (result == null) result = new ArrayList<>();
|
||||||
|
result.add(value(index));
|
||||||
|
from = index + 1;
|
||||||
|
}
|
||||||
|
return result == null ? List.of() : result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> all() {
|
||||||
|
if (count == 0) return List.of();
|
||||||
|
List<String> result = new ArrayList<>(count);
|
||||||
|
for (int i = 0; i < count; i++) result.add(value(i));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ByteView view(String name) {
|
||||||
|
int index = indexOf(name, 0);
|
||||||
|
if (index < 0) return null;
|
||||||
|
int base = index * 4;
|
||||||
|
view.reset(bytes.array(), fields[base + 2], fields[base + 3]);
|
||||||
|
return view;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean valueEqualsIgnoreCase(String name, String value) {
|
||||||
|
int index = indexOf(name, 0);
|
||||||
|
if (index < 0) return false;
|
||||||
|
int base = index * 4;
|
||||||
|
return ByteScan.equalsIgnoreCaseAscii(
|
||||||
|
bytes.array(), fields[base + 2], fields[base + 2] + fields[base + 3], value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public boolean contains(String name) { return indexOf(name, 0) >= 0; }
|
||||||
|
@Override public int count() { return count; }
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void forEach(HeaderConsumer consumer) {
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
int base = i * 4;
|
||||||
|
scanName.reset(bytes.array(), fields[base], fields[base + 1]);
|
||||||
|
scanValue.reset(bytes.array(), fields[base + 2], fields[base + 3]);
|
||||||
|
consumer.accept(scanName, scanValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int indexOf(String name, int from) {
|
||||||
|
for (int i = from; i < count; i++) {
|
||||||
|
int base = i * 4;
|
||||||
|
if (ByteScan.equalsIgnoreCaseAscii(
|
||||||
|
bytes.array(), fields[base], fields[base] + fields[base + 1], name)) return i;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String value(int index) {
|
||||||
|
int base = index * 4;
|
||||||
|
return new String(bytes.array(), fields[base + 2], fields[base + 3], StandardCharsets.UTF_8);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ensure(int needed) {
|
||||||
|
int ints = needed * 4;
|
||||||
|
if (ints <= fields.length) return;
|
||||||
|
fields = Arrays.copyOf(fields, Math.max(ints, fields.length * 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package dev.relism.flash.models;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.PipedInputStream;
|
||||||
|
import java.io.PipedOutputStream;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
|
/** Bounded bridge from a push producer to the protocol writers' common pull path. */
|
||||||
|
final class ProducerInputStream extends InputStream {
|
||||||
|
private final PipedInputStream input;
|
||||||
|
private final ProducerOutput output;
|
||||||
|
private final Consumer<ResponseStream> producer;
|
||||||
|
private volatile Throwable failure;
|
||||||
|
private boolean started;
|
||||||
|
|
||||||
|
ProducerInputStream(Consumer<ResponseStream> producer, Response response) {
|
||||||
|
try {
|
||||||
|
input = new PipedInputStream(16 * 1024);
|
||||||
|
output = new ProducerOutput(new PipedOutputStream(input), response);
|
||||||
|
} catch (IOException impossible) {
|
||||||
|
throw new IllegalStateException(impossible);
|
||||||
|
}
|
||||||
|
this.producer = producer;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() throws IOException {
|
||||||
|
start();
|
||||||
|
int value = input.read();
|
||||||
|
checkFailure(value < 0);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read(byte[] bytes, int offset, int length) throws IOException {
|
||||||
|
start();
|
||||||
|
int count = input.read(bytes, offset, length);
|
||||||
|
checkFailure(count < 0);
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws IOException {
|
||||||
|
input.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private synchronized void start() {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
Thread.startVirtualThread(() -> {
|
||||||
|
try (output) {
|
||||||
|
producer.accept(output);
|
||||||
|
} catch (Throwable thrown) {
|
||||||
|
failure = thrown;
|
||||||
|
try {
|
||||||
|
output.close();
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private void checkFailure(boolean eof) throws IOException {
|
||||||
|
if (eof && failure != null) throw new IOException("response stream producer failed", failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class ProducerOutput implements ResponseStream {
|
||||||
|
private final PipedOutputStream output;
|
||||||
|
private final Response response;
|
||||||
|
private boolean closed;
|
||||||
|
|
||||||
|
ProducerOutput(PipedOutputStream output, Response response) {
|
||||||
|
this.output = output;
|
||||||
|
this.response = response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void write(byte[] data, int offset, int length) throws IOException {
|
||||||
|
if (closed) throw new IOException("response stream is closed");
|
||||||
|
output.write(data, offset, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void flush() throws IOException {
|
||||||
|
if (closed) throw new IOException("response stream is closed");
|
||||||
|
output.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void trailer(String name, String value) {
|
||||||
|
if (closed) throw new IllegalStateException("response stream is closed");
|
||||||
|
response.trailer(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public synchronized void close() throws IOException {
|
||||||
|
if (closed) return;
|
||||||
|
closed = true;
|
||||||
|
output.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ import java.util.List;
|
|||||||
public class Request {
|
public class Request {
|
||||||
|
|
||||||
private RequestBody body;
|
private RequestBody body;
|
||||||
|
private HeaderView trailers = EmptyHeaderView.INSTANCE;
|
||||||
|
|
||||||
/** Internal: the parsed request line (method, path, query, protocol, headers). */
|
/** Internal: the parsed request line (method, path, query, protocol, headers). */
|
||||||
private RequestLine requestLine;
|
private RequestLine requestLine;
|
||||||
@@ -95,6 +96,7 @@ public class Request {
|
|||||||
void reset(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
|
void reset(RequestLine requestLine, RequestBody body, InetSocketAddress remoteAddress, SSLSocket sslSocket) {
|
||||||
this.requestLine = requestLine;
|
this.requestLine = requestLine;
|
||||||
this.body = body;
|
this.body = body;
|
||||||
|
this.trailers = EmptyHeaderView.INSTANCE;
|
||||||
this.pathParams = null;
|
this.pathParams = null;
|
||||||
this.queryParams = null;
|
this.queryParams = null;
|
||||||
this.cachedPath = null;
|
this.cachedPath = null;
|
||||||
@@ -143,6 +145,11 @@ public class Request {
|
|||||||
return pooled;
|
return pooled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Internal protocol hook that supplies the request's trailer collection. */
|
||||||
|
public void setTrailers(HeaderView trailers) {
|
||||||
|
this.trailers = trailers == null ? EmptyHeaderView.INSTANCE : trailers;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Request line ──────────────────────────────────────────────────────────
|
// ── Request line ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** HTTP method ({@code GET}, {@code POST}, …). */
|
/** HTTP method ({@code GET}, {@code POST}, …). */
|
||||||
@@ -253,6 +260,19 @@ public class Request {
|
|||||||
*/
|
*/
|
||||||
public RequestBody body() { checkActive(); return body; }
|
public RequestBody body() { checkActive(); return body; }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns request trailers after the body has been consumed completely.
|
||||||
|
*
|
||||||
|
* @throws IllegalStateException when called before the body reaches EOF
|
||||||
|
*/
|
||||||
|
public HeaderView trailers() {
|
||||||
|
checkActive();
|
||||||
|
if (!body.fullyRead()) {
|
||||||
|
throw new IllegalStateException("request trailers are available only after the body is fully read");
|
||||||
|
}
|
||||||
|
return trailers;
|
||||||
|
}
|
||||||
|
|
||||||
/** Discards unread body bytes; called by the server after each request on keep-alive connections. */
|
/** Discards unread body bytes; called by the server after each request on keep-alive connections. */
|
||||||
public void drain() { body.drain(); }
|
public void drain() { body.drain(); }
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import java.io.*;
|
|||||||
* bodies larger than 2 GB.</li>
|
* bodies larger than 2 GB.</li>
|
||||||
* <li>{@link #stream()} — returns a bounded {@link InputStream} without upfront allocation.
|
* <li>{@link #stream()} — returns a bounded {@link InputStream} without upfront allocation.
|
||||||
* into the already-buffered header bytes stitched to the socket; for chunked bodies it is
|
* into the already-buffered header bytes stitched to the socket; for chunked bodies it is
|
||||||
* the raw {@link dev.relism.ChunkedInputStream} that de-chunks on the fly.</li>
|
* the raw {@link dev.relism.flash.ChunkedInputStream} that de-chunks on the fly.</li>
|
||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <p><b>Mutual exclusivity:</b> calling both {@code bytes()} and {@code stream()} on the same
|
* <p><b>Mutual exclusivity:</b> calling both {@code bytes()} and {@code stream()} on the same
|
||||||
@@ -87,6 +87,15 @@ public class RequestBody {
|
|||||||
*/
|
*/
|
||||||
public long contentLength() { return contentLength; }
|
public long contentLength() { return contentLength; }
|
||||||
|
|
||||||
|
/** Whether the complete body has been consumed by the application. */
|
||||||
|
public boolean fullyRead() {
|
||||||
|
if (resolved != null || contentLength == 0) return true;
|
||||||
|
if (socket instanceof BodyCompletion completion) return completion.fullyRead();
|
||||||
|
if (contentLength < 0) return false;
|
||||||
|
if (boundedStream != null) return boundedStream.complete();
|
||||||
|
return preBufLen >= contentLength;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Materialises and caches the full body. Suitable for JSON, small form data, and any payload
|
* Materialises and caches the full body. Suitable for JSON, small form data, and any payload
|
||||||
* that must be inspected in full. The result is cached — repeated calls return the same array.
|
* that must be inspected in full. The result is cached — repeated calls return the same array.
|
||||||
@@ -125,7 +134,7 @@ public class RequestBody {
|
|||||||
* <p>For fixed-length bodies: a reused {@link BoundedBufferedInputStream} (see the class
|
* <p>For fixed-length bodies: a reused {@link BoundedBufferedInputStream} (see the class
|
||||||
* view of the socket stream — zero allocation on a warm connection.
|
* view of the socket stream — zero allocation on a warm connection.
|
||||||
*
|
*
|
||||||
* <p>For chunked bodies: the raw {@link dev.relism.ChunkedInputStream} that de-chunks on
|
* <p>For chunked bodies: the raw {@link dev.relism.flash.ChunkedInputStream} that de-chunks on
|
||||||
* the fly; EOF signals the end of the logical body and leaves the socket positioned for
|
* the fly; EOF signals the end of the logical body and leaves the socket positioned for
|
||||||
* the next keep-alive request.
|
* the next keep-alive request.
|
||||||
*
|
*
|
||||||
@@ -178,6 +187,10 @@ public class RequestBody {
|
|||||||
this.socketRemaining = socketRemaining;
|
this.socketRemaining = socketRemaining;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean complete() {
|
||||||
|
return preBufRemaining == 0 && socketRemaining == 0;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public int read() throws IOException {
|
public int read() throws IOException {
|
||||||
if (preBufRemaining > 0) {
|
if (preBufRemaining > 0) {
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import java.nio.charset.StandardCharsets;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* HTTP response. All mutating methods return {@code this} for fluent chaining.
|
* HTTP response. All mutating methods return {@code this} for fluent chaining.
|
||||||
@@ -43,7 +45,9 @@ public class Response {
|
|||||||
private InputStream stream;
|
private InputStream stream;
|
||||||
private long streamLength; // meaningful only when isStreaming() && !chunked
|
private long streamLength; // meaningful only when isStreaming() && !chunked
|
||||||
private boolean chunked;
|
private boolean chunked;
|
||||||
|
private boolean pushStreaming;
|
||||||
private byte[] contentType;
|
private byte[] contentType;
|
||||||
|
private final MutableHeaderMap trailers = new MutableHeaderMap();
|
||||||
|
|
||||||
// of a List<byte[]> of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder +
|
// of a List<byte[]> of fully-rendered "Name: Value\r\n" lines (which cost a StringBuilder +
|
||||||
// char[] + String + getBytes() chain per header(String,String) call). Two backing stores,
|
// char[] + String + getBytes() chain per header(String,String) call). Two backing stores,
|
||||||
@@ -112,9 +116,11 @@ public class Response {
|
|||||||
this.stream = null;
|
this.stream = null;
|
||||||
this.streamLength = 0;
|
this.streamLength = 0;
|
||||||
this.chunked = false;
|
this.chunked = false;
|
||||||
|
this.pushStreaming = false;
|
||||||
this.contentType = contentType.getBytes();
|
this.contentType = contentType.getBytes();
|
||||||
this.headerQuadCount = 0;
|
this.headerQuadCount = 0;
|
||||||
this.headerCount = 0;
|
this.headerCount = 0;
|
||||||
|
this.trailers.reset();
|
||||||
if (rawHeaderLines != null) rawHeaderLines.clear();
|
if (rawHeaderLines != null) rawHeaderLines.clear();
|
||||||
this.active = true;
|
this.active = true;
|
||||||
return this;
|
return this;
|
||||||
@@ -175,6 +181,7 @@ public class Response {
|
|||||||
checkActive();
|
checkActive();
|
||||||
this.body = bytes;
|
this.body = bytes;
|
||||||
this.stream = null;
|
this.stream = null;
|
||||||
|
this.pushStreaming = false;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,6 +196,7 @@ public class Response {
|
|||||||
this.streamLength = length;
|
this.streamLength = length;
|
||||||
this.chunked = false;
|
this.chunked = false;
|
||||||
this.body = null;
|
this.body = null;
|
||||||
|
this.pushStreaming = false;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,9 +206,62 @@ public class Response {
|
|||||||
this.stream = is;
|
this.stream = is;
|
||||||
this.chunked = true;
|
this.chunked = true;
|
||||||
this.body = null;
|
this.body = null;
|
||||||
|
this.pushStreaming = false;
|
||||||
return this;
|
return this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Push-style streaming response with bounded blocking backpressure. */
|
||||||
|
public Response streaming(Consumer<ResponseStream> producer) {
|
||||||
|
checkActive();
|
||||||
|
this.stream = new ProducerInputStream(Objects.requireNonNull(producer), this);
|
||||||
|
this.streamLength = -1;
|
||||||
|
this.chunked = true;
|
||||||
|
this.pushStreaming = true;
|
||||||
|
this.body = null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a trailer rendered after the response body on both HTTP versions. */
|
||||||
|
public Response trailer(String name, String value) {
|
||||||
|
checkActive();
|
||||||
|
validateTrailer(name, value);
|
||||||
|
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
byte[] valueBytes = value.getBytes(StandardCharsets.UTF_8);
|
||||||
|
trailers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a pre-encoded structured trailer. */
|
||||||
|
public Response trailer(PreEncodedHeader trailer) {
|
||||||
|
checkActive();
|
||||||
|
byte[] name = trailer.nameBytes();
|
||||||
|
byte[] value = trailer.valueBytes();
|
||||||
|
validateTrailer(
|
||||||
|
new String(name, StandardCharsets.US_ASCII),
|
||||||
|
new String(value, StandardCharsets.US_ASCII));
|
||||||
|
trailers.add(name, 0, name.length, value, 0, value.length);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void validateTrailer(String name, String value) {
|
||||||
|
if (name.isEmpty() || name.charAt(0) == ':' || containsLineBreak(name)
|
||||||
|
|| containsLineBreak(value)) {
|
||||||
|
throw new IllegalArgumentException("invalid response trailer");
|
||||||
|
}
|
||||||
|
if (name.equalsIgnoreCase("content-length")
|
||||||
|
|| name.equalsIgnoreCase("transfer-encoding")
|
||||||
|
|| name.equalsIgnoreCase("connection")
|
||||||
|
|| name.equalsIgnoreCase("host")
|
||||||
|
|| name.equalsIgnoreCase("te")
|
||||||
|
|| name.equalsIgnoreCase("trailer")) {
|
||||||
|
throw new IllegalArgumentException("field is not permitted in response trailers: " + name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean containsLineBreak(String value) {
|
||||||
|
return value.indexOf('\r') >= 0 || value.indexOf('\n') >= 0;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 302 Found redirect. Clears the body, sets status and {@code Location} header. Encoded once at
|
* 302 Found redirect. Clears the body, sets status and {@code Location} header. Encoded once at
|
||||||
* call time; zero-alloc on the write path.
|
* call time; zero-alloc on the write path.
|
||||||
@@ -367,6 +428,12 @@ public class Response {
|
|||||||
return chunked;
|
return chunked;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Internal distinction between producer-driven and InputStream-driven response bodies. */
|
||||||
|
public boolean isPushStreaming() {
|
||||||
|
checkActive();
|
||||||
|
return pushStreaming;
|
||||||
|
}
|
||||||
|
|
||||||
public int getStatusCode() {
|
public int getStatusCode() {
|
||||||
checkActive();
|
checkActive();
|
||||||
return statusCode;
|
return statusCode;
|
||||||
@@ -397,6 +464,20 @@ public class Response {
|
|||||||
return streamLength;
|
return streamLength;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean hasTrailers() {
|
||||||
|
checkActive();
|
||||||
|
return trailers.count() != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void writeTrailers(OutputStream output) throws IOException {
|
||||||
|
checkActive();
|
||||||
|
trailers.writeLines(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
void forEachTrailerField(ResponseSerializer.FieldConsumer consumer) {
|
||||||
|
trailers.forEachStructured(consumer);
|
||||||
|
}
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Internal setters used by HttpServer for handler return values
|
// Internal setters used by HttpServer for handler return values
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -54,4 +54,9 @@ public final class ResponseSerializer {
|
|||||||
public static void forEachCustomField(Response response, FieldConsumer consumer) {
|
public static void forEachCustomField(Response response, FieldConsumer consumer) {
|
||||||
response.forEachStructuredField(consumer);
|
response.forEachStructuredField(consumer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Enumerates response trailers in declaration order. */
|
||||||
|
public static void forEachTrailerField(Response response, FieldConsumer consumer) {
|
||||||
|
response.forEachTrailerField(consumer);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package dev.relism.flash.models;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/** Blocking, flow-controlled response body used by push-style streaming producers. */
|
||||||
|
public interface ResponseStream extends AutoCloseable {
|
||||||
|
void write(byte[] data, int offset, int length) throws IOException;
|
||||||
|
void flush() throws IOException;
|
||||||
|
void trailer(String name, String value);
|
||||||
|
@Override void close() throws IOException;
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package dev.relism.flash.models;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
|
||||||
|
/** Adapts a flow-controlled response stream to APIs that write to an {@link OutputStream}. */
|
||||||
|
public final class ResponseStreamOutputStream extends OutputStream {
|
||||||
|
private final ResponseStream stream;
|
||||||
|
private final byte[] single = new byte[1];
|
||||||
|
|
||||||
|
public ResponseStreamOutputStream(ResponseStream stream) {
|
||||||
|
this.stream = stream;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(int value) throws IOException {
|
||||||
|
single[0] = (byte) value;
|
||||||
|
stream.write(single, 0, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(byte[] bytes, int offset, int length) throws IOException {
|
||||||
|
stream.write(bytes, offset, length);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void flush() throws IOException {
|
||||||
|
stream.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws IOException {
|
||||||
|
stream.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -84,7 +84,10 @@ public abstract class AbstractRouter {
|
|||||||
*/
|
*/
|
||||||
public AbstractRouter doRegister(HttpMethod method, String path,
|
public AbstractRouter doRegister(HttpMethod method, String path,
|
||||||
RequestHandler handler, Middleware[] middlewares) {
|
RequestHandler handler, Middleware[] middlewares) {
|
||||||
return addRoute(method, PathUtils.sanitize(path), compile(handler, middlewares));
|
String target = method == HttpMethod.CONNECT
|
||||||
|
? PathUtils.sanitizeAuthority(path)
|
||||||
|
: PathUtils.sanitize(path);
|
||||||
|
return addRoute(method, target, compile(handler, middlewares));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ public class PathUtils {
|
|||||||
return sanitized;
|
return sanitized;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Normalizes an authority-form CONNECT target without turning it into an origin-form path. */
|
||||||
|
public static String sanitizeAuthority(String authority) {
|
||||||
|
if (authority == null) return "";
|
||||||
|
String sanitized = authority.trim();
|
||||||
|
while (sanitized.startsWith("/")) sanitized = sanitized.substring(1);
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Joins two path segments and ensures the result is sanitized.
|
* Joins two path segments and ensures the result is sanitized.
|
||||||
* Prevents "double namespace" if the path already starts with the base.
|
* Prevents "double namespace" if the path already starts with the base.
|
||||||
|
|||||||
@@ -61,21 +61,38 @@ public final class ConnectionRunner {
|
|||||||
* Submits {@code socket} to the virtual-thread executor for full connection handling. {@code
|
* Submits {@code socket} to the virtual-thread executor for full connection handling. {@code
|
||||||
* stopped} is threaded through to the eventual {@link ConnectionContext} so the protocol
|
* stopped} is threaded through to the eventual {@link ConnectionContext} so the protocol
|
||||||
* implementation can observe an in-progress graceful shutdown.
|
* implementation can observe an in-progress graceful shutdown.
|
||||||
|
*
|
||||||
|
* <p>Rejects before any per-connection state exists — no TLS handshake, no protocol
|
||||||
|
* negotiation, no HPACK tables — once {@code activeSockets} reaches {@link
|
||||||
|
* FlashConfiguration#getMaxConnections()}. This is an approximate check (accept runs on up to
|
||||||
|
* {@link TransportTuning#ACCEPT_THREADS} concurrent threads, so a burst can briefly land a few
|
||||||
|
* connections past the limit), not an atomic guarantee; it only needs to bound worst-case
|
||||||
|
* growth, not enforce an exact count.
|
||||||
*/
|
*/
|
||||||
public void accept(Socket socket, BooleanSupplier stopped) {
|
public void accept(Socket socket, BooleanSupplier stopped) {
|
||||||
|
int max = configuration.getMaxConnections();
|
||||||
|
if (max > 0 && activeSockets.size() >= max) {
|
||||||
|
closeQuietly(socket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
activeSockets.add(socket);
|
||||||
try {
|
try {
|
||||||
executorService.submit(() -> handle(socket, stopped));
|
executorService.submit(() -> handle(socket, stopped));
|
||||||
} catch (RejectedExecutionException ignored) {
|
} catch (RejectedExecutionException ignored) {
|
||||||
try {
|
activeSockets.remove(socket);
|
||||||
socket.close();
|
closeQuietly(socket);
|
||||||
} catch (IOException e) {
|
}
|
||||||
log.debug("Error closing socket on shutdown", e);
|
}
|
||||||
}
|
|
||||||
|
private static void closeQuietly(Socket socket) {
|
||||||
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("Error closing socket on shutdown", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void handle(Socket socket, BooleanSupplier stopped) {
|
private void handle(Socket socket, BooleanSupplier stopped) {
|
||||||
activeSockets.add(socket);
|
|
||||||
ConnectionScratch scratch = scratchPool.acquire();
|
ConnectionScratch scratch = scratchPool.acquire();
|
||||||
try (socket;
|
try (socket;
|
||||||
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
|
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
|
||||||
@@ -134,17 +151,13 @@ public final class ConnectionRunner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Decides h1 vs h2 while keeping the TLS and cleartext rollout gates independent. */
|
||||||
* Decides h1 vs h2 for this connection, applying {@link FlashConfiguration#isHttp2Enabled()} to
|
|
||||||
* the plaintext (h2c) path — see {@link ProtocolNegotiator}'s Javadoc for why the flag is applied
|
|
||||||
* here rather than inside the negotiator itself.
|
|
||||||
*/
|
|
||||||
private NegotiatedProtocol negotiateProtocol(Socket socket, BufferedByteSource in)
|
private NegotiatedProtocol negotiateProtocol(Socket socket, BufferedByteSource in)
|
||||||
throws IOException {
|
throws IOException {
|
||||||
if (socket instanceof SSLSocket) {
|
if (socket instanceof SSLSocket) {
|
||||||
return ProtocolNegotiator.negotiate(socket, in); // ALPN — already resolved, no I/O
|
return ProtocolNegotiator.negotiate(socket, in); // ALPN — already resolved, no I/O
|
||||||
}
|
}
|
||||||
if (!configuration.isHttp2Enabled()) {
|
if (!configuration.isHttp2CleartextEnabled()) {
|
||||||
return NegotiatedProtocol.HTTP_1_1; // skip the h2c peek entirely when disabled
|
return NegotiatedProtocol.HTTP_1_1; // skip the h2c peek entirely when disabled
|
||||||
}
|
}
|
||||||
in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L);
|
in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L);
|
||||||
|
|||||||
@@ -1,63 +1,35 @@
|
|||||||
package dev.relism.flash.transport;
|
package dev.relism.flash.transport;
|
||||||
|
|
||||||
import javax.net.ssl.SSLSocket;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.Socket;
|
import java.net.Socket;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
import javax.net.ssl.SSLSocket;
|
||||||
|
|
||||||
/**
|
/** Detects HTTP/1.1 or HTTP/2 once, before the connection parser is selected. */
|
||||||
* Decides, once per connection and before any request is parsed, whether the connection speaks
|
|
||||||
* immediately after ALPN/preface detection").
|
|
||||||
*
|
|
||||||
* <p>Two independent signals, in order:
|
|
||||||
* <ol>
|
|
||||||
* <li><b>ALPN</b> (TLS connections). If the socket is an {@link SSLSocket} and the TLS
|
|
||||||
* handshake already resolved {@code "h2"} as the application protocol, this connection is
|
|
||||||
* {@link NegotiatedProtocol#HTTP_2}. Anything else negotiated — {@code "http/1.1"}, no
|
|
||||||
* protocol at all (a peer that doesn't speak ALPN), or an empty string — is
|
|
||||||
* {@link NegotiatedProtocol#HTTP_1_1}. This costs nothing beyond a field read: ALPN is
|
|
||||||
* resolved during the handshake, which must already have completed (see
|
|
||||||
* {@code TlsConfig}'s Javadoc on why {@code startHandshake()} must be called explicitly
|
|
||||||
* <li><b>h2c prior knowledge</b> (plaintext connections, RFC 9113 §3.4). The first 24 bytes of
|
|
||||||
* the connection are compared, without being consumed, against the client connection
|
|
||||||
* preface {@code "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n"}. A match is
|
|
||||||
* {@link NegotiatedProtocol#HTTP_2}; anything else — including a partial match followed by
|
|
||||||
* EOF, or a preface look-alike that diverges partway through — is
|
|
||||||
* {@link NegotiatedProtocol#HTTP_1_1}. This is why {@link BufferedByteSource#peek} exists:
|
|
||||||
* the bytes must remain available for {@code RequestParser} if they turn out not to be an
|
|
||||||
* h2 preface after all.</li>
|
|
||||||
* </ol>
|
|
||||||
*
|
|
||||||
* <p>This method reports the protocol accurately and unconditionally — it does not consult
|
|
||||||
* {@code FlashConfiguration.http2Enabled}. Gating whether an {@link NegotiatedProtocol#HTTP_2}
|
|
||||||
* {@code Http2Connection} yet) and whether the h2c peek is even attempted for plaintext
|
|
||||||
* connections are both the caller's responsibility, so that this class stays a pure,
|
|
||||||
* directly-testable detector (see {@code ProtocolNegotiatorTest}).
|
|
||||||
*/
|
|
||||||
public final class ProtocolNegotiator {
|
public final class ProtocolNegotiator {
|
||||||
|
private static final byte[] H2C_PREFACE =
|
||||||
|
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
|
||||||
|
|
||||||
/**
|
private ProtocolNegotiator() {}
|
||||||
* reconstructed per connection.
|
|
||||||
*/
|
|
||||||
private static final byte[] H2C_PREFACE =
|
|
||||||
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
|
|
||||||
|
|
||||||
private ProtocolNegotiator() {
|
/**
|
||||||
|
* Uses the completed TLS ALPN result for secure sockets and a non-consuming prior-knowledge
|
||||||
|
* preface probe for plaintext sockets. Configuration gates remain the caller's responsibility,
|
||||||
|
* which keeps detection deterministic and independently testable.
|
||||||
|
*/
|
||||||
|
public static NegotiatedProtocol negotiate(Socket socket, BufferedByteSource source)
|
||||||
|
throws IOException {
|
||||||
|
if (socket instanceof SSLSocket ssl) {
|
||||||
|
return "h2".equals(ssl.getApplicationProtocol())
|
||||||
|
? NegotiatedProtocol.HTTP_2
|
||||||
|
: NegotiatedProtocol.HTTP_1_1;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static NegotiatedProtocol negotiate(Socket socket, BufferedByteSource source) throws IOException {
|
byte[] probe = new byte[H2C_PREFACE.length];
|
||||||
if (socket instanceof SSLSocket ssl) {
|
int read = source.peek(probe, 0, probe.length);
|
||||||
String applicationProtocol = ssl.getApplicationProtocol();
|
return read == H2C_PREFACE.length && Arrays.equals(probe, H2C_PREFACE)
|
||||||
return "h2".equals(applicationProtocol) ? NegotiatedProtocol.HTTP_2 : NegotiatedProtocol.HTTP_1_1;
|
? NegotiatedProtocol.HTTP_2
|
||||||
}
|
: NegotiatedProtocol.HTTP_1_1;
|
||||||
|
}
|
||||||
byte[] probe = new byte[H2C_PREFACE.length];
|
|
||||||
int n = source.peek(probe, 0, probe.length);
|
|
||||||
if (n == H2C_PREFACE.length && Arrays.equals(probe, H2C_PREFACE)) {
|
|
||||||
return NegotiatedProtocol.HTTP_2;
|
|
||||||
}
|
|
||||||
return NegotiatedProtocol.HTTP_1_1;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package dev.relism.flash.transport;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transport-level bound on concurrent connections, enforced by {@link ConnectionRunner} before
|
||||||
|
* any per-connection state (TLS handshake, protocol negotiation, HPACK tables, buffers) is set
|
||||||
|
* up. Unlike {@link dev.relism.flash.http2.Http2Limits} (bounds on what one already-admitted
|
||||||
|
* connection may do), this bounds how many connections are admitted at all — the guard a stress
|
||||||
|
* test found completely absent: {@code AcceptLoop} accepted unconditionally, so a connection
|
||||||
|
* flood ran the JVM out of heap rather than being turned away.
|
||||||
|
*/
|
||||||
|
public final class TransportLimits {
|
||||||
|
|
||||||
|
private TransportLimits() {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deliberately conservative estimate of one connection's worst-case retained heap (HPACK
|
||||||
|
* tables, stream table, in-flight response batches, up to {@code
|
||||||
|
* Http2Limits#MAX_CONCURRENT_STREAMS} concurrent streams), used only to size {@link
|
||||||
|
* #defaultMaxConnections()}'s auto-scaled budget — not an enforced per-connection cap.
|
||||||
|
*
|
||||||
|
* <p>Not a precise per-byte accounting. A stress test on this codebase (h2load, 20 concurrent
|
||||||
|
* HTTP/2 streams per connection) observed {@code OutOfMemoryError} somewhere between 200 and
|
||||||
|
* 400 concurrent connections on a 1.5 GiB heap. This constant is chosen so {@link
|
||||||
|
* #defaultMaxConnections()} lands comfortably below that observed floor (~150 connections at
|
||||||
|
* 1.5 GiB) rather than hugging it. A heap-dump-derived precise figure is a natural follow-up;
|
||||||
|
* until then this trades some throughput headroom for a real safety margin.
|
||||||
|
*/
|
||||||
|
static final long ASSUMED_WORST_CASE_BYTES_PER_CONNECTION = 5L * 1024 * 1024;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fraction of the JVM's max heap set aside for connection-admission accounting; the rest is
|
||||||
|
* left for GC headroom, response buffers, and everything else the server needs.
|
||||||
|
*/
|
||||||
|
static final double HEAP_FRACTION_FOR_CONNECTIONS = 0.5;
|
||||||
|
|
||||||
|
/** Floor so a tiny heap (dev/test containers) still gets a usable, non-degenerate limit. */
|
||||||
|
static final int MIN_MAX_CONNECTIONS = 64;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-scaled default for {@code FlashConfiguration#getMaxConnections()}. Computed from {@link
|
||||||
|
* Runtime#maxMemory()} so the same default protects a 256 MiB container and an 8 GiB one
|
||||||
|
* without operator input; set {@code maxConnections} explicitly to override it, or to {@code 0}
|
||||||
|
* to disable the check (unlimited — the behavior every version before this had unconditionally).
|
||||||
|
*/
|
||||||
|
public static int defaultMaxConnections() {
|
||||||
|
long heapBudget = (long) (Runtime.getRuntime().maxMemory() * HEAP_FRACTION_FOR_CONNECTIONS);
|
||||||
|
long computed = heapBudget / ASSUMED_WORST_CASE_BYTES_PER_CONNECTION;
|
||||||
|
return (int) Math.max(MIN_MAX_CONNECTIONS, Math.min(Integer.MAX_VALUE, computed));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,39 +3,44 @@ package dev.relism.flash.websocket;
|
|||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Drives one {@link WebSocketSession}'s read loop until the session closes, dispatching frames
|
* Drives one {@link WebSocketSession}'s read loop until the session closes. The handshake and
|
||||||
* responsibility is this loop; the handshake and upgrade detection live in
|
* upgrade detection live in {@link WebSocketUpgrade}.
|
||||||
* {@link WebSocketUpgrade}.
|
|
||||||
*/
|
*/
|
||||||
public final class WebSocketLoop {
|
public final class WebSocketLoop {
|
||||||
|
|
||||||
private WebSocketLoop() {
|
private WebSocketLoop() {}
|
||||||
}
|
|
||||||
|
|
||||||
public static void run(WebSocketSession session, WebSocketHandler handler) {
|
public static void run(WebSocketSession session, WebSocketHandler handler) {
|
||||||
handler.onOpen(session);
|
WebSocketFrame frame = new WebSocketFrame();
|
||||||
WebSocketFrame frame = new WebSocketFrame();
|
try {
|
||||||
try {
|
handler.onOpen(session);
|
||||||
while (session.isOpen()) {
|
while (session.isOpen()) {
|
||||||
if (!session.readFrame(frame)) break;
|
if (!session.readFrame(frame)) break;
|
||||||
switch (frame.opcode()) {
|
switch (frame.opcode()) {
|
||||||
case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY
|
case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY ->
|
||||||
-> handler.onMessage(session, frame);
|
handler.onMessage(session, frame);
|
||||||
case WebSocketFrame.OP_CLOSE
|
case WebSocketFrame.OP_CLOSE -> session.closeFromPeer(frame);
|
||||||
-> session.closeFromPeer(frame);
|
case WebSocketFrame.OP_PING -> session.sendPong(frame);
|
||||||
case WebSocketFrame.OP_PING
|
case WebSocketFrame.OP_PONG -> {
|
||||||
-> session.sendPong(frame);
|
// Heartbeat acknowledgement; no action is required.
|
||||||
case WebSocketFrame.OP_PONG -> { /* heartbeat ack, no-op */ }
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (WebSocketProtocolException e) {
|
|
||||||
try { session.close(e.closeCode()); } catch (IOException ignored) { }
|
|
||||||
handler.onError(session, e);
|
|
||||||
} catch (IOException e) {
|
|
||||||
handler.onError(session, e);
|
|
||||||
} finally {
|
|
||||||
handler.onClose(session, session.closeCode());
|
|
||||||
session.forceClose();
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
} catch (WebSocketProtocolException failure) {
|
||||||
|
try {
|
||||||
|
session.close(failure.closeCode());
|
||||||
|
} catch (IOException ignored) {
|
||||||
|
// The peer may already have closed the transport.
|
||||||
|
}
|
||||||
|
handler.onError(session, failure);
|
||||||
|
} catch (IOException | RuntimeException failure) {
|
||||||
|
handler.onError(session, failure);
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
handler.onClose(session, session.closeCode());
|
||||||
|
} finally {
|
||||||
|
session.forceClose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ class ChunkedInputStreamTest {
|
|||||||
@Test
|
@Test
|
||||||
void trailers_consumed() throws IOException {
|
void trailers_consumed() throws IOException {
|
||||||
// trailing headers after 0-chunk must be consumed
|
// trailing headers after 0-chunk must be consumed
|
||||||
assertEquals("hi", readAll(wrap("2\r\nhi\r\n0\r\nTrailer: value\r\n\r\n")));
|
assertEquals("hi", readAll(wrap("2\r\nhi\r\n0\r\nX-Trailer: value\r\n\r\n")));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- byte-by-byte read ---
|
// --- byte-by-byte read ---
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package dev.relism.flash;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import dev.relism.flash.http.ContentType;
|
||||||
|
import dev.relism.flash.http.HttpMethod;
|
||||||
|
import dev.relism.flash.http1.Http1ResponseWriter;
|
||||||
|
import dev.relism.flash.models.Request;
|
||||||
|
import dev.relism.flash.models.Response;
|
||||||
|
import dev.relism.flash.transport.BufferedByteSource;
|
||||||
|
import dev.relism.flash.transport.ConnectionScratch;
|
||||||
|
import dev.relism.flash.transport.ScratchPool;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http1TrailersTest {
|
||||||
|
@Test
|
||||||
|
void requestTrailersBecomeVisibleOnlyAfterBodyEof() throws Exception {
|
||||||
|
byte[] wire = ("POST / HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n"
|
||||||
|
+ "3\r\nabc\r\n0\r\nGrpc-Status: 0\r\nX-Trace: done\r\n\r\n")
|
||||||
|
.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
Request request = new RequestParser().parse(
|
||||||
|
new BufferedByteSource(new ByteArrayInputStream(wire), null));
|
||||||
|
|
||||||
|
assertThrows(IllegalStateException.class, request::trailers);
|
||||||
|
assertEquals("abc", new String(request.body().bytes(), StandardCharsets.US_ASCII));
|
||||||
|
assertEquals("0", request.trailers().first("grpc-status"));
|
||||||
|
assertEquals("done", request.trailers().first("x-trace"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void responseTrailersUseChunkedRendering() throws Exception {
|
||||||
|
Response response = new Response(200, "hello", ContentType.TEXT_PLAIN)
|
||||||
|
.trailer("grpc-status", "0");
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
|
||||||
|
Http1ResponseWriter.writeResponse(
|
||||||
|
output, response, HttpMethod.GET, true, false, new ScratchPool().acquire());
|
||||||
|
|
||||||
|
String wire = output.toString(StandardCharsets.US_ASCII);
|
||||||
|
assertEquals(true, wire.contains("Transfer-Encoding: chunked\r\n"));
|
||||||
|
assertEquals(true, wire.endsWith("5\r\nhello\r\n0\r\ngrpc-status: 0\r\n\r\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void pushStreamingAndTrailersShareTheSameHttp1Writer() throws Exception {
|
||||||
|
Response response = new Response(200, ContentType.BINARY).streaming(stream -> {
|
||||||
|
try {
|
||||||
|
stream.write("one".getBytes(StandardCharsets.US_ASCII), 0, 3);
|
||||||
|
stream.write("two".getBytes(StandardCharsets.US_ASCII), 0, 3);
|
||||||
|
stream.trailer("grpc-status", "0");
|
||||||
|
} catch (Exception failure) {
|
||||||
|
throw new RuntimeException(failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
Http1ResponseWriter.writeResponse(
|
||||||
|
output, response, HttpMethod.GET, true, false, new ScratchPool().acquire());
|
||||||
|
|
||||||
|
String wire = output.toString(StandardCharsets.US_ASCII);
|
||||||
|
assertEquals(true, wire.contains("one"));
|
||||||
|
assertEquals(true, wire.contains("two"));
|
||||||
|
assertEquals(true, wire.endsWith("0\r\ngrpc-status: 0\r\n\r\n"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
package dev.relism.flash;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTimeout;
|
||||||
|
import static org.junit.jupiter.api.Assertions.fail;
|
||||||
|
|
||||||
|
import dev.relism.flash.exceptions.MalformedRequestException;
|
||||||
|
import dev.relism.flash.transport.BufferedByteSource;
|
||||||
|
import dev.relism.flash.testing.FuzzMemory;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.time.Duration;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class RequestParserFuzzTest {
|
||||||
|
private static final int CASES = 25_000;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void arbitraryWireBytesHaveBoundedTypedOutcomes() {
|
||||||
|
assertTimeout(
|
||||||
|
Duration.ofSeconds(20),
|
||||||
|
() -> {
|
||||||
|
byte[] input = new byte[256];
|
||||||
|
long state = 0x9112_4854_5450_314CL;
|
||||||
|
long baseline = FuzzMemory.snapshot();
|
||||||
|
for (int iteration = 0; iteration < CASES; iteration++) {
|
||||||
|
state = next(state);
|
||||||
|
int length = (int) (state & 255);
|
||||||
|
for (int i = 0; i < length; i++) {
|
||||||
|
state = next(state);
|
||||||
|
input[i] = (byte) state;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
new RequestParser(512)
|
||||||
|
.parse(
|
||||||
|
new BufferedByteSource(
|
||||||
|
new ByteArrayInputStream(input, 0, length), null, 256));
|
||||||
|
} catch (MalformedRequestException expected) {
|
||||||
|
// Hostile HTTP/1 syntax is rejected with an explicit response status.
|
||||||
|
} catch (IOException unexpected) {
|
||||||
|
fail("in-memory input produced I/O failure at case " + iteration, unexpected);
|
||||||
|
} catch (Throwable unexpected) {
|
||||||
|
fail("unexpected failure at case " + iteration + ", length " + length, unexpected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FuzzMemory.assertGrowthBelow(baseline, 8L * 1024 * 1024);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long next(long value) {
|
||||||
|
value ^= value << 13;
|
||||||
|
value ^= value >>> 7;
|
||||||
|
return value ^ (value << 17);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package dev.relism.flash.http;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.PooledSlice;
|
||||||
|
import dev.relism.flash.http.HopByHopHeaders.Protocol;
|
||||||
|
import dev.relism.flash.models.MutableHeaderMap;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class HopByHopHeaderTest {
|
||||||
|
@Test
|
||||||
|
void sharedPolicyCoversAllFourProtocolConversions() {
|
||||||
|
for (Protocol sourceProtocol : Protocol.values()) {
|
||||||
|
for (Protocol targetProtocol : Protocol.values()) {
|
||||||
|
MutableHeaderMap source = new MutableHeaderMap();
|
||||||
|
add(source, "connection", "x-private, keep-alive");
|
||||||
|
add(source, "x-private", "secret");
|
||||||
|
add(source, "upgrade", "websocket");
|
||||||
|
add(source, "te", "trailers");
|
||||||
|
add(source, "x-end-to-end", "yes");
|
||||||
|
|
||||||
|
assertFalse(forward(source, "connection", "x-private", sourceProtocol, targetProtocol));
|
||||||
|
assertFalse(forward(source, "x-private", "secret", sourceProtocol, targetProtocol));
|
||||||
|
assertFalse(forward(source, "upgrade", "websocket", sourceProtocol, targetProtocol));
|
||||||
|
assertTrue(forward(source, "x-end-to-end", "yes", sourceProtocol, targetProtocol));
|
||||||
|
assertTrue(forward(source, "te", "trailers", sourceProtocol, Protocol.HTTP_2));
|
||||||
|
assertFalse(forward(source, "te", "trailers", sourceProtocol, Protocol.HTTP_1_1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean forward(
|
||||||
|
MutableHeaderMap source,
|
||||||
|
String name,
|
||||||
|
String value,
|
||||||
|
Protocol sourceProtocol,
|
||||||
|
Protocol targetProtocol) {
|
||||||
|
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
PooledSlice nameView = new PooledSlice();
|
||||||
|
PooledSlice valueView = new PooledSlice();
|
||||||
|
nameView.reset(nameBytes, 0, nameBytes.length);
|
||||||
|
valueView.reset(valueBytes, 0, valueBytes.length);
|
||||||
|
return HopByHopHeaders.shouldForward(
|
||||||
|
source, nameView, valueView, sourceProtocol, targetProtocol);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void add(MutableHeaderMap headers, String name, String value) {
|
||||||
|
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
headers.add(nameBytes, 0, nameBytes.length, valueBytes, 0, valueBytes.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,11 @@ import dev.relism.flash.transport.ConnectionScratch;
|
|||||||
import dev.relism.flash.transport.ScratchPool;
|
import dev.relism.flash.transport.ScratchPool;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
import java.io.ByteArrayOutputStream;
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
|
|
||||||
@@ -177,4 +180,74 @@ class Http1ResponseWriterTest {
|
|||||||
assertEquals(1, out.arrayWriteCalls);
|
assertEquals(1, out.arrayWriteCalls);
|
||||||
assertFalse(out.sink.toString(StandardCharsets.UTF_8).contains("hello world"));
|
assertFalse(out.sink.toString(StandardCharsets.UTF_8).contains("hello world"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Streaming body close-on-every-exit -----------------------------------------
|
||||||
|
|
||||||
|
/** Tracks whether {@code close()} was called, regardless of how the stream was read. */
|
||||||
|
private static final class TrackingInputStream extends ByteArrayInputStream {
|
||||||
|
boolean closed;
|
||||||
|
|
||||||
|
TrackingInputStream(byte[] buf) {
|
||||||
|
super(buf);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws IOException {
|
||||||
|
closed = true;
|
||||||
|
super.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Simulates a client disconnecting mid-transfer: the Nth write() call throws. */
|
||||||
|
private static final class FailingOutputStream extends OutputStream {
|
||||||
|
private final int failAfterCalls;
|
||||||
|
private int calls;
|
||||||
|
|
||||||
|
FailingOutputStream(int failAfterCalls) {
|
||||||
|
this.failAfterCalls = failAfterCalls;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override public void write(int b) {}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(byte[] b, int off, int len) throws IOException {
|
||||||
|
calls++;
|
||||||
|
if (calls > failAfterCalls) throw new IOException("simulated client disconnect");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void chunkedStreamingBody_isClosed_onCleanCompletion() throws IOException {
|
||||||
|
TrackingInputStream stream = new TrackingInputStream("hello world".getBytes(StandardCharsets.UTF_8));
|
||||||
|
Response response = new Response(200, ContentType.TEXT_PLAIN).chunked(stream);
|
||||||
|
Http1ResponseWriter.writeResponse(new ByteArrayOutputStream(), response, HttpMethod.GET, true, false, scratch());
|
||||||
|
|
||||||
|
assertTrue(stream.closed, "a fully-relayed streaming body must be closed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void fixedLengthStreamingBody_isClosed_evenWhenTheClientDisconnectsMidTransfer() {
|
||||||
|
// Regression test: a handler's streaming body (e.g. a reverse proxy relaying a pooled
|
||||||
|
// upstream connection's response) must have close() called even when the downstream
|
||||||
|
// write fails partway through — otherwise a resource that's only released from close(),
|
||||||
|
// not from observing EOF on a read() the failed write means it never reaches, leaks.
|
||||||
|
TrackingInputStream stream = new TrackingInputStream("hello world".getBytes(StandardCharsets.UTF_8));
|
||||||
|
Response response = new Response(200, ContentType.TEXT_PLAIN).stream(stream, 11);
|
||||||
|
FailingOutputStream out = new FailingOutputStream(1); // 1st call writes the head, 2nd (body) fails
|
||||||
|
|
||||||
|
assertThrows(IOException.class, () ->
|
||||||
|
Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch()));
|
||||||
|
assertTrue(stream.closed, "the streaming body must be closed even when the write to the client fails");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void chunkedStreamingBody_isClosed_evenWhenTheClientDisconnectsMidTransfer() {
|
||||||
|
TrackingInputStream stream = new TrackingInputStream("hello world".getBytes(StandardCharsets.UTF_8));
|
||||||
|
Response response = new Response(200, ContentType.TEXT_PLAIN).chunked(stream);
|
||||||
|
FailingOutputStream out = new FailingOutputStream(1); // 1st call writes the head, 2nd (body) fails
|
||||||
|
|
||||||
|
assertThrows(IOException.class, () ->
|
||||||
|
Http1ResponseWriter.writeResponse(out, response, HttpMethod.GET, true, false, scratch()));
|
||||||
|
assertTrue(stream.closed, "the streaming body must be closed even when the write to the client fails");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import dev.relism.flash.tls.TestKeystores;
|
||||||
|
import dev.relism.flash.tls.TlsConfig;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
@EnabledIfSystemProperty(named = "curl.executable", matches = ".+")
|
||||||
|
class CurlInteropTest {
|
||||||
|
private FlashApp app;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stop() {
|
||||||
|
if (app != null) app.stop().join();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tlsGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
Path keystore =
|
||||||
|
TestKeystores.build(
|
||||||
|
directory,
|
||||||
|
"curl.p12",
|
||||||
|
"changeit",
|
||||||
|
TestKeystores.Entry.of("server", "localhost", "localhost"));
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.port(port)
|
||||||
|
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||||
|
.http2Enabled(true)
|
||||||
|
.build());
|
||||||
|
exercise(directory, "https://localhost:" + port, "--http2", "--insecure");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleartextGetPostLargeUploadAndLargeDownload(@TempDir Path directory) throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.port(port)
|
||||||
|
.http2CleartextEnabled(true)
|
||||||
|
.build());
|
||||||
|
exercise(directory, "http://127.0.0.1:" + port, "--http2-prior-knowledge");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void exercise(Path directory, String origin, String... mode) throws Exception {
|
||||||
|
byte[] large = new byte[2 * 1024 * 1024 + 17];
|
||||||
|
for (int i = 0; i < large.length; i++) large[i] = (byte) (i * 31);
|
||||||
|
app.get("/get", (request, response) -> "curl-get");
|
||||||
|
app.post("/post", (request, response) -> request.body().bytes());
|
||||||
|
app.get("/large", (request, response) -> response.body(large));
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
Path upload = directory.resolve("upload.bin");
|
||||||
|
Path output = directory.resolve("output.bin");
|
||||||
|
Files.write(upload, large);
|
||||||
|
assertArrayEquals(
|
||||||
|
"curl-get".getBytes(StandardCharsets.US_ASCII),
|
||||||
|
runCurl(output, origin + "/get", mode));
|
||||||
|
assertArrayEquals(
|
||||||
|
"small-post".getBytes(StandardCharsets.US_ASCII),
|
||||||
|
runCurl(output, origin + "/post", append(mode, "--data-binary", "small-post")));
|
||||||
|
assertArrayEquals(
|
||||||
|
large,
|
||||||
|
runCurl(output, origin + "/post", append(mode, "--data-binary", "@" + upload)));
|
||||||
|
assertArrayEquals(large, runCurl(output, origin + "/large", mode));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] runCurl(Path output, String url, String... options) throws Exception {
|
||||||
|
String executable = System.getProperty("curl.executable");
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add(executable);
|
||||||
|
command.add("--silent");
|
||||||
|
command.add("--show-error");
|
||||||
|
command.add("--fail");
|
||||||
|
command.addAll(List.of(options));
|
||||||
|
command.add("--output");
|
||||||
|
command.add(output.toString());
|
||||||
|
command.add(url);
|
||||||
|
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||||
|
assertTrue(process.waitFor(Duration.ofSeconds(30).toMillis(), TimeUnit.MILLISECONDS));
|
||||||
|
String diagnostics =
|
||||||
|
new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
assertEquals(0, process.exitValue(), diagnostics);
|
||||||
|
return Files.readAllBytes(output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String[] append(String[] values, String... suffix) {
|
||||||
|
String[] result = new String[values.length + suffix.length];
|
||||||
|
System.arraycopy(values, 0, result, 0, values.length);
|
||||||
|
System.arraycopy(suffix, 0, result, values.length, suffix.length);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int freePort() throws Exception {
|
||||||
|
try (ServerSocket socket = new ServerSocket(0)) {
|
||||||
|
return socket.getLocalPort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
@Tag("interop")
|
||||||
|
@EnabledIfSystemProperty(named = "grpcurl.executable", matches = ".+")
|
||||||
|
class GrpcInteropTest {
|
||||||
|
private FlashApp app;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stop() {
|
||||||
|
if (app != null) app.stop().join();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void grpcurlCompletesUnaryStreamingAndErrorCalls(@TempDir Path directory) throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.port(port)
|
||||||
|
.http2CleartextEnabled(true)
|
||||||
|
.build());
|
||||||
|
app.post("/flash.test.Echo/Unary", (request, response) ->
|
||||||
|
response.type("application/grpc")
|
||||||
|
.body(request.body().bytes())
|
||||||
|
.trailer("grpc-status", "0"));
|
||||||
|
app.post("/flash.test.Echo/Stream", (request, response) -> {
|
||||||
|
byte[] message = request.body().bytes();
|
||||||
|
return response.type("application/grpc").streaming(stream -> {
|
||||||
|
try {
|
||||||
|
for (int i = 0; i < 3; i++) stream.write(message, 0, message.length);
|
||||||
|
stream.trailer("grpc-status", "0");
|
||||||
|
} catch (Exception failure) {
|
||||||
|
throw new RuntimeException(failure);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
app.post("/flash.test.Echo/Fail", (request, response) ->
|
||||||
|
response.type("application/grpc")
|
||||||
|
.trailer("grpc-status", "3")
|
||||||
|
.trailer("grpc-message", "invalid request"));
|
||||||
|
app.post("/flash.test.Echo/ClientStream", (request, response) ->
|
||||||
|
response.type("application/grpc")
|
||||||
|
.body(firstGrpcMessage(request.body().bytes()))
|
||||||
|
.trailer("grpc-status", "0"));
|
||||||
|
app.post("/flash.test.Echo/Bidi", (request, response) ->
|
||||||
|
response.type("application/grpc")
|
||||||
|
.body(request.body().bytes())
|
||||||
|
.trailer("grpc-status", "0"));
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
Path proto = directory.resolve("echo.proto");
|
||||||
|
Files.writeString(proto, """
|
||||||
|
syntax = "proto3";
|
||||||
|
package flash.test;
|
||||||
|
service Echo {
|
||||||
|
rpc Unary (Message) returns (Message);
|
||||||
|
rpc Stream (Message) returns (stream Message);
|
||||||
|
rpc ClientStream (stream Message) returns (Message);
|
||||||
|
rpc Bidi (stream Message) returns (stream Message);
|
||||||
|
rpc Fail (Message) returns (Message);
|
||||||
|
}
|
||||||
|
message Message { string value = 1; }
|
||||||
|
""");
|
||||||
|
|
||||||
|
Result unary = call(directory, port, "Unary");
|
||||||
|
assertEquals(0, unary.exitCode);
|
||||||
|
assertTrue(unary.output.contains("hello"), unary.output);
|
||||||
|
|
||||||
|
Result streaming = call(directory, port, "Stream");
|
||||||
|
assertEquals(0, streaming.exitCode);
|
||||||
|
assertEquals(3, occurrences(streaming.output, "hello"), streaming.output);
|
||||||
|
|
||||||
|
Result clientStreaming = streamCall(directory, port, "ClientStream");
|
||||||
|
assertEquals(0, clientStreaming.exitCode, clientStreaming.output);
|
||||||
|
assertEquals(1, occurrences(clientStreaming.output, "hello"), clientStreaming.output);
|
||||||
|
|
||||||
|
Result bidi = streamCall(directory, port, "Bidi");
|
||||||
|
assertEquals(0, bidi.exitCode, bidi.output);
|
||||||
|
assertEquals(2, occurrences(bidi.output, "hello"), bidi.output);
|
||||||
|
|
||||||
|
Result error = call(directory, port, "Fail");
|
||||||
|
assertTrue(error.exitCode != 0);
|
||||||
|
assertTrue(error.output.contains("InvalidArgument"), error.output);
|
||||||
|
assertTrue(error.output.contains("invalid request"), error.output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result call(Path directory, int port, String method) throws Exception {
|
||||||
|
return call(directory, port, method, "{\"value\":\"hello\"}", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result streamCall(Path directory, int port, String method) throws Exception {
|
||||||
|
return call(
|
||||||
|
directory,
|
||||||
|
port,
|
||||||
|
method,
|
||||||
|
"{\"value\":\"hello\"}\n{\"value\":\"hello\"}\n",
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Result call(
|
||||||
|
Path directory, int port, String method, String input, boolean stdin) throws Exception {
|
||||||
|
Process process = new ProcessBuilder(
|
||||||
|
System.getProperty("grpcurl.executable"),
|
||||||
|
"-plaintext",
|
||||||
|
"-import-path", directory.toString(),
|
||||||
|
"-proto", "echo.proto",
|
||||||
|
"-d", stdin ? "@" : input,
|
||||||
|
"127.0.0.1:" + port,
|
||||||
|
"flash.test.Echo/" + method)
|
||||||
|
.redirectErrorStream(true)
|
||||||
|
.start();
|
||||||
|
if (stdin) {
|
||||||
|
process.getOutputStream().write(input.getBytes(StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
process.getOutputStream().close();
|
||||||
|
assertTrue(process.waitFor(10, TimeUnit.SECONDS), "grpcurl timed out");
|
||||||
|
return new Result(process.exitValue(),
|
||||||
|
new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] firstGrpcMessage(byte[] body) {
|
||||||
|
if (body.length < 5) return body;
|
||||||
|
int length =
|
||||||
|
((body[1] & 0xff) << 24)
|
||||||
|
| ((body[2] & 0xff) << 16)
|
||||||
|
| ((body[3] & 0xff) << 8)
|
||||||
|
| (body[4] & 0xff);
|
||||||
|
int end = Math.min(body.length, 5 + length);
|
||||||
|
return java.util.Arrays.copyOf(body, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int occurrences(String text, String needle) {
|
||||||
|
int count = 0;
|
||||||
|
int position = 0;
|
||||||
|
while ((position = text.indexOf(needle, position)) >= 0) {
|
||||||
|
count++;
|
||||||
|
position += needle.length();
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int freePort() throws Exception {
|
||||||
|
try (ServerSocket socket = new ServerSocket(0)) {
|
||||||
|
return socket.getLocalPort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Result(int exitCode, String output) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
@Tag("benchmark")
|
||||||
|
@EnabledIfSystemProperty(named = "h2load.executable", matches = ".+")
|
||||||
|
@EnabledIfSystemProperty(named = "nghttpd.executable", matches = ".+")
|
||||||
|
class H2LoadMeasurementTest {
|
||||||
|
private static final int[] CONNECTIONS = {1, 10, 100, 1_000};
|
||||||
|
private static final int[] STREAMS = {1, 10, 100};
|
||||||
|
private static final Pattern RATE = Pattern.compile("([0-9.]+) req/s");
|
||||||
|
private static final Pattern REQUESTS =
|
||||||
|
Pattern.compile("requests: (\\d+) total, .*? (\\d+) succeeded, (\\d+) failed");
|
||||||
|
|
||||||
|
private FlashApp app;
|
||||||
|
private Process reference;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stop() {
|
||||||
|
if (app != null) app.stop().join();
|
||||||
|
if (reference != null) reference.destroyForcibly();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void measureFlashAndNghttpdMatrix(@TempDir Path directory) throws Exception {
|
||||||
|
int flashPort = freePort();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.port(flashPort)
|
||||||
|
.http2CleartextEnabled(true)
|
||||||
|
.h2MaxStreamsCreatedPerInterval(Integer.MAX_VALUE)
|
||||||
|
.h2MaxStreamsPerConnection(0)
|
||||||
|
.build());
|
||||||
|
app.get("/index.html", (request, response) -> "flash-load");
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
int referencePort = freePort();
|
||||||
|
Files.writeString(directory.resolve("index.html"), "flash-load");
|
||||||
|
ProcessBuilder server =
|
||||||
|
new ProcessBuilder(
|
||||||
|
System.getProperty("nghttpd.executable"),
|
||||||
|
"--no-tls",
|
||||||
|
"--max-concurrent-streams=128",
|
||||||
|
"-d",
|
||||||
|
directory.toString(),
|
||||||
|
Integer.toString(referencePort));
|
||||||
|
applyLibraryPath(server);
|
||||||
|
reference = server.redirectErrorStream(true).start();
|
||||||
|
Thread.sleep(200);
|
||||||
|
|
||||||
|
System.out.println(
|
||||||
|
"implementation,connections,requested_streams,effective_streams,requests,requests_per_second");
|
||||||
|
for (int connections : CONNECTIONS) {
|
||||||
|
for (int streams : STREAMS) {
|
||||||
|
int requests = Math.max(1_000, connections * streams);
|
||||||
|
int effectiveStreams =
|
||||||
|
Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(
|
||||||
|
Math.min(streams, Http2Limits.MAX_CONCURRENT_STREAMS),
|
||||||
|
4_096 / connections));
|
||||||
|
measure("flash", flashPort, connections, streams, effectiveStreams, requests);
|
||||||
|
measure("nghttpd", referencePort, connections, streams, effectiveStreams, requests);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void measure(
|
||||||
|
String implementation,
|
||||||
|
int port,
|
||||||
|
int connections,
|
||||||
|
int requestedStreams,
|
||||||
|
int effectiveStreams,
|
||||||
|
int requests)
|
||||||
|
throws Exception {
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add(System.getProperty("h2load.executable"));
|
||||||
|
command.add("-n");
|
||||||
|
command.add(Integer.toString(requests));
|
||||||
|
command.add("-c");
|
||||||
|
command.add(Integer.toString(connections));
|
||||||
|
command.add("-m");
|
||||||
|
command.add(Integer.toString(effectiveStreams));
|
||||||
|
command.add("-t");
|
||||||
|
command.add(Integer.toString(Math.min(8, connections)));
|
||||||
|
command.add("http://127.0.0.1:" + port + "/index.html");
|
||||||
|
ProcessBuilder builder = new ProcessBuilder(command).redirectErrorStream(true);
|
||||||
|
applyLibraryPath(builder);
|
||||||
|
Process process = builder.start();
|
||||||
|
assertTrue(process.waitFor(Duration.ofMinutes(2).toMillis(), TimeUnit.MILLISECONDS));
|
||||||
|
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
assertEquals(0, process.exitValue(), output);
|
||||||
|
Matcher requestsResult = REQUESTS.matcher(output);
|
||||||
|
assertTrue(requestsResult.find(), output);
|
||||||
|
assertEquals(requests, Integer.parseInt(requestsResult.group(1)), output);
|
||||||
|
assertEquals(requests, Integer.parseInt(requestsResult.group(2)), output);
|
||||||
|
assertEquals(0, Integer.parseInt(requestsResult.group(3)), output);
|
||||||
|
Matcher rate = RATE.matcher(output);
|
||||||
|
assertTrue(rate.find(), output);
|
||||||
|
System.out.printf(
|
||||||
|
"%s,%d,%d,%d,%d,%s%n",
|
||||||
|
implementation,
|
||||||
|
connections,
|
||||||
|
requestedStreams,
|
||||||
|
effectiveStreams,
|
||||||
|
requests,
|
||||||
|
rate.group(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void applyLibraryPath(ProcessBuilder builder) {
|
||||||
|
String path = System.getProperty("nghttp.library.path");
|
||||||
|
if (path != null) builder.environment().put("LD_LIBRARY_PATH", path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int freePort() throws Exception {
|
||||||
|
try (ServerSocket socket = new ServerSocket(0)) {
|
||||||
|
return socket.getLocalPort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import dev.relism.flash.tls.TestKeystores;
|
||||||
|
import dev.relism.flash.tls.TlsConfig;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.locks.LockSupport;
|
||||||
|
import javax.xml.parsers.DocumentBuilderFactory;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.condition.EnabledIfSystemProperty;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
import org.w3c.dom.Document;
|
||||||
|
import org.w3c.dom.NodeList;
|
||||||
|
|
||||||
|
@EnabledIfSystemProperty(named = "h2spec.executable", matches = ".+")
|
||||||
|
class H2SpecComplianceTest {
|
||||||
|
private static final String VERSION = "2.6.0";
|
||||||
|
private FlashApp app;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stop() {
|
||||||
|
if (app != null) app.stop().join();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void cleartextSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.port(port)
|
||||||
|
.http2CleartextEnabled(true)
|
||||||
|
.build());
|
||||||
|
registerProbeRoutes();
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
runH2Spec(port, false, directory.resolve("h2spec-h2c.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void tlsSuiteHasNoFailuresOrSkips(@TempDir Path directory) throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
Path keystore =
|
||||||
|
TestKeystores.build(
|
||||||
|
directory,
|
||||||
|
"h2spec.p12",
|
||||||
|
"changeit",
|
||||||
|
TestKeystores.Entry.of("server", "localhost", "localhost"));
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.port(port)
|
||||||
|
.tls(TlsConfig.keystore(keystore, "changeit"))
|
||||||
|
.http2Enabled(true)
|
||||||
|
.build());
|
||||||
|
registerProbeRoutes();
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
runH2Spec(port, true, directory.resolve("h2spec-tls.xml"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void registerProbeRoutes() {
|
||||||
|
app.get("/", (request, response) -> probeResponse());
|
||||||
|
app.post("/", (request, response) -> probeResponse());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String probeResponse() {
|
||||||
|
// h2spec deliberately writes illegal follow-up frames immediately after END_STREAM. Keep the
|
||||||
|
// ordinary response from winning that wire race so the suite can observe the required reset.
|
||||||
|
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(20));
|
||||||
|
return "flash-compliance";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void runH2Spec(int port, boolean tls, Path report) throws Exception {
|
||||||
|
String executable = System.getProperty("h2spec.executable");
|
||||||
|
ProcessResult version = run(List.of(executable, "--version"), Duration.ofSeconds(5));
|
||||||
|
assertEquals(0, version.exitCode, version.output);
|
||||||
|
assertTrue(version.output.contains(VERSION), "unexpected h2spec version: " + version.output);
|
||||||
|
|
||||||
|
List<String> command = new ArrayList<>();
|
||||||
|
command.add(executable);
|
||||||
|
command.add("--host");
|
||||||
|
command.add(tls ? "localhost" : "127.0.0.1");
|
||||||
|
command.add("--port");
|
||||||
|
command.add(Integer.toString(port));
|
||||||
|
command.add("--timeout");
|
||||||
|
command.add("5");
|
||||||
|
command.add("--junit-report");
|
||||||
|
command.add(report.toString());
|
||||||
|
if (tls) {
|
||||||
|
command.add("--tls");
|
||||||
|
command.add("--insecure");
|
||||||
|
} else {
|
||||||
|
command.add("generic");
|
||||||
|
command.add("hpack");
|
||||||
|
command.add("http2/3.5/1");
|
||||||
|
command.add("http2/4");
|
||||||
|
command.add("http2/5");
|
||||||
|
command.add("http2/6");
|
||||||
|
command.add("http2/7");
|
||||||
|
command.add("http2/8");
|
||||||
|
}
|
||||||
|
|
||||||
|
ProcessResult result = run(command, Duration.ofMinutes(3));
|
||||||
|
assertEquals(0, result.exitCode, result.output);
|
||||||
|
assertReportHasNoFailuresOrSkips(report, result.output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ProcessResult run(List<String> command, Duration timeout) throws Exception {
|
||||||
|
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
||||||
|
boolean completed = process.waitFor(timeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||||
|
if (!completed) {
|
||||||
|
process.destroyForcibly();
|
||||||
|
throw new AssertionError("external command timed out: " + String.join(" ", command));
|
||||||
|
}
|
||||||
|
String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
|
||||||
|
return new ProcessResult(process.exitValue(), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertReportHasNoFailuresOrSkips(Path report, String output)
|
||||||
|
throws Exception {
|
||||||
|
assertTrue(Files.isRegularFile(report), "h2spec did not create its JUnit report\n" + output);
|
||||||
|
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||||
|
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||||
|
Document document = factory.newDocumentBuilder().parse(report.toFile());
|
||||||
|
NodeList failures = document.getElementsByTagName("failure");
|
||||||
|
NodeList errors = document.getElementsByTagName("error");
|
||||||
|
NodeList skipped = document.getElementsByTagName("skipped");
|
||||||
|
assertEquals(0, failures.getLength(), output);
|
||||||
|
assertEquals(0, errors.getLength(), output);
|
||||||
|
assertEquals(0, skipped.getLength(), output);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int freePort() throws Exception {
|
||||||
|
try (ServerSocket socket = new ServerSocket(0)) {
|
||||||
|
return socket.getLocalPort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ProcessResult(int exitCode, String output) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
import dev.relism.flash.http2.frame.FrameFlags;
|
||||||
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
|
import dev.relism.flash.http2.frame.FrameWriteBuffer;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackDecoder;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackEncoder;
|
||||||
|
import dev.relism.flash.websocket.WebSocketFrame;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.Closeable;
|
||||||
|
import java.io.EOFException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.OutputStream;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** Minimal RFC 8441 peer used only by the live WebSocket-over-h2 tests. */
|
||||||
|
final class H2WebSocketTestClient implements Closeable {
|
||||||
|
private static final int WINDOW = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
private final Socket socket;
|
||||||
|
private final InputStream input;
|
||||||
|
private final OutputStream output;
|
||||||
|
private final ByteArrayOutputStream responseData = new ByteArrayOutputStream();
|
||||||
|
private int connectionWindow = 65_535;
|
||||||
|
private int streamWindow = 65_535;
|
||||||
|
private int peerMaxFrame = 16_384;
|
||||||
|
private boolean connectProtocolAdvertised;
|
||||||
|
private boolean responseEnded;
|
||||||
|
|
||||||
|
H2WebSocketTestClient(String host, int port, String path) throws Exception {
|
||||||
|
socket = new Socket(host, port);
|
||||||
|
socket.setSoTimeout(5_000);
|
||||||
|
input = socket.getInputStream();
|
||||||
|
output = socket.getOutputStream();
|
||||||
|
writePreface();
|
||||||
|
awaitSettings();
|
||||||
|
writeConnect(host + ":" + port, path);
|
||||||
|
int status = awaitStatus();
|
||||||
|
if (status != 200) throw new IOException("extended CONNECT returned " + status);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean connectProtocolAdvertised() {
|
||||||
|
return connectProtocolAdvertised;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendText(String value) throws Exception {
|
||||||
|
sendWebSocketFrame(true, WebSocketFrame.OP_TEXT, value.getBytes(StandardCharsets.UTF_8), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendFragmentedText(String first, String second) throws Exception {
|
||||||
|
sendWebSocketFrame(
|
||||||
|
false, WebSocketFrame.OP_TEXT, first.getBytes(StandardCharsets.UTF_8), false);
|
||||||
|
sendWebSocketFrame(
|
||||||
|
true, WebSocketFrame.OP_CONTINUATION, second.getBytes(StandardCharsets.UTF_8), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void sendBinary(byte[] value) throws Exception {
|
||||||
|
sendWebSocketFrame(true, WebSocketFrame.OP_BINARY, value, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] readMessage(byte expectedOpcode) throws Exception {
|
||||||
|
responseData.reset();
|
||||||
|
while (true) {
|
||||||
|
readAndHandleFrame();
|
||||||
|
byte[] bytes = responseData.toByteArray();
|
||||||
|
if (bytes.length < 2) continue;
|
||||||
|
int opcode = bytes[0] & 0x0f;
|
||||||
|
int marker = bytes[1] & 0x7f;
|
||||||
|
int headerLength;
|
||||||
|
long payloadLength;
|
||||||
|
if (marker < 126) {
|
||||||
|
headerLength = 2;
|
||||||
|
payloadLength = marker;
|
||||||
|
} else if (marker == 126) {
|
||||||
|
if (bytes.length < 4) continue;
|
||||||
|
headerLength = 4;
|
||||||
|
payloadLength = ((bytes[2] & 0xff) << 8) | (bytes[3] & 0xff);
|
||||||
|
} else {
|
||||||
|
if (bytes.length < 10) continue;
|
||||||
|
headerLength = 10;
|
||||||
|
payloadLength = 0;
|
||||||
|
for (int i = 2; i < 10; i++) payloadLength = (payloadLength << 8) | (bytes[i] & 0xffL);
|
||||||
|
}
|
||||||
|
if (payloadLength > Integer.MAX_VALUE || bytes.length < headerLength + payloadLength) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (opcode != expectedOpcode) throw new IOException("unexpected WebSocket opcode " + opcode);
|
||||||
|
byte[] payload = new byte[(int) payloadLength];
|
||||||
|
System.arraycopy(bytes, headerLength, payload, 0, payload.length);
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void closeGracefully() throws Exception {
|
||||||
|
sendWebSocketFrame(true, WebSocketFrame.OP_CLOSE, new byte[] {3, (byte) 232}, true);
|
||||||
|
while (!responseEnded) readAndHandleFrame();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() throws IOException {
|
||||||
|
socket.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writePreface() throws IOException {
|
||||||
|
output.write(Http2Preface.clientPreface());
|
||||||
|
ByteWriter bytes = new ByteWriter(64);
|
||||||
|
FrameWriteBuffer frames = new FrameWriteBuffer(bytes);
|
||||||
|
frames.beginFrame(FrameType.SETTINGS, 0, 0);
|
||||||
|
bytes.writeUInt16(Http2Settings.ENABLE_PUSH);
|
||||||
|
bytes.writeUInt32(0);
|
||||||
|
bytes.writeUInt16(Http2Settings.INITIAL_WINDOW_SIZE);
|
||||||
|
bytes.writeUInt32(WINDOW);
|
||||||
|
frames.endFrame();
|
||||||
|
frames.beginFrame(FrameType.WINDOW_UPDATE, 0, 0);
|
||||||
|
bytes.writeUInt31(WINDOW - 65_535);
|
||||||
|
frames.endFrame();
|
||||||
|
output.write(bytes.array(), 0, bytes.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void awaitSettings() throws Exception {
|
||||||
|
while (!connectProtocolAdvertised) {
|
||||||
|
WireFrame frame = readFrame();
|
||||||
|
if (frame.type == FrameType.SETTINGS.code() && (frame.flags & FrameFlags.ACK) == 0) {
|
||||||
|
for (int offset = 0; offset < frame.payload.length; offset += 6) {
|
||||||
|
int id = ((frame.payload[offset] & 0xff) << 8) | (frame.payload[offset + 1] & 0xff);
|
||||||
|
int value = readInt(frame.payload, offset + 2);
|
||||||
|
if (id == Http2Settings.ENABLE_CONNECT_PROTOCOL && value == 1) {
|
||||||
|
connectProtocolAdvertised = true;
|
||||||
|
} else if (id == Http2Settings.INITIAL_WINDOW_SIZE) {
|
||||||
|
streamWindow = value;
|
||||||
|
} else if (id == Http2Settings.MAX_FRAME_SIZE) {
|
||||||
|
peerMaxFrame = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0);
|
||||||
|
} else {
|
||||||
|
handle(frame);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeConnect(String authority, String path) throws IOException {
|
||||||
|
ByteWriter bytes = new ByteWriter(256);
|
||||||
|
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
|
||||||
|
frame.beginFrame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
bytes, 2, "CONNECT".getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
HpackEncoder.writeIndexed(bytes, 6);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
bytes, 1, authority.getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
bytes, 4, path.getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
HpackEncoder.writeLiteral(
|
||||||
|
bytes,
|
||||||
|
":protocol".getBytes(StandardCharsets.US_ASCII),
|
||||||
|
"websocket".getBytes(StandardCharsets.US_ASCII));
|
||||||
|
frame.endFrame();
|
||||||
|
output.write(bytes.array(), 0, bytes.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private int awaitStatus() throws Exception {
|
||||||
|
HpackDecoder decoder = new HpackDecoder();
|
||||||
|
while (true) {
|
||||||
|
WireFrame frame = readFrame();
|
||||||
|
if (frame.type != FrameType.HEADERS.code() || frame.streamId != 1) {
|
||||||
|
handle(frame);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
int[] status = {0};
|
||||||
|
decoder.decode(
|
||||||
|
frame.payload,
|
||||||
|
0,
|
||||||
|
frame.payload.length,
|
||||||
|
(name, value, never) -> {
|
||||||
|
if (name.length() == 7 && name.byteAt(0) == ':') {
|
||||||
|
status[0] =
|
||||||
|
(value.byteAt(0) - '0') * 100
|
||||||
|
+ (value.byteAt(1) - '0') * 10
|
||||||
|
+ value.byteAt(2)
|
||||||
|
- '0';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return status[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendWebSocketFrame(boolean fin, byte opcode, byte[] payload, boolean endStream)
|
||||||
|
throws Exception {
|
||||||
|
byte[] encoded = maskedFrame(fin, opcode, payload);
|
||||||
|
int offset = 0;
|
||||||
|
while (offset < encoded.length) {
|
||||||
|
while (connectionWindow <= 0 || streamWindow <= 0) readAndHandleFrame();
|
||||||
|
int count =
|
||||||
|
Math.min(
|
||||||
|
encoded.length - offset,
|
||||||
|
Math.min(peerMaxFrame, Math.min(connectionWindow, streamWindow)));
|
||||||
|
writeData(encoded, offset, count, endStream && offset + count == encoded.length);
|
||||||
|
offset += count;
|
||||||
|
connectionWindow -= count;
|
||||||
|
streamWindow -= count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void readAndHandleFrame() throws Exception {
|
||||||
|
handle(readFrame());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void handle(WireFrame frame) throws IOException {
|
||||||
|
if (frame.type == FrameType.WINDOW_UPDATE.code()) {
|
||||||
|
int increment = readInt(frame.payload, 0) & 0x7fff_ffff;
|
||||||
|
if (frame.streamId == 0) connectionWindow += increment;
|
||||||
|
else if (frame.streamId == 1) streamWindow += increment;
|
||||||
|
} else if (frame.type == FrameType.DATA.code() && frame.streamId == 1) {
|
||||||
|
responseData.write(frame.payload);
|
||||||
|
responseEnded = (frame.flags & FrameFlags.END_STREAM) != 0;
|
||||||
|
} else if (frame.type == FrameType.SETTINGS.code() && (frame.flags & FrameFlags.ACK) == 0) {
|
||||||
|
writeEmpty(FrameType.SETTINGS, FrameFlags.ACK, 0);
|
||||||
|
} else if (frame.type == FrameType.RST_STREAM.code() && frame.streamId == 1) {
|
||||||
|
throw new IOException("WebSocket stream reset with " + readInt(frame.payload, 0));
|
||||||
|
} else if (frame.type == FrameType.GOAWAY.code()) {
|
||||||
|
throw new IOException("HTTP/2 connection closed with " + readInt(frame.payload, 4));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeData(byte[] payload, int offset, int length, boolean endStream)
|
||||||
|
throws IOException {
|
||||||
|
ByteWriter bytes = new ByteWriter(length + 9);
|
||||||
|
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
|
||||||
|
frame.beginFrame(FrameType.DATA, endStream ? FrameFlags.END_STREAM : 0, 1);
|
||||||
|
bytes.writeBytes(payload, offset, length);
|
||||||
|
frame.endFrame();
|
||||||
|
output.write(bytes.array(), 0, bytes.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeEmpty(FrameType type, int flags, int streamId) throws IOException {
|
||||||
|
byte[] frame = {0, 0, 0, (byte) type.code(), (byte) flags, 0, 0, 0, (byte) streamId};
|
||||||
|
output.write(frame);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WireFrame readFrame() throws IOException {
|
||||||
|
byte[] header = input.readNBytes(9);
|
||||||
|
if (header.length != 9) throw new EOFException("HTTP/2 connection closed between frames");
|
||||||
|
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
|
||||||
|
int streamId =
|
||||||
|
((header[5] & 0x7f) << 24)
|
||||||
|
| ((header[6] & 0xff) << 16)
|
||||||
|
| ((header[7] & 0xff) << 8)
|
||||||
|
| (header[8] & 0xff);
|
||||||
|
byte[] payload = input.readNBytes(length);
|
||||||
|
if (payload.length != length) throw new EOFException("HTTP/2 frame truncated");
|
||||||
|
return new WireFrame(header[3] & 0xff, header[4] & 0xff, streamId, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] maskedFrame(boolean fin, byte opcode, byte[] payload) {
|
||||||
|
int lengthBytes = payload.length <= 125 ? 0 : payload.length <= 0xffff ? 2 : 8;
|
||||||
|
byte[] frame = new byte[2 + lengthBytes + 4 + payload.length];
|
||||||
|
int position = 0;
|
||||||
|
frame[position++] = (byte) ((fin ? 0x80 : 0) | opcode);
|
||||||
|
if (lengthBytes == 0) {
|
||||||
|
frame[position++] = (byte) (0x80 | payload.length);
|
||||||
|
} else if (lengthBytes == 2) {
|
||||||
|
frame[position++] = (byte) (0x80 | 126);
|
||||||
|
frame[position++] = (byte) (payload.length >>> 8);
|
||||||
|
frame[position++] = (byte) payload.length;
|
||||||
|
} else {
|
||||||
|
frame[position++] = (byte) (0x80 | 127);
|
||||||
|
long payloadLength = payload.length;
|
||||||
|
for (int shift = 56; shift >= 0; shift -= 8) {
|
||||||
|
frame[position++] = (byte) (payloadLength >>> shift);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
byte[] mask = {1, 2, 3, 4};
|
||||||
|
System.arraycopy(mask, 0, frame, position, mask.length);
|
||||||
|
position += mask.length;
|
||||||
|
for (int i = 0; i < payload.length; i++) {
|
||||||
|
frame[position + i] = (byte) (payload[i] ^ mask[i & 3]);
|
||||||
|
}
|
||||||
|
return frame;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int readInt(byte[] bytes, int offset) {
|
||||||
|
return ((bytes[offset] & 0xff) << 24)
|
||||||
|
| ((bytes[offset + 1] & 0xff) << 16)
|
||||||
|
| ((bytes[offset + 2] & 0xff) << 8)
|
||||||
|
| (bytes[offset + 3] & 0xff);
|
||||||
|
}
|
||||||
|
|
||||||
|
private record WireFrame(int type, int flags, int streamId, byte[] payload) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import dev.relism.flash.http2.frame.FrameFlags;
|
||||||
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackDecoder;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackEncoder;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class H2cPriorKnowledgeTest {
|
||||||
|
private FlashApp app;
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void stop() {
|
||||||
|
if (app != null) app.stop().join();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void priorKnowledgeRequiresItsIndependentOptIn() throws Exception {
|
||||||
|
int disabledPort = freePort();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.port(disabledPort)
|
||||||
|
.http2Enabled(true)
|
||||||
|
.build());
|
||||||
|
app.get("/", (request, response) -> "wrong protocol");
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
try (Socket socket = new Socket("127.0.0.1", disabledPort)) {
|
||||||
|
socket.setSoTimeout(2_000);
|
||||||
|
socket.getOutputStream().write(Http2Preface.clientPreface());
|
||||||
|
byte[] prefix = socket.getInputStream().readNBytes(5);
|
||||||
|
assertArrayEquals("HTTP/".getBytes(StandardCharsets.US_ASCII), prefix);
|
||||||
|
}
|
||||||
|
app.stop().join();
|
||||||
|
|
||||||
|
int enabledPort = freePort();
|
||||||
|
app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.port(enabledPort)
|
||||||
|
.http2CleartextEnabled(true)
|
||||||
|
.build());
|
||||||
|
app.get("/", (request, response) -> "h2c");
|
||||||
|
app.start();
|
||||||
|
|
||||||
|
try (Socket socket = new Socket("127.0.0.1", enabledPort)) {
|
||||||
|
socket.setSoTimeout(5_000);
|
||||||
|
ByteWriter block = new ByteWriter(32);
|
||||||
|
HpackEncoder.writeIndexed(block, 2); // :method GET
|
||||||
|
HpackEncoder.writeIndexed(block, 6); // :scheme http
|
||||||
|
HpackEncoder.writeIndexed(block, 4); // :path /
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(
|
||||||
|
block, 1, ("127.0.0.1:" + enabledPort).getBytes(StandardCharsets.US_ASCII), false);
|
||||||
|
socket
|
||||||
|
.getOutputStream()
|
||||||
|
.write(
|
||||||
|
Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE,
|
||||||
|
Http2TestFrames.settings(),
|
||||||
|
Http2TestFrames.frame(
|
||||||
|
FrameType.HEADERS,
|
||||||
|
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
|
||||||
|
1,
|
||||||
|
Arrays.copyOf(block.array(), block.length()))));
|
||||||
|
socket.getOutputStream().flush();
|
||||||
|
|
||||||
|
assertEquals(200, readStatus(socket.getInputStream()));
|
||||||
|
assertEquals("h2c", new String(readData(socket.getInputStream()), StandardCharsets.UTF_8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int readStatus(InputStream input) throws Exception {
|
||||||
|
HpackDecoder decoder = new HpackDecoder();
|
||||||
|
while (true) {
|
||||||
|
Http2TestFrames.WireFrame frame = readFrame(input);
|
||||||
|
if (frame.type() != FrameType.HEADERS.code() || frame.streamId() != 1) continue;
|
||||||
|
int[] status = {0};
|
||||||
|
decoder.decode(
|
||||||
|
frame.payload(),
|
||||||
|
0,
|
||||||
|
frame.payload().length,
|
||||||
|
(name, value, never) -> {
|
||||||
|
if (name.length() == 7 && name.byteAt(0) == ':') {
|
||||||
|
status[0] =
|
||||||
|
(value.byteAt(0) - '0') * 100
|
||||||
|
+ (value.byteAt(1) - '0') * 10
|
||||||
|
+ value.byteAt(2)
|
||||||
|
- '0';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return status[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] readData(InputStream input) throws Exception {
|
||||||
|
for (int i = 0; i < 12; i++) {
|
||||||
|
Http2TestFrames.WireFrame frame = readFrame(input);
|
||||||
|
if (frame.streamId() == 1 && frame.type() == FrameType.DATA.code()
|
||||||
|
&& frame.payload().length != 0) return frame.payload();
|
||||||
|
}
|
||||||
|
throw new AssertionError("missing h2c response DATA");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception {
|
||||||
|
byte[] header = input.readNBytes(9);
|
||||||
|
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
|
||||||
|
byte[] payload = input.readNBytes(length);
|
||||||
|
return new Http2TestFrames.WireFrame(
|
||||||
|
header[3] & 0xff, header[4] & 0xff, Http2TestFrames.readInt(header, 5) & 0x7fff_ffff,
|
||||||
|
payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int freePort() throws Exception {
|
||||||
|
try (ServerSocket socket = new ServerSocket(0)) {
|
||||||
|
return socket.getLocalPort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import dev.relism.flash.bytes.ByteWriter;
|
||||||
|
import dev.relism.flash.http2.frame.FrameFlags;
|
||||||
|
import dev.relism.flash.http2.frame.FrameType;
|
||||||
|
import dev.relism.flash.http2.hpack.HeaderListSizeException;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackDecoder;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackEncoder;
|
||||||
|
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
|
||||||
|
import dev.relism.flash.http2.message.PseudoHeaders;
|
||||||
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
|
import dev.relism.flash.extension.FlashApp;
|
||||||
|
import dev.relism.flash.http2.frame.Http2FrameWriter;
|
||||||
|
import dev.relism.flash.transport.BufferedByteSource;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.EOFException;
|
||||||
|
import java.io.InputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.SocketTimeoutException;
|
||||||
|
import java.net.ServerSocket;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2AbuseTest {
|
||||||
|
@Test
|
||||||
|
void rapidResetClosesConnectionWithEnhanceYourCalm() throws Exception {
|
||||||
|
List<byte[]> frames = new ArrayList<>();
|
||||||
|
frames.add(Http2TestFrames.PREFACE);
|
||||||
|
frames.add(Http2TestFrames.settings());
|
||||||
|
for (int i = 0; i <= Http2Limits.MAX_RESET_STREAMS_PER_INTERVAL; i++) {
|
||||||
|
int streamId = i * 2 + 1;
|
||||||
|
frames.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, streamId,
|
||||||
|
new byte[0]));
|
||||||
|
frames.add(Http2TestFrames.frame(FrameType.RST_STREAM, 0, streamId, new byte[4]));
|
||||||
|
}
|
||||||
|
assertCalm(run(frames));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void streamCreationFloodIsBoundedIndependentlyOfResets() throws Exception {
|
||||||
|
List<byte[]> frames = new ArrayList<>();
|
||||||
|
frames.add(Http2TestFrames.PREFACE);
|
||||||
|
frames.add(Http2TestFrames.settings());
|
||||||
|
for (int i = 0; i <= Http2Limits.MAX_STREAMS_CREATED_PER_INTERVAL; i++) {
|
||||||
|
frames.add(Http2TestFrames.frame(
|
||||||
|
FrameType.HEADERS, FrameFlags.END_HEADERS, i * 2 + 1, new byte[0]));
|
||||||
|
}
|
||||||
|
assertCalm(run(frames));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void settingsAndPingFloodsAreRateLimited() throws Exception {
|
||||||
|
List<byte[]> settings = base();
|
||||||
|
for (int i = 0; i <= Http2Limits.MAX_SETTINGS_PER_INTERVAL; i++) {
|
||||||
|
settings.add(Http2TestFrames.settings());
|
||||||
|
}
|
||||||
|
assertCalm(run(settings));
|
||||||
|
|
||||||
|
List<byte[]> pings = base();
|
||||||
|
for (int i = 0; i <= Http2Limits.MAX_PINGS_PER_INTERVAL; i++) {
|
||||||
|
pings.add(Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]));
|
||||||
|
}
|
||||||
|
assertCalm(run(pings));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aggregateNonProgressFrameFloodIsRateLimited() throws Exception {
|
||||||
|
List<byte[]> frames = base();
|
||||||
|
byte[] priority = new byte[5];
|
||||||
|
for (int i = 0; i <= Http2Limits.MAX_USELESS_FRAMES_PER_INTERVAL; i++) {
|
||||||
|
frames.add(Http2TestFrames.frame(FrameType.PRIORITY, 0, 1, priority));
|
||||||
|
}
|
||||||
|
assertCalm(run(frames));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void operatorConnectionStreamAndByteBudgetsAreEnforced() throws Exception {
|
||||||
|
FlashConfiguration oneStream = FlashConfiguration.builder()
|
||||||
|
.h2MaxStreamsPerConnection(1).build();
|
||||||
|
List<byte[]> streams = base();
|
||||||
|
streams.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0]));
|
||||||
|
streams.add(Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 3, new byte[0]));
|
||||||
|
assertCalm(runConfigured(streams, oneStream));
|
||||||
|
|
||||||
|
FlashConfiguration nineBytes = FlashConfiguration.builder()
|
||||||
|
.h2MaxBytesPerConnection(9).build();
|
||||||
|
List<byte[]> bytes = base();
|
||||||
|
bytes.add(Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]));
|
||||||
|
assertCalm(runConfigured(bytes, nineBytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void optionalConnectionLifetimeBudgetRotatesTheConnection() throws Exception {
|
||||||
|
byte[] initial = Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings());
|
||||||
|
ByteArrayInputStream delegate = new ByteArrayInputStream(initial);
|
||||||
|
InputStream stalled = new InputStream() {
|
||||||
|
@Override
|
||||||
|
public int read(byte[] target, int offset, int length) throws IOException {
|
||||||
|
if (delegate.available() > 0) return delegate.read(target, offset, length);
|
||||||
|
try {
|
||||||
|
Thread.sleep(5);
|
||||||
|
} catch (InterruptedException interrupted) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException(interrupted);
|
||||||
|
}
|
||||||
|
throw new SocketTimeoutException("idle");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() throws IOException {
|
||||||
|
byte[] one = new byte[1];
|
||||||
|
int count = read(one, 0, 1);
|
||||||
|
return count < 0 ? -1 : one[0] & 0xff;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Http2Connection connection = new Http2Connection();
|
||||||
|
connection.configure(FlashConfiguration.builder().h2MaxConnectionLifetimeMs(1).build());
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000);
|
||||||
|
try {
|
||||||
|
connection.run(new BufferedByteSource(stalled, null), writer, () -> false);
|
||||||
|
} finally {
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
assertCalm(new Run(Http2TestFrames.parse(output.toByteArray())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void continuationFloodDiesBeforeMaterializingAttack() throws Exception {
|
||||||
|
List<byte[]> frames = base();
|
||||||
|
frames.add(Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82}));
|
||||||
|
byte[] continuation = Http2TestFrames.frame(FrameType.CONTINUATION, 0, 1, new byte[0]);
|
||||||
|
for (int i = 0; i < 100_000; i++) frames.add(continuation);
|
||||||
|
byte[] input = Http2TestFrames.concat(frames.toArray(byte[][]::new));
|
||||||
|
long before = usedHeap();
|
||||||
|
|
||||||
|
Run result = org.junit.jupiter.api.Assertions.assertTimeoutPreemptively(
|
||||||
|
Duration.ofSeconds(2), () -> run(input));
|
||||||
|
|
||||||
|
assertEquals(Http2ErrorCode.PROTOCOL_ERROR.code(), result.lastGoAwayError());
|
||||||
|
assertTrue(usedHeap() - before < 8L * 1024 * 1024, "attack processing retained too much heap");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hpackBombStopsPublishingFieldsAtTheConfiguredBound() {
|
||||||
|
ByteWriter block = new ByteWriter(4096);
|
||||||
|
byte[] name = "x".getBytes(java.nio.charset.StandardCharsets.US_ASCII);
|
||||||
|
byte[] value = new byte[1024];
|
||||||
|
for (int i = 0; i < 100; i++) HpackEncoder.writeLiteral(block, name, value);
|
||||||
|
int[] published = {0};
|
||||||
|
|
||||||
|
assertThrows(
|
||||||
|
HeaderListSizeException.class,
|
||||||
|
() -> new HpackDecoder(4096, 4096).decode(
|
||||||
|
block.array(), 0, block.length(), (n, v, sensitive) -> published[0]++));
|
||||||
|
|
||||||
|
assertTrue(published[0] <= 3, "fields beyond the list bound reached stream storage");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void incompleteHeaderBlockHasAnAbsoluteAssemblyDeadline() throws Exception {
|
||||||
|
byte[] wire = Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82});
|
||||||
|
dev.relism.flash.http2.frame.Http2FrameReader reader =
|
||||||
|
new dev.relism.flash.http2.frame.Http2FrameReader(
|
||||||
|
new BufferedByteSource(new ByteArrayInputStream(wire), null));
|
||||||
|
Http2HeaderBlockDecoder decoder = new Http2HeaderBlockDecoder(1);
|
||||||
|
decoder.accept(reader.readFrame(), (name, value, sensitive) -> {});
|
||||||
|
Thread.sleep(5);
|
||||||
|
|
||||||
|
Http2Exception failure = assertThrows(Http2Exception.class, decoder::checkTimeout);
|
||||||
|
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, failure.errorCode());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void idleOpenStreamIsCancelledWithinConfiguredDeadline() throws Exception {
|
||||||
|
int port = freePort();
|
||||||
|
FlashApp app =
|
||||||
|
FlashApp.create(
|
||||||
|
FlashConfiguration.builder()
|
||||||
|
.host("127.0.0.1")
|
||||||
|
.port(port)
|
||||||
|
.http2CleartextEnabled(true)
|
||||||
|
.h2StreamIdleTimeoutMs(20)
|
||||||
|
.build());
|
||||||
|
app.post("/idle", (request, response) -> request.body().bytes());
|
||||||
|
app.start();
|
||||||
|
ByteWriter headers = new ByteWriter(64);
|
||||||
|
HpackEncoder.writeIndexed(headers, 3);
|
||||||
|
HpackEncoder.writeIndexed(headers, 6);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(headers, 4, ascii("/idle"), false);
|
||||||
|
HpackEncoder.writeLiteralWithNameIndex(headers, 1, ascii("localhost"), false);
|
||||||
|
|
||||||
|
try (Socket socket = new Socket("127.0.0.1", port)) {
|
||||||
|
socket.setSoTimeout(2_000);
|
||||||
|
socket.getOutputStream().write(Http2TestFrames.concat(
|
||||||
|
Http2TestFrames.PREFACE, Http2TestFrames.settings(),
|
||||||
|
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
|
||||||
|
Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1,
|
||||||
|
java.util.Arrays.copyOf(headers.array(), headers.length()))));
|
||||||
|
socket.getOutputStream().flush();
|
||||||
|
Http2TestFrames.WireFrame rst = readUntil(socket.getInputStream(), FrameType.RST_STREAM);
|
||||||
|
assertEquals(Http2ErrorCode.CANCEL.code(), Http2TestFrames.readInt(rst.payload(), 0));
|
||||||
|
} finally {
|
||||||
|
app.stop().join();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void zeroNameDuplicatePseudoAndOversizedFieldAreRejected() {
|
||||||
|
HpackHeaderBlock emptyName = new HpackHeaderBlock();
|
||||||
|
new HpackDecoder().decode(new byte[] {0, 0, 0}, 0, 3, emptyName);
|
||||||
|
assertThrows(Http2StreamException.class,
|
||||||
|
() -> new PseudoHeaders().validate(emptyName, 1));
|
||||||
|
|
||||||
|
HpackHeaderBlock duplicate = new HpackHeaderBlock();
|
||||||
|
new HpackDecoder().decode(new byte[] {(byte) 0x82, (byte) 0x82}, 0, 2, duplicate);
|
||||||
|
assertThrows(Http2StreamException.class,
|
||||||
|
() -> new PseudoHeaders().validate(duplicate, 1));
|
||||||
|
|
||||||
|
assertThrows(Http2Exception.class,
|
||||||
|
() -> new HpackDecoder().decode(new byte[] {0, 0x7f, (byte) 0x81, 0x3f}, 0, 4,
|
||||||
|
(n, v, s) -> {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<byte[]> base() {
|
||||||
|
List<byte[]> frames = new ArrayList<>();
|
||||||
|
frames.add(Http2TestFrames.PREFACE);
|
||||||
|
frames.add(Http2TestFrames.settings());
|
||||||
|
return frames;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Run run(List<byte[]> frames) throws Exception {
|
||||||
|
return run(Http2TestFrames.concat(frames.toArray(byte[][]::new)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Run run(byte[] input) throws Exception {
|
||||||
|
Http2ConnectionHandshakeTest.RunResult result = Http2ConnectionHandshakeTest.run(input);
|
||||||
|
return new Run(Http2TestFrames.parse(result.output()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Run runConfigured(List<byte[]> frames, FlashConfiguration configuration)
|
||||||
|
throws Exception {
|
||||||
|
Http2Connection connection = new Http2Connection();
|
||||||
|
connection.configure(configuration);
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000);
|
||||||
|
try {
|
||||||
|
connection.run(
|
||||||
|
new BufferedByteSource(
|
||||||
|
new ByteArrayInputStream(Http2TestFrames.concat(frames.toArray(byte[][]::new))), null),
|
||||||
|
writer,
|
||||||
|
() -> false);
|
||||||
|
writer.drain();
|
||||||
|
} finally {
|
||||||
|
writer.close();
|
||||||
|
}
|
||||||
|
return new Run(Http2TestFrames.parse(output.toByteArray()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void assertCalm(Run result) {
|
||||||
|
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM.code(), result.lastGoAwayError());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static long usedHeap() {
|
||||||
|
Runtime runtime = Runtime.getRuntime();
|
||||||
|
return runtime.totalMemory() - runtime.freeMemory();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] ascii(String text) {
|
||||||
|
return text.getBytes(StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Http2TestFrames.WireFrame readUntil(InputStream input, FrameType expected)
|
||||||
|
throws Exception {
|
||||||
|
for (int i = 0; i < 12; i++) {
|
||||||
|
byte[] header = input.readNBytes(9);
|
||||||
|
if (header.length != 9) throw new EOFException();
|
||||||
|
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
|
||||||
|
byte[] payload = input.readNBytes(length);
|
||||||
|
Http2TestFrames.WireFrame frame = new Http2TestFrames.WireFrame(
|
||||||
|
header[3] & 0xff, header[4] & 0xff,
|
||||||
|
Http2TestFrames.readInt(header, 5) & 0x7fff_ffff, payload);
|
||||||
|
if (frame.type() == expected.code()) return frame;
|
||||||
|
}
|
||||||
|
throw new AssertionError("missing " + expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int freePort() throws Exception {
|
||||||
|
try (ServerSocket socket = new ServerSocket(0)) {
|
||||||
|
return socket.getLocalPort();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Run(List<Http2TestFrames.WireFrame> frames) {
|
||||||
|
int lastGoAwayError() {
|
||||||
|
for (int i = frames.size() - 1; i >= 0; i--) {
|
||||||
|
Http2TestFrames.WireFrame frame = frames.get(i);
|
||||||
|
if (frame.type() == FrameType.GOAWAY.code()) {
|
||||||
|
return Http2TestFrames.readInt(frame.payload(), 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new AssertionError("missing GOAWAY");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2AuthorityTest {
|
||||||
|
@Test
|
||||||
|
void matchesExactIpPortAndSingleLabelWildcardAuthorities() {
|
||||||
|
assertTrue(Http2Authority.matches("api.example.com:443", "api.example.com"));
|
||||||
|
assertTrue(Http2Authority.matches("127.0.0.1:8443", "127.0.0.1"));
|
||||||
|
assertTrue(Http2Authority.matches("one.example.com", "*.example.com"));
|
||||||
|
assertFalse(Http2Authority.matches("example.com", "*.example.com"));
|
||||||
|
assertFalse(Http2Authority.matches("two.one.example.com", "*.example.com"));
|
||||||
|
assertFalse(Http2Authority.matches("other.example.net", "*.example.com"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package dev.relism.flash.http2;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
import dev.relism.flash.http2.message.DataBufferPool;
|
||||||
|
import dev.relism.flash.http2.message.Http2RequestBody;
|
||||||
|
import dev.relism.flash.http2.stream.Http2FlowController;
|
||||||
|
import dev.relism.flash.http2.stream.Http2Stream;
|
||||||
|
import dev.relism.flash.http2.stream.Http2StreamTable;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class Http2BackpressureTest {
|
||||||
|
@Test
|
||||||
|
void windowUpdatesAreWithheldUntilTheHandlerConsumesQueuedData() throws Exception {
|
||||||
|
AtomicInteger updates = new AtomicInteger();
|
||||||
|
Http2FlowController flow =
|
||||||
|
new Http2FlowController((streamId, increment) -> updates.addAndGet(increment));
|
||||||
|
Http2Stream stream = new Http2StreamTable(1).acquire(1);
|
||||||
|
DataBufferPool pool = new DataBufferPool(Http2Limits.MAX_FRAME_SIZE_LOCAL, 32);
|
||||||
|
Http2RequestBody body = new Http2RequestBody(pool);
|
||||||
|
body.begin(-1, false, bytes -> flow.consumed(stream, bytes));
|
||||||
|
byte[] frame = new byte[Http2Limits.MAX_FRAME_SIZE_LOCAL];
|
||||||
|
|
||||||
|
for (int i = 0; i < 32; i++) {
|
||||||
|
flow.receiveConnectionBytes(frame.length);
|
||||||
|
flow.receiveStreamBytes(stream, frame.length);
|
||||||
|
body.offer(1, frame, 0, frame.length, frame.length);
|
||||||
|
}
|
||||||
|
assertEquals(0, updates.get(), "receiving alone must not reopen either window");
|
||||||
|
|
||||||
|
body.finish(1);
|
||||||
|
assertEquals(32L * frame.length, body.readAllBytes().length);
|
||||||
|
assertEquals(2 * 32 * frame.length, updates.get());
|
||||||
|
assertEquals(32, pool.availableCount());
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user