feat(core): add HTTP/2 connection state machine
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user