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:
co-authored by
Claude Sonnet 5
parent
5a2aaf5a07
commit
a315e1df8b
@@ -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);
|
||||
}
|
||||
}
|
||||
+116
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+220
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user