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