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
@@ -15,7 +15,7 @@ import static org.junit.jupiter.api.Assertions.*;
/**
* One test per rejection rule added in HTTP/2 plan Phase 1 (EX-02, EX-03, EX-08, EX-18, EX-35,
* EX-36), each asserting the specific status code {@link MalformedRequestException} carries —
* not merely that some exception was thrown. {@code HttpServer} always closes the connection
* not merely that some exception was thrown. {@code Http1Connection}/{@code ConnectionRunner} always closes the connection
* after any of these (never keep-alive); that behaviour is exercised at the integration level
* by {@code HttpServerTest}.
*/
@@ -0,0 +1,62 @@
package dev.relism.flash.architecture;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.stream.Stream;
import static org.junit.jupiter.api.Assertions.fail;
/**
* {@code R1}/{@code DEC-02}: HTTP/1.1 and HTTP/2 are peers behind the {@code ConnectionProtocol}
* seam, never coupled to each other directly. A lightweight source-scan rather than ArchUnit —
* this project has no bytecode-analysis test dependency yet, and one import-statement check per
* package pair does not need one; record the choice here rather than in {@code DECISIONS.md}
* since it is this test's own implementation detail, not a design decision affecting shipped
* code.
*/
class PackageBoundaryTest {
@Test
void http1DoesNotImportH2() throws IOException {
assertNoImportOfPackage("dev/relism/flash/http1", "dev.relism.flash.h2");
}
@Test
void h2DoesNotImportHttp1() throws IOException {
assertNoImportOfPackage("dev/relism/flash/h2", "dev.relism.flash.http1");
}
private static void assertNoImportOfPackage(String sourceDirRelative, String forbiddenImportPrefix) throws IOException {
Path root = findSourceRoot(sourceDirRelative);
// Neither package boundary can be meaningfully checked before both packages exist; once
// dev.relism.flash.h2 gains real classes (Phase 3+) this stops being a no-op for the
// h2-side test.
if (root == null) return;
try (Stream<Path> files = Files.walk(root)) {
List<Path> javaFiles = files.filter(p -> p.toString().endsWith(".java")).toList();
for (Path file : javaFiles) {
for (String line : Files.readAllLines(file)) {
String trimmed = line.strip();
if (trimmed.startsWith("import " + forbiddenImportPrefix + ".")
|| trimmed.startsWith("import " + forbiddenImportPrefix + ";")) {
fail(file + " imports " + forbiddenImportPrefix
+ " — violates the h1/h2 package boundary (R1/DEC-02): " + trimmed);
}
}
}
}
}
private static Path findSourceRoot(String packageRelativePath) {
for (String base : List.of("flash/src/main/java", "src/main/java")) {
Path candidate = Path.of(base).resolve(packageRelativePath);
if (Files.isDirectory(candidate)) return candidate;
}
return null;
}
}
@@ -0,0 +1,136 @@
package dev.relism.flash.http1;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Response;
import dev.relism.flash.transport.ConnectionScratch;
import dev.relism.flash.transport.ScratchPool;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
class Http1ResponseWriterTest {
private static ConnectionScratch scratch() {
return new ScratchPool().acquire();
}
private static String write(Response response, HttpMethod method, boolean keepAlive, boolean sendDate) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
Http1ResponseWriter.writeResponse(out, response, method, keepAlive, sendDate, scratch());
return out.toString(StandardCharsets.UTF_8);
}
// --- EX-14: HEAD ------------------------------------------------------------
@Test
void head_reportsContentLengthButWritesNoBody() throws IOException {
Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.HEAD, true, false);
assertTrue(raw.contains("Content-Length: 11\r\n"), raw);
assertFalse(raw.contains("hello world"), raw);
}
@Test
void get_writesTheBody_forComparison() throws IOException {
Response response = new Response(200, "hello world", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, false);
assertTrue(raw.contains("Content-Length: 11\r\n"), raw);
assertTrue(raw.endsWith("hello world"), raw);
}
// --- EX-15: 204 / 304 / 1xx never carry Content-Length or a body ------------
@Test
void status204_omitsContentLengthAndBody() throws IOException {
Response response = new Response(204, ContentType.NONE);
response.setBody("should never appear");
String raw = write(response, HttpMethod.GET, true, false);
assertFalse(raw.contains("Content-Length"), raw);
assertFalse(raw.contains("should never appear"), raw);
}
@Test
void status304_omitsContentLengthAndBody() throws IOException {
Response response = new Response(304, ContentType.NONE);
response.setBody("should never appear");
String raw = write(response, HttpMethod.GET, true, false);
assertFalse(raw.contains("Content-Length"), raw);
assertFalse(raw.contains("should never appear"), raw);
}
@Test
void status1xx_omitsContentLengthAndBody() throws IOException {
Response response = new Response(103, ContentType.NONE);
response.setBody("should never appear");
String raw = write(response, HttpMethod.GET, true, false);
assertFalse(raw.contains("Content-Length"), raw);
assertFalse(raw.contains("should never appear"), raw);
}
@Test
void status200_stillCarriesContentLength_forComparison() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, false);
assertTrue(raw.contains("Content-Length: 1\r\n"), raw);
}
// --- EX-15: ContentType.NONE omits the Content-Type line entirely -----------
@Test
void contentTypeNone_omitsContentTypeLine() throws IOException {
Response response = new Response(200, ContentType.NONE);
String raw = write(response, HttpMethod.GET, true, false);
assertFalse(raw.contains("Content-Type"), raw);
}
@Test
void contentTypeTextPlain_includesContentTypeLine() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, false);
assertTrue(raw.contains("Content-Type: text/plain\r\n"), raw);
}
// --- EX-16: Date header -------------------------------------------------------
@Test
void sendDateTrue_includesDateHeader() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, true);
assertTrue(raw.contains("Date: "), raw);
// RFC 9110 IMF-fixdate, e.g. "Date: Tue, 03 Jun 2008 11:05:30 GMT\r\n"
assertTrue(raw.matches("(?s).*Date: [A-Za-z]{3}, \\d{2} [A-Za-z]{3} \\d{4} \\d{2}:\\d{2}:\\d{2} GMT\\r\\n.*"), raw);
}
@Test
void sendDateFalse_omitsDateHeader() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, false);
assertFalse(raw.contains("Date: "), raw);
}
// --- Connection header --------------------------------------------------------
@Test
void keepAlive_writesKeepAliveConnectionHeader() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, true, false);
assertTrue(raw.contains("Connection: keep-alive\r\n"), raw);
}
@Test
void notKeepAlive_writesCloseConnectionHeader() throws IOException {
Response response = new Response(200, "x", ContentType.TEXT_PLAIN);
String raw = write(response, HttpMethod.GET, false, false);
assertTrue(raw.contains("Connection: close\r\n"), raw);
}
}
@@ -0,0 +1,71 @@
package dev.relism.flash.transport;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.routing.AbstractRouter;
import dev.relism.flash.routing.AbstractWsRouter;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathRouterImpl;
import dev.relism.flash.routing.routers.fastpathrouter.FastPathWsRouterImpl;
import org.junit.jupiter.api.Test;
import java.io.IOException;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
/**
* A scratch is always released — including on an exception path — and a socket is always
* removed from {@code activeSockets}, regardless of how the dispatched
* {@link ConnectionProtocol} exits. This is a resource-leak safety property (Phase 2's Safety
* checks list), verified here with a protocol implementation that deliberately throws.
*/
class ConnectionRunnerTest {
@Test
void scratchAndActiveSocketEntry_alwaysReleased_evenWhenTheProtocolThrows() throws Exception {
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
Set<Socket> activeSockets = ConcurrentHashMap.newKeySet();
ScratchPool scratchPool = new ScratchPool();
AbstractRouter router = new FastPathRouterImpl();
AbstractWsRouter wsRouter = new FastPathWsRouterImpl();
FlashConfiguration configuration = FlashConfiguration.builder().port(0).build();
ConnectionProtocol throwingProtocol = ctx -> {
throw new IOException("simulated protocol failure");
};
ConnectionRunner runner = new ConnectionRunner(
executor, activeSockets, scratchPool, router, wsRouter, configuration, throwingProtocol);
try (ServerSocket serverSocket = new ServerSocket(0)) {
int port = serverSocket.getLocalPort();
CountDownLatch accepted = new CountDownLatch(1);
Thread acceptThread = new Thread(() -> {
try (Socket serverSide = serverSocket.accept()) {
runner.accept(serverSide, () -> false);
accepted.countDown();
Thread.sleep(300); // give the submitted virtual-thread task time to run
} catch (Exception ignored) {
}
});
acceptThread.start();
try (Socket client = new Socket("127.0.0.1", port)) {
assertTrue(accepted.await(2, TimeUnit.SECONDS));
Thread.sleep(300); // let ConnectionRunner's virtual thread finish
assertTrue(activeSockets.isEmpty(), "socket must be removed from activeSockets on every exit path");
}
acceptThread.join(2000);
} finally {
executor.shutdownNow();
}
}
}
@@ -22,7 +22,7 @@ import static org.junit.jupiter.api.Assertions.*;
/**
* {@link ProtocolNegotiator#negotiate} is a pure, directly-testable detector (see its Javadoc
* for why it does not itself consult {@code FlashConfiguration.http2Enabled}) — every case here
* calls it directly rather than through {@code HttpServer}.
* calls it directly rather than through {@code Http1Connection}/{@code ConnectionRunner}.
*/
class ProtocolNegotiatorTest {
@@ -84,7 +84,7 @@ class ProtocolNegotiatorTest {
/**
* Binds a real TLS listener offering {@code serverAlpn}, connects a client offering
* {@code clientAlpn}, forces the handshake on both sides (mirroring {@code HttpServer}'s
* {@code clientAlpn}, forces the handshake on both sides (mirroring {@code Http1Connection}/{@code ConnectionRunner}'s
* EX-30 fix), and hands the accepted server-side socket to {@code assertion}.
*/
private static void withNegotiatedAlpn(Path dir, String[] serverAlpn, String[] clientAlpn,
@@ -0,0 +1,72 @@
package dev.relism.flash.transport;
import org.junit.jupiter.api.Test;
import java.util.HashSet;
import java.util.Set;
import static org.junit.jupiter.api.Assertions.*;
class ScratchPoolTest {
@Test
void acquire_withEmptyPool_returnsFreshInstance() {
ScratchPool pool = new ScratchPool();
ConnectionScratch scratch = pool.acquire();
assertNotNull(scratch);
assertNotNull(scratch.sha1);
assertEquals(ConnectionScratch.DECIMAL_BUFFER_SIZE, scratch.decimalBuffer.length);
assertEquals(ConnectionScratch.RELAY_BUFFER_SIZE, scratch.relayBuffer.length);
}
@Test
void release_thenAcquire_reusesTheSameInstance() {
ScratchPool pool = new ScratchPool();
ConnectionScratch first = pool.acquire();
pool.release(first);
ConnectionScratch second = pool.acquire();
assertSame(first, second);
}
@Test
void bound_isRespected_excessReleasesAreDropped() {
ScratchPool pool = new ScratchPool(2);
ConnectionScratch a = pool.acquire();
ConnectionScratch b = pool.acquire();
ConnectionScratch c = pool.acquire();
pool.release(a);
pool.release(b);
pool.release(c); // pool already has 2 -- this one is dropped, not queued
Set<ConnectionScratch> reacquired = new HashSet<>();
reacquired.add(pool.acquire());
reacquired.add(pool.acquire());
ConnectionScratch third = pool.acquire(); // freshly allocated, pool was exhausted at 2
assertFalse(reacquired.contains(third));
assertEquals(2, reacquired.size());
}
@Test
void reset_clearsTheMessageDigestState() {
// A dirty digest (mid-update, not yet digested) must not leak into the next connection
// that reuses this scratch -- the classic cross-connection-leak hazard for pooled state.
ScratchPool pool = new ScratchPool();
ConnectionScratch scratch = pool.acquire();
scratch.sha1.update((byte) 'x');
pool.release(scratch);
ConnectionScratch reused = pool.acquire();
assertSame(scratch, reused);
// If reset() had not run, digesting an empty input now would still reflect the earlier
// update. A byte array is not the actual assertion here (MessageDigest doesn't expose
// "reset happened") - the practical proof is that digest() with no further updates
// matches the well-known empty-input SHA-1 digest.
byte[] emptyDigest = reused.sha1.digest();
byte[] expected = {
(byte) 0xda, 0x39, (byte) 0xa3, (byte) 0xee, 0x5e, 0x6b, 0x4b, 0x0d,
0x32, 0x55, (byte) 0xbf, (byte) 0xef, (byte) 0x95, 0x60, 0x18, (byte) 0x90,
(byte) 0xaf, (byte) 0xd8, 0x07, 0x09
};
assertArrayEquals(expected, emptyDigest);
}
}
@@ -0,0 +1,116 @@
package dev.relism.flash.transport;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-32}: the two-stage graceful shutdown — stop accepting, let an in-flight request
* finish (forced to {@code Connection: close}), then force-close whatever remains after
* {@code shutdownDrainTimeoutMs}.
*/
class ServerLifecycleGracefulShutdownTest {
private FlashApp app;
@AfterEach
void tearDown() {
if (app != null) app.stop();
}
private static int freePort() throws Exception {
try (ServerSocket s = new ServerSocket(0)) {
return s.getLocalPort();
}
}
@Test
void inFlightRequest_completesWithConnectionClose_duringShutdown() throws Exception {
int port = freePort();
CountDownLatch handlerStarted = new CountDownLatch(1);
CountDownLatch releaseHandler = new CountDownLatch(1);
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.shutdownDrainTimeoutMs(5_000)
.build());
app.get("/slow", (req, res) -> {
handlerStarted.countDown();
assertTrue(releaseHandler.await(5, TimeUnit.SECONDS));
return "done";
});
app.start();
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(5_000);
OutputStream out = socket.getOutputStream();
out.write("GET /slow HTTP/1.1\r\nHost: localhost\r\n\r\n".getBytes(StandardCharsets.UTF_8));
out.flush();
assertTrue(handlerStarted.await(2, TimeUnit.SECONDS));
// Begin shutdown while the handler is still running.
CompletableFuture<Void> stopping = app.stop();
// Give stop() a moment to mark the server as stopping and close the listener.
Thread.sleep(100);
releaseHandler.countDown();
byte[] buf = new byte[4096];
int n = socket.getInputStream().read(buf);
String response = new String(buf, 0, n, StandardCharsets.UTF_8);
assertTrue(response.startsWith("HTTP/1.1 200 OK"), response);
assertTrue(response.contains("done"), response);
// EX-32: the in-flight request is forced to close rather than keep-alive, even
// though the client asked for HTTP/1.1's default keep-alive.
assertTrue(response.contains("Connection: close"), response);
stopping.get(5, TimeUnit.SECONDS);
}
}
@Test
void stop_closesListener_soNewConnectionsAreRefused() throws Exception {
int port = freePort();
app = FlashApp.create(FlashConfiguration.builder()
.port(port).host("127.0.0.1")
.shutdownDrainTimeoutMs(500)
.build());
app.get("/ping", (req, res) -> "pong");
app.start();
// Confirm the server actually answers before stopping it.
try (Socket probe = new Socket("127.0.0.1", port)) {
probe.setSoTimeout(2_000);
probe.getOutputStream().write("GET /ping HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"
.getBytes(StandardCharsets.UTF_8));
probe.getOutputStream().flush();
assertTrue(probe.getInputStream().read() != -1);
}
app.stop().get(5, TimeUnit.SECONDS);
assertThrows(Exception.class, () -> {
try (Socket socket = new Socket()) {
socket.connect(new java.net.InetSocketAddress("127.0.0.1", port), 500);
socket.setSoTimeout(500);
socket.getOutputStream().write("GET /ping HTTP/1.1\r\nHost: localhost\r\n\r\n"
.getBytes(StandardCharsets.UTF_8));
socket.getOutputStream().flush();
int result = socket.getInputStream().read();
if (result == -1) throw new java.io.IOException("connection refused/closed, as expected");
}
});
}
}
@@ -0,0 +1,220 @@
package dev.relism.flash.websocket;
import org.junit.jupiter.api.Test;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@code EX-11} (bulk header read) and {@code EX-12} (continuation reassembly, mandatory
* masking, opcode validation, control-frame constraints, correct close codes) coverage for
* {@link WebSocketSession#readFrame}.
*/
class WebSocketFragmentationAndValidationTest {
private static final byte[] MASK = {1, 2, 3, 4};
private static byte[] frame(int opcode, boolean fin, boolean masked, byte[] payload) throws IOException {
ByteArrayOutputStream buf = new ByteArrayOutputStream();
buf.write((fin ? 0x80 : 0) | opcode);
int len = payload.length;
int maskBit = masked ? 0x80 : 0x00;
if (len <= 125) {
buf.write(maskBit | len);
} else {
buf.write(maskBit | 126);
buf.write((len >> 8) & 0xFF);
buf.write(len & 0xFF);
}
byte[] out = payload;
if (masked) {
buf.write(MASK);
out = payload.clone();
for (int i = 0; i < out.length; i++) out[i] ^= MASK[i % 4];
}
buf.write(out);
return buf.toByteArray();
}
private static byte[] concat(byte[]... arrays) throws IOException {
ByteArrayOutputStream out = new ByteArrayOutputStream();
for (byte[] a : arrays) out.write(a);
return out.toByteArray();
}
/** Server-mode session (masked incoming required) over the given raw bytes. */
private static WebSocketSession serverSession(byte[] raw, int bufferSize) {
return new WebSocketSession(new ByteArrayInputStream(raw), new ByteArrayOutputStream(), bufferSize);
}
// --- EX-12: continuation reassembly ------------------------------------------
@Test
void continuationFrames_reassembleIntoOneMessage() throws IOException {
byte[] raw = concat(
frame(WebSocketFrame.OP_TEXT, false, true, "hel".getBytes(StandardCharsets.UTF_8)),
frame(WebSocketFrame.OP_CONTINUATION, false, true, "lo ".getBytes(StandardCharsets.UTF_8)),
frame(WebSocketFrame.OP_CONTINUATION, true, true, "world".getBytes(StandardCharsets.UTF_8)));
WebSocketSession session = serverSession(raw, 64);
WebSocketFrame frame = new WebSocketFrame();
assertTrue(session.readFrame(frame));
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
assertTrue(frame.isFin());
assertEquals("hello world", new String(frame.copyPayload(), StandardCharsets.UTF_8));
}
@Test
void controlFrame_interleavedDuringFragmentation_deliveredWithoutDisturbingReassembly() throws IOException {
byte[] raw = concat(
frame(WebSocketFrame.OP_TEXT, false, true, "AB".getBytes(StandardCharsets.UTF_8)),
frame(WebSocketFrame.OP_PING, true, true, "ping".getBytes(StandardCharsets.UTF_8)),
frame(WebSocketFrame.OP_CONTINUATION, true, true, "CD".getBytes(StandardCharsets.UTF_8)));
WebSocketSession session = serverSession(raw, 64);
WebSocketFrame frame = new WebSocketFrame();
assertTrue(session.readFrame(frame));
assertEquals(WebSocketFrame.OP_PING, frame.opcode());
assertEquals("ping", new String(frame.copyPayload(), StandardCharsets.UTF_8));
assertTrue(session.readFrame(frame));
assertEquals(WebSocketFrame.OP_TEXT, frame.opcode());
assertEquals("ABCD", new String(frame.copyPayload(), StandardCharsets.UTF_8));
}
@Test
void continuationWithoutInitiatedMessage_rejected1002() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_CONTINUATION, true, true, "x".getBytes(StandardCharsets.UTF_8));
WebSocketSession session = serverSession(raw, 64);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void newDataFrameWhileFragmenting_rejected1002() throws IOException {
byte[] raw = concat(
frame(WebSocketFrame.OP_TEXT, false, true, "a".getBytes(StandardCharsets.UTF_8)),
frame(WebSocketFrame.OP_TEXT, true, true, "b".getBytes(StandardCharsets.UTF_8)));
WebSocketSession session = serverSession(raw, 64);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void reassembledMessageExceedingBuffer_rejected1009() throws IOException {
byte[] raw = concat(
frame(WebSocketFrame.OP_TEXT, false, true, new byte[5]),
frame(WebSocketFrame.OP_CONTINUATION, true, true, new byte[5]));
WebSocketSession session = serverSession(raw, 8); // 5 + 5 = 10 > 8
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1009, e.closeCode());
}
// --- EX-12: mandatory masking direction ---------------------------------------
@Test
void serverSession_unmaskedIncomingFrame_rejected1002() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_TEXT, true, false, "hi".getBytes(StandardCharsets.UTF_8));
WebSocketSession session = serverSession(raw, 64);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void clientSession_maskedIncomingFrame_rejected1002() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_TEXT, true, true, "hi".getBytes(StandardCharsets.UTF_8));
WebSocketSession session = new WebSocketSession(
new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 64, null, true);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void clientSession_unmaskedIncomingFrame_accepted() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_TEXT, true, false, "hi".getBytes(StandardCharsets.UTF_8));
WebSocketSession session = new WebSocketSession(
new ByteArrayInputStream(raw), new ByteArrayOutputStream(), 64, null, true);
WebSocketFrame frame = new WebSocketFrame();
assertTrue(session.readFrame(frame));
assertEquals("hi", new String(frame.copyPayload(), StandardCharsets.UTF_8));
}
// --- EX-12: opcode validation --------------------------------------------------
@Test
void reservedOpcode_rejected1002() throws IOException {
byte[] raw = frame(0x3, true, true, new byte[0]); // 0x3 is reserved
WebSocketSession session = serverSession(raw, 64);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
// --- EX-12: control-frame constraints ------------------------------------------
@Test
void fragmentedControlFrame_rejected1002() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_PING, false, true, "x".getBytes(StandardCharsets.UTF_8));
WebSocketSession session = serverSession(raw, 64);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void oversizedControlFramePayload_rejected1002() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_PING, true, true, new byte[126]);
WebSocketSession session = serverSession(raw, 200);
WebSocketProtocolException e = assertThrows(WebSocketProtocolException.class,
() -> session.readFrame(new WebSocketFrame()));
assertEquals(1002, e.closeCode());
}
@Test
void controlFrameAtTheMaxAllowedSize_accepted() throws IOException {
byte[] raw = frame(WebSocketFrame.OP_PING, true, true, new byte[125]);
WebSocketSession session = serverSession(raw, 200);
WebSocketFrame frame = new WebSocketFrame();
assertTrue(session.readFrame(frame));
assertEquals(125, frame.payloadLength());
}
// --- EX-11: bulk header read, not one syscall per byte -------------------------
private static final class CountingInputStream extends InputStream {
private final InputStream delegate;
int reads = 0;
CountingInputStream(InputStream delegate) { this.delegate = delegate; }
@Override public int read() throws IOException { reads++; return delegate.read(); }
@Override public int read(byte[] b, int off, int len) throws IOException { reads++; return delegate.read(b, off, len); }
}
@Test
void readFrame_withExtendedLengthAndMask_doesNotReadOneByteAtATime() throws IOException {
// 200-byte payload forces the 16-bit extended length; masked, so 4 mask bytes too.
// Pre-fix: 1 (b0) + 1 (b1) + 2 (extended length, read one at a time originally via two
// separate in.read() calls — already bulk-free in that part) + 4 (mask, one at a time)
// = several individual reads for the header alone, on top of one per payload byte if
// the underlying stream were unbuffered. Post-fix: the header's variable remainder
// (length + mask) is exactly one readFully call.
byte[] raw = frame(WebSocketFrame.OP_BINARY, true, true, new byte[200]);
CountingInputStream counting = new CountingInputStream(new ByteArrayInputStream(raw));
WebSocketSession session = new WebSocketSession(counting, new ByteArrayOutputStream(), 256);
assertTrue(session.readFrame(new WebSocketFrame()));
// b0, b1, one bulk read for (2 extended-length + 4 mask) bytes, one bulk read for the
// 200-byte payload: 4 total, independent of the payload size.
assertTrue(counting.reads <= 4, "expected at most 4 underlying reads, was " + counting.reads);
}
}