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
35 changed files with 2332 additions and 757 deletions
Showing only changes of commit a315e1df8b - Show all commits
+9 -4
View File
@@ -263,20 +263,25 @@ upgrading `Request` — no separate TLS state is tracked for WS.
## Architecture
```
ServerSocket.accept()
TransportFactory.create() # binds every listener, wires the connection runner
→ AcceptLoop # one per listener × accept thread; hands sockets off
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
→ Http1Connection.run() # the ConnectionProtocol seam; HTTP/2 plugs in here later
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
→ RequestHandler.handle() # user handler; return value sets body
→ Request.drain() # consume unread body for keep-alive
→ HttpServer writes response # status line, headers, then fixed or chunked body
→ loop or close socket # based on Connection header
→ Http1ResponseWriter.write() # status line, headers, then fixed or chunked body
→ loop or close socket # based on Connection header, or ServerLifecycle draining
```
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`). Java 21 required.
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
- **`ConnectionProtocol` seam** — h1 and h2 (in progress, see `flash/docs/http2/`) are peers behind this interface, decided once per connection by `ProtocolNegotiator`, never by an `if` inside shared code. See `flash/docs/http2/TRANSPORT.md` for the full component breakdown.
## Build & test
+63
View File
@@ -371,3 +371,66 @@ codebase's error-status convention, while the distinct type is what lets `HttpSe
at the parse site specifically.
**Revisit when.** Not expected to be revisited.
---
## DEC-15 — Phase 2 plan correction: the "no `ThreadLocal` anywhere" DoD line was inconsistent with `EX-06`'s own phasing
**Context.** Phase 2's DoD stated flatly: "No `ThreadLocal` remains anywhere in `flash` core."
`EX-06`'s registry entry — the fix this DoD line is checking — explicitly phases itself:
"**Phase**: 2 (introduce), 3 (h2 consumes it), 4 (router consumes it)." `FastPathRouterImpl` and
`FastPathWsRouterImpl`'s `ThreadLocal`s (`MatchResult`, `MethodPathByteView`) are the "router
consumes it" part, assigned to Phase 4 — where the router also gains the scratch-parameter (or
request-context) API surface change needed to remove them correctly, per `EX-06`'s own fix
description ("the router now takes the scratch as a parameter or reads it from the request's
context"). Taken literally, Phase 2's DoD line would have required either doing Phase 4's router
work two phases early (undermining the reason `EX-06` was split across phases in the first
place — the router-facing API change is more invasive and deserves its own phase) or leaving the
DoD unresolvable.
**Options.**
1. Do the full router `ThreadLocal` removal now, in Phase 2, to satisfy the DoD line literally.
2. Correct the DoD line to match `EX-06`'s already-considered phasing, and record why.
**Decision.** Option 2.
**Consequence.** Phase 2 removes every `ThreadLocal` `HttpServer` itself owned (`SHA1`,
`LONG_BUF`, `STREAM_RELAY_BUFFER` — all now fields on `ConnectionScratch`). The router's two
`ThreadLocal`s are explicitly left for Phase 4, tracked there, not silently dropped — this is
still R10-compliant (the defect is registered and scheduled, not ignored) and keeps Phase 2
scoped to what it already set out to do (kill the `HttpServer` god class), rather than absorbing
an unrelated API-surface change under deadline pressure.
**Revisit when.** N/A — resolved; Phase 4 closes the remaining `EX-06` scope.
---
## DEC-16 — No separate `WebSocketFrameCodec` class; the `EX-11`/`EX-12` fixes stay inside `WebSocketSession`
**Context.** Phase 2's file list named `dev.relism.flash.websocket.WebSocketFrameCodec.java`,
extracted from `WebSocketSession`, as a Phase 2 deliverable — motivated by R6 (no god classes)
and by a forward reference in Phase 15 ("this requires abstracting its InputStream/OutputStream
pair behind a small interface — which the Phase 2 WebSocketFrameCodec extraction should already
have made possible").
**Options.**
1. Extract a `WebSocketFrameCodec` operating on byte arrays/scratch buffers, with
`WebSocketSession` calling into it for encode/decode and owning only the actual stream I/O.
2. Keep frame encode/decode inside `WebSocketSession`, where it already lived.
**Decision.** Option 2, for this phase.
**Consequence.** `WebSocketSession` after the `EX-01`/`EX-11`/`EX-12` fixes is ~360 lines — over
R6's soft ~250-line guidance, but R6 itself carves out exactly this case: "a 300-line class that
is one cohesive state machine ... is fine; a 150-line class doing two things is not." Frame
header decode, continuation reassembly, and masking are one state machine (RFC 6455 §5's frame
grammar), not two unrelated responsibilities glued together, so the soft guidance's exception
applies. Splitting it now, before any concrete second caller exists, risks the "artificial
split that doesn't reduce complexity" R6 also warns against implicitly — there is no code today
that would consume a standalone codec except `WebSocketSession` itself. Phase 15's forward
reference is noted and re-evaluated then: if RFC 8441 (WebSocket over h2) genuinely needs frame
encode/decode decoupled from a socket-backed `InputStream`/`OutputStream` pair (an h2 stream is
not one), the extraction happens at that point, with a real second shape driving the interface
instead of a speculative one.
**Revisit when.** Phase 15, when RFC 8441's transport requirements are concrete.
+47 -27
View File
@@ -63,7 +63,7 @@ Status values: `not started` / `in progress` / `blocked` / `done`.
|---|---|---|---|
| 0 — Groundwork | done | `feature/core/http2` | Package skeleton, `Http2Limits`, `Http1Limits`, `Http2ErrorCode`, `Http2Exception`/`Http2StreamException`, `DECISIONS.md` (`DEC-01``DEC-11`), `package-info.java`. 226/226 tests green. |
| 1 — HTTP/1.1 hardening + ALPN/preface | done | `feature/core/http2` | EX-02/03/07/08/10/17/18/30/31 fixed; EX-35/36 found+fixed. `BufferedByteSource`, `ProtocolNegotiator`, `MalformedRequestException` added (plan corrected, DEC-12). 277/277 tests green (run twice). h1 benchmark check deferred — no JMH harness until Phase 3 (documented in DoD). |
| 2 — Transport decomposition | not started | — | — |
| 2 — Transport decomposition | done | `feature/core/http2` | `HttpServer.java` deleted; `transport`/`http1` packages + WS extraction (EX-01/06/11/12/13/14/15/16/32/34) done. Router `ThreadLocal` (EX-06 router half) deliberately deferred to Phase 4 per DEC-15. 311/311 tests green (run 3×). h1 benchmark check deferred — no JMH harness until Phase 3. |
| 3 — Serialized frame writer (GO/NO-GO gate) | not started | — | — |
| 4 — Byte-layer foundations | not started | — | — |
| 5 — Frame layer | not started | — | — |
@@ -1105,29 +1105,33 @@ replaced by pooled per-connection scratch, and the WebSocket header read stops a
nothing but stops syscalling per byte. No new steady-state allocation is introduced.
### Safety checks
- [ ] `ScratchPool` is bounded and cannot grow without limit
- [ ] A scratch is always released, including on exception paths (try/finally, not
try-with-resources unless `ConnectionScratch` implements `AutoCloseable` — if it does,
document that `close()` means "return to pool", not "destroy")
- [ ] A scratch returned to the pool is fully reset; no request data leaks between connections
(this is a **security** property, not just hygiene — add an explicit test)
- [ ] WebSocket: unmasked client frame → close 1002
- [ ] WebSocket: message exceeding the bound → close 1009
- [ ] WebSocket: invalid opcode → close 1002
- [ ] WebSocket: fragmented control frame → close 1002
- [x] `ScratchPool` is bounded and cannot grow without limit — `ScratchPoolTest.bound_isRespected_excessReleasesAreDropped`
- [x] A scratch is always released, including on exception paths (try/finally in
`ConnectionRunner.handle`) — `ConnectionRunnerTest.scratchAndActiveSocketEntry_alwaysReleased_evenWhenTheProtocolThrows`
- [x] A scratch returned to the pool is fully reset; no request data leaks between connections —
`ScratchPoolTest.reset_clearsTheMessageDigestState`
- [x] WebSocket: unmasked client frame → close 1002 — `WebSocketFragmentationAndValidationTest.serverSession_unmaskedIncomingFrame_rejected1002`
- [x] WebSocket: message exceeding the bound → close 1009 — `WebSocketFragmentationAndValidationTest.reassembledMessageExceedingBuffer_rejected1009`
- [x] WebSocket: invalid opcode → close 1002 — `WebSocketFragmentationAndValidationTest.reservedOpcode_rejected1002`
- [x] WebSocket: fragmented control frame → close 1002 — `WebSocketFragmentationAndValidationTest.fragmentedControlFrame_rejected1002`
### Tests
- All existing tests pass with only import changes.
- `ConnectionScratchTest` — pool bound respected; reset clears every field; a scratch reused
across two connections never exposes the first connection's bytes.
- `WebSocketFrameCodecTest` — continuation reassembly, masking enforcement, control-frame rules,
syscall count.
- `Http1ResponseWriterTest` — HEAD, 204, 304, `ContentType.NONE`, `Date` present/absent.
- `ServerLifecycleTest` — graceful drain completes in-flight requests; force-close after the
drain timeout.
- A new architecture test (simple reflection-based, or ArchUnit if the team accepts the
dependency — record the decision): `dev.relism.flash.http1` must not reference
`dev.relism.flash.h2` and vice versa.
- [x] All existing tests pass with only import changes (277 pre-Phase-2 tests unmodified in
behavior; two files touched only for the log-string/class-relocation, see PR).
- [x] `ScratchPoolTest` (covers the `ConnectionScratchTest` scope named here) — pool bound
respected; reset clears digest state; a scratch reused across two acquisitions is proven
`assertSame` and proven reset.
- [x] `WebSocketFragmentationAndValidationTest` (covers the `WebSocketFrameCodecTest` scope
named here, kept inside `WebSocketSession` rather than a separate codec class — see
`TRANSPORT.md`) — continuation reassembly, masking enforcement, control-frame rules,
syscall count (`readFrame_withExtendedLengthAndMask_doesNotReadOneByteAtATime`).
- [x] `Http1ResponseWriterTest` — HEAD, 204, 304, 1xx, `ContentType.NONE`, `Date` present/absent.
- [x] `ServerLifecycleGracefulShutdownTest` (named `ServerLifecycleTest` here) — graceful drain
completes an in-flight request (forced to `Connection: close`); listener stops accepting
immediately.
- [x] `PackageBoundaryTest` — a source-scan architecture test (decision recorded in the test's
own Javadoc: no ArchUnit dependency yet, and one import check per package pair does not
need one): `dev.relism.flash.http1` must not import `dev.relism.flash.h2` and vice versa.
### Docs
- `README.md` architecture section (lines 257-274) rewritten to reflect the new component
@@ -1137,11 +1141,27 @@ nothing but stops syscalling per byte. No new steady-state allocation is introdu
will extend.
### DoD
- [ ] `HttpServer.java` no longer exists (or is under 60 lines of pure composition).
- [ ] No `ThreadLocal` remains anywhere in `flash` core. (Grep for it in the DoD check.)
- [ ] No `synchronized` block in `flash` core encloses a blocking I/O call. (Grep + review.)
- [ ] Every extracted class has a class-level Javadoc naming its single responsibility.
- [ ] h1 benchmark: no regression; ideally an improvement from `EX-06` and `EX-11`.
- [x] `HttpServer.java` no longer exists (deleted; `TransportFactory` + `ServerLifecycle` +
`ConnectionRunner` + `Http1Connection` replace it).
- [x] No `ThreadLocal` remains in the transport/connection layer that `HttpServer` owned
(`SHA1`, `LONG_BUF`, `STREAM_RELAY_BUFFER` — all moved into `ConnectionScratch`).
**Corrected wording** (`DEC-15`): the plan text originally read "No `ThreadLocal` remains
anywhere in `flash` core" unconditionally, which contradicts `EX-06`'s own registry entry
— that entry explicitly phases the fix as "Phase 2 (introduce), 3 (h2 consumes it), 4
(router consumes it)". `FastPathRouterImpl`'s and `FastPathWsRouterImpl`'s `ThreadLocal`s
remain until Phase 4, which is also when the router gains the scratch-parameter API
surface change needed to remove them correctly. Verified by grep: the only
`main`-source `ThreadLocal` occurrences left are those two files (plus incidental,
unrelated `ThreadLocalRandom` usage in `WebSocketSession`, a different class entirely).
- [x] No `synchronized` block in `flash` core encloses a blocking I/O call. Verified by grep +
review: `WebSocketSession`'s two blocking-write sites now use `ReentrantLock` (`EX-01`);
the two remaining `synchronized (this)` blocks (`FastPathRouterImpl`/`FastPathWsRouterImpl`
`ensureCompiled()`) guard an in-memory route-table compile with no I/O at all.
- [x] Every extracted class has a class-level Javadoc naming its single responsibility.
- [ ] h1 benchmark: no regression; ideally an improvement from `EX-06` and `EX-11`. **Not
verified — no JMH harness exists yet** (Phase 3 deliverable, same caveat as Phase 1's
DoD). Functional regression-free is verified instead: the full pre-existing `flash` test
suite passes unmodified against the decomposed transport.
---
+152
View File
@@ -0,0 +1,152 @@
# Transport Architecture (Phase 2)
Audience: contributors. This is the document Phase 3 onward extends as HTTP/2 grows a real
connection state machine behind the seam described here.
## Why this exists
Before Phase 2, `HttpServer` (563 lines) did bind, accept, virtual-thread dispatch, WebSocket
upgrade detection, WebSocket handshake, the WebSocket session loop, keep-alive detection, HTTP
response serialization, chunked encoding, hex encoding, and decimal encoding — eleven reasons to
change in one class (R6). It also held three `ThreadLocal`s that meant "one per connection" under
virtual threads, not "one per core" (`EX-06`), and used `synchronized` around blocking socket
writes in `WebSocketSession`, which pins a virtual thread's carrier on Java 21 (`EX-01`).
Phase 2 replaces it with named, single-responsibility components and the `ConnectionProtocol`
seam HTTP/2 will plug into starting Phase 8.
## Package layout
```
dev.relism.flash.transport
├── TransportFactory composes everything below; ServerHandle.create()'s implementation (EX-34)
├── ListenerBinder FlashConfiguration.Listener -> bound ServerSocket
├── BoundListener record: the bound socket + whether it is TLS
├── TransportTuning accept-thread count / backlog / socket buffer size constants
├── AcceptLoop one listener's accept loop body
├── ConnectionRunner per-connection setup/teardown: TLS handshake, protocol negotiation,
│ dispatch to a ConnectionProtocol, guaranteed cleanup
├── ConnectionProtocol the h1/h2 seam: void run(ConnectionContext)
├── ConnectionContext everything a ConnectionProtocol needs, bundled (record)
├── ConnectionScratch per-connection reusable buffers (EX-06's fix)
├── ScratchPool a bounded cache of ConnectionScratch instances
├── ServerLifecycle implements ServerHandle: start/startAndBlock/stop, graceful shutdown (EX-32)
├── BufferedByteSource the buffered, deadline-aware, peekable inbound-byte source (Phase 1, EX-10)
└── ProtocolNegotiator/NegotiatedProtocol ALPN + h2c preface detection (Phase 1)
dev.relism.flash.http1
├── Http1Connection implements ConnectionProtocol: the h1 keep-alive request loop
├── Http1ResponseWriter serializes a Response as an HTTP/1.1 message
└── Http1KeepAlive keep-alive decision + the shared Connection-header token scanner (EX-13)
dev.relism.flash.websocket (existing package, extended)
├── WebSocketUpgrade upgrade detection + handshake response
├── WebSocketLoop the session read/dispatch loop
├── WebSocketSession per-connection WS I/O (frame codec + send API), EX-01/EX-11/EX-12
└── WebSocketProtocolException RFC 6455 violation, carries the correct close code
```
## The connection lifecycle
```
TransportFactory.create(configuration, router, wsRouter)
binds every configured listener (ListenerBinder)
builds one ConnectionRunner (shared virtual-thread executor, ScratchPool, Http1Connection)
returns a ServerLifecycle (implements ServerHandle)
ServerLifecycle.start()
for each listener, spawns TransportTuning.ACCEPT_THREADS platform threads
each runs AcceptLoop.run(listener, runner, this::isStopped)
AcceptLoop.run(...)
loop: listener.socket().accept() -> runner.accept(socket, stopped)
ConnectionRunner.accept(socket, stopped)
submits to the virtual-thread executor -> handle(socket, stopped)
ConnectionRunner.handle(socket, stopped)
activeSockets.add(socket); scratch = scratchPool.acquire()
try:
configure TCP_NODELAY / send buffer size
if SSLSocket: force startHandshake() under headerReadTimeoutMs (EX-30)
wrap streams: BufferedByteSource in, buffered OutputStream out, raw OutputStream rawOut
negotiated = negotiateProtocol(socket, in) # ALPN or h2c preface
if negotiated == H2: return # no Http2Connection yet (Phase 8) -- close cleanly
build ConnectionContext, dispatch to http1Protocol.run(ctx)
finally:
activeSockets.remove(socket); scratchPool.release(scratch)
```
`Http1Connection.run(ConnectionContext)` is where HTTP/1.1 semantics actually live: the
keep-alive loop, the idle/header/body deadline transitions (Phase 1), the `MalformedRequestException`
rejection path, the WebSocket upgrade handoff, and the response write.
## `ConnectionScratch` and `ScratchPool` (`EX-06`)
`ThreadLocal` is the right idiom when "one per thread" means "one per core" — a bounded
platform-thread pool. Flash runs one **virtual** thread per connection
(`Executors.newVirtualThreadPerTaskExecutor()`), so a `ThreadLocal` there means one per
*connection*, with no upper bound: at 100 000 concurrent connections, an 8 KB relay buffer alone
would be ~800 MB that a bounded pool would otherwise cap.
`ConnectionScratch` is therefore an explicit, plain object (decimal-encoding buffer, streaming
relay buffer, the WebSocket-handshake `MessageDigest`) acquired from a `ScratchPool` at
connection start and released at connection end. The pool is a bounded *cache*, not a
leak-free arena: above its bound (`min(availableProcessors * 64, 4096)` by default), a released
scratch is simply dropped for the garbage collector rather than queued, so an unusually large
burst of connections cannot grow it without limit.
The router's own `ThreadLocal`s (`FastPathRouterImpl`, `FastPathWsRouterImpl`) are **not**
removed in this phase — `EX-06`'s registry entry explicitly phases that part of the fix to
Phase 4, where the router also gains the API surface change (a scratch parameter, or reading
from the request's context) needed to remove them correctly. See `DECISIONS.md` (`DEC-15`) for
why Phase 2's Definition of Done was corrected to say so explicitly rather than silently drift
from the registry.
## The `ConnectionProtocol` seam (R1 / `DEC-02`)
```java
public interface ConnectionProtocol {
void run(ConnectionContext ctx) throws IOException;
}
```
`ConnectionRunner` decides h1 vs h2 exactly once, immediately after ALPN/preface detection, and
dispatches. Today only `Http1Connection` exists; an `H2` negotiation result is closed cleanly
(there is no `Http2Connection` to hand off to until Phase 8). Neither implementation is aware
the other exists — `dev.relism.flash.http1` and `dev.relism.flash.h2` do not import each other,
enforced by `PackageBoundaryTest`.
## Graceful shutdown (`EX-32`)
Two stages, driven by `ServerLifecycle.stop()`:
1. **Stop accepting.** Every listener socket is closed immediately; `stopped` flips to `true`.
2. **Drain, then force-close.** `Http1Connection`'s request loop checks `ctx.stopped()` twice:
once before waiting for the next request (exits immediately if already stopped, rather than
waiting out the idle-keep-alive timeout), and again right before writing the *current*
response — forcing `Connection: close` on it even if the response's own `Connection` header
logic would have said keep-alive, and even if shutdown began *while the handler was running*
(the common case). `ServerLifecycle.stop()` polls `activeSockets` for up to
`shutdownDrainTimeoutMs`, then force-closes whatever remains and shuts down the executor.
HTTP/2's half of this fix (a `GOAWAY` frame, RFC 9113 §6.8) lands in Phase 8.
## What changed for WebSocket (`EX-01`, `EX-11`, `EX-12`, `EX-13`)
- **`EX-01`**: `WebSocketSession`'s two blocking-write sites (`close`, `writeFrame`) now
serialize on a `ReentrantLock` instead of `synchronized (out)` — a virtual thread blocking
inside `synchronized` pins its carrier platform thread on Java 21 (JEP 491, which removes
this, is JDK 24+). `ReentrantLock` unmounts the blocked virtual thread instead.
- **`EX-11`**: `readFrame` used to read the extended-length and mask-key bytes one at a time.
It now reads that whole variable-length remainder in a single bounded `readFully` into the
existing `hdrScratch` array, then decodes with shifts.
- **`EX-12`**: `readFrame` now reassembles continuation frames into one logical message (bounded
by the same buffer a single frame already had), enforces the masking direction RFC 6455 §5.1
requires for this session's role, validates the opcode against the RFC's defined set, enforces
control-frame constraints (not fragmented, ≤125 bytes), and reports violations via
`WebSocketProtocolException` carrying the correct close code (1002 protocol error, 1009
message too big) for `WebSocketLoop` to send before closing.
- **`EX-13`**: the `Connection` header is a comma-separated token list, not a single value —
`Http1KeepAlive.tokenListContains` is the one scanner both the keep-alive decision and
`WebSocketUpgrade`'s `Connection: Upgrade` check use, so they cannot drift apart again.
@@ -1,667 +0,0 @@
package dev.relism.flash;
import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.tls.TlsConfig;
import dev.relism.flash.transport.BufferedByteSource;
import dev.relism.flash.transport.NegotiatedProtocol;
import dev.relism.flash.transport.ProtocolNegotiator;
import dev.relism.flash.websocket.WebSocketFrame;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketSession;
import dev.relism.fpr.core.ByteView;
import lombok.extern.slf4j.Slf4j;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLSocket;
import java.io.*;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Set;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
/**
* Pure I/O transport layer. Owns one {@link ServerSocket} per configured listener (plain or
* TLS), the virtual-thread executor, and the keep-alive accept loop. Routing is delegated to
* HTTP and WS routers — identically, regardless of which listener accepted the connection.
*
* <p>TLS is a transport-level concern only: once a {@link BoundListener} is bound, an accepted
* {@link Socket} is either plain or an {@code SSLSocket} indistinguishably from here on —
* {@link #process} never branches on it. This is also why WSS needs no separate code path from
* WS: the WebSocket upgrade happens over whatever transport {@link #process} was handed.
*
* <h3>Allocation model</h3>
* <ul>
* <li>{@code LONG_BUF} (20 bytes) and {@code STREAM_RELAY_BUFFER} (8 KB, for a streaming
* {@link Response} body — see {@link #writeStreamingBody}) are the only {@link ThreadLocal}s
* kept here. Both are per-connection, not per-request: one virtual thread runs a
* connection's whole keep-alive request loop (see {@link #process}), so a handler that
* streams a large response on every request allocates its relay buffer once per
* connection, not once per request.</li>
* <li>WS handshake SHA-1: {@link ThreadLocal}&lt;{@link MessageDigest}&gt; — one per
* accept thread (there are now {@code ACCEPT_THREADS} of them, not one).</li>
* </ul>
*/
@Slf4j
class HttpServer implements ServerHandle {
// ── Tuning constants ──────────────────────────────────────────────────────
/**
* Number of platform threads competing on {@code serverSocket.accept()}.
* Rule of thumb: number of available CPU cores, capped at 8.
* More than this rarely helps — accept is cheap; the bottleneck is usually
* the virtual-thread executor dispatching the connection handler.
*/
private static final int ACCEPT_THREADS = Math.min(Runtime.getRuntime().availableProcessors(), 8);
/**
* TCP listen backlog. The kernel holds up to this many fully-established
* (SYN+ACK sent, ACK received) connections waiting for accept().
* 4096 is safe on Linux; /proc/sys/net/core/somaxconn must be >= this value,
* or the kernel silently caps it. Raise somaxconn if needed:
* sysctl -w net.core.somaxconn=4096
*/
private static final int ACCEPT_BACKLOG = 4096;
/**
* Socket send/receive buffer sizes. Matched to the WS frame read buffer
* ({@link FlashConfiguration#getWsFrameBufferSize()}) so the kernel never
* needs to fragment a full frame into multiple TCP segments on the receive
* side, and never blocks a write waiting for the send buffer to drain.
*
* Linux default is 87380 bytes (rmem) / 16384 bytes (wmem). We raise both
* to 256 KB — a good fit for up to ~250 KB WS frames with no partial reads.
*/
private static final int SOCKET_BUF_SIZE = 256 * 1024;
// ── Instance fields ───────────────────────────────────────────────────────
private final FlashConfiguration configuration;
private final List<BoundListener> boundListeners;
private final AbstractRouter router;
private final AbstractWsRouter wsRouter;
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
private volatile boolean stopped = false;
/** Latch that reaches 0 when all accept threads, across all listeners, have exited. */
private final CountDownLatch acceptLatch;
/** One bound listener socket (plain or TLS) plus whether it is TLS, for logging only. */
private record BoundListener(ServerSocket socket, boolean secure) {}
// ── Static byte constants (written once, read-only on hot path) ──────────
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
private static final byte[] WS_HANDSHAKE_PREFIX =
("HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: ")
.getBytes(StandardCharsets.ISO_8859_1);
private static final byte[] WS_HANDSHAKE_SUFFIX =
"\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1);
private static final byte[] WS_REJECT_400 =
"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.getBytes(StandardCharsets.ISO_8859_1);
private static final byte[] WS_GUID_BYTES =
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1);
private static final ThreadLocal<MessageDigest> SHA1 =
ThreadLocal.withInitial(() -> {
try { return MessageDigest.getInstance("SHA-1"); }
catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); }
});
private static final ThreadLocal<byte[]> LONG_BUF = ThreadLocal.withInitial(() -> new byte[20]);
/**
* Relay buffer for copying a streaming {@link Response} body to the client — shared by
* {@link #writeStreamingBody}'s non-chunked path and {@link #writeChunked}, so both draw
* from the same reused array instead of each allocating its own {@code byte[8192]} (the
* non-chunked path previously relied on {@link InputStream#transferTo}, which allocates
* internally on every call). Sized to match the pre-existing behavior this replaces, not
* newly tuned — not exposed as a {@link FlashConfiguration} tunable since nothing here
* needed one before.
*/
private static final int STREAM_RELAY_BUFFER_SIZE = 8192;
private static final ThreadLocal<byte[]> STREAM_RELAY_BUFFER =
ThreadLocal.withInitial(() -> new byte[STREAM_RELAY_BUFFER_SIZE]);
private static final int SHA1_LEN = 20;
private static final int WS_ACCEPT_LEN = 28;
// ── Constructor ───────────────────────────────────────────────────────────
HttpServer(FlashConfiguration configuration, AbstractRouter router, AbstractWsRouter wsRouter) throws IOException {
this.configuration = configuration;
this.router = router;
this.wsRouter = wsRouter;
List<FlashConfiguration.Listener> specs = configuration.getListeners().isEmpty()
? List.of(new FlashConfiguration.Listener(
configuration.getPort(), configuration.getHost(), configuration.getTls()))
: configuration.getListeners();
List<BoundListener> bound = new ArrayList<>(specs.size());
for (FlashConfiguration.Listener spec : specs) bound.add(bind(spec));
this.boundListeners = List.copyOf(bound);
this.acceptLatch = new CountDownLatch(ACCEPT_THREADS * boundListeners.size());
for (BoundListener bl : boundListeners) {
log.info("HttpServer bound on {}:{} (tls={}, backlog={}, acceptThreads={})",
bl.socket().getInetAddress(), bl.socket().getLocalPort(), bl.secure(),
ACCEPT_BACKLOG, ACCEPT_THREADS);
}
}
/**
* Binds one listener. A TLS listener gets its {@link ServerSocket} from
* {@link TlsConfig#serverSocketFactory()} instead of {@code new ServerSocket()}, and its
* protocol/client-auth parameters from {@link TlsConfig#applyTo} — reuse-address, receive
* buffer size, backlog and the bind call itself are identical either way. TLS only changes
* which bytes come out of {@code accept()}; it never changes how the accept loop, or
* anything downstream of it, treats them.
*/
private static BoundListener bind(FlashConfiguration.Listener spec) throws IOException {
TlsConfig tls = spec.tls();
ServerSocket socket = tls != null ? tls.serverSocketFactory().createServerSocket() : new ServerSocket();
// setReuseAddress(true) must be called BEFORE bind().
socket.setReuseAddress(true);
socket.setReceiveBufferSize(SOCKET_BUF_SIZE);
if (tls != null) tls.applyTo((SSLServerSocket) socket);
InetSocketAddress addr = spec.host() != null
? new InetSocketAddress(spec.host(), spec.port())
: new InetSocketAddress(spec.port());
socket.bind(addr, ACCEPT_BACKLOG);
return new BoundListener(socket, tls != null);
}
// ── Lifecycle ─────────────────────────────────────────────────────────────
@Override
public void start() {
for (int li = 0; li < boundListeners.size(); li++) {
BoundListener listener = boundListeners.get(li);
for (int i = 0; i < ACCEPT_THREADS; i++) {
Thread.ofPlatform()
.name("flash-accept-" + li + "-" + i)
.daemon(false)
.start(() -> acceptLoop(listener));
}
}
}
@Override
public void startAndBlock() {
start();
try { acceptLatch.await(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
/**
* Single accept loop body — runs on each of the {@code ACCEPT_THREADS}
* platform threads bound to one {@code listener}. All threads for that listener block on
* the same {@link ServerSocket}; the JVM ensures only one wakes per incoming connection
* (no thundering herd). Other listeners' accept threads are entirely independent.
*/
private void acceptLoop(BoundListener listener) {
try {
while (!stopped) {
try {
process(listener.socket().accept());
} catch (IOException e) {
if (!stopped) log.error("Accept error", e);
}
}
} finally {
acceptLatch.countDown();
}
}
@Override
public CompletableFuture<Void> stop() {
return CompletableFuture.runAsync(() -> {
stopped = true;
for (BoundListener bl : boundListeners) {
try { bl.socket().close(); } catch (IOException e) { log.error("Error closing server socket", e); }
}
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
executorService.shutdown();
try {
if (!executorService.awaitTermination(30, TimeUnit.SECONDS))
executorService.shutdownNow();
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
});
}
// ── Hot-path ──────────────────────────────────────────────────────────────
private void process(Socket socket) {
try {
executorService.submit(() -> {
activeSockets.add(socket);
try (socket;
OutputStream out = new BufferedOutputStream(socket.getOutputStream())) {
// TCP_NODELAY: disable Nagle's algorithm.
// Small WS frames (< MSS) are sent immediately rather than
// waiting up to 200 ms for more data to coalesce. Latency
// drops significantly at the cost of slightly more TCP segments
// under sustained bulk transfer — acceptable for interactive WS.
socket.setTcpNoDelay(true);
socket.setSendBufferSize(SOCKET_BUF_SIZE);
// EX-30: force the TLS handshake explicitly, under a bounded timeout,
// before any protocol decision is made. SSLSocket#getApplicationProtocol()
// (which ProtocolNegotiator relies on) returns null until the handshake has
// actually completed; nothing previously forced that before the first read,
// which happened to work by accident (the JDK triggers it lazily on read)
// but left ALPN unreadable at exactly the point negotiation needs it.
if (socket instanceof SSLSocket sslSocketForHandshake) {
socket.setSoTimeout(configuration.getHeaderReadTimeoutMs());
sslSocketForHandshake.startHandshake();
socket.setSoTimeout(0); // BufferedByteSource's deadline takes over below
}
// rawOut is the unbuffered socket stream — passed to WebSocketSession
// directly. WS writes are already bulk (header + payload in two calls);
// with TCP_NODELAY the kernel ships them without Nagle delay, so no
// userspace buffer is needed and no flush() is required per frame.
// HTTP responses continue to use the BufferedOutputStream (out) because
// writeResponse() does many small individual writes that benefit from
// userspace coalescing before a single syscall.
OutputStream rawOut = socket.getOutputStream();
// EX-10: the single buffered, deadline-aware view over this connection's
// inbound bytes — see BufferedByteSource's Javadoc. Not part of the
// try-with-resources list above because closing `socket` already closes
// the stream it wraps (same reasoning that already applied to rawOut).
BufferedByteSource in = new BufferedByteSource(socket.getInputStream(), socket);
NegotiatedProtocol negotiated = negotiateProtocol(socket, in);
if (negotiated == NegotiatedProtocol.H2) {
// No Http2Connection exists yet (lands in Phase 8) — close cleanly
// rather than attempt to speak a protocol this version cannot serve.
return;
}
RequestParser parser = new RequestParser(
configuration.getMaxHeaderBufferSize(),
(InetSocketAddress) socket.getRemoteSocketAddress(),
socket instanceof SSLSocket sslSocket ? sslSocket : null);
byte[] idleProbe = new byte[1];
while (!stopped) {
// EX-07: wait for the next request to begin, bounded by the generous
// idle-keep-alive timeout — sitting idle between keep-alive requests is
// normal, not an attack. peek() lets us detect "bytes have started
// arriving" without handing them to the parser under the wrong deadline.
//
// Skipped entirely when the parser already has bytes buffered from a
// previous read (HTTP pipelining: a client that sent two requests back
// to back before reading either response). In that case the next
// request has, by definition, already started — peeking the *source*
// for a fresh byte would wait for something that is never coming there,
// since it already arrived and is sitting in the parser's own buffer.
if (!parser.hasBufferedBytes()) {
in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L);
int firstByteSeen;
try {
firstByteSeen = in.peek(idleProbe, 0, 1);
} catch (SocketTimeoutException e) {
break; // idle timeout — nothing pending; close quietly, like EOF
}
if (firstByteSeen <= 0) break; // clean EOF
}
// Bytes have started arriving: tighten to the slowloris-specific bound
// for the rest of the header block. A per-read SO_TIMEOUT alone would
// never trip here — see BufferedByteSource's Javadoc.
in.setDeadline(System.nanoTime() + configuration.getHeaderReadTimeoutMs() * 1_000_000L);
Request request;
try {
request = parser.parse(in);
} catch (MalformedRequestException e) {
// EX-02/03/08/18: a fixed, minimal, non-customizable rejection —
// never routed through the handler or the user's exception handler
// (see MalformedRequestException's Javadoc) — and the connection is
// always closed afterwards, never kept alive.
Response rejection = new Response(e.status(), e.getMessage(), ContentType.TEXT_PLAIN);
writeResponse(out, rejection, false);
break;
} catch (SocketTimeoutException e) {
break; // header-read deadline exceeded — close
}
if (request == null) break;
if (request.method() == HttpMethod.GET && isWebSocketUpgrade(request)) {
in.clearDeadline(); // the WS session loop is long-lived; it paces itself
WebSocketHandler wsHandler = wsRouter.route(request);
if (wsHandler == null) {
out.write(WS_REJECT_400);
out.flush();
break;
}
// Flush buffered HTTP bytes (the 101 response) before WebSocketSession
// takes over rawOut — otherwise the handshake reply stays stuck in
// the BufferedOutputStream buffer and the client never sees it.
performHandshake(out, request);
out.flush();
request.drain();
WebSocketSession session = new WebSocketSession(
in, rawOut, configuration.getWsFrameBufferSize(), request, false);
runWsLoop(session, wsHandler);
return;
}
// Headers are fully read; the body (if any) may still be pending —
// whether the handler consumes it or the automatic drain() below does,
// bound it by the same deadline (EX-07).
in.setDeadline(System.nanoTime() + configuration.getBodyReadTimeoutMs() * 1_000_000L);
boolean keepAlive = isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN);
RequestHandler handler = router.route(request);
if (handler == null) handler = router.getNotFoundHandler();
try {
Object result = handler.handle(request, response);
if (result instanceof Response r) response = r;
else if (result != null) response.setBody(result);
} catch (Exception ex) {
Object result = router.getExceptionHandler().handle(ex, request, response);
if (result instanceof Response r) response = r;
else if (result != null) response.setBody(result);
}
writeResponse(out, response, keepAlive);
request.drain();
in.clearDeadline();
if (!keepAlive) break;
}
} catch (IOException e) {
if (!stopped) {
if (e instanceof java.net.SocketException)
log.debug("Connection closed: {}", e.getMessage());
else
log.error("I/O error handling request", e);
}
} catch (Exception e) {
// Anything not an IOException here means a collaborator misbehaved on the TLS
// handshake path — most likely a custom TlsConfig#ofContext KeyManager/
// TrustManager throwing (e.g. a failed DB lookup or on-demand cert issuance).
// That failure is isolated to this one virtual thread/connection: the
// try-with-resources above still closes the socket, the finally below still
// runs, and the accept loop (a different thread entirely) never sees this.
if (!stopped) log.error("Unexpected error handling connection", e);
} finally {
activeSockets.remove(socket);
}
});
} catch (RejectedExecutionException ignored) {
try { socket.close(); } catch (IOException e) { log.debug("Error closing socket on shutdown", e); }
}
}
// ── Protocol negotiation ───────────────────────────────────────────────────
/**
* Decides h1 vs h2 for one 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. TLS/ALPN detection costs nothing
* (the handshake already resolved it) and is therefore always performed, regardless of the
* flag: what the flag gates is whether Flash even attempts the h2c preface peek on a
* plaintext socket, so that a plaintext connection with the feature left at its default
* behaves byte-for-byte like pre-HTTP/2 Flash.
*/
private NegotiatedProtocol negotiateProtocol(Socket socket, BufferedByteSource in) throws IOException {
if (socket instanceof SSLSocket) {
return ProtocolNegotiator.negotiate(socket, in);
}
if (!configuration.isHttp2Enabled()) {
return NegotiatedProtocol.HTTP_1_1;
}
in.setDeadline(System.nanoTime() + configuration.getIdleKeepAliveTimeoutMs() * 1_000_000L);
try {
return ProtocolNegotiator.negotiate(socket, in);
} finally {
in.clearDeadline();
}
}
// ── WebSocket upgrade detection (zero-alloc) ──────────────────────────────
private static boolean isWebSocketUpgrade(Request request) {
ByteView upgrade = request.getRequestLine().getHeaders().view("Upgrade");
if (upgrade == null) return false;
if (!tokenEqualsIgnoreCase(upgrade, 0, upgrade.length(), "websocket")) return false;
return connectionContainsUpgrade(request);
}
private static boolean connectionContainsUpgrade(Request request) {
ByteView conn = request.getRequestLine().getHeaders().view("Connection");
if (conn == null) return false;
int len = conn.length(), i = 0;
while (i < len) {
while (i < len && conn.byteAt(i) == ' ') i++;
int start = i;
while (i < len && conn.byteAt(i) != ',') i++;
if (tokenEqualsIgnoreCase(conn, start, i, "upgrade")) return true;
i++;
}
return false;
}
private static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) {
int tlen = token.length();
int wlen = end - start;
while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--;
if (wlen != tlen) return false;
for (int i = 0; i < tlen; i++) {
byte b = view.byteAt(start + i);
if (b >= 'A' && b <= 'Z') b += 32;
if (b != (byte) token.charAt(i)) return false;
}
return true;
}
// ── WebSocket handshake ───────────────────────────────────────────────────
private void performHandshake(OutputStream out, Request request) throws IOException {
ByteView keyView = request.getRequestLine().getHeaders().view("Sec-WebSocket-Key");
if (keyView == null) throw new IOException("Missing Sec-WebSocket-Key header");
MessageDigest sha1 = SHA1.get();
sha1.reset();
for (int i = 0, len = keyView.length(); i < len; i++) sha1.update(keyView.byteAt(i));
sha1.update(WS_GUID_BYTES);
byte[] accept = Base64.getEncoder().encode(sha1.digest());
out.write(WS_HANDSHAKE_PREFIX);
out.write(accept);
out.write(WS_HANDSHAKE_SUFFIX);
out.flush();
}
// ── WebSocket session loop ────────────────────────────────────────────────
private void runWsLoop(WebSocketSession session, WebSocketHandler handler) {
handler.onOpen(session);
WebSocketFrame frame = new WebSocketFrame();
try {
while (session.isOpen()) {
if (!session.readFrame(frame)) break;
switch (frame.opcode()) {
case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY
-> handler.onMessage(session, frame);
case WebSocketFrame.OP_CLOSE
-> session.closeFromPeer(frame);
case WebSocketFrame.OP_PING
-> session.sendPong(frame);
case WebSocketFrame.OP_PONG -> { /* heartbeat ack, no-op */ }
}
}
} catch (IOException e) {
handler.onError(session, e);
} finally {
handler.onClose(session, session.closeCode());
session.forceClose();
}
}
// ── HTTP keep-alive detection ─────────────────────────────────────────────
private static boolean isKeepAlive(Request request) {
if (request.headerEquals("Connection", "close")) return false;
ByteView protocol = request.getRequestLine().getProtocol();
int plen = protocol.length();
if (plen == 8) {
byte minor = protocol.byteAt(7);
if (minor == '1') return true;
if (minor == '0') return request.headerEquals("Connection", "keep-alive");
}
log.debug("Unrecognised protocol '{}', treating as close", protocol);
return false;
}
// ── Response serialisation ────────────────────────────────────────────────
private static void writeResponse(OutputStream out, Response response, boolean keepAlive) throws IOException {
out.write(HTTP_1_1);
byte[] statusBytes = response.getStatusBytes();
if (statusBytes != null) out.write(statusBytes);
else writeStatusPhrase(out, response.getStatusCode());
out.write(CRLF);
out.write(CONTENT_TYPE);
out.write(response.getContentType());
out.write(CRLF);
response.writeHeaders(out);
if (response.isStreaming()) {
writeStreamingBody(out, response, keepAlive);
} else {
byte[] body = response.getBody();
out.write(CONTENT_LENGTH);
writeLong(out, body != null ? body.length : 0);
out.write(CRLF);
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
if (body != null) out.write(body);
}
out.flush();
}
private static void writeStreamingBody(OutputStream out, Response response, boolean keepAlive) throws IOException {
if (!response.isChunked()) {
out.write(CONTENT_LENGTH);
writeLong(out, response.getStreamLength());
out.write(CRLF);
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
relay(response.getStream(), out);
} else {
out.write(TRANSFER_CHUNKED);
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
writeChunked(out, response.getStream());
}
}
/**
* Copies {@code in} to {@code out} until EOF, same contract as {@link InputStream#transferTo}
* — but via {@link #STREAM_RELAY_BUFFER} instead of a fresh {@code byte[]} per call, which is
* what {@code transferTo}'s own (JDK-internal) implementation would otherwise allocate on
* every streamed response.
*/
private static void relay(InputStream in, OutputStream out) throws IOException {
byte[] buf = STREAM_RELAY_BUFFER.get();
int n;
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
}
private static void writeStatusPhrase(OutputStream out, int statusCode) throws IOException {
byte[] phrase = HttpStatus.bytesForCode(statusCode);
if (phrase != null) out.write(phrase);
else { writeLong(out, statusCode); out.write(UNKNOWN_STATUS_SUFFIX); }
}
private static void writeLong(OutputStream out, long value) throws IOException {
if (value == 0) { out.write('0'); return; }
byte[] buf = LONG_BUF.get();
int pos = buf.length;
boolean neg = value < 0;
if (neg) value = -value;
do { buf[--pos] = (byte) ('0' + value % 10); value /= 10; } while (value > 0);
if (neg) buf[--pos] = '-';
out.write(buf, pos, buf.length - pos);
}
private static void writeChunked(OutputStream out, InputStream stream) throws IOException {
byte[] buf = STREAM_RELAY_BUFFER.get();
int n;
while ((n = stream.read(buf)) > 0) {
writeHex(out, n);
out.write(CRLF);
out.write(buf, 0, n);
out.write(CRLF);
}
out.write(FINAL_CHUNK);
}
private static void writeHex(OutputStream out, int value) throws IOException {
int shift = 28;
boolean leading = true;
while (shift >= 0) {
int digit = (value >>> shift) & 0xF;
if (digit != 0 || !leading) {
leading = false;
out.write(digit < 10 ? '0' + digit : 'a' + digit - 10);
}
shift -= 4;
}
if (leading) out.write('0');
}
}
@@ -106,7 +106,7 @@ public class RequestParser {
* complete), so an idle-timeout wait on the underlying source would wait for bytes that
* were never going to arrive there — they are already here.
*/
boolean hasBufferedBytes() {
public boolean hasBufferedBytes() {
return bufLen > 0;
}
@@ -4,6 +4,7 @@ import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.transport.TransportFactory;
import java.io.IOException;
import java.util.concurrent.CompletableFuture;
@@ -11,7 +12,8 @@ import java.util.concurrent.CompletableFuture;
/**
* Public handle to the underlying HTTP transport. Returned by {@link #create}
* so that {@link FlashApp} can start and stop the server
* without holding a direct reference to the package-private {@link HttpServer}.
* without holding a direct reference to the transport's internal composition
* ({@link TransportFactory}, {@code EX-34}).
*/
public interface ServerHandle {
@@ -30,6 +32,6 @@ public interface ServerHandle {
static ServerHandle create(FlashConfiguration config,
AbstractRouter httpRouter,
AbstractWsRouter wsRouter) throws IOException {
return new HttpServer(config, httpRouter, wsRouter);
return TransportFactory.create(config, httpRouter, wsRouter);
}
}
@@ -109,6 +109,14 @@ public class FlashConfiguration {
@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;
/** 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); }
@@ -0,0 +1,56 @@
package dev.relism.flash.http;
import java.nio.charset.StandardCharsets;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
/**
* {@code EX-16}: RFC 9110 §6.6.1 — an origin server with a clock SHOULD send {@code Date}.
* Flash never emitted it. Rather than formatting a timestamp on every response, a single
* daemon thread refreshes a pre-encoded {@code "Date: ...\r\n"} field line once per second into
* a {@code volatile byte[]}; {@code Http1ResponseWriter} writes it with one
* {@code OutputStream#write(byte[])} — the cost per response is one volatile read and one
* write, never a format call (R4).
*
* <p>The parallel HPACK-encoded rendering for HTTP/2 responses is added in Phase 9.
*/
public final class DateHeader {
private DateHeader() {
}
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.RFC_1123_DATE_TIME.withZone(ZoneOffset.UTC);
private static volatile byte[] current = encode();
static {
Thread refresher = new Thread(() -> {
while (true) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
current = encode();
}
}, "flash-date-header");
refresher.setDaemon(true);
refresher.start();
}
private static byte[] encode() {
String line = "Date: " + FORMATTER.format(ZonedDateTime.now(ZoneOffset.UTC)) + "\r\n";
return line.getBytes(StandardCharsets.US_ASCII);
}
/**
* The current pre-encoded {@code "Date: ...\r\n"} field line, accurate to within one
* second. Never allocates — the same array is returned until the next refresh.
*/
public static byte[] bytes() {
return current;
}
}
@@ -0,0 +1,131 @@
package dev.relism.flash.http1;
import dev.relism.flash.RequestParser;
import dev.relism.flash.exceptions.MalformedRequestException;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import dev.relism.flash.models.RequestHandler;
import dev.relism.flash.models.Response;
import dev.relism.flash.transport.BufferedByteSource;
import dev.relism.flash.transport.ConnectionContext;
import dev.relism.flash.transport.ConnectionProtocol;
import dev.relism.flash.websocket.WebSocketHandler;
import dev.relism.flash.websocket.WebSocketLoop;
import dev.relism.flash.websocket.WebSocketSession;
import dev.relism.flash.websocket.WebSocketUpgrade;
import java.io.IOException;
import java.io.OutputStream;
import java.net.SocketTimeoutException;
/**
* The HTTP/1.1 keep-alive request loop: parse → route → handle → respond, repeated until the
* connection closes. Sole responsibility: drive that loop for one connection; parsing lives in
* {@link RequestParser}, serialization in {@link Http1ResponseWriter}, and the WebSocket upgrade
* path hands off to {@link WebSocketUpgrade}/{@link WebSocketLoop} entirely — once a connection
* upgrades, this class has nothing further to do with it.
*/
public final class Http1Connection implements ConnectionProtocol {
@Override
public void run(ConnectionContext ctx) throws IOException {
RequestParser parser = new RequestParser(
ctx.configuration().getMaxHeaderBufferSize(),
ctx.remoteAddress(),
ctx.sslSocket());
BufferedByteSource in = ctx.in();
OutputStream out = ctx.out();
byte[] idleProbe = new byte[1];
while (!ctx.stopped().getAsBoolean()) {
// EX-07: wait for the next request to begin, bounded by the generous
// idle-keep-alive timeout — sitting idle between keep-alive requests is normal, not
// an attack. Skipped when the parser already has bytes buffered from a previous
// read (HTTP pipelining): the next request has, by definition, already started, so
// waiting on the *source* for a fresh byte would wait for something that already
// arrived and is sitting in the parser's own buffer.
if (!parser.hasBufferedBytes()) {
in.setDeadline(System.nanoTime() + ctx.configuration().getIdleKeepAliveTimeoutMs() * 1_000_000L);
int firstByteSeen;
try {
firstByteSeen = in.peek(idleProbe, 0, 1);
} catch (SocketTimeoutException e) {
break; // idle timeout — nothing pending; close quietly, like EOF
}
if (firstByteSeen <= 0) break; // clean EOF
}
// Bytes have started arriving: tighten to the slowloris-specific bound for the rest
// of the header block.
in.setDeadline(System.nanoTime() + ctx.configuration().getHeaderReadTimeoutMs() * 1_000_000L);
Request request;
try {
request = parser.parse(in);
} catch (MalformedRequestException e) {
// EX-02/03/08/18: a fixed, minimal, non-customizable rejection — never routed
// through a handler or the user's exception handler — and the connection is
// always closed afterwards, never kept alive.
Response rejection = new Response(e.status(), e.getMessage(), ContentType.TEXT_PLAIN);
Http1ResponseWriter.writeResponse(out, rejection, null, false, ctx.configuration().isSendDate(), ctx.scratch());
break;
} catch (SocketTimeoutException e) {
break; // header-read deadline exceeded — close
}
if (request == null) break;
if (request.method() == HttpMethod.GET && WebSocketUpgrade.isWebSocketUpgrade(request)) {
in.clearDeadline(); // the WS session loop is long-lived; it paces itself
WebSocketHandler wsHandler = ctx.wsRouter().route(request);
if (wsHandler == null) {
out.write(WebSocketUpgrade.REJECT_400);
out.flush();
break;
}
// Flush buffered HTTP bytes (the 101 response) before WebSocketSession takes
// over rawOut — otherwise the handshake reply stays stuck in the buffered
// stream and the client never sees it.
WebSocketUpgrade.performHandshake(out, request, ctx.scratch());
out.flush();
request.drain();
WebSocketSession session = new WebSocketSession(
in, ctx.rawOut(), ctx.configuration().getWsFrameBufferSize(), request, false);
WebSocketLoop.run(session, wsHandler);
return;
}
// Headers are fully read; the body (if any) may still be pending — whether the
// handler consumes it or the automatic drain() below does, bound it by the same
// deadline.
in.setDeadline(System.nanoTime() + ctx.configuration().getBodyReadTimeoutMs() * 1_000_000L);
boolean keepAlive = Http1KeepAlive.isKeepAlive(request);
Response response = new Response(200, ContentType.TEXT_PLAIN);
RequestHandler handler = ctx.router().route(request);
if (handler == null) handler = ctx.router().getNotFoundHandler();
try {
Object result = handler.handle(request, response);
if (result instanceof Response r) response = r;
else if (result != null) response.setBody(result);
} catch (Exception ex) {
Object result = ctx.router().getExceptionHandler().handle(ex, request, response);
if (result instanceof Response r) response = r;
else if (result != null) response.setBody(result);
}
// EX-32: re-checked here, not just before dispatch — a shutdown that begins while
// this handler was running (the common case: draining connections mid-request) must
// still force this response to Connection: close, not whatever was decided before
// the handler ran.
boolean actuallyKeepAlive = keepAlive && !ctx.stopped().getAsBoolean();
Http1ResponseWriter.writeResponse(out, response, request.method(), actuallyKeepAlive,
ctx.configuration().isSendDate(), ctx.scratch());
request.drain();
in.clearDeadline();
if (!actuallyKeepAlive) break;
}
}
}
@@ -0,0 +1,71 @@
package dev.relism.flash.http1;
import dev.relism.flash.models.Request;
import dev.relism.fpr.core.ByteView;
/**
* HTTP/1.1 keep-alive decision (RFC 9110 §7.6.1) and the shared {@code Connection} header
* token-list scanner both it and WebSocket upgrade detection need.
*
* <p>{@code EX-13}: {@code Connection} is a comma-separated token list
* (e.g. {@code "Connection: keep-alive, Upgrade"}), not a single value — a whole-value compare
* against {@code "close"} misses exactly that case. {@link #tokenListContains} is the one
* scanner both this class's {@link #isKeepAlive} and {@code WebSocketUpgrade}'s
* {@code Connection: Upgrade} check use, so the two can never drift apart again.
*/
public final class Http1KeepAlive {
private Http1KeepAlive() {
}
/**
* Whether the connection should remain open after this response. HTTP/1.1 defaults to
* keep-alive unless {@code Connection} lists {@code close}; HTTP/1.0 defaults to close
* unless it lists {@code keep-alive}.
*/
public static boolean isKeepAlive(Request request) {
if (connectionContainsToken(request, "close")) return false;
ByteView protocol = request.getRequestLine().getProtocol();
int plen = protocol.length();
if (plen == 8) {
byte minor = protocol.byteAt(7);
if (minor == '1') return true;
if (minor == '0') return connectionContainsToken(request, "keep-alive");
}
return false;
}
/** Whether the request's {@code Connection} header lists {@code token} (case-insensitive). */
public static boolean connectionContainsToken(Request request, String token) {
ByteView conn = request.getRequestLine().getHeaders().view("Connection");
if (conn == null) return false;
return tokenListContains(conn, token);
}
/** Scans a comma-separated token list for {@code token} (case-insensitive, OWS-tolerant). */
public static boolean tokenListContains(ByteView view, String token) {
int len = view.length(), i = 0;
while (i < len) {
while (i < len && view.byteAt(i) == ' ') i++;
int start = i;
while (i < len && view.byteAt(i) != ',') i++;
if (tokenEqualsIgnoreCase(view, start, i, token)) return true;
i++;
}
return false;
}
/** Case-insensitive compare of {@code view[start, end)}, trimming trailing spaces, against {@code token}. */
public static boolean tokenEqualsIgnoreCase(ByteView view, int start, int end, String token) {
int tlen = token.length();
int wlen = end - start;
while (wlen > 0 && view.byteAt(start + wlen - 1) == ' ') wlen--;
if (wlen != tlen) return false;
for (int i = 0; i < tlen; i++) {
byte b = view.byteAt(start + i);
if (b >= 'A' && b <= 'Z') b += 32;
if (b != (byte) token.charAt(i)) return false;
}
return true;
}
}
@@ -0,0 +1,170 @@
package dev.relism.flash.http1;
import dev.relism.flash.http.DateHeader;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.http.HttpStatus;
import dev.relism.flash.models.Response;
import dev.relism.flash.transport.ConnectionScratch;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
/**
* Serializes a {@link Response} as an HTTP/1.1 message. Sole responsibility: response
* serialization — routing, handler dispatch, and the request loop live in
* {@link Http1Connection}.
*
* <p>Zero-allocation: the decimal encoding of the status code / {@code Content-Length} and the
* relay buffer used for streaming bodies both come from the connection's {@link ConnectionScratch}
* ({@code EX-06}) instead of a per-call allocation or a {@code ThreadLocal}.
*/
public final class Http1ResponseWriter {
private Http1ResponseWriter() {
}
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
/**
* Writes {@code response} to {@code out} as a complete HTTP/1.1 message.
*
* @param method the request method — {@code null} is treated as "not HEAD" (used for
* parser-rejection responses, which never reach a handler and so have no
* associated method)
* @param sendDate whether to include the {@code Date} header ({@code FlashConfiguration#isSendDate()})
*/
public static void writeResponse(OutputStream out, Response response, HttpMethod method,
boolean keepAlive, boolean sendDate, ConnectionScratch scratch) throws IOException {
int statusCode = response.getStatusCode();
// RFC 9110 §8.6/§15: 204, 304 and all 1xx responses MUST NOT carry Content-Length or a
// body at all — not "an empty one", none (EX-15). A HEAD response (RFC 9110 §9.3.2)
// still reports the Content-Length GET would have, but never writes body bytes.
boolean noContentAllowed = statusCode == 204 || statusCode == 304 || (statusCode >= 100 && statusCode < 200);
boolean suppressBody = noContentAllowed || method == HttpMethod.HEAD;
out.write(HTTP_1_1);
byte[] statusBytes = response.getStatusBytes();
if (statusBytes != null) out.write(statusBytes);
else writeStatusPhrase(out, statusCode, scratch);
out.write(CRLF);
// EX-15: a Content-Type of ContentType.NONE (empty byte[]) used to still emit the line
// "Content-Type: \r\n" — a header with no value. Skip the line entirely instead.
byte[] contentType = response.getContentType();
if (contentType != null && contentType.length > 0) {
out.write(CONTENT_TYPE);
out.write(contentType);
out.write(CRLF);
}
// EX-16: precomputed once per second by a shared daemon thread — one volatile read,
// one write(byte[]), never a per-response format call.
if (sendDate) out.write(DateHeader.bytes());
response.writeHeaders(out);
if (response.isStreaming()) {
writeStreamingBody(out, response, keepAlive, noContentAllowed, suppressBody, scratch);
} else {
byte[] body = response.getBody();
int len = body != null ? body.length : 0;
if (!noContentAllowed) {
out.write(CONTENT_LENGTH);
writeLong(out, len, scratch);
out.write(CRLF);
}
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
// EX-14: HEAD reports the Content-Length GET would have (above) but never writes
// the body itself.
if (body != null && !suppressBody) out.write(body);
}
out.flush();
}
private static void writeStreamingBody(OutputStream out, Response response, boolean keepAlive,
boolean noContentAllowed, boolean suppressBody,
ConnectionScratch scratch) throws IOException {
if (!response.isChunked()) {
if (!noContentAllowed) {
out.write(CONTENT_LENGTH);
writeLong(out, response.getStreamLength(), scratch);
out.write(CRLF);
}
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
if (!suppressBody) relay(response.getStream(), out, scratch);
} else {
out.write(TRANSFER_CHUNKED);
out.write(keepAlive ? CONNECTION_KEEPALIVE : CONNECTION_CLOSE);
out.write(CRLF);
// A HEAD response still declares the Transfer-Encoding GET would have used (RFC
// 9110 §9.3.2) but writes zero body bytes — not even the final-chunk marker, since
// there is no chunk framing at all for a message with no body.
if (!suppressBody) writeChunked(out, response.getStream(), scratch);
}
}
/**
* Copies {@code in} to {@code out} until EOF, via {@link ConnectionScratch#relayBuffer}
* instead of a fresh {@code byte[]} per call.
*/
private static void relay(InputStream in, OutputStream out, ConnectionScratch scratch) throws IOException {
byte[] buf = scratch.relayBuffer;
int n;
while ((n = in.read(buf)) > 0) out.write(buf, 0, n);
}
private static void writeStatusPhrase(OutputStream out, int statusCode, ConnectionScratch scratch) throws IOException {
byte[] phrase = HttpStatus.bytesForCode(statusCode);
if (phrase != null) out.write(phrase);
else { writeLong(out, statusCode, scratch); out.write(UNKNOWN_STATUS_SUFFIX); }
}
private static void writeLong(OutputStream out, long value, ConnectionScratch scratch) throws IOException {
if (value == 0) { out.write('0'); return; }
byte[] buf = scratch.decimalBuffer;
int pos = buf.length;
boolean neg = value < 0;
if (neg) value = -value;
do { buf[--pos] = (byte) ('0' + value % 10); value /= 10; } while (value > 0);
if (neg) buf[--pos] = '-';
out.write(buf, pos, buf.length - pos);
}
private static void writeChunked(OutputStream out, InputStream stream, ConnectionScratch scratch) throws IOException {
byte[] buf = scratch.relayBuffer;
int n;
while ((n = stream.read(buf)) > 0) {
writeHex(out, n);
out.write(CRLF);
out.write(buf, 0, n);
out.write(CRLF);
}
out.write(FINAL_CHUNK);
}
private static void writeHex(OutputStream out, int value) throws IOException {
int shift = 28;
boolean leading = true;
while (shift >= 0) {
int digit = (value >>> shift) & 0xF;
if (digit != 0 || !leading) {
leading = false;
out.write(digit < 10 ? '0' + digit : 'a' + digit - 10);
}
shift -= 4;
}
if (leading) out.write('0');
}
}
@@ -0,0 +1,29 @@
package dev.relism.flash.transport;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.util.function.BooleanSupplier;
/**
* Single accept-loop body — runs on each of a listener's accept threads. All threads for the
* same listener block on the same {@link java.net.ServerSocket}; the JVM ensures only one wakes
* per incoming connection (no thundering herd). Other listeners' accept threads are entirely
* independent.
*/
@Slf4j
public final class AcceptLoop {
private AcceptLoop() {
}
public static void run(BoundListener listener, ConnectionRunner runner, BooleanSupplier stopped) {
while (!stopped.getAsBoolean()) {
try {
runner.accept(listener.socket().accept(), stopped);
} catch (IOException e) {
if (!stopped.getAsBoolean()) log.error("Accept error", e);
}
}
}
}
@@ -0,0 +1,7 @@
package dev.relism.flash.transport;
import java.net.ServerSocket;
/** One bound listener socket (plain or TLS) plus whether it is TLS, for logging only. */
public record BoundListener(ServerSocket socket, boolean secure) {
}
@@ -0,0 +1,49 @@
package dev.relism.flash.transport;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import javax.net.ssl.SSLSocket;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.function.BooleanSupplier;
/**
* Everything a {@link ConnectionProtocol} implementation needs to serve one connection, bundled
* into a single object instead of a long parameter list.
*
* @param socket the accepted socket — owns its lifecycle (closing it is the caller's,
* i.e. {@link ConnectionRunner}'s, responsibility, not the protocol's)
* @param sslSocket {@code socket} narrowed to {@link SSLSocket}, or {@code null} for a
* plaintext connection
* @param in the single buffered, deadline-aware source for this connection's
* inbound bytes
* @param out the buffered output stream — for header/body writes that benefit from
* userspace coalescing before a single syscall
* @param rawOut the unbuffered output stream — for WebSocket, whose writes are already
* bulk (see {@code WebSocketSession})
* @param remoteAddress the client's address, or {@code null} if unavailable
* @param scratch this connection's reusable buffers ({@code EX-06})
* @param router the HTTP router
* @param wsRouter the WebSocket router
* @param configuration the server configuration (timeouts, limits, feature flags)
* @param stopped {@code true} once the server has begun shutting down — a protocol
* implementation's request loop must check this and exit promptly
*/
public record ConnectionContext(
Socket socket,
SSLSocket sslSocket,
BufferedByteSource in,
OutputStream out,
OutputStream rawOut,
InetSocketAddress remoteAddress,
ConnectionScratch scratch,
AbstractRouter router,
AbstractWsRouter wsRouter,
FlashConfiguration configuration,
BooleanSupplier stopped
) {
}
@@ -0,0 +1,14 @@
package dev.relism.flash.transport;
import java.io.IOException;
/**
* The h1/h2 seam R1 requires: the protocol decision is made once, immediately after
* ALPN/preface detection ({@link ConnectionRunner}), and dispatches to one implementation of
* this interface. After that point neither implementation knows the other exists.
*/
public interface ConnectionProtocol {
/** Runs this connection to completion. Returns when the connection should be closed. */
void run(ConnectionContext ctx) throws IOException;
}
@@ -0,0 +1,142 @@
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;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketException;
import java.util.Set;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.RejectedExecutionException;
import java.util.function.BooleanSupplier;
/**
* Owns one connection's socket lifecycle from accept to close: configures socket options,
* forces the TLS handshake if applicable ({@code EX-30}), negotiates the protocol, and
* dispatches to the matching {@link ConnectionProtocol} — guaranteeing cleanup (scratch
* release, active-socket tracking) regardless of how the protocol implementation exits.
*
* <p>Sole responsibility: connection setup/teardown. It contains no HTTP semantics at all —
* those live entirely inside whichever {@link ConnectionProtocol} it dispatches to (today,
* always {@code Http1Connection}; an {@code H2} negotiation result is closed cleanly, since
* {@code Http2Connection} does not exist until Phase 8).
*/
@Slf4j
public final class ConnectionRunner {
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;
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;
}
/** 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) {
// EX-30: force the handshake explicitly, under a bounded timeout, before any
// protocol decision — SSLSocket#getApplicationProtocol() (which
// ProtocolNegotiator relies on) returns null until the handshake has run.
socket.setSoTimeout(configuration.getHeaderReadTimeoutMs());
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.H2) {
// No Http2Connection exists yet (lands in Phase 8) — close cleanly rather than
// 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
}
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();
}
}
}
@@ -0,0 +1,65 @@
package dev.relism.flash.transport;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
/**
* The {@code EX-06} fix. Owns every per-connection reusable buffer that used to live in a
* {@link ThreadLocal} on {@code HttpServer}: the decimal-formatting scratch, the streaming
* relay buffer, and the WebSocket-handshake {@link MessageDigest}.
*
* <h3>Why not {@code ThreadLocal}</h3>
* {@code ThreadLocal} is the right idiom for a bounded platform-thread pool, where "one per
* thread" means "one per core". Flash runs one <em>virtual</em> thread per connection
* ({@code Executors.newVirtualThreadPerTaskExecutor()}), so a {@code ThreadLocal} here means
* one per connection, not one per core — with no upper bound. At 100 000 concurrent
* connections, an 8 KB relay buffer alone is ~800 MB of memory that a bounded pool would
* instead cap. {@code ConnectionScratch} is therefore explicit and pooled ({@link ScratchPool}),
* not thread-local.
*
* <h3>Lifetime and thread-safety contract</h3>
* Allocated once per connection (or reused from {@link ScratchPool}), owned exclusively by the
* single virtual thread driving that connection for its whole lifetime, and returned to the
* pool when the connection closes. Never shared between two connections at once — there is no
* synchronization here because none is needed.
*
* <p>Extended in Phase 4 with the router's reusable {@code MatchResult}/path-view fields
* (currently still {@code ThreadLocal} in {@code FastPathRouterImpl}, per {@code EX-06}'s own
* multi-phase assignment — see {@code DECISIONS.md} for why Phase 2 does not also absorb that
* part of the fix) and in later phases with HTTP/2 write/HPACK scratch.
*/
public final class ConnectionScratch {
/** Matches the relay-buffer size the {@code ThreadLocal} it replaces used. */
public static final int RELAY_BUFFER_SIZE = 8192;
/** Large enough for the decimal digits of any {@code long}, including a sign. */
public static final int DECIMAL_BUFFER_SIZE = 20;
/** Scratch for {@code Http1ResponseWriter}'s decimal (status code / Content-Length) encoding. */
public final byte[] decimalBuffer = new byte[DECIMAL_BUFFER_SIZE];
/** Scratch for relaying a streaming or chunked response body without allocating per response. */
public final byte[] relayBuffer = new byte[RELAY_BUFFER_SIZE];
/** Scratch for the WebSocket handshake's {@code Sec-WebSocket-Accept} SHA-1 digest. */
public final MessageDigest sha1;
ConnectionScratch() {
try {
this.sha1 = MessageDigest.getInstance("SHA-1");
} catch (NoSuchAlgorithmException e) {
// Every JDK ships SHA-1 — this is a broken-runtime condition, not a request-time one.
throw new IllegalStateException("SHA-1 MessageDigest unavailable", e);
}
}
/** Called by {@link ScratchPool} before handing a reused instance to a new connection. */
void reset() {
sha1.reset();
// decimalBuffer/relayBuffer need no clearing: every reader of either only ever reads
// back exactly the region the immediately preceding writer just wrote (writeLong fills
// from the end backward and reports its own start position; relay() reports its own
// fill length), so stale bytes from a previous connection are never observed.
}
}
@@ -0,0 +1,43 @@
package dev.relism.flash.transport;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.tls.TlsConfig;
import javax.net.ssl.SSLServerSocket;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.ServerSocket;
/**
* Turns a {@link FlashConfiguration.Listener} into a bound {@link ServerSocket}. Sole
* responsibility: binding — not accepting, not connection handling.
*
* <p>A TLS listener gets its {@link ServerSocket} from {@link TlsConfig#serverSocketFactory()}
* instead of {@code new ServerSocket()}, and its protocol/client-auth/cipher parameters from
* {@link TlsConfig#applyTo}; reuse-address, receive buffer size, backlog and the bind call
* itself are identical either way. TLS only changes which bytes come out of {@code accept()};
* it never changes how the accept loop, or anything downstream of it, treats them.
*/
public final class ListenerBinder {
private ListenerBinder() {
}
public static BoundListener bind(FlashConfiguration.Listener spec) throws IOException {
TlsConfig tls = spec.tls();
ServerSocket socket = tls != null ? tls.serverSocketFactory().createServerSocket() : new ServerSocket();
// setReuseAddress(true) must be called BEFORE bind().
socket.setReuseAddress(true);
socket.setReceiveBufferSize(TransportTuning.SOCKET_BUF_SIZE);
if (tls != null) tls.applyTo((SSLServerSocket) socket);
InetSocketAddress addr = spec.host() != null
? new InetSocketAddress(spec.host(), spec.port())
: new InetSocketAddress(spec.port());
socket.bind(addr, TransportTuning.ACCEPT_BACKLOG);
return new BoundListener(socket, tls != null);
}
}
@@ -0,0 +1,61 @@
package dev.relism.flash.transport;
import java.util.concurrent.ConcurrentLinkedQueue;
import java.util.concurrent.atomic.AtomicInteger;
/**
* A bounded cache of {@link ConnectionScratch} instances, reused across connections instead of
* being allocated and garbage-collected per connection.
*
* <p>This is a <em>cache</em>, not a leak-free arena: a burst of 100 000 concurrent connections
* still allocates 100 000 {@link ConnectionScratch} instances (one per connection, since each
* connection needs its own for as long as it is open), but only {@link #bound} of them survive
* being released back to the pool afterward — the rest are simply dropped for the garbage
* collector, exactly as they would have been without this class. What the pool buys is avoiding
* repeated allocation for the common case of many short-lived or sequential connections sharing
* a bounded set of scratch objects.
*
* <h3>Thread-safety</h3>
* {@link #acquire()} and {@link #release} are safe to call concurrently from any number of
* threads — the underlying queue and size guard are lock-free.
*/
public final class ScratchPool {
/** Default bound: generous enough that a real workload rarely misses, small enough that it
* is not itself a meaningful memory commitment (a few hundred KB at most). */
public static final int DEFAULT_BOUND = Math.min(Runtime.getRuntime().availableProcessors() * 64, 4096);
private final ConcurrentLinkedQueue<ConnectionScratch> pool = new ConcurrentLinkedQueue<>();
private final AtomicInteger size = new AtomicInteger();
private final int bound;
public ScratchPool() {
this(DEFAULT_BOUND);
}
public ScratchPool(int bound) {
this.bound = bound;
}
/** Returns a reset, ready-to-use scratch — either reused from the pool or freshly allocated. */
public ConnectionScratch acquire() {
ConnectionScratch scratch = pool.poll();
if (scratch != null) {
size.decrementAndGet();
scratch.reset();
return scratch;
}
return new ConnectionScratch();
}
/**
* Returns {@code scratch} to the pool for reuse, unless the pool is already at its bound —
* in which case it is simply dropped, for the garbage collector, so an unusually large burst
* of connections cannot grow this cache without limit.
*/
public void release(ConnectionScratch scratch) {
if (size.get() >= bound) return;
size.incrementAndGet();
pool.offer(scratch);
}
}
@@ -0,0 +1,115 @@
package dev.relism.flash.transport;
import dev.relism.flash.ServerHandle;
import dev.relism.flash.extension.FlashConfiguration;
import lombok.extern.slf4j.Slf4j;
import java.io.IOException;
import java.net.Socket;
import java.util.List;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.TimeUnit;
/**
* Owns the server's lifecycle: the accept threads (one per listener ×
* {@code TransportTuning.ACCEPT_THREADS}), the active-socket registry, and the two-stage
* graceful shutdown ({@code EX-32}) — stop accepting, let in-flight connections drain up to
* {@code shutdownDrainTimeoutMs} (during which {@code Http1Connection} forces
* {@code Connection: close} on the next response once it observes {@link #isStopped()}), then
* force-close whatever remains.
*
* <p>Implements {@link ServerHandle} directly — its three methods already match that contract
* exactly, so no separate wrapper class is needed.
*/
@Slf4j
public final class ServerLifecycle implements ServerHandle {
private final List<BoundListener> listeners;
private final ConnectionRunner runner;
private final FlashConfiguration configuration;
private final ExecutorService executorService;
private final Set<Socket> activeSockets;
private final CountDownLatch acceptLatch;
private volatile boolean stopped = false;
public ServerLifecycle(List<BoundListener> listeners, ConnectionRunner runner,
FlashConfiguration configuration, ExecutorService executorService,
Set<Socket> activeSockets) {
this.listeners = listeners;
this.runner = runner;
this.configuration = configuration;
this.executorService = executorService;
this.activeSockets = activeSockets;
this.acceptLatch = new CountDownLatch(TransportTuning.ACCEPT_THREADS * listeners.size());
}
/** Whether the server has begun shutting down. Passed down to every connection as a
* {@link java.util.function.BooleanSupplier} so in-flight request loops can drain
* promptly instead of waiting for their next keep-alive request. */
public boolean isStopped() {
return stopped;
}
@Override
public void start() {
for (int li = 0; li < listeners.size(); li++) {
BoundListener listener = listeners.get(li);
for (int i = 0; i < TransportTuning.ACCEPT_THREADS; i++) {
Thread.ofPlatform()
.name("flash-accept-" + li + "-" + i)
.daemon(false)
.start(() -> {
try {
AcceptLoop.run(listener, runner, this::isStopped);
} finally {
acceptLatch.countDown();
}
});
}
}
}
@Override
public void startAndBlock() {
start();
try { acceptLatch.await(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
@Override
public CompletableFuture<Void> stop() {
return CompletableFuture.runAsync(() -> {
stopped = true;
for (BoundListener bl : listeners) {
try { bl.socket().close(); } catch (IOException e) { log.error("Error closing server socket", e); }
}
// EX-32: give in-flight connections a chance to finish their current response and
// exit (Http1Connection forces Connection: close once it observes isStopped())
// before force-closing whatever is still open.
long deadlineNanos = System.nanoTime() + configuration.getShutdownDrainTimeoutMs() * 1_000_000L;
while (!activeSockets.isEmpty() && System.nanoTime() < deadlineNanos) {
try {
Thread.sleep(50);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
}
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) { } });
executorService.shutdown();
try {
if (!executorService.awaitTermination(30, TimeUnit.SECONDS))
executorService.shutdownNow();
} catch (InterruptedException e) {
executorService.shutdownNow();
Thread.currentThread().interrupt();
}
});
}
}
@@ -0,0 +1,63 @@
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.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;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* 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.
*
* <p>{@code EX-34}: this is the "composed transport rather than a god object" the registry
* asked for — {@code ServerHandle.create} used to construct {@code HttpServer} directly, which
* no longer exists. Package-private-in-spirit (public only because {@code ServerHandle} lives
* in a different package and must call it) — user code has no reason to call this directly.
*/
@Slf4j
public final class 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 spec : specs) 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);
}
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);
}
}
@@ -0,0 +1,39 @@
package dev.relism.flash.transport;
/** Tuning constants shared by {@link ListenerBinder}, {@link ServerLifecycle}, and
* {@link ConnectionRunner} — grouped here so the accept-side and connection-side constants
* that must stay consistent with each other (e.g. the socket buffer size applied at bind time
* and at accept time) are declared exactly once. */
final class TransportTuning {
private TransportTuning() {
}
/**
* Number of platform threads competing on {@code serverSocket.accept()}.
* Rule of thumb: number of available CPU cores, capped at 8.
* More than this rarely helps — accept is cheap; the bottleneck is usually
* the virtual-thread executor dispatching the connection handler.
*/
static final int ACCEPT_THREADS = Math.min(Runtime.getRuntime().availableProcessors(), 8);
/**
* TCP listen backlog. The kernel holds up to this many fully-established
* (SYN+ACK sent, ACK received) connections waiting for accept().
* 4096 is safe on Linux; /proc/sys/net/core/somaxconn must be >= this value,
* or the kernel silently caps it. Raise somaxconn if needed:
* sysctl -w net.core.somaxconn=4096
*/
static final int ACCEPT_BACKLOG = 4096;
/**
* Socket send/receive buffer sizes. Matched to the WS frame read buffer
* ({@code FlashConfiguration#getWsFrameBufferSize()}) so the kernel never
* needs to fragment a full frame into multiple TCP segments on the receive
* side, and never blocks a write waiting for the send buffer to drain.
*
* Linux default is 87380 bytes (rmem) / 16384 bytes (wmem). We raise both
* to 256 KB — a good fit for up to ~250 KB WS frames with no partial reads.
*/
static final int SOCKET_BUF_SIZE = 256 * 1024;
}
@@ -0,0 +1,43 @@
package dev.relism.flash.websocket;
import java.io.IOException;
/**
* Drives one {@link WebSocketSession}'s read loop until the session closes, dispatching frames
* to the user's {@link WebSocketHandler}. Extracted from {@code HttpServer} (Phase 2) — its only
* responsibility is this loop; the handshake and upgrade detection live in
* {@link WebSocketUpgrade}.
*/
public final class WebSocketLoop {
private WebSocketLoop() {
}
public static void run(WebSocketSession session, WebSocketHandler handler) {
handler.onOpen(session);
WebSocketFrame frame = new WebSocketFrame();
try {
while (session.isOpen()) {
if (!session.readFrame(frame)) break;
switch (frame.opcode()) {
case WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY
-> handler.onMessage(session, frame);
case WebSocketFrame.OP_CLOSE
-> session.closeFromPeer(frame);
case WebSocketFrame.OP_PING
-> session.sendPong(frame);
case WebSocketFrame.OP_PONG -> { /* heartbeat ack, no-op */ }
}
}
} catch (WebSocketProtocolException e) {
// EX-12: tell the peer why, with the correct close code, before tearing down.
try { session.close(e.closeCode()); } catch (IOException ignored) { }
handler.onError(session, e);
} catch (IOException e) {
handler.onError(session, e);
} finally {
handler.onClose(session, session.closeCode());
session.forceClose();
}
}
}
@@ -0,0 +1,23 @@
package dev.relism.flash.websocket;
import java.io.IOException;
/**
* A WebSocket frame violated RFC 6455 (bad opcode, wrong masking direction, oversized control
* frame, fragmented control frame, or a message exceeding the session's buffer). Carries the
* close code ({@code 1002} protocol error, {@code 1009} message too big) the session must send
* before closing — see {@code WebSocketLoop}, the single site that catches this.
*/
public final class WebSocketProtocolException extends IOException {
private final int closeCode;
public WebSocketProtocolException(int closeCode, String message) {
super(message);
this.closeCode = closeCode;
}
public int closeCode() {
return closeCode;
}
}
@@ -10,13 +10,14 @@ import java.io.InputStream;
import java.io.OutputStream;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
/**
* Per-connection WebSocket I/O state. One instance per virtual thread.
*
* <ul>
* <li>
* <p>With {@code TCP_NODELAY} enabled on the socket (set in {@code HttpServer}),
* <p>With {@code TCP_NODELAY} enabled on the socket (set by the connection runner),
* Nagle's algorithm is disabled: the kernel sends data as soon as it lands in
* the send buffer, without waiting. {@link java.io.BufferedOutputStream} will
* still batch multiple small writes into one syscall when they happen in the
@@ -26,7 +27,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
* <p>The only place an explicit flush is still needed is after the WS
* handshake (one-time, not on the hot path) and after the CLOSE frame
* (end of session). Both are handled in {@link #close} and in
* {@code HttpServer#performHandshake}.</li>
* {@code WebSocketUpgrade#performHandshake}.</li>
*
* <li><b>Flush on CLOSE frame</b>: {@link #close} still flushes explicitly
* because the CLOSE frame is the last thing written before the stream is
@@ -34,10 +35,23 @@ import java.util.concurrent.atomic.AtomicBoolean;
* </ul>
*
* <h3>Thread safety</h3>
* {@link #sendText}, {@link #send}, and {@link #close} are synchronized on
* {@code out} and safe to call from threads other than the session loop.
* {@link #close} uses a CAS on {@code open} to guarantee exactly-once CLOSE
* {@link #sendText}, {@link #send}, and {@link #close} are serialized on a
* {@link ReentrantLock} (never {@code synchronized} — see {@code EX-01}: a virtual thread
* blocking inside {@code synchronized} pins its carrier platform thread on Java 21, and a
* blocking socket write is exactly the kind of call that can block. {@link ReentrantLock}
* unmounts the blocked virtual thread instead) and are safe to call from threads other than the
* session loop. {@link #close} uses a CAS on {@code open} to guarantee exactly-once CLOSE
* frame emission under concurrent calls.
*
* <h3>Fragmentation, masking, and control frames (RFC 6455 §5)</h3>
* {@link #readFrame} reassembles continuation frames into one logical message (bounded by the
* read buffer's capacity — the same bound a single unfragmented frame already had), enforces
* that incoming frames are masked exactly when this session's role requires it (server sessions
* require masked frames from the client; client-mode sessions require unmasked frames from the
* server), validates the opcode against RFC 6455's defined set, and enforces the control-frame
* constraints (FIN must be set, payload ≤ 125 bytes). A violation throws
* {@link WebSocketProtocolException} carrying the correct close code (1002 protocol error, 1009
* message too big) for the caller to send before closing.
*/
public final class WebSocketSession {
@@ -47,12 +61,27 @@ public final class WebSocketSession {
private final Request request;
private final boolean maskOutgoing;
/** Server sessions (the common case) require every incoming frame to be masked, per RFC
* 6455 §5.1 ("a server MUST close the connection upon receiving a frame that is not
* masked"). A client-mode session ({@link #maskOutgoing} true) requires the opposite. */
private final boolean requireMaskedIncoming;
private final AtomicBoolean open = new AtomicBoolean(true);
private int closeCode = 1000;
private final ReentrantLock writeLock = new ReentrantLock();
/** 1 opcode byte + up to 8 extended-length bytes + up to 4 mask-key bytes (masked mode only). */
private final byte[] hdrScratch = new byte[14];
/** Scratch for control-frame payloads (RFC 6455 §5.5: at most 125 bytes), kept separate
* from {@link #readBuf} so a control frame arriving mid-fragmentation (RFC 6455 §5.4
* permits this) never disturbs the data message being reassembled there. */
private final byte[] controlBuf = new byte[125];
// Fragmentation state (RFC 6455 §5.4). fragmentLength == 0 means "no message in progress".
private byte fragmentOpcode;
private int fragmentLength;
public WebSocketSession(InputStream in, OutputStream out, int bufferSize) {
this(in, out, bufferSize, null, false);
}
@@ -64,7 +93,9 @@ public final class WebSocketSession {
* @param maskOutgoing {@code true} if this session is acting as a WS <em>client</em> — RFC 6455
* requires client-to-server frames to be masked, unlike the server-to-client
* direction {@link #writeFrame} originally only supported. See {@link
* #writeFrame} for how masking is applied without allocating.
* #writeFrame} for how masking is applied without allocating. Also
* determines the expected masking of *incoming* frames — see
* {@link #requireMaskedIncoming}.
*/
public WebSocketSession(InputStream in, OutputStream out, int bufferSize, Request request, boolean maskOutgoing) {
this.in = in;
@@ -72,6 +103,7 @@ public final class WebSocketSession {
this.readBuf = new byte[bufferSize];
this.request = request;
this.maskOutgoing = maskOutgoing;
this.requireMaskedIncoming = !maskOutgoing;
}
public boolean isOpen() { return open.get(); }
@@ -109,52 +141,130 @@ public final class WebSocketSession {
*/
public void close(int code) throws IOException {
if (!open.compareAndSet(true, false)) return;
synchronized (out) {
writeLock.lock();
try {
out.write(0x88);
out.write(0x02);
out.write((code >> 8) & 0xFF);
out.write(code & 0xFF);
} finally {
writeLock.unlock();
}
}
// ── Session loop internals ─────────────────────────────────────────────
/**
* Reads the next complete message, reassembling continuation frames and delivering control
* frames (CLOSE/PING/PONG) as soon as they arrive — RFC 6455 §5.4 explicitly permits a
* control frame to interleave with a fragmented data message, and this must not disturb the
* data message's in-progress reassembly.
*
* @return {@code false} only on a clean EOF between messages (the peer closed the TCP
* connection without sending a CLOSE frame); an EOF in the middle of a frame is a
* protocol violation and throws, it is not reported as {@code false}.
* @throws WebSocketProtocolException on any RFC 6455 violation (bad opcode, unmasked/masked
* frame when the opposite was required, oversized control frame, fragmented control
* frame, message exceeding the buffer) — carries the correct close code.
*/
public boolean readFrame(WebSocketFrame frame) throws IOException {
while (true) {
int b0 = in.read();
if (b0 < 0) return false;
if (b0 < 0) return false; // clean EOF between messages
int b1 = in.read();
if (b1 < 0) return false;
if (b1 < 0) throw new EOFException("WebSocket stream closed mid-frame");
boolean fin = (b0 & 0x80) != 0;
byte opcode = (byte) (b0 & 0x0F);
boolean masked = (b1 & 0x80) != 0;
long payLen = (b1 & 0x7F);
int lenBits = b1 & 0x7F;
if (payLen == 126) {
payLen = ((in.read() & 0xFF) << 8) | (in.read() & 0xFF);
} else if (payLen == 127) {
payLen = 0;
for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (in.read() & 0xFF);
validateOpcode(opcode);
if (masked != requireMaskedIncoming) {
throw new WebSocketProtocolException(1002,
requireMaskedIncoming ? "client frame must be masked" : "server frame must not be masked");
}
if (payLen > readBuf.length) throw new IOException(
"WS frame payload " + payLen + " bytes exceeds buffer " + readBuf.length);
int extLenBytes = lenBits == 127 ? 8 : lenBits == 126 ? 2 : 0;
int maskBytes = masked ? 4 : 0;
int extraLen = extLenBytes + maskBytes;
if (extraLen > 0) readFullyHeader(extraLen);
long payLen;
int pos;
if (extLenBytes == 2) {
payLen = ((hdrScratch[0] & 0xFFL) << 8) | (hdrScratch[1] & 0xFFL);
pos = 2;
} else if (extLenBytes == 8) {
// RFC 6455 §5.2: the most significant bit of the 64-bit length MUST be 0.
if ((hdrScratch[0] & 0x80) != 0) {
throw new WebSocketProtocolException(1002, "extended payload length MSB must be 0");
}
payLen = 0;
for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (hdrScratch[i] & 0xFFL);
pos = 8;
} else {
payLen = lenBits;
pos = 0;
}
boolean isControl = opcode == WebSocketFrame.OP_CLOSE
|| opcode == WebSocketFrame.OP_PING || opcode == WebSocketFrame.OP_PONG;
if (isControl) {
if (!fin) throw new WebSocketProtocolException(1002, "control frame must not be fragmented");
if (payLen > controlBuf.length) throw new WebSocketProtocolException(1002, "control frame payload exceeds 125 bytes");
} else if (opcode == WebSocketFrame.OP_CONTINUATION) {
if (fragmentLength == 0) throw new WebSocketProtocolException(1002, "continuation frame without an initiated message");
} else { // TEXT or BINARY
if (fragmentLength != 0) throw new WebSocketProtocolException(1002, "new data frame while a fragmented message is in progress");
}
byte m0 = 0, m1 = 0, m2 = 0, m3 = 0;
if (masked) {
m0 = (byte) in.read(); m1 = (byte) in.read();
m2 = (byte) in.read(); m3 = (byte) in.read();
m0 = hdrScratch[pos]; m1 = hdrScratch[pos + 1]; m2 = hdrScratch[pos + 2]; m3 = hdrScratch[pos + 3];
}
int len = (int) payLen;
readFully(readBuf, 0, len);
if (masked) unmaskInPlace(readBuf, 0, len, m0, m1, m2, m3);
frame.reset(readBuf, 0, len, opcode, fin);
if (isControl) {
readFully(controlBuf, 0, len);
if (masked) unmaskInPlace(controlBuf, 0, len, m0, m1, m2, m3);
frame.reset(controlBuf, 0, len, opcode, true);
return true;
}
// Data frame (fresh TEXT/BINARY, or a CONTINUATION of one already in progress):
// accumulate into readBuf, bounded by its capacity — the same bound a single
// unfragmented frame already had before this fix.
if (fragmentLength + (long) len > readBuf.length) {
throw new WebSocketProtocolException(1009, "message exceeds " + readBuf.length + " bytes");
}
readFully(readBuf, fragmentLength, len);
if (masked) unmaskInPlace(readBuf, fragmentLength, len, m0, m1, m2, m3);
byte messageOpcode = opcode == WebSocketFrame.OP_CONTINUATION ? fragmentOpcode : opcode;
if (opcode != WebSocketFrame.OP_CONTINUATION) fragmentOpcode = opcode;
fragmentLength += len;
if (fin) {
frame.reset(readBuf, 0, fragmentLength, messageOpcode, true);
fragmentLength = 0;
return true;
}
// Not FIN: loop to read the next continuation frame (or an interleaved control frame).
}
}
private static void validateOpcode(byte opcode) throws WebSocketProtocolException {
switch (opcode) {
case WebSocketFrame.OP_CONTINUATION, WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY,
WebSocketFrame.OP_CLOSE, WebSocketFrame.OP_PING, WebSocketFrame.OP_PONG -> { /* valid */ }
default -> throw new WebSocketProtocolException(1002, "reserved/invalid opcode " + opcode);
}
}
public void sendPong(WebSocketFrame ping) throws IOException {
writeFrame(WebSocketFrame.OP_PONG, ping.buffer(), ping.payloadOffset(), ping.payloadLength());
}
@@ -187,13 +297,12 @@ public final class WebSocketSession {
* extended-length + up to 4 mask-key), then writes header + payload in two bulk calls to the
* raw socket stream.
*
* <p>No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}
* (see {@code HttpServer#process}). Each {@code write()} lands directly in the
* kernel send buffer. With {@code TCP_NODELAY} set on the socket, the kernel
* transmits the segment immediately without Nagle coalescing. The two writes
* (header then payload) will be merged into a single TCP segment by the kernel
* because they arrive faster than the ACK from the peer — exactly the coalescing
* we want, at zero cost.
* <p>No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}.
* Each {@code write()} lands directly in the kernel send buffer. With {@code TCP_NODELAY}
* set on the socket, the kernel transmits the segment immediately without Nagle coalescing.
* The two writes (header then payload) will be merged into a single TCP segment by the
* kernel because they arrive faster than the ACK from the peer — exactly the coalescing we
* want, at zero cost.
*
* <p><b>{@link #maskOutgoing} (client mode):</b> RFC 6455 requires every client-to-server frame
* to be masked. The mask key is generated into {@link #hdrScratch} (no new allocation — same
@@ -204,7 +313,8 @@ public final class WebSocketSession {
* masked mode must not reuse that buffer expecting it unchanged after the call.
*/
private void writeFrame(byte opcode, byte[] payload, int off, int len) throws IOException {
synchronized (out) {
writeLock.lock();
try {
int hlen = 0;
hdrScratch[hlen++] = (byte) (0x80 | opcode);
int maskBit = maskOutgoing ? 0x80 : 0x00;
@@ -237,6 +347,19 @@ public final class WebSocketSession {
out.write(hdrScratch, 0, hlen);
out.write(payload, off, len);
// No flush — TCP_NODELAY handles delivery. See Javadoc above.
} finally {
writeLock.unlock();
}
}
/** Bulk-reads {@code len} bytes into {@link #hdrScratch} starting at offset 0 — the {@code
* EX-11} fix: the extended-length and mask-key bytes used to be read one at a time. */
private void readFullyHeader(int len) throws IOException {
int remaining = len;
while (remaining > 0) {
int n = in.read(hdrScratch, len - remaining, remaining);
if (n < 0) throw new EOFException("WebSocket stream closed mid-frame");
remaining -= n;
}
}
@@ -0,0 +1,71 @@
package dev.relism.flash.websocket;
import dev.relism.flash.http1.Http1KeepAlive;
import dev.relism.flash.models.Request;
import dev.relism.flash.transport.ConnectionScratch;
import dev.relism.fpr.core.ByteView;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
/**
* WebSocket upgrade detection (RFC 6455 §4.2.1) and handshake response. Extracted from
* {@code HttpServer} (Phase 2) — its only responsibility is deciding whether a request is an
* upgrade request and, if so, answering the {@code 101 Switching Protocols} handshake. The
* session loop itself lives in {@link WebSocketLoop}.
*/
public final class WebSocketUpgrade {
private WebSocketUpgrade() {
}
private static final byte[] WS_HANDSHAKE_PREFIX =
("HTTP/1.1 101 Switching Protocols\r\n" +
"Upgrade: websocket\r\n" +
"Connection: Upgrade\r\n" +
"Sec-WebSocket-Accept: ")
.getBytes(StandardCharsets.ISO_8859_1);
private static final byte[] WS_HANDSHAKE_SUFFIX =
"\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1);
public static final byte[] REJECT_400 =
"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
.getBytes(StandardCharsets.ISO_8859_1);
private static final byte[] WS_GUID_BYTES =
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1);
/**
* Whether {@code request} is a WebSocket upgrade request: {@code Upgrade: websocket} and a
* {@code Connection} header whose token list includes {@code upgrade} ({@code EX-13} — the
* shared token-list scanner in {@link Http1KeepAlive} is what fixed the whole-value compare
* bug this check used to have too).
*/
public static boolean isWebSocketUpgrade(Request request) {
ByteView upgrade = request.getRequestLine().getHeaders().view("Upgrade");
if (upgrade == null) return false;
if (!Http1KeepAlive.tokenEqualsIgnoreCase(upgrade, 0, upgrade.length(), "websocket")) return false;
return Http1KeepAlive.connectionContainsToken(request, "upgrade");
}
/** Writes and flushes the {@code 101 Switching Protocols} handshake response. */
public static void performHandshake(OutputStream out, Request request, ConnectionScratch scratch) throws IOException {
ByteView keyView = request.getRequestLine().getHeaders().view("Sec-WebSocket-Key");
if (keyView == null) throw new IOException("Missing Sec-WebSocket-Key header");
MessageDigest sha1 = scratch.sha1;
sha1.reset();
for (int i = 0, len = keyView.length(); i < len; i++) sha1.update(keyView.byteAt(i));
sha1.update(WS_GUID_BYTES);
byte[] accept = Base64.getEncoder().encode(sha1.digest());
out.write(WS_HANDSHAKE_PREFIX);
out.write(accept);
out.write(WS_HANDSHAKE_SUFFIX);
out.flush();
}
}
@@ -15,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.*;
/**
* One test per rejection rule added in HTTP/2 plan Phase 1 (EX-02, EX-03, EX-08, EX-18, EX-35,
* EX-36), each asserting the specific status code {@link MalformedRequestException} carries —
* not merely that some exception was thrown. {@code HttpServer} always closes the connection
* not merely that some exception was thrown. {@code Http1Connection}/{@code ConnectionRunner} always closes the connection
* after any of these (never keep-alive); that behaviour is exercised at the integration level
* by {@code HttpServerTest}.
*/
@@ -0,0 +1,62 @@
package dev.relism.flash.architecture;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.fail;
/**
* {@code R1}/{@code DEC-02}: HTTP/1.1 and HTTP/2 are peers behind the {@code ConnectionProtocol}
* seam, never coupled to each other directly. A lightweight source-scan rather than ArchUnit —
* this project has no bytecode-analysis test dependency yet, and one import-statement check per
* package pair does not need one; record the choice here rather than in {@code DECISIONS.md}
* since it is this test's own implementation detail, not a design decision affecting shipped
* code.
*/
class PackageBoundaryTest {
@Test
void http1DoesNotImportH2() throws IOException {
assertNoImportOfPackage("dev/relism/flash/http1", "dev.relism.flash.h2");
}
@Test
void h2DoesNotImportHttp1() throws IOException {
assertNoImportOfPackage("dev/relism/flash/h2", "dev.relism.flash.http1");
}
private static void assertNoImportOfPackage(String sourceDirRelative, String forbiddenImportPrefix) throws IOException {
Path root = findSourceRoot(sourceDirRelative);
// Neither package boundary can be meaningfully checked before both packages exist; once
// dev.relism.flash.h2 gains real classes (Phase 3+) this stops being a no-op for the
// h2-side test.
if (root == null) return;
try (Stream<Path> files = Files.walk(root)) {
List<Path> javaFiles = files.filter(p -> p.toString().endsWith(".java")).toList();
for (Path file : javaFiles) {
for (String line : Files.readAllLines(file)) {
String trimmed = line.strip();
if (trimmed.startsWith("import " + forbiddenImportPrefix + ".")
|| trimmed.startsWith("import " + forbiddenImportPrefix + ";")) {
fail(file + " imports " + forbiddenImportPrefix
+ " — violates the h1/h2 package boundary (R1/DEC-02): " + trimmed);
}
}
}
}
}
private static Path findSourceRoot(String packageRelativePath) {
for (String base : List.of("flash/src/main/java", "src/main/java")) {
Path candidate = Path.of(base).resolve(packageRelativePath);
if (Files.isDirectory(candidate)) return candidate;
}
return null;
}
}
@@ -0,0 +1,136 @@
package dev.relism.flash.http1;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Response;
import dev.relism.flash.transport.ConnectionScratch;
import dev.relism.flash.transport.ScratchPool;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class Http1ResponseWriterTest {
private static ConnectionScratch scratch() {
return new ScratchPool().acquire();
}
private static String write(Response response, HttpMethod method, boolean keepAlive, boolean sendDate) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
Http1ResponseWriter.writeResponse(out, response, method, keepAlive, sendDate, scratch());
return out.toString(StandardCharsets.UTF_8);
}
// --- EX-14: HEAD ------------------------------------------------------------
@Test
void head_reportsContentLengthButWritesNoBody() throws IOException {
Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.HEAD, true, false);
assertTrue(raw.contains("Content-Length: 11\r\n"), raw);
assertFalse(raw.contains("hello world"), raw);
}
@Test
void get_writesTheBody_forComparison() throws IOException {
Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, false);
assertTrue(raw.contains("Content-Length: 11\r\n"), raw);
assertTrue(raw.endsWith("hello world"), raw);
}
// --- EX-15: 204 / 304 / 1xx never carry Content-Length or a body ------------
@Test
void status204_omitsContentLengthAndBody() throws IOException {
Response response = new Response(204, ContentType.NONE);
response.setBody("should never appear");
String raw = write(response, HttpMethod.GET, true, false);
assertFalse(raw.contains("Content-Length"), raw);
assertFalse(raw.contains("should never appear"), raw);
}
@Test
void status304_omitsContentLengthAndBody() throws IOException {
Response response = new Response(304, ContentType.NONE);
response.setBody("should never appear");
String raw = write(response, HttpMethod.GET, true, false);
assertFalse(raw.contains("Content-Length"), raw);
assertFalse(raw.contains("should never appear"), raw);
}
@Test
void status1xx_omitsContentLengthAndBody() throws IOException {
Response response = new Response(103, ContentType.NONE);
response.setBody("should never appear");
String raw = write(response, HttpMethod.GET, true, false);
assertFalse(raw.contains("Content-Length"), raw);
assertFalse(raw.contains("should never appear"), raw);
}
@Test
void status200_stillCarriesContentLength_forComparison() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, false);
assertTrue(raw.contains("Content-Length: 1\r\n"), raw);
}
// --- EX-15: ContentType.NONE omits the Content-Type line entirely -----------
@Test
void contentTypeNone_omitsContentTypeLine() throws IOException {
Response response = new Response(200, ContentType.NONE);
String raw = write(response, HttpMethod.GET, true, false);
assertFalse(raw.contains("Content-Type"), raw);
}
@Test
void contentTypeTextPlain_includesContentTypeLine() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, false);
assertTrue(raw.contains("Content-Type: text/plain\r\n"), raw);
}
// --- EX-16: Date header -------------------------------------------------------
@Test
void sendDateTrue_includesDateHeader() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, true);
assertTrue(raw.contains("Date: "), raw);
// RFC 9110 IMF-fixdate, e.g. "Date: Tue, 03 Jun 2008 11:05:30 GMT\r\n"
assertTrue(raw.matches("(?s).*Date: [A-Za-z]{3}, \\d{2} [A-Za-z]{3} \\d{4} \\d{2}:\\d{2}:\\d{2} GMT\\r\\n.*"), raw);
}
@Test
void sendDateFalse_omitsDateHeader() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, false);
assertFalse(raw.contains("Date: "), raw);
}
// --- Connection header --------------------------------------------------------
@Test
void keepAlive_writesKeepAliveConnectionHeader() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, false);
assertTrue(raw.contains("Connection: keep-alive\r\n"), raw);
}
@Test
void notKeepAlive_writesCloseConnectionHeader() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, false, false);
assertTrue(raw.contains("Connection: close\r\n"), raw);
}
}
@@ -0,0 +1,71 @@
package dev.relism.flash.transport;
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;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
/**
* A scratch is always released — including on an exception path — and a socket is always
* removed from {@code activeSockets}, regardless of how the dispatched
* {@link ConnectionProtocol} exits. This is a resource-leak safety property (Phase 2's Safety
* 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();
ConnectionProtocol throwingProtocol = ctx -> {
throw new IOException("simulated protocol failure");
};
ConnectionRunner runner = new ConnectionRunner(
executor, activeSockets, scratchPool, router, wsRouter, configuration, throwingProtocol);
try (ServerSocket serverSocket = new ServerSocket(0)) {
int port = serverSocket.getLocalPort();
CountDownLatch accepted = new CountDownLatch(1);
Thread acceptThread = new Thread(() -> {
try (Socket serverSide = serverSocket.accept()) {
runner.accept(serverSide, () -> false);
accepted.countDown();
Thread.sleep(300); // give the submitted virtual-thread task time to run
} catch (Exception ignored) {
}
});
acceptThread.start();
try (Socket client = new Socket("127.0.0.1", port)) {
assertTrue(accepted.await(2, TimeUnit.SECONDS));
Thread.sleep(300); // let ConnectionRunner's virtual thread finish
assertTrue(activeSockets.isEmpty(), "socket must be removed from activeSockets on every exit path");
}
acceptThread.join(2000);
} finally {
executor.shutdownNow();
}
}
}
@@ -22,7 +22,7 @@ import static org.junit.jupiter.api.Assertions.*;
/**
* {@link ProtocolNegotiator#negotiate} is a pure, directly-testable detector (see its Javadoc
* for why it does not itself consult {@code FlashConfiguration.http2Enabled}) every case here
* calls it directly rather than through {@code HttpServer}.
* calls it directly rather than through {@code Http1Connection}/{@code ConnectionRunner}.
*/
class ProtocolNegotiatorTest {
@@ -84,7 +84,7 @@ class ProtocolNegotiatorTest {
/**
* Binds a real TLS listener offering {@code serverAlpn}, connects a client offering
* {@code clientAlpn}, forces the handshake on both sides (mirroring {@code HttpServer}'s
* {@code clientAlpn}, forces the handshake on both sides (mirroring {@code Http1Connection}/{@code ConnectionRunner}'s
* EX-30 fix), and hands the accepted server-side socket to {@code assertion}.
*/
private static void withNegotiatedAlpn(Path dir, String[] serverAlpn, String[] clientAlpn,
@@ -0,0 +1,72 @@
package dev.relism.flash.transport;
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class ScratchPoolTest {
@Test
void acquire_withEmptyPool_returnsFreshInstance() {
ScratchPool pool = new ScratchPool();
ConnectionScratch scratch = pool.acquire();
assertNotNull(scratch);
assertNotNull(scratch.sha1);
assertEquals(ConnectionScratch.DECIMAL_BUFFER_SIZE, scratch.decimalBuffer.length);
assertEquals(ConnectionScratch.RELAY_BUFFER_SIZE, scratch.relayBuffer.length);
}
@Test
void release_thenAcquire_reusesTheSameInstance() {
ScratchPool pool = new ScratchPool();
ConnectionScratch first = pool.acquire();
pool.release(first);
ConnectionScratch second = pool.acquire();
assertSame(first, second);
}
@Test
void bound_isRespected_excessReleasesAreDropped() {
ScratchPool pool = new ScratchPool(2);
ConnectionScratch a = pool.acquire();
ConnectionScratch b = pool.acquire();
ConnectionScratch c = pool.acquire();
pool.release(a);
pool.release(b);
pool.release(c); // pool already has 2 -- this one is dropped, not queued
Set<ConnectionScratch> reacquired = new HashSet<>();
reacquired.add(pool.acquire());
reacquired.add(pool.acquire());
ConnectionScratch third = pool.acquire(); // freshly allocated, pool was exhausted at 2
assertFalse(reacquired.contains(third));
assertEquals(2, reacquired.size());
}
@Test
void reset_clearsTheMessageDigestState() {
// A dirty digest (mid-update, not yet digested) must not leak into the next connection
// that reuses this scratch -- the classic cross-connection-leak hazard for pooled state.
ScratchPool pool = new ScratchPool();
ConnectionScratch scratch = pool.acquire();
scratch.sha1.update((byte) 'x');
pool.release(scratch);
ConnectionScratch reused = pool.acquire();
assertSame(scratch, reused);
// If reset() had not run, digesting an empty input now would still reflect the earlier
// update. A byte array is not the actual assertion here (MessageDigest doesn't expose
// "reset happened") - the practical proof is that digest() with no further updates
// matches the well-known empty-input SHA-1 digest.
byte[] emptyDigest = reused.sha1.digest();
byte[] expected = {
(byte) 0xda, 0x39, (byte) 0xa3, (byte) 0xee, 0x5e, 0x6b, 0x4b, 0x0d,
0x32, 0x55, (byte) 0xbf, (byte) 0xef, (byte) 0x95, 0x60, 0x18, (byte) 0x90,
(byte) 0xaf, (byte) 0xd8, 0x07, 0x09
};
assertArrayEquals(expected, emptyDigest);
}
}
@@ -0,0 +1,116 @@
package dev.relism.flash.transport;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-32}: the two-stage graceful shutdown stop accepting, let an in-flight request
* finish (forced to {@code Connection: close}), then force-close whatever remains after
* {@code shutdownDrainTimeoutMs}.
*/
class ServerLifecycleGracefulShutdownTest {
private FlashApp app;
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
private static int freePort() throws Exception {
try (ServerSocket s = new ServerSocket(0)) {
return s.getLocalPort();
}
}
@Test
void inFlightRequest_completesWithConnectionClose_duringShutdown() throws Exception {
int port = freePort();
CountDownLatch handlerStarted = new CountDownLatch(1);
CountDownLatch releaseHandler = new CountDownLatch(1);
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.shutdownDrainTimeoutMs(5_000)
.build());
app.get("/slow", (req, res) -> {
handlerStarted.countDown();
assertTrue(releaseHandler.await(5, TimeUnit.SECONDS));
return "done";
});
app.start();
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(5_000);
OutputStream out = socket.getOutputStream();
out.write("GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n".getBytes(StandardCharsets.UTF_8));
out.flush();
assertTrue(handlerStarted.await(2, TimeUnit.SECONDS));
// Begin shutdown while the handler is still running.
CompletableFuture<Void> stopping = app.stop();
// Give stop() a moment to mark the server as stopping and close the listener.
Thread.sleep(100);
releaseHandler.countDown();
byte[] buf = new byte[4096];
int n = socket.getInputStream().read(buf);
String response = new String(buf, 0, n, StandardCharsets.UTF_8);
assertTrue(response.startsWith("HTTP/1.1 200 OK"), response);
assertTrue(response.contains("done"), response);
// EX-32: the in-flight request is forced to close rather than keep-alive, even
// though the client asked for HTTP/1.1's default keep-alive.
assertTrue(response.contains("Connection: close"), response);
stopping.get(5, TimeUnit.SECONDS);
}
}
@Test
void stop_closesListener_soNewConnectionsAreRefused() throws Exception {
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.shutdownDrainTimeoutMs(500)
.build());
app.get("/ping", (req, res) -> "pong");
app.start();
// Confirm the server actually answers before stopping it.
try (Socket probe = new Socket("127.0.0.1", port)) {
probe.setSoTimeout(2_000);
probe.getOutputStream().write("GET /ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
.getBytes(StandardCharsets.UTF_8));
probe.getOutputStream().flush();
assertTrue(probe.getInputStream().read() != -1);
}
app.stop().get(5, TimeUnit.SECONDS);
assertThrows(Exception.class, () -> {
try (Socket socket = new Socket()) {
socket.connect(new java.net.InetSocketAddress("127.0.0.1", port), 500);
socket.setSoTimeout(500);
socket.getOutputStream().write("GET /ping HTTP/1.1\r\nHost: localhost\r\n\r\n"
.getBytes(StandardCharsets.UTF_8));
socket.getOutputStream().flush();
int result = socket.getInputStream().read();
if (result == -1) throw new java.io.IOException("connection refused/closed, as expected");
}
});
}
}
@@ -0,0 +1,220 @@
package dev.relism.flash.websocket;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-11} (bulk header read) and {@code EX-12} (continuation reassembly, mandatory
* masking, opcode validation, control-frame constraints, correct close codes) coverage for
* {@link WebSocketSession#readFrame}.
*/
class WebSocketFragmentationAndValidationTest {
private static final byte[] MASK = {1, 2, 3, 4};
private static byte[] frame(int opcode, boolean fin, boolean masked, byte[] payload) throws IOException {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
buf.write((fin ? 0x80 : 0) | opcode);
int len = payload.length;
int maskBit = masked ? 0x80 : 0x00;
if (len <= 125) {
buf.write(maskBit | len);
} else {
buf.write(maskBit | 126);
buf.write((len >> 8) & 0xFF);
buf.write(len & 0xFF);
}
byte[] out = payload;
if (masked) {
buf.write(MASK);
out = payload.clone();
for (int i = 0; i < out.length; i++) out[i] ^= MASK[i % 4];
}
buf.write(out);
return buf.toByteArray();
}
private static byte[] concat(byte[]... arrays) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
for (byte[] a : arrays) out.write(a);
return out.toByteArray();
}
/** Server-mode session (masked incoming required) over the given raw bytes. */
private static WebSocketSession serverSession(byte[] raw, int bufferSize) {
return new WebSocketSession(new ByteArrayInputStream(raw), new ByteArrayOutputStream(), bufferSize);
}
// --- EX-12: continuation reassembly ------------------------------------------
@Test
void continuationFrames_reassembleIntoOneMessage() throws IOException {
byte[] raw = concat(
frame(WebSocketFrame.OP_TEXT, false, true, "hel".getBytes(StandardCharsets.UTF_8)),
frame(WebSocketFrame.OP_CONTINUATION, false, true, "lo ".getBytes(StandardCharsets.UTF_8)),
frame(WebSocketFrame.OP_CONTINUATION, true, true, "world".getBytes(StandardCharsets.UTF_8)));
WebSocketSession session = serverSession(raw, 64);
WebSocketFrame frame = new WebSocketFrame();
assertTrue(session.readFrame(frame));
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
assertTrue(frame.isFin());
assertEquals("hello world", new String(frame.copyPayload(), StandardCharsets.UTF_8));
}
@Test
void controlFrame_interleavedDuringFragmentation_deliveredWithoutDisturbingReassembly() throws IOException {
byte[] raw = concat(
frame(WebSocketFrame.OP_TEXT, false, true, "AB".getBytes(StandardCharsets.UTF_8)),
frame(WebSocketFrame.OP_PING, true, true, "ping".getBytes(StandardCharsets.UTF_8)),
frame(WebSocketFrame.OP_CONTINUATION, true, true, "CD".getBytes(StandardCharsets.UTF_8)));
WebSocketSession session = serverSession(raw, 64);
WebSocketFrame frame = new WebSocketFrame();
assertTrue(session.readFrame(frame));
assertEquals(WebSocketFrame.OP_PING, frame.opcode());
assertEquals("ping", new String(frame.copyPayload(), StandardCharsets.UTF_8));
assertTrue(session.readFrame(frame));
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
assertEquals("ABCD", new String(frame.copyPayload(), StandardCharsets.UTF_8));
}
@Test
void continuationWithoutInitiatedMessage_rejected1002() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_CONTINUATION, true, true, "x".getBytes(StandardCharsets.UTF_8));
WebSocketSession session = serverSession(raw, 64);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void newDataFrameWhileFragmenting_rejected1002() throws IOException {
byte[] raw = concat(
frame(WebSocketFrame.OP_TEXT, false, true, "a".getBytes(StandardCharsets.UTF_8)),
frame(WebSocketFrame.OP_TEXT, true, true, "b".getBytes(StandardCharsets.UTF_8)));
WebSocketSession session = serverSession(raw, 64);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void reassembledMessageExceedingBuffer_rejected1009() throws IOException {
byte[] raw = concat(
frame(WebSocketFrame.OP_TEXT, false, true, new byte[5]),
frame(WebSocketFrame.OP_CONTINUATION, true, true, new byte[5]));
WebSocketSession session = serverSession(raw, 8); // 5 + 5 = 10 > 8
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1009, e.closeCode());
}
// --- EX-12: mandatory masking direction ---------------------------------------
@Test
void serverSession_unmaskedIncomingFrame_rejected1002() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_TEXT, true, false, "hi".getBytes(StandardCharsets.UTF_8));
WebSocketSession session = serverSession(raw, 64);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void clientSession_maskedIncomingFrame_rejected1002() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_TEXT, true, true, "hi".getBytes(StandardCharsets.UTF_8));
WebSocketSession session = new WebSocketSession(
new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 64, null, true);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void clientSession_unmaskedIncomingFrame_accepted() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_TEXT, true, false, "hi".getBytes(StandardCharsets.UTF_8));
WebSocketSession session = new WebSocketSession(
new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 64, null, true);
WebSocketFrame frame = new WebSocketFrame();
assertTrue(session.readFrame(frame));
assertEquals("hi", new String(frame.copyPayload(), StandardCharsets.UTF_8));
}
// --- EX-12: opcode validation --------------------------------------------------
@Test
void reservedOpcode_rejected1002() throws IOException {
byte[] raw = frame(0x3, true, true, new byte[0]); // 0x3 is reserved
WebSocketSession session = serverSession(raw, 64);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
// --- EX-12: control-frame constraints ------------------------------------------
@Test
void fragmentedControlFrame_rejected1002() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_PING, false, true, "x".getBytes(StandardCharsets.UTF_8));
WebSocketSession session = serverSession(raw, 64);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void oversizedControlFramePayload_rejected1002() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_PING, true, true, new byte[126]);
WebSocketSession session = serverSession(raw, 200);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void controlFrameAtTheMaxAllowedSize_accepted() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_PING, true, true, new byte[125]);
WebSocketSession session = serverSession(raw, 200);
WebSocketFrame frame = new WebSocketFrame();
assertTrue(session.readFrame(frame));
assertEquals(125, frame.payloadLength());
}
// --- EX-11: bulk header read, not one syscall per byte -------------------------
private static final class CountingInputStream extends InputStream {
private final InputStream delegate;
int reads = 0;
CountingInputStream(InputStream delegate) { this.delegate = delegate; }
@Override public int read() throws IOException { reads++; return delegate.read(); }
@Override public int read(byte[] b, int off, int len) throws IOException { reads++; return delegate.read(b, off, len); }
}
@Test
void readFrame_withExtendedLengthAndMask_doesNotReadOneByteAtATime() throws IOException {
// 200-byte payload forces the 16-bit extended length; masked, so 4 mask bytes too.
// Pre-fix: 1 (b0) + 1 (b1) + 2 (extended length, read one at a time originally via two
// separate in.read() calls already bulk-free in that part) + 4 (mask, one at a time)
// = several individual reads for the header alone, on top of one per payload byte if
// the underlying stream were unbuffered. Post-fix: the header's variable remainder
// (length + mask) is exactly one readFully call.
byte[] raw = frame(WebSocketFrame.OP_BINARY, true, true, new byte[200]);
CountingInputStream counting = new CountingInputStream(new ByteArrayInputStream(raw));
WebSocketSession session = new WebSocketSession(counting, new ByteArrayOutputStream(), 256);
assertTrue(session.readFrame(new WebSocketFrame()));
// b0, b1, one bulk read for (2 extended-length + 4 mask) bytes, one bulk read for the
// 200-byte payload: 4 total, independent of the payload size.
assertTrue(counting.reads <= 4, "expected at most 4 underlying reads, was " + counting.reads);
}
}