feat(core): add HTTP/2 stream dispatch

This commit is contained in:
Zakaria El Orche
2026-08-13 18:33:04 +00:00
parent 9391f80f76
commit c96d51f7ea
24 changed files with 1621 additions and 70 deletions
@@ -2,22 +2,32 @@ package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.*;
import dev.relism.flash.bytes.ByteWriter;
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.http2.hpack.HpackDecoder;
import dev.relism.flash.http2.hpack.HpackEncoder;
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.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import javax.net.ssl.SSLParameters;
import javax.net.ssl.SSLSocket;
import org.junit.jupiter.api.AfterEach;
@@ -32,6 +42,171 @@ class Http2ConnectionIntegrationTest {
if (app != null) app.stop().join();
}
@Test
void javaHttpClientUsesHttp2AgainstAnExistingParameterizedRoute(@TempDir Path directory)
throws Exception {
int port = freePort();
Path keystore =
TestKeystores.build(
directory,
"http2-route.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.get(
"/users/{id}", (request, response) -> request.param("id") + ":" + request.header("host"));
app.start();
HttpClient client =
HttpClient.newBuilder()
.sslContext(TestKeystores.trustAllClientContext())
.version(HttpClient.Version.HTTP_2)
.build();
HttpResponse<String> response =
client.send(
HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/users/42"))
.GET()
.build(),
HttpResponse.BodyHandlers.ofString());
assertEquals(HttpClient.Version.HTTP_2, response.version());
assertEquals(200, response.statusCode());
assertEquals("42:localhost:" + port, response.body());
}
@Test
void bodylessGetRunsExistingRouteAndReturnsHeadersAndData() throws Exception {
int port = freePort();
app =
FlashApp.create(
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
app.get("/api/ping", (request, response) -> "pong");
app.start();
ByteWriter block = new ByteWriter(64);
HpackEncoder.writeIndexed(block, 2);
HpackEncoder.writeIndexed(block, 6);
HpackEncoder.writeLiteralWithNameIndex(
block, 4, "/api/ping".getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeLiteralWithNameIndex(
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
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 | FrameFlags.END_STREAM,
1,
Arrays.copyOf(block.array(), block.length()))));
socket.getOutputStream().flush();
ByteWriter responseBlock = new ByteWriter(128);
byte[] body = null;
for (int i = 0; i < 10 && body == null; i++) {
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
if (frame.streamId() != 1) continue;
if (frame.type() == FrameType.HEADERS.code()
|| frame.type() == FrameType.CONTINUATION.code()) {
responseBlock.writeBytes(frame.payload());
} else if (frame.type() == FrameType.DATA.code()) {
body = frame.payload();
}
}
List<String> fields = new ArrayList<>();
new HpackDecoder()
.decode(
responseBlock.array(),
0,
responseBlock.length(),
(name, value, never) -> fields.add(ascii(name) + "=" + ascii(value)));
assertTrue(fields.contains(":status=200"));
assertTrue(fields.contains("content-length=4"));
assertEquals("pong", new String(body, StandardCharsets.US_ASCII));
}
}
@Test
void resetQueuedStreamCannotReleaseOrReuseItBeforeDispatchObservesCancellation()
throws Exception {
int port = freePort();
AtomicInteger calls = new AtomicInteger();
app =
FlashApp.create(
FlashConfiguration.builder().port(port).host("127.0.0.1").http2Enabled(true).build());
app.get(
"/queued",
(request, response) -> {
calls.incrementAndGet();
return "ok";
});
app.start();
ByteWriter block = new ByteWriter(64);
HpackEncoder.writeIndexed(block, 2);
HpackEncoder.writeIndexed(block, 6);
HpackEncoder.writeLiteralWithNameIndex(
block, 4, "/queued".getBytes(StandardCharsets.US_ASCII), false);
HpackEncoder.writeLiteralWithNameIndex(
block, 1, "localhost".getBytes(StandardCharsets.US_ASCII), false);
byte[] headers = Arrays.copyOf(block.array(), block.length());
byte[] cancel = {0, 0, 0, 8};
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 | FrameFlags.END_STREAM,
1,
headers),
Http2TestFrames.frame(FrameType.RST_STREAM, 0, 1, cancel),
Http2TestFrames.frame(
FrameType.HEADERS,
FrameFlags.END_HEADERS | FrameFlags.END_STREAM,
3,
headers)));
socket.getOutputStream().flush();
Http2TestFrames.WireFrame response = null;
for (int i = 0; i < 10; i++) {
Http2TestFrames.WireFrame frame = readFrame(socket.getInputStream());
assertFalse(
frame.streamId() == 1
&& (frame.type() == FrameType.HEADERS.code()
|| frame.type() == FrameType.DATA.code()),
"a reset request must not produce a response");
if (frame.streamId() == 3 && frame.type() == FrameType.DATA.code()) {
response = frame;
break;
}
}
assertNotNull(response);
assertEquals("ok", new String(response.payload(), StandardCharsets.US_ASCII));
assertEquals(1, calls.get());
}
}
@Test
void h2cTransportDispatchesControlFramesWithoutRunningApplicationWork() throws Exception {
int port = freePort();
@@ -244,4 +419,10 @@ class Http2ConnectionIntegrationTest {
return socket.getLocalPort();
}
}
private static String ascii(dev.relism.fpr.core.ByteView view) {
byte[] bytes = new byte[view.length()];
for (int i = 0; i < bytes.length; i++) bytes[i] = view.byteAt(i);
return new String(bytes, StandardCharsets.US_ASCII);
}
}
@@ -0,0 +1,86 @@
package dev.relism.flash.http2.message;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
import static org.junit.jupiter.api.Assertions.assertThrows;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http2.Http2StreamException;
import dev.relism.flash.http2.hpack.HpackHeaderBlock;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class PseudoHeaderValidationTest {
@Test
void validRequest() {
assertDoesNotThrow(
() ->
validate(
":method", "GET", ":scheme", "https", ":path", "/", ":authority", "example.com"));
}
@Test
void rejectsPseudoAfterRegular() {
rejects("x", "1", ":method", "GET", ":scheme", "https", ":path", "/", ":authority", "x");
}
@Test
void rejectsUnknownAndDuplicatePseudoHeaders() {
rejects(":method", "GET", ":scheme", "https", ":path", "/", ":authority", "x", ":other", "x");
rejects(
":method", "GET", ":method", "POST", ":scheme", "https", ":path", "/", ":authority", "x");
}
@Test
void rejectsMissingAndEmptyPseudoHeaders() {
rejects(":method", "GET", ":scheme", "https", ":path", "/");
rejects(":method", "GET", ":scheme", "https", ":path", "", ":authority", "x");
}
@Test
void validatesConnectShape() {
assertDoesNotThrow(() -> validate(":method", "CONNECT", ":authority", "example.com:443"));
rejects(":method", "CONNECT", ":scheme", "https", ":authority", "example.com:443");
}
@Test
void rejectsUppercaseForbiddenAndInvalidTeFields() {
rejects(validWith("X-Test", "1"));
rejects(validWith("connection", "close"));
rejects(validWith("keep-alive", "timeout=5"));
rejects(validWith("proxy-connection", "close"));
rejects(validWith("transfer-encoding", "chunked"));
rejects(validWith("upgrade", "websocket"));
rejects(validWith("te", "gzip"));
assertDoesNotThrow(() -> validate(validWith("te", "trailers")));
}
@Test
void rejectsHostAuthorityConflict() {
rejects(validWith("host", "other.example"));
assertDoesNotThrow(() -> validate(validWith("host", "example.com")));
}
private static String[] validWith(String name, String value) {
return new String[] {
":method", "GET", ":scheme", "https", ":path", "/", ":authority", "example.com", name, value
};
}
private static void rejects(String... fields) {
assertThrows(Http2StreamException.class, () -> validate(fields));
}
private static void validate(String... fields) {
HpackHeaderBlock block = new HpackHeaderBlock();
PooledSlice name = new PooledSlice();
PooledSlice value = new PooledSlice();
for (int i = 0; i < fields.length; i += 2) {
byte[] nameBytes = fields[i].getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = fields[i + 1].getBytes(StandardCharsets.US_ASCII);
name.reset(nameBytes, 0, nameBytes.length);
value.reset(valueBytes, 0, valueBytes.length);
block.accept(name, value, false);
}
new PseudoHeaders().validate(block, 1);
}
}
@@ -0,0 +1,43 @@
package dev.relism.flash.http2.stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import dev.relism.flash.bytes.PooledSlice;
import dev.relism.flash.http.HttpMethod;
import dev.relism.flash.models.Request;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
class Http2RequestAssemblyTest {
@Test
void assemblesProtocolNeutralRequestWithQueryAndAuthorityAlias() {
Http2StreamTable table = new Http2StreamTable(1);
Http2Stream stream = table.acquire(1);
field(stream, ":method", "GET");
field(stream, ":scheme", "https");
field(stream, ":path", "/users/42?verbose=true");
field(stream, ":authority", "example.com");
field(stream, "x-trace", "abc");
Request request = stream.assembleRequest(null, null);
assertEquals(HttpMethod.GET, request.method());
assertEquals("/users/42", request.path());
assertEquals("true", request.query("verbose"));
assertEquals("example.com", request.header("host"));
assertEquals("example.com", request.header(":authority"));
assertEquals("abc", request.header("X-Trace"));
assertNull(request.remoteAddress());
}
private static void field(Http2Stream stream, String name, String value) {
byte[] nameBytes = name.getBytes(StandardCharsets.US_ASCII);
byte[] valueBytes = value.getBytes(StandardCharsets.US_ASCII);
PooledSlice nameView = new PooledSlice();
PooledSlice valueView = new PooledSlice();
nameView.reset(nameBytes, 0, nameBytes.length);
valueView.reset(valueBytes, 0, valueBytes.length);
stream.headerBlock().accept(nameView, valueView, false);
}
}
@@ -0,0 +1,20 @@
package dev.relism.flash.http2.stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import org.junit.jupiter.api.Test;
class Http2StreamLeakTest {
@Test
void oneHundredThousandAcquireReleaseCyclesReuseOneStream() {
Http2StreamTable table = new Http2StreamTable(100);
for (int i = 0; i < 100_000; i++) {
Http2Stream stream = table.acquire((i << 1) | 1);
table.remove(stream.id());
table.release(stream);
}
assertEquals(1, table.createdCount());
assertEquals(1, table.freeCount());
assertEquals(0, table.size());
}
}
@@ -0,0 +1,32 @@
package dev.relism.flash.http2.stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import dev.relism.flash.http2.Http2StreamException;
import org.junit.jupiter.api.Test;
class Http2StreamStateTest {
@Test
void everyTransitionCellIsExecutableOrTypedError() {
for (Http2StreamState state : Http2StreamState.values()) {
for (Http2StreamState.Event event : Http2StreamState.Event.values()) {
if (Http2StreamState.isValid(state, event)) {
Http2StreamState next = state.transition(1, event);
assertEquals(true, next != null);
} else {
assertThrows(Http2StreamException.class, () -> state.transition(1, event));
}
}
}
}
@Test
void bodylessRequestAndResponseCloseStream() {
Http2StreamState state =
Http2StreamState.IDLE.transition(1, Http2StreamState.Event.RECV_HEADERS_ES);
assertEquals(Http2StreamState.HALF_CLOSED_REMOTE, state);
assertEquals(
Http2StreamState.CLOSED, state.transition(1, Http2StreamState.Event.SEND_HEADERS_ES));
}
}
@@ -0,0 +1,26 @@
package dev.relism.flash.http2.stream;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import org.junit.jupiter.api.Test;
class Http2StreamTableTest {
@Test
void insertLookupRemoveAtCapacityAndAcrossProbeClusters() {
Http2StreamTable table = new Http2StreamTable(8);
Http2Stream[] streams = new Http2Stream[8];
for (int i = 0; i < streams.length; i++) {
streams[i] = table.acquire(i * 2 + 1);
assertSame(streams[i], table.get(i * 2 + 1));
}
assertNull(table.acquire(99));
for (int i = 0; i < streams.length; i += 2) {
assertSame(streams[i], table.remove(streams[i].id()));
table.release(streams[i]);
}
for (int i = 1; i < streams.length; i += 2) {
assertSame(streams[i], table.get(streams[i].id()));
}
}
}