153 lines
8.8 KiB
Markdown
153 lines
8.8 KiB
Markdown
# 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.http2` 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.
|