feat(core): HTTP/2 Phase 2 — transport decomposition

Breaks HttpServer (563 lines, eleven responsibilities) into named,
single-purpose components and introduces the ConnectionProtocol seam
HTTP/2 plugs into starting Phase 8, per
flash/docs/http2/IMPLEMENTATION-PLAN.md Phase 2.

New packages:
- dev.relism.flash.transport: TransportFactory (composition root, EX-34),
  ListenerBinder, BoundListener, TransportTuning, AcceptLoop,
  ConnectionRunner (per-connection setup/teardown), ConnectionProtocol
  (the h1/h2 seam), ConnectionContext, ConnectionScratch + ScratchPool
  (EX-06), ServerLifecycle (implements ServerHandle; start/stop/graceful
  shutdown, EX-32).
- dev.relism.flash.http1: Http1Connection (the keep-alive request loop,
  implements ConnectionProtocol), Http1ResponseWriter, Http1KeepAlive
  (the shared Connection-header token-list scanner, EX-13).
- dev.relism.flash.websocket additions: WebSocketUpgrade (detection +
  handshake), WebSocketLoop (session loop), WebSocketProtocolException.

Existing-code defects fixed (EX-nn):
- EX-01: WebSocketSession's two blocking-write sites use ReentrantLock
  instead of synchronized (out) -- a virtual thread blocking inside
  synchronized pins its carrier platform thread on Java 21.
- EX-06: HttpServer's three ThreadLocals (SHA1, LONG_BUF,
  STREAM_RELAY_BUFFER) replaced by ConnectionScratch, pooled via
  ScratchPool instead of one-per-virtual-thread (i.e. one-per-connection)
  growth. The router's ThreadLocals are deliberately deferred to Phase 4
  per this EX item's own phasing -- see DEC-15 for the plan-wording fix.
- EX-11: WebSocketSession.readFrame's extended-length and mask-key bytes
  are now read in a single bounded readFully instead of one at a time.
- EX-12: full RFC 6455 frame validation -- continuation-frame
  reassembly, mandatory masking-direction enforcement, opcode
  validation, control-frame constraints (not fragmented, <=125 bytes),
  and WebSocketProtocolException carrying the correct close code (1002
  protocol error, 1009 message too big).
- EX-13: Connection header token-list scanning shared between the
  keep-alive decision and the WebSocket upgrade check.
- EX-14: HEAD responses report Content-Length but write no body.
- EX-15: Content-Type omitted when empty; Content-Length and the body
  omitted entirely for 204/304/1xx responses.
- EX-16: Date header (dev.relism.flash.http.DateHeader), refreshed once
  per second by a shared daemon thread; FlashConfiguration.sendDate.
- EX-32: two-stage graceful shutdown -- stop accepting, force
  Connection: close on the response an in-flight handler is still
  producing (re-checked after the handler runs, not just before
  dispatch, so a shutdown beginning mid-handler is still honoured),
  drain up to shutdownDrainTimeoutMs, then force-close.
- EX-34: ServerHandle.create delegates to TransportFactory instead of
  constructing HttpServer directly.

Two plan corrections recorded: DEC-15 (Phase 2's "no ThreadLocal
anywhere" DoD line contradicted EX-06's own multi-phase assignment --
corrected to match the registry) and DEC-16 (no separate
WebSocketFrameCodec class this phase; the EX-11/EX-12 fixes stay inside
WebSocketSession, which is one cohesive state machine under R6's own
carve-out -- revisit at Phase 15 if RFC 8441 needs the decoupling for
real).

HttpServer.java deleted.

311/311 tests green (flash module), run three times for stability of
the wall-clock-based timeout/shutdown tests. Whole-repo build green.
h1 benchmark regression check remains unverified in the plan's DoD (no
JMH harness until Phase 3, same caveat as Phase 1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Zakaria El Orche
2026-08-13 12:03:44 +00:00
co-authored by Claude Sonnet 5
parent 5a2aaf5a07
commit a315e1df8b
35 changed files with 2332 additions and 757 deletions
@@ -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}&lt;{@link MessageDigest}&gt; — 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
* were never going to arrive there — they are already here.
*/
boolean hasBufferedBytes() {
public boolean hasBufferedBytes() {
return bufLen > 0;
}
@@ -4,6 +4,7 @@ import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.transport.TransportFactory;
import java.io.IOException;
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}
* 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 {
@@ -30,6 +32,6 @@ public interface ServerHandle {
static ServerHandle create(FlashConfiguration config,
AbstractRouter httpRouter,
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
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. */
public record Listener(int port, String host, TlsConfig tls) {
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.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.locks.ReentrantLock;
/**
* Per-connection WebSocket I/O state. One instance per virtual thread.
*
* <ul>
* <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
* the send buffer, without waiting. {@link java.io.BufferedOutputStream} will
* 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
* handshake (one-time, not on the hot path) and after the CLOSE frame
* (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
* because the CLOSE frame is the last thing written before the stream is
@@ -34,10 +35,23 @@ import java.util.concurrent.atomic.AtomicBoolean;
* </ul>
*
* <h3>Thread safety</h3>
* {@link #sendText}, {@link #send}, and {@link #close} are synchronized on
* {@code out} and safe to call from threads other than the session loop.
* {@link #close} uses a CAS on {@code open} to guarantee exactly-once CLOSE
* {@link #sendText}, {@link #send}, and {@link #close} are serialized on a
* {@link ReentrantLock} (never {@code synchronized} — see {@code EX-01}: a virtual thread
* 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.
*
* <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 {
@@ -47,12 +61,27 @@ public final class WebSocketSession {
private final Request request;
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 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). */
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) {
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
* requires client-to-server frames to be masked, unlike the server-to-client
* 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) {
this.in = in;
this.out = out;
this.readBuf = new byte[bufferSize];
this.request = request;
this.maskOutgoing = maskOutgoing;
this.in = in;
this.out = out;
this.readBuf = new byte[bufferSize];
this.request = request;
this.maskOutgoing = maskOutgoing;
this.requireMaskedIncoming = !maskOutgoing;
}
public boolean isOpen() { return open.get(); }
@@ -109,50 +141,128 @@ public final class WebSocketSession {
*/
public void close(int code) throws IOException {
if (!open.compareAndSet(true, false)) return;
synchronized (out) {
writeLock.lock();
try {
out.write(0x88);
out.write(0x02);
out.write((code >> 8) & 0xFF);
out.write(code & 0xFF);
} finally {
writeLock.unlock();
}
}
// ── 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 {
int b0 = in.read();
if (b0 < 0) return false;
int b1 = in.read();
if (b1 < 0) return false;
while (true) {
int b0 = in.read();
if (b0 < 0) return false; // clean EOF between messages
int b1 = in.read();
if (b1 < 0) throw new EOFException("WebSocket stream closed mid-frame");
boolean fin = (b0 & 0x80) != 0;
byte opcode = (byte) (b0 & 0x0F);
boolean masked = (b1 & 0x80) != 0;
long payLen = (b1 & 0x7F);
boolean fin = (b0 & 0x80) != 0;
byte opcode = (byte) (b0 & 0x0F);
boolean masked = (b1 & 0x80) != 0;
int lenBits = b1 & 0x7F;
if (payLen == 126) {
payLen = ((in.read() & 0xFF) << 8) | (in.read() & 0xFF);
} else if (payLen == 127) {
payLen = 0;
for (int i = 0; i < 8; i++) payLen = (payLen << 8) | (in.read() & 0xFF);
validateOpcode(opcode);
if (masked != requireMaskedIncoming) {
throw new WebSocketProtocolException(1002,
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(
"WS frame payload " + payLen + " bytes exceeds buffer " + readBuf.length);
byte m0 = 0, m1 = 0, m2 = 0, m3 = 0;
if (masked) {
m0 = (byte) in.read(); m1 = (byte) in.read();
m2 = (byte) in.read(); m3 = (byte) in.read();
private static void validateOpcode(byte opcode) throws WebSocketProtocolException {
switch (opcode) {
case WebSocketFrame.OP_CONTINUATION, WebSocketFrame.OP_TEXT, WebSocketFrame.OP_BINARY,
WebSocketFrame.OP_CLOSE, WebSocketFrame.OP_PING, WebSocketFrame.OP_PONG -> { /* valid */ }
default -> throw new WebSocketProtocolException(1002, "reserved/invalid opcode " + opcode);
}
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 {
@@ -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
* raw socket stream.
*
* <p>No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}
* (see {@code HttpServer#process}). Each {@code write()} lands directly in the
* kernel send buffer. With {@code TCP_NODELAY} set on the socket, the kernel
* transmits the segment immediately without Nagle coalescing. The two writes
* (header then payload) will be merged into a single TCP segment by the kernel
* because they arrive faster than the ACK from the peer — exactly the coalescing
* we want, at zero cost.
* <p>No {@code flush()} — {@code out} is the unbuffered socket {@link OutputStream}.
* Each {@code write()} lands directly in the kernel send buffer. With {@code TCP_NODELAY}
* set on the socket, the kernel transmits the segment immediately without Nagle coalescing.
* The two writes (header then payload) will be merged into a single TCP segment by the
* kernel because they arrive faster than the ACK from the peer — exactly the coalescing we
* want, at zero cost.
*
* <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
@@ -204,7 +313,8 @@ public final class WebSocketSession {
* 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 {
synchronized (out) {
writeLock.lock();
try {
int hlen = 0;
hdrScratch[hlen++] = (byte) (0x80 | opcode);
int maskBit = maskOutgoing ? 0x80 : 0x00;
@@ -237,6 +347,19 @@ public final class WebSocketSession {
out.write(hdrScratch, 0, hlen);
out.write(payload, off, len);
// 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;
}
}
@@ -265,4 +388,4 @@ public final class WebSocketSession {
if (i < end) { buf[i++] ^= m1; }
if (i < end) { buf[i] ^= m2; }
}
}
}
@@ -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();
}
}