feat(core): HTTP/2 Phase 2 — transport decomposition
Breaks HttpServer (563 lines, eleven responsibilities) into named, single-purpose components and introduces the ConnectionProtocol seam HTTP/2 plugs into starting Phase 8, per flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 2. New packages: - dev.relism.flash.transport: TransportFactory (composition root, EX-34), ListenerBinder, BoundListener, TransportTuning, AcceptLoop, ConnectionRunner (per-connection setup/teardown), ConnectionProtocol (the h1/h2 seam), ConnectionContext, ConnectionScratch + ScratchPool (EX-06), ServerLifecycle (implements ServerHandle; start/stop/graceful shutdown, EX-32). - dev.relism.flash.http1: Http1Connection (the keep-alive request loop, implements ConnectionProtocol), Http1ResponseWriter, Http1KeepAlive (the shared Connection-header token-list scanner, EX-13). - dev.relism.flash.websocket additions: WebSocketUpgrade (detection + handshake), WebSocketLoop (session loop), WebSocketProtocolException. Existing-code defects fixed (EX-nn): - EX-01: WebSocketSession's two blocking-write sites use ReentrantLock instead of synchronized (out) -- a virtual thread blocking inside synchronized pins its carrier platform thread on Java 21. - EX-06: HttpServer's three ThreadLocals (SHA1, LONG_BUF, STREAM_RELAY_BUFFER) replaced by ConnectionScratch, pooled via ScratchPool instead of one-per-virtual-thread (i.e. one-per-connection) growth. The router's ThreadLocals are deliberately deferred to Phase 4 per this EX item's own phasing -- see DEC-15 for the plan-wording fix. - EX-11: WebSocketSession.readFrame's extended-length and mask-key bytes are now read in a single bounded readFully instead of one at a time. - EX-12: full RFC 6455 frame validation -- continuation-frame reassembly, mandatory masking-direction enforcement, opcode validation, control-frame constraints (not fragmented, <=125 bytes), and WebSocketProtocolException carrying the correct close code (1002 protocol error, 1009 message too big). - EX-13: Connection header token-list scanning shared between the keep-alive decision and the WebSocket upgrade check. - EX-14: HEAD responses report Content-Length but write no body. - EX-15: Content-Type omitted when empty; Content-Length and the body omitted entirely for 204/304/1xx responses. - EX-16: Date header (dev.relism.flash.http.DateHeader), refreshed once per second by a shared daemon thread; FlashConfiguration.sendDate. - EX-32: two-stage graceful shutdown -- stop accepting, force Connection: close on the response an in-flight handler is still producing (re-checked after the handler runs, not just before dispatch, so a shutdown beginning mid-handler is still honoured), drain up to shutdownDrainTimeoutMs, then force-close. - EX-34: ServerHandle.create delegates to TransportFactory instead of constructing HttpServer directly. Two plan corrections recorded: DEC-15 (Phase 2's "no ThreadLocal anywhere" DoD line contradicted EX-06's own multi-phase assignment -- corrected to match the registry) and DEC-16 (no separate WebSocketFrameCodec class this phase; the EX-11/EX-12 fixes stay inside WebSocketSession, which is one cohesive state machine under R6's own carve-out -- revisit at Phase 15 if RFC 8441 needs the decoupling for real). HttpServer.java deleted. 311/311 tests green (flash module), run three times for stability of the wall-clock-based timeout/shutdown tests. Whole-repo build green. h1 benchmark regression check remains unverified in the plan's DoD (no JMH harness until Phase 3, same caveat as Phase 1). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
5a2aaf5a07
commit
a315e1df8b
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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}<{@link MessageDigest}> — one per
|
||||
* accept thread (there are now {@code ACCEPT_THREADS} of them, not one).</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
class HttpServer implements ServerHandle {
|
||||
|
||||
// ── Tuning constants ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Number of platform threads competing on {@code serverSocket.accept()}.
|
||||
* Rule of thumb: number of available CPU cores, capped at 8.
|
||||
* More than this rarely helps — accept is cheap; the bottleneck is usually
|
||||
* the virtual-thread executor dispatching the connection handler.
|
||||
*/
|
||||
private static final int ACCEPT_THREADS = Math.min(Runtime.getRuntime().availableProcessors(), 8);
|
||||
|
||||
/**
|
||||
* TCP listen backlog. The kernel holds up to this many fully-established
|
||||
* (SYN+ACK sent, ACK received) connections waiting for accept().
|
||||
* 4096 is safe on Linux; /proc/sys/net/core/somaxconn must be >= this value,
|
||||
* or the kernel silently caps it. Raise somaxconn if needed:
|
||||
* sysctl -w net.core.somaxconn=4096
|
||||
*/
|
||||
private static final int ACCEPT_BACKLOG = 4096;
|
||||
|
||||
/**
|
||||
* Socket send/receive buffer sizes. Matched to the WS frame read buffer
|
||||
* ({@link FlashConfiguration#getWsFrameBufferSize()}) so the kernel never
|
||||
* needs to fragment a full frame into multiple TCP segments on the receive
|
||||
* side, and never blocks a write waiting for the send buffer to drain.
|
||||
*
|
||||
* Linux default is 87380 bytes (rmem) / 16384 bytes (wmem). We raise both
|
||||
* to 256 KB — a good fit for up to ~250 KB WS frames with no partial reads.
|
||||
*/
|
||||
private static final int SOCKET_BUF_SIZE = 256 * 1024;
|
||||
|
||||
// ── Instance fields ───────────────────────────────────────────────────────
|
||||
|
||||
private final FlashConfiguration configuration;
|
||||
private final List<BoundListener> boundListeners;
|
||||
private final AbstractRouter router;
|
||||
private final AbstractWsRouter wsRouter;
|
||||
private final ExecutorService executorService = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
|
||||
private volatile boolean stopped = false;
|
||||
|
||||
/** Latch that reaches 0 when all accept threads, across all listeners, have exited. */
|
||||
private final CountDownLatch acceptLatch;
|
||||
|
||||
/** One bound listener socket (plain or TLS) plus whether it is TLS, for logging only. */
|
||||
private record BoundListener(ServerSocket socket, boolean secure) {}
|
||||
|
||||
// ── Static byte constants (written once, read-only on hot path) ──────────
|
||||
|
||||
private static final byte[] HTTP_1_1 = "HTTP/1.1 ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CRLF = "\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_TYPE = "Content-Type: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONTENT_LENGTH = "Content-Length: ".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_CLOSE = "Connection: close\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] CONNECTION_KEEPALIVE = "Connection: keep-alive\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] TRANSFER_CHUNKED = "Transfer-Encoding: chunked\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] FINAL_CHUNK = "0\r\n\r\n".getBytes(StandardCharsets.UTF_8);
|
||||
private static final byte[] UNKNOWN_STATUS_SUFFIX = " Unknown".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
private static final byte[] WS_HANDSHAKE_PREFIX =
|
||||
("HTTP/1.1 101 Switching Protocols\r\n" +
|
||||
"Upgrade: websocket\r\n" +
|
||||
"Connection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Accept: ")
|
||||
.getBytes(StandardCharsets.ISO_8859_1);
|
||||
private static final byte[] WS_HANDSHAKE_SUFFIX =
|
||||
"\r\n\r\n".getBytes(StandardCharsets.ISO_8859_1);
|
||||
private static final byte[] WS_REJECT_400 =
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
.getBytes(StandardCharsets.ISO_8859_1);
|
||||
|
||||
private static final byte[] WS_GUID_BYTES =
|
||||
"258EAFA5-E914-47DA-95CA-C5AB0DC85B11".getBytes(StandardCharsets.ISO_8859_1);
|
||||
|
||||
private static final ThreadLocal<MessageDigest> SHA1 =
|
||||
ThreadLocal.withInitial(() -> {
|
||||
try { return MessageDigest.getInstance("SHA-1"); }
|
||||
catch (NoSuchAlgorithmException e) { throw new RuntimeException(e); }
|
||||
});
|
||||
|
||||
private static final ThreadLocal<byte[]> LONG_BUF = ThreadLocal.withInitial(() -> new byte[20]);
|
||||
|
||||
/**
|
||||
* Relay buffer for copying a streaming {@link Response} body to the client — shared by
|
||||
* {@link #writeStreamingBody}'s non-chunked path and {@link #writeChunked}, so both draw
|
||||
* from the same reused array instead of each allocating its own {@code byte[8192]} (the
|
||||
* non-chunked path previously relied on {@link InputStream#transferTo}, which allocates
|
||||
* internally on every call). Sized to match the pre-existing behavior this replaces, not
|
||||
* newly tuned — not exposed as a {@link FlashConfiguration} tunable since nothing here
|
||||
* needed one before.
|
||||
*/
|
||||
private static final int STREAM_RELAY_BUFFER_SIZE = 8192;
|
||||
private static final ThreadLocal<byte[]> STREAM_RELAY_BUFFER =
|
||||
ThreadLocal.withInitial(() -> new byte[STREAM_RELAY_BUFFER_SIZE]);
|
||||
|
||||
private static final int SHA1_LEN = 20;
|
||||
private static final int WS_ACCEPT_LEN = 28;
|
||||
|
||||
// ── Constructor ───────────────────────────────────────────────────────────
|
||||
|
||||
HttpServer(FlashConfiguration configuration, AbstractRouter router, AbstractWsRouter wsRouter) throws IOException {
|
||||
this.configuration = configuration;
|
||||
this.router = router;
|
||||
this.wsRouter = wsRouter;
|
||||
|
||||
List<FlashConfiguration.Listener> specs = configuration.getListeners().isEmpty()
|
||||
? List.of(new FlashConfiguration.Listener(
|
||||
configuration.getPort(), configuration.getHost(), configuration.getTls()))
|
||||
: configuration.getListeners();
|
||||
|
||||
List<BoundListener> bound = new ArrayList<>(specs.size());
|
||||
for (FlashConfiguration.Listener spec : specs) bound.add(bind(spec));
|
||||
this.boundListeners = List.copyOf(bound);
|
||||
this.acceptLatch = new CountDownLatch(ACCEPT_THREADS * boundListeners.size());
|
||||
|
||||
for (BoundListener bl : boundListeners) {
|
||||
log.info("HttpServer bound on {}:{} (tls={}, backlog={}, acceptThreads={})",
|
||||
bl.socket().getInetAddress(), bl.socket().getLocalPort(), bl.secure(),
|
||||
ACCEPT_BACKLOG, ACCEPT_THREADS);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Binds one listener. A TLS listener gets its {@link ServerSocket} from
|
||||
* {@link TlsConfig#serverSocketFactory()} instead of {@code new ServerSocket()}, and its
|
||||
* protocol/client-auth parameters from {@link TlsConfig#applyTo} — reuse-address, receive
|
||||
* buffer size, backlog and the bind call itself are identical either way. TLS only changes
|
||||
* which bytes come out of {@code accept()}; it never changes how the accept loop, or
|
||||
* anything downstream of it, treats them.
|
||||
*/
|
||||
private static BoundListener bind(FlashConfiguration.Listener spec) throws IOException {
|
||||
TlsConfig tls = spec.tls();
|
||||
|
||||
ServerSocket socket = tls != null ? tls.serverSocketFactory().createServerSocket() : new ServerSocket();
|
||||
// setReuseAddress(true) must be called BEFORE bind().
|
||||
socket.setReuseAddress(true);
|
||||
socket.setReceiveBufferSize(SOCKET_BUF_SIZE);
|
||||
if (tls != null) tls.applyTo((SSLServerSocket) socket);
|
||||
|
||||
InetSocketAddress addr = spec.host() != null
|
||||
? new InetSocketAddress(spec.host(), spec.port())
|
||||
: new InetSocketAddress(spec.port());
|
||||
socket.bind(addr, ACCEPT_BACKLOG);
|
||||
|
||||
return new BoundListener(socket, tls != null);
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────
|
||||
|
||||
@Override
|
||||
public void start() {
|
||||
for (int li = 0; li < boundListeners.size(); li++) {
|
||||
BoundListener listener = boundListeners.get(li);
|
||||
for (int i = 0; i < ACCEPT_THREADS; i++) {
|
||||
Thread.ofPlatform()
|
||||
.name("flash-accept-" + li + "-" + i)
|
||||
.daemon(false)
|
||||
.start(() -> acceptLoop(listener));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void startAndBlock() {
|
||||
start();
|
||||
try { acceptLatch.await(); }
|
||||
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Single accept loop body — runs on each of the {@code ACCEPT_THREADS}
|
||||
* platform threads bound to one {@code listener}. All threads for that listener block on
|
||||
* the same {@link ServerSocket}; the JVM ensures only one wakes per incoming connection
|
||||
* (no thundering herd). Other listeners' accept threads are entirely independent.
|
||||
*/
|
||||
private void acceptLoop(BoundListener listener) {
|
||||
try {
|
||||
while (!stopped) {
|
||||
try {
|
||||
process(listener.socket().accept());
|
||||
} catch (IOException e) {
|
||||
if (!stopped) log.error("Accept error", e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
acceptLatch.countDown();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> stop() {
|
||||
return CompletableFuture.runAsync(() -> {
|
||||
stopped = true;
|
||||
for (BoundListener bl : boundListeners) {
|
||||
try { bl.socket().close(); } catch (IOException e) { log.error("Error closing server socket", e); }
|
||||
}
|
||||
activeSockets.forEach(s -> { try { s.close(); } catch (IOException ignored) {} });
|
||||
executorService.shutdown();
|
||||
try {
|
||||
if (!executorService.awaitTermination(30, TimeUnit.SECONDS))
|
||||
executorService.shutdownNow();
|
||||
} catch (InterruptedException e) {
|
||||
executorService.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Hot-path ──────────────────────────────────────────────────────────────
|
||||
|
||||
private void process(Socket socket) {
|
||||
try {
|
||||
executorService.submit(() -> {
|
||||
activeSockets.add(socket);
|
||||
try (socket;
|
||||
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);
|
||||
}
|
||||
}
|
||||
+116
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+220
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user