8.8 KiB
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 ThreadLocals 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 ThreadLocals (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)
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():
- Stop accepting. Every listener socket is closed immediately;
stoppedflips totrue. - Drain, then force-close.
Http1Connection's request loop checksctx.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 — forcingConnection: closeon it even if the response's ownConnectionheader logic would have said keep-alive, and even if shutdown began while the handler was running (the common case).ServerLifecycle.stop()pollsactiveSocketsfor up toshutdownDrainTimeoutMs, 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 aReentrantLockinstead ofsynchronized (out)— a virtual thread blocking insidesynchronizedpins its carrier platform thread on Java 21 (JEP 491, which removes this, is JDK 24+).ReentrantLockunmounts the blocked virtual thread instead.EX-11:readFrameused to read the extended-length and mask-key bytes one at a time. It now reads that whole variable-length remainder in a single boundedreadFullyinto the existinghdrScratcharray, then decodes with shifts.EX-12:readFramenow 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 viaWebSocketProtocolExceptioncarrying the correct close code (1002 protocol error, 1009 message too big) forWebSocketLoopto send before closing.EX-13: theConnectionheader is a comma-separated token list, not a single value —Http1KeepAlive.tokenListContainsis the one scanner both the keep-alive decision andWebSocketUpgrade'sConnection: Upgradecheck use, so they cannot drift apart again.