feat(core): add HTTP/2 flow-controlled bodies

This commit is contained in:
Zakaria El Orche
2026-08-13 19:00:19 +00:00
parent c96d51f7ea
commit 8d5340a0b4
21 changed files with 1679 additions and 101 deletions
@@ -0,0 +1,37 @@
package dev.relism.flash.http2;
import static org.junit.jupiter.api.Assertions.assertEquals;
import dev.relism.flash.http2.message.DataBufferPool;
import dev.relism.flash.http2.message.Http2RequestBody;
import dev.relism.flash.http2.stream.Http2FlowController;
import dev.relism.flash.http2.stream.Http2Stream;
import dev.relism.flash.http2.stream.Http2StreamTable;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class Http2BackpressureTest {
@Test
void windowUpdatesAreWithheldUntilTheHandlerConsumesQueuedData() throws Exception {
AtomicInteger updates = new AtomicInteger();
Http2FlowController flow =
new Http2FlowController((streamId, increment) -> updates.addAndGet(increment));
Http2Stream stream = new Http2StreamTable(1).acquire(1);
DataBufferPool pool = new DataBufferPool(Http2Limits.MAX_FRAME_SIZE_LOCAL, 32);
Http2RequestBody body = new Http2RequestBody(pool);
body.begin(-1, false, bytes -> flow.consumed(stream, bytes));
byte[] frame = new byte[Http2Limits.MAX_FRAME_SIZE_LOCAL];
for (int i = 0; i < 32; i++) {
flow.receiveConnectionBytes(frame.length);
flow.receiveStreamBytes(stream, frame.length);
body.offer(1, frame, 0, frame.length, frame.length);
}
assertEquals(0, updates.get(), "receiving alone must not reopen either window");
body.finish(1);
assertEquals(32L * frame.length, body.readAllBytes().length);
assertEquals(2 * 32 * frame.length, updates.get());
assertEquals(32, pool.availableCount());
}
}
@@ -11,6 +11,7 @@ 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.ByteArrayInputStream;
import java.io.EOFException;
import java.io.InputStream;
import java.net.ServerSocket;
@@ -81,6 +82,115 @@ class Http2ConnectionIntegrationTest {
assertEquals("42:localhost:" + port, response.body());
}
@Test
void javaHttpClientUploadsAndDownloadsFlowControlledBodies(@TempDir Path directory)
throws Exception {
int port = freePort();
Path keystore =
TestKeystores.build(
directory,
"http2-bodies.p12",
"changeit",
TestKeystores.Entry.of("server", "localhost", "localhost"));
byte[] upload = new byte[2 * 1024 * 1024];
for (int i = 0; i < upload.length; i++) upload[i] = (byte) (i * 31);
byte[] download = new byte[2 * 1024 * 1024 + 17];
for (int i = 0; i < download.length; i++) download[i] = (byte) (i * 17);
app =
FlashApp.create(
FlashConfiguration.builder()
.port(port)
.host("127.0.0.1")
.tls(TlsConfig.keystore(keystore, "changeit"))
.http2Enabled(true)
.build());
app.post("/echo", (request, response) -> request.body().bytes());
app.get("/fixed", (request, response) -> response.body(download));
app.get(
"/stream",
(request, response) -> response.chunked(new ByteArrayInputStream(download)));
app.start();
HttpClient client =
HttpClient.newBuilder()
.sslContext(TestKeystores.trustAllClientContext())
.version(HttpClient.Version.HTTP_2)
.build();
HttpResponse<byte[]> echoed =
client.send(
HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/echo"))
.POST(HttpRequest.BodyPublishers.ofByteArray(upload))
.build(),
HttpResponse.BodyHandlers.ofByteArray());
HttpResponse<byte[]> fixed =
client.send(
HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/fixed")).GET().build(),
HttpResponse.BodyHandlers.ofByteArray());
HttpResponse<byte[]> streamed =
client.send(
HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/stream")).GET().build(),
HttpResponse.BodyHandlers.ofByteArray());
assertArrayEquals(upload, echoed.body());
assertArrayEquals(download, fixed.body());
assertArrayEquals(download, streamed.body());
assertTrue(streamed.headers().firstValue("transfer-encoding").isEmpty());
}
@Test
void hundredMegabyteUploadAndDownloadRemainStreaming(@TempDir Path directory) throws Exception {
int port = freePort();
long length = 100L * 1024 * 1024;
Path keystore =
TestKeystores.build(
directory,
"http2-large-bodies.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.post(
"/upload",
(request, response) -> {
long count = verifyPattern(request.body().stream());
return Long.toString(count);
});
app.get(
"/download",
(request, response) -> response.stream(new PatternInputStream(length), length));
app.start();
HttpClient client =
HttpClient.newBuilder()
.sslContext(TestKeystores.trustAllClientContext())
.version(HttpClient.Version.HTTP_2)
.build();
HttpResponse<String> upload =
client.send(
HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/upload"))
.POST(HttpRequest.BodyPublishers.ofInputStream(() -> new PatternInputStream(length)))
.build(),
HttpResponse.BodyHandlers.ofString());
HttpResponse<InputStream> download =
client.send(
HttpRequest.newBuilder(URI.create("https://localhost:" + port + "/download"))
.GET()
.build(),
HttpResponse.BodyHandlers.ofInputStream());
assertEquals(Long.toString(length), upload.body());
try (InputStream body = download.body()) {
assertEquals(length, verifyPattern(body));
}
}
@Test
void bodylessGetRunsExistingRouteAndReturnsHeadersAndData() throws Exception {
int port = freePort();
@@ -401,6 +511,39 @@ class Http2ConnectionIntegrationTest {
throw new AssertionError();
}
private static long verifyPattern(InputStream input) throws Exception {
byte[] buffer = new byte[64 * 1024];
long position = 0;
int count;
while ((count = input.read(buffer)) >= 0) {
for (int i = 0; i < count; i++) assertEquals((byte) (position++ * 31), buffer[i]);
}
return position;
}
private static final class PatternInputStream extends InputStream {
private final long length;
private long position;
PatternInputStream(long length) {
this.length = length;
}
@Override
public int read() {
if (position == length) return -1;
return (byte) (position++ * 31) & 0xff;
}
@Override
public int read(byte[] target, int offset, int requested) {
if (position == length) return -1;
int count = (int) Math.min(requested, length - position);
for (int i = 0; i < count; i++) target[offset + i] = (byte) (position++ * 31);
return count;
}
}
private static Http2TestFrames.WireFrame readFrame(InputStream input) throws Exception {
byte[] header = input.readNBytes(9);
if (header.length != 9) throw new EOFException("truncated frame header");
@@ -0,0 +1,54 @@
package dev.relism.flash.http2.message;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import dev.relism.flash.http.ContentType;
import dev.relism.flash.models.Response;
import java.io.InputStream;
import org.junit.jupiter.api.Test;
class Http2LargeResponseTest {
@Test
void hundredMegabyteStreamUsesOneBoundedReusableFrameBuffer() throws Exception {
long length = 100L * 1024 * 1024;
Response response =
new Response(200, ContentType.BINARY).stream(new RepeatingInputStream(length), length);
Http2ResponseWriter writer = new Http2ResponseWriter();
long written =
writer.startFlowControlled(
response, 1, false, false, true, false, false, 16_384, 32_768, 16_384);
int largestBuffer = writer.buffer().length;
while (!writer.finished()) {
written += writer.resume(16_384, 16_384);
largestBuffer = Math.max(largestBuffer, writer.buffer().length);
}
assertEquals(length, written);
assertTrue(largestBuffer <= 65_536, "serialized storage must not scale with body length");
}
private static final class RepeatingInputStream extends InputStream {
private long remaining;
RepeatingInputStream(long remaining) {
this.remaining = remaining;
}
@Override
public int read() {
if (remaining == 0) return -1;
remaining--;
return 0x5a;
}
@Override
public int read(byte[] target, int offset, int length) {
if (remaining == 0) return -1;
int count = (int) Math.min(length, remaining);
java.util.Arrays.fill(target, offset, offset + count, (byte) 0x5a);
remaining -= count;
return count;
}
}
}
@@ -0,0 +1,86 @@
package dev.relism.flash.http2.message;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import dev.relism.flash.http2.Http2ErrorCode;
import dev.relism.flash.http2.Http2Limits;
import dev.relism.flash.http2.Http2StreamException;
import dev.relism.flash.models.RequestBody;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class Http2RequestBodyTest {
@Test
void inlineBodyFeedsTheProtocolNeutralRequestBodyWithOneMaterialization() {
AtomicInteger consumed = new AtomicInteger();
Http2RequestBody source = new Http2RequestBody(new DataBufferPool(16, 1));
source.begin(3, true, consumed::addAndGet);
source.offer(1, "abc".getBytes(StandardCharsets.US_ASCII), 0, 3, 5);
source.finish(1);
RequestBody body = new RequestBody();
body.reset(source, 3, null, 0, 0);
assertArrayEquals("abc".getBytes(StandardCharsets.US_ASCII), body.bytes());
assertEquals(5, consumed.get());
assertEquals(3, body.contentLength());
}
@Test
void streamingBodyReusesAndReturnsPooledBuffers() throws Exception {
DataBufferPool pool = new DataBufferPool(8, 2);
AtomicInteger consumed = new AtomicInteger();
Http2RequestBody source = new Http2RequestBody(pool);
source.begin(-1, false, consumed::addAndGet);
source.offer(1, new byte[] {1, 2, 3, 4, 5, 6, 7, 8}, 0, 8, 8);
source.offer(1, new byte[] {9, 10}, 0, 2, 2);
source.finish(1);
assertArrayEquals(new byte[] {1, 2, 3, 4, 5, 6, 7, 8, 9, 10}, source.readAllBytes());
assertEquals(10, consumed.get());
assertEquals(2, pool.createdCount());
assertEquals(2, pool.availableCount());
}
@Test
void contentLengthMismatchIsAProtocolStreamError() {
Http2RequestBody source = new Http2RequestBody(new DataBufferPool(8, 1));
source.begin(4, true, bytes -> {});
source.offer(3, new byte[] {1, 2, 3}, 0, 3, 3);
Http2StreamException failure =
assertThrows(Http2StreamException.class, () -> source.finish(3));
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, failure.errorCode());
}
@Test
void boundedPoolNeverAllocatesPastItsCapacity() {
DataBufferPool pool = new DataBufferPool(4, 1);
Http2RequestBody source = new Http2RequestBody(pool);
source.begin(-1, false, bytes -> {});
source.offer(1, new byte[] {1, 2, 3, 4}, 0, 4, 4);
Http2StreamException failure =
assertThrows(
Http2StreamException.class,
() -> source.offer(1, new byte[] {2}, 0, 1, 1));
assertEquals(Http2ErrorCode.ENHANCE_YOUR_CALM, failure.errorCode());
assertEquals(1, pool.createdCount());
}
@Test
void unknownLengthBodyCannotExceedTheConfiguredMaximum() {
Http2RequestBody source = new Http2RequestBody(new DataBufferPool(8, 1));
source.begin(-1, false, bytes -> {});
Http2StreamException failure =
assertThrows(
Http2StreamException.class,
() ->
source.offer(
1, new byte[1], 0, Http2Limits.MAX_REQUEST_BODY_SIZE + 1, 1));
assertEquals(Http2ErrorCode.PROTOCOL_ERROR, failure.errorCode());
}
}
@@ -12,6 +12,7 @@ import dev.relism.flash.http2.frame.FrameType;
import dev.relism.flash.http2.hpack.HpackDecoder;
import dev.relism.flash.models.Response;
import dev.relism.fpr.core.ByteView;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Arrays;
@@ -99,6 +100,38 @@ class Http2ResponseWriterTest {
() -> writer.prepare(response, 5, false, false, true, false, false, 16_384, 41, 65_535));
}
@Test
void flowControlledHeadPreservesKnownRepresentationLength() throws Exception {
Response response =
new Response(200, ContentType.BINARY)
.stream(new ByteArrayInputStream(new byte[] {1, 2, 3, 4}), 4);
Http2ResponseWriter writer = new Http2ResponseWriter();
writer.startFlowControlled(
response, 1, true, false, true, false, false, 16_384, 4096, 16_384);
Parsed parsed = parse(writer);
assertEquals(List.of(FrameType.HEADERS), parsed.types);
assertTrue(decode(parsed.headerBlock).contains("content-length=4"));
}
@Test
void unknownLengthStreamUsesNativeDataWithoutTransferEncoding() throws Exception {
Response response =
new Response(200, ContentType.BINARY)
.chunked(new ByteArrayInputStream(new byte[] {1, 2, 3, 4}));
Http2ResponseWriter writer = new Http2ResponseWriter();
writer.startFlowControlled(
response, 1, false, false, true, false, false, 16_384, 4096, 16_384);
Parsed parsed = parse(writer);
List<String> fields = decode(parsed.headerBlock);
assertFalse(fields.stream().anyMatch(field -> field.startsWith("content-length=")));
assertFalse(fields.stream().anyMatch(field -> field.startsWith("transfer-encoding=")));
assertEquals(List.of(FrameType.HEADERS, FrameType.DATA), parsed.types);
}
private static Parsed parse(Http2ResponseWriter writer) {
Parsed parsed = new Parsed();
byte[] wire = writer.buffer();
@@ -0,0 +1,81 @@
package dev.relism.flash.http2.stream;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import dev.relism.flash.http2.Http2Exception;
import dev.relism.flash.http2.Http2Limits;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
class Http2FlowControlTest {
@Test
void receiveWindowsReopenAtHalfWindowAtBothLevels() throws Exception {
AtomicInteger connectionUpdates = new AtomicInteger();
AtomicInteger streamUpdates = new AtomicInteger();
Http2FlowController controller =
new Http2FlowController(
(streamId, increment) -> {
if (streamId == 0) connectionUpdates.addAndGet(increment);
else streamUpdates.addAndGet(increment);
});
Http2Stream stream = new Http2StreamTable(1).acquire(1);
int half = Http2Limits.INITIAL_WINDOW_SIZE_LOCAL / 2;
controller.receiveConnectionBytes(half);
controller.receiveStreamBytes(stream, half);
controller.consumed(stream, half);
assertEquals(half, connectionUpdates.get());
assertEquals(half, streamUpdates.get());
assertEquals(Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL, controller.connectionReceiveWindow());
}
@Test
void connectionAndStreamUnderflowUseTheirCorrectErrorScope() {
Http2FlowController controller = new Http2FlowController((streamId, increment) -> {});
Http2Stream stream = new Http2StreamTable(1).acquire(1);
assertSame(
Http2Exception.FLOW_CONTROL_ERROR,
assertThrows(
Http2Exception.class,
() ->
controller.receiveConnectionBytes(
Http2Limits.CONNECTION_WINDOW_SIZE_LOCAL + 1)));
assertEquals(
dev.relism.flash.http2.Http2ErrorCode.FLOW_CONTROL_ERROR,
assertThrows(
dev.relism.flash.http2.Http2StreamException.class,
() ->
controller.receiveStreamBytes(
stream, Http2Limits.INITIAL_WINDOW_SIZE_LOCAL + 1))
.errorCode());
}
@Test
void sendReservationHonoursBothWindowsAndRejectsOverflow() {
Http2FlowController controller = new Http2FlowController((streamId, increment) -> {});
Http2Stream stream = new Http2StreamTable(1).acquire(1);
assertEquals(65_535, controller.reserveSend(stream, 100_000));
assertEquals(0, controller.reserveSend(stream, 1));
controller.increaseConnectionSendWindow(Integer.MAX_VALUE);
assertSame(
Http2Exception.FLOW_CONTROL_ERROR,
assertThrows(Http2Exception.class, () -> controller.increaseConnectionSendWindow(1)));
}
@Test
void emptyDataFrameCounterCrossesTheConfiguredLimitDeterministically() {
Http2Stream stream = new Http2StreamTable(1).acquire(1);
for (int i = 1; i <= Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM; i++) {
assertEquals(i, stream.incrementEmptyDataFrames());
}
assertEquals(
Http2Limits.MAX_EMPTY_DATA_FRAMES_PER_STREAM + 1, stream.incrementEmptyDataFrames());
stream.resetEmptyDataFrames();
assertEquals(1, stream.incrementEmptyDataFrames());
}
}