diff --git a/README.md b/README.md index aa45e88..4d3bd14 100644 --- a/README.md +++ b/README.md @@ -263,20 +263,25 @@ upgrading `Request` — no separate TLS state is tracked for WS. ## Architecture ``` -ServerSocket.accept() - → RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive - → GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl - → RequestHandler.handle() # user handler; return value sets body - → Request.drain() # consume unread body for keep-alive - → HttpServer writes response # status line, headers, then fixed or chunked body - → loop or close socket # based on Connection header +TransportFactory.create() # binds every listener, wires the connection runner + → AcceptLoop # one per listener × accept thread; hands sockets off + → ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation + → ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once + → Http1Connection.run() # 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 + → 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 diff --git a/flash/docs/http2/DECISIONS.md b/flash/docs/http2/DECISIONS.md index 8b606a5..ff34b4d 100644 --- a/flash/docs/http2/DECISIONS.md +++ b/flash/docs/http2/DECISIONS.md @@ -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. diff --git a/flash/docs/http2/IMPLEMENTATION-PLAN.md b/flash/docs/http2/IMPLEMENTATION-PLAN.md index dada6e3..a55322f 100644 --- a/flash/docs/http2/IMPLEMENTATION-PLAN.md +++ b/flash/docs/http2/IMPLEMENTATION-PLAN.md @@ -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. --- diff --git a/flash/docs/http2/TRANSPORT.md b/flash/docs/http2/TRANSPORT.md new file mode 100644 index 0000000..6be8abe --- /dev/null +++ b/flash/docs/http2/TRANSPORT.md @@ -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. diff --git a/flash/src/main/java/dev/relism/flash/HttpServer.java b/flash/src/main/java/dev/relism/flash/HttpServer.java deleted file mode 100644 index b068aa9..0000000 --- a/flash/src/main/java/dev/relism/flash/HttpServer.java +++ /dev/null @@ -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. - * - *
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. - * - *
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; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http1/Http1Connection.java b/flash/src/main/java/dev/relism/flash/http1/Http1Connection.java new file mode 100644 index 0000000..43cc581 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http1/Http1Connection.java @@ -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; + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/http1/Http1KeepAlive.java b/flash/src/main/java/dev/relism/flash/http1/Http1KeepAlive.java new file mode 100644 index 0000000..30857e9 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http1/Http1KeepAlive.java @@ -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. + * + *
{@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; + } +} diff --git a/flash/src/main/java/dev/relism/flash/http1/Http1ResponseWriter.java b/flash/src/main/java/dev/relism/flash/http1/Http1ResponseWriter.java new file mode 100644 index 0000000..9dd8ef5 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/http1/Http1ResponseWriter.java @@ -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}. + * + *
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'); + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/AcceptLoop.java b/flash/src/main/java/dev/relism/flash/transport/AcceptLoop.java new file mode 100644 index 0000000..42bea09 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/AcceptLoop.java @@ -0,0 +1,29 @@ +package dev.relism.flash.transport; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.function.BooleanSupplier; + +/** + * Single accept-loop body — runs on each of a listener's accept threads. All threads for the + * same listener block on the same {@link java.net.ServerSocket}; the JVM ensures only one wakes + * per incoming connection (no thundering herd). Other listeners' accept threads are entirely + * independent. + */ +@Slf4j +public final class AcceptLoop { + + private AcceptLoop() { + } + + public static void run(BoundListener listener, ConnectionRunner runner, BooleanSupplier stopped) { + while (!stopped.getAsBoolean()) { + try { + runner.accept(listener.socket().accept(), stopped); + } catch (IOException e) { + if (!stopped.getAsBoolean()) log.error("Accept error", e); + } + } + } +} diff --git a/flash/src/main/java/dev/relism/flash/transport/BoundListener.java b/flash/src/main/java/dev/relism/flash/transport/BoundListener.java new file mode 100644 index 0000000..6fd2fe6 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/BoundListener.java @@ -0,0 +1,7 @@ +package dev.relism.flash.transport; + +import java.net.ServerSocket; + +/** One bound listener socket (plain or TLS) plus whether it is TLS, for logging only. */ +public record BoundListener(ServerSocket socket, boolean secure) { +} diff --git a/flash/src/main/java/dev/relism/flash/transport/ConnectionContext.java b/flash/src/main/java/dev/relism/flash/transport/ConnectionContext.java new file mode 100644 index 0000000..f7024d3 --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/ConnectionContext.java @@ -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 +) { +} diff --git a/flash/src/main/java/dev/relism/flash/transport/ConnectionProtocol.java b/flash/src/main/java/dev/relism/flash/transport/ConnectionProtocol.java new file mode 100644 index 0000000..c4fe26c --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/ConnectionProtocol.java @@ -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; +} diff --git a/flash/src/main/java/dev/relism/flash/transport/ConnectionRunner.java b/flash/src/main/java/dev/relism/flash/transport/ConnectionRunner.java new file mode 100644 index 0000000..056760e --- /dev/null +++ b/flash/src/main/java/dev/relism/flash/transport/ConnectionRunner.java @@ -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. + * + *
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 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.
+ }
+}
diff --git a/flash/src/main/java/dev/relism/flash/transport/ListenerBinder.java b/flash/src/main/java/dev/relism/flash/transport/ListenerBinder.java
new file mode 100644
index 0000000..5aed7ba
--- /dev/null
+++ b/flash/src/main/java/dev/relism/flash/transport/ListenerBinder.java
@@ -0,0 +1,43 @@
+package dev.relism.flash.transport;
+
+import dev.relism.flash.extension.FlashConfiguration;
+import dev.relism.flash.tls.TlsConfig;
+
+import javax.net.ssl.SSLServerSocket;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+
+/**
+ * Turns a {@link FlashConfiguration.Listener} into a bound {@link ServerSocket}. Sole
+ * responsibility: binding — not accepting, not connection handling.
+ *
+ * A TLS listener gets its {@link ServerSocket} from {@link TlsConfig#serverSocketFactory()}
+ * instead of {@code new ServerSocket()}, and its protocol/client-auth/cipher parameters from
+ * {@link TlsConfig#applyTo}; reuse-address, receive buffer size, backlog and the bind call
+ * itself are identical either way. TLS only changes which bytes come out of {@code accept()};
+ * it never changes how the accept loop, or anything downstream of it, treats them.
+ */
+public final class ListenerBinder {
+
+ private ListenerBinder() {
+ }
+
+ public static BoundListener bind(FlashConfiguration.Listener spec) throws IOException {
+ TlsConfig tls = spec.tls();
+
+ ServerSocket socket = tls != null ? tls.serverSocketFactory().createServerSocket() : new ServerSocket();
+ // setReuseAddress(true) must be called BEFORE bind().
+ socket.setReuseAddress(true);
+ socket.setReceiveBufferSize(TransportTuning.SOCKET_BUF_SIZE);
+ if (tls != null) tls.applyTo((SSLServerSocket) socket);
+
+ InetSocketAddress addr = spec.host() != null
+ ? new InetSocketAddress(spec.host(), spec.port())
+ : new InetSocketAddress(spec.port());
+ socket.bind(addr, TransportTuning.ACCEPT_BACKLOG);
+
+ return new BoundListener(socket, tls != null);
+ }
+}
diff --git a/flash/src/main/java/dev/relism/flash/transport/ScratchPool.java b/flash/src/main/java/dev/relism/flash/transport/ScratchPool.java
new file mode 100644
index 0000000..df2ed27
--- /dev/null
+++ b/flash/src/main/java/dev/relism/flash/transport/ScratchPool.java
@@ -0,0 +1,61 @@
+package dev.relism.flash.transport;
+
+import java.util.concurrent.ConcurrentLinkedQueue;
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * A bounded cache of {@link ConnectionScratch} instances, reused across connections instead of
+ * being allocated and garbage-collected per connection.
+ *
+ * This is a cache, not a leak-free arena: a burst of 100 000 concurrent connections
+ * still allocates 100 000 {@link ConnectionScratch} instances (one per connection, since each
+ * connection needs its own for as long as it is open), but only {@link #bound} of them survive
+ * being released back to the pool afterward — the rest are simply dropped for the garbage
+ * collector, exactly as they would have been without this class. What the pool buys is avoiding
+ * repeated allocation for the common case of many short-lived or sequential connections sharing
+ * a bounded set of scratch objects.
+ *
+ * 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 {@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 With {@code TCP_NODELAY} enabled on the socket (set in {@code HttpServer}),
+ * With {@code TCP_NODELAY} enabled on the socket (set by the connection runner),
* Nagle's algorithm is disabled: the kernel sends data as soon as it lands in
* the send buffer, without waiting. {@link java.io.BufferedOutputStream} will
* still batch multiple small writes into one syscall when they happen in the
@@ -26,7 +27,7 @@ import java.util.concurrent.atomic.AtomicBoolean;
* The only place an explicit flush is still needed is after the WS
* handshake (one-time, not on the hot path) and after the CLOSE frame
* (end of session). Both are handled in {@link #close} and in
- * {@code HttpServer#performHandshake}. No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}
- * (see {@code HttpServer#process}). Each {@code write()} lands directly in the
- * kernel send buffer. With {@code TCP_NODELAY} set on the socket, the kernel
- * transmits the segment immediately without Nagle coalescing. The two writes
- * (header then payload) will be merged into a single TCP segment by the kernel
- * because they arrive faster than the ACK from the peer — exactly the coalescing
- * we want, at zero cost.
+ * No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}.
+ * Each {@code write()} lands directly in the kernel send buffer. With {@code TCP_NODELAY}
+ * set on the socket, the kernel transmits the segment immediately without Nagle coalescing.
+ * The two writes (header then payload) will be merged into a single TCP segment by the
+ * kernel because they arrive faster than the ACK from the peer — exactly the coalescing we
+ * want, at zero cost.
*
* {@link #maskOutgoing} (client mode): RFC 6455 requires every client-to-server frame
* to be masked. The mask key is generated into {@link #hdrScratch} (no new allocation — same
@@ -204,7 +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;
}
}
@@ -265,4 +388,4 @@ public final class WebSocketSession {
if (i < end) { buf[i++] ^= m1; }
if (i < end) { buf[i] ^= m2; }
}
-}
\ No newline at end of file
+}
diff --git a/flash/src/main/java/dev/relism/flash/websocket/WebSocketUpgrade.java b/flash/src/main/java/dev/relism/flash/websocket/WebSocketUpgrade.java
new file mode 100644
index 0000000..6938c5b
--- /dev/null
+++ b/flash/src/main/java/dev/relism/flash/websocket/WebSocketUpgrade.java
@@ -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();
+ }
+}
diff --git a/flash/src/test/java/dev/relism/flash/RequestParserSecurityTest.java b/flash/src/test/java/dev/relism/flash/RequestParserSecurityTest.java
index 4410a8d..045bac1 100644
--- a/flash/src/test/java/dev/relism/flash/RequestParserSecurityTest.java
+++ b/flash/src/test/java/dev/relism/flash/RequestParserSecurityTest.java
@@ -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}.
*/
diff --git a/flash/src/test/java/dev/relism/flash/architecture/PackageBoundaryTest.java b/flash/src/test/java/dev/relism/flash/architecture/PackageBoundaryTest.java
new file mode 100644
index 0000000..665e94b
--- /dev/null
+++ b/flash/src/test/java/dev/relism/flash/architecture/PackageBoundaryTest.java
@@ -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 (StreamWhy not {@code ThreadLocal}
+ * {@code ThreadLocal} is the right idiom for a bounded platform-thread pool, where "one per
+ * thread" means "one per core". Flash runs one virtual thread per connection
+ * ({@code Executors.newVirtualThreadPerTaskExecutor()}), so a {@code ThreadLocal} here means
+ * one per connection, not one per core — with no upper bound. At 100 000 concurrent
+ * connections, an 8 KB relay buffer alone is ~800 MB of memory that a bounded pool would
+ * instead cap. {@code ConnectionScratch} is therefore explicit and pooled ({@link ScratchPool}),
+ * not thread-local.
+ *
+ * Lifetime and thread-safety contract
+ * Allocated once per connection (or reused from {@link ScratchPool}), owned exclusively by the
+ * single virtual thread driving that connection for its whole lifetime, and returned to the
+ * pool when the connection closes. Never shared between two connections at once — there is no
+ * synchronization here because none is needed.
+ *
+ * Thread-safety
+ * {@link #acquire()} and {@link #release} are safe to call concurrently from any number of
+ * threads — the underlying queue and size guard are lock-free.
+ */
+public final class ScratchPool {
+
+ /** Default bound: generous enough that a real workload rarely misses, small enough that it
+ * is not itself a meaningful memory commitment (a few hundred KB at most). */
+ public static final int DEFAULT_BOUND = Math.min(Runtime.getRuntime().availableProcessors() * 64, 4096);
+
+ private final ConcurrentLinkedQueue
*
*
* Thread safety
- * {@link #sendText}, {@link #send}, and {@link #close} are synchronized on
- * {@code out} and safe to call from threads other than the session loop.
- * {@link #close} uses a CAS on {@code open} to guarantee exactly-once CLOSE
+ * {@link #sendText}, {@link #send}, and {@link #close} are serialized on a
+ * {@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.
+ *
+ * Fragmentation, masking, and control frames (RFC 6455 §5)
+ * {@link #readFrame} reassembles continuation frames into one logical message (bounded by the
+ * read buffer's capacity — the same bound a single unfragmented frame already had), enforces
+ * that incoming frames are masked exactly when this session's role requires it (server sessions
+ * require masked frames from the client; client-mode sessions require unmasked frames from the
+ * server), validates the opcode against RFC 6455's defined set, and enforces the control-frame
+ * constraints (FIN must be set, payload ≤ 125 bytes). A violation throws
+ * {@link WebSocketProtocolException} carrying the correct close code (1002 protocol error, 1009
+ * message too big) for the caller to send before closing.
*/
public final class WebSocketSession {
@@ -47,12 +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,14 +93,17 @@ public final class WebSocketSession {
* @param maskOutgoing {@code true} if this session is acting as a WS client — RFC 6455
* requires client-to-server frames to be masked, unlike the server-to-client
* direction {@link #writeFrame} originally only supported. See {@link
- * #writeFrame} for how masking is applied without allocating.
+ * #writeFrame} for how masking is applied without allocating. Also
+ * determines the expected masking of *incoming* frames — see
+ * {@link #requireMaskedIncoming}.
*/
public WebSocketSession(InputStream in, OutputStream out, int bufferSize, Request request, boolean maskOutgoing) {
- this.in = in;
- this.out = out;
- this.readBuf = new byte[bufferSize];
- this.request = request;
- this.maskOutgoing = maskOutgoing;
+ this.in = in;
+ this.out = out;
+ this.readBuf = new byte[bufferSize];
+ this.request = request;
+ this.maskOutgoing = maskOutgoing;
+ this.requireMaskedIncoming = !maskOutgoing;
}
public boolean isOpen() { return open.get(); }
@@ -109,50 +141,128 @@ public final class WebSocketSession {
*/
public void close(int code) throws IOException {
if (!open.compareAndSet(true, false)) return;
- synchronized (out) {
+ writeLock.lock();
+ try {
out.write(0x88);
out.write(0x02);
out.write((code >> 8) & 0xFF);
out.write(code & 0xFF);
+ } finally {
+ writeLock.unlock();
}
}
// ── Session loop internals ─────────────────────────────────────────────
+ /**
+ * Reads the next complete message, reassembling continuation frames and delivering control
+ * frames (CLOSE/PING/PONG) as soon as they arrive — RFC 6455 §5.4 explicitly permits a
+ * control frame to interleave with a fragmented data message, and this must not disturb the
+ * data message's in-progress reassembly.
+ *
+ * @return {@code false} only on a clean EOF between messages (the peer closed the TCP
+ * connection without sending a CLOSE frame); an EOF in the middle of a frame is a
+ * protocol violation and throws, it is not reported as {@code false}.
+ * @throws WebSocketProtocolException on any RFC 6455 violation (bad opcode, unmasked/masked
+ * frame when the opposite was required, oversized control frame, fragmented control
+ * frame, message exceeding the buffer) — carries the correct close code.
+ */
public boolean readFrame(WebSocketFrame frame) throws IOException {
- int b0 = in.read();
- if (b0 < 0) return false;
- int b1 = in.read();
- if (b1 < 0) return false;
+ while (true) {
+ int b0 = in.read();
+ if (b0 < 0) return false; // clean EOF between messages
+ int b1 = in.read();
+ if (b1 < 0) throw new EOFException("WebSocket stream closed mid-frame");
- boolean fin = (b0 & 0x80) != 0;
- byte opcode = (byte) (b0 & 0x0F);
- boolean masked = (b1 & 0x80) != 0;
- long payLen = (b1 & 0x7F);
+ boolean fin = (b0 & 0x80) != 0;
+ byte opcode = (byte) (b0 & 0x0F);
+ boolean masked = (b1 & 0x80) != 0;
+ int lenBits = b1 & 0x7F;
- if (payLen == 126) {
- payLen = ((in.read() & 0xFF) << 8) | (in.read() & 0xFF);
- } else if (payLen == 127) {
- payLen = 0;
- for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (in.read() & 0xFF);
+ validateOpcode(opcode);
+
+ if (masked != requireMaskedIncoming) {
+ throw new WebSocketProtocolException(1002,
+ requireMaskedIncoming ? "client frame must be masked" : "server frame must not be masked");
+ }
+
+ int extLenBytes = lenBits == 127 ? 8 : lenBits == 126 ? 2 : 0;
+ int maskBytes = masked ? 4 : 0;
+ int extraLen = extLenBytes + maskBytes;
+ if (extraLen > 0) readFullyHeader(extraLen);
+
+ long payLen;
+ int pos;
+ if (extLenBytes == 2) {
+ payLen = ((hdrScratch[0] & 0xFFL) << 8) | (hdrScratch[1] & 0xFFL);
+ pos = 2;
+ } else if (extLenBytes == 8) {
+ // RFC 6455 §5.2: the most significant bit of the 64-bit length MUST be 0.
+ if ((hdrScratch[0] & 0x80) != 0) {
+ throw new WebSocketProtocolException(1002, "extended payload length MSB must be 0");
+ }
+ payLen = 0;
+ for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (hdrScratch[i] & 0xFFL);
+ pos = 8;
+ } else {
+ payLen = lenBits;
+ pos = 0;
+ }
+
+ boolean isControl = opcode == WebSocketFrame.OP_CLOSE
+ || opcode == WebSocketFrame.OP_PING || opcode == WebSocketFrame.OP_PONG;
+
+ if (isControl) {
+ if (!fin) throw new WebSocketProtocolException(1002, "control frame must not be fragmented");
+ if (payLen > controlBuf.length) throw new WebSocketProtocolException(1002, "control frame payload exceeds 125 bytes");
+ } else if (opcode == WebSocketFrame.OP_CONTINUATION) {
+ if (fragmentLength == 0) throw new WebSocketProtocolException(1002, "continuation frame without an initiated message");
+ } else { // TEXT or BINARY
+ if (fragmentLength != 0) throw new WebSocketProtocolException(1002, "new data frame while a fragmented message is in progress");
+ }
+
+ byte m0 = 0, m1 = 0, m2 = 0, m3 = 0;
+ if (masked) {
+ m0 = hdrScratch[pos]; m1 = hdrScratch[pos + 1]; m2 = hdrScratch[pos + 2]; m3 = hdrScratch[pos + 3];
+ }
+
+ int len = (int) payLen;
+
+ if (isControl) {
+ readFully(controlBuf, 0, len);
+ if (masked) unmaskInPlace(controlBuf, 0, len, m0, m1, m2, m3);
+ frame.reset(controlBuf, 0, len, opcode, true);
+ return true;
+ }
+
+ // Data frame (fresh TEXT/BINARY, or a CONTINUATION of one already in progress):
+ // accumulate into readBuf, bounded by its capacity — the same bound a single
+ // unfragmented frame already had before this fix.
+ if (fragmentLength + (long) len > readBuf.length) {
+ throw new WebSocketProtocolException(1009, "message exceeds " + readBuf.length + " bytes");
+ }
+ readFully(readBuf, fragmentLength, len);
+ if (masked) unmaskInPlace(readBuf, fragmentLength, len, m0, m1, m2, m3);
+
+ byte messageOpcode = opcode == WebSocketFrame.OP_CONTINUATION ? fragmentOpcode : opcode;
+ if (opcode != WebSocketFrame.OP_CONTINUATION) fragmentOpcode = opcode;
+ fragmentLength += len;
+
+ if (fin) {
+ frame.reset(readBuf, 0, fragmentLength, messageOpcode, true);
+ fragmentLength = 0;
+ return true;
+ }
+ // Not FIN: loop to read the next continuation frame (or an interleaved control frame).
}
+ }
- if (payLen > readBuf.length) throw new IOException(
- "WS frame payload " + payLen + " bytes exceeds buffer " + readBuf.length);
-
- byte m0 = 0, m1 = 0, m2 = 0, m3 = 0;
- if (masked) {
- m0 = (byte) in.read(); m1 = (byte) in.read();
- m2 = (byte) in.read(); m3 = (byte) in.read();
+ private static void validateOpcode(byte opcode) throws WebSocketProtocolException {
+ switch (opcode) {
+ case WebSocketFrame.OP_CONTINUATION, WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY,
+ WebSocketFrame.OP_CLOSE, WebSocketFrame.OP_PING, WebSocketFrame.OP_PONG -> { /* valid */ }
+ default -> throw new WebSocketProtocolException(1002, "reserved/invalid opcode " + opcode);
}
-
- int len = (int) payLen;
- readFully(readBuf, 0, len);
-
- if (masked) unmaskInPlace(readBuf, 0, len, m0, m1, m2, m3);
-
- frame.reset(readBuf, 0, len, opcode, fin);
- return true;
}
public void sendPong(WebSocketFrame ping) throws IOException {
@@ -187,13 +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.
*
- *