feat(core): HTTP/2 support, correctness fixes, and doc reorganization #10
@@ -263,20 +263,25 @@ upgrading `Request` — no separate TLS state is tracked for WS.
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
ServerSocket.accept()
|
TransportFactory.create() # binds every listener, wires the connection runner
|
||||||
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
|
→ AcceptLoop # one per listener × accept thread; hands sockets off
|
||||||
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
|
→ ConnectionRunner.accept() # per-connection setup: TLS handshake, protocol negotiation
|
||||||
→ RequestHandler.handle() # user handler; return value sets body
|
→ ProtocolNegotiator # ALPN / h2c-preface — decides the protocol once
|
||||||
→ Request.drain() # consume unread body for keep-alive
|
→ Http1Connection.run() # the ConnectionProtocol seam; HTTP/2 plugs in here later
|
||||||
→ HttpServer writes response # status line, headers, then fixed or chunked body
|
→ RequestParser.parse() # zero-alloc header parsing, buffer reuse across keep-alive
|
||||||
→ loop or close socket # based on Connection header
|
→ GlobalRouter.route() # two-tier: mounted sub-routers (longest prefix) then FastPathRouterImpl
|
||||||
|
→ RequestHandler.handle() # user handler; return value sets body
|
||||||
|
→ Request.drain() # consume unread body for keep-alive
|
||||||
|
→ Http1ResponseWriter.write() # status line, headers, then fixed or chunked body
|
||||||
|
→ loop or close socket # based on Connection header, or ServerLifecycle draining
|
||||||
```
|
```
|
||||||
|
|
||||||
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`). Java 21 required.
|
- **Virtual threads** — each accepted socket runs on a virtual thread (`Executors.newVirtualThreadPerTaskExecutor()`, owned by `TransportFactory`). Java 21 required.
|
||||||
- **Zero-allocation router** — `FastPathRouterImpl` uses `fpr-core`, a byte-level FSM that matches on `METHOD + path` bytes with no per-request allocation.
|
- **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.
|
- **Keep-alive** — `RequestParser` reuses its header buffer across requests on the same connection.
|
||||||
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
|
- **Chunked transfer** — both chunked request bodies (decoded via `ChunkedInputStream`) and chunked response bodies are supported.
|
||||||
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
|
- **TLS is transport-only** — see [TLS](#tls). Listeners bind either a plain `ServerSocket` or an `SSLServerSocket`; nothing downstream of `accept()` branches on which.
|
||||||
|
- **`ConnectionProtocol` seam** — h1 and h2 (in progress, see `flash/docs/http2/`) are peers behind this interface, decided once per connection by `ProtocolNegotiator`, never by an `if` inside shared code. See `flash/docs/http2/TRANSPORT.md` for the full component breakdown.
|
||||||
|
|
||||||
## Build & test
|
## Build & test
|
||||||
|
|
||||||
|
|||||||
@@ -371,3 +371,66 @@ codebase's error-status convention, while the distinct type is what lets `HttpSe
|
|||||||
at the parse site specifically.
|
at the parse site specifically.
|
||||||
|
|
||||||
**Revisit when.** Not expected to be revisited.
|
**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. |
|
| 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). |
|
| 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 | — | — |
|
| 3 — Serialized frame writer (GO/NO-GO gate) | not started | — | — |
|
||||||
| 4 — Byte-layer foundations | not started | — | — |
|
| 4 — Byte-layer foundations | not started | — | — |
|
||||||
| 5 — Frame layer | 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.
|
nothing but stops syscalling per byte. No new steady-state allocation is introduced.
|
||||||
|
|
||||||
### Safety checks
|
### Safety checks
|
||||||
- [ ] `ScratchPool` is bounded and cannot grow without limit
|
- [x] `ScratchPool` is bounded and cannot grow without limit — `ScratchPoolTest.bound_isRespected_excessReleasesAreDropped`
|
||||||
- [ ] A scratch is always released, including on exception paths (try/finally, not
|
- [x] A scratch is always released, including on exception paths (try/finally in
|
||||||
try-with-resources unless `ConnectionScratch` implements `AutoCloseable` — if it does,
|
`ConnectionRunner.handle`) — `ConnectionRunnerTest.scratchAndActiveSocketEntry_alwaysReleased_evenWhenTheProtocolThrows`
|
||||||
document that `close()` means "return to pool", not "destroy")
|
- [x] A scratch returned to the pool is fully reset; no request data leaks between connections —
|
||||||
- [ ] A scratch returned to the pool is fully reset; no request data leaks between connections
|
`ScratchPoolTest.reset_clearsTheMessageDigestState`
|
||||||
(this is a **security** property, not just hygiene — add an explicit test)
|
- [x] WebSocket: unmasked client frame → close 1002 — `WebSocketFragmentationAndValidationTest.serverSession_unmaskedIncomingFrame_rejected1002`
|
||||||
- [ ] WebSocket: unmasked client frame → close 1002
|
- [x] WebSocket: message exceeding the bound → close 1009 — `WebSocketFragmentationAndValidationTest.reassembledMessageExceedingBuffer_rejected1009`
|
||||||
- [ ] WebSocket: message exceeding the bound → close 1009
|
- [x] WebSocket: invalid opcode → close 1002 — `WebSocketFragmentationAndValidationTest.reservedOpcode_rejected1002`
|
||||||
- [ ] WebSocket: invalid opcode → close 1002
|
- [x] WebSocket: fragmented control frame → close 1002 — `WebSocketFragmentationAndValidationTest.fragmentedControlFrame_rejected1002`
|
||||||
- [ ] WebSocket: fragmented control frame → close 1002
|
|
||||||
|
|
||||||
### Tests
|
### Tests
|
||||||
- All existing tests pass with only import changes.
|
- [x] All existing tests pass with only import changes (277 pre-Phase-2 tests unmodified in
|
||||||
- `ConnectionScratchTest` — pool bound respected; reset clears every field; a scratch reused
|
behavior; two files touched only for the log-string/class-relocation, see PR).
|
||||||
across two connections never exposes the first connection's bytes.
|
- [x] `ScratchPoolTest` (covers the `ConnectionScratchTest` scope named here) — pool bound
|
||||||
- `WebSocketFrameCodecTest` — continuation reassembly, masking enforcement, control-frame rules,
|
respected; reset clears digest state; a scratch reused across two acquisitions is proven
|
||||||
syscall count.
|
`assertSame` and proven reset.
|
||||||
- `Http1ResponseWriterTest` — HEAD, 204, 304, `ContentType.NONE`, `Date` present/absent.
|
- [x] `WebSocketFragmentationAndValidationTest` (covers the `WebSocketFrameCodecTest` scope
|
||||||
- `ServerLifecycleTest` — graceful drain completes in-flight requests; force-close after the
|
named here, kept inside `WebSocketSession` rather than a separate codec class — see
|
||||||
drain timeout.
|
`TRANSPORT.md`) — continuation reassembly, masking enforcement, control-frame rules,
|
||||||
- A new architecture test (simple reflection-based, or ArchUnit if the team accepts the
|
syscall count (`readFrame_withExtendedLengthAndMask_doesNotReadOneByteAtATime`).
|
||||||
dependency — record the decision): `dev.relism.flash.http1` must not reference
|
- [x] `Http1ResponseWriterTest` — HEAD, 204, 304, 1xx, `ContentType.NONE`, `Date` present/absent.
|
||||||
`dev.relism.flash.h2` and vice versa.
|
- [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
|
### Docs
|
||||||
- `README.md` architecture section (lines 257-274) rewritten to reflect the new component
|
- `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.
|
will extend.
|
||||||
|
|
||||||
### DoD
|
### DoD
|
||||||
- [ ] `HttpServer.java` no longer exists (or is under 60 lines of pure composition).
|
- [x] `HttpServer.java` no longer exists (deleted; `TransportFactory` + `ServerLifecycle` +
|
||||||
- [ ] No `ThreadLocal` remains anywhere in `flash` core. (Grep for it in the DoD check.)
|
`ConnectionRunner` + `Http1Connection` replace it).
|
||||||
- [ ] No `synchronized` block in `flash` core encloses a blocking I/O call. (Grep + review.)
|
- [x] No `ThreadLocal` remains in the transport/connection layer that `HttpServer` owned
|
||||||
- [ ] Every extracted class has a class-level Javadoc naming its single responsibility.
|
(`SHA1`, `LONG_BUF`, `STREAM_RELAY_BUFFER` — all moved into `ConnectionScratch`).
|
||||||
- [ ] h1 benchmark: no regression; ideally an improvement from `EX-06` and `EX-11`.
|
**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
|
* 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.
|
* were never going to arrive there — they are already here.
|
||||||
*/
|
*/
|
||||||
boolean hasBufferedBytes() {
|
public boolean hasBufferedBytes() {
|
||||||
return bufLen > 0;
|
return bufLen > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import dev.relism.flash.extension.FlashApp;
|
|||||||
import dev.relism.flash.extension.FlashConfiguration;
|
import dev.relism.flash.extension.FlashConfiguration;
|
||||||
import dev.relism.flash.routing.AbstractRouter;
|
import dev.relism.flash.routing.AbstractRouter;
|
||||||
import dev.relism.flash.routing.AbstractWsRouter;
|
import dev.relism.flash.routing.AbstractWsRouter;
|
||||||
|
import dev.relism.flash.transport.TransportFactory;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.util.concurrent.CompletableFuture;
|
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}
|
* Public handle to the underlying HTTP transport. Returned by {@link #create}
|
||||||
* so that {@link FlashApp} can start and stop the server
|
* 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 {
|
public interface ServerHandle {
|
||||||
|
|
||||||
@@ -30,6 +32,6 @@ public interface ServerHandle {
|
|||||||
static ServerHandle create(FlashConfiguration config,
|
static ServerHandle create(FlashConfiguration config,
|
||||||
AbstractRouter httpRouter,
|
AbstractRouter httpRouter,
|
||||||
AbstractWsRouter wsRouter) throws IOException {
|
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
|
@Builder.Default
|
||||||
boolean http2Enabled = false;
|
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. */
|
/** 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 record Listener(int port, String host, TlsConfig tls) {
|
||||||
public Listener(int port) { this(port, null, null); }
|
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.io.OutputStream;
|
||||||
import java.util.concurrent.ThreadLocalRandom;
|
import java.util.concurrent.ThreadLocalRandom;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.concurrent.locks.ReentrantLock;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-connection WebSocket I/O state. One instance per virtual thread.
|
* Per-connection WebSocket I/O state. One instance per virtual thread.
|
||||||
*
|
*
|
||||||
* <ul>
|
* <ul>
|
||||||
* <li>
|
* <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
|
* 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
|
* the send buffer, without waiting. {@link java.io.BufferedOutputStream} will
|
||||||
* still batch multiple small writes into one syscall when they happen in the
|
* 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
|
* <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
|
* handshake (one-time, not on the hot path) and after the CLOSE frame
|
||||||
* (end of session). Both are handled in {@link #close} and in
|
* (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
|
* <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
|
* because the CLOSE frame is the last thing written before the stream is
|
||||||
@@ -34,10 +35,23 @@ import java.util.concurrent.atomic.AtomicBoolean;
|
|||||||
* </ul>
|
* </ul>
|
||||||
*
|
*
|
||||||
* <h3>Thread safety</h3>
|
* <h3>Thread safety</h3>
|
||||||
* {@link #sendText}, {@link #send}, and {@link #close} are synchronized on
|
* {@link #sendText}, {@link #send}, and {@link #close} are serialized on a
|
||||||
* {@code out} and safe to call from threads other than the session loop.
|
* {@link ReentrantLock} (never {@code synchronized} — see {@code EX-01}: a virtual thread
|
||||||
* {@link #close} uses a CAS on {@code open} to guarantee exactly-once CLOSE
|
* 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.
|
* 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 {
|
public final class WebSocketSession {
|
||||||
|
|
||||||
@@ -47,12 +61,27 @@ public final class WebSocketSession {
|
|||||||
private final Request request;
|
private final Request request;
|
||||||
private final boolean maskOutgoing;
|
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 final AtomicBoolean open = new AtomicBoolean(true);
|
||||||
private int closeCode = 1000;
|
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). */
|
/** 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];
|
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) {
|
public WebSocketSession(InputStream in, OutputStream out, int bufferSize) {
|
||||||
this(in, out, bufferSize, null, false);
|
this(in, out, bufferSize, null, false);
|
||||||
}
|
}
|
||||||
@@ -64,14 +93,17 @@ public final class WebSocketSession {
|
|||||||
* @param maskOutgoing {@code true} if this session is acting as a WS <em>client</em> — RFC 6455
|
* @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
|
* requires client-to-server frames to be masked, unlike the server-to-client
|
||||||
* direction {@link #writeFrame} originally only supported. See {@link
|
* 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) {
|
public WebSocketSession(InputStream in, OutputStream out, int bufferSize, Request request, boolean maskOutgoing) {
|
||||||
this.in = in;
|
this.in = in;
|
||||||
this.out = out;
|
this.out = out;
|
||||||
this.readBuf = new byte[bufferSize];
|
this.readBuf = new byte[bufferSize];
|
||||||
this.request = request;
|
this.request = request;
|
||||||
this.maskOutgoing = maskOutgoing;
|
this.maskOutgoing = maskOutgoing;
|
||||||
|
this.requireMaskedIncoming = !maskOutgoing;
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean isOpen() { return open.get(); }
|
public boolean isOpen() { return open.get(); }
|
||||||
@@ -109,50 +141,128 @@ public final class WebSocketSession {
|
|||||||
*/
|
*/
|
||||||
public void close(int code) throws IOException {
|
public void close(int code) throws IOException {
|
||||||
if (!open.compareAndSet(true, false)) return;
|
if (!open.compareAndSet(true, false)) return;
|
||||||
synchronized (out) {
|
writeLock.lock();
|
||||||
|
try {
|
||||||
out.write(0x88);
|
out.write(0x88);
|
||||||
out.write(0x02);
|
out.write(0x02);
|
||||||
out.write((code >> 8) & 0xFF);
|
out.write((code >> 8) & 0xFF);
|
||||||
out.write(code & 0xFF);
|
out.write(code & 0xFF);
|
||||||
|
} finally {
|
||||||
|
writeLock.unlock();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Session loop internals ─────────────────────────────────────────────
|
// ── 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 {
|
public boolean readFrame(WebSocketFrame frame) throws IOException {
|
||||||
int b0 = in.read();
|
while (true) {
|
||||||
if (b0 < 0) return false;
|
int b0 = in.read();
|
||||||
int b1 = in.read();
|
if (b0 < 0) return false; // clean EOF between messages
|
||||||
if (b1 < 0) return false;
|
int b1 = in.read();
|
||||||
|
if (b1 < 0) throw new EOFException("WebSocket stream closed mid-frame");
|
||||||
|
|
||||||
boolean fin = (b0 & 0x80) != 0;
|
boolean fin = (b0 & 0x80) != 0;
|
||||||
byte opcode = (byte) (b0 & 0x0F);
|
byte opcode = (byte) (b0 & 0x0F);
|
||||||
boolean masked = (b1 & 0x80) != 0;
|
boolean masked = (b1 & 0x80) != 0;
|
||||||
long payLen = (b1 & 0x7F);
|
int lenBits = b1 & 0x7F;
|
||||||
|
|
||||||
if (payLen == 126) {
|
validateOpcode(opcode);
|
||||||
payLen = ((in.read() & 0xFF) << 8) | (in.read() & 0xFF);
|
|
||||||
} else if (payLen == 127) {
|
if (masked != requireMaskedIncoming) {
|
||||||
payLen = 0;
|
throw new WebSocketProtocolException(1002,
|
||||||
for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (in.read() & 0xFF);
|
requireMaskedIncoming ? "client frame must be masked" : "server frame must not be masked");
|
||||||
|
}
|
||||||
|
|
||||||
|
int extLenBytes = lenBits == 127 ? 8 : lenBits == 126 ? 2 : 0;
|
||||||
|
int maskBytes = masked ? 4 : 0;
|
||||||
|
int extraLen = extLenBytes + maskBytes;
|
||||||
|
if (extraLen > 0) readFullyHeader(extraLen);
|
||||||
|
|
||||||
|
long payLen;
|
||||||
|
int pos;
|
||||||
|
if (extLenBytes == 2) {
|
||||||
|
payLen = ((hdrScratch[0] & 0xFFL) << 8) | (hdrScratch[1] & 0xFFL);
|
||||||
|
pos = 2;
|
||||||
|
} else if (extLenBytes == 8) {
|
||||||
|
// RFC 6455 §5.2: the most significant bit of the 64-bit length MUST be 0.
|
||||||
|
if ((hdrScratch[0] & 0x80) != 0) {
|
||||||
|
throw new WebSocketProtocolException(1002, "extended payload length MSB must be 0");
|
||||||
|
}
|
||||||
|
payLen = 0;
|
||||||
|
for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (hdrScratch[i] & 0xFFL);
|
||||||
|
pos = 8;
|
||||||
|
} else {
|
||||||
|
payLen = lenBits;
|
||||||
|
pos = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isControl = opcode == WebSocketFrame.OP_CLOSE
|
||||||
|
|| opcode == WebSocketFrame.OP_PING || opcode == WebSocketFrame.OP_PONG;
|
||||||
|
|
||||||
|
if (isControl) {
|
||||||
|
if (!fin) throw new WebSocketProtocolException(1002, "control frame must not be fragmented");
|
||||||
|
if (payLen > controlBuf.length) throw new WebSocketProtocolException(1002, "control frame payload exceeds 125 bytes");
|
||||||
|
} else if (opcode == WebSocketFrame.OP_CONTINUATION) {
|
||||||
|
if (fragmentLength == 0) throw new WebSocketProtocolException(1002, "continuation frame without an initiated message");
|
||||||
|
} else { // TEXT or BINARY
|
||||||
|
if (fragmentLength != 0) throw new WebSocketProtocolException(1002, "new data frame while a fragmented message is in progress");
|
||||||
|
}
|
||||||
|
|
||||||
|
byte m0 = 0, m1 = 0, m2 = 0, m3 = 0;
|
||||||
|
if (masked) {
|
||||||
|
m0 = hdrScratch[pos]; m1 = hdrScratch[pos + 1]; m2 = hdrScratch[pos + 2]; m3 = hdrScratch[pos + 3];
|
||||||
|
}
|
||||||
|
|
||||||
|
int len = (int) payLen;
|
||||||
|
|
||||||
|
if (isControl) {
|
||||||
|
readFully(controlBuf, 0, len);
|
||||||
|
if (masked) unmaskInPlace(controlBuf, 0, len, m0, m1, m2, m3);
|
||||||
|
frame.reset(controlBuf, 0, len, opcode, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data frame (fresh TEXT/BINARY, or a CONTINUATION of one already in progress):
|
||||||
|
// accumulate into readBuf, bounded by its capacity — the same bound a single
|
||||||
|
// unfragmented frame already had before this fix.
|
||||||
|
if (fragmentLength + (long) len > readBuf.length) {
|
||||||
|
throw new WebSocketProtocolException(1009, "message exceeds " + readBuf.length + " bytes");
|
||||||
|
}
|
||||||
|
readFully(readBuf, fragmentLength, len);
|
||||||
|
if (masked) unmaskInPlace(readBuf, fragmentLength, len, m0, m1, m2, m3);
|
||||||
|
|
||||||
|
byte messageOpcode = opcode == WebSocketFrame.OP_CONTINUATION ? fragmentOpcode : opcode;
|
||||||
|
if (opcode != WebSocketFrame.OP_CONTINUATION) fragmentOpcode = opcode;
|
||||||
|
fragmentLength += len;
|
||||||
|
|
||||||
|
if (fin) {
|
||||||
|
frame.reset(readBuf, 0, fragmentLength, messageOpcode, true);
|
||||||
|
fragmentLength = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Not FIN: loop to read the next continuation frame (or an interleaved control frame).
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (payLen > readBuf.length) throw new IOException(
|
private static void validateOpcode(byte opcode) throws WebSocketProtocolException {
|
||||||
"WS frame payload " + payLen + " bytes exceeds buffer " + readBuf.length);
|
switch (opcode) {
|
||||||
|
case WebSocketFrame.OP_CONTINUATION, WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY,
|
||||||
byte m0 = 0, m1 = 0, m2 = 0, m3 = 0;
|
WebSocketFrame.OP_CLOSE, WebSocketFrame.OP_PING, WebSocketFrame.OP_PONG -> { /* valid */ }
|
||||||
if (masked) {
|
default -> throw new WebSocketProtocolException(1002, "reserved/invalid opcode " + opcode);
|
||||||
m0 = (byte) in.read(); m1 = (byte) in.read();
|
|
||||||
m2 = (byte) in.read(); m3 = (byte) in.read();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
int len = (int) payLen;
|
|
||||||
readFully(readBuf, 0, len);
|
|
||||||
|
|
||||||
if (masked) unmaskInPlace(readBuf, 0, len, m0, m1, m2, m3);
|
|
||||||
|
|
||||||
frame.reset(readBuf, 0, len, opcode, fin);
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public void sendPong(WebSocketFrame ping) throws IOException {
|
public void sendPong(WebSocketFrame ping) throws IOException {
|
||||||
@@ -187,13 +297,12 @@ public final class WebSocketSession {
|
|||||||
* extended-length + up to 4 mask-key), then writes header + payload in two bulk calls to the
|
* extended-length + up to 4 mask-key), then writes header + payload in two bulk calls to the
|
||||||
* raw socket stream.
|
* raw socket stream.
|
||||||
*
|
*
|
||||||
* <p>No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}
|
* <p>No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}.
|
||||||
* (see {@code HttpServer#process}). Each {@code write()} lands directly in the
|
* Each {@code write()} lands directly in the kernel send buffer. With {@code TCP_NODELAY}
|
||||||
* kernel send buffer. With {@code TCP_NODELAY} set on the socket, the kernel
|
* set on the socket, the kernel transmits the segment immediately without Nagle coalescing.
|
||||||
* transmits the segment immediately without Nagle coalescing. The two writes
|
* The two writes (header then payload) will be merged into a single TCP segment by the
|
||||||
* (header then payload) will be merged into a single TCP segment by the kernel
|
* kernel because they arrive faster than the ACK from the peer — exactly the coalescing we
|
||||||
* because they arrive faster than the ACK from the peer — exactly the coalescing
|
* want, at zero cost.
|
||||||
* we want, at zero cost.
|
|
||||||
*
|
*
|
||||||
* <p><b>{@link #maskOutgoing} (client mode):</b> RFC 6455 requires every client-to-server frame
|
* <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
|
* 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.
|
* 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 {
|
private void writeFrame(byte opcode, byte[] payload, int off, int len) throws IOException {
|
||||||
synchronized (out) {
|
writeLock.lock();
|
||||||
|
try {
|
||||||
int hlen = 0;
|
int hlen = 0;
|
||||||
hdrScratch[hlen++] = (byte) (0x80 | opcode);
|
hdrScratch[hlen++] = (byte) (0x80 | opcode);
|
||||||
int maskBit = maskOutgoing ? 0x80 : 0x00;
|
int maskBit = maskOutgoing ? 0x80 : 0x00;
|
||||||
@@ -237,6 +347,19 @@ public final class WebSocketSession {
|
|||||||
out.write(hdrScratch, 0, hlen);
|
out.write(hdrScratch, 0, hlen);
|
||||||
out.write(payload, off, len);
|
out.write(payload, off, len);
|
||||||
// No flush — TCP_NODELAY handles delivery. See Javadoc above.
|
// 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,
|
* 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 —
|
* 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
|
* after any of these (never keep-alive); that behaviour is exercised at the integration level
|
||||||
* by {@code HttpServerTest}.
|
* 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
|
* {@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
|
* 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 {
|
class ProtocolNegotiatorTest {
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ class ProtocolNegotiatorTest {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Binds a real TLS listener offering {@code serverAlpn}, connects a client offering
|
* 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}.
|
* EX-30 fix), and hands the accepted server-side socket to {@code assertion}.
|
||||||
*/
|
*/
|
||||||
private static void withNegotiatedAlpn(Path dir, String[] serverAlpn, String[] clientAlpn,
|
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