feat(core): add HTTP/2 connection state machine

This commit is contained in:
Zakaria El Orche
2026-08-13 17:49:20 +00:00
parent 95c33e7bf2
commit cfa192e689
28 changed files with 3469 additions and 1410 deletions
@@ -0,0 +1,169 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.Http2FrameWriter;
import dev.relism.flash.transport.BufferedByteSource;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.SocketTimeoutException;
import java.util.List;
import org.junit.jupiter.api.Test;
class Http2ConnectionHandshakeTest {
@Test
void exactPrefaceExchangesSettingsAndAcknowledgesPeerSettings() throws Exception {
byte[] input =
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(Http2Settings.MAX_FRAME_SIZE, 32_768));
RunResult result = run(input);
List<Http2TestFrames.WireFrame> frames = Http2TestFrames.parse(result.output());
assertEquals(3, frames.size());
assertEquals(FrameType.SETTINGS.code(), frames.get(0).type());
assertEquals(0, frames.get(0).flags());
assertEquals(FrameType.WINDOW_UPDATE.code(), frames.get(1).type());
assertEquals(FrameType.SETTINGS.code(), frames.get(2).type());
assertEquals(FrameFlags.ACK, frames.get(2).flags());
assertEquals(0, frames.get(2).payload().length);
assertEquals(32_768, result.connection().peerSettings().maxFrameSize());
}
@Test
void mismatchedOrTruncatedPrefaceClosesWithoutSendingGoAway() throws Exception {
byte[] mismatched = Http2TestFrames.PREFACE.clone();
mismatched[10] ^= 1;
assertEquals(0, run(mismatched).output().length);
assertEquals(0, run(java.util.Arrays.copyOf(Http2TestFrames.PREFACE, 12)).output().length);
}
@Test
void firstPeerFrameMustBeSettings() throws Exception {
byte[] ping = Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]);
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(run(Http2TestFrames.concat(Http2TestFrames.PREFACE, ping)).output());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(FrameType.GOAWAY.code(), goAway.type());
assertEquals(
Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
@Test
void settingsAcknowledgementCannotReplaceInitialPeerSettings() throws Exception {
byte[] ack = Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]);
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(run(Http2TestFrames.concat(Http2TestFrames.PREFACE, ack)).output());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(
Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
@Test
void invalidHpackBlockProducesCompressionError() throws Exception {
byte[] headers =
Http2TestFrames.frame(
FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[] {(byte) 0x80});
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(
run(Http2TestFrames.concat(
Http2TestFrames.PREFACE, Http2TestFrames.settings(), headers))
.output());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(
Http2ErrorCode.COMPRESSION_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
@Test
void frameInterleavingDuringContinuationSequenceIsProtocolError() throws Exception {
byte[] incompleteHeaders =
Http2TestFrames.frame(FrameType.HEADERS, 0, 1, new byte[] {(byte) 0x82});
byte[] ping = Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8]);
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(
run(Http2TestFrames.concat(
Http2TestFrames.PREFACE, Http2TestFrames.settings(), incompleteHeaders, ping))
.output());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(
Http2ErrorCode.PROTOCOL_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
@Test
void settingsAckWithPayloadIsFrameSizeError() throws Exception {
byte[] badAck = Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[6]);
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(
run(Http2TestFrames.concat(Http2TestFrames.PREFACE, badAck)).output());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(
Http2ErrorCode.FRAME_SIZE_ERROR.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
@Test
void missingSettingsAcknowledgementTimesOutWithDedicatedErrorCode() throws Exception {
byte[] initial = Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings());
InputStream stallsAfterInput =
new InputStream() {
private final ByteArrayInputStream delegate = new ByteArrayInputStream(initial);
@Override
public int read() throws java.io.IOException {
byte[] one = new byte[1];
int n = read(one, 0, 1);
return n < 0 ? -1 : one[0] & 0xff;
}
@Override
public int read(byte[] target, int off, int len) throws java.io.IOException {
if (delegate.available() > 0) return delegate.read(target, off, len);
try {
Thread.sleep(15);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new java.io.IOException(e);
}
throw new SocketTimeoutException("simulated idle peer");
}
};
Http2Connection connection = new Http2Connection(delta -> {}, 5);
ByteArrayOutputStream output = new ByteArrayOutputStream();
Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000);
try {
connection.run(new BufferedByteSource(stallsAfterInput, null), writer, () -> false);
} finally {
writer.close();
}
List<Http2TestFrames.WireFrame> frames = Http2TestFrames.parse(output.toByteArray());
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(
Http2ErrorCode.SETTINGS_TIMEOUT.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
static RunResult run(byte[] input) throws Exception {
Http2Connection connection = new Http2Connection();
ByteArrayOutputStream output = new ByteArrayOutputStream();
Http2FrameWriter writer = new Http2FrameWriter(output::write, 5_000);
try {
connection.run(
new BufferedByteSource(new ByteArrayInputStream(input), null), writer, () -> false);
writer.drain();
} finally {
writer.close();
}
return new RunResult(connection, output.toByteArray());
}
record RunResult(Http2Connection connection, byte[] output) {}
}
@@ -0,0 +1,247 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.extension.FlashApp;
import dev.relism.flash.extension.FlashConfiguration;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.tls.TestKeystores;
import dev.relism.flash.tls.TlsConfig;
import java.io.EOFException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class Http2ConnectionIntegrationTest {
private FlashApp app;
@AfterEach
void stopApp() {
if (app != null) app.stop().join();
}
@Test
void h2cTransportDispatchesControlFramesWithoutRunningApplicationWork() throws Exception {
int port = freePort();
AtomicBoolean handlerEntered = new AtomicBoolean();
app =
FlashApp.create(
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
app.get(
"/",
(request, response) -> {
handlerEntered.set(true);
Thread.sleep(5_000);
return "late";
});
app.start();
byte[] clientPing = "client!!".getBytes(StandardCharsets.US_ASCII);
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(5_000);
socket
.getOutputStream()
.write(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0]),
Http2TestFrames.frame(FrameType.PING, 0, 0, clientPing)));
socket.getOutputStream().flush();
byte[] shutdownPing = null;
boolean sawClientPong = false;
for (int i = 0; i < 8 && !sawClientPong; i++) {
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
if (frame.type() == FrameType.PING.code()) {
if ((frame.flags() & FrameFlags.ACK) != 0 && Arrays.equals(clientPing, frame.payload())) {
sawClientPong = true;
} else if ((frame.flags() & FrameFlags.ACK) == 0) {
shutdownPing = frame.payload();
}
}
}
assertTrue(sawClientPong, "PING must be processed while a route exists");
assertFalse(handlerEntered.get(), "the connection demux must not execute handlers");
if (shutdownPing != null) {
socket
.getOutputStream()
.write(Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, shutdownPing));
socket.getOutputStream().flush();
}
}
}
@Test
void serverStopInitiatesTwoStageGoAwayOnIdleConnection() throws Exception {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.http2Enabled(true)
.shutdownDrainTimeoutMs(5_000)
.build());
app.start();
try (Socket socket = new Socket("127.0.0.1", port)) {
socket.setSoTimeout(5_000);
socket
.getOutputStream()
.write(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0])));
socket.getOutputStream().flush();
readFrame(socket.getInputStream()); // server SETTINGS
readFrame(socket.getInputStream()); // initial connection WINDOW_UPDATE
readFrame(socket.getInputStream()); // SETTINGS ACK
CompletableFuture<Void> stopped = app.stop();
app = null;
Http2TestFrames.WireFrame firstGoAway = readUntil(socket, FrameType.GOAWAY);
assertEquals(Integer.MAX_VALUE, Http2TestFrames.readInt(firstGoAway.payload(), 0));
Http2TestFrames.WireFrame ping = readUntil(socket, FrameType.PING);
socket
.getOutputStream()
.write(Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, ping.payload()));
socket.getOutputStream().flush();
Http2TestFrames.WireFrame finalGoAway = readUntil(socket, FrameType.GOAWAY);
assertEquals(0, Http2TestFrames.readInt(finalGoAway.payload(), 0));
stopped.get(5, TimeUnit.SECONDS);
}
}
@Test
void protocolStateDoesNotLeakAcrossConsecutiveConnections() throws Exception {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
app.start();
try (Socket first = new Socket("127.0.0.1", port)) {
first.setSoTimeout(5_000);
first
.getOutputStream()
.write(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.frame(FrameType.PING, 0, 0, new byte[8])));
first.getOutputStream().flush();
readUntil(first, FrameType.GOAWAY);
}
byte[] opaque = "isolated".getBytes(StandardCharsets.US_ASCII);
try (Socket second = new Socket("127.0.0.1", port)) {
second.setSoTimeout(5_000);
second
.getOutputStream()
.write(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.SETTINGS, FrameFlags.ACK, 0, new byte[0]),
Http2TestFrames.frame(FrameType.PING, 0, 0, opaque)));
second.getOutputStream().flush();
Http2TestFrames.WireFrame pong = null;
for (int i = 0; i < 6; i++) {
Http2TestFrames.WireFrame frame = readFrame(second.getInputStream());
if (frame.type() == FrameType.PING.code()
&& (frame.flags() & FrameFlags.ACK) != 0
&& Arrays.equals(opaque, frame.payload())) {
pong = frame;
break;
}
}
assertNotNull(pong, "a fresh connection must start with fresh SETTINGS/GOAWAY state");
}
}
@Test
void tlsListenerOffersAndNegotiatesH2WhenHttp2IsEnabled(@TempDir Path directory)
throws Exception {
int port = freePort();
Path keystore =
TestKeystores.build(
directory,
"http2.p12",
"changeit",
TestKeystores.Entry.of("server", "localhost", "localhost"));
app =
FlashApp.create(
FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.tls(TlsConfig.keystore(keystore, "changeit"))
.http2Enabled(true)
.build());
app.start();
try (SSLSocket socket =
(SSLSocket)
TestKeystores.trustAllClientContext()
.getSocketFactory()
.createSocket("127.0.0.1", port)) {
socket.setSoTimeout(5_000);
SSLParameters parameters = socket.getSSLParameters();
parameters.setApplicationProtocols(new String[] {"h2", "http/1.1"});
socket.setSSLParameters(parameters);
socket.startHandshake();
assertEquals("h2", socket.getApplicationProtocol());
socket
.getOutputStream()
.write(Http2TestFrames.concat(Http2TestFrames.PREFACE, Http2TestFrames.settings()));
socket.getOutputStream().flush();
assertEquals(FrameType.SETTINGS.code(), readFrame(socket.getInputStream()).type());
}
}
private static Http2TestFrames.WireFrame readUntil(Socket socket, FrameType type)
throws Exception {
for (int i = 0; i < 8; i++) {
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
if (frame.type() == type.code()) return frame;
}
fail("did not receive " + type);
throw new AssertionError();
}
private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception {
byte[] header = input.readNBytes(9);
if (header.length != 9) throw new EOFException("truncated frame header");
int length = ((header[0] & 0xff) << 16) | ((header[1] & 0xff) << 8) | (header[2] & 0xff);
byte[] payload = input.readNBytes(length);
if (payload.length != length) throw new EOFException("truncated frame payload");
return new Http2TestFrames.WireFrame(
header[3] & 0xff,
header[4] & 0xff,
Http2TestFrames.readInt(header, 5) & Integer.MAX_VALUE,
payload);
}
private static int freePort() throws Exception {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
}
}
}
@@ -0,0 +1,67 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import java.util.List;
import org.junit.jupiter.api.Test;
class Http2GoAwayTest {
private static final byte[] SHUTDOWN_PING = {
(byte) 0x46, (byte) 0x4c, (byte) 0x41, (byte) 0x53,
(byte) 0x48, (byte) 0x47, (byte) 0x4f, (byte) 0x21
};
@Test
void gracefulShutdownUsesTwoGoAwayStagesSeparatedByPingRoundTrip() throws Exception {
byte[] input =
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.HEADERS, FrameFlags.END_HEADERS, 1, new byte[0]),
Http2TestFrames.frame(FrameType.PING, FrameFlags.ACK, 0, SHUTDOWN_PING));
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(Http2ConnectionHandshakeTest.run(input).output());
List<Http2TestFrames.WireFrame> goAways =
frames.stream().filter(frame -> frame.type() == FrameType.GOAWAY.code()).toList();
assertEquals(2, goAways.size());
assertEquals(Integer.MAX_VALUE, Http2TestFrames.readInt(goAways.get(0).payload(), 0));
assertEquals(1, Http2TestFrames.readInt(goAways.get(1).payload(), 0));
assertEquals(
Http2ErrorCode.NO_ERROR.code(), Http2TestFrames.readInt(goAways.get(0).payload(), 4));
assertEquals(
Http2ErrorCode.NO_ERROR.code(), Http2TestFrames.readInt(goAways.get(1).payload(), 4));
int firstGoAway = indexOf(frames, FrameType.GOAWAY.code(), 0);
int ping = indexOf(frames, FrameType.PING.code(), firstGoAway + 1);
int secondGoAway = indexOf(frames, FrameType.GOAWAY.code(), firstGoAway + 1);
assertTrue(firstGoAway < ping && ping < secondGoAway);
assertArrayEquals(SHUTDOWN_PING, frames.get(ping).payload());
}
@Test
void receivedGoAwayRecordsPeerState() throws Exception {
byte[] payload = new byte[8];
payload[3] = 7;
payload[7] = (byte) Http2ErrorCode.ENHANCE_YOUR_CALM.code();
Http2ConnectionHandshakeTest.RunResult result =
Http2ConnectionHandshakeTest.run(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.GOAWAY, 0, 0, payload)));
assertEquals(7, result.connection().peerLastStreamId());
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM.code(), result.connection().peerErrorCode());
}
private static int indexOf(List<Http2TestFrames.WireFrame> frames, int type, int from) {
for (int i = from; i < frames.size(); i++) {
if (frames.get(i).type() == type) return i;
}
return -1;
}
}
@@ -0,0 +1,46 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.http2.Http2ConnectionScratch.ControlIntent;
import dev.relism.flash.http2.Http2ConnectionScratch.ControlKind;
import dev.relism.flash.http2.frame.FrameFlags;
import dev.relism.flash.http2.frame.FrameType;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import org.junit.jupiter.api.Test;
class Http2PingTest {
@Test
void pingResponseEchoesOpaqueBytesExactly() throws Exception {
byte[] opaque = "12345678".getBytes(StandardCharsets.US_ASCII);
byte[] input =
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.PING, 0, 0, opaque));
List<Http2TestFrames.WireFrame> frames =
Http2TestFrames.parse(Http2ConnectionHandshakeTest.run(input).output());
Http2TestFrames.WireFrame pong = frames.get(frames.size() - 1);
assertEquals(FrameType.PING.code(), pong.type());
assertEquals(FrameFlags.ACK, pong.flags());
assertArrayEquals(opaque, pong.payload());
}
@Test
void pingQueueIsStrictlyBounded() {
Http2ConnectionScratch scratch = new Http2ConnectionScratch();
List<ControlIntent> claimed = new ArrayList<>();
for (int i = 0; i < Http2Limits.MAX_PING_QUEUE_DEPTH; i++) {
claimed.add(scratch.acquire(ControlKind.PING));
}
Http2Exception error =
assertThrows(Http2Exception.class, () -> scratch.acquire(ControlKind.PING));
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, error.errorCode());
claimed.forEach(ControlIntent::completed);
}
}
@@ -0,0 +1,116 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.*;
import java.util.Arrays;
import org.junit.jupiter.api.Test;
class Http2SettingsTest {
@Test
void appliesEveryKnownSettingAndIgnoresUnknownIdentifiers() {
Http2Settings settings = new Http2Settings();
byte[] payload =
payload(
Http2Settings.HEADER_TABLE_SIZE,
8_192,
Http2Settings.ENABLE_PUSH,
0,
Http2Settings.MAX_CONCURRENT_STREAMS,
123,
Http2Settings.INITIAL_WINDOW_SIZE,
70_000,
Http2Settings.MAX_FRAME_SIZE,
32_768,
Http2Settings.MAX_HEADER_LIST_SIZE,
99_999,
0xf00d,
42);
int[] delta = new int[1];
settings.apply(payload, 0, payload.length, value -> delta[0] = value);
assertEquals(Http2Limits.HPACK_DYNAMIC_TABLE_SIZE_LOCAL, settings.headerTableSize());
assertFalse(settings.pushEnabled());
assertEquals(123, settings.maxConcurrentStreams());
assertEquals(70_000, settings.initialWindowSize());
assertEquals(32_768, settings.maxFrameSize());
assertEquals(99_999, settings.maxHeaderListSize());
assertEquals(70_000 - 65_535, delta[0]);
}
@Test
void validatesEnablePushInitialWindowAndFrameSize() {
assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.ENABLE_PUSH, 2));
assertCode(
Http2ErrorCode.FLOW_CONTROL_ERROR, payload(Http2Settings.INITIAL_WINDOW_SIZE, 0x8000_0000));
assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.MAX_FRAME_SIZE, 16_383));
assertCode(Http2ErrorCode.PROTOCOL_ERROR, payload(Http2Settings.MAX_FRAME_SIZE, 16_777_216));
}
@Test
void initialWindowDeltaMayMakeOpenStreamsNegative() {
Http2Settings settings = new Http2Settings();
long[] windows = {10, 100, 65_535};
settings.apply(
payload(Http2Settings.INITIAL_WINDOW_SIZE, 1),
0,
6,
delta -> {
for (int i = 0; i < windows.length; i++) windows[i] += delta;
});
assertArrayEquals(new long[] {-65_524, -65_434, 1}, windows);
}
@Test
void streamWindowOverflowRejectsWholeSettingsPayloadTransactionally() {
Http2Settings settings = new Http2Settings();
byte[] payload =
payload(
Http2Settings.ENABLE_PUSH, 0,
Http2Settings.INITIAL_WINDOW_SIZE, 100_000);
Http2Exception error =
assertThrows(
Http2Exception.class,
() ->
settings.apply(
payload,
0,
payload.length,
delta -> {
throw Http2Exception.FLOW_CONTROL_ERROR;
}));
assertEquals(Http2ErrorCode.FLOW_CONTROL_ERROR, error.errorCode());
assertTrue(settings.pushEnabled(), "no earlier setting may leak through a failed update");
assertEquals(65_535, settings.initialWindowSize());
}
@Test
void malformedLengthAndEntryFloodAreRejected() {
Http2Settings settings = new Http2Settings();
assertSame(
Http2Exception.FRAME_SIZE_ERROR,
assertThrows(Http2Exception.class, () -> settings.apply(new byte[5], 0, 5, d -> {})));
byte[] flood = new byte[(Http2Limits.MAX_SETTINGS_ENTRIES_PER_FRAME + 1) * 6];
Http2Exception error =
assertThrows(Http2Exception.class, () -> settings.apply(flood, 0, flood.length, d -> {}));
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, error.errorCode());
}
private static void assertCode(Http2ErrorCode code, byte[] payload) {
Http2Settings settings = new Http2Settings();
Http2Exception error =
assertThrows(
Http2Exception.class, () -> settings.apply(payload, 0, payload.length, d -> {}));
assertEquals(code, error.errorCode());
}
private static byte[] payload(int... pairs) {
byte[] settingsFrame = Http2TestFrames.settings(pairs);
return Arrays.copyOfRange(settingsFrame, 9, settingsFrame.length);
}
}
@@ -0,0 +1,70 @@
package dev.relism.flash.http2;
import dev.relism.flash.bytes.ByteWriter;
import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.frame.FrameWriteBuffer;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
final class Http2TestFrames {
static final byte[] PREFACE =
"PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
private Http2TestFrames() {}
static byte[] frame(FrameType type, int flags, int streamId, byte[] payload) {
ByteWriter bytes = new ByteWriter(32);
FrameWriteBuffer frame = new FrameWriteBuffer(bytes);
frame.beginFrame(type, flags, streamId);
bytes.writeBytes(payload);
frame.endFrame();
byte[] result = new byte[bytes.length()];
System.arraycopy(bytes.array(), 0, result, 0, result.length);
return result;
}
static byte[] settings(int... idValuePairs) {
ByteWriter payload = new ByteWriter(Math.max(16, idValuePairs.length * 3));
for (int i = 0; i < idValuePairs.length; i += 2) {
payload.writeUInt16(idValuePairs[i]);
payload.writeUInt32(idValuePairs[i + 1]);
}
byte[] body = new byte[payload.length()];
System.arraycopy(payload.array(), 0, body, 0, body.length);
return frame(FrameType.SETTINGS, 0, 0, body);
}
static byte[] concat(byte[]... parts) {
ByteArrayOutputStream out = new ByteArrayOutputStream();
for (byte[] part : parts) out.writeBytes(part);
return out.toByteArray();
}
static List<WireFrame> parse(byte[] bytes) {
List<WireFrame> frames = new ArrayList<>();
int pos = 0;
while (pos < bytes.length) {
int length =
((bytes[pos] & 0xFF) << 16) | ((bytes[pos + 1] & 0xFF) << 8) | (bytes[pos + 2] & 0xFF);
int type = bytes[pos + 3] & 0xFF;
int flags = bytes[pos + 4] & 0xFF;
int streamId = readInt(bytes, pos + 5) & 0x7FFF_FFFF;
byte[] payload = new byte[length];
System.arraycopy(bytes, pos + 9, payload, 0, length);
frames.add(new WireFrame(type, flags, streamId, payload));
pos += 9 + length;
}
return frames;
}
static int readInt(byte[] bytes, int off) {
return ((bytes[off] & 0xFF) << 24)
| ((bytes[off + 1] & 0xFF) << 16)
| ((bytes[off + 2] & 0xFF) << 8)
| (bytes[off + 3] & 0xFF);
}
record WireFrame(int type, int flags, int streamId, byte[] payload) {}
}
@@ -0,0 +1,48 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.http2.frame.FrameType;
import java.util.List;
import org.junit.jupiter.api.Test;
class Http2WindowUpdateTest {
@Test
void connectionWindowUpdateIncreasesSendWindow() throws Exception {
Http2ConnectionHandshakeTest.RunResult result = runWindowUpdate(10_000);
assertEquals(75_535, result.connection().connectionSendWindow());
}
@Test
void zeroIncrementIsProtocolError() throws Exception {
assertGoAwayCode(Http2ErrorCode.PROTOCOL_ERROR, runWindowUpdate(0).output());
}
@Test
void connectionWindowOverflowIsFlowControlError() throws Exception {
assertGoAwayCode(
Http2ErrorCode.FLOW_CONTROL_ERROR, runWindowUpdate(Integer.MAX_VALUE).output());
}
private static Http2ConnectionHandshakeTest.RunResult runWindowUpdate(int increment)
throws Exception {
byte[] payload = {
(byte) (increment >>> 24),
(byte) (increment >>> 16),
(byte) (increment >>> 8),
(byte) increment
};
return Http2ConnectionHandshakeTest.run(
Http2TestFrames.concat(
Http2TestFrames.PREFACE,
Http2TestFrames.settings(),
Http2TestFrames.frame(FrameType.WINDOW_UPDATE, 0, 0, payload)));
}
private static void assertGoAwayCode(Http2ErrorCode expected, byte[] output) {
List<Http2TestFrames.WireFrame> frames = Http2TestFrames.parse(output);
Http2TestFrames.WireFrame goAway = frames.get(frames.size() - 1);
assertEquals(FrameType.GOAWAY.code(), goAway.type());
assertEquals(expected.code(), Http2TestFrames.readInt(goAway.payload(), 4));
}
}
@@ -1,96 +1,170 @@
package dev.relism.flash.http2.frame;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
class Http2FrameWriterTest {
private static final class TestIntent implements WriteIntent {
final byte[] buf;
WriteIntent next;
TestIntent(byte[] buf) { this.buf = buf; }
TestIntent(String s) { this(s.getBytes()); }
@Override public byte[] buffer() { return buf; }
@Override public int offset() { return 0; }
@Override public int length() { return buf.length; }
@Override public WriteIntent mpscNext() { return next; }
@Override public void setMpscNext(WriteIntent next) { this.next = next; }
private static final class TestIntent implements WriteIntent {
final byte[] buf;
WriteIntent next;
TestIntent(byte[] buf) {
this.buf = buf;
}
private static final class RecordingSink implements Http2FrameWriter.Sink {
final List<byte[]> calls = new ArrayList<>();
@Override
public void write(byte[] buf, int off, int len) {
byte[] copy = new byte[len];
System.arraycopy(buf, off, copy, 0, len);
calls.add(copy);
}
TestIntent(String s) {
this(s.getBytes());
}
@Test
void singleWrite_deliversBytesImmediately() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent("hello"));
assertEquals(1, sink.calls.size());
assertArrayEquals("hello".getBytes(), sink.calls.get(0));
writer.close();
@Override
public byte[] buffer() {
return buf;
}
@Test
void sequentialWrites_fromOneThread_preserveOrderAndAreNotSplitOrMerged() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent("one"));
writer.write(new TestIntent("two"));
writer.write(new TestIntent("three"));
assertEquals(List.of("one", "two", "three"),
sink.calls.stream().map(String::new).toList());
writer.close();
@Override
public int offset() {
return 0;
}
@Test
void exceptionFromSink_doesNotLeaveTheLockHeld() throws IOException {
Http2FrameWriter.Sink failingOnce = new Http2FrameWriter.Sink() {
boolean thrown = false;
@Override
public void write(byte[] buf, int off, int len) throws IOException {
if (!thrown) {
thrown = true;
throw new IOException("simulated sink failure");
}
@Override
public int length() {
return buf.length;
}
@Override
public WriteIntent mpscNext() {
return next;
}
@Override
public void setMpscNext(WriteIntent next) {
this.next = next;
}
}
private static final class RecordingSink implements Http2FrameWriter.Sink {
final List<byte[]> calls = new ArrayList<>();
@Override
public void write(byte[] buf, int off, int len) {
byte[] copy = new byte[len];
System.arraycopy(buf, off, copy, 0, len);
calls.add(copy);
}
}
@Test
void singleWrite_deliversBytesImmediately() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent("hello"));
assertEquals(1, sink.calls.size());
assertArrayEquals("hello".getBytes(), sink.calls.get(0));
writer.close();
}
@Test
void sequentialWrites_fromOneThread_preserveOrderAndAreNotSplitOrMerged() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent("one"));
writer.write(new TestIntent("two"));
writer.write(new TestIntent("three"));
assertEquals(List.of("one", "two", "three"), sink.calls.stream().map(String::new).toList());
writer.close();
}
@Test
void exceptionFromSink_doesNotLeaveTheLockHeld() throws IOException {
Http2FrameWriter.Sink failingOnce =
new Http2FrameWriter.Sink() {
boolean thrown = false;
@Override
public void write(byte[] buf, int off, int len) throws IOException {
if (!thrown) {
thrown = true;
throw new IOException("simulated sink failure");
}
}
};
Http2FrameWriter writer = new Http2FrameWriter(failingOnce, 5_000);
Http2FrameWriter writer = new Http2FrameWriter(failingOnce, 5_000);
assertThrows(IOException.class, () -> writer.write(new TestIntent("boom")));
// If the lock were left held by the failed write, this would hang (tryLock() would
// keep failing forever) rather than complete promptly.
assertDoesNotThrow(() -> writer.write(new TestIntent("recovered")));
writer.close();
assertThrows(IOException.class, () -> writer.write(new TestIntent("boom")));
// If the lock were left held by the failed write, this would hang (tryLock() would
// keep failing forever) rather than complete promptly.
assertDoesNotThrow(() -> writer.write(new TestIntent("recovered")));
writer.close();
}
@Test
void drain_withNothingQueued_isANoOp() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.drain();
assertTrue(sink.calls.isEmpty());
writer.close();
}
@Test
void emptyIntent_writesZeroBytesWithoutError() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent(new byte[0]));
assertEquals(1, sink.calls.size());
assertEquals(0, sink.calls.get(0).length);
writer.close();
}
@Test
void priorityFrameOvertakesQueuedOrdinaryFrame() throws Exception {
CountDownLatch firstWriteEntered = new CountDownLatch(1);
CountDownLatch releaseFirstWrite = new CountDownLatch(1);
RecordingSink recording = new RecordingSink();
Http2FrameWriter.Sink blocking =
(buf, off, len) -> {
if (firstWriteEntered.getCount() != 0) {
firstWriteEntered.countDown();
try {
if (!releaseFirstWrite.await(5, TimeUnit.SECONDS)) {
throw new IOException("timed out waiting to release first write");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException(e);
}
}
recording.write(buf, off, len);
};
Http2FrameWriter writer = new Http2FrameWriter(blocking, 5_000);
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var first =
executor.submit(
() -> {
writer.write(new TestIntent("in-flight"));
return null;
});
assertTrue(firstWriteEntered.await(5, TimeUnit.SECONDS));
writer.write(new TestIntent("ordinary"));
writer.writePriority(new TestIntent("priority"));
releaseFirstWrite.countDown();
first.get(5, TimeUnit.SECONDS);
writer.drain();
} finally {
writer.close();
}
@Test
void drain_withNothingQueued_isANoOp() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.drain();
assertTrue(sink.calls.isEmpty());
writer.close();
}
@Test
void emptyIntent_writesZeroBytesWithoutError() throws IOException {
RecordingSink sink = new RecordingSink();
Http2FrameWriter writer = new Http2FrameWriter(sink, 5_000);
writer.write(new TestIntent(new byte[0]));
assertEquals(1, sink.calls.size());
assertEquals(0, sink.calls.get(0).length);
writer.close();
}
assertEquals(
List.of("in-flight", "priority", "ordinary"),
recording.calls.stream().map(String::new).toList());
}
}
@@ -1,191 +1,213 @@
package dev.relism.flash.tls;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLServerSocket;
import static org.junit.jupiter.api.Assertions.*;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLServerSocket;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
class TlsConfigTest {
private static SSLServerSocket unboundSocket(TlsConfig tls) throws IOException {
return (SSLServerSocket) tls.serverSocketFactory().createServerSocket();
private static SSLServerSocket unboundSocket(TlsConfig tls) throws IOException {
return (SSLServerSocket) tls.serverSocketFactory().createServerSocket();
}
@Test
void keystore_pinsProtocolsToTls12And13(@TempDir Path dir) throws Exception {
Path ks =
TestKeystores.build(
dir, "id.p12", "changeit", TestKeystores.Entry.of("only", "single.test"));
TlsConfig tls = TlsConfig.keystore(ks, "changeit");
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
List<String> protocols = Arrays.asList(socket.getSSLParameters().getProtocols());
assertTrue(protocols.contains("TLSv1.2"));
assertTrue(protocols.contains("TLSv1.3"));
assertFalse(protocols.contains("SSLv3"));
assertFalse(protocols.contains("TLSv1"));
assertFalse(protocols.contains("TLSv1.1"));
}
}
@Test
void keystore_pinsProtocolsToTls12And13(@TempDir Path dir) throws Exception {
Path ks = TestKeystores.build(dir, "id.p12", "changeit",
TestKeystores.Entry.of("only", "single.test"));
TlsConfig tls = TlsConfig.keystore(ks, "changeit");
@Test
void ofContext_appliesNoParameterOverlay() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext(); // any valid SSLContext will do here
TlsConfig tls = TlsConfig.ofContext(ctx);
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
List<String> protocols = Arrays.asList(socket.getSSLParameters().getProtocols());
assertTrue(protocols.contains("TLSv1.2"));
assertTrue(protocols.contains("TLSv1.3"));
assertFalse(protocols.contains("SSLv3"));
assertFalse(protocols.contains("TLSv1"));
assertFalse(protocols.contains("TLSv1.1"));
}
try (SSLServerSocket socket = unboundSocket(tls)) {
SSLParameters before = socket.getSSLParameters();
String[] protocolsBefore = before.getProtocols();
tls.applyTo(socket);
assertArrayEquals(
protocolsBefore,
socket.getSSLParameters().getProtocols(),
"ofContext must not narrow/override protocols set on the caller's SSLContext");
assertFalse(socket.getNeedClientAuth());
assertFalse(socket.getWantClientAuth());
}
}
@Test
void ofContext_appliesNoParameterOverlay() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext(); // any valid SSLContext will do here
TlsConfig tls = TlsConfig.ofContext(ctx);
@Test
void ofContext_preservesCallerConfiguredAlpnAndProtocols() throws Exception {
// Mirrors the real use case this escape hatch exists for (TLS-ALPN-01 / RFC 8737):
// the caller sets its own ALPN protocol list — and, to make the point unambiguous,
// a protocol list *narrower* than what Flash's own keystore() path would pin — directly
// on the socket. applyTo() must not touch either. There is no SSLContext#setDefault-
// SSLParameters in the public JSSE API, so this per-socket SSLParameters is the only
// place such configuration can live; this test is the contract that makes it safe to
// rely on.
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
try (SSLServerSocket socket = unboundSocket(tls)) {
SSLParameters before = socket.getSSLParameters();
String[] protocolsBefore = before.getProtocols();
try (SSLServerSocket socket = unboundSocket(tls)) {
SSLParameters custom = socket.getSSLParameters();
custom.setApplicationProtocols(new String[] {"acme-tls/1", "http/1.1"});
custom.setProtocols(new String[] {"TLSv1.3"});
socket.setSSLParameters(custom);
tls.applyTo(socket);
tls.applyTo(socket);
assertArrayEquals(protocolsBefore, socket.getSSLParameters().getProtocols(),
"ofContext must not narrow/override protocols set on the caller's SSLContext");
assertFalse(socket.getNeedClientAuth());
assertFalse(socket.getWantClientAuth());
}
SSLParameters after = socket.getSSLParameters();
assertArrayEquals(
new String[] {"acme-tls/1", "http/1.1"},
after.getApplicationProtocols(),
"ofContext must not touch ALPN protocols the caller configured on its own socket");
assertArrayEquals(
new String[] {"TLSv1.3"},
after.getProtocols(),
"ofContext must not widen/override the caller's own protocol list");
// clientAuth still applies — it is the caller's own explicit instruction through
// this API, not a Flash-imposed default. See TlsConfig's class Javadoc.
assertTrue(socket.getWantClientAuth());
}
}
@Test
void ofContext_preservesCallerConfiguredAlpnAndProtocols() throws Exception {
// Mirrors the real use case this escape hatch exists for (TLS-ALPN-01 / RFC 8737):
// the caller sets its own ALPN protocol list — and, to make the point unambiguous,
// a protocol list *narrower* than what Flash's own keystore() path would pin — directly
// on the socket. applyTo() must not touch either. There is no SSLContext#setDefault-
// SSLParameters in the public JSSE API, so this per-socket SSLParameters is the only
// place such configuration can live; this test is the contract that makes it safe to
// rely on.
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
@Test
void clientAuth_none_makesNoClientAuthCall() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx);
try (SSLServerSocket socket = unboundSocket(tls)) {
SSLParameters custom = socket.getSSLParameters();
custom.setApplicationProtocols(new String[] { "acme-tls/1", "http/1.1" });
custom.setProtocols(new String[] { "TLSv1.3" });
socket.setSSLParameters(custom);
tls.applyTo(socket);
SSLParameters after = socket.getSSLParameters();
assertArrayEquals(new String[] { "acme-tls/1", "http/1.1" }, after.getApplicationProtocols(),
"ofContext must not touch ALPN protocols the caller configured on its own socket");
assertArrayEquals(new String[] { "TLSv1.3" }, after.getProtocols(),
"ofContext must not widen/override the caller's own protocol list");
// clientAuth still applies — it is the caller's own explicit instruction through
// this API, not a Flash-imposed default. See TlsConfig's class Javadoc.
assertTrue(socket.getWantClientAuth());
}
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertFalse(socket.getNeedClientAuth());
assertFalse(socket.getWantClientAuth());
}
}
@Test
void clientAuth_none_makesNoClientAuthCall() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx);
@Test
void clientAuth_require_setsNeedClientAuth() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.REQUIRE);
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertFalse(socket.getNeedClientAuth());
assertFalse(socket.getWantClientAuth());
}
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertTrue(socket.getNeedClientAuth());
}
}
@Test
void clientAuth_require_setsNeedClientAuth() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.REQUIRE);
@Test
void clientAuth_optional_setsWantClientAuth() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertTrue(socket.getNeedClientAuth());
}
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertTrue(socket.getWantClientAuth());
assertFalse(socket.getNeedClientAuth());
}
}
@Test
void clientAuth_optional_setsWantClientAuth() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).clientAuth(ClientAuth.OPTIONAL);
@Test
void negotiatesH2_trueOnlyWhenH2IsInTheOfferedList() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1").negotiatesH2());
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2").negotiatesH2());
assertFalse(TlsConfig.ofContext(ctx).applicationProtocols("http/1.1").negotiatesH2());
assertFalse(TlsConfig.ofContext(ctx).negotiatesH2()); // no applicationProtocols call at all
}
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
assertTrue(socket.getWantClientAuth());
assertFalse(socket.getNeedClientAuth());
}
@Test
void applyTo_withH2Offered_removesBlockedTls12CipherSuites() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1");
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
List<String> enabled = Arrays.asList(socket.getEnabledCipherSuites());
// Spot-check a handful of RFC 9113 Appendix A entries across different families
// (RSA key exchange, 3DES, plain ECDHE-CBC) rather than the full ~280-entry list —
// TLS12_H2_BLOCKED_CIPHERS itself is the source of truth for the complete set.
assertFalse(enabled.contains("TLS_RSA_WITH_AES_128_CBC_SHA"));
assertFalse(enabled.contains("TLS_RSA_WITH_3DES_EDE_CBC_SHA"));
assertFalse(enabled.contains("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"));
assertFalse(enabled.contains("TLS_NULL_WITH_NULL_NULL"));
}
}
@Test
void applyTo_withH2Offered_keepsTheRequiredCipherSuiteWhenTheJdkEnabledIt() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2");
@Test
void negotiatesH2_trueOnlyWhenH2IsInTheOfferedList() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1").negotiatesH2());
assertTrue(TlsConfig.ofContext(ctx).applicationProtocols("h2").negotiatesH2());
assertFalse(TlsConfig.ofContext(ctx).applicationProtocols("http/1.1").negotiatesH2());
assertFalse(TlsConfig.ofContext(ctx).negotiatesH2()); // no applicationProtocols call at all
try (SSLServerSocket socket = unboundSocket(tls)) {
boolean jdkEnabledItByDefault =
Arrays.asList(socket.getEnabledCipherSuites())
.contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE);
tls.applyTo(socket);
if (jdkEnabledItByDefault) {
assertTrue(
Arrays.asList(socket.getEnabledCipherSuites())
.contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE),
"RFC 9113 §9.2.2 requires supporting this suite — filtering must never remove it");
}
}
}
@Test
void applyTo_withH2Offered_removesBlockedTls12CipherSuites() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2", "http/1.1");
@Test
void applyTo_withoutH2Offered_leavesCipherSuitesUntouched() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("http/1.1");
try (SSLServerSocket socket = unboundSocket(tls)) {
tls.applyTo(socket);
List<String> enabled = Arrays.asList(socket.getEnabledCipherSuites());
// Spot-check a handful of RFC 9113 Appendix A entries across different families
// (RSA key exchange, 3DES, plain ECDHE-CBC) rather than the full ~280-entry list —
// TLS12_H2_BLOCKED_CIPHERS itself is the source of truth for the complete set.
assertFalse(enabled.contains("TLS_RSA_WITH_AES_128_CBC_SHA"));
assertFalse(enabled.contains("TLS_RSA_WITH_3DES_EDE_CBC_SHA"));
assertFalse(enabled.contains("TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA"));
assertFalse(enabled.contains("TLS_NULL_WITH_NULL_NULL"));
}
try (SSLServerSocket socket = unboundSocket(tls)) {
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
tls.applyTo(socket);
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
}
}
@Test
void applyTo_withH2Offered_keepsTheRequiredCipherSuiteWhenTheJdkEnabledIt() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("h2");
@Test
void applyTo_noApplicationProtocolsAtAll_leavesCipherSuitesUntouched() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx);
try (SSLServerSocket socket = unboundSocket(tls)) {
boolean jdkEnabledItByDefault =
Arrays.asList(socket.getEnabledCipherSuites()).contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE);
tls.applyTo(socket);
if (jdkEnabledItByDefault) {
assertTrue(Arrays.asList(socket.getEnabledCipherSuites()).contains(TlsConfig.REQUIRED_H2_CIPHER_SUITE),
"RFC 9113 §9.2.2 requires supporting this suite — filtering must never remove it");
}
}
try (SSLServerSocket socket = unboundSocket(tls)) {
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
tls.applyTo(socket);
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
}
}
@Test
void applyTo_withoutH2Offered_leavesCipherSuitesUntouched() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("http/1.1");
@Test
void enableHttp2AlpnPreservesCustomPriorityAndRetainsHttp1Fallback() throws Exception {
SSLContext ctx = SSLContext.getDefault();
TlsConfig tls = TlsConfig.ofContext(ctx).applicationProtocols("acme-tls/1");
SSLServerSocket socket =
(SSLServerSocket) tls.enableHttp2Alpn().serverSocketFactory().createServerSocket();
try (SSLServerSocket socket = unboundSocket(tls)) {
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
tls.applyTo(socket);
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
}
}
tls.enableHttp2Alpn().applyTo(socket);
@Test
void applyTo_noApplicationProtocolsAtAll_leavesCipherSuitesUntouched() throws Exception {
SSLContext ctx = TestKeystores.trustAllClientContext();
TlsConfig tls = TlsConfig.ofContext(ctx);
try (SSLServerSocket socket = unboundSocket(tls)) {
List<String> before = Arrays.asList(socket.getEnabledCipherSuites());
tls.applyTo(socket);
assertEquals(before, Arrays.asList(socket.getEnabledCipherSuites()));
}
}
assertArrayEquals(
new String[] {"acme-tls/1", "h2", "http/1.1"},
socket.getSSLParameters().getApplicationProtocols());
socket.close();
}
}
@@ -1,12 +1,12 @@
package dev.relism.flash.transport;
import static org.junit.jupiter.api.Assertions.*;
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;
@@ -16,55 +16,67 @@ 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.*;
import org.junit.jupiter.api.Test;
/**
* A scratch is always released — including on an exception path — and a socket is always
* removed from {@code activeSockets}, regardless of how the dispatched
* checks list), verified here with a protocol implementation that deliberately throws.
* A scratch is always released — including on an exception path — and a socket is always removed
* from {@code activeSockets}, regardless of how the dispatched 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();
@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");
ConnectionProtocol throwingProtocol =
ctx -> {
throw new IOException("simulated protocol failure");
};
ConnectionRunner runner = new ConnectionRunner(
executor, activeSockets, scratchPool, router, wsRouter, configuration, throwingProtocol);
ConnectionRunner runner =
new ConnectionRunner(
executor,
activeSockets,
scratchPool,
router,
wsRouter,
configuration,
throwingProtocol,
() -> throwingProtocol);
try (ServerSocket serverSocket = new ServerSocket(0)) {
int port = serverSocket.getLocalPort();
CountDownLatch accepted = new CountDownLatch(1);
try (ServerSocket serverSocket = new ServerSocket(0)) {
int port = serverSocket.getLocalPort();
CountDownLatch accepted = new CountDownLatch(1);
Thread acceptThread = new Thread(() -> {
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
runner.accept(serverSide, () -> false);
accepted.countDown();
Thread.sleep(300); // give the submitted virtual-thread task time to run
} catch (Exception ignored) {
}
});
acceptThread.start();
});
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
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();
}
assertTrue(
activeSockets.isEmpty(),
"socket must be removed from activeSockets on every exit path");
}
acceptThread.join(2000);
} finally {
executor.shutdownNow();
}
}
}