Files
Flash5/flash/docs/http2/TRANSPORT.md
T

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.