HttpProxy and Http2Client (719 LOC) shipped a reverse-proxy adapter and outbound HTTP/2 client from flash core with zero callers anywhere in the server itself — only each other and their own tests. An HTTP/1.1+2 server framework has no business bundling an outbound client; that capability belongs in its own flash-extensions/flash-ext-* module if/when it's needed. Removed, along with the now-dead src/bench load driver that depended on Http2Client (no replacement client written here — flagged as follow-up work, not silently dropped). docs/http2/ had accumulated core, cross-protocol documentation alongside genuine HTTP/2-protocol internals: HTTP1-HARDENING, TRANSPORT, MESSAGE-MODEL, TRAILERS-AND-STREAMING and BYTES all describe machinery HTTP/1.1 and HTTP/2 share, not HTTP/2 specifically. Moved to a new docs/core/, leaving docs/http2/ to the protocol layers, wire internals and operational docs that are actually HTTP/2-specific. CLEARTEXT-AND-PROXY.md renamed to CLEARTEXT.md and its now-removed upstream-client section cut, matching the source removal above. README.md: removed the "HTTP/2 upstream proxy" section (documented the deleted HttpProxy/Http2Client), the flash-bench module row and build command (not a module that exists in this repo), and fixed every doc link to the new docs/core/ paths. Added the new FlashConfiguration.maxConnections field to the configuration reference. src/bench/ (a load-test harness distinct from the JMH suite, not wired into any Maven profile or CI) is committed here for the first time.
149 lines
8.3 KiB
Markdown
149 lines
8.3 KiB
Markdown
# Transport architecture
|
|
|
|
Audience: contributors. This document describes the shared listener and connection layer behind
|
|
the HTTP/1.1 and HTTP/2 implementations.
|
|
|
|
## Why this exists
|
|
|
|
The original `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`).
|
|
|
|
The current design replaces it with named, single-responsibility components and a
|
|
`ConnectionProtocol` seam implemented by both wire protocols.
|
|
|
|
## 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, both protocols)
|
|
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
|
|
build ConnectionContext
|
|
dispatch to http1Protocol.run(ctx) or http2Protocol.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 routers use an explicit per-connection scratch passed through `AbstractRouter.route`; neither
|
|
`FastPathRouterImpl` nor `FastPathWsRouterImpl` retains connection state in a `ThreadLocal`.
|
|
|
|
## 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 to `Http1Connection` or `Http2Connection`. 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 shutdown sends the two-stage `GOAWAY` sequence from RFC 9113 §6.8 before the lifecycle's
|
|
drain deadline force-closes remaining sockets.
|
|
|
|
## 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.
|