feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10

Merged
Relism merged 23 commits from feature/core/http2 into master 2026-08-14 18:20:30 +00:00
28 changed files with 3469 additions and 1410 deletions
Showing only changes of commit cfa192e689 - Show all commits
+70
View File
@@ -0,0 +1,70 @@
# HTTP/2 connection control
`Http2Connection` owns only connection-level protocol state. It verifies the preface, drives the
frame reader, dispatches control frames and performs shutdown. HPACK fragment extraction and decode
live in `Http2HeaderBlockDecoder`; socket serialization remains exclusively in
`Http2FrameWriter`. Stream dispatch and application handlers are separate layers.
Each accepted HTTP/2 socket receives a new `Http2Connection`. Sharing the stateless
`Http1Connection` implementation is safe, but sharing an HTTP/2 instance would leak dynamic HPACK,
SETTINGS, flow-control and GOAWAY state between peers.
## Demultiplexing invariant
The demux thread never invokes application work. It reads and validates frames, updates bounded
connection state, and enqueues or directly writes control frames. A registered handler cannot delay
SETTINGS or PING processing. The connection reader polls at a short interval so server shutdown is
observed promptly, while `Http2FrameReader` retains one non-renewable absolute deadline for a
partially received frame; polling therefore does not weaken slow-frame protection.
## Settings
| Identifier | Default | Validation and handling |
|---|---:|---|
| `HEADER_TABLE_SIZE` | 4096 | Unsigned 32-bit; locally capped |
| `ENABLE_PUSH` | 1 | Only 0 or 1; Flash advertises 0 |
| `MAX_CONCURRENT_STREAMS` | unlimited | Unsigned 32-bit |
| `INITIAL_WINDOW_SIZE` | 65535 | At most 2^31-1 |
| `MAX_FRAME_SIZE` | 16384 | 16384 through 16777215 |
| `MAX_HEADER_LIST_SIZE` | unlimited | Unsigned 32-bit |
Unknown identifiers are ignored. A payload is validated as a transaction before values are
committed. The initial-window delta is handed to the stream table as one operation: negative stream
windows are valid, but any result above 2^31-1 rejects the complete update with
`FLOW_CONTROL_ERROR`. Every non-ACK SETTINGS frame receives an empty ACK; locally sent settings are
bounded and have an acknowledgement deadline.
## Priority control writes
`Http2FrameWriter` has one priority MPSC lane in front of its ordinary stream-data lane. PING and
SETTINGS acknowledgements, RST_STREAM and GOAWAY use reusable control intents from the connection
scratch. They can overtake queued DATA but never split or interrupt a socket write already in
progress. Both PING and SETTINGS response queues are bounded.
## Shutdown
Graceful shutdown follows the two-stage protocol:
1. Send GOAWAY with last-stream-id 2^31-1 and `NO_ERROR`.
2. Send a connection PING and wait for its matching ACK, establishing a round trip.
3. Send a second GOAWAY with the real last processed stream id, then close after current work.
A connection error instead sends one GOAWAY with the precise error code, the real last processed
stream id and a bounded diagnostic string. A preface mismatch closes silently because the peer has
not established a valid HTTP/2 connection.
## Verification
The reusable control lifecycle (preface, SETTINGS/ACK, PING/PONG, WINDOW_UPDATE and received
GOAWAY) measures 974.263 ns/op and 0.008 B/op on JDK 21.0.11; the allocation figure is the JMH GC
profiler noise floor with no collections. `curl 8.5.0` using h2c prior knowledge completed the
handshake and observed both clean GOAWAY stages. It exits with code 56 because this phase
deliberately sends no response HEADERS or DATA; those arrive with the response and stream phases.
h2spec 2.6.0 passes 28 of the 35 selected section 3, 4, 6.5, 6.7, 6.8 and 6.9 cases, including all
connection-owned SETTINGS validation, PING, GOAWAY, frame-format, HPACK interleaving and
connection-window cases. Six failures require response HEADERS/DATA or per-stream flow control and
remain assigned to the response, stream and DATA phases. The seventh is h2spec's expectation of a
GOAWAY after an invalid preface; Flash intentionally closes without writing because no valid HTTP/2
connection exists yet, as permitted by RFC 7540 §3.5 and required by this implementation's preface
contract.
+21
View File
@@ -884,3 +884,24 @@ 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 911. 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 911 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 911 land, then rerun
the combined selection without skips.
---
+41 -25
View File
@@ -69,7 +69,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
| 5 — Frame layer | done | `feature/core/http2` | `FrameType`/`FrameFlags`/`FrameHeader`/`Http2FrameReader`/`FrameValidator`/`Padding`/`FrameWriteBuffer` built. All 10 frame types read/validated/written; per-type RFC error codes verified individually (`FrameValidatorTest`); fuzz-tested 10M random inputs (~14s, green). Zero-alloc contract measured, not asserted: read+validate+consume 0.002 B/op, write ≈10⁻⁴ B/op (`DEC-21`). Found+fixed `EX-37` (`BufferedByteSource`'s deadline mechanism NPE'd against a `null` socket — zero prior test coverage of `EX-07`'s own fix; added `BufferedByteSourceTest`). `FRAMES.md` written. 449/449 tests green. |
| 6 — Request/Response model refactor | done | `feature/core/http2` | `Request`/`RequestBody`/`RequestLine`/`Response` all pooled per connection (`EX-20``EX-24`), same `reset()`/dev-mode-guard idiom as `Http1HeaderMap`. `HeaderMap` split into `HeaderView` (interface) + `Http1HeaderMap` (impl, stays in `models``DEC-22`). `Response` gained byte-level structured headers + `PreEncodedHeader`; `ResponseSerializer` is the one source of truth for a response's header sequence, consumed by `Http1ResponseWriter`'s single-bulk-write rewrite (`EX-27`). `ByteTemplate` fixed to O(1) slot lookup + a buffer-writing overload (`EX-28`). `Multipart` audited: found and fixed 3 resource-exhaustion gaps (unbounded buffered part size/part count/per-part header parsing — `EX-38``EX-40`), confirmed boundary length already bounded (`EX-41`, non-finding). Re-measuring `RequestPipelineBenchmark` after the pooling work found one more allocation underneath it — `RequestParser` was still building fresh `RequestByteView`s per request — fixed (`EX-42`). Zero-alloc contract closed: `parseAndRoute` 120.008 → 0.008 B/op (`DEC-20`/`DEC-23`). Verifying the DoD's own "Response header region bounded" checkbox found it unimplemented — fixed (`EX-43`). `MESSAGE-MODEL.md` written; README gained an "Object lifetime" section. 503/503 tests green. |
| 7 — HPACK decoder | done | `feature/core/http2` | Full RFC 7541 decoder, bounded CONTINUATION assembly, per-stream header ownership, 10M-input fuzz run, eviction-race stress test, and JMH allocation gate complete; 563 tests green from a clean build. |
| 8 — Connection state machine | not started | — | — |
| 8 — Connection state machine | done | `feature/core/http2` | Preface, transactional SETTINGS, priority PING ACK, connection WINDOW_UPDATE, two-stage GOAWAY, per-socket transport/ALPN dispatch, HPACK block composition, clean curl handshake, h2spec 28/35 selected cases and 0.008 B/op JMH gate complete. Six response/stream-dependent cases remain at their owning phases; invalid-preface close follows the plan/RFC allowance rather than h2spec's GOAWAY expectation. |
| 9 — HPACK encoder + h2 response path | not started | — | — |
| 10 — Stream state machine + dispatch | not started | — | — |
| 11 — DATA, flow control, bodies | not started | — | — |
@@ -748,6 +748,15 @@ the counter without the process comment and audited every non-comment line remov
commit. `MultipartTest`'s part-count limit coverage remains the regression test; phase closure now
uses `mvn clean test` so stale classes cannot mask source damage. **Phase**: 7.
### EX-45 — Stateful HTTP/2 protocol instance was shared across accepted sockets
Found by running h2spec repeatedly against the Phase 8 transport integration. `TransportFactory`
constructed one `Http2Connection` and `ConnectionRunner` reused it for every accepted socket, which
is valid for the stateless `Http1Connection` but leaked SETTINGS, GOAWAY and flow-control state
between HTTP/2 peers. **Fix**: `ConnectionRunner` now receives an HTTP/2 protocol factory and creates
one state machine per accepted HTTP/2 connection. `Http2ConnectionIntegrationTest` first poisons one
connection with a protocol error, then verifies that a second connection completes a fresh SETTINGS
exchange and PING/PONG. **Phase**: 8.
---
# PART III — The phases
@@ -2138,20 +2147,21 @@ green before stream semantics exist.
### Files
Created:
- `h2/Http2Connection.java` — the demux loop and connection state. Single responsibility:
- `http2/Http2Connection.java` — the demux loop and connection state. Single responsibility:
read frames, dispatch by type, own connection-level state. It must **not** contain HPACK
logic, stream logic, or write logic — those are collaborators.
- `h2/Http2Settings.java` — local and remote settings with per-parameter validation.
- `h2/Http2ConnectionScratch.java` — extends/holds the shared `ConnectionScratch` plus the h2
buffers: read buffer, HPACK assembly buffer, HPACK decode scratch, write scratch, the dynamic
table arena, the stream-arena pool, the body-buffer free list.
- `h2/Http2Preface.java` — the 24-byte client preface constant and the server's initial
- `http2/Http2Settings.java` — local and remote settings with per-parameter validation.
- `http2/Http2ConnectionScratch.java` — holds reusable connection-control frame slots.
- `http2/Http2HeaderBlockDecoder.java` — composes HEADERS/CONTINUATION extraction with the HPACK
decoder without putting compression logic in the connection state machine.
- `http2/Http2Preface.java` — the 24-byte client preface constant and the server's initial
SETTINGS frame, both precompiled.
Modified:
- `transport/ProtocolNegotiator.java` — `H2` now dispatches to `Http2Connection`.
- `transport/ServerLifecycle.java` — graceful shutdown sends GOAWAY to h2 connections
(`EX-32`).
- `transport/ConnectionRunner.java` / `TransportFactory.java` — HTTP/2 dispatch creates one
stateful connection protocol per accepted socket.
- `transport/ServerLifecycle.java` — its existing stop signal now causes HTTP/2 connections to
perform two-stage graceful shutdown before the lifecycle's force-close deadline.
- `tls/TlsConfig.java` / `FlashConfiguration.java` — `h2` is offered in ALPN when
`http2Enabled`.
@@ -2226,18 +2236,19 @@ GOAWAY — must be **0 B/op** after connection setup. All the frames we send her
precompiled constants or serialized into the write scratch.
### Safety checks
- [ ] Preface verified byte-exact
- [ ] First frame from peer must be SETTINGS
- [ ] Every SETTINGS parameter validated per the table above
- [ ] Unknown SETTINGS identifiers ignored
- [ ] SETTINGS ACK with non-zero length rejected
- [ ] SETTINGS ACK timeout enforced
- [ ] `INITIAL_WINDOW_SIZE` delta applied to all open streams, negative windows permitted,
- [x] Preface verified byte-exact
- [x] First frame from peer must be SETTINGS
- [x] Every SETTINGS parameter validated per the table above
- [x] Unknown SETTINGS identifiers ignored
- [x] SETTINGS ACK with non-zero length rejected
- [x] SETTINGS ACK timeout enforced
- [x] `INITIAL_WINDOW_SIZE` delta applied transactionally through the stream-table updater;
negative windows permitted,
overflow rejected
- [ ] PING length and stream id validated; PING response queue bounded
- [ ] WINDOW_UPDATE zero-increment and overflow rejected
- [ ] GOAWAY two-stage graceful shutdown implemented
- [ ] Demux loop never blocks on application work — asserted by design review and by a test that
- [x] PING length and stream id validated; PING response queue bounded
- [x] WINDOW_UPDATE zero-increment and overflow rejected
- [x] GOAWAY two-stage graceful shutdown implemented
- [x] Demux loop never blocks on application work — asserted by design review and by a test that
registers a deliberately slow handler and verifies other frames still process
### Tests
@@ -2254,10 +2265,15 @@ precompiled constants or serialized into the write scratch.
shutdown protocol.
### DoD
- [ ] `curl --http2 https://localhost:port/` completes the handshake and receives a clean
GOAWAY (no stream handling yet).
- [ ] The listed `h2spec` sections are green.
- [ ] 0 B/op for the connection lifecycle.
- [x] `curl --http2-prior-knowledge http://127.0.0.1:18080/` completes the handshake and receives
both clean GOAWAY stages (curl exits 56 because response HEADERS/DATA do not exist yet).
- [ ] The listed `h2spec` sections are fully green. Connection-owned cases are green; cases that
require response HEADERS/DATA or stream-level flow control are deferred to Phases 911.
Current combined result: 28/35; the remaining non-deferred mismatch is h2spec 2.6.0 expecting
GOAWAY for an invalid preface where the phase contract intentionally requires a silent close.
- [x] Connection control lifecycle measured by JMH at 0.008 B/op (profiler noise floor),
974.263 ns/op, with no collections.
- [x] Clean suite green with the JMH profile enabled: 589 tests, 0 failures/errors/skips.
---
@@ -0,0 +1,131 @@
package dev.relism.flash.http2;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameWriteBuffer;
import dev.relism.flash.http2.frame.Http2FrameReader;
import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.Fork;
import org.openjdk.jmh.annotations.Level;
import org.openjdk.jmh.annotations.Measurement;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.TearDown;
import org.openjdk.jmh.annotations.Warmup;
/** Measures the allocation-free control-frame lifecycle after connection objects are prepared. */
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public class Http2ConnectionBenchmark {
private static final java.util.function.BooleanSupplier RUNNING = () -> false;
private byte[] wire;
private Http2Connection connection;
private BufferedByteSource input;
private Http2FrameReader reader;
private Http2FrameWriter writer;
private CountingSink sink;
private ResettableInputStream stream;
@Setup(Level.Trial)
public void buildWire() {
ByteWriter bytes = new ByteWriter(128);
bytes.writeBytes("PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII));
FrameWriteBuffer frames = new FrameWriteBuffer(bytes);
frames.beginFrame(FrameType.SETTINGS, 0, 0);
frames.endFrame();
frames.beginFrame(FrameType.SETTINGS, FrameFlags.ACK, 0);
frames.endFrame();
frames.beginFrame(FrameType.PING, 0, 0);
bytes.writeAscii("12345678");
frames.endFrame();
frames.beginFrame(FrameType.WINDOW_UPDATE, 0, 0);
bytes.writeUInt31(1);
frames.endFrame();
frames.beginFrame(FrameType.GOAWAY, 0, 0);
bytes.writeUInt31(0);
bytes.writeUInt32(Http2ErrorCode.NO_ERROR.code());
frames.endFrame();
wire = new byte[bytes.length()];
System.arraycopy(bytes.array(), 0, wire, 0, wire.length);
setupConnection();
}
private void setupConnection() {
connection = new Http2Connection();
stream = new ResettableInputStream(wire);
input = new BufferedByteSource(stream, null);
reader = new Http2FrameReader(input);
sink = new CountingSink();
writer = new Http2FrameWriter(sink, 5_000);
}
@Setup(Level.Invocation)
public void resetConnection() {
stream.reset();
connection.reset();
sink.bytes = 0;
}
@TearDown(Level.Trial)
public void closeWriter() {
writer.close();
}
@Benchmark
public int controlLifecycle() throws Exception {
connection.runPrepared(input, reader, writer, RUNNING);
return sink.bytes;
}
private static final class ResettableInputStream extends InputStream {
private final byte[] bytes;
private int position;
private ResettableInputStream(byte[] bytes) {
this.bytes = bytes;
}
@Override
public void reset() {
position = 0;
}
@Override
public int read() {
return position == bytes.length ? -1 : bytes[position++] & 0xff;
}
@Override
public int read(byte[] target, int offset, int length) {
if (position == bytes.length) return -1;
int count = Math.min(length, bytes.length - position);
System.arraycopy(bytes, position, target, offset, count);
position += count;
return count;
}
}
private static final class CountingSink implements Http2FrameWriter.Sink {
private int bytes;
@Override
public void write(byte[] buffer, int offset, int length) {
bytes += length;
}
}
}
@@ -1,13 +1,11 @@
package dev.relism.flash.extension;
import dev.relism.flash.tls.TlsConfig;
import java.util.List;
import lombok.Builder;
import lombok.Singular;
import lombok.Value;
import java.util.List;
/**
* Configuration for a {@link FlashApp} instance.
*
@@ -39,81 +37,81 @@ import java.util.List;
@Builder
public class FlashConfiguration {
int port;
String host;
int port;
String host;
/** TLS for the single default listener ({@link #port}/{@link #host}). Ignored if {@link #listeners} is non-empty. */
TlsConfig tls;
/**
* TLS for the single default listener ({@link #port}/{@link #host}). Ignored if {@link
* #listeners} is non-empty.
*/
TlsConfig tls;
/** One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link #host}/{@link #tls}. */
@Singular
List<Listener> listeners;
/**
* One or more listeners for this app. Non-empty list takes precedence over {@link #port}/{@link
* #host}/{@link #tls}.
*/
@Singular List<Listener> listeners;
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
@Builder.Default
int maxHeaderBufferSize = 64 * 1024;
/** Maximum size of the request header buffer in bytes. Default: 64 KB. */
@Builder.Default int maxHeaderBufferSize = 64 * 1024;
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
@Builder.Default
int wsFrameBufferSize = 64 * 1024;
/** Per-connection WebSocket read buffer size in bytes. Default: 64 KB. */
@Builder.Default int wsFrameBufferSize = 64 * 1024;
/**
* Maximum time, in milliseconds, allowed for a request's headers to be fully read once the
* first byte of it has arrived. Bounds the classic slowloris attack: a peer that trickles
* one header byte every few seconds forever. Enforced by an absolute deadline
* (see {@code dev.relism.flash.transport.BufferedByteSource}), not merely a per-read socket
* timeout a per-read timeout alone never trips as long as each individual read succeeds
* within the window, no matter how long the overall header block takes. Default: 10 000
*/
@Builder.Default
int headerReadTimeoutMs = 10_000;
/**
* Maximum time, in milliseconds, allowed for a request's headers to be fully read once the first
* byte of it has arrived. Bounds the classic slowloris attack: a peer that trickles one header
* byte every few seconds forever. Enforced by an absolute deadline (see {@code
* dev.relism.flash.transport.BufferedByteSource}), not merely a per-read socket timeout a
* per-read timeout alone never trips as long as each individual read succeeds within the window,
* no matter how long the overall header block takes. Default: 10 000
*/
@Builder.Default int headerReadTimeoutMs = 10_000;
/**
* Maximum time, in milliseconds, a keep-alive connection may sit idle waiting for its next
* request before being closed. More generous than {@link #headerReadTimeoutMs} because an
* idle keep-alive connection is normal, expected behaviour, not an attack in progress the
* tighter bound applies only once bytes have actually started arriving. Default: 60 000
*/
@Builder.Default
int idleKeepAliveTimeoutMs = 60_000;
/**
* Maximum time, in milliseconds, a keep-alive connection may sit idle waiting for its next
* request before being closed. More generous than {@link #headerReadTimeoutMs} because an idle
* keep-alive connection is normal, expected behaviour, not an attack in progress the tighter
* bound applies only once bytes have actually started arriving. Default: 60 000
*/
@Builder.Default int idleKeepAliveTimeoutMs = 60_000;
/**
* Maximum time, in milliseconds, a request's body may take to be fully read (by the handler
* or by the automatic drain after it returns) once headers are parsed. Default: 30 000
*/
@Builder.Default
int bodyReadTimeoutMs = 30_000;
/**
* Maximum time, in milliseconds, a request's body may take to be fully read (by the handler or by
* the automatic drain after it returns) once headers are parsed. Default: 30 000
*/
@Builder.Default int bodyReadTimeoutMs = 30_000;
/**
* Maximum time, in milliseconds, {@link dev.relism.flash.ServerHandle#stop()} waits for
* in-flight requests to finish after it stops accepting new connections, before force-
*/
@Builder.Default
int shutdownDrainTimeoutMs = 15_000;
/**
* Maximum time, in milliseconds, {@link dev.relism.flash.ServerHandle#stop()} waits for in-flight
* requests to finish after it stops accepting new connections, before force-
*/
@Builder.Default int shutdownDrainTimeoutMs = 15_000;
/**
* Whether this server will ever negotiate HTTP/2. Default {@code false}: until the h2
* flag currently only gates the h2c cleartext-preface detection
* ({@code dev.relism.flash.transport.ProtocolNegotiator}) skipping it entirely keeps
* plaintext connections byte-for-byte identical to pre-HTTP/2 Flash when left at its
* default. TLS/ALPN connections are always detected accurately regardless of this flag
* (that costs nothing see {@code ProtocolNegotiator}'s Javadoc) but are cleanly rejected
* rather than served until the phases that implement HTTP/2 land.
*/
@Builder.Default
boolean http2Enabled = false;
/**
* Whether this server negotiates HTTP/2. When enabled, plaintext listeners recognize h2c prior
* knowledge and TLS listeners advertise {@code h2} followed by HTTP/1.1 through ALPN. Disabled by
* default until the HTTP/2 request/response path is complete.
*/
@Builder.Default boolean http2Enabled = false;
/**
* Whether every response includes a {@code Date} header (RFC 9110 §6.6.1). Default
* {@code true}; set {@code false} if Flash sits behind a reverse proxy that already adds
* one, to skip the (already cheap see {@code dev.relism.flash.http.DateHeader}) write.
*/
@Builder.Default
boolean sendDate = true;
/**
* Whether every response includes a {@code Date} header (RFC 9110 §6.6.1). Default {@code true};
* set {@code false} if Flash sits behind a reverse proxy that already adds one, to skip the
* (already cheap see {@code dev.relism.flash.http.DateHeader}) write.
*/
@Builder.Default boolean sendDate = true;
/** One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS. */
public record Listener(int port, String host, TlsConfig tls) {
public Listener(int port) { this(port, null, null); }
public Listener(int port, TlsConfig tls) { this(port, null, tls); }
/**
* One bind target: a TCP port, an optional bind host (default: all interfaces), and optional TLS.
*/
public record Listener(int port, String host, TlsConfig tls) {
public Listener(int port) {
this(port, null, null);
}
public Listener(int port, TlsConfig tls) {
this(port, null, tls);
}
}
}
@@ -0,0 +1,307 @@
package dev.relism.flash.http2;
import dev.relism.flash.http2.Http2ConnectionScratch.ControlIntent;
import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameHeader;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameValidator;
import dev.relism.flash.http2.frame.Http2FrameReader;
import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.transport.BufferedByteSource;
import dev.relism.flash.transport.ConnectionContext;
import dev.relism.flash.transport.ConnectionProtocol;
import java.io.IOException;
import java.net.SocketTimeoutException;
import java.util.function.BooleanSupplier;
import lombok.extern.slf4j.Slf4j;
/**
* Owns one HTTP/2 connection's demultiplexing and connection-level protocol state. The demux loop
* never invokes application code and never waits for a handler or body consumer; stream dispatch is
* handed to independent virtual threads by the stream layer.
*/
@Slf4j
public final class Http2Connection implements ConnectionProtocol {
private static final byte[] SHUTDOWN_PING = {
(byte) 0x46, (byte) 0x4c, (byte) 0x41, (byte) 0x53,
(byte) 0x48, (byte) 0x47, (byte) 0x4f, (byte) 0x21
};
private final Http2Settings peerSettings = new Http2Settings();
private final Http2ConnectionScratch scratch = new Http2ConnectionScratch();
private final Http2Settings.StreamWindowUpdater streamWindows;
private final long settingsAckTimeoutMs;
private final Http2HeaderBlockDecoder headerBlocks = new Http2HeaderBlockDecoder();
private long connectionSendWindow = 65_535;
private int outstandingLocalSettings;
private long oldestSettingsSentNanos;
private int lastProcessedStreamId;
private int peerLastStreamId = Integer.MAX_VALUE;
private int peerErrorCode;
private boolean peerGoAway;
private boolean gracefulStarted;
private boolean gracefulFinished;
public Http2Connection() {
this(delta -> {}, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
}
public Http2Connection(Http2Settings.StreamWindowUpdater streamWindows) {
this(streamWindows, Http2Limits.SETTINGS_ACK_TIMEOUT_MS);
}
Http2Connection(Http2Settings.StreamWindowUpdater streamWindows, long settingsAckTimeoutMs) {
this.streamWindows = streamWindows;
this.settingsAckTimeoutMs = settingsAckTimeoutMs;
}
@Override
public void run(ConnectionContext ctx) throws IOException {
Http2FrameWriter writer = new Http2FrameWriter(ctx.rawOut()::write);
try {
run(ctx.in(), writer, ctx.stopped());
} finally {
writer.close();
}
}
void run(BufferedByteSource input, Http2FrameWriter writer, BooleanSupplier stopped)
throws IOException {
Http2FrameReader reader = new Http2FrameReader(input);
runPrepared(input, reader, writer, stopped);
}
/** Runs with connection collaborators that were allocated during connection setup. */
void runPrepared(
BufferedByteSource input,
Http2FrameReader reader,
Http2FrameWriter writer,
BooleanSupplier stopped)
throws IOException {
if (!verifyPreface(input)) return;
sendConstant(writer, Http2Preface.serverSettings());
sendConstant(writer, Http2Preface.initialConnectionWindow());
outstandingLocalSettings = 1;
oldestSettingsSentNanos = System.nanoTime();
boolean firstFrame = true;
try {
while (!gracefulFinished && !peerGoAway) {
if (stopped.getAsBoolean() && !gracefulStarted) startGracefulShutdown(writer);
FrameHeader frame;
try {
frame = reader.readFrame(Math.min(100, nextReadTimeoutMs()));
} catch (SocketTimeoutException timeout) {
checkSettingsTimeout();
if (stopped.getAsBoolean() && !gracefulStarted) {
startGracefulShutdown(writer);
continue;
}
if (reader.frameDeadlineExpired()) throw timeout;
continue;
}
if (frame == null) break;
try {
FrameValidator.validate(frame, headerBlocks.insideHeaderBlock());
if (headerBlocks.insideHeaderBlock() && frame.type() != FrameType.CONTINUATION) {
throw Http2Exception.PROTOCOL_ERROR;
}
if (firstFrame && frame.type() != FrameType.SETTINGS) {
throw Http2Exception.PROTOCOL_ERROR;
}
if (firstFrame && FrameFlags.isAck(frame.flags()) && frame.length() == 0) {
throw Http2Exception.PROTOCOL_ERROR;
}
firstFrame = false;
dispatch(frame, writer);
} catch (Http2StreamException streamError) {
sendRstStream(writer, streamError);
} finally {
reader.consumeFrame();
}
writer.drain();
checkSettingsTimeout();
}
} catch (Http2Exception connectionError) {
sendGoAway(
writer, lastProcessedStreamId, connectionError.errorCode(), connectionError.getMessage());
} catch (IOException io) {
throw io;
} catch (RuntimeException unexpected) {
log.error("Unexpected failure in HTTP/2 demux loop", unexpected);
sendGoAway(writer, lastProcessedStreamId, Http2ErrorCode.INTERNAL_ERROR, "internal error");
}
}
private boolean verifyPreface(BufferedByteSource input) throws IOException {
byte[] preface = scratch.prefaceBuffer();
int read = 0;
input.setDeadline(System.nanoTime() + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L);
try {
while (read < preface.length) {
int n = input.read(preface, read, preface.length - read);
if (n < 0) return false;
read += n;
}
return Http2Preface.matchesClientPreface(preface);
} finally {
input.clearDeadline();
}
}
private void dispatch(FrameHeader frame, Http2FrameWriter writer) throws IOException {
FrameType type = frame.type();
if (type == null) return;
switch (type) {
case SETTINGS -> receiveSettings(frame, writer);
case PING -> receivePing(frame, writer);
case WINDOW_UPDATE -> receiveWindowUpdate(frame);
case GOAWAY -> receiveGoAway(frame);
case HEADERS, CONTINUATION -> {
if (headerBlocks.accept(frame)) {
lastProcessedStreamId = Math.max(lastProcessedStreamId, frame.streamId());
if (!gracefulStarted) startGracefulShutdown(writer);
}
}
default -> {
// Stream semantics are introduced by the stream layer. Structurally-valid
// frames are consumed here so connection-level state remains synchronized.
}
}
}
private void receiveSettings(FrameHeader frame, Http2FrameWriter writer) throws IOException {
boolean ack = FrameFlags.isAck(frame.flags());
if (ack) {
if (frame.length() != 0) throw Http2Exception.FRAME_SIZE_ERROR;
if (outstandingLocalSettings == 0) throw Http2Exception.PROTOCOL_ERROR;
outstandingLocalSettings--;
if (outstandingLocalSettings == 0) oldestSettingsSentNanos = 0;
return;
}
peerSettings.apply(frame.buffer(), frame.payloadOffset(), frame.length(), streamWindows);
sendConstant(writer, Http2Preface.settingsAck());
}
private void receivePing(FrameHeader frame, Http2FrameWriter writer) throws IOException {
if (FrameFlags.isAck(frame.flags())) {
if (gracefulStarted && matches(frame.buffer(), frame.payloadOffset(), SHUTDOWN_PING)) {
sendGoAway(writer, lastProcessedStreamId, Http2ErrorCode.NO_ERROR, "shutdown complete");
gracefulFinished = true;
}
return;
}
ControlIntent pong = scratch.acquire(ControlKind.PING);
pong.frame(FrameType.PING, FrameFlags.ACK, 0, frame.buffer(), frame.payloadOffset(), 8);
writer.writePriority(pong);
}
private void receiveWindowUpdate(FrameHeader frame) {
int increment = readUInt31(frame.buffer(), frame.payloadOffset());
if (increment == 0) throw Http2Exception.PROTOCOL_ERROR;
if (frame.streamId() != 0) return;
long next = connectionSendWindow + increment;
if (next > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR;
connectionSendWindow = next;
}
private void receiveGoAway(FrameHeader frame) {
peerLastStreamId = readUInt31(frame.buffer(), frame.payloadOffset());
peerErrorCode = readInt(frame.buffer(), frame.payloadOffset() + 4);
peerGoAway = true;
}
private void startGracefulShutdown(Http2FrameWriter writer) throws IOException {
gracefulStarted = true;
sendGoAway(writer, Integer.MAX_VALUE, Http2ErrorCode.NO_ERROR, "server shutting down");
ControlIntent ping = scratch.acquire(ControlKind.PING);
ping.frame(FrameType.PING, 0, 0, SHUTDOWN_PING, 0, SHUTDOWN_PING.length);
writer.writePriority(ping);
}
private void sendRstStream(Http2FrameWriter writer, Http2StreamException error)
throws IOException {
ControlIntent rst = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
rst.frame(FrameType.RST_STREAM, 0, error.streamId(), error.errorCode().bytes(), 0, 4);
writer.writePriority(rst);
}
private void sendGoAway(
Http2FrameWriter writer, int lastStreamId, Http2ErrorCode error, String debug)
throws IOException {
ControlIntent goAway = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
goAway.goAway(lastStreamId, error, debug == null ? "" : debug);
writer.writePriority(goAway);
}
private void sendConstant(Http2FrameWriter writer, byte[] bytes) throws IOException {
ControlIntent intent = scratch.acquire(ControlKind.SETTINGS_OR_OTHER);
intent.copy(bytes);
writer.writePriority(intent);
}
private long nextReadTimeoutMs() {
if (outstandingLocalSettings == 0) return Http2Limits.FRAME_READ_TIMEOUT_MS;
long elapsed = System.nanoTime() - oldestSettingsSentNanos;
long remainingNanos = settingsAckTimeoutMs * 1_000_000L - elapsed;
if (remainingNanos <= 0) throw Http2Exception.SETTINGS_TIMEOUT;
long remainingMs = Math.max(1, (remainingNanos + 999_999L) / 1_000_000L);
return Math.min(Http2Limits.FRAME_READ_TIMEOUT_MS, remainingMs);
}
private void checkSettingsTimeout() {
if (outstandingLocalSettings != 0
&& System.nanoTime() - oldestSettingsSentNanos >= settingsAckTimeoutMs * 1_000_000L) {
throw Http2Exception.SETTINGS_TIMEOUT;
}
}
private static boolean matches(byte[] buf, int off, byte[] expected) {
int different = 0;
for (int i = 0; i < expected.length; i++) different |= buf[off + i] ^ expected[i];
return different == 0;
}
private static int readUInt31(byte[] buf, int off) {
return readInt(buf, off) & 0x7FFF_FFFF;
}
private static int readInt(byte[] buf, int off) {
return ((buf[off] & 0xFF) << 24)
| ((buf[off + 1] & 0xFF) << 16)
| ((buf[off + 2] & 0xFF) << 8)
| (buf[off + 3] & 0xFF);
}
public Http2Settings peerSettings() {
return peerSettings;
}
public long connectionSendWindow() {
return connectionSendWindow;
}
public int peerLastStreamId() {
return peerLastStreamId;
}
public int peerErrorCode() {
return peerErrorCode;
}
void reset() {
peerSettings.reset();
connectionSendWindow = 65_535;
outstandingLocalSettings = 0;
oldestSettingsSentNanos = 0;
lastProcessedStreamId = 0;
peerLastStreamId = Integer.MAX_VALUE;
peerErrorCode = 0;
peerGoAway = false;
gracefulStarted = false;
gracefulFinished = false;
}
}
@@ -0,0 +1,168 @@
package dev.relism.flash.http2;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.WriteIntent;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
/** Reusable control-frame storage owned by one HTTP/2 connection. */
final class Http2ConnectionScratch {
private static final int CONTROL_SLOT_COUNT =
Http2Limits.MAX_PING_QUEUE_DEPTH + Http2Limits.MAX_SETTINGS_ACK_QUEUE_DEPTH + 8;
private static final int CONTROL_FRAME_CAPACITY = 256;
private final ControlIntent[] controls = new ControlIntent[CONTROL_SLOT_COUNT];
private final AtomicInteger pingResponses = new AtomicInteger();
private final AtomicInteger settingsAcks = new AtomicInteger();
private final byte[] preface = new byte[Http2Preface.clientPrefaceLength()];
Http2ConnectionScratch() {
for (int i = 0; i < controls.length; i++) {
controls[i] = new ControlIntent(this, CONTROL_FRAME_CAPACITY);
}
}
byte[] prefaceBuffer() {
return preface;
}
ControlIntent acquire(ControlKind kind) {
AtomicInteger counter = counter(kind);
int limit = limit(kind);
int queued = counter.incrementAndGet();
if (queued > limit) {
counter.decrementAndGet();
throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, kind + " queue exhausted");
}
for (ControlIntent intent : controls) {
if (intent.claim(kind)) return intent;
}
counter.decrementAndGet();
throw Http2Exception.of(Http2ErrorCode.ENHANCE_YOUR_CALM, "control-frame queue exhausted");
}
private void release(ControlIntent intent) {
counter(intent.kind).decrementAndGet();
intent.release();
}
private AtomicInteger counter(ControlKind kind) {
return kind == ControlKind.PING ? pingResponses : settingsAcks;
}
private static int limit(ControlKind kind) {
return kind == ControlKind.PING
? Http2Limits.MAX_PING_QUEUE_DEPTH
: Http2Limits.MAX_SETTINGS_ACK_QUEUE_DEPTH;
}
enum ControlKind {
PING,
SETTINGS_OR_OTHER
}
static final class ControlIntent implements WriteIntent {
private final Http2ConnectionScratch owner;
private final byte[] buffer;
private final AtomicBoolean claimed = new AtomicBoolean();
private volatile WriteIntent next;
private ControlKind kind;
private int length;
private ControlIntent(Http2ConnectionScratch owner, int capacity) {
this.owner = owner;
this.buffer = new byte[capacity];
}
private boolean claim(ControlKind kind) {
if (!claimed.compareAndSet(false, true)) return false;
this.kind = kind;
this.length = 0;
this.next = null;
return true;
}
void copy(byte[] source) {
System.arraycopy(source, 0, buffer, 0, source.length);
length = source.length;
}
void frame(FrameType type, int flags, int streamId, byte[] payload, int off, int len) {
if (9 + len > buffer.length) {
throw new IllegalArgumentException("control frame exceeds scratch capacity");
}
buffer[0] = (byte) (len >>> 16);
buffer[1] = (byte) (len >>> 8);
buffer[2] = (byte) len;
buffer[3] = (byte) type.code();
buffer[4] = (byte) flags;
writeUInt31(buffer, 5, streamId);
System.arraycopy(payload, off, buffer, 9, len);
length = 9 + len;
}
void goAway(int lastStreamId, Http2ErrorCode error, String debug) {
int debugLength =
Math.min(
debug.length(),
Math.min(Http2Limits.MAX_GOAWAY_DEBUG_DATA_LENGTH, buffer.length - 17));
int payloadLength = 8 + debugLength;
buffer[0] = 0;
buffer[1] = 0;
buffer[2] = (byte) payloadLength;
buffer[3] = (byte) FrameType.GOAWAY.code();
buffer[4] = 0;
writeUInt31(buffer, 5, 0);
writeUInt31(buffer, 9, lastStreamId);
writeUInt32(buffer, 13, error.code());
for (int i = 0; i < debugLength; i++) buffer[17 + i] = (byte) debug.charAt(i);
length = 17 + debugLength;
}
private static void writeUInt31(byte[] target, int off, int value) {
writeUInt32(target, off, value & 0x7FFF_FFFF);
}
private static void writeUInt32(byte[] target, int off, int value) {
target[off] = (byte) (value >>> 24);
target[off + 1] = (byte) (value >>> 16);
target[off + 2] = (byte) (value >>> 8);
target[off + 3] = (byte) value;
}
@Override
public byte[] buffer() {
return buffer;
}
@Override
public int offset() {
return 0;
}
@Override
public int length() {
return length;
}
@Override
public WriteIntent mpscNext() {
return next;
}
@Override
public void setMpscNext(WriteIntent next) {
this.next = next;
}
@Override
public void completed() {
owner.release(this);
}
private void release() {
next = null;
claimed.set(false);
}
}
}
@@ -0,0 +1,78 @@
package dev.relism.flash.http2;
import dev.relism.flash.bytes.Pairs;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameHeader;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.Padding;
import dev.relism.flash.http2.hpack.ContinuationAssembler;
import dev.relism.flash.http2.hpack.HeaderListSizeException;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
/** Composes frame fragment extraction, CONTINUATION assembly and HPACK decoding. */
final class Http2HeaderBlockDecoder {
private static final int PRIORITY_FIELDS_LENGTH = 5;
private final ContinuationAssembler assembler = new ContinuationAssembler();
private final HpackDecoder decoder = new HpackDecoder();
private final HpackHeaderBlock headers = new HpackHeaderBlock();
boolean insideHeaderBlock() {
return assembler.isActive();
}
/** Accepts HEADERS or CONTINUATION and returns true when a complete block was decoded. */
boolean accept(FrameHeader frame) {
if (assembler.isActive() && frame.type() != FrameType.CONTINUATION) {
throw Http2Exception.PROTOCOL_ERROR;
}
if (frame.type() == FrameType.HEADERS) {
begin(frame);
} else if (frame.type() == FrameType.CONTINUATION) {
assembler.continuation(
frame.streamId(),
frame.buffer(),
frame.payloadOffset(),
frame.length(),
FrameFlags.isEndHeaders(frame.flags()));
} else {
return false;
}
if (!assembler.isComplete()) return false;
headers.reset();
try {
decoder.decode(assembler.buffer(), 0, assembler.length(), headers);
} catch (HeaderListSizeException tooLarge) {
int streamId = assembler.streamId();
assembler.reset();
throw new Http2StreamException(
streamId, Http2ErrorCode.ENHANCE_YOUR_CALM, tooLarge.getMessage());
}
assembler.reset();
return true;
}
private void begin(FrameHeader frame) {
long unpadded =
Padding.unpad(
frame.buffer(),
frame.payloadOffset(),
frame.length(),
FrameFlags.isPadded(frame.flags()));
int fragmentOffset = Pairs.hi(unpadded);
int fragmentLength = Pairs.lo(unpadded);
if (FrameFlags.hasPriority(frame.flags())) {
if (fragmentLength < PRIORITY_FIELDS_LENGTH) throw Http2Exception.FRAME_SIZE_ERROR;
fragmentOffset += PRIORITY_FIELDS_LENGTH;
fragmentLength -= PRIORITY_FIELDS_LENGTH;
}
assembler.begin(
frame.streamId(),
frame.buffer(),
fragmentOffset,
fragmentLength,
FrameFlags.isEndHeaders(frame.flags()));
}
}
@@ -3,159 +3,176 @@ package dev.relism.flash.http2;
/**
* Every bound the HTTP/2 implementation enforces against a peer's input, in one place.
*
* Every wire-derived length, index, count, or size is checked against a named constant here
* never against an ad-hoc literal, and never by letting the
* underlying array or buffer throw on overrun. Each field's Javadoc names the specific attack
* or resource it bounds and, where one exists, the CVE.
* <p>Every wire-derived length, index, count, or size is checked against a named constant here
* never against an ad-hoc literal, and never by letting the underlying array or buffer throw on
* overrun. Each field's Javadoc names the specific attack or resource it bounds and, where one
* exists, the CVE.
*
* <p>These are compile-time defaults, not runtime configuration. A limit becomes configurable
* only when the operational need and its safe range are established.
* <p>These are compile-time defaults, not runtime configuration. A limit becomes configurable only
* when the operational need and its safe range are established.
*
* <p>Each field is introduced with the feature that enforces it; this class contains no unused
* placeholders.
*/
public final class Http2Limits {
private Http2Limits() {
}
private Http2Limits() {}
/**
* Maximum number of streams a single connection may have open concurrently. Advertised to
* the peer as {@code SETTINGS_MAX_CONCURRENT_STREAMS}. Bounds per-connection memory (each
* open stream owns a per-stream HPACK arena and request/response state) against a peer that
* simply opens streams and never closes them.
*/
public static final int MAX_CONCURRENT_STREAMS = 100;
/**
* Maximum number of streams a single connection may have open concurrently. Advertised to the
* peer as {@code SETTINGS_MAX_CONCURRENT_STREAMS}. Bounds per-connection memory (each open stream
* owns a per-stream HPACK arena and request/response state) against a peer that simply opens
* streams and never closes them.
*/
public static final int MAX_CONCURRENT_STREAMS = 100;
/**
* The largest frame payload we accept without the peer first raising it via our own
* {@code SETTINGS_MAX_FRAME_SIZE}. RFC 9113 §4.2 fixes the protocol default at 16384 and
* requires any advertised value to stay within {@code 16384..16777215}. Bounds the memory a
* single frame read can force us to hold.
*/
public static final int MAX_FRAME_SIZE_LOCAL = 16_384;
/**
* The largest frame payload we accept without the peer first raising it via our own {@code
* SETTINGS_MAX_FRAME_SIZE}. RFC 9113 §4.2 fixes the protocol default at 16384 and requires any
* advertised value to stay within {@code 16384..16777215}. Bounds the memory a single frame read
* can force us to hold.
*/
public static final int MAX_FRAME_SIZE_LOCAL = 16_384;
/**
* Maximum total size (name + value + 32 per RFC 7541 §4.1's accounting, summed over every
* header) of a decoded header list. Advertised as {@code SETTINGS_MAX_HEADER_LIST_SIZE}
* (RFC 9113 §6.5.2). This is the primary defence against an HPACK bomb: a small compressed
* block that references dynamic-table entries to expand into an enormous header list.
*/
public static final int MAX_HEADER_LIST_SIZE = 32_768;
/**
* Maximum total size (name + value + 32 per RFC 7541 §4.1's accounting, summed over every header)
* of a decoded header list. Advertised as {@code SETTINGS_MAX_HEADER_LIST_SIZE} (RFC 9113
* §6.5.2). This is the primary defence against an HPACK bomb: a small compressed block that
* references dynamic-table entries to expand into an enormous header list.
*/
public static final int MAX_HEADER_LIST_SIZE = 32_768;
/**
* Maximum number of CONTINUATION frames accepted for a single header block before the
* connection is torn down. Defence against CVE-2024-27316 (the "HTTP/2 CONTINUATION
* Flood"): a peer that never sets {@code END_HEADERS} can otherwise force unbounded
* decode/reassembly work per header block.
*/
public static final int MAX_CONTINUATION_FRAMES_PER_BLOCK = 8;
/**
* Maximum number of CONTINUATION frames accepted for a single header block before the connection
* is torn down. Defence against CVE-2024-27316 (the "HTTP/2 CONTINUATION Flood"): a peer that
* never sets {@code END_HEADERS} can otherwise force unbounded decode/reassembly work per header
* block.
*/
public static final int MAX_CONTINUATION_FRAMES_PER_BLOCK = 8;
/**
* Maximum number of {@code RST_STREAM} frames accepted from the peer within
* {@link #RESET_RATE_INTERVAL_MS}. Defence against CVE-2023-44487 ("HTTP/2 Rapid Reset"):
* opening a stream and immediately resetting it does not count against
* {@link #MAX_CONCURRENT_STREAMS}, so without a rate bound a peer can force unbounded
* per-stream setup/teardown work at effectively unlimited concurrency.
*/
public static final int MAX_RESET_STREAMS_PER_INTERVAL = 200;
/**
* Maximum number of {@code RST_STREAM} frames accepted from the peer within {@link
* #RESET_RATE_INTERVAL_MS}. Defence against CVE-2023-44487 ("HTTP/2 Rapid Reset"): opening a
* stream and immediately resetting it does not count against {@link #MAX_CONCURRENT_STREAMS}, so
* without a rate bound a peer can force unbounded per-stream setup/teardown work at effectively
* unlimited concurrency.
*/
public static final int MAX_RESET_STREAMS_PER_INTERVAL = 200;
/** The rolling window (milliseconds) over which {@link #MAX_RESET_STREAMS_PER_INTERVAL} is measured. */
public static final long RESET_RATE_INTERVAL_MS = 10_000;
/**
* The rolling window (milliseconds) over which {@link #MAX_RESET_STREAMS_PER_INTERVAL} is
* measured.
*/
public static final long RESET_RATE_INTERVAL_MS = 10_000;
/**
* Maximum number of new streams accepted from the peer within
* {@link #RESET_RATE_INTERVAL_MS}. A companion bound to
* {@link #MAX_RESET_STREAMS_PER_INTERVAL}: Rapid Reset defences that only count resets can
* still be bypassed by a peer that creates streams fast enough that the reset counter never
* saturates within any single window boundary.
*/
public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 400;
/**
* Maximum number of new streams accepted from the peer within {@link #RESET_RATE_INTERVAL_MS}. A
* companion bound to {@link #MAX_RESET_STREAMS_PER_INTERVAL}: Rapid Reset defences that only
* count resets can still be bypassed by a peer that creates streams fast enough that the reset
* counter never saturates within any single window boundary.
*/
public static final int MAX_STREAMS_CREATED_PER_INTERVAL = 400;
/**
* Maximum number of SETTINGS parameter entries accepted in a single SETTINGS frame. A
* SETTINGS frame is already bounded in byte length by {@link #MAX_FRAME_SIZE_LOCAL} (each
* entry is 6 bytes), but an explicit entry-count bound keeps the per-entry validation loop
* itself cheap to reason about and gives a distinct, loud rejection reason.
*/
public static final int MAX_SETTINGS_ENTRIES_PER_FRAME = 64;
/**
* Maximum number of SETTINGS parameter entries accepted in a single SETTINGS frame. A SETTINGS
* frame is already bounded in byte length by {@link #MAX_FRAME_SIZE_LOCAL} (each entry is 6
* bytes), but an explicit entry-count bound keeps the per-entry validation loop itself cheap to
* reason about and gives a distinct, loud rejection reason.
*/
public static final int MAX_SETTINGS_ENTRIES_PER_FRAME = 64;
/**
* Maximum number of outstanding (unanswered) PING responses queued for the writer. A PING
* flood forces a PONG per PING; without a bound, a peer that reads its own responses slowly
* can make us buffer unbounded PONG frames.
*/
public static final int MAX_PING_QUEUE_DEPTH = 64;
/** Maximum number of locally-sent SETTINGS frames awaiting acknowledgement. */
public static final int MAX_OUTSTANDING_LOCAL_SETTINGS = 8;
/**
* Maximum number of zero-length DATA frames accepted per stream. Zero-length DATA consumes
* no flow-control window, so window accounting does not bound it without this limit a
* peer can force unbounded per-frame dispatch/validation CPU work at zero cost to itself.
*/
public static final int MAX_EMPTY_DATA_FRAMES_PER_STREAM = 1_000;
/** Maximum time allowed for the peer to acknowledge a locally-sent SETTINGS frame. */
public static final long SETTINGS_ACK_TIMEOUT_MS = 10_000;
/**
* The value of {@code SETTINGS_INITIAL_WINDOW_SIZE} Flash advertises for every new stream:
* deliberately large (1 MiB, versus the RFC default of 65535) so that a normal-sized
*/
public static final int INITIAL_WINDOW_SIZE_LOCAL = 1_048_576;
/**
* Maximum number of SETTINGS acknowledgements waiting behind a blocked socket writer. This
* prevents a peer from turning a stream of empty SETTINGS frames into an unbounded queue of
* mandatory responses.
*/
public static final int MAX_SETTINGS_ACK_QUEUE_DEPTH = 64;
/**
* The connection-level flow-control window Flash advertises. Sized above
* {@link #INITIAL_WINDOW_SIZE_LOCAL} so a single active stream is never bottlenecked by the
* connection window before its own stream window, but well below
* {@code MAX_CONCURRENT_STREAMS * INITIAL_WINDOW_SIZE_LOCAL} real traffic is never all
* streams simultaneously saturating their windows, and sizing for that worst case would
* commit 100 MiB of receive window to every connection regardless of load.
*/
public static final int CONNECTION_WINDOW_SIZE_LOCAL = 16 * 1_048_576;
/** Maximum diagnostic bytes included in an outbound GOAWAY frame. */
public static final int MAX_GOAWAY_DEBUG_DATA_LENGTH = 128;
/**
* The HPACK dynamic table size Flash's decoder honours, in bytes of RFC 7541 §4.1
* accounting. RFC 7541's protocol default. The encoder never uses a dynamic table at all
*/
public static final int HPACK_DYNAMIC_TABLE_SIZE_LOCAL = 4_096;
/**
* Maximum number of outstanding (unanswered) PING responses queued for the writer. A PING flood
* forces a PONG per PING; without a bound, a peer that reads its own responses slowly can make us
* buffer unbounded PONG frames.
*/
public static final int MAX_PING_QUEUE_DEPTH = 64;
/**
* Maximum length, in decoded bytes, of a single HPACK string literal. Applied during
* Huffman decode as bytes are produced, not to the encoded length a Huffman string can
* expand by roughly 8/5, so bounding only the encoded length would let a compact input
* still decode past this limit.
*/
public static final int MAX_HPACK_STRING_LENGTH = 8_192;
/**
* Maximum number of zero-length DATA frames accepted per stream. Zero-length DATA consumes no
* flow-control window, so window accounting does not bound it without this limit a peer can
* force unbounded per-frame dispatch/validation CPU work at zero cost to itself.
*/
public static final int MAX_EMPTY_DATA_FRAMES_PER_STREAM = 1_000;
/**
* Maximum time, in milliseconds, allowed between a HEADERS frame's arrival and the header
* block's completion (its {@code END_HEADERS} flag, possibly after CONTINUATION frames). A
* peer that starts a header block and then stalls indefinitely would otherwise hold the
* per-stream arena and the connection's HPACK assembly buffer forever.
*/
public static final long HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS = 10_000;
/**
* The value of {@code SETTINGS_INITIAL_WINDOW_SIZE} Flash advertises for every new stream:
* deliberately large (1 MiB, versus the RFC default of 65535) so that a normal-sized
*/
public static final int INITIAL_WINDOW_SIZE_LOCAL = 1_048_576;
/**
* Maximum time, in milliseconds, a stream may remain open with no frame activity in either
* direction. Bounds resource pinning by a peer that opens a stream and then goes silent
* without closing it the h2 equivalent of the h1 slowloris defence in
* {@code FlashConfiguration.idleKeepAliveTimeoutMs}.
*/
public static final long STREAM_IDLE_TIMEOUT_MS = 60_000;
/**
* The connection-level flow-control window Flash advertises. Sized above {@link
* #INITIAL_WINDOW_SIZE_LOCAL} so a single active stream is never bottlenecked by the connection
* window before its own stream window, but well below {@code MAX_CONCURRENT_STREAMS *
* INITIAL_WINDOW_SIZE_LOCAL} real traffic is never all streams simultaneously saturating their
* windows, and sizing for that worst case would commit 100 MiB of receive window to every
* connection regardless of load.
*/
public static final int CONNECTION_WINDOW_SIZE_LOCAL = 16 * 1_048_576;
/**
* Maximum time, in milliseconds, {@code Http2FrameWriter} may spend blocked inside a single
* socket write. A blocking write is unavoidable when the kernel send buffer is full and the
* peer is not reading (that peer holds the connection's single writer lock for the duration
* see {@code WRITER.md}), but it must not be unbounded: a peer that simply stops reading
* would otherwise let a single stalled connection wedge the writer forever. Enforced via a
* background reaper interrupting the blocked thread past the deadline, not
* {@code Socket#setSoTimeout} that option bounds reads, not writes.
*/
public static final long WRITE_TIMEOUT_MS = 30_000;
/**
* The HPACK dynamic table size Flash's decoder honours, in bytes of RFC 7541 §4.1 accounting. RFC
* 7541's protocol default. The encoder never uses a dynamic table at all
*/
public static final int HPACK_DYNAMIC_TABLE_SIZE_LOCAL = 4_096;
/**
* Maximum 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
* without it, a peer that sends 9 header bytes and then never sends the declared payload
* would hold this connection's frame reader waiting forever.
*/
public static final long FRAME_READ_TIMEOUT_MS = 20_000;
/**
* Maximum length, in decoded bytes, of a single HPACK string literal. Applied during Huffman
* decode as bytes are produced, not to the encoded length a Huffman string can expand by
* roughly 8/5, so bounding only the encoded length would let a compact input still decode past
* this limit.
*/
public static final int MAX_HPACK_STRING_LENGTH = 8_192;
/**
* Maximum time, in milliseconds, allowed between a HEADERS frame's arrival and the header block's
* completion (its {@code END_HEADERS} flag, possibly after CONTINUATION frames). A peer that
* starts a header block and then stalls indefinitely would otherwise hold the per-stream arena
* and the connection's HPACK assembly buffer forever.
*/
public static final long HEADER_BLOCK_ASSEMBLY_TIMEOUT_MS = 10_000;
/**
* Maximum time, in milliseconds, a stream may remain open with no frame activity in either
* direction. Bounds resource pinning by a peer that opens a stream and then goes silent without
* closing it the h2 equivalent of the h1 slowloris defence in {@code
* FlashConfiguration.idleKeepAliveTimeoutMs}.
*/
public static final long STREAM_IDLE_TIMEOUT_MS = 60_000;
/**
* Maximum time, in milliseconds, {@code Http2FrameWriter} may spend blocked inside a single
* socket write. A blocking write is unavoidable when the kernel send buffer is full and the peer
* is not reading (that peer holds the connection's single writer lock for the duration see
* {@code WRITER.md}), but it must not be unbounded: a peer that simply stops reading would
* otherwise let a single stalled connection wedge the writer forever. Enforced via a background
* reaper interrupting the blocked thread past the deadline, not {@code Socket#setSoTimeout}
* that option bounds reads, not writes.
*/
public static final long WRITE_TIMEOUT_MS = 30_000;
/**
* Maximum time, in milliseconds, {@code Http2FrameReader} may wait for a single frame's
* header and payload to fully arrive. Bounds the same slowloris-shaped hazard {@code
* without it, a peer that sends 9 header bytes and then never sends the declared payload
* would hold this connection's frame reader waiting forever.
*/
public static final long FRAME_READ_TIMEOUT_MS = 20_000;
}
@@ -0,0 +1,81 @@
package dev.relism.flash.http2;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameWriteBuffer;
import java.nio.charset.StandardCharsets;
/** Byte-exact client preface and immutable server startup frames, compiled once at class load. */
final class Http2Preface {
private static final byte[] CLIENT_PREFACE =
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
private static final byte[] SERVER_SETTINGS = buildServerSettings();
private static final byte[] SETTINGS_ACK = frame(FrameType.SETTINGS, FrameFlags.ACK, 0, 0);
private static final byte[] INITIAL_CONNECTION_WINDOW = buildInitialConnectionWindow();
private Http2Preface() {}
static int clientPrefaceLength() {
return CLIENT_PREFACE.length;
}
static boolean matchesClientPreface(byte[] candidate) {
if (candidate.length != CLIENT_PREFACE.length) return false;
int different = 0;
for (int i = 0; i < CLIENT_PREFACE.length; i++) {
different |= candidate[i] ^ CLIENT_PREFACE[i];
}
return different == 0;
}
static byte[] serverSettings() {
return SERVER_SETTINGS;
}
static byte[] settingsAck() {
return SETTINGS_ACK;
}
static byte[] initialConnectionWindow() {
return INITIAL_CONNECTION_WINDOW;
}
private static byte[] buildServerSettings() {
ByteWriter bytes = new ByteWriter(64);
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
frame.beginFrame(FrameType.SETTINGS, 0, 0);
setting(bytes, Http2Settings.HEADER_TABLE_SIZE, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL);
setting(bytes, Http2Settings.ENABLE_PUSH, 0);
setting(bytes, Http2Settings.MAX_CONCURRENT_STREAMS, Http2Limits.MAX_CONCURRENT_STREAMS);
setting(bytes, Http2Settings.INITIAL_WINDOW_SIZE, Http2Limits.INITIAL_WINDOW_SIZE_LOCAL);
setting(bytes, Http2Settings.MAX_HEADER_LIST_SIZE, Http2Limits.MAX_HEADER_LIST_SIZE);
frame.endFrame();
return copy(bytes);
}
private static byte[] buildInitialConnectionWindow() {
int increment = Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL - 65_535;
return frame(FrameType.WINDOW_UPDATE, 0, 0, increment);
}
private static byte[] frame(FrameType type, int flags, int streamId, int payload) {
ByteWriter bytes = new ByteWriter(16);
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
frame.beginFrame(type, flags, streamId);
if (type == FrameType.WINDOW_UPDATE) bytes.writeUInt31(payload);
frame.endFrame();
return copy(bytes);
}
private static void setting(ByteWriter bytes, int id, int value) {
bytes.writeUInt16(id);
bytes.writeUInt32(value);
}
private static byte[] copy(ByteWriter bytes) {
byte[] result = new byte[bytes.length()];
System.arraycopy(bytes.array(), 0, result, 0, result.length);
return result;
}
}
@@ -0,0 +1,148 @@
package dev.relism.flash.http2;
/**
* The peer's HTTP/2 SETTINGS state. A received payload is validated completely before any value is
* applied, so a malformed parameter cannot leave a partially-updated connection.
*/
public final class Http2Settings {
public static final int HEADER_TABLE_SIZE = 0x1;
public static final int ENABLE_PUSH = 0x2;
public static final int MAX_CONCURRENT_STREAMS = 0x3;
public static final int INITIAL_WINDOW_SIZE = 0x4;
public static final int MAX_FRAME_SIZE = 0x5;
public static final int MAX_HEADER_LIST_SIZE = 0x6;
public static final int DEFAULT_HEADER_TABLE_SIZE = 4_096;
public static final int DEFAULT_INITIAL_WINDOW_SIZE = 65_535;
public static final int DEFAULT_MAX_FRAME_SIZE = 16_384;
/**
* Applies an INITIAL_WINDOW_SIZE delta to every open stream. Implementations must validate all
* resulting windows before changing any of them; negative results are valid, while a result above
* {@link Integer#MAX_VALUE} is a connection FLOW_CONTROL_ERROR.
*/
@FunctionalInterface
public interface StreamWindowUpdater {
void applyInitialWindowDelta(int delta);
}
private int headerTableSize = DEFAULT_HEADER_TABLE_SIZE;
private boolean pushEnabled = true;
private long maxConcurrentStreams = 0xFFFF_FFFFL;
private int initialWindowSize = DEFAULT_INITIAL_WINDOW_SIZE;
private int maxFrameSize = DEFAULT_MAX_FRAME_SIZE;
private long maxHeaderListSize = 0xFFFF_FFFFL;
/** Validates and applies one SETTINGS payload. Unknown identifiers are ignored. */
public void apply(byte[] payload, int off, int len, StreamWindowUpdater streamWindows) {
if (len % 6 != 0) throw Http2Exception.FRAME_SIZE_ERROR;
int entries = len / 6;
if (entries > Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME) {
throw Http2Exception.of(
Http2ErrorCode.ENHANCE_YOUR_CALM, "too many SETTINGS entries: " + entries);
}
checkRange(payload, off, len);
int nextHeaderTableSize = headerTableSize;
boolean nextPushEnabled = pushEnabled;
long nextMaxConcurrentStreams = maxConcurrentStreams;
int nextInitialWindowSize = initialWindowSize;
int nextMaxFrameSize = maxFrameSize;
long nextMaxHeaderListSize = maxHeaderListSize;
for (int pos = off; pos < off + len; pos += 6) {
int id = readUInt16(payload, pos);
long value = readUInt32(payload, pos + 2);
validate(id, value);
switch (id) {
case HEADER_TABLE_SIZE ->
nextHeaderTableSize = (int) Math.min(value, Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL);
case ENABLE_PUSH -> nextPushEnabled = value == 1;
case MAX_CONCURRENT_STREAMS -> nextMaxConcurrentStreams = value;
case INITIAL_WINDOW_SIZE -> nextInitialWindowSize = (int) value;
case MAX_FRAME_SIZE -> nextMaxFrameSize = (int) value;
case MAX_HEADER_LIST_SIZE -> nextMaxHeaderListSize = value;
default -> {
// RFC 9113 §6.5.2: ignore unknown settings.
}
}
}
streamWindows.applyInitialWindowDelta(nextInitialWindowSize - initialWindowSize);
headerTableSize = nextHeaderTableSize;
pushEnabled = nextPushEnabled;
maxConcurrentStreams = nextMaxConcurrentStreams;
initialWindowSize = nextInitialWindowSize;
maxFrameSize = nextMaxFrameSize;
maxHeaderListSize = nextMaxHeaderListSize;
}
private static void validate(int id, long value) {
switch (id) {
case ENABLE_PUSH -> {
if (value > 1) throw Http2Exception.PROTOCOL_ERROR;
}
case INITIAL_WINDOW_SIZE -> {
if (value > Integer.MAX_VALUE) throw Http2Exception.FLOW_CONTROL_ERROR;
}
case MAX_FRAME_SIZE -> {
if (value < 16_384 || value > 16_777_215) {
throw Http2Exception.PROTOCOL_ERROR;
}
}
default -> {
// HEADER_TABLE_SIZE, MAX_CONCURRENT_STREAMS and MAX_HEADER_LIST_SIZE accept
// every unsigned 32-bit value. Unknown identifiers are ignored by the RFC.
}
}
}
private static void checkRange(byte[] payload, int off, int len) {
if (off < 0 || len < 0 || off > payload.length - len) {
throw new IndexOutOfBoundsException("invalid SETTINGS payload range");
}
}
private static int readUInt16(byte[] buf, int off) {
return ((buf[off] & 0xFF) << 8) | (buf[off + 1] & 0xFF);
}
private static long readUInt32(byte[] buf, int off) {
return ((long) (buf[off] & 0xFF) << 24)
| ((long) (buf[off + 1] & 0xFF) << 16)
| ((long) (buf[off + 2] & 0xFF) << 8)
| (buf[off + 3] & 0xFFL);
}
public int headerTableSize() {
return headerTableSize;
}
public boolean pushEnabled() {
return pushEnabled;
}
public long maxConcurrentStreams() {
return maxConcurrentStreams;
}
public int initialWindowSize() {
return initialWindowSize;
}
public int maxFrameSize() {
return maxFrameSize;
}
public long maxHeaderListSize() {
return maxHeaderListSize;
}
void reset() {
headerTableSize = DEFAULT_HEADER_TABLE_SIZE;
pushEnabled = true;
maxConcurrentStreams = 0xFFFF_FFFFL;
initialWindowSize = DEFAULT_INITIAL_WINDOW_SIZE;
maxFrameSize = DEFAULT_MAX_FRAME_SIZE;
maxHeaderListSize = 0xFFFF_FFFFL;
}
}
@@ -1,86 +1,113 @@
package dev.relism.flash.http2.frame;
/**
* The 10 HTTP/2 frame types (RFC 9113 §6), plus the shared per-type validation rules
* {@link FrameValidator} enforces. Values above {@code 0x9} are not assigned a constant here
* RFC 9113 §4.1 requires unknown types to be silently ignored (read and discard the payload),
* which {@link Http2FrameReader}'s caller implements by checking {@code type >
* FrameType.maxKnown()} rather than by this enum growing an {@code UNKNOWN} member (an
* {@code UNKNOWN} constant would misleadingly suggest "a recognised category of unrecognised
* frame", when the correct handling is simply "not this table, skip it").
* The 10 HTTP/2 frame types (RFC 9113 §6), plus the shared per-type validation rules {@link
* FrameValidator} enforces. Values above {@code 0x9} are not assigned a constant here RFC 9113
* §4.1 requires unknown types to be silently ignored (read and discard the payload), which {@link
* Http2FrameReader}'s caller implements by checking {@code type > FrameType.maxKnown()} rather than
* by this enum growing an {@code UNKNOWN} member (an {@code UNKNOWN} constant would misleadingly
* suggest "a recognised category of unrecognised frame", when the correct handling is simply "not
* this table, skip it").
*
* Each constant carries the RFC-mandated payload length bounds, whether a zero stream id is
* <p>Each constant carries the RFC-mandated payload length bounds, whether a zero stream id is
* required/forbidden/either, and whether the frame counts toward the CONTINUATION-flood guard
* ({@code EX}-style defence, {@code Http2Limits#MAX_CONTINUATION_FRAMES_PER_BLOCK}) see
* {@link FrameValidator} for how these are applied and the specific RFC citation per rule.
* (bounded by {@code Http2Limits#MAX_CONTINUATION_FRAMES_PER_BLOCK}) see {@link FrameValidator}
* for how these are applied and the specific RFC citation per rule.
*/
public enum FrameType {
/** RFC 9113 §6.1. Stream body bytes. Stream id required. Length: 0..MAX_FRAME_SIZE. */
DATA(0x0, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
/** RFC 9113 §6.2. Header block fragment (HPACK). Stream id required. */
HEADERS(0x1, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
PRIORITY(0x2, 5, 5, StreamIdRule.REQUIRED),
/** RFC 9113 §6.4. Stream-level error. Exactly 4 bytes (the error code). Stream id required. */
RST_STREAM(0x3, 4, 4, StreamIdRule.REQUIRED),
/** RFC 9113 §6.5. Connection-level parameters. Length must be a multiple of 6. Stream id must be 0. */
SETTINGS(0x4, 0, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
/** RFC 9113 §6.6. Never sent (Flash advertises {@code SETTINGS_ENABLE_PUSH=0}); receiving one from a client is a protocol error. */
PUSH_PROMISE(0x5, 4, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
/** RFC 9113 §6.7. Connection liveness / RTT probe. Exactly 8 bytes of opaque data. Stream id must be 0. */
PING(0x6, 8, 8, StreamIdRule.FORBIDDEN),
/** RFC 9113 §6.8. Connection shutdown notice. At least 8 bytes (last-stream-id + error code). Stream id must be 0. */
GOAWAY(0x7, 8, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
/** RFC 9113 §6.9. Flow-control window increment. Exactly 4 bytes. Stream id may be either (0 = connection window). */
WINDOW_UPDATE(0x8, 4, 4, StreamIdRule.EITHER),
/** RFC 9113 §6.10. Continuation of a header block that did not fit one HEADERS/PUSH_PROMISE frame. Stream id required. */
CONTINUATION(0x9, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED);
/** RFC 9113 §6.1. Stream body bytes. Stream id required. Length: 0..MAX_FRAME_SIZE. */
DATA(0x0, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
/** RFC 9113 §6.2. Header block fragment (HPACK). Stream id required. */
HEADERS(0x1, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
PRIORITY(0x2, 5, 5, StreamIdRule.REQUIRED),
/** RFC 9113 §6.4. Stream-level error. Exactly 4 bytes (the error code). Stream id required. */
RST_STREAM(0x3, 4, 4, StreamIdRule.REQUIRED),
/**
* RFC 9113 §6.5. Connection-level parameters. Length must be a multiple of 6. Stream id must be
* 0.
*/
SETTINGS(0x4, 0, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
/**
* RFC 9113 §6.6. Never sent (Flash advertises {@code SETTINGS_ENABLE_PUSH=0}); receiving one from
* a client is a protocol error.
*/
PUSH_PROMISE(0x5, 4, Integer.MAX_VALUE, StreamIdRule.REQUIRED),
/**
* RFC 9113 §6.7. Connection liveness / RTT probe. Exactly 8 bytes of opaque data. Stream id must
* be 0.
*/
PING(0x6, 8, 8, StreamIdRule.FORBIDDEN),
/**
* RFC 9113 §6.8. Connection shutdown notice. At least 8 bytes (last-stream-id + error code).
* Stream id must be 0.
*/
GOAWAY(0x7, 8, Integer.MAX_VALUE, StreamIdRule.FORBIDDEN),
/**
* RFC 9113 §6.9. Flow-control window increment. Exactly 4 bytes. Stream id may be either (0 =
* connection window).
*/
WINDOW_UPDATE(0x8, 4, 4, StreamIdRule.EITHER),
/**
* RFC 9113 §6.10. Continuation of a header block that did not fit one HEADERS/PUSH_PROMISE frame.
* Stream id required.
*/
CONTINUATION(0x9, 0, Integer.MAX_VALUE, StreamIdRule.REQUIRED);
/** Whether a frame type requires stream id 0, requires it non-zero, or permits either. */
public enum StreamIdRule { REQUIRED, FORBIDDEN, EITHER }
/** Whether a frame type requires stream id 0, requires it non-zero, or permits either. */
public enum StreamIdRule {
REQUIRED,
FORBIDDEN,
EITHER
}
private static final FrameType[] BY_CODE = new FrameType[values().length];
private static final FrameType[] BY_CODE = new FrameType[values().length];
static {
for (FrameType t : values()) {
BY_CODE[t.code] = t;
}
static {
for (FrameType t : values()) {
BY_CODE[t.code] = t;
}
}
private final int code;
private final int minLength;
private final int maxLength;
private final StreamIdRule streamIdRule;
private final int code;
private final int minLength;
private final int maxLength;
private final StreamIdRule streamIdRule;
FrameType(int code, int minLength, int maxLength, StreamIdRule streamIdRule) {
this.code = code;
this.minLength = minLength;
this.maxLength = maxLength;
this.streamIdRule = streamIdRule;
}
FrameType(int code, int minLength, int maxLength, StreamIdRule streamIdRule) {
this.code = code;
this.minLength = minLength;
this.maxLength = maxLength;
this.streamIdRule = streamIdRule;
}
public int code() {
return code;
}
public int code() {
return code;
}
public int minLength() {
return minLength;
}
public int minLength() {
return minLength;
}
public int maxLength() {
return maxLength;
}
public int maxLength() {
return maxLength;
}
public StreamIdRule streamIdRule() {
return streamIdRule;
}
public StreamIdRule streamIdRule() {
return streamIdRule;
}
/** The highest type code this enum recognises — anything above must be ignored per RFC 9113 §4.1. */
public static int maxKnown() {
return CONTINUATION.code;
}
/**
* The highest type code this enum recognises anything above must be ignored per RFC 9113 §4.1.
*/
public static int maxKnown() {
return CONTINUATION.code;
}
/** Looks up the constant for a wire type byte, or {@code null} if it is an unrecognised (to-be-ignored) type. */
public static FrameType fromCode(int code) {
return (code >= 0 && code < BY_CODE.length) ? BY_CODE[code] : null;
}
/**
* Looks up the constant for a wire type byte, or {@code null} if it is an unrecognised
* (to-be-ignored) type.
*/
public static FrameType fromCode(int code) {
return (code >= 0 && code < BY_CODE.length) ? BY_CODE[code] : null;
}
}
@@ -3,7 +3,6 @@ package dev.relism.flash.http2.frame;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.EOFException;
import java.io.IOException;
import java.util.Arrays;
@@ -11,16 +10,18 @@ import java.util.Arrays;
/**
* Reads length-prefixed HTTP/2 frames from one connection's {@link BufferedByteSource}. Simpler
* than {@code RequestParser} by construction: HTTP/2 frames declare their length up front (the
* 9-byte header), so nothing is ever scanned for {@code Http2FrameReader} only ever needs to
* know "do I have N bytes yet", never "where does this end".
* 9-byte header), so nothing is ever scanned for {@code Http2FrameReader} only ever needs to know
* "do I have N bytes yet", never "where does this end".
*
* <h3>Buffer discipline</h3>
*
* One growable {@code byte[]} per connection, reused across every frame the same
* compact-before-grow discipline {@code RequestParser}'s own buffer uses. A frame's declared
* length is checked against {@link Http2Limits#MAX_FRAME_SIZE_LOCAL} <em>before</em> the buffer
* compact-before-grow discipline {@code RequestParser}'s own buffer uses. A frame's declared length
* is checked against {@link Http2Limits#MAX_FRAME_SIZE_LOCAL} <em>before</em> the buffer
* length-check, not after an allocation already paid for it.
*
* <h3>Usage</h3>
*
* <pre>{@code
* FrameHeader header = reader.readFrame();
* if (header == null) { /* clean EOF between frames connection closing *\/ }
@@ -29,103 +30,126 @@ import java.util.Arrays;
* }</pre>
*
* <h3>Thread-safety</h3>
* Not thread-safe exactly one virtual thread (the connection's demux loop) ever calls this,
* the same invariant every other per-connection reader in this codebase assumes.
*
* Not thread-safe exactly one virtual thread (the connection's demux loop) ever calls this, the
* same invariant every other per-connection reader in this codebase assumes.
*/
public final class Http2FrameReader {
private static final int FRAME_HEADER_SIZE = 9;
private static final int INITIAL_BUFFER_SIZE = 16 * 1024;
private static final int FRAME_HEADER_SIZE = 9;
private static final int INITIAL_BUFFER_SIZE = 16 * 1024;
private final BufferedByteSource in;
private final FrameHeader header = new FrameHeader();
private byte[] buffer;
private int base; // offset of the first unconsumed byte
private int totalRead; // count of valid unconsumed bytes at [base, base + totalRead)
private final BufferedByteSource in;
private final FrameHeader header = new FrameHeader();
private byte[] buffer;
private int base; // offset of the first unconsumed byte
private int totalRead; // count of valid unconsumed bytes at [base, base + totalRead)
private long frameDeadlineNanos;
public Http2FrameReader(BufferedByteSource in) {
this(in, INITIAL_BUFFER_SIZE);
public Http2FrameReader(BufferedByteSource in) {
this(in, INITIAL_BUFFER_SIZE);
}
public Http2FrameReader(BufferedByteSource in, int initialBufferSize) {
this.in = in;
this.buffer = new byte[Math.max(initialBufferSize, FRAME_HEADER_SIZE)];
}
/**
* Reads the next frame's header and payload, bounded by {@link
* Http2Limits#FRAME_READ_TIMEOUT_MS}, and returns the reused {@link FrameHeader} flyweight
* positioned over it or {@code null} on a clean EOF between frames (the peer closed the
* connection while nothing was in flight; not an error).
*
* <p>The caller MUST call {@link #consumeFrame()} exactly once after processing this frame (or
* deciding to discard it) and before calling this method again.
*
* @throws Http2Exception if the declared length exceeds {@link Http2Limits#MAX_FRAME_SIZE_LOCAL}
* @throws EOFException if the connection closes after a frame has already started arriving
* @throws java.net.SocketTimeoutException if {@link Http2Limits#FRAME_READ_TIMEOUT_MS} elapses
*/
public FrameHeader readFrame() throws IOException {
return readFrame(Http2Limits.FRAME_READ_TIMEOUT_MS);
}
/** Reads one frame using a caller-supplied upper bound for this frame's absolute deadline. */
public FrameHeader readFrame(long timeoutMs) throws IOException {
if (timeoutMs <= 0) throw new IllegalArgumentException("timeoutMs must be positive");
long now = System.nanoTime();
if (frameDeadlineNanos == 0) {
frameDeadlineNanos = now + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L;
}
public Http2FrameReader(BufferedByteSource in, int initialBufferSize) {
this.in = in;
this.buffer = new byte[Math.max(initialBufferSize, FRAME_HEADER_SIZE)];
in.setDeadline(Math.min(frameDeadlineNanos, now + timeoutMs * 1_000_000L));
try {
if (!ensureAvailable(FRAME_HEADER_SIZE)) {
frameDeadlineNanos = 0;
return null; // clean EOF: nothing buffered yet, peer closed between frames
}
int declaredLength = decodeLength(buffer, base);
// never causes an oversized allocation, only a rejection.
if (declaredLength > Http2Limits.MAX_FRAME_SIZE_LOCAL) {
throw Http2Exception.FRAME_SIZE_ERROR;
}
ensureAvailable(FRAME_HEADER_SIZE + declaredLength);
header.reset(buffer, base);
return header;
} catch (java.net.SocketTimeoutException timeout) {
if (totalRead == 0) frameDeadlineNanos = 0;
throw timeout;
} finally {
in.clearDeadline();
}
}
/**
* Reads the next frame's header and payload, bounded by
* {@link Http2Limits#FRAME_READ_TIMEOUT_MS}, and returns the reused {@link FrameHeader}
* flyweight positioned over it or {@code null} on a clean EOF between frames (the peer
* closed the connection while nothing was in flight; not an error).
*
* <p>The caller MUST call {@link #consumeFrame()} exactly once after processing this frame
* (or deciding to discard it) and before calling this method again.
*
* @throws Http2Exception if the declared length exceeds {@link Http2Limits#MAX_FRAME_SIZE_LOCAL}
* @throws EOFException if the connection closes after a frame has already started arriving
* @throws java.net.SocketTimeoutException if {@link Http2Limits#FRAME_READ_TIMEOUT_MS} elapses
*/
public FrameHeader readFrame() throws IOException {
in.setDeadline(System.nanoTime() + Http2Limits.FRAME_READ_TIMEOUT_MS * 1_000_000L);
try {
if (!ensureAvailable(FRAME_HEADER_SIZE)) {
return null; // clean EOF: nothing buffered yet, peer closed between frames
}
int declaredLength = decodeLength(buffer, base);
// never causes an oversized allocation, only a rejection.
if (declaredLength > Http2Limits.MAX_FRAME_SIZE_LOCAL) {
throw Http2Exception.FRAME_SIZE_ERROR;
}
ensureAvailable(FRAME_HEADER_SIZE + declaredLength);
header.reset(buffer, base);
return header;
} finally {
in.clearDeadline();
/** Advances past the frame last returned by {@link #readFrame()}. Zero-copy, zero-allocation. */
public void consumeFrame() {
int consumed = FRAME_HEADER_SIZE + header.length();
base += consumed;
totalRead -= consumed;
if (totalRead == 0) {
base = 0; // nothing buffered reset to the front rather than drifting forever
}
frameDeadlineNanos = 0;
}
/** Whether a partially received frame exhausted its non-renewable absolute deadline. */
public boolean frameDeadlineExpired() {
return totalRead != 0 && System.nanoTime() >= frameDeadlineNanos;
}
private static int decodeLength(byte[] buf, int off) {
int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF;
return (b0 << 16) | (b1 << 8) | b2;
}
/**
* Ensures at least {@code need} bytes are available starting at {@link #base}, growing or
* compacting the buffer as necessary. Returns {@code false} only for a clean EOF with nothing at
* all buffered yet (the between-frames case); an EOF after any bytes of the current frame have
* already arrived is a genuine truncation and throws.
*/
private boolean ensureAvailable(int need) throws IOException {
while (totalRead < need) {
if (base + need > buffer.length) {
if (base > 0) {
// Compact: slide unconsumed bytes to the front frees room without growing.
System.arraycopy(buffer, base, buffer, 0, totalRead);
base = 0;
} else {
// need <= 9 + MAX_FRAME_SIZE_LOCAL always, by readFrame()'s own check before
// the payload-sized call grow exactly enough, never unbounded.
int grown = buffer.length;
while (grown < need) grown *= 2;
buffer = Arrays.copyOf(buffer, grown);
}
}
int n = in.read(buffer, base + totalRead, buffer.length - base - totalRead);
if (n < 0) {
if (totalRead == 0) return false;
throw new EOFException(
"connection closed mid-frame (" + totalRead + "/" + need + " bytes read)");
}
totalRead += n;
}
/** Advances past the frame last returned by {@link #readFrame()}. Zero-copy, zero-allocation. */
public void consumeFrame() {
int consumed = FRAME_HEADER_SIZE + header.length();
base += consumed;
totalRead -= consumed;
if (totalRead == 0) {
base = 0; // nothing buffered reset to the front rather than drifting forever
}
}
private static int decodeLength(byte[] buf, int off) {
int b0 = buf[off] & 0xFF, b1 = buf[off + 1] & 0xFF, b2 = buf[off + 2] & 0xFF;
return (b0 << 16) | (b1 << 8) | b2;
}
/**
* Ensures at least {@code need} bytes are available starting at {@link #base}, growing or
* compacting the buffer as necessary. Returns {@code false} only for a clean EOF with
* nothing at all buffered yet (the between-frames case); an EOF after any bytes of the
* current frame have already arrived is a genuine truncation and throws.
*/
private boolean ensureAvailable(int need) throws IOException {
while (totalRead < need) {
if (base + need > buffer.length) {
if (base > 0) {
// Compact: slide unconsumed bytes to the front frees room without growing.
System.arraycopy(buffer, base, buffer, 0, totalRead);
base = 0;
} else {
// need <= 9 + MAX_FRAME_SIZE_LOCAL always, by readFrame()'s own check before
// the payload-sized call grow exactly enough, never unbounded.
int grown = buffer.length;
while (grown < need) grown *= 2;
buffer = Arrays.copyOf(buffer, grown);
}
}
int n = in.read(buffer, base + totalRead, buffer.length - base - totalRead);
if (n < 0) {
if (totalRead == 0) return false;
throw new EOFException("connection closed mid-frame (" + totalRead + "/" + need + " bytes read)");
}
totalRead += n;
}
return true;
}
return true;
}
}
@@ -1,7 +1,6 @@
package dev.relism.flash.http2.frame;
import dev.relism.flash.http2.Http2Limits;
import java.io.IOException;
import java.io.InterruptedIOException;
import java.util.Set;
@@ -16,34 +15,36 @@ import java.util.concurrent.locks.ReentrantLock;
*
* <h2>The design, three layers</h2>
*
* <p><b>Layer 1 serialize outside the lock.</b> By the time {@link #write} is called, the
* caller has already built its complete frame into a buffer it owns (see {@link WriteIntent}).
* This writer never serializes anything; it only ever issues one bulk
* {@code sink.write(buffer, offset, length)} call while holding the lock never many small
* writes, which would turn "hold the lock" into "hold the lock across a serialization pass."
* <p><b>Layer 1 serialize outside the lock.</b> By the time {@link #write} is called, the caller
* has already built its complete frame into a buffer it owns (see {@link WriteIntent}). This writer
* never serializes anything; it only ever issues one bulk {@code sink.write(buffer, offset,
* length)} call while holding the lock never many small writes, which would turn "hold the lock"
* into "hold the lock across a serialization pass."
*
* <p><b>Layer 2 {@link ReentrantLock}, never {@code synchronized}.</b> On Java 21, a virtual
* thread blocking inside {@code synchronized} pins its carrier platform thread; blocking on a
* {@link ReentrantLock} unmounts it instead (JEP 491, which removes the {@code synchronized}
* {@code ReentrantLock} is also load-bearing here for a second reason {@code synchronized}
* cannot offer: {@link ReentrantLock#tryLock()}.
* {@link ReentrantLock} unmounts it instead (JEP 491, which removes the {@code synchronized} {@code
* ReentrantLock} is also load-bearing here for a second reason {@code synchronized} cannot offer:
* {@link ReentrantLock#tryLock()}.
*
* <p><b>Layer 3 {@code tryLock()} fast path, intrusive MPSC fallback.</b> The overwhelmingly
* common case, even on a genuinely multiplexed connection, is exactly one stream wanting to
* write at a given instant. {@code tryLock()} on an uncontended lock is one successful CAS; the
* calling thread writes inline and releases no handoff, no queue touched, no allocation, no
* context switch. Only when {@code tryLock()} fails (genuine contention) does the intent get
* published through {@link IntrusiveMpscQueue} (one more CAS, still zero allocation the
* intent itself is the queue node) for the current lock holder to drain.
* common case, even on a genuinely multiplexed connection, is exactly one stream wanting to write
* at a given instant. {@code tryLock()} on an uncontended lock is one successful CAS; the calling
* thread writes inline and releases no handoff, no queue touched, no allocation, no context
* switch. Only when {@code tryLock()} fails (genuine contention) does the intent get published
* through {@link IntrusiveMpscQueue} (one more CAS, still zero allocation the intent itself is
* the queue node) for the current lock holder to drain.
*
* <h3>Lost-wakeup avoidance</h3>
*
* The classic hazard: a producer offers its intent to the queue at the exact moment the current
* holder has just found the queue empty and is about to unlock the item would be stranded
* with nobody left to drain it. This is closed by two cooperating checks, and the correctness
* argument for why together they are sufficient is a happens-before chain through the queue's
* {@code AtomicReference} and the lock's own acquire/release ordering (recorded in full in
* {@code WRITER.md}, since it is exactly the kind of reasoning a future reader must be able to
* re-derive, not just trust):
* holder has just found the queue empty and is about to unlock the item would be stranded with
* nobody left to drain it. This is closed by two cooperating checks, and the correctness argument
* for why together they are sufficient is a happens-before chain through the queue's {@code
* AtomicReference} and the lock's own acquire/release ordering (recorded in full in {@code
* WRITER.md}, since it is exactly the kind of reasoning a future reader must be able to re-derive,
* not just trust):
*
* <pre>
* write(intent):
* if tryLock() succeeds: // 1 CAS, the fast path
@@ -61,193 +62,222 @@ import java.util.concurrent.locks.ReentrantLock;
* poll-and-write until empty
* unlock()
* </pre>
* A frame's bytes are never interleaved with another frame's bytes: every write of one intent
* is a single {@code sink.write} call issued while holding the lock, and the lock is not
* released between a {@code WriteIntent}'s bytes.
*
* A frame's bytes are never interleaved with another frame's bytes: every write of one intent is a
* single {@code sink.write} call issued while holding the lock, and the lock is not released
* between a {@code WriteIntent}'s bytes.
*
* <h3>Write timeout</h3>
* A blocking write is unavoidable when the kernel send buffer is full and the peer is not
* reading whoever holds the lock is blocked in the syscall, holding up every other stream on
* the connection. This is bounded by {@link Http2Limits#WRITE_TIMEOUT_MS}, enforced by a shared
*
* A blocking write is unavoidable when the kernel send buffer is full and the peer is not reading
* whoever holds the lock is blocked in the syscall, holding up every other stream on the
* connection. This is bounded by {@link Http2Limits#WRITE_TIMEOUT_MS}, enforced by a shared
* background reaper ({@link WriteTimeoutReaper}) that interrupts the blocked thread past the
* deadline {@code Socket#setSoTimeout} bounds reads, not writes, so it cannot be used here.
* connection setup), so arming/disarming the deadline for each individual write is two
* {@code volatile} field writes, not an allocation.
* connection setup), so arming/disarming the deadline for each individual write is two {@code
* volatile} field writes, not an allocation.
*/
public final class Http2FrameWriter {
/** What a frame's serialized bytes are ultimately written to. Kept minimal and separate
* from {@code java.io.OutputStream} so this class is testable without a real socket. */
public interface Sink {
void write(byte[] buf, int off, int len) throws IOException;
/**
* What a frame's serialized bytes are ultimately written to. Kept minimal and separate from
* {@code java.io.OutputStream} so this class is testable without a real socket.
*/
public interface Sink {
void write(byte[] buf, int off, int len) throws IOException;
}
private final Sink sink;
private final long writeTimeoutMs;
private final ReentrantLock lock = new ReentrantLock();
private final IntrusiveMpscQueue priorityQueue = new IntrusiveMpscQueue();
private final IntrusiveMpscQueue queue = new IntrusiveMpscQueue();
// Set only for the duration of an in-flight sink.write() call; see WriteTimeoutReaper. A
// single volatile write to arm, one to disarm no timestamp is recorded here (see the
// reaper's own Javadoc for why: a per-write System.nanoTime() call measurably missed the
// N=1 gate's 50 ns overhead budget when this was first benchmarked, recorded in WRITER.md).
private volatile Thread writingThread;
public Http2FrameWriter(Sink sink) {
this(sink, Http2Limits.WRITE_TIMEOUT_MS);
}
public Http2FrameWriter(Sink sink, long writeTimeoutMs) {
this.sink = sink;
this.writeTimeoutMs = writeTimeoutMs;
WriteTimeoutReaper.register(this);
}
/**
* Serializes and writes one frame. Returns when the bytes are in the socket buffer or safely
* queued behind another writer. Never blocks on another stream's I/O while holding the lock for
* longer than that stream's own single bulk write.
*
* <p><b>Why the fast path is gated on {@code !queue.hasWork()}, not just {@code tryLock()}</b>
* Writing {@code intent} immediately, before anything already queued, is only safe when nothing
* is already queued. Without the {@code hasWork()} check, this sequence is possible and
* violates same-producer ordering, which the stress test asserts: a producer's {@code write(a)}
* then {@code write(b)} contends and both get queued (fire-and-forget); the current holder is
* about to drain them but has not yet; that producer's very next call, {@code write(c)}, finds
* the lock free (the holder released it between the producer's calls) and would otherwise write
* {@code c} directly landing on the wire before {@code a} and {@code b}, which are still
* sitting in the queue. Checking {@code hasWork()} first means "bypass the queue" only happens
* when the queue is observed genuinely empty, i.e. everything previously offered by any
* producer has already been written; see {@code WRITER.md} for the full argument.
*/
public void write(WriteIntent intent) throws IOException {
if (!priorityQueue.hasWork() && !queue.hasWork() && lock.tryLock()) {
drive(intent);
} else {
queue.offer(intent);
if (lock.tryLock()) {
drive(null);
}
}
}
private final Sink sink;
private final long writeTimeoutMs;
private final ReentrantLock lock = new ReentrantLock();
private final IntrusiveMpscQueue queue = new IntrusiveMpscQueue();
// Set only for the duration of an in-flight sink.write() call; see WriteTimeoutReaper. A
// single volatile write to arm, one to disarm no timestamp is recorded here (see the
// reaper's own Javadoc for why: a per-write System.nanoTime() call measurably missed the
// N=1 gate's 50 ns overhead budget when this was first benchmarked, recorded in WRITER.md).
private volatile Thread writingThread;
public Http2FrameWriter(Sink sink) {
this(sink, Http2Limits.WRITE_TIMEOUT_MS);
/**
* Writes a connection-control frame ahead of queued stream data. An already executing socket
* write is never interrupted, but once it completes the priority queue is drained before the
* ordinary queue. This is used for PING acknowledgements, SETTINGS acknowledgements, GOAWAY and
* RST_STREAM.
*/
public void writePriority(WriteIntent intent) throws IOException {
priorityQueue.offer(intent);
if (lock.tryLock()) {
drive(null);
}
}
public Http2FrameWriter(Sink sink, long writeTimeoutMs) {
this.sink = sink;
this.writeTimeoutMs = writeTimeoutMs;
WriteTimeoutReaper.register(this);
/**
* Flushes any queued intents. Called by the demux loop when it has nothing left to read a no-op
* on the (overwhelmingly common) fast path where nothing is queued.
*/
public void drain() throws IOException {
if (!priorityQueue.hasWork() && !queue.hasWork()) return;
if (lock.tryLock()) {
drive(null);
}
}
/**
* Serializes and writes one frame. Returns when the bytes are in the socket buffer or
* safely queued behind another writer. Never blocks on another stream's I/O while holding
* the lock for longer than that stream's own single bulk write.
*
* <p><b>Why the fast path is gated on {@code !queue.hasWork()}, not just {@code tryLock()}</b>
* Writing {@code intent} immediately, before anything already queued, is only
* safe when nothing is already queued. Without the {@code hasWork()} check, this sequence
* is possible and violates same-producer ordering, which the stress test asserts: a
* producer's {@code write(a)} then {@code write(b)} contends and both get queued
* (fire-and-forget); the current holder is about to drain them but has not yet; that
* producer's very next call, {@code write(c)}, finds the lock free (the holder released it
* between the producer's calls) and would otherwise write {@code c} directly landing on
* the wire before {@code a} and {@code b}, which are still sitting in the queue. Checking
* {@code hasWork()} first means "bypass the queue" only happens when the queue is observed
* genuinely empty, i.e. everything previously offered by any producer has already been
* written; see {@code WRITER.md} for the full argument.
*/
public void write(WriteIntent intent) throws IOException {
if (!queue.hasWork() && lock.tryLock()) {
drive(intent);
} else {
queue.offer(intent);
if (lock.tryLock()) {
drive(null);
}
}
/**
* Deregisters this writer from the write-timeout reaper. Call once, when the connection closes.
*/
public void close() {
WriteTimeoutReaper.unregister(this);
}
private void drive(WriteIntent firstIntentOrNull) throws IOException {
try {
if (firstIntentOrNull != null) writeDirect(firstIntentOrNull);
drainQueues();
} finally {
lock.unlock();
}
/** Flushes any queued intents. Called by the demux loop when it has nothing left to read
* a no-op on the (overwhelmingly common) fast path where nothing is queued. */
public void drain() throws IOException {
if (!queue.hasWork()) return;
if (lock.tryLock()) {
drive(null);
}
// Lost-wakeup fix: re-check after unlocking, looping because this cycle itself can race
// the same way see the class Javadoc for the correctness argument.
while (priorityQueue.hasWork() || queue.hasWork()) {
if (!lock.tryLock()) break;
try {
drainQueues();
} finally {
lock.unlock();
}
}
}
/** Deregisters this writer from the write-timeout reaper. Call once, when the connection
* closes. */
public void close() {
WriteTimeoutReaper.unregister(this);
private void drainQueues() throws IOException {
WriteIntent next;
while (true) {
while ((next = priorityQueue.poll()) != null) {
writeDirect(next);
}
next = queue.poll();
if (next == null) return;
writeDirect(next);
}
}
private void drive(WriteIntent firstIntentOrNull) throws IOException {
try {
if (firstIntentOrNull != null) writeDirect(firstIntentOrNull);
WriteIntent next;
while ((next = queue.poll()) != null) {
writeDirect(next);
}
} finally {
lock.unlock();
}
// Lost-wakeup fix: re-check after unlocking, looping because this cycle itself can race
// the same way see the class Javadoc for the correctness argument.
while (queue.hasWork()) {
if (!lock.tryLock()) break;
try {
WriteIntent next;
while ((next = queue.poll()) != null) {
writeDirect(next);
}
} finally {
lock.unlock();
}
}
private void writeDirect(WriteIntent intent) throws IOException {
writingThread = Thread.currentThread();
try {
sink.write(intent.buffer(), intent.offset(), intent.length());
} catch (IOException e) {
if (Thread.interrupted()) {
InterruptedIOException timeout =
new InterruptedIOException("HTTP/2 write timed out after ~" + writeTimeoutMs + " ms");
timeout.initCause(e);
throw timeout;
}
throw e;
} finally {
writingThread = null;
Thread.interrupted(); // clear a stray interrupt flag defensively before returning control
intent.completed();
}
}
private void writeDirect(WriteIntent intent) throws IOException {
writingThread = Thread.currentThread();
try {
sink.write(intent.buffer(), intent.offset(), intent.length());
} catch (IOException e) {
if (Thread.interrupted()) {
InterruptedIOException timeout = new InterruptedIOException(
"HTTP/2 write timed out after ~" + writeTimeoutMs + " ms");
timeout.initCause(e);
throw timeout;
}
throw e;
} finally {
writingThread = null;
Thread.interrupted(); // clear a stray interrupt flag defensively before returning control
}
}
/**
* A single shared daemon thread scanning every registered {@link Http2FrameWriter} for a blocking
* write that has overrun {@link Http2Limits#WRITE_TIMEOUT_MS}. One thread for the whole process
* (like {@code DateHeader}'s refresher), not one per connection registration per-write one.
*
* <p>Deliberately does <em>not</em> ask each write to record a {@code System.nanoTime()} {@code
* nanoTime()} call (plus the extra volatile field it required) costing enough to miss the N=1
* gate's 50 ns-over-baseline budget (recorded in {@code WRITER.md}). Instead, the reaper counts
* <em>consecutive scans</em> a given writer has been observed still blocked ({@link
* #writingThread} non-null); a writer blocked for more than {@code WRITE_TIMEOUT_MS /
* SCAN_INTERVAL_MS} consecutive scans is interrupted. This trades a little precision (up to one
* scan interval of slop already inherent to any background-reaper design) for removing all
* per-write timing cost.
*/
static final class WriteTimeoutReaper {
private static final long SCAN_INTERVAL_MS = 50;
private static final Set<Http2FrameWriter> ACTIVE = ConcurrentHashMap.newKeySet();
// Touched only by the single reaper thread -- no synchronization needed.
private static final java.util.Map<Http2FrameWriter, Integer> BLOCKED_SCAN_COUNTS =
new java.util.IdentityHashMap<>();
/**
* A single shared daemon thread scanning every registered {@link Http2FrameWriter} for a
* blocking write that has overrun {@link Http2Limits#WRITE_TIMEOUT_MS}. One thread for the
* whole process (like {@code DateHeader}'s refresher), not one per connection registration
* per-write one.
*
* <p>Deliberately does <em>not</em> ask each write to record a {@code System.nanoTime()}
* {@code nanoTime()} call (plus the extra volatile field it required) costing enough to miss
* the N=1 gate's 50 ns-over-baseline budget (recorded in {@code WRITER.md}). Instead, the
* reaper counts <em>consecutive scans</em> a given writer has been observed still blocked
* ({@link #writingThread} non-null); a writer blocked for more than
* {@code WRITE_TIMEOUT_MS / SCAN_INTERVAL_MS} consecutive scans is interrupted. This trades
* a little precision (up to one scan interval of slop already inherent to any
* background-reaper design) for removing all per-write timing cost.
*/
static final class WriteTimeoutReaper {
private static final long SCAN_INTERVAL_MS = 50;
private static final Set<Http2FrameWriter> ACTIVE = ConcurrentHashMap.newKeySet();
// Touched only by the single reaper thread -- no synchronization needed.
private static final java.util.Map<Http2FrameWriter, Integer> BLOCKED_SCAN_COUNTS = new java.util.IdentityHashMap<>();
static {
Thread reaper = new Thread(() -> {
static {
Thread reaper =
new Thread(
() -> {
while (true) {
try {
Thread.sleep(SCAN_INTERVAL_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
try {
Thread.sleep(SCAN_INTERVAL_MS);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
for (Http2FrameWriter writer : ACTIVE) {
Thread t = writer.writingThread;
if (t == null) {
BLOCKED_SCAN_COUNTS.remove(writer);
continue;
}
for (Http2FrameWriter writer : ACTIVE) {
Thread t = writer.writingThread;
if (t == null) {
BLOCKED_SCAN_COUNTS.remove(writer);
continue;
}
int scans = BLOCKED_SCAN_COUNTS.merge(writer, 1, Integer::sum);
long thresholdScans = Math.max(1, writer.writeTimeoutMs / SCAN_INTERVAL_MS);
if (scans >= thresholdScans) {
t.interrupt();
BLOCKED_SCAN_COUNTS.remove(writer);
}
int scans = BLOCKED_SCAN_COUNTS.merge(writer, 1, Integer::sum);
long thresholdScans = Math.max(1, writer.writeTimeoutMs / SCAN_INTERVAL_MS);
if (scans >= thresholdScans) {
t.interrupt();
BLOCKED_SCAN_COUNTS.remove(writer);
}
}
}
}, "flash-http2-write-timeout-reaper");
reaper.setDaemon(true);
reaper.start();
}
private WriteTimeoutReaper() {
}
static void register(Http2FrameWriter writer) {
ACTIVE.add(writer);
}
static void unregister(Http2FrameWriter writer) {
ACTIVE.remove(writer);
}
},
"flash-http2-write-timeout-reaper");
reaper.setDaemon(true);
reaper.start();
}
private WriteTimeoutReaper() {}
static void register(Http2FrameWriter writer) {
ACTIVE.add(writer);
}
static void unregister(Http2FrameWriter writer) {
ACTIVE.remove(writer);
}
}
}
@@ -1,40 +1,49 @@
package dev.relism.flash.http2.frame;
/**
* "Serialize yourself, then hand me the finished bytes." The interface a stream (and,
* eventually, connection-level singletons the precompiled SETTINGS ACK, PING ACK, GOAWAY,
* WINDOW_UPDATE frames) implements to write through {@link Http2FrameWriter}.
* "Serialize yourself, then hand me the finished bytes." The interface a stream (and, eventually,
* connection-level singletons the precompiled SETTINGS ACK, PING ACK, GOAWAY, WINDOW_UPDATE
* frames) implements to write through {@link Http2FrameWriter}.
*
* <h3>Layer 1 serialize outside the lock</h3>
* By the time {@link Http2FrameWriter#write} is called, the implementation has already built
* its complete output (frame header + HPACK block + payload, or whatever the frame needs) into
* a buffer it owns a per-stream scratch buffer, reused across writes, never allocated per
* call. {@link #buffer()}/{@link #offset()}/{@link #length()} just describe where that
* already-finished output lives. {@code Http2FrameWriter} never serializes anything itself; it
* only ever issues one bulk {@code write(buffer, offset, length)} while holding the connection's
* write lock see {@code WRITER.md} for why that distinction is the entire point of this
* design (the lock must never be held across serialization work, only across the syscall).
*
* By the time {@link Http2FrameWriter#write} is called, the implementation has already built its
* complete output (frame header + HPACK block + payload, or whatever the frame needs) into a buffer
* it owns a per-stream scratch buffer, reused across writes, never allocated per call. {@link
* #buffer()}/{@link #offset()}/{@link #length()} just describe where that already-finished output
* lives. {@code Http2FrameWriter} never serializes anything itself; it only ever issues one bulk
* {@code write(buffer, offset, length)} while holding the connection's write lock see {@code
* WRITER.md} for why that distinction is the entire point of this design (the lock must never be
* held across serialization work, only across the syscall).
*
* <h3>Intrusive queue linkage</h3>
*
* {@link #mpscNext()}/{@link #setMpscNext} are not part of the writer's public contract they
* exist so a {@code WriteIntent} can double as an {@link IntrusiveMpscQueue} node with zero
* extra allocation when the writer is contended. Implementations provide simple field storage;
* nothing about the field is meaningful outside {@link IntrusiveMpscQueue}.
* exist so a {@code WriteIntent} can double as an {@link IntrusiveMpscQueue} node with zero extra
* allocation when the writer is contended. Implementations provide simple field storage; nothing
* about the field is meaningful outside {@link IntrusiveMpscQueue}.
*/
public interface WriteIntent {
/** The buffer holding this intent's already-serialized bytes. */
byte[] buffer();
/** The buffer holding this intent's already-serialized bytes. */
byte[] buffer();
/** Offset of the first byte to write, within {@link #buffer()}. */
int offset();
/** Offset of the first byte to write, within {@link #buffer()}. */
int offset();
/** Number of bytes to write, starting at {@link #offset()}. */
int length();
/** Number of bytes to write, starting at {@link #offset()}. */
int length();
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
WriteIntent mpscNext();
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
WriteIntent mpscNext();
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
void setMpscNext(WriteIntent next);
/** Intrusive MPSC queue linkage — see {@link IntrusiveMpscQueue}. Not for external use. */
void setMpscNext(WriteIntent next);
/**
* Called exactly once after this intent leaves the writer, whether the socket write succeeded or
* failed. Pooled control-frame intents use this hook to return their slot to the owning
* connection without allocating a completion object.
*/
default void completed() {}
}
@@ -1,13 +1,5 @@
package dev.relism.flash.tls;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLServerSocketFactory;
import javax.net.ssl.X509ExtendedKeyManager;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
@@ -17,444 +9,476 @@ import java.security.KeyStore;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import javax.net.ssl.KeyManager;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLServerSocketFactory;
import javax.net.ssl.X509ExtendedKeyManager;
/**
* Declarative TLS configuration for a {@link dev.relism.flash.extension.FlashConfiguration.Listener}.
* Declarative TLS configuration for a {@link
* dev.relism.flash.extension.FlashConfiguration.Listener}.
*
* <h3>Two ways in</h3>
*
* <ul>
* <li>{@link #keystore(Path, String)} Flash builds the {@link SSLContext} from a PKCS12/JKS
* keystore. A keystore holding more than one certificate entry gets SNI-based selection
* for free (see {@link SniKeyManager}) no per-hostname config needed. Flash also pins
* {@code TLSv1.2}/{@code TLSv1.3} as the enabled protocols; cipher suites are left at the
* JDK's own curated default, which each JDK security release keeps current Flash does
* not maintain its own suite allow-list.</li>
* <li>{@link #ofContext(SSLContext)} escape hatch. The given {@link SSLContext} is used
* exactly as built: Flash never calls {@code setSSLParameters} on this path unless you
* explicitly call {@link #applicationProtocols} or {@link #clientAuth} yourself, so
* anything else you configured on it is 100% authoritative.</li>
* keystore. A keystore holding more than one certificate entry gets SNI-based selection for
* free (see {@link SniKeyManager}) no per-hostname config needed. Flash also pins {@code
* TLSv1.2}/{@code TLSv1.3} as the enabled protocols; cipher suites are left at the JDK's own
* curated default, which each JDK security release keeps current Flash does not maintain
* its own suite allow-list.
* <li>{@link #ofContext(SSLContext)} escape hatch. The given {@link SSLContext} is used exactly
* as built: Flash never calls {@code setSSLParameters} on this path unless you explicitly
* call {@link #applicationProtocols} or {@link #clientAuth} yourself, so anything else you
* configured on it is 100% authoritative.
* </ul>
*
* <p>{@link #clientAuth(ClientAuth)} and {@link #applicationProtocols(String...)} apply on
* either path they are explicit instructions through this API, not Flash-chosen defaults, so
* each is only ever applied when called. Neither has a value by default, on either path.
* <p>{@link #clientAuth(ClientAuth)} and {@link #applicationProtocols(String...)} apply on either
* path they are explicit instructions through this API, not Flash-chosen defaults, so each is
* only ever applied when called. Neither has a value by default, on either path.
*
* <h3>ALPN (e.g. TLS-ALPN-01 / RFC 8737)</h3>
* {@link #applicationProtocols(String...)} sets the listener's negotiable protocol list via
* {@link SSLParameters#setApplicationProtocols}, inherited by every accepted socket exactly like
* {@link ClientAuth} no per-connection code needed. ALPN is resolved during {@code ClientHello}
* processing/{@code ServerHello} production, which always precedes {@code Certificate} production
* so a custom {@link javax.net.ssl.X509ExtendedKeyManager} deciding which certificate to serve
* can read the client's negotiated protocol via {@code engine.getHandshakeApplicationProtocol()}
* (or {@code ((SSLSocket) socket).getHandshakeApplicationProtocol()}) inside
* {@code chooseEngineServerAlias}/{@code chooseServerAlias} and it is already resolved by then.
*
* {@link #applicationProtocols(String...)} sets the listener's negotiable protocol list via {@link
* SSLParameters#setApplicationProtocols}, inherited by every accepted socket exactly like {@link
* ClientAuth} no per-connection code needed. ALPN is resolved during {@code ClientHello}
* processing/{@code ServerHello} production, which always precedes {@code Certificate} production
* so a custom {@link javax.net.ssl.X509ExtendedKeyManager} deciding which certificate to serve can
* read the client's negotiated protocol via {@code engine.getHandshakeApplicationProtocol()} (or
* {@code ((SSLSocket) socket).getHandshakeApplicationProtocol()}) inside {@code
* chooseEngineServerAlias}/{@code chooseServerAlias} and it is already resolved by then.
*/
public final class TlsConfig {
private static final String[] SECURE_PROTOCOLS = { "TLSv1.3", "TLSv1.2" };
private static final String[] SECURE_PROTOCOLS = {"TLSv1.3", "TLSv1.2"};
/**
* cipher suites over TLS 1.2 (the list is unchanged from RFC 7540 Appendix A, which 9113
* carries forward verbatim), and that it MUST support at least
* {@code TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}. TLS 1.3 is unaffected: none of its cipher
* suites (the {@code TLS_AES_*}/{@code TLS_CHACHA20_*} identifiers) appear here, since
* TLS 1.3 removed static/non-ephemeral key exchange and CBC-mode ciphers entirely the
* exact property this blocklist exists to enforce for TLS 1.2.
*
* <p>Transcribed from Go's {@code golang.org/x/net/http2} {@code isBadCipher} table
* (BSD-licensed, itself an implementation of this exact RFC 9113 requirement, cross-checked
* against the IANA TLS Cipher Suite registry) rather than by hand from the RFC text, for the
* static table be transcribed from the RFC directly and verified: a transcription error in a
* ~280-entry list is easy to make and easy to miss, and here the failure mode is silently
* permitting a cipher suite RFC 9113 requires rejecting. Built once, in a static
*/
private static final Set<String> TLS12_H2_BLOCKED_CIPHERS = Set.of(
"TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA",
"TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA",
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA",
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA256",
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA",
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA256",
"TLS_DHE_DSS_WITH_ARIA_128_CBC_SHA256",
"TLS_DHE_DSS_WITH_ARIA_256_CBC_SHA384",
"TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA",
"TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA",
"TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_DHE_DSS_WITH_DES_CBC_SHA",
"TLS_DHE_DSS_WITH_SEED_CBC_SHA",
"TLS_DHE_PSK_WITH_3DES_EDE_CBC_SHA",
"TLS_DHE_PSK_WITH_AES_128_CBC_SHA",
"TLS_DHE_PSK_WITH_AES_128_CBC_SHA256",
"TLS_DHE_PSK_WITH_AES_256_CBC_SHA",
"TLS_DHE_PSK_WITH_AES_256_CBC_SHA384",
"TLS_DHE_PSK_WITH_ARIA_128_CBC_SHA256",
"TLS_DHE_PSK_WITH_ARIA_256_CBC_SHA384",
"TLS_DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_DHE_PSK_WITH_NULL_SHA",
"TLS_DHE_PSK_WITH_NULL_SHA256",
"TLS_DHE_PSK_WITH_NULL_SHA384",
"TLS_DHE_PSK_WITH_RC4_128_SHA",
"TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA",
"TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA",
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA256",
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA",
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA256",
"TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256",
"TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384",
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA",
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA",
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_DHE_RSA_WITH_DES_CBC_SHA",
"TLS_DHE_RSA_WITH_SEED_CBC_SHA",
"TLS_DH_DSS_EXPORT_WITH_DES40_CBC_SHA",
"TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA",
"TLS_DH_DSS_WITH_AES_128_CBC_SHA",
"TLS_DH_DSS_WITH_AES_128_CBC_SHA256",
"TLS_DH_DSS_WITH_AES_128_GCM_SHA256",
"TLS_DH_DSS_WITH_AES_256_CBC_SHA",
"TLS_DH_DSS_WITH_AES_256_CBC_SHA256",
"TLS_DH_DSS_WITH_AES_256_GCM_SHA384",
"TLS_DH_DSS_WITH_ARIA_128_CBC_SHA256",
"TLS_DH_DSS_WITH_ARIA_128_GCM_SHA256",
"TLS_DH_DSS_WITH_ARIA_256_CBC_SHA384",
"TLS_DH_DSS_WITH_ARIA_256_GCM_SHA384",
"TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA",
"TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DH_DSS_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA",
"TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_DH_DSS_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_DH_DSS_WITH_DES_CBC_SHA",
"TLS_DH_DSS_WITH_SEED_CBC_SHA",
"TLS_DH_RSA_EXPORT_WITH_DES40_CBC_SHA",
"TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_DH_RSA_WITH_AES_128_CBC_SHA",
"TLS_DH_RSA_WITH_AES_128_CBC_SHA256",
"TLS_DH_RSA_WITH_AES_128_GCM_SHA256",
"TLS_DH_RSA_WITH_AES_256_CBC_SHA",
"TLS_DH_RSA_WITH_AES_256_CBC_SHA256",
"TLS_DH_RSA_WITH_AES_256_GCM_SHA384",
"TLS_DH_RSA_WITH_ARIA_128_CBC_SHA256",
"TLS_DH_RSA_WITH_ARIA_128_GCM_SHA256",
"TLS_DH_RSA_WITH_ARIA_256_CBC_SHA384",
"TLS_DH_RSA_WITH_ARIA_256_GCM_SHA384",
"TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA",
"TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DH_RSA_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA",
"TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_DH_RSA_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_DH_RSA_WITH_DES_CBC_SHA",
"TLS_DH_RSA_WITH_SEED_CBC_SHA",
"TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA",
"TLS_DH_anon_EXPORT_WITH_RC4_40_MD5",
"TLS_DH_anon_WITH_3DES_EDE_CBC_SHA",
"TLS_DH_anon_WITH_AES_128_CBC_SHA",
"TLS_DH_anon_WITH_AES_128_CBC_SHA256",
"TLS_DH_anon_WITH_AES_128_GCM_SHA256",
"TLS_DH_anon_WITH_AES_256_CBC_SHA",
"TLS_DH_anon_WITH_AES_256_CBC_SHA256",
"TLS_DH_anon_WITH_AES_256_GCM_SHA384",
"TLS_DH_anon_WITH_ARIA_128_CBC_SHA256",
"TLS_DH_anon_WITH_ARIA_128_GCM_SHA256",
"TLS_DH_anon_WITH_ARIA_256_CBC_SHA384",
"TLS_DH_anon_WITH_ARIA_256_GCM_SHA384",
"TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA",
"TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DH_anon_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA",
"TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_DH_anon_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_DH_anon_WITH_DES_CBC_SHA",
"TLS_DH_anon_WITH_RC4_128_MD5",
"TLS_DH_anon_WITH_SEED_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384",
"TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256",
"TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384",
"TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_ECDHE_ECDSA_WITH_NULL_SHA",
"TLS_ECDHE_ECDSA_WITH_RC4_128_SHA",
"TLS_ECDHE_PSK_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA",
"TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA",
"TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384",
"TLS_ECDHE_PSK_WITH_ARIA_128_CBC_SHA256",
"TLS_ECDHE_PSK_WITH_ARIA_256_CBC_SHA384",
"TLS_ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_ECDHE_PSK_WITH_NULL_SHA",
"TLS_ECDHE_PSK_WITH_NULL_SHA256",
"TLS_ECDHE_PSK_WITH_NULL_SHA384",
"TLS_ECDHE_PSK_WITH_RC4_128_SHA",
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384",
"TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256",
"TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384",
"TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_ECDHE_RSA_WITH_NULL_SHA",
"TLS_ECDHE_RSA_WITH_RC4_128_SHA",
"TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA",
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA",
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384",
"TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDH_ECDSA_WITH_ARIA_128_CBC_SHA256",
"TLS_ECDH_ECDSA_WITH_ARIA_128_GCM_SHA256",
"TLS_ECDH_ECDSA_WITH_ARIA_256_CBC_SHA384",
"TLS_ECDH_ECDSA_WITH_ARIA_256_GCM_SHA384",
"TLS_ECDH_ECDSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_ECDH_ECDSA_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_ECDH_ECDSA_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_ECDH_ECDSA_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_ECDH_ECDSA_WITH_NULL_SHA",
"TLS_ECDH_ECDSA_WITH_RC4_128_SHA",
"TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA",
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA",
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384",
"TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDH_RSA_WITH_ARIA_128_CBC_SHA256",
"TLS_ECDH_RSA_WITH_ARIA_128_GCM_SHA256",
"TLS_ECDH_RSA_WITH_ARIA_256_CBC_SHA384",
"TLS_ECDH_RSA_WITH_ARIA_256_GCM_SHA384",
"TLS_ECDH_RSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_ECDH_RSA_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_ECDH_RSA_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_ECDH_RSA_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_ECDH_RSA_WITH_NULL_SHA",
"TLS_ECDH_RSA_WITH_RC4_128_SHA",
"TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDH_anon_WITH_AES_128_CBC_SHA",
"TLS_ECDH_anon_WITH_AES_256_CBC_SHA",
"TLS_ECDH_anon_WITH_NULL_SHA",
"TLS_ECDH_anon_WITH_RC4_128_SHA",
"TLS_EMPTY_RENEGOTIATION_INFO_SCSV",
"TLS_KRB5_EXPORT_WITH_DES_CBC_40_MD5",
"TLS_KRB5_EXPORT_WITH_DES_CBC_40_SHA",
"TLS_KRB5_EXPORT_WITH_RC2_CBC_40_MD5",
"TLS_KRB5_EXPORT_WITH_RC2_CBC_40_SHA",
"TLS_KRB5_EXPORT_WITH_RC4_40_MD5",
"TLS_KRB5_EXPORT_WITH_RC4_40_SHA",
"TLS_KRB5_WITH_3DES_EDE_CBC_MD5",
"TLS_KRB5_WITH_3DES_EDE_CBC_SHA",
"TLS_KRB5_WITH_DES_CBC_MD5",
"TLS_KRB5_WITH_DES_CBC_SHA",
"TLS_KRB5_WITH_IDEA_CBC_MD5",
"TLS_KRB5_WITH_IDEA_CBC_SHA",
"TLS_KRB5_WITH_RC4_128_MD5",
"TLS_KRB5_WITH_RC4_128_SHA",
"TLS_NULL_WITH_NULL_NULL",
"TLS_PSK_WITH_3DES_EDE_CBC_SHA",
"TLS_PSK_WITH_AES_128_CBC_SHA",
"TLS_PSK_WITH_AES_128_CBC_SHA256",
"TLS_PSK_WITH_AES_128_CCM",
"TLS_PSK_WITH_AES_128_CCM_8",
"TLS_PSK_WITH_AES_128_GCM_SHA256",
"TLS_PSK_WITH_AES_256_CBC_SHA",
"TLS_PSK_WITH_AES_256_CBC_SHA384",
"TLS_PSK_WITH_AES_256_CCM",
"TLS_PSK_WITH_AES_256_CCM_8",
"TLS_PSK_WITH_AES_256_GCM_SHA384",
"TLS_PSK_WITH_ARIA_128_CBC_SHA256",
"TLS_PSK_WITH_ARIA_128_GCM_SHA256",
"TLS_PSK_WITH_ARIA_256_CBC_SHA384",
"TLS_PSK_WITH_ARIA_256_GCM_SHA384",
"TLS_PSK_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_PSK_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_PSK_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_PSK_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_PSK_WITH_NULL_SHA",
"TLS_PSK_WITH_NULL_SHA256",
"TLS_PSK_WITH_NULL_SHA384",
"TLS_PSK_WITH_RC4_128_SHA",
"TLS_RSA_EXPORT_WITH_DES40_CBC_SHA",
"TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5",
"TLS_RSA_EXPORT_WITH_RC4_40_MD5",
"TLS_RSA_PSK_WITH_3DES_EDE_CBC_SHA",
"TLS_RSA_PSK_WITH_AES_128_CBC_SHA",
"TLS_RSA_PSK_WITH_AES_128_CBC_SHA256",
"TLS_RSA_PSK_WITH_AES_128_GCM_SHA256",
"TLS_RSA_PSK_WITH_AES_256_CBC_SHA",
"TLS_RSA_PSK_WITH_AES_256_CBC_SHA384",
"TLS_RSA_PSK_WITH_AES_256_GCM_SHA384",
"TLS_RSA_PSK_WITH_ARIA_128_CBC_SHA256",
"TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256",
"TLS_RSA_PSK_WITH_ARIA_256_CBC_SHA384",
"TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384",
"TLS_RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_RSA_PSK_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_RSA_PSK_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_RSA_PSK_WITH_NULL_SHA",
"TLS_RSA_PSK_WITH_NULL_SHA256",
"TLS_RSA_PSK_WITH_NULL_SHA384",
"TLS_RSA_PSK_WITH_RC4_128_SHA",
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_RSA_WITH_AES_128_CBC_SHA",
"TLS_RSA_WITH_AES_128_CBC_SHA256",
"TLS_RSA_WITH_AES_128_CCM",
"TLS_RSA_WITH_AES_128_CCM_8",
"TLS_RSA_WITH_AES_128_GCM_SHA256",
"TLS_RSA_WITH_AES_256_CBC_SHA",
"TLS_RSA_WITH_AES_256_CBC_SHA256",
"TLS_RSA_WITH_AES_256_CCM",
"TLS_RSA_WITH_AES_256_CCM_8",
"TLS_RSA_WITH_AES_256_GCM_SHA384",
"TLS_RSA_WITH_ARIA_128_CBC_SHA256",
"TLS_RSA_WITH_ARIA_128_GCM_SHA256",
"TLS_RSA_WITH_ARIA_256_CBC_SHA384",
"TLS_RSA_WITH_ARIA_256_GCM_SHA384",
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA",
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_RSA_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA",
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_RSA_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_RSA_WITH_DES_CBC_SHA",
"TLS_RSA_WITH_IDEA_CBC_SHA",
"TLS_RSA_WITH_NULL_MD5",
"TLS_RSA_WITH_NULL_SHA",
"TLS_RSA_WITH_NULL_SHA256",
"TLS_RSA_WITH_RC4_128_MD5",
"TLS_RSA_WITH_RC4_128_SHA",
"TLS_RSA_WITH_SEED_CBC_SHA",
"TLS_SRP_SHA_DSS_WITH_3DES_EDE_CBC_SHA",
"TLS_SRP_SHA_DSS_WITH_AES_128_CBC_SHA",
"TLS_SRP_SHA_DSS_WITH_AES_256_CBC_SHA",
"TLS_SRP_SHA_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_SRP_SHA_RSA_WITH_AES_128_CBC_SHA",
"TLS_SRP_SHA_RSA_WITH_AES_256_CBC_SHA",
"TLS_SRP_SHA_WITH_3DES_EDE_CBC_SHA",
"TLS_SRP_SHA_WITH_AES_128_CBC_SHA",
"TLS_SRP_SHA_WITH_AES_256_CBC_SHA"
);
/**
* cipher suites over TLS 1.2 (the list is unchanged from RFC 7540 Appendix A, which 9113 carries
* forward verbatim), and that it MUST support at least {@code
* TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256}. TLS 1.3 is unaffected: none of its cipher suites (the
* {@code TLS_AES_*}/{@code TLS_CHACHA20_*} identifiers) appear here, since TLS 1.3 removed
* static/non-ephemeral key exchange and CBC-mode ciphers entirely the exact property this
* blocklist exists to enforce for TLS 1.2.
*
* <p>Transcribed from Go's {@code golang.org/x/net/http2} {@code isBadCipher} table
* (BSD-licensed, itself an implementation of this exact RFC 9113 requirement, cross-checked
* against the IANA TLS Cipher Suite registry) rather than by hand from the RFC text, for the
* static table be transcribed from the RFC directly and verified: a transcription error in a
* ~280-entry list is easy to make and easy to miss, and here the failure mode is silently
* permitting a cipher suite RFC 9113 requires rejecting. Built once, in a static
*/
private static final Set<String> TLS12_H2_BLOCKED_CIPHERS =
Set.of(
"TLS_DHE_DSS_EXPORT_WITH_DES40_CBC_SHA",
"TLS_DHE_DSS_WITH_3DES_EDE_CBC_SHA",
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA",
"TLS_DHE_DSS_WITH_AES_128_CBC_SHA256",
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA",
"TLS_DHE_DSS_WITH_AES_256_CBC_SHA256",
"TLS_DHE_DSS_WITH_ARIA_128_CBC_SHA256",
"TLS_DHE_DSS_WITH_ARIA_256_CBC_SHA384",
"TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA",
"TLS_DHE_DSS_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA",
"TLS_DHE_DSS_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_DHE_DSS_WITH_DES_CBC_SHA",
"TLS_DHE_DSS_WITH_SEED_CBC_SHA",
"TLS_DHE_PSK_WITH_3DES_EDE_CBC_SHA",
"TLS_DHE_PSK_WITH_AES_128_CBC_SHA",
"TLS_DHE_PSK_WITH_AES_128_CBC_SHA256",
"TLS_DHE_PSK_WITH_AES_256_CBC_SHA",
"TLS_DHE_PSK_WITH_AES_256_CBC_SHA384",
"TLS_DHE_PSK_WITH_ARIA_128_CBC_SHA256",
"TLS_DHE_PSK_WITH_ARIA_256_CBC_SHA384",
"TLS_DHE_PSK_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DHE_PSK_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_DHE_PSK_WITH_NULL_SHA",
"TLS_DHE_PSK_WITH_NULL_SHA256",
"TLS_DHE_PSK_WITH_NULL_SHA384",
"TLS_DHE_PSK_WITH_RC4_128_SHA",
"TLS_DHE_RSA_EXPORT_WITH_DES40_CBC_SHA",
"TLS_DHE_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA",
"TLS_DHE_RSA_WITH_AES_128_CBC_SHA256",
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA",
"TLS_DHE_RSA_WITH_AES_256_CBC_SHA256",
"TLS_DHE_RSA_WITH_ARIA_128_CBC_SHA256",
"TLS_DHE_RSA_WITH_ARIA_256_CBC_SHA384",
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA",
"TLS_DHE_RSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA",
"TLS_DHE_RSA_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_DHE_RSA_WITH_DES_CBC_SHA",
"TLS_DHE_RSA_WITH_SEED_CBC_SHA",
"TLS_DH_DSS_EXPORT_WITH_DES40_CBC_SHA",
"TLS_DH_DSS_WITH_3DES_EDE_CBC_SHA",
"TLS_DH_DSS_WITH_AES_128_CBC_SHA",
"TLS_DH_DSS_WITH_AES_128_CBC_SHA256",
"TLS_DH_DSS_WITH_AES_128_GCM_SHA256",
"TLS_DH_DSS_WITH_AES_256_CBC_SHA",
"TLS_DH_DSS_WITH_AES_256_CBC_SHA256",
"TLS_DH_DSS_WITH_AES_256_GCM_SHA384",
"TLS_DH_DSS_WITH_ARIA_128_CBC_SHA256",
"TLS_DH_DSS_WITH_ARIA_128_GCM_SHA256",
"TLS_DH_DSS_WITH_ARIA_256_CBC_SHA384",
"TLS_DH_DSS_WITH_ARIA_256_GCM_SHA384",
"TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA",
"TLS_DH_DSS_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DH_DSS_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA",
"TLS_DH_DSS_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_DH_DSS_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_DH_DSS_WITH_DES_CBC_SHA",
"TLS_DH_DSS_WITH_SEED_CBC_SHA",
"TLS_DH_RSA_EXPORT_WITH_DES40_CBC_SHA",
"TLS_DH_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_DH_RSA_WITH_AES_128_CBC_SHA",
"TLS_DH_RSA_WITH_AES_128_CBC_SHA256",
"TLS_DH_RSA_WITH_AES_128_GCM_SHA256",
"TLS_DH_RSA_WITH_AES_256_CBC_SHA",
"TLS_DH_RSA_WITH_AES_256_CBC_SHA256",
"TLS_DH_RSA_WITH_AES_256_GCM_SHA384",
"TLS_DH_RSA_WITH_ARIA_128_CBC_SHA256",
"TLS_DH_RSA_WITH_ARIA_128_GCM_SHA256",
"TLS_DH_RSA_WITH_ARIA_256_CBC_SHA384",
"TLS_DH_RSA_WITH_ARIA_256_GCM_SHA384",
"TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA",
"TLS_DH_RSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DH_RSA_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA",
"TLS_DH_RSA_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_DH_RSA_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_DH_RSA_WITH_DES_CBC_SHA",
"TLS_DH_RSA_WITH_SEED_CBC_SHA",
"TLS_DH_anon_EXPORT_WITH_DES40_CBC_SHA",
"TLS_DH_anon_EXPORT_WITH_RC4_40_MD5",
"TLS_DH_anon_WITH_3DES_EDE_CBC_SHA",
"TLS_DH_anon_WITH_AES_128_CBC_SHA",
"TLS_DH_anon_WITH_AES_128_CBC_SHA256",
"TLS_DH_anon_WITH_AES_128_GCM_SHA256",
"TLS_DH_anon_WITH_AES_256_CBC_SHA",
"TLS_DH_anon_WITH_AES_256_CBC_SHA256",
"TLS_DH_anon_WITH_AES_256_GCM_SHA384",
"TLS_DH_anon_WITH_ARIA_128_CBC_SHA256",
"TLS_DH_anon_WITH_ARIA_128_GCM_SHA256",
"TLS_DH_anon_WITH_ARIA_256_CBC_SHA384",
"TLS_DH_anon_WITH_ARIA_256_GCM_SHA384",
"TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA",
"TLS_DH_anon_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_DH_anon_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA",
"TLS_DH_anon_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_DH_anon_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_DH_anon_WITH_DES_CBC_SHA",
"TLS_DH_anon_WITH_RC4_128_MD5",
"TLS_DH_anon_WITH_SEED_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA",
"TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384",
"TLS_ECDHE_ECDSA_WITH_ARIA_128_CBC_SHA256",
"TLS_ECDHE_ECDSA_WITH_ARIA_256_CBC_SHA384",
"TLS_ECDHE_ECDSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_ECDHE_ECDSA_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_ECDHE_ECDSA_WITH_NULL_SHA",
"TLS_ECDHE_ECDSA_WITH_RC4_128_SHA",
"TLS_ECDHE_PSK_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA",
"TLS_ECDHE_PSK_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA",
"TLS_ECDHE_PSK_WITH_AES_256_CBC_SHA384",
"TLS_ECDHE_PSK_WITH_ARIA_128_CBC_SHA256",
"TLS_ECDHE_PSK_WITH_ARIA_256_CBC_SHA384",
"TLS_ECDHE_PSK_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_ECDHE_PSK_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_ECDHE_PSK_WITH_NULL_SHA",
"TLS_ECDHE_PSK_WITH_NULL_SHA256",
"TLS_ECDHE_PSK_WITH_NULL_SHA384",
"TLS_ECDHE_PSK_WITH_RC4_128_SHA",
"TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA",
"TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA",
"TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA384",
"TLS_ECDHE_RSA_WITH_ARIA_128_CBC_SHA256",
"TLS_ECDHE_RSA_WITH_ARIA_256_CBC_SHA384",
"TLS_ECDHE_RSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_ECDHE_RSA_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_ECDHE_RSA_WITH_NULL_SHA",
"TLS_ECDHE_RSA_WITH_RC4_128_SHA",
"TLS_ECDH_ECDSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA",
"TLS_ECDH_ECDSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDH_ECDSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA",
"TLS_ECDH_ECDSA_WITH_AES_256_CBC_SHA384",
"TLS_ECDH_ECDSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDH_ECDSA_WITH_ARIA_128_CBC_SHA256",
"TLS_ECDH_ECDSA_WITH_ARIA_128_GCM_SHA256",
"TLS_ECDH_ECDSA_WITH_ARIA_256_CBC_SHA384",
"TLS_ECDH_ECDSA_WITH_ARIA_256_GCM_SHA384",
"TLS_ECDH_ECDSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_ECDH_ECDSA_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_ECDH_ECDSA_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_ECDH_ECDSA_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_ECDH_ECDSA_WITH_NULL_SHA",
"TLS_ECDH_ECDSA_WITH_RC4_128_SHA",
"TLS_ECDH_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA",
"TLS_ECDH_RSA_WITH_AES_128_CBC_SHA256",
"TLS_ECDH_RSA_WITH_AES_128_GCM_SHA256",
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA",
"TLS_ECDH_RSA_WITH_AES_256_CBC_SHA384",
"TLS_ECDH_RSA_WITH_AES_256_GCM_SHA384",
"TLS_ECDH_RSA_WITH_ARIA_128_CBC_SHA256",
"TLS_ECDH_RSA_WITH_ARIA_128_GCM_SHA256",
"TLS_ECDH_RSA_WITH_ARIA_256_CBC_SHA384",
"TLS_ECDH_RSA_WITH_ARIA_256_GCM_SHA384",
"TLS_ECDH_RSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_ECDH_RSA_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_ECDH_RSA_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_ECDH_RSA_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_ECDH_RSA_WITH_NULL_SHA",
"TLS_ECDH_RSA_WITH_RC4_128_SHA",
"TLS_ECDH_anon_WITH_3DES_EDE_CBC_SHA",
"TLS_ECDH_anon_WITH_AES_128_CBC_SHA",
"TLS_ECDH_anon_WITH_AES_256_CBC_SHA",
"TLS_ECDH_anon_WITH_NULL_SHA",
"TLS_ECDH_anon_WITH_RC4_128_SHA",
"TLS_EMPTY_RENEGOTIATION_INFO_SCSV",
"TLS_KRB5_EXPORT_WITH_DES_CBC_40_MD5",
"TLS_KRB5_EXPORT_WITH_DES_CBC_40_SHA",
"TLS_KRB5_EXPORT_WITH_RC2_CBC_40_MD5",
"TLS_KRB5_EXPORT_WITH_RC2_CBC_40_SHA",
"TLS_KRB5_EXPORT_WITH_RC4_40_MD5",
"TLS_KRB5_EXPORT_WITH_RC4_40_SHA",
"TLS_KRB5_WITH_3DES_EDE_CBC_MD5",
"TLS_KRB5_WITH_3DES_EDE_CBC_SHA",
"TLS_KRB5_WITH_DES_CBC_MD5",
"TLS_KRB5_WITH_DES_CBC_SHA",
"TLS_KRB5_WITH_IDEA_CBC_MD5",
"TLS_KRB5_WITH_IDEA_CBC_SHA",
"TLS_KRB5_WITH_RC4_128_MD5",
"TLS_KRB5_WITH_RC4_128_SHA",
"TLS_NULL_WITH_NULL_NULL",
"TLS_PSK_WITH_3DES_EDE_CBC_SHA",
"TLS_PSK_WITH_AES_128_CBC_SHA",
"TLS_PSK_WITH_AES_128_CBC_SHA256",
"TLS_PSK_WITH_AES_128_CCM",
"TLS_PSK_WITH_AES_128_CCM_8",
"TLS_PSK_WITH_AES_128_GCM_SHA256",
"TLS_PSK_WITH_AES_256_CBC_SHA",
"TLS_PSK_WITH_AES_256_CBC_SHA384",
"TLS_PSK_WITH_AES_256_CCM",
"TLS_PSK_WITH_AES_256_CCM_8",
"TLS_PSK_WITH_AES_256_GCM_SHA384",
"TLS_PSK_WITH_ARIA_128_CBC_SHA256",
"TLS_PSK_WITH_ARIA_128_GCM_SHA256",
"TLS_PSK_WITH_ARIA_256_CBC_SHA384",
"TLS_PSK_WITH_ARIA_256_GCM_SHA384",
"TLS_PSK_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_PSK_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_PSK_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_PSK_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_PSK_WITH_NULL_SHA",
"TLS_PSK_WITH_NULL_SHA256",
"TLS_PSK_WITH_NULL_SHA384",
"TLS_PSK_WITH_RC4_128_SHA",
"TLS_RSA_EXPORT_WITH_DES40_CBC_SHA",
"TLS_RSA_EXPORT_WITH_RC2_CBC_40_MD5",
"TLS_RSA_EXPORT_WITH_RC4_40_MD5",
"TLS_RSA_PSK_WITH_3DES_EDE_CBC_SHA",
"TLS_RSA_PSK_WITH_AES_128_CBC_SHA",
"TLS_RSA_PSK_WITH_AES_128_CBC_SHA256",
"TLS_RSA_PSK_WITH_AES_128_GCM_SHA256",
"TLS_RSA_PSK_WITH_AES_256_CBC_SHA",
"TLS_RSA_PSK_WITH_AES_256_CBC_SHA384",
"TLS_RSA_PSK_WITH_AES_256_GCM_SHA384",
"TLS_RSA_PSK_WITH_ARIA_128_CBC_SHA256",
"TLS_RSA_PSK_WITH_ARIA_128_GCM_SHA256",
"TLS_RSA_PSK_WITH_ARIA_256_CBC_SHA384",
"TLS_RSA_PSK_WITH_ARIA_256_GCM_SHA384",
"TLS_RSA_PSK_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_RSA_PSK_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_RSA_PSK_WITH_CAMELLIA_256_CBC_SHA384",
"TLS_RSA_PSK_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_RSA_PSK_WITH_NULL_SHA",
"TLS_RSA_PSK_WITH_NULL_SHA256",
"TLS_RSA_PSK_WITH_NULL_SHA384",
"TLS_RSA_PSK_WITH_RC4_128_SHA",
"TLS_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_RSA_WITH_AES_128_CBC_SHA",
"TLS_RSA_WITH_AES_128_CBC_SHA256",
"TLS_RSA_WITH_AES_128_CCM",
"TLS_RSA_WITH_AES_128_CCM_8",
"TLS_RSA_WITH_AES_128_GCM_SHA256",
"TLS_RSA_WITH_AES_256_CBC_SHA",
"TLS_RSA_WITH_AES_256_CBC_SHA256",
"TLS_RSA_WITH_AES_256_CCM",
"TLS_RSA_WITH_AES_256_CCM_8",
"TLS_RSA_WITH_AES_256_GCM_SHA384",
"TLS_RSA_WITH_ARIA_128_CBC_SHA256",
"TLS_RSA_WITH_ARIA_128_GCM_SHA256",
"TLS_RSA_WITH_ARIA_256_CBC_SHA384",
"TLS_RSA_WITH_ARIA_256_GCM_SHA384",
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA",
"TLS_RSA_WITH_CAMELLIA_128_CBC_SHA256",
"TLS_RSA_WITH_CAMELLIA_128_GCM_SHA256",
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA",
"TLS_RSA_WITH_CAMELLIA_256_CBC_SHA256",
"TLS_RSA_WITH_CAMELLIA_256_GCM_SHA384",
"TLS_RSA_WITH_DES_CBC_SHA",
"TLS_RSA_WITH_IDEA_CBC_SHA",
"TLS_RSA_WITH_NULL_MD5",
"TLS_RSA_WITH_NULL_SHA",
"TLS_RSA_WITH_NULL_SHA256",
"TLS_RSA_WITH_RC4_128_MD5",
"TLS_RSA_WITH_RC4_128_SHA",
"TLS_RSA_WITH_SEED_CBC_SHA",
"TLS_SRP_SHA_DSS_WITH_3DES_EDE_CBC_SHA",
"TLS_SRP_SHA_DSS_WITH_AES_128_CBC_SHA",
"TLS_SRP_SHA_DSS_WITH_AES_256_CBC_SHA",
"TLS_SRP_SHA_RSA_WITH_3DES_EDE_CBC_SHA",
"TLS_SRP_SHA_RSA_WITH_AES_128_CBC_SHA",
"TLS_SRP_SHA_RSA_WITH_AES_256_CBC_SHA",
"TLS_SRP_SHA_WITH_3DES_EDE_CBC_SHA",
"TLS_SRP_SHA_WITH_AES_128_CBC_SHA",
"TLS_SRP_SHA_WITH_AES_256_CBC_SHA");
/** RFC 9113 §9.2.2: an h2 endpoint MUST support this cipher suite. Not enforced (Flash
* cannot force a peer to offer it), but documented here as the fact {@link #applyTo}'s
* filtering relies on: filtering the blocklist above out of the JDK's default enabled set
* never removes this one, because it was never in the blocklist to begin with. */
static final String REQUIRED_H2_CIPHER_SUITE = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256";
/**
* RFC 9113 §9.2.2: an h2 endpoint MUST support this cipher suite. Not enforced (Flash cannot
* force a peer to offer it), but documented here as the fact {@link #applyTo}'s filtering relies
* on: filtering the blocklist above out of the JDK's default enabled set never removes this one,
* because it was never in the blocklist to begin with.
*/
static final String REQUIRED_H2_CIPHER_SUITE = "TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256";
private final SSLContext context;
private final boolean hardenDefaults;
private final ClientAuth clientAuth;
private final String[] applicationProtocols;
private final SSLContext context;
private final boolean hardenDefaults;
private final ClientAuth clientAuth;
private final String[] applicationProtocols;
private TlsConfig(SSLContext context, boolean hardenDefaults, ClientAuth clientAuth, String[] applicationProtocols) {
this.context = context;
this.hardenDefaults = hardenDefaults;
this.clientAuth = clientAuth;
this.applicationProtocols = applicationProtocols;
}
private TlsConfig(
SSLContext context,
boolean hardenDefaults,
ClientAuth clientAuth,
String[] applicationProtocols) {
this.context = context;
this.hardenDefaults = hardenDefaults;
this.clientAuth = clientAuth;
this.applicationProtocols = applicationProtocols;
}
/**
* Builds an {@link SSLContext} from a PKCS12/JKS keystore type is guessed from the file
* extension ({@code .jks} means JKS, anything else PKCS12). The private-key password is
* assumed equal to the store password, the common case for PKCS12.
*/
public static TlsConfig keystore(Path path, String password) {
try {
KeyStore store = KeyStore.getInstance(path.toString().endsWith(".jks") ? "JKS" : "PKCS12");
try (InputStream in = Files.newInputStream(path)) {
store.load(in, password.toCharArray());
}
KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(store, password.toCharArray());
/**
* Builds an {@link SSLContext} from a PKCS12/JKS keystore type is guessed from the file
* extension ({@code .jks} means JKS, anything else PKCS12). The private-key password is assumed
* equal to the store password, the common case for PKCS12.
*/
public static TlsConfig keystore(Path path, String password) {
try {
KeyStore store = KeyStore.getInstance(path.toString().endsWith(".jks") ? "JKS" : "PKCS12");
try (InputStream in = Files.newInputStream(path)) {
store.load(in, password.toCharArray());
}
KeyManagerFactory kmf =
KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
kmf.init(store, password.toCharArray());
KeyManager[] managers = kmf.getKeyManagers();
for (int i = 0; i < managers.length; i++) {
if (managers[i] instanceof X509ExtendedKeyManager x509) {
managers[i] = new SniKeyManager(x509, store);
}
}
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(managers, null, null);
return new TlsConfig(ctx, true, ClientAuth.NONE, null);
} catch (GeneralSecurityException | IOException e) {
throw new IllegalArgumentException("Failed to load TLS keystore: " + path, e);
KeyManager[] managers = kmf.getKeyManagers();
for (int i = 0; i < managers.length; i++) {
if (managers[i] instanceof X509ExtendedKeyManager x509) {
managers[i] = new SniKeyManager(x509, store);
}
}
SSLContext ctx = SSLContext.getInstance("TLS");
ctx.init(managers, null, null);
return new TlsConfig(ctx, true, ClientAuth.NONE, null);
} catch (GeneralSecurityException | IOException e) {
throw new IllegalArgumentException("Failed to load TLS keystore: " + path, e);
}
}
/**
* Escape hatch see class Javadoc. Flash applies nothing to the socket beyond what you
* explicitly call ({@link #clientAuth}/{@link #applicationProtocols}) on this instance.
*/
public static TlsConfig ofContext(SSLContext context) {
return new TlsConfig(context, false, ClientAuth.NONE, null);
}
/**
* Escape hatch see class Javadoc. Flash applies nothing to the socket beyond what you
* explicitly call ({@link #clientAuth}/{@link #applicationProtocols}) on this instance.
*/
public static TlsConfig ofContext(SSLContext context) {
return new TlsConfig(context, false, ClientAuth.NONE, null);
}
/** Client-certificate requirement. Applies on either construction path — see class Javadoc. */
public TlsConfig clientAuth(ClientAuth mode) {
return new TlsConfig(context, hardenDefaults, mode, applicationProtocols);
}
/** Client-certificate requirement. Applies on either construction path — see class Javadoc. */
public TlsConfig clientAuth(ClientAuth mode) {
return new TlsConfig(context, hardenDefaults, mode, applicationProtocols);
}
/**
* ALPN protocols this listener negotiates, in preference order (e.g.
* {@code "acme-tls/1", "http/1.1"}). Applies on either construction path see class Javadoc
* for how a custom {@code KeyManager} observes the negotiated value.
*/
public TlsConfig applicationProtocols(String... protocols) {
return new TlsConfig(context, hardenDefaults, clientAuth, protocols.clone());
}
/**
* ALPN protocols this listener negotiates, in preference order (e.g. {@code "acme-tls/1",
* "http/1.1"}). Applies on either construction path see class Javadoc for how a custom {@code
* KeyManager} observes the negotiated value.
*/
public TlsConfig applicationProtocols(String... protocols) {
return new TlsConfig(context, hardenDefaults, clientAuth, protocols.clone());
}
// Consumed by HttpServer at bind time not meant for direct use
public SSLServerSocketFactory serverSocketFactory() {
return context.getServerSocketFactory();
}
public void applyTo(SSLServerSocket socket) {
if (hardenDefaults || applicationProtocols != null) {
SSLParameters params = socket.getSSLParameters();
if (hardenDefaults) params.setProtocols(SECURE_PROTOCOLS);
if (applicationProtocols != null) params.setApplicationProtocols(applicationProtocols);
socket.setSSLParameters(params);
}
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true);
// suite list must exclude every suite on the TLS 1.2 blocklist. TLS 1.3 suites are
// never in that list (see TLS12_H2_BLOCKED_CIPHERS's Javadoc) so this only narrows
// which TLS 1.2 suites remain available; TLS 1.3 is unaffected either way.
if (negotiatesH2()) {
String[] enabled = socket.getEnabledCipherSuites();
List<String> filtered = new ArrayList<>(enabled.length);
for (String suite : enabled) {
if (!TLS12_H2_BLOCKED_CIPHERS.contains(suite)) filtered.add(suite);
}
socket.setEnabledCipherSuites(filtered.toArray(new String[0]));
/** Returns this TLS configuration with HTTP/2 enabled and HTTP/1.1 retained as fallback. */
public TlsConfig enableHttp2Alpn() {
List<String> protocols = new ArrayList<>();
if (applicationProtocols != null) {
for (String protocol : applicationProtocols) {
if (!"h2".equals(protocol) && !"http/1.1".equals(protocol)) {
protocols.add(protocol);
}
}
}
protocols.add("h2");
protocols.add("http/1.1");
return new TlsConfig(context, hardenDefaults, clientAuth, protocols.toArray(String[]::new));
}
/**
* Whether this listener's configured ALPN protocol list ({@link #applicationProtocols})
* includes {@code "h2"}. Lets a caller (the connection runner, for logging; {@link #applyTo}
* duplicating the offered-protocols check.
*/
public boolean negotiatesH2() {
if (applicationProtocols == null) return false;
for (String protocol : applicationProtocols) {
if ("h2".equals(protocol)) return true;
}
return false;
// Consumed by HttpServer at bind time not meant for direct use
public SSLServerSocketFactory serverSocketFactory() {
return context.getServerSocketFactory();
}
public void applyTo(SSLServerSocket socket) {
if (hardenDefaults || applicationProtocols != null) {
SSLParameters params = socket.getSSLParameters();
if (hardenDefaults) params.setProtocols(SECURE_PROTOCOLS);
if (applicationProtocols != null) params.setApplicationProtocols(applicationProtocols);
socket.setSSLParameters(params);
}
if (clientAuth == ClientAuth.REQUIRE) socket.setNeedClientAuth(true);
else if (clientAuth == ClientAuth.OPTIONAL) socket.setWantClientAuth(true);
// suite list must exclude every suite on the TLS 1.2 blocklist. TLS 1.3 suites are
// never in that list (see TLS12_H2_BLOCKED_CIPHERS's Javadoc) so this only narrows
// which TLS 1.2 suites remain available; TLS 1.3 is unaffected either way.
if (negotiatesH2()) {
String[] enabled = socket.getEnabledCipherSuites();
List<String> filtered = new ArrayList<>(enabled.length);
for (String suite : enabled) {
if (!TLS12_H2_BLOCKED_CIPHERS.contains(suite)) filtered.add(suite);
}
socket.setEnabledCipherSuites(filtered.toArray(new String[0]));
}
}
/**
* Whether this listener's configured ALPN protocol list ({@link #applicationProtocols}) includes
* {@code "h2"}. Lets a caller (the connection runner, for logging; {@link #applyTo} duplicating
* the offered-protocols check.
*/
public boolean negotiatesH2() {
if (applicationProtocols == null) return false;
for (String protocol : applicationProtocols) {
if ("h2".equals(protocol)) return true;
}
return false;
}
}
@@ -3,11 +3,6 @@ package dev.relism.flash.transport;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import lombok.extern.slf4j.Slf4j;
import javax.net.ssl.SSLSocket;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.OutputStream;
@@ -18,121 +13,144 @@ import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.BooleanSupplier;
import java.util.function.Supplier;
import javax.net.ssl.SSLSocket;
import lombok.extern.slf4j.Slf4j;
/**
* Owns one connection's socket lifecycle from accept to close: configures socket options,
* dispatches to the matching {@link ConnectionProtocol} guaranteeing cleanup (scratch
* release, active-socket tracking) regardless of how the protocol implementation exits.
* dispatches to the matching {@link ConnectionProtocol} guaranteeing cleanup (scratch release,
* active-socket tracking) regardless of how the protocol implementation exits.
*
* <p>Sole responsibility: connection setup/teardown. It contains no HTTP semantics at all
* those live entirely inside whichever {@link ConnectionProtocol} it dispatches to (today,
* always {@code Http1Connection}; an {@code H2} negotiation result is closed cleanly, since
* <p>Sole responsibility: connection setup/teardown. It contains no HTTP semantics at all those
* live entirely inside whichever {@link ConnectionProtocol} it dispatches to (today, always {@code
* Http1Connection}; an {@code H2} negotiation result is closed cleanly, since
*/
@Slf4j
public final class ConnectionRunner {
private final ExecutorService executorService;
private final Set<Socket> activeSockets;
private final ScratchPool scratchPool;
private final AbstractRouter router;
private final AbstractWsRouter wsRouter;
private final FlashConfiguration configuration;
private final ConnectionProtocol http1Protocol;
private final ExecutorService executorService;
private final Set<Socket> activeSockets;
private final ScratchPool scratchPool;
private final AbstractRouter router;
private final AbstractWsRouter wsRouter;
private final FlashConfiguration configuration;
private final ConnectionProtocol http1Protocol;
private final Supplier<? extends ConnectionProtocol> http2ProtocolFactory;
public ConnectionRunner(ExecutorService executorService, Set<Socket> activeSockets, ScratchPool scratchPool,
AbstractRouter router, AbstractWsRouter wsRouter, FlashConfiguration configuration,
ConnectionProtocol http1Protocol) {
this.executorService = executorService;
this.activeSockets = activeSockets;
this.scratchPool = scratchPool;
this.router = router;
this.wsRouter = wsRouter;
this.configuration = configuration;
this.http1Protocol = http1Protocol;
public ConnectionRunner(
ExecutorService executorService,
Set<Socket> activeSockets,
ScratchPool scratchPool,
AbstractRouter router,
AbstractWsRouter wsRouter,
FlashConfiguration configuration,
ConnectionProtocol http1Protocol,
Supplier<? extends ConnectionProtocol> http2ProtocolFactory) {
this.executorService = executorService;
this.activeSockets = activeSockets;
this.scratchPool = scratchPool;
this.router = router;
this.wsRouter = wsRouter;
this.configuration = configuration;
this.http1Protocol = http1Protocol;
this.http2ProtocolFactory = http2ProtocolFactory;
}
/**
* Submits {@code socket} to the virtual-thread executor for full connection handling. {@code
* stopped} is threaded through to the eventual {@link ConnectionContext} so the protocol
* implementation can observe an in-progress graceful shutdown.
*/
public void accept(Socket socket, BooleanSupplier stopped) {
try {
executorService.submit(() -> handle(socket, stopped));
} catch (RejectedExecutionException ignored) {
try {
socket.close();
} catch (IOException e) {
log.debug("Error closing socket on shutdown", e);
}
}
}
/** Submits {@code socket} to the virtual-thread executor for full connection handling.
* {@code stopped} is threaded through to the eventual {@link ConnectionContext} so the
* protocol implementation can observe an in-progress graceful shutdown. */
public void accept(Socket socket, BooleanSupplier stopped) {
try {
executorService.submit(() -> handle(socket, stopped));
} catch (RejectedExecutionException ignored) {
try { socket.close(); } catch (IOException e) { log.debug("Error closing socket on shutdown", e); }
}
private void handle(Socket socket, BooleanSupplier stopped) {
activeSockets.add(socket);
ConnectionScratch scratch = scratchPool.acquire();
try (socket;
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
// TCP_NODELAY: disable Nagle's algorithm. Small WS frames (< MSS) are sent
// immediately rather than waiting up to 200 ms for more data to coalesce.
socket.setTcpNoDelay(true);
socket.setSendBufferSize(TransportTuning.SOCKET_BUF_SIZE);
SSLSocket sslSocket = socket instanceof SSLSocket ssl ? ssl : null;
if (sslSocket != null) {
// protocol decision SSLSocket#getApplicationProtocol() (which
// ProtocolNegotiator relies on) returns null until the handshake has run.
socket.setSoTimeout(configuration.getHeaderReadTimeoutMs());
sslSocket.startHandshake();
socket.setSoTimeout(0); // BufferedByteSource's own deadline takes over below
}
// rawOut is the unbuffered socket stream passed to WebSocketSession directly.
// WS writes are already bulk; HTTP responses use the buffered `out` because
// Http1ResponseWriter does several small writes that benefit from coalescing.
OutputStream rawOut = socket.getOutputStream();
BufferedByteSource in = new BufferedByteSource(socket.getInputStream(), socket);
NegotiatedProtocol negotiated = negotiateProtocol(socket, in);
ConnectionContext ctx =
new ConnectionContext(
socket,
sslSocket,
in,
out,
rawOut,
(InetSocketAddress) socket.getRemoteSocketAddress(),
scratch,
router,
wsRouter,
configuration,
stopped);
if (negotiated == NegotiatedProtocol.HTTP_2) http2ProtocolFactory.get().run(ctx);
else http1Protocol.run(ctx);
} catch (IOException e) {
if (!stopped.getAsBoolean()) {
if (e instanceof SocketException) log.debug("Connection closed: {}", e.getMessage());
else log.error("I/O error handling request", e);
}
} catch (Exception e) {
// Anything not an IOException here means a collaborator misbehaved on the TLS
// handshake path most likely a custom TlsConfig#ofContext KeyManager/TrustManager
// throwing. That failure is isolated to this one virtual thread/connection.
if (!stopped.getAsBoolean()) log.error("Unexpected error handling connection", e);
} finally {
activeSockets.remove(socket);
scratchPool.release(scratch);
}
}
private void handle(Socket socket, BooleanSupplier stopped) {
activeSockets.add(socket);
ConnectionScratch scratch = scratchPool.acquire();
try (socket;
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
// TCP_NODELAY: disable Nagle's algorithm. Small WS frames (< MSS) are sent
// immediately rather than waiting up to 200 ms for more data to coalesce.
socket.setTcpNoDelay(true);
socket.setSendBufferSize(TransportTuning.SOCKET_BUF_SIZE);
SSLSocket sslSocket = socket instanceof SSLSocket ssl ? ssl : null;
if (sslSocket != null) {
// protocol decision SSLSocket#getApplicationProtocol() (which
// ProtocolNegotiator relies on) returns null until the handshake has run.
socket.setSoTimeout(configuration.getHeaderReadTimeoutMs());
sslSocket.startHandshake();
socket.setSoTimeout(0); // BufferedByteSource's own deadline takes over below
}
// rawOut is the unbuffered socket stream passed to WebSocketSession directly.
// WS writes are already bulk; HTTP responses use the buffered `out` because
// Http1ResponseWriter does several small writes that benefit from coalescing.
OutputStream rawOut = socket.getOutputStream();
BufferedByteSource in = new BufferedByteSource(socket.getInputStream(), socket);
NegotiatedProtocol negotiated = negotiateProtocol(socket, in);
if (negotiated == NegotiatedProtocol.HTTP_2) {
// attempt to speak a protocol this version cannot yet serve.
return;
}
ConnectionContext ctx = new ConnectionContext(
socket, sslSocket, in, out, rawOut,
(InetSocketAddress) socket.getRemoteSocketAddress(),
scratch, router, wsRouter, configuration, stopped);
http1Protocol.run(ctx);
} catch (IOException e) {
if (!stopped.getAsBoolean()) {
if (e instanceof SocketException) log.debug("Connection closed: {}", e.getMessage());
else log.error("I/O error handling request", e);
}
} catch (Exception e) {
// Anything not an IOException here means a collaborator misbehaved on the TLS
// handshake path most likely a custom TlsConfig#ofContext KeyManager/TrustManager
// throwing. That failure is isolated to this one virtual thread/connection.
if (!stopped.getAsBoolean()) log.error("Unexpected error handling connection", e);
} finally {
activeSockets.remove(socket);
scratchPool.release(scratch);
}
/**
* Decides h1 vs h2 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)
throws IOException {
if (socket instanceof SSLSocket) {
return ProtocolNegotiator.negotiate(socket, in); // ALPN already resolved, no I/O
}
/**
* 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) throws IOException {
if (socket instanceof SSLSocket) {
return ProtocolNegotiator.negotiate(socket, in); // ALPN already resolved, no I/O
}
if (!configuration.isHttp2Enabled()) {
return NegotiatedProtocol.HTTP_1_1; // skip the h2c peek entirely when disabled
}
in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L);
try {
return ProtocolNegotiator.negotiate(socket, in);
} finally {
in.clearDeadline();
}
if (!configuration.isHttp2Enabled()) {
return NegotiatedProtocol.HTTP_1_1; // skip the h2c peek entirely when disabled
}
in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L);
try {
return ProtocolNegotiator.negotiate(socket, in);
} finally {
in.clearDeadline();
}
}
}
@@ -3,11 +3,9 @@ package dev.relism.flash.transport;
import dev.relism.flash.ServerHandle;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http1.Http1Connection;
import dev.relism.flash.http2.Http2Connection;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.net.Socket;
import java.util.ArrayList;
@@ -16,47 +14,70 @@ import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import lombok.extern.slf4j.Slf4j;
/**
* Composes the whole transport: binds every configured listener, wires the connection runner
* and the h1 protocol, and returns the {@link ServerHandle} implementation
* ({@link ServerLifecycle}) that {@link dev.relism.flash.ServerHandle#create} exposes publicly.
* Composes the whole transport: binds every configured listener, wires the connection runner and
* the h1 protocol, and returns the {@link ServerHandle} implementation ({@link ServerLifecycle})
* that {@link dev.relism.flash.ServerHandle#create} exposes publicly.
*
* asked for {@code ServerHandle.create} used to construct {@code HttpServer} directly, which
* no longer exists. Package-private-in-spirit (public only because {@code ServerHandle} lives
* in a different package and must call it) user code has no reason to call this directly.
* <p>asked for {@code ServerHandle.create} used to construct {@code HttpServer} directly, which
* no longer exists. Package-private-in-spirit (public only because {@code ServerHandle} lives in a
* different package and must call it) user code has no reason to call this directly.
*/
@Slf4j
public final class TransportFactory {
private TransportFactory() {
private TransportFactory() {}
public static ServerHandle create(
FlashConfiguration configuration, AbstractRouter router, AbstractWsRouter wsRouter)
throws IOException {
List<FlashConfiguration.Listener> specs =
configuration.getListeners().isEmpty()
? List.of(
new FlashConfiguration.Listener(
configuration.getPort(), configuration.getHost(), configuration.getTls()))
: configuration.getListeners();
List<BoundListener> bound = new ArrayList<>(specs.size());
for (FlashConfiguration.Listener original : specs) {
FlashConfiguration.Listener spec = original;
if (configuration.isHttp2Enabled() && original.tls() != null) {
spec =
new FlashConfiguration.Listener(
original.port(), original.host(), original.tls().enableHttp2Alpn());
}
bound.add(ListenerBinder.bind(spec));
}
List<BoundListener> boundListeners = List.copyOf(bound);
for (BoundListener bl : boundListeners) {
log.info(
"HTTP server bound on {}:{} (tls={}, backlog={}, acceptThreads={})",
bl.socket().getInetAddress(),
bl.socket().getLocalPort(),
bl.secure(),
TransportTuning.ACCEPT_BACKLOG,
TransportTuning.ACCEPT_THREADS);
}
public static ServerHandle create(FlashConfiguration configuration,
AbstractRouter router, AbstractWsRouter wsRouter) throws IOException {
List<FlashConfiguration.Listener> specs = configuration.getListeners().isEmpty()
? List.of(new FlashConfiguration.Listener(
configuration.getPort(), configuration.getHost(), configuration.getTls()))
: configuration.getListeners();
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
ScratchPool scratchPool = new ScratchPool();
List<BoundListener> bound = new ArrayList<>(specs.size());
for (FlashConfiguration.Listener spec : specs) bound.add(ListenerBinder.bind(spec));
List<BoundListener> boundListeners = List.copyOf(bound);
ConnectionRunner runner =
new ConnectionRunner(
executorService,
activeSockets,
scratchPool,
router,
wsRouter,
configuration,
new Http1Connection(),
Http2Connection::new);
for (BoundListener bl : boundListeners) {
log.info("HTTP server bound on {}:{} (tls={}, backlog={}, acceptThreads={})",
bl.socket().getInetAddress(), bl.socket().getLocalPort(), bl.secure(),
TransportTuning.ACCEPT_BACKLOG, TransportTuning.ACCEPT_THREADS);
}
ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
ScratchPool scratchPool = new ScratchPool();
ConnectionRunner runner = new ConnectionRunner(
executorService, activeSockets, scratchPool, router, wsRouter, configuration,
new Http1Connection());
return new ServerLifecycle(boundListeners, runner, configuration, executorService, activeSockets);
}
return new ServerLifecycle(
boundListeners, runner, configuration, executorService, activeSockets);
}
}
@@ -0,0 +1,169 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.SocketTimeoutException;
import java.util.List;
import org.junit.jupiter.api.Test;
class Http2ConnectionHandshakeTest {
@Test
void exactPrefaceExchangesSettingsAndAcknowledgesPeerSettings() throws Exception {
byte[] input =
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(Http2Settings.MAX_FRAME_SIZE, 32_768));
RunResult result = run(input);
List<Http2TestFrames.WireFrame> frames = Http2TestFrames.parse(result.output());
assertEquals(3, frames.size());
assertEquals(FrameType.SETTINGS.code(), frames.get(0).type());
assertEquals(0, frames.get(0).flags());
assertEquals(FrameType.WINDOW_UPDATE.code(), frames.get(1).type());
assertEquals(FrameType.SETTINGS.code(), frames.get(2).type());
assertEquals(FrameFlags.ACK, frames.get(2).flags());
assertEquals(0, frames.get(2).payload().length);
assertEquals(32_768, result.connection().peerSettings().maxFrameSize());
}
@Test
void mismatchedOrTruncatedPrefaceClosesWithoutSendingGoAway() throws Exception {
byte[] mismatched = Http2TestFrames.PREFACE.clone();
mismatched[10] ^= 1;
assertEquals(0, run(mismatched).output().length);
assertEquals(0, run(java.util.Arrays.copyOf(Http2TestFrames.PREFACE, 12)).output().length);
}
@Test
void firstPeerFrameMustBeSettings() throws Exception {
byte[] ping = Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]);
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(run(Http2TestFrames.concat(Http2TestFrames.PREFACE, ping)).output());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(FrameType.GOAWAY.code(), goAway.type());
assertEquals(
Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
@Test
void settingsAcknowledgementCannotReplaceInitialPeerSettings() throws Exception {
byte[] ack = Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]);
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(run(Http2TestFrames.concat(Http2TestFrames.PREFACE, ack)).output());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(
Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
@Test
void invalidHpackBlockProducesCompressionError() throws Exception {
byte[] headers =
Http2TestFrames.frame(
FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[] {(byte) 0x80});
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(
run(Http2TestFrames.concat(
Http2TestFrames.PREFACE, Http2TestFrames.settings(), headers))
.output());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(
Http2ErrorCode.COMPRESSION_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
@Test
void frameInterleavingDuringContinuationSequenceIsProtocolError() throws Exception {
byte[] incompleteHeaders =
Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82});
byte[] ping = Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]);
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(
run(Http2TestFrames.concat(
Http2TestFrames.PREFACE, Http2TestFrames.settings(), incompleteHeaders, ping))
.output());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(
Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
@Test
void settingsAckWithPayloadIsFrameSizeError() throws Exception {
byte[] badAck = Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[6]);
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(
run(Http2TestFrames.concat(Http2TestFrames.PREFACE, badAck)).output());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(
Http2ErrorCode.FRAME_SIZE_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
@Test
void missingSettingsAcknowledgementTimesOutWithDedicatedErrorCode() throws Exception {
byte[] initial = Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings());
InputStream stallsAfterInput =
new InputStream() {
private final ByteArrayInputStream delegate = new ByteArrayInputStream(initial);
@Override
public int read() throws java.io.IOException {
byte[] one = new byte[1];
int n = read(one, 0, 1);
return n < 0 ? -1 : one[0] & 0xff;
}
@Override
public int read(byte[] target, int off, int len) throws java.io.IOException {
if (delegate.available() > 0) return delegate.read(target, off, len);
try {
Thread.sleep(15);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new java.io.IOException(e);
}
throw new SocketTimeoutException("simulated idle peer");
}
};
Http2Connection connection = new Http2Connection(delta -> {}, 5);
ByteArrayOutputStream output = new ByteArrayOutputStream();
Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000);
try {
connection.run(new BufferedByteSource(stallsAfterInput, null), writer, () -> false);
} finally {
writer.close();
}
List<Http2TestFrames.WireFrame> frames = Http2TestFrames.parse(output.toByteArray());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(
Http2ErrorCode.SETTINGS_TIMEOUT.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
static RunResult run(byte[] input) throws Exception {
Http2Connection connection = new Http2Connection();
ByteArrayOutputStream output = new ByteArrayOutputStream();
Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000);
try {
connection.run(
new BufferedByteSource(new ByteArrayInputStream(input), null), writer, () -> false);
writer.drain();
} finally {
writer.close();
}
return new RunResult(connection, output.toByteArray());
}
record RunResult(Http2Connection connection, byte[] output) {}
}
@@ -0,0 +1,247 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.*;
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.tls.TestKeystores;
import dev.relism.flash.tls.TlsConfig;
import java.io.EOFException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class Http2ConnectionIntegrationTest {
private FlashApp app;
@AfterEach
void stopApp() {
if (app != null) app.stop().join();
}
@Test
void h2cTransportDispatchesControlFramesWithoutRunningApplicationWork() throws Exception {
int port = freePort();
AtomicBoolean handlerEntered = new AtomicBoolean();
app =
FlashApp.create(
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
app.get(
"/",
(request, response) -> {
handlerEntered.set(true);
Thread.sleep(5_000);
return "late";
});
app.start();
byte[] clientPing = "client!!".getBytes(StandardCharsets.US_ASCII);
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(5_000);
socket
.getOutputStream()
.write(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0]),
Http2TestFrames.frame(FrameType.PING, 0, 0, clientPing)));
socket.getOutputStream().flush();
byte[] shutdownPing = null;
boolean sawClientPong = false;
for (int i = 0; i < 8 && !sawClientPong; i++) {
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
if (frame.type() == FrameType.PING.code()) {
if ((frame.flags() & FrameFlags.ACK) != 0 && Arrays.equals(clientPing, frame.payload())) {
sawClientPong = true;
} else if ((frame.flags() & FrameFlags.ACK) == 0) {
shutdownPing = frame.payload();
}
}
}
assertTrue(sawClientPong, "PING must be processed while a route exists");
assertFalse(handlerEntered.get(), "the connection demux must not execute handlers");
if (shutdownPing != null) {
socket
.getOutputStream()
.write(Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, shutdownPing));
socket.getOutputStream().flush();
}
}
}
@Test
void serverStopInitiatesTwoStageGoAwayOnIdleConnection() throws Exception {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.http2Enabled(true)
.shutdownDrainTimeoutMs(5_000)
.build());
app.start();
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(5_000);
socket
.getOutputStream()
.write(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0])));
socket.getOutputStream().flush();
readFrame(socket.getInputStream()); // server SETTINGS
readFrame(socket.getInputStream()); // initial connection WINDOW_UPDATE
readFrame(socket.getInputStream()); // SETTINGS ACK
CompletableFuture<Void> stopped = app.stop();
app = null;
Http2TestFrames.WireFrame firstGoAway = readUntil(socket, FrameType.GOAWAY);
assertEquals(Integer.MAX_VALUE, Http2TestFrames.readInt(firstGoAway.payload(), 0));
Http2TestFrames.WireFrame ping = readUntil(socket, FrameType.PING);
socket
.getOutputStream()
.write(Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, ping.payload()));
socket.getOutputStream().flush();
Http2TestFrames.WireFrame finalGoAway = readUntil(socket, FrameType.GOAWAY);
assertEquals(0, Http2TestFrames.readInt(finalGoAway.payload(), 0));
stopped.get(5, TimeUnit.SECONDS);
}
}
@Test
void protocolStateDoesNotLeakAcrossConsecutiveConnections() throws Exception {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
app.start();
try (Socket first = new Socket("127.0.0.1", port)) {
first.setSoTimeout(5_000);
first
.getOutputStream()
.write(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8])));
first.getOutputStream().flush();
readUntil(first, FrameType.GOAWAY);
}
byte[] opaque = "isolated".getBytes(StandardCharsets.US_ASCII);
try (Socket second = new Socket("127.0.0.1", port)) {
second.setSoTimeout(5_000);
second
.getOutputStream()
.write(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
Http2TestFrames.frame(FrameType.PING, 0, 0, opaque)));
second.getOutputStream().flush();
Http2TestFrames.WireFrame pong = null;
for (int i = 0; i < 6; i++) {
Http2TestFrames.WireFrame frame = readFrame(second.getInputStream());
if (frame.type() == FrameType.PING.code()
&& (frame.flags() & FrameFlags.ACK) != 0
&& Arrays.equals(opaque, frame.payload())) {
pong = frame;
break;
}
}
assertNotNull(pong, "a fresh connection must start with fresh SETTINGS/GOAWAY state");
}
}
@Test
void tlsListenerOffersAndNegotiatesH2WhenHttp2IsEnabled(@TempDir Path directory)
throws Exception {
int port = freePort();
Path keystore =
TestKeystores.build(
directory,
"http2.p12",
"changeit",
TestKeystores.Entry.of("server", "localhost", "localhost"));
app =
FlashApp.create(
FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.tls(TlsConfig.keystore(keystore, "changeit"))
.http2Enabled(true)
.build());
app.start();
try (SSLSocket socket =
(SSLSocket)
TestKeystores.trustAllClientContext()
.getSocketFactory()
.createSocket("127.0.0.1", port)) {
socket.setSoTimeout(5_000);
SSLParameters parameters = socket.getSSLParameters();
parameters.setApplicationProtocols(new String[] {"h2", "http/1.1"});
socket.setSSLParameters(parameters);
socket.startHandshake();
assertEquals("h2", socket.getApplicationProtocol());
socket
.getOutputStream()
.write(Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings()));
socket.getOutputStream().flush();
assertEquals(FrameType.SETTINGS.code(), readFrame(socket.getInputStream()).type());
}
}
private static Http2TestFrames.WireFrame readUntil(Socket socket, FrameType type)
throws Exception {
for (int i = 0; i < 8; i++) {
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
if (frame.type() == type.code()) return frame;
}
fail("did not receive " + type);
throw new AssertionError();
}
private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception {
byte[] header = input.readNBytes(9);
if (header.length != 9) throw new EOFException("truncated frame header");
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
byte[] payload = input.readNBytes(length);
if (payload.length != length) throw new EOFException("truncated frame payload");
return new Http2TestFrames.WireFrame(
header[3] & 0xff,
header[4] & 0xff,
Http2TestFrames.readInt(header, 5) & Integer.MAX_VALUE,
payload);
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -0,0 +1,67 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import java.util.List;
import org.junit.jupiter.api.Test;
class Http2GoAwayTest {
private static final byte[] SHUTDOWN_PING = {
(byte) 0x46, (byte) 0x4c, (byte) 0x41, (byte) 0x53,
(byte) 0x48, (byte) 0x47, (byte) 0x4f, (byte) 0x21
};
@Test
void gracefulShutdownUsesTwoGoAwayStagesSeparatedByPingRoundTrip() throws Exception {
byte[] input =
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0]),
Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, SHUTDOWN_PING));
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(Http2ConnectionHandshakeTest.run(input).output());
List<Http2TestFrames.WireFrame> goAways =
frames.stream().filter(frame -> frame.type() == FrameType.GOAWAY.code()).toList();
assertEquals(2, goAways.size());
assertEquals(Integer.MAX_VALUE, Http2TestFrames.readInt(goAways.get(0).payload(), 0));
assertEquals(1, Http2TestFrames.readInt(goAways.get(1).payload(), 0));
assertEquals(
Http2ErrorCode.NO_ERROR.code(), Http2TestFrames.readInt(goAways.get(0).payload(), 4));
assertEquals(
Http2ErrorCode.NO_ERROR.code(), Http2TestFrames.readInt(goAways.get(1).payload(), 4));
int firstGoAway = indexOf(frames, FrameType.GOAWAY.code(), 0);
int ping = indexOf(frames, FrameType.PING.code(), firstGoAway + 1);
int secondGoAway = indexOf(frames, FrameType.GOAWAY.code(), firstGoAway + 1);
assertTrue(firstGoAway < ping && ping < secondGoAway);
assertArrayEquals(SHUTDOWN_PING, frames.get(ping).payload());
}
@Test
void receivedGoAwayRecordsPeerState() throws Exception {
byte[] payload = new byte[8];
payload[3] = 7;
payload[7] = (byte) Http2ErrorCode.ENHANCE_YOUR_CALM.code();
Http2ConnectionHandshakeTest.RunResult result =
Http2ConnectionHandshakeTest.run(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.GOAWAY, 0, 0, payload)));
assertEquals(7, result.connection().peerLastStreamId());
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM.code(), result.connection().peerErrorCode());
}
private static int indexOf(List<Http2TestFrames.WireFrame> frames, int type, int from) {
for (int i = from; i < frames.size(); i++) {
if (frames.get(i).type() == type) return i;
}
return -1;
}
}
@@ -0,0 +1,46 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.http2.Http2ConnectionScratch.ControlIntent;
import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class Http2PingTest {
@Test
void pingResponseEchoesOpaqueBytesExactly() throws Exception {
byte[] opaque = "12345678".getBytes(StandardCharsets.US_ASCII);
byte[] input =
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.PING, 0, 0, opaque));
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(Http2ConnectionHandshakeTest.run(input).output());
Http2TestFrames.WireFrame pong = frames.get(frames.size() - 1);
assertEquals(FrameType.PING.code(), pong.type());
assertEquals(FrameFlags.ACK, pong.flags());
assertArrayEquals(opaque, pong.payload());
}
@Test
void pingQueueIsStrictlyBounded() {
Http2ConnectionScratch scratch = new Http2ConnectionScratch();
List<ControlIntent> claimed = new ArrayList<>();
for (int i = 0; i < Http2Limits.MAX_PING_QUEUE_DEPTH; i++) {
claimed.add(scratch.acquire(ControlKind.PING));
}
Http2Exception error =
assertThrows(Http2Exception.class, () -> scratch.acquire(ControlKind.PING));
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, error.errorCode());
claimed.forEach(ControlIntent::completed);
}
}
@@ -0,0 +1,116 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
class Http2SettingsTest {
@Test
void appliesEveryKnownSettingAndIgnoresUnknownIdentifiers() {
Http2Settings settings = new Http2Settings();
byte[] payload =
payload(
Http2Settings.HEADER_TABLE_SIZE,
8_192,
Http2Settings.ENABLE_PUSH,
0,
Http2Settings.MAX_CONCURRENT_STREAMS,
123,
Http2Settings.INITIAL_WINDOW_SIZE,
70_000,
Http2Settings.MAX_FRAME_SIZE,
32_768,
Http2Settings.MAX_HEADER_LIST_SIZE,
99_999,
0xf00d,
42);
int[] delta = new int[1];
settings.apply(payload, 0, payload.length, value -> delta[0] = value);
assertEquals(Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL, settings.headerTableSize());
assertFalse(settings.pushEnabled());
assertEquals(123, settings.maxConcurrentStreams());
assertEquals(70_000, settings.initialWindowSize());
assertEquals(32_768, settings.maxFrameSize());
assertEquals(99_999, settings.maxHeaderListSize());
assertEquals(70_000 - 65_535, delta[0]);
}
@Test
void validatesEnablePushInitialWindowAndFrameSize() {
assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.ENABLE_PUSH, 2));
assertCode(
Http2ErrorCode.FLOW_CONTROL_ERROR, payload(Http2Settings.INITIAL_WINDOW_SIZE, 0x8000_0000));
assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.MAX_FRAME_SIZE, 16_383));
assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.MAX_FRAME_SIZE, 16_777_216));
}
@Test
void initialWindowDeltaMayMakeOpenStreamsNegative() {
Http2Settings settings = new Http2Settings();
long[] windows = {10, 100, 65_535};
settings.apply(
payload(Http2Settings.INITIAL_WINDOW_SIZE, 1),
0,
6,
delta -> {
for (int i = 0; i < windows.length; i++) windows[i] += delta;
});
assertArrayEquals(new long[] {-65_524, -65_434, 1}, windows);
}
@Test
void streamWindowOverflowRejectsWholeSettingsPayloadTransactionally() {
Http2Settings settings = new Http2Settings();
byte[] payload =
payload(
Http2Settings.ENABLE_PUSH, 0,
Http2Settings.INITIAL_WINDOW_SIZE, 100_000);
Http2Exception error =
assertThrows(
Http2Exception.class,
() ->
settings.apply(
payload,
0,
payload.length,
delta -> {
throw Http2Exception.FLOW_CONTROL_ERROR;
}));
assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, error.errorCode());
assertTrue(settings.pushEnabled(), "no earlier setting may leak through a failed update");
assertEquals(65_535, settings.initialWindowSize());
}
@Test
void malformedLengthAndEntryFloodAreRejected() {
Http2Settings settings = new Http2Settings();
assertSame(
Http2Exception.FRAME_SIZE_ERROR,
assertThrows(Http2Exception.class, () -> settings.apply(new byte[5], 0, 5, d -> {})));
byte[] flood = new byte[(Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME + 1) * 6];
Http2Exception error =
assertThrows(Http2Exception.class, () -> settings.apply(flood, 0, flood.length, d -> {}));
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, error.errorCode());
}
private static void assertCode(Http2ErrorCode code, byte[] payload) {
Http2Settings settings = new Http2Settings();
Http2Exception error =
assertThrows(
Http2Exception.class, () -> settings.apply(payload, 0, payload.length, d -> {}));
assertEquals(code, error.errorCode());
}
private static byte[] payload(int... pairs) {
byte[] settingsFrame = Http2TestFrames.settings(pairs);
return Arrays.copyOfRange(settingsFrame, 9, settingsFrame.length);
}
}
@@ -0,0 +1,70 @@
package dev.relism.flash.http2;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameWriteBuffer;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
final class Http2TestFrames {
static final byte[] PREFACE =
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
private Http2TestFrames() {}
static byte[] frame(FrameType type, int flags, int streamId, byte[] payload) {
ByteWriter bytes = new ByteWriter(32);
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
frame.beginFrame(type, flags, streamId);
bytes.writeBytes(payload);
frame.endFrame();
byte[] result = new byte[bytes.length()];
System.arraycopy(bytes.array(), 0, result, 0, result.length);
return result;
}
static byte[] settings(int... idValuePairs) {
ByteWriter payload = new ByteWriter(Math.max(16, idValuePairs.length * 3));
for (int i = 0; i < idValuePairs.length; i += 2) {
payload.writeUInt16(idValuePairs[i]);
payload.writeUInt32(idValuePairs[i + 1]);
}
byte[] body = new byte[payload.length()];
System.arraycopy(payload.array(), 0, body, 0, body.length);
return frame(FrameType.SETTINGS, 0, 0, body);
}
static byte[] concat(byte[]... parts) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
for (byte[] part : parts) out.writeBytes(part);
return out.toByteArray();
}
static List<WireFrame> parse(byte[] bytes) {
List<WireFrame> frames = new ArrayList<>();
int pos = 0;
while (pos < bytes.length) {
int length =
((bytes[pos] & 0xFF) << 16) | ((bytes[pos + 1] & 0xFF) << 8) | (bytes[pos + 2] & 0xFF);
int type = bytes[pos + 3] & 0xFF;
int flags = bytes[pos + 4] & 0xFF;
int streamId = readInt(bytes, pos + 5) & 0x7FFF_FFFF;
byte[] payload = new byte[length];
System.arraycopy(bytes, pos + 9, payload, 0, length);
frames.add(new WireFrame(type, flags, streamId, payload));
pos += 9 + length;
}
return frames;
}
static int readInt(byte[] bytes, int off) {
return ((bytes[off] & 0xFF) << 24)
| ((bytes[off + 1] & 0xFF) << 16)
| ((bytes[off + 2] & 0xFF) << 8)
| (bytes[off + 3] & 0xFF);
}
record WireFrame(int type, int flags, int streamId, byte[] payload) {}
}
@@ -0,0 +1,48 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.http2.frame.FrameType;
import java.util.List;
import org.junit.jupiter.api.Test;
class Http2WindowUpdateTest {
@Test
void connectionWindowUpdateIncreasesSendWindow() throws Exception {
Http2ConnectionHandshakeTest.RunResult result = runWindowUpdate(10_000);
assertEquals(75_535, result.connection().connectionSendWindow());
}
@Test
void zeroIncrementIsProtocolError() throws Exception {
assertGoAwayCode(Http2ErrorCode.PROTOCOL_ERROR, runWindowUpdate(0).output());
}
@Test
void connectionWindowOverflowIsFlowControlError() throws Exception {
assertGoAwayCode(
Http2ErrorCode.FLOW_CONTROL_ERROR, runWindowUpdate(Integer.MAX_VALUE).output());
}
private static Http2ConnectionHandshakeTest.RunResult runWindowUpdate(int increment)
throws Exception {
byte[] payload = {
(byte) (increment >>> 24),
(byte) (increment >>> 16),
(byte) (increment >>> 8),
(byte) increment
};
return Http2ConnectionHandshakeTest.run(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.WINDOW_UPDATE, 0, 0, payload)));
}
private static void assertGoAwayCode(Http2ErrorCode expected, byte[] output) {
List<Http2TestFrames.WireFrame> frames = Http2TestFrames.parse(output);
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(FrameType.GOAWAY.code(), goAway.type());
assertEquals(expected.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
}
@@ -1,96 +1,170 @@
package dev.relism.flash.http2.frame;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
class Http2FrameWriterTest {
private static final class TestIntent implements WriteIntent {
final byte[] buf;
WriteIntent next;
TestIntent(byte[] buf) { this.buf = buf; }
TestIntent(String s) { this(s.getBytes()); }
@Override public byte[] buffer() { return buf; }
@Override public int offset() { return 0; }
@Override public int length() { return buf.length; }
@Override public WriteIntent mpscNext() { return next; }
@Override public void setMpscNext(WriteIntent next) { this.next = next; }
private static final class TestIntent implements WriteIntent {
final byte[] buf;
WriteIntent next;
TestIntent(byte[] buf) {
this.buf = buf;
}
private static final class RecordingSink implements Http2FrameWriter.Sink {
final List<byte[]> calls = new ArrayList<>();
@Override
public void write(byte[] buf, int off, int len) {
byte[] copy = new byte[len];
System.arraycopy(buf, off, copy, 0, len);
calls.add(copy);
}
TestIntent(String s) {
this(s.getBytes());
}
@Test
void singleWrite_deliversBytesImmediately() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent("hello"));
assertEquals(1, sink.calls.size());
assertArrayEquals("hello".getBytes(), sink.calls.get(0));
writer.close();
@Override
public byte[] buffer() {
return buf;
}
@Test
void sequentialWrites_fromOneThread_preserveOrderAndAreNotSplitOrMerged() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent("one"));
writer.write(new TestIntent("two"));
writer.write(new TestIntent("three"));
assertEquals(List.of("one", "two", "three"),
sink.calls.stream().map(String::new).toList());
writer.close();
@Override
public int offset() {
return 0;
}
@Test
void exceptionFromSink_doesNotLeaveTheLockHeld() throws IOException {
Http2FrameWriter.Sink failingOnce = new Http2FrameWriter.Sink() {
boolean thrown = false;
@Override
public void write(byte[] buf, int off, int len) throws IOException {
if (!thrown) {
thrown = true;
throw new IOException("simulated sink failure");
}
@Override
public int length() {
return buf.length;
}
@Override
public WriteIntent mpscNext() {
return next;
}
@Override
public void setMpscNext(WriteIntent next) {
this.next = next;
}
}
private static final class RecordingSink implements Http2FrameWriter.Sink {
final List<byte[]> calls = new ArrayList<>();
@Override
public void write(byte[] buf, int off, int len) {
byte[] copy = new byte[len];
System.arraycopy(buf, off, copy, 0, len);
calls.add(copy);
}
}
@Test
void singleWrite_deliversBytesImmediately() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent("hello"));
assertEquals(1, sink.calls.size());
assertArrayEquals("hello".getBytes(), sink.calls.get(0));
writer.close();
}
@Test
void sequentialWrites_fromOneThread_preserveOrderAndAreNotSplitOrMerged() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent("one"));
writer.write(new TestIntent("two"));
writer.write(new TestIntent("three"));
assertEquals(List.of("one", "two", "three"), sink.calls.stream().map(String::new).toList());
writer.close();
}
@Test
void exceptionFromSink_doesNotLeaveTheLockHeld() throws IOException {
Http2FrameWriter.Sink failingOnce =
new Http2FrameWriter.Sink() {
boolean thrown = false;
@Override
public void write(byte[] buf, int off, int len) throws IOException {
if (!thrown) {
thrown = true;
throw new IOException("simulated sink failure");
}
}
};
Http2FrameWriter writer = new Http2FrameWriter(failingOnce, 5_000);
Http2FrameWriter writer = new Http2FrameWriter(failingOnce, 5_000);
assertThrows(IOException.class, () -> writer.write(new TestIntent("boom")));
// If the lock were left held by the failed write, this would hang (tryLock() would
// keep failing forever) rather than complete promptly.
assertDoesNotThrow(() -> writer.write(new TestIntent("recovered")));
writer.close();
assertThrows(IOException.class, () -> writer.write(new TestIntent("boom")));
// If the lock were left held by the failed write, this would hang (tryLock() would
// keep failing forever) rather than complete promptly.
assertDoesNotThrow(() -> writer.write(new TestIntent("recovered")));
writer.close();
}
@Test
void drain_withNothingQueued_isANoOp() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.drain();
assertTrue(sink.calls.isEmpty());
writer.close();
}
@Test
void emptyIntent_writesZeroBytesWithoutError() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent(new byte[0]));
assertEquals(1, sink.calls.size());
assertEquals(0, sink.calls.get(0).length);
writer.close();
}
@Test
void priorityFrameOvertakesQueuedOrdinaryFrame() throws Exception {
CountDownLatch firstWriteEntered = new CountDownLatch(1);
CountDownLatch releaseFirstWrite = new CountDownLatch(1);
RecordingSink recording = new RecordingSink();
Http2FrameWriter.Sink blocking =
(buf, off, len) -> {
if (firstWriteEntered.getCount() != 0) {
firstWriteEntered.countDown();
try {
if (!releaseFirstWrite.await(5, TimeUnit.SECONDS)) {
throw new IOException("timed out waiting to release first write");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException(e);
}
}
recording.write(buf, off, len);
};
Http2FrameWriter writer = new Http2FrameWriter(blocking, 5_000);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var first =
executor.submit(
() -> {
writer.write(new TestIntent("in-flight"));
return null;
});
assertTrue(firstWriteEntered.await(5, TimeUnit.SECONDS));
writer.write(new TestIntent("ordinary"));
writer.writePriority(new TestIntent("priority"));
releaseFirstWrite.countDown();
first.get(5, TimeUnit.SECONDS);
writer.drain();
} finally {
writer.close();
}
@Test
void drain_withNothingQueued_isANoOp() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.drain();
assertTrue(sink.calls.isEmpty());
writer.close();
}
@Test
void emptyIntent_writesZeroBytesWithoutError() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent(new byte[0]));
assertEquals(1, sink.calls.size());
assertEquals(0, sink.calls.get(0).length);
writer.close();
}
assertEquals(
List.of("in-flight", "priority", "ordinary"),
recording.calls.stream().map(String::new).toList());
}
}
@@ -1,191 +1,213 @@
package dev.relism.flash.tls;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLServerSocket;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLServerSocket;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class TlsConfigTest {
private static SSLServerSocket unboundSocket(TlsConfig tls) throws IOException {
return (SSLServerSocket) tls.serverSocketFactory().createServerSocket();
private static SSLServerSocket unboundSocket(TlsConfig tls) throws IOException {
return (SSLServerSocket) tls.serverSocketFactory().createServerSocket();
}
@Test
void keystore_pinsProtocolsToTls12And13(@TempDir Path dir) throws Exception {
Path ks =
TestKeystores.build(
dir, "id.p12", "changeit", TestKeystores.Entry.of("only", "single.test"));
TlsConfig tls = TlsConfig.keystore(ks, "changeit");
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
List<String> protocols = Arrays.asList(socket.getSSLParameters().getProtocols());
assertTrue(protocols.contains("TLSv1.2"));
assertTrue(protocols.contains("TLSv1.3"));
assertFalse(protocols.contains("SSLv3"));
assertFalse(protocols.contains("TLSv1"));
assertFalse(protocols.contains("TLSv1.1"));
}
}
@Test
void keystore_pinsProtocolsToTls12And13(@TempDir Path dir) throws Exception {
Path ks = TestKeystores.build(dir, "id.p12", "changeit",
TestKeystores.Entry.of("only", "single.test"));
TlsConfig tls = TlsConfig.keystore(ks, "changeit");
@Test
void ofContext_appliesNoParameterOverlay() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext(); // any valid SSLContext will do here
TlsConfig tls = TlsConfig.ofContext(ctx);
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
List<String> protocols = Arrays.asList(socket.getSSLParameters().getProtocols());
assertTrue(protocols.contains("TLSv1.2"));
assertTrue(protocols.contains("TLSv1.3"));
assertFalse(protocols.contains("SSLv3"));
assertFalse(protocols.contains("TLSv1"));
assertFalse(protocols.contains("TLSv1.1"));
}
try (SSLServerSocket socket = unboundSocket(tls)) {
SSLParameters before = socket.getSSLParameters();
String[] protocolsBefore = before.getProtocols();
tls.applyTo(socket);
assertArrayEquals(
protocolsBefore,
socket.getSSLParameters().getProtocols(),
"ofContext must not narrow/override protocols set on the caller's SSLContext");
assertFalse(socket.getNeedClientAuth());
assertFalse(socket.getWantClientAuth());
}
}
@Test
void ofContext_appliesNoParameterOverlay() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext(); // any valid SSLContext will do here
TlsConfig tls = TlsConfig.ofContext(ctx);
@Test
void ofContext_preservesCallerConfiguredAlpnAndProtocols() throws Exception {
// Mirrors the real use case this escape hatch exists for (TLS-ALPN-01 / RFC 8737):
// the caller sets its own ALPN protocol list and, to make the point unambiguous,
// a protocol list *narrower* than what Flash's own keystore() path would pin directly
// on the socket. applyTo() must not touch either. There is no SSLContext#setDefault-
// SSLParameters in the public JSSE API, so this per-socket SSLParameters is the only
// place such configuration can live; this test is the contract that makes it safe to
// rely on.
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
try (SSLServerSocket socket = unboundSocket(tls)) {
SSLParameters before = socket.getSSLParameters();
String[] protocolsBefore = before.getProtocols();
try (SSLServerSocket socket = unboundSocket(tls)) {
SSLParameters custom = socket.getSSLParameters();
custom.setApplicationProtocols(new String[] {"acme-tls/1", "http/1.1"});
custom.setProtocols(new String[] {"TLSv1.3"});
socket.setSSLParameters(custom);
tls.applyTo(socket);
tls.applyTo(socket);
assertArrayEquals(protocolsBefore, socket.getSSLParameters().getProtocols(),
"ofContext must not narrow/override protocols set on the caller's SSLContext");
assertFalse(socket.getNeedClientAuth());
assertFalse(socket.getWantClientAuth());
}
SSLParameters after = socket.getSSLParameters();
assertArrayEquals(
new String[] {"acme-tls/1", "http/1.1"},
after.getApplicationProtocols(),
"ofContext must not touch ALPN protocols the caller configured on its own socket");
assertArrayEquals(
new String[] {"TLSv1.3"},
after.getProtocols(),
"ofContext must not widen/override the caller's own protocol list");
// clientAuth still applies it is the caller's own explicit instruction through
// this API, not a Flash-imposed default. See TlsConfig's class Javadoc.
assertTrue(socket.getWantClientAuth());
}
}
@Test
void ofContext_preservesCallerConfiguredAlpnAndProtocols() throws Exception {
// Mirrors the real use case this escape hatch exists for (TLS-ALPN-01 / RFC 8737):
// the caller sets its own ALPN protocol list and, to make the point unambiguous,
// a protocol list *narrower* than what Flash's own keystore() path would pin directly
// on the socket. applyTo() must not touch either. There is no SSLContext#setDefault-
// SSLParameters in the public JSSE API, so this per-socket SSLParameters is the only
// place such configuration can live; this test is the contract that makes it safe to
// rely on.
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
@Test
void clientAuth_none_makesNoClientAuthCall() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx);
try (SSLServerSocket socket = unboundSocket(tls)) {
SSLParameters custom = socket.getSSLParameters();
custom.setApplicationProtocols(new String[] { "acme-tls/1", "http/1.1" });
custom.setProtocols(new String[] { "TLSv1.3" });
socket.setSSLParameters(custom);
tls.applyTo(socket);
SSLParameters after = socket.getSSLParameters();
assertArrayEquals(new String[] { "acme-tls/1", "http/1.1" }, after.getApplicationProtocols(),
"ofContext must not touch ALPN protocols the caller configured on its own socket");
assertArrayEquals(new String[] { "TLSv1.3" }, after.getProtocols(),
"ofContext must not widen/override the caller's own protocol list");
// clientAuth still applies it is the caller's own explicit instruction through
// this API, not a Flash-imposed default. See TlsConfig's class Javadoc.
assertTrue(socket.getWantClientAuth());
}
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertFalse(socket.getNeedClientAuth());
assertFalse(socket.getWantClientAuth());
}
}
@Test
void clientAuth_none_makesNoClientAuthCall() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx);
@Test
void clientAuth_require_setsNeedClientAuth() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.REQUIRE);
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertFalse(socket.getNeedClientAuth());
assertFalse(socket.getWantClientAuth());
}
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertTrue(socket.getNeedClientAuth());
}
}
@Test
void clientAuth_require_setsNeedClientAuth() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.REQUIRE);
@Test
void clientAuth_optional_setsWantClientAuth() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertTrue(socket.getNeedClientAuth());
}
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertTrue(socket.getWantClientAuth());
assertFalse(socket.getNeedClientAuth());
}
}
@Test
void clientAuth_optional_setsWantClientAuth() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
@Test
void negotiatesH2_trueOnlyWhenH2IsInTheOfferedList() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1").negotiatesH2());
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2").negotiatesH2());
assertFalse(TlsConfig.ofContext(ctx).applicationProtocols("http/1.1").negotiatesH2());
assertFalse(TlsConfig.ofContext(ctx).negotiatesH2()); // no applicationProtocols call at all
}
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertTrue(socket.getWantClientAuth());
assertFalse(socket.getNeedClientAuth());
}
@Test
void applyTo_withH2Offered_removesBlockedTls12CipherSuites() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1");
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
List<String> enabled = Arrays.asList(socket.getEnabledCipherSuites());
// Spot-check a handful of RFC 9113 Appendix A entries across different families
// (RSA key exchange, 3DES, plain ECDHE-CBC) rather than the full ~280-entry list
// TLS12_H2_BLOCKED_CIPHERS itself is the source of truth for the complete set.
assertFalse(enabled.contains("TLS_RSA_WITH_AES_128_CBC_SHA"));
assertFalse(enabled.contains("TLS_RSA_WITH_3DES_EDE_CBC_SHA"));
assertFalse(enabled.contains("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"));
assertFalse(enabled.contains("TLS_NULL_WITH_NULL_NULL"));
}
}
@Test
void applyTo_withH2Offered_keepsTheRequiredCipherSuiteWhenTheJdkEnabledIt() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2");
@Test
void negotiatesH2_trueOnlyWhenH2IsInTheOfferedList() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1").negotiatesH2());
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2").negotiatesH2());
assertFalse(TlsConfig.ofContext(ctx).applicationProtocols("http/1.1").negotiatesH2());
assertFalse(TlsConfig.ofContext(ctx).negotiatesH2()); // no applicationProtocols call at all
try (SSLServerSocket socket = unboundSocket(tls)) {
boolean jdkEnabledItByDefault =
Arrays.asList(socket.getEnabledCipherSuites())
.contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE);
tls.applyTo(socket);
if (jdkEnabledItByDefault) {
assertTrue(
Arrays.asList(socket.getEnabledCipherSuites())
.contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE),
"RFC 9113 §9.2.2 requires supporting this suite — filtering must never remove it");
}
}
}
@Test
void applyTo_withH2Offered_removesBlockedTls12CipherSuites() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1");
@Test
void applyTo_withoutH2Offered_leavesCipherSuitesUntouched() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("http/1.1");
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
List<String> enabled = Arrays.asList(socket.getEnabledCipherSuites());
// Spot-check a handful of RFC 9113 Appendix A entries across different families
// (RSA key exchange, 3DES, plain ECDHE-CBC) rather than the full ~280-entry list
// TLS12_H2_BLOCKED_CIPHERS itself is the source of truth for the complete set.
assertFalse(enabled.contains("TLS_RSA_WITH_AES_128_CBC_SHA"));
assertFalse(enabled.contains("TLS_RSA_WITH_3DES_EDE_CBC_SHA"));
assertFalse(enabled.contains("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"));
assertFalse(enabled.contains("TLS_NULL_WITH_NULL_NULL"));
}
try (SSLServerSocket socket = unboundSocket(tls)) {
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
tls.applyTo(socket);
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
}
}
@Test
void applyTo_withH2Offered_keepsTheRequiredCipherSuiteWhenTheJdkEnabledIt() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2");
@Test
void applyTo_noApplicationProtocolsAtAll_leavesCipherSuitesUntouched() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx);
try (SSLServerSocket socket = unboundSocket(tls)) {
boolean jdkEnabledItByDefault =
Arrays.asList(socket.getEnabledCipherSuites()).contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE);
tls.applyTo(socket);
if (jdkEnabledItByDefault) {
assertTrue(Arrays.asList(socket.getEnabledCipherSuites()).contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE),
"RFC 9113 §9.2.2 requires supporting this suite — filtering must never remove it");
}
}
try (SSLServerSocket socket = unboundSocket(tls)) {
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
tls.applyTo(socket);
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
}
}
@Test
void applyTo_withoutH2Offered_leavesCipherSuitesUntouched() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("http/1.1");
@Test
void enableHttp2AlpnPreservesCustomPriorityAndRetainsHttp1Fallback() throws Exception {
SSLContext ctx = SSLContext.getDefault();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("acme-tls/1");
SSLServerSocket socket =
(SSLServerSocket) tls.enableHttp2Alpn().serverSocketFactory().createServerSocket();
try (SSLServerSocket socket = unboundSocket(tls)) {
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
tls.applyTo(socket);
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
}
}
tls.enableHttp2Alpn().applyTo(socket);
@Test
void applyTo_noApplicationProtocolsAtAll_leavesCipherSuitesUntouched() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx);
try (SSLServerSocket socket = unboundSocket(tls)) {
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
tls.applyTo(socket);
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
}
}
assertArrayEquals(
new String[] {"acme-tls/1", "h2", "http/1.1"},
socket.getSSLParameters().getApplicationProtocols());
socket.close();
}
}
@@ -1,12 +1,12 @@
package dev.relism.flash.transport;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
@@ -16,55 +16,67 @@ import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;
/**
* A scratch is always released including on an exception path and a socket is always
* removed from {@code activeSockets}, regardless of how the dispatched
* checks list), verified here with a protocol implementation that deliberately throws.
* A scratch is always released including on an exception path and a socket is always removed
* from {@code activeSockets}, regardless of how the dispatched checks list), verified here with a
* protocol implementation that deliberately throws.
*/
class ConnectionRunnerTest {
@Test
void scratchAndActiveSocketEntry_alwaysReleased_evenWhenTheProtocolThrows() throws Exception {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
ScratchPool scratchPool = new ScratchPool();
AbstractRouter router = new FastPathRouterImpl();
AbstractWsRouter wsRouter = new FastPathWsRouterImpl();
FlashConfiguration configuration = FlashConfiguration.builder().port(0).build();
@Test
void scratchAndActiveSocketEntry_alwaysReleased_evenWhenTheProtocolThrows() throws Exception {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
ScratchPool scratchPool = new ScratchPool();
AbstractRouter router = new FastPathRouterImpl();
AbstractWsRouter wsRouter = new FastPathWsRouterImpl();
FlashConfiguration configuration = FlashConfiguration.builder().port(0).build();
ConnectionProtocol throwingProtocol = ctx -> {
throw new IOException("simulated protocol failure");
ConnectionProtocol throwingProtocol =
ctx -> {
throw new IOException("simulated protocol failure");
};
ConnectionRunner runner = new ConnectionRunner(
executor, activeSockets, scratchPool, router, wsRouter, configuration, throwingProtocol);
ConnectionRunner runner =
new ConnectionRunner(
executor,
activeSockets,
scratchPool,
router,
wsRouter,
configuration,
throwingProtocol,
() -> throwingProtocol);
try (ServerSocket serverSocket = new ServerSocket(0)) {
int port = serverSocket.getLocalPort();
CountDownLatch accepted = new CountDownLatch(1);
try (ServerSocket serverSocket = new ServerSocket(0)) {
int port = serverSocket.getLocalPort();
CountDownLatch accepted = new CountDownLatch(1);
Thread acceptThread = new Thread(() -> {
Thread acceptThread =
new Thread(
() -> {
try (Socket serverSide = serverSocket.accept()) {
runner.accept(serverSide, () -> false);
accepted.countDown();
Thread.sleep(300); // give the submitted virtual-thread task time to run
runner.accept(serverSide, () -> false);
accepted.countDown();
Thread.sleep(300); // give the submitted virtual-thread task time to run
} catch (Exception ignored) {
}
});
acceptThread.start();
});
acceptThread.start();
try (Socket client = new Socket("127.0.0.1", port)) {
assertTrue(accepted.await(2, TimeUnit.SECONDS));
Thread.sleep(300); // let ConnectionRunner's virtual thread finish
try (Socket client = new Socket("127.0.0.1", port)) {
assertTrue(accepted.await(2, TimeUnit.SECONDS));
Thread.sleep(300); // let ConnectionRunner's virtual thread finish
assertTrue(activeSockets.isEmpty(), "socket must be removed from activeSockets on every exit path");
}
acceptThread.join(2000);
} finally {
executor.shutdownNow();
}
assertTrue(
activeSockets.isEmpty(),
"socket must be removed from activeSockets on every exit path");
}
acceptThread.join(2000);
} finally {
executor.shutdownNow();
}
}
}