From c020496f70fe7ecc42361b4542d87903005969ce Mon Sep 17 00:00:00 2001 From: Daniel Kec Date: Tue, 26 Nov 2024 17:17:33 +0100 Subject: [PATCH] Http/2 revamp (#9520) 9273 Http/2 revamp * Add h2spec test * Consuming request trailers * Larger frame splitting fix * Flow control update timeout * Streamed payload larger than content length discovery Signed-off-by: Daniel Kec --- .../http/http2/Http2ConnectionWriter.java | 53 +--- .../io/helidon/http/http2/WindowSizeImpl.java | 56 +++- http/tests/media/multipart/pom.xml | 4 + .../test/resources/logging-test.properties | 4 +- tests/integration/h2spec/Dockerfile | 39 +++ tests/integration/h2spec/pom.xml | 133 ++++++++ .../h2spec/src/test/java/H2SpecIT.java | 154 ++++++++++ tests/integration/pom.xml | 1 + .../Http2ClientProtocolConfigBlueprint.java | 4 +- .../webserver/http2/Http2ConfigBlueprint.java | 7 +- .../webserver/http2/Http2Connection.java | 14 +- .../webserver/http2/Http2ServerResponse.java | 4 + .../webserver/http2/Http2ServerStream.java | 152 ++++++---- .../junit5/http2/Http2ServerExtension.java | 44 ++- .../testing/junit5/http2/Http2TestClient.java | 49 +++ .../junit5/http2/Http2TestConnection.java | 283 ++++++++++++++++++ webserver/tests/http2/pom.xml | 10 + .../tests/http2/ContentLengthTest.java | 185 ++++++++++++ .../test/resources/logging-test.properties | 6 +- 19 files changed, 1076 insertions(+), 126 deletions(-) create mode 100644 tests/integration/h2spec/Dockerfile create mode 100644 tests/integration/h2spec/pom.xml create mode 100644 tests/integration/h2spec/src/test/java/H2SpecIT.java create mode 100644 webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestClient.java create mode 100644 webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestConnection.java create mode 100644 webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/ContentLengthTest.java diff --git a/http/http2/src/main/java/io/helidon/http/http2/Http2ConnectionWriter.java b/http/http2/src/main/java/io/helidon/http/http2/Http2ConnectionWriter.java index a52f3cc3b6b..147fa6bbdde 100755 --- a/http/http2/src/main/java/io/helidon/http/http2/Http2ConnectionWriter.java +++ b/http/http2/src/main/java/io/helidon/http/http2/Http2ConnectionWriter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -30,7 +30,6 @@ public class Http2ConnectionWriter implements Http2StreamWriter { private final DataWriter writer; - // todo replace with prioritized lock (stream priority + connection writes have highest prio) private final Lock streamLock = new ReentrantLock(true); private final SocketContext ctx; private final Http2FrameListener listener; @@ -143,24 +142,14 @@ public int writeHeaders(Http2Headers headers, Http2Flag.HeaderFlags flags, Http2FrameData dataFrame, FlowControl.Outbound flowControl) { - // this is executing in the thread of the stream - // we must enforce parallelism of exactly 1, to make sure the dynamic table is updated - // and then immediately written - - lock(); - try { - int bytesWritten = 0; - - bytesWritten += writeHeaders(headers, streamId, flags, flowControl); - - writeData(dataFrame, flowControl); - bytesWritten += Http2FrameHeader.LENGTH; - bytesWritten += dataFrame.header().length(); - - return bytesWritten; - } finally { - streamLock.unlock(); - } + // Executed on stream thread + int bytesWritten = 0; + bytesWritten += writeHeaders(headers, streamId, flags, flowControl); + writeData(dataFrame, flowControl); + bytesWritten += Http2FrameHeader.LENGTH; + bytesWritten += dataFrame.header().length(); + + return bytesWritten; } /** @@ -227,32 +216,10 @@ private void splitAndWrite(Http2FrameData frame, FlowControl.Outbound flowContro } else if (splitFrames.length == 2) { // write send-able part and block until window update with the rest lockedWrite(splitFrames[0]); - flowControl.decrementWindowSize(currFrame.header().length()); + flowControl.decrementWindowSize(splitFrames[0].header().length()); flowControl.blockTillUpdate(); currFrame = splitFrames[1]; } } } - - // TODO use for fastpath - // private void noLockWrite(Http2FrameData... frames) { - // List toWrite = new LinkedList<>(); - // - // for (Http2FrameData frame : frames) { - // BufferData headerData = frame.header().write(); - // - // listener.frameHeader(ctx, frame.header()); - // listener.frameHeader(ctx, headerData); - // - // toWrite.add(headerData); - // - // BufferData data = frame.data(); - // - // if (data.available() != 0) { - // toWrite.add(data); - // } - // } - // - // writer.write(toWrite.toArray(new BufferData[0])); - // } } diff --git a/http/http2/src/main/java/io/helidon/http/http2/WindowSizeImpl.java b/http/http2/src/main/java/io/helidon/http/http2/WindowSizeImpl.java index f5107d7b375..5bb8a648054 100644 --- a/http/http2/src/main/java/io/helidon/http/http2/WindowSizeImpl.java +++ b/http/http2/src/main/java/io/helidon/http/http2/WindowSizeImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2023 Oracle and/or its affiliates. + * Copyright (c) 2022, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,12 +15,9 @@ */ package io.helidon.http.http2; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; +import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; -import java.util.concurrent.TimeoutException; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; import java.util.function.BiConsumer; import static java.lang.System.Logger.Level.DEBUG; @@ -124,7 +121,9 @@ public long incrementWindowSize(int increment) { */ static final class Outbound extends WindowSizeImpl implements WindowSize.Outbound { - private final AtomicReference> updated = new AtomicReference<>(new CompletableFuture<>()); + private static final int BACKOFF_MIN = 50; + private static final int BACKOFF_MAX = 5000; + private final Semaphore updatedSemaphore = new Semaphore(1); private final ConnectionFlowControl.Type type; private final int streamId; private final long timeoutMillis; @@ -146,21 +145,52 @@ public long incrementWindowSize(int increment) { return remaining; } + @Override + public void resetWindowSize(int size) { + super.resetWindowSize(size); + triggerUpdate(); + } + + @Override + public int decrementWindowSize(int decrement) { + int n = super.decrementWindowSize(decrement); + triggerUpdate(); + return n; + } + @Override public void triggerUpdate() { - updated.getAndSet(new CompletableFuture<>()).complete(null); + updatedSemaphore.release(); } @Override public void blockTillUpdate() { + var startTime = System.currentTimeMillis(); + int backoff = BACKOFF_MIN; while (getRemainingWindowSize() < 1) { try { - updated.get().get(timeoutMillis, TimeUnit.MILLISECONDS); - } catch (InterruptedException | ExecutionException | TimeoutException e) { - if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { - LOGGER_OUTBOUND.log(DEBUG, - String.format("%s OFC STR %d: Window depleted, waiting for update.", type, streamId)); - } + updatedSemaphore.drainPermits(); + var ignored = updatedSemaphore.tryAcquire(backoff, TimeUnit.MILLISECONDS); + // linear deterministic backoff + backoff = Math.min(backoff * 2, BACKOFF_MAX); + } catch (InterruptedException e) { + debugLog("%s OFC STR %d: Window depleted, waiting for update interrupted.", e); + throw new Http2Exception(Http2ErrorCode.FLOW_CONTROL, "Flow control update wait interrupted."); + } + if (System.currentTimeMillis() - startTime > timeoutMillis) { + debugLog("%s OFC STR %d: Window depleted, waiting for update time-out.", null); + throw new Http2Exception(Http2ErrorCode.FLOW_CONTROL, "Flow control update wait time-out."); + } + debugLog("%s OFC STR %d: Window depleted, waiting for update.", null); + } + } + + private void debugLog(String message, Exception e) { + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + if (e != null) { + LOGGER_OUTBOUND.log(DEBUG, String.format(message, type, streamId), e); + } else { + LOGGER_OUTBOUND.log(DEBUG, String.format(message, type, streamId)); } } } diff --git a/http/tests/media/multipart/pom.xml b/http/tests/media/multipart/pom.xml index c5b784d9085..0794c682313 100644 --- a/http/tests/media/multipart/pom.xml +++ b/http/tests/media/multipart/pom.xml @@ -36,6 +36,10 @@ io.helidon.http.media helidon-http-media-multipart + + io.helidon.logging + helidon-logging-jul + io.helidon.webserver.testing.junit5 helidon-webserver-testing-junit5 diff --git a/http/tests/media/multipart/src/test/resources/logging-test.properties b/http/tests/media/multipart/src/test/resources/logging-test.properties index 4cb5c0b4f2f..90874f99c69 100644 --- a/http/tests/media/multipart/src/test/resources/logging-test.properties +++ b/http/tests/media/multipart/src/test/resources/logging-test.properties @@ -1,5 +1,5 @@ # -# Copyright (c) 2022, 2023 Oracle and/or its affiliates. +# Copyright (c) 2022, 2024 Oracle and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,7 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. # -handlers=java.util.logging.ConsoleHandler +handlers=io.helidon.logging.jul.HelidonConsoleHandler java.util.logging.ConsoleHandler.level=FINEST java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter java.util.logging.SimpleFormatter.format=%1$tH:%1$tM:%1$tS %4$s %3$s %5$s%6$s%n diff --git a/tests/integration/h2spec/Dockerfile b/tests/integration/h2spec/Dockerfile new file mode 100644 index 00000000000..757b78d8298 --- /dev/null +++ b/tests/integration/h2spec/Dockerfile @@ -0,0 +1,39 @@ +# +# Copyright (c) 2024 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +FROM container-registry.oracle.com/os/oraclelinux:9-slim AS build +ENV GO111MODULE=on +ENV GOPROXY=https://proxy.golang.org +ENV CGO_ENABLED=0 +ENV VERSION=2.6.1-SNAPSHOT +ENV COMMIT=af83a65f0b6273ef38bf778d400d98892e7653d8 + +RUN microdnf install go-toolset git -y + +WORKDIR /workspace +RUN git clone https://github.com/summerwind/h2spec.git + +WORKDIR /workspace/h2spec +RUN git checkout ${COMMIT} +RUN go build -ldflags "-X main.VERSION=${VERSION} -X main.COMMIT=${COMMIT}" ./cmd/h2spec + +FROM container-registry.oracle.com/os/oraclelinux:9-slim +ARG PORT=8080 +ARG HOST=localhost +ENV PORT=${PORT} +ENV HOST=${HOST} +COPY --from=build /workspace/h2spec/h2spec /usr/local/bin/h2spec +CMD ["/usr/local/bin/h2spec", "-h", "${HOST}", "-p", "${PORT}"] \ No newline at end of file diff --git a/tests/integration/h2spec/pom.xml b/tests/integration/h2spec/pom.xml new file mode 100644 index 00000000000..4338985156c --- /dev/null +++ b/tests/integration/h2spec/pom.xml @@ -0,0 +1,133 @@ + + + + + 4.0.0 + + io.helidon.applications + helidon-mp + 4.2.0-SNAPSHOT + ../../../applications/mp/pom.xml + + io.helidon.tests.integration.h2spec + helidon-tests-integration-h2spec + Helidon Tests Integration Http/2 h2spec + + + io.helidon.webserver.h2spec.Main + true + + + + + io.helidon.webserver + helidon-webserver + + + io.helidon.webserver + helidon-webserver-http2 + + + io.helidon.config + helidon-config-yaml + + + io.helidon.logging + helidon-logging-jul + + + org.slf4j + slf4j-jdk14 + + + org.junit.jupiter + junit-jupiter-api + test + + + org.junit.jupiter + junit-jupiter-params + test + + + org.hamcrest + hamcrest-all + test + + + + org.testcontainers + junit-jupiter + test + + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5 + test + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-libs + + + + + org.apache.maven.plugins + maven-surefire-plugin + + + **/*IT + + + + ${project.build.outputDirectory}/logging.properties + + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + + + ${project.build.outputDirectory}/logging.properties + + + ${redirectTestOutputToFile} + + + + + integration-test + verify + + + + + + + diff --git a/tests/integration/h2spec/src/test/java/H2SpecIT.java b/tests/integration/h2spec/src/test/java/H2SpecIT.java new file mode 100644 index 00000000000..8c8b0d93e78 --- /dev/null +++ b/tests/integration/h2spec/src/test/java/H2SpecIT.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import java.io.InputStream; +import java.nio.file.Path; +import java.time.Duration; +import java.util.ArrayList; +import java.util.stream.Stream; + +import javax.xml.parsers.DocumentBuilderFactory; + +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http2.Http2Config; +import io.helidon.webserver.http2.Http2Route; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.images.PullPolicy; +import org.testcontainers.images.builder.ImageFromDockerfile; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.w3c.dom.Element; +import org.w3c.dom.Node; + +import static io.helidon.http.Method.GET; +import static io.helidon.http.Method.POST; + +@Testcontainers(disabledWithoutDocker = true) +class H2SpecIT { + + private static final Logger LOGGER = LoggerFactory.getLogger(H2SpecIT.class); + + @ParameterizedTest(name = "{0}: {1}") + @MethodSource("runH2Spec") + void h2spec(String caseName, String desc, String id, String err, String skipped) { + LOGGER.info("{}: \n - {} \nID: {}", caseName, desc, id); + if (err != null) { + Assertions.fail(err); + } + if (skipped != null) { + Assumptions.abort(skipped); + } + } + + private static Stream runH2Spec() { + + HttpRouting.Builder router = HttpRouting.builder(); + router.route(Http2Route.route(GET, "/", (req, res) -> { + res.send("Hi Frank!"); + })); + + router.route(Http2Route.route(POST, "/", (req, res) -> { + req.content().consume(); + res.send("pong"); + })); + + WebServer server = WebServer.builder() + .addProtocol(Http2Config.builder() + .sendErrorDetails(true) + // 5.1.2 https://github.com/summerwind/h2spec/issues/136 + .maxConcurrentStreams(10) + .build()) + .routing(router) + .build(); + + int port = server.start().port(); + + try (var cont = new GenericContainer<>( + new ImageFromDockerfile().withDockerfile(Path.of("./Dockerfile"))) + .withAccessToHost(true) + .withImagePullPolicy(PullPolicy.ageBased(Duration.ofDays(365))) + .withLogConsumer(outputFrame -> LOGGER.info(outputFrame.getUtf8StringWithoutLineEnding())) + .waitingFor(Wait.forLogMessage(".*Finished in.*", 1))) { + + org.testcontainers.Testcontainers.exposeHostPorts(port); + cont.withCommand("/usr/local/bin/h2spec " + + "-h host.testcontainers.internal " + + "--junit-report junit-report.xml " + // h2spec creates dummy test headers x-dummy0 with generated content of length configured by parameter --max-header-length + // default value is 4000 to fit just under the default protocol max table size(4096) with margin of 96 + // as we are using custom host name 'host.testcontainers.internal' authority header is longer than usual 'localhost' + // also random port can have more chars than the usual 8080 + + "--max-header-length " + (4000 + - ("host.testcontainers.internal".length() - "localhost".length()) + - (String.valueOf(port).length() - "8080".length())) + + " -p " + port) + .withStartupAttempts(1) + .start(); + + cont.copyFileFromContainer("/junit-report.xml", "./target/h2spec-report.xml"); + return cont.copyFileFromContainer("/junit-report.xml", H2SpecIT::parseReport); + } finally { + server.stop(); + } + + } + + private static Stream parseReport(InputStream is) throws Exception { + var a = new ArrayList(); + var dom = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(is); + var suitList = dom.getDocumentElement() + .getElementsByTagName("testsuite"); + + for (int i = 0; i < suitList.getLength(); i++) { + var suitEl = (Element) suitList.item(i); + var suitName = suitEl.getAttribute("name"); + var caseList = suitEl.getElementsByTagName("testcase"); + for (int j = 0; j < caseList.getLength(); j++) { + var caseEl = (Element) caseList.item(j); + var className = caseEl.getAttribute("classname"); + var id = caseEl.getAttribute("package"); + a.add(Arguments.of(suitName, + className, + id, + getChildElValue(caseEl, "error", "failure"), + getChildElValue(caseEl, "skipped"))); + } + } + return a.stream(); + } + + private static String getChildElValue(Element caseEl, String... nodeNames) { + for (int k = 0; k < caseEl.getChildNodes().getLength(); k++) { + Node node = caseEl.getChildNodes().item(k); + for (var nodeName : nodeNames) { + if (nodeName.equals(node.getNodeName())) { + return node.getTextContent(); + } + } + } + return null; + } + +} diff --git a/tests/integration/pom.xml b/tests/integration/pom.xml index aaf2d127b53..c342cceedb3 100644 --- a/tests/integration/pom.xml +++ b/tests/integration/pom.xml @@ -66,6 +66,7 @@ vault zipkin-mp-2.2 tls-revocation-config + h2spec diff --git a/webclient/http2/src/main/java/io/helidon/webclient/http2/Http2ClientProtocolConfigBlueprint.java b/webclient/http2/src/main/java/io/helidon/webclient/http2/Http2ClientProtocolConfigBlueprint.java index 76cd212ed35..c2d25e0c7d6 100644 --- a/webclient/http2/src/main/java/io/helidon/webclient/http2/Http2ClientProtocolConfigBlueprint.java +++ b/webclient/http2/src/main/java/io/helidon/webclient/http2/Http2ClientProtocolConfigBlueprint.java @@ -89,12 +89,12 @@ default String type() { int initialWindowSize(); /** - * Timeout for blocking between windows size check iterations. + * Timeout for blocking while waiting for window update when window is depleted. * * @return timeout */ @Option.Configured - @Option.Default("PT0.1S") + @Option.Default("PT15S") Duration flowControlBlockTimeout(); /** diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ConfigBlueprint.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ConfigBlueprint.java index d1fe59ed0c0..e17a8de8dfe 100644 --- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ConfigBlueprint.java +++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ConfigBlueprint.java @@ -82,9 +82,8 @@ interface Http2ConfigBlueprint extends ProtocolConfig { /** * Outbound flow control blocking timeout configured as {@link java.time.Duration} * or text in ISO-8601 format. - * Blocking timeout defines an interval to wait for the outbound window size changes(incoming window updates) - * before the next blocking iteration. - * Default value is {@code PT0.1S}. + * Blocking timeout defines an interval to wait for the outbound window size changes(incoming window updates). + * Default value is {@code PT15S}. * * * @@ -97,7 +96,7 @@ interface Http2ConfigBlueprint extends ProtocolConfig { * @see ISO_8601 Durations */ @Option.Configured - @Option.Default("PT0.1S") + @Option.Default("PT15S") Duration flowControlTimeout(); /** diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Connection.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Connection.java index 295672a6130..c599a33847d 100644 --- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Connection.java +++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2Connection.java @@ -617,7 +617,7 @@ private void doHeaders(Limit limit) { int streamId = frameHeader.streamId(); StreamContext streamContext = stream(streamId); - streamContext.stream().checkHeadersReceivable(); + boolean trailers = streamContext.stream().checkHeadersReceivable(); // first frame, expecting continuation if (frameHeader.type() == Http2FrameType.HEADERS && !frameHeader.flags(Http2FrameTypes.HEADERS).endOfHeaders()) { @@ -672,6 +672,18 @@ private void doHeaders(Limit limit) { } receiveFrameListener.headers(ctx, streamId, headers); + + + if (trailers) { + if (!endOfStream) { + throw new Http2Exception(Http2ErrorCode.PROTOCOL, "Received trailers without endOfStream flag " + streamId); + } + stream.closeFromRemote(); + state = State.READ_FRAME; + // Client's trailers are ignored, we don't provide any API to consume them yet + return; + } + headers.validateRequest(); String path = headers.path(); Method method = headers.method(); diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java index 019ec79515d..374cdb5ba28 100644 --- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java +++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerResponse.java @@ -30,7 +30,9 @@ import io.helidon.http.ServerResponseHeaders; import io.helidon.http.ServerResponseTrailers; import io.helidon.http.Status; +import io.helidon.http.http2.Http2Exception; import io.helidon.http.http2.Http2Headers; +import io.helidon.webserver.CloseConnectionException; import io.helidon.webserver.ConnectionContext; import io.helidon.webserver.ServerConnectionException; import io.helidon.webserver.http.ServerResponseBase; @@ -121,6 +123,8 @@ public void send(byte[] entityBytes) { } afterSend(); + } catch (Http2Exception e) { + throw new CloseConnectionException("Failed writing entity", e); } catch (UncheckedIOException e) { throw new ServerConnectionException("Failed writing entity", e); } diff --git a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerStream.java b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerStream.java index 50333162e29..987fb8c79a0 100644 --- a/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerStream.java +++ b/webserver/http2/src/main/java/io/helidon/webserver/http2/Http2ServerStream.java @@ -22,6 +22,7 @@ import java.util.Set; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.Semaphore; +import java.util.concurrent.atomic.AtomicReference; import io.helidon.common.buffers.BufferData; import io.helidon.common.concurrency.limits.FixedLimit; @@ -93,13 +94,12 @@ class Http2ServerStream implements Runnable, Http2Stream { private final StreamFlowControl flowControl; private final Http2ConcurrentConnectionStreams streams; private final HttpRouting routing; - + private final AtomicReference writeState = new AtomicReference<>(WriteState.INIT); private boolean wasLastDataFrame = false; private volatile Http2Headers headers; private volatile Http2Priority priority; // used from this instance and from connection private volatile Http2StreamState state = Http2StreamState.IDLE; - private WriteState writeState = WriteState.INIT; private Http2SubProtocolSelector.SubProtocolHandler subProtocolHandler; private long expectedLength = -1; private HttpPrologue prologue; @@ -165,22 +165,25 @@ public void checkDataReceivable() throws Http2Exception { * Check if headers can be received on this stream. * This method is called from connection thread. * + * @return true if headers are receivable as trailers * @throws Http2Exception in case headers cannot be received. */ - public void checkHeadersReceivable() throws Http2Exception { + public boolean checkHeadersReceivable() throws Http2Exception { switch (state) { case IDLE: - // this is OK - break; + // headers + return false; + case OPEN: + // trailers + return true; case HALF_CLOSED_LOCAL: case HALF_CLOSED_REMOTE: case CLOSED: throw new Http2Exception(Http2ErrorCode.STREAM_CLOSED, "Stream " + streamId + " received headers when stream is " + state); - case OPEN: - throw new Http2Exception(Http2ErrorCode.PROTOCOL, "Received headers for open stream " + streamId); default: - throw new Http2Exception(Http2ErrorCode.INTERNAL, "Unknown stream state: " + streamId + ", state: " + state); + throw new Http2Exception(Http2ErrorCode.INTERNAL, + "Unknown stream state, streamId: " + streamId + ", state: " + state); } } @@ -192,7 +195,7 @@ public boolean rstStream(Http2RstStream rstStream) { + streamId + " in IDLE state"); } // TODO interrupt - boolean rapidReset = writeState == WriteState.INIT; + boolean rapidReset = writeState.get() == WriteState.INIT; this.state = Http2StreamState.CLOSED; return rapidReset; } @@ -228,14 +231,14 @@ public void windowUpdate(Http2WindowUpdate windowUpdate) { @Override public void headers(Http2Headers headers, boolean endOfStream) { this.headers = headers; - this.state = endOfStream ? Http2StreamState.HALF_CLOSED_REMOTE : Http2StreamState.OPEN; - if (state == Http2StreamState.HALF_CLOSED_REMOTE) { - try { - // we need to notify that there is no data coming - inboundData.put(TERMINATING_FRAME); - } catch (InterruptedException e) { - throw new Http2Exception(Http2ErrorCode.INTERNAL, "Interrupted", e); - } + if (endOfStream) { + closeFromRemote(); + } else { + this.state = Http2StreamState.OPEN; + } + Headers httpHeaders = headers.httpHeaders(); + if (httpHeaders.contains(HeaderNames.CONTENT_LENGTH)) { + this.expectedLength = httpHeaders.get(HeaderNames.CONTENT_LENGTH).get(long.class); } } @@ -243,9 +246,20 @@ public void headers(Http2Headers headers, boolean endOfStream) { public void data(Http2FrameHeader header, BufferData data, boolean endOfStream) { if (expectedLength != -1 && expectedLength < header.length()) { state = Http2StreamState.CLOSED; + writeState.updateAndGet(s -> s.checkAndMove(WriteState.END)); + streams.remove(this.streamId); Http2RstStream rst = new Http2RstStream(Http2ErrorCode.PROTOCOL); writer.write(rst.toFrameData(clientSettings, streamId, Http2Flag.NoFlags.create())); - return; + + try { + // we need to notify that there is no data coming + inboundData.put(TERMINATING_FRAME); + } catch (InterruptedException e) { + throw new Http2Exception(Http2ErrorCode.INTERNAL, "Interrupted", e); + } + + throw new Http2Exception(Http2ErrorCode.ENHANCE_YOUR_CALM, + "Request data length doesn't correspond to the content-length header."); } if (expectedLength != -1) { expectedLength -= header.length(); @@ -330,11 +344,28 @@ public void run() { } } - int writeHeaders(Http2Headers http2Headers, boolean endOfStream) { - writeState = writeState.checkAndMove(WriteState.HEADERS_SENT); + void closeFromRemote() { + this.state = Http2StreamState.HALF_CLOSED_REMOTE; + try { + // we need to notify that there is no data coming + inboundData.put(TERMINATING_FRAME); + } catch (InterruptedException e) { + throw new Http2Exception(Http2ErrorCode.INTERNAL, "Interrupted", e); + } + } + + int writeHeaders(Http2Headers http2Headers, final boolean endOfStream) { + writeState.updateAndGet(s -> { + if (endOfStream) { + return s.checkAndMove(WriteState.HEADERS_SENT) + .checkAndMove(WriteState.END); + } + return s.checkAndMove(WriteState.HEADERS_SENT); + }); + Http2Flag.HeaderFlags flags; + if (endOfStream) { - writeState = writeState.checkAndMove(WriteState.END); streams.remove(this.streamId); flags = Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS | Http2Flag.END_OF_STREAM); } else { @@ -349,12 +380,9 @@ int writeHeaders(Http2Headers http2Headers, boolean endOfStream) { } int writeHeadersWithData(Http2Headers http2Headers, int contentLength, BufferData bufferData, boolean endOfStream) { - writeState = writeState.checkAndMove(WriteState.HEADERS_SENT); - writeState = writeState.checkAndMove(WriteState.DATA_SENT); - if (endOfStream) { - writeState = writeState.checkAndMove(WriteState.END); - streams.remove(this.streamId); - } + writeState.updateAndGet(s -> s + .checkAndMove(WriteState.HEADERS_SENT) + .checkAndMove(WriteState.DATA_SENT)); Http2FrameData frameData = new Http2FrameData(Http2FrameHeader.create(contentLength, @@ -369,13 +397,24 @@ int writeHeadersWithData(Http2Headers http2Headers, int contentLength, BufferDat flowControl.outbound()); } catch (UncheckedIOException e) { throw new ServerConnectionException("Failed to write headers", e); + } finally { + if (endOfStream) { + writeState.updateAndGet(s -> s.checkAndMove(WriteState.END)); + streams.remove(this.streamId); + } } } - int writeData(BufferData bufferData, boolean endOfStream) { - writeState = writeState.checkAndMove(WriteState.DATA_SENT); + int writeData(BufferData bufferData, final boolean endOfStream) { + writeState.updateAndGet(s -> { + if (endOfStream) { + return s.checkAndMove(WriteState.DATA_SENT) + .checkAndMove(WriteState.END); + } + return s.checkAndMove(WriteState.DATA_SENT); + }); + if (endOfStream) { - writeState = writeState.checkAndMove(WriteState.END); streams.remove(this.streamId); } @@ -395,30 +434,33 @@ int writeData(BufferData bufferData, boolean endOfStream) { } int writeTrailers(Http2Headers http2trailers) { - writeState = writeState.checkAndMove(WriteState.TRAILERS_SENT); + writeState.updateAndGet(s -> s.checkAndMove(WriteState.TRAILERS_SENT)); streams.remove(this.streamId); try { - return writer.writeHeaders(http2trailers, - streamId, - Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS | Http2Flag.END_OF_STREAM), - flowControl.outbound()); + return writer.writeHeaders(http2trailers, + streamId, + Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS | Http2Flag.END_OF_STREAM), + flowControl.outbound()); } catch (UncheckedIOException e) { throw new ServerConnectionException("Failed to write trailers", e); } } void write100Continue() { - if (writeState == WriteState.EXPECTED_100) { - writeState = writeState.checkAndMove(WriteState.CONTINUE_100_SENT); - + if (WriteState.EXPECTED_100 == writeState.getAndUpdate(s -> { + if (WriteState.EXPECTED_100 == s) { + return s.checkAndMove(WriteState.CONTINUE_100_SENT); + } + return s; + })) { Header status = HeaderValues.createCached(Http2Headers.STATUS_NAME, 100); Http2Headers http2Headers = Http2Headers.create(WritableHeaders.create().add(status)); try { writer.writeHeaders(http2Headers, - streamId, - Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS), - flowControl.outbound()); + streamId, + Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS), + flowControl.outbound()); } catch (UncheckedIOException e) { throw new ServerConnectionException("Failed to write 100-Continue", e); } @@ -454,17 +496,17 @@ private BufferData readEntityFromPipeline() { if (frame.header().flags(Http2FrameTypes.DATA).endOfStream()) { wasLastDataFrame = true; + if (state == Http2StreamState.CLOSED) { + throw RequestException.builder().message("Stream is closed.").build(); + } } return frame.data(); } private void handle() { Headers httpHeaders = headers.httpHeaders(); - if (httpHeaders.contains(HeaderNames.CONTENT_LENGTH)) { - this.expectedLength = httpHeaders.get(HeaderNames.CONTENT_LENGTH).get(long.class); - } if (headers.httpHeaders().contains(HeaderValues.EXPECT_100)) { - writeState = writeState.checkAndMove(WriteState.EXPECTED_100); + writeState.updateAndGet(s -> s.checkAndMove(WriteState.EXPECTED_100)); } subProtocolHandler = null; @@ -574,20 +616,18 @@ private void handle() { } } - private record DataFrame(Http2FrameHeader header, BufferData data) { } - private enum WriteState { END, TRAILERS_SENT(END), DATA_SENT(TRAILERS_SENT, END), HEADERS_SENT(DATA_SENT, TRAILERS_SENT, END), - CONTINUE_100_SENT(HEADERS_SENT), - EXPECTED_100(CONTINUE_100_SENT, HEADERS_SENT), - INIT(EXPECTED_100, HEADERS_SENT); + CONTINUE_100_SENT(HEADERS_SENT, END), + EXPECTED_100(CONTINUE_100_SENT, HEADERS_SENT, END), + INIT(EXPECTED_100, HEADERS_SENT, END); private final Set allowedTransitions; - WriteState(WriteState... allowedTransitions){ + WriteState(WriteState... allowedTransitions) { this.allowedTransitions = Set.of(allowedTransitions); } @@ -595,7 +635,15 @@ WriteState checkAndMove(WriteState newState) { if (this == newState || allowedTransitions.contains(newState)) { return newState; } - throw new IllegalStateException("Transition from " + this + " to " + newState + " is not allowed!"); + + IllegalStateException badTransitionException = + new IllegalStateException("Transition from " + this + " to " + newState + " is not allowed!"); + if (this == END) { + throw new IllegalStateException("Stream is already closed.", badTransitionException); + } + throw badTransitionException; } } + + private record DataFrame(Http2FrameHeader header, BufferData data) { } } diff --git a/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2ServerExtension.java b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2ServerExtension.java index 50477caa3c7..3e5ac072431 100644 --- a/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2ServerExtension.java +++ b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2ServerExtension.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Oracle and/or its affiliates. + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,9 @@ package io.helidon.webserver.testing.junit5.http2; +import java.net.URI; +import java.util.Set; + import io.helidon.webclient.http2.Http2Client; import io.helidon.webserver.WebServer; import io.helidon.webserver.testing.junit5.Junit5Util; @@ -30,18 +33,15 @@ * artifacts, such as {@link io.helidon.webclient.http2.Http2Client} in Helidon integration tests. */ public class Http2ServerExtension implements ServerJunitExtension { + + private static final Set> SUPPORTED = Set.of(Http2Client.class, Http2TestClient.class); + /** * Required constructor for {@link java.util.ServiceLoader}. */ public Http2ServerExtension() { } - @Override - public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) - throws ParameterResolutionException { - return Http2Client.class.equals(parameterContext.getParameter().getType()); - } - @Override public Object resolveParameter(ParameterContext parameterContext, ExtensionContext extensionContext, @@ -49,11 +49,39 @@ public Object resolveParameter(ParameterContext parameterContext, WebServer server) { String socketName = Junit5Util.socketName(parameterContext.getParameter()); + URI uri = URI.create("http://localhost:" + server.port(socketName)); + if (Http2Client.class.equals(parameterType)) { return Http2Client.builder() - .baseUri("http://localhost:" + server.port(socketName)) + .baseUri(uri) .build(); } + + if (Http2TestClient.class.equals(parameterType)) { + Http2TestClient client = new Http2TestClient(uri); + extensionContext + .getStore(ExtensionContext.Namespace.GLOBAL) + .put(Http2TestClient.class.getName(), client); + return client; + } + throw new ParameterResolutionException("HTTP/2 extension only supports Http2Client parameter type"); } + + @Override + public void afterEach(ExtensionContext context) { + ServerJunitExtension.super.afterEach(context); + Http2TestClient client = (Http2TestClient) context + .getStore(ExtensionContext.Namespace.GLOBAL) + .remove(Http2TestClient.class.getName()); + if (client != null) { + client.close(); + } + } + + @Override + public boolean supportsParameter(ParameterContext parameterContext, ExtensionContext extensionContext) + throws ParameterResolutionException { + return SUPPORTED.contains(parameterContext.getParameter().getType()); + } } diff --git a/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestClient.java b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestClient.java new file mode 100644 index 00000000000..a54683650b4 --- /dev/null +++ b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestClient.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.testing.junit5.http2; + +import java.net.URI; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Http/2 low-level testing client. + */ +public class Http2TestClient implements AutoCloseable { + + private final URI uri; + private final ConcurrentLinkedQueue testConnections = new ConcurrentLinkedQueue<>(); + + Http2TestClient(URI uri) { + this.uri = uri; + } + + /** + * Create new low-level http/2 connection. + * @return new connection + */ + public Http2TestConnection createConnection() { + var testConnection = new Http2TestConnection(uri); + testConnections.add(testConnection); + return testConnection; + } + + @Override + public void close() { + testConnections.forEach(Http2TestConnection::close); + } +} + diff --git a/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestConnection.java b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestConnection.java new file mode 100644 index 00000000000..886a70b89db --- /dev/null +++ b/webserver/testing/junit5/http2/src/main/java/io/helidon/webserver/testing/junit5/http2/Http2TestConnection.java @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.testing.junit5.http2; + +import java.io.UncheckedIOException; +import java.net.URI; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ArrayBlockingQueue; +import java.util.concurrent.TimeUnit; + +import io.helidon.common.buffers.BufferData; +import io.helidon.common.buffers.DataReader; +import io.helidon.common.tls.Tls; +import io.helidon.http.Method; +import io.helidon.http.WritableHeaders; +import io.helidon.http.http2.FlowControl; +import io.helidon.http.http2.Http2ConnectionWriter; +import io.helidon.http.http2.Http2ErrorCode; +import io.helidon.http.http2.Http2Flag; +import io.helidon.http.http2.Http2FrameData; +import io.helidon.http.http2.Http2FrameHeader; +import io.helidon.http.http2.Http2FrameType; +import io.helidon.http.http2.Http2FrameTypes; +import io.helidon.http.http2.Http2GoAway; +import io.helidon.http.http2.Http2Headers; +import io.helidon.http.http2.Http2HuffmanDecoder; +import io.helidon.http.http2.Http2RstStream; +import io.helidon.http.http2.Http2Setting; +import io.helidon.http.http2.Http2Settings; +import io.helidon.http.http2.Http2Util; +import io.helidon.http.http2.Http2WindowUpdate; +import io.helidon.webclient.api.ClientUri; +import io.helidon.webclient.api.ConnectionKey; +import io.helidon.webclient.api.DefaultDnsResolver; +import io.helidon.webclient.api.DnsAddressLookup; +import io.helidon.webclient.api.Proxy; +import io.helidon.webclient.api.TcpClientConnection; +import io.helidon.webclient.api.WebClient; + +import org.hamcrest.Matchers; + +import static java.lang.System.Logger.Level.DEBUG; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +/** + * Http/2 low-level testing client connection. + */ +public class Http2TestConnection implements AutoCloseable { + + private static final System.Logger LOGGER = System.getLogger(Http2TestConnection.class.getName()); + private static final int FRAME_HEADER_LENGTH = 9; + + private final TcpClientConnection conn; + private final Http2ConnectionWriter dataWriter; + private final DataReader reader; + private final ArrayBlockingQueue readQueue = new ArrayBlockingQueue<>(100); + private final Thread readThread; + private final ClientUri clientUri; + private final Http2Headers.DynamicTable requestDynamicTable = + Http2Headers.DynamicTable.create(Http2Setting.HEADER_TABLE_SIZE.defaultValue()); + private final Http2HuffmanDecoder requestHuffman = Http2HuffmanDecoder.create(); + + Http2TestConnection(URI uri) { + clientUri = ClientUri.create(uri); + ConnectionKey connectionKey = new ConnectionKey(clientUri.scheme(), + clientUri.host(), + clientUri.port(), + Duration.ZERO, + Tls.builder().enabled(false).build(), + DefaultDnsResolver.create(), + DnsAddressLookup.defaultLookup(), + Proxy.noProxy()); + + conn = TcpClientConnection.create(WebClient.builder() + .baseUri(clientUri) + .build(), + connectionKey, + List.of(), + connection -> false, + connection -> { + }) + .connect(); + + conn.writer().writeNow(Http2Util.prefaceData()); + reader = conn.reader(); + dataWriter = new Http2ConnectionWriter(conn.helidonSocket(), conn.writer(), List.of()); + readThread = Thread + .ofVirtual() + .start(() -> { + try { + for (;;) { + if (Thread.interrupted()) { + return; + } + BufferData frameHeaderBuffer = reader.readBuffer(FRAME_HEADER_LENGTH); + Http2FrameHeader frameHeader = Http2FrameHeader.create(frameHeaderBuffer); + LOGGER.log(DEBUG, () -> "<-- " + frameHeader); + readQueue.add(new Http2FrameData(frameHeader, reader.readBuffer(frameHeader.length()))); + } + } catch (DataReader.InsufficientDataAvailableException | UncheckedIOException e) { + // closed connection + } + }); + + sendSettings(Http2Settings.builder() + .add(Http2Setting.INITIAL_WINDOW_SIZE, 65535L) + .add(Http2Setting.MAX_FRAME_SIZE, 16384L) + .add(Http2Setting.ENABLE_PUSH, false) + .build()); + } + + /** + * Send settings frame. + * + * @param http2Settings frame to send + * @return this connection + */ + public Http2TestConnection sendSettings(Http2Settings http2Settings) { + Http2Flag.SettingsFlags flags = Http2Flag.SettingsFlags.create(0); + Http2FrameData frameData = http2Settings.toFrameData(null, 0, flags); + writer().write(frameData); + return this; + } + + /** + * Return connection writer for direct frame sending. + * + * @return connection writer + */ + public Http2ConnectionWriter writer() { + return dataWriter; + } + + /** + * Send HTTP request with given stream id with single data frame created from supplied buffer data, + * dataframe has end of stream flag. + * + * @param streamId send request as given stream id + * @param method http method + * @param path context path + * @param headers http headers + * @param payload payload data which has to fit in single frame + * @return this connection + */ + public Http2TestConnection request(int streamId, Method method, String path, WritableHeaders headers, BufferData payload) { + Http2Headers h2Headers = Http2Headers.create(headers); + h2Headers.method(method); + h2Headers.path(path); + h2Headers.scheme(clientUri().scheme()); + + writer().writeHeaders(h2Headers, + streamId, + Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS), + FlowControl.Outbound.NOOP); + + Http2FrameData frameDataData = + new Http2FrameData(Http2FrameHeader.create(payload.available(), + Http2FrameTypes.DATA, + Http2Flag.DataFlags.create(Http2Flag.END_OF_STREAM), + streamId), + payload); + writer().writeData(frameDataData, FlowControl.Outbound.NOOP); + return this; + } + + /** + * Await next frame, blocks until next frame arrive. + * + * @param timeout timeout for blocking + * @return next frame in order of reading from socket + */ + public Http2FrameData awaitNextFrame(Duration timeout) { + try { + return readQueue.poll(timeout.toMillis(), TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + /** + * Wait for the next frame and assert its frame type to be RST_STREAM. + * @param streamId stream id asserted from retrieved RST_STREAM frame. + * @param timeout timeout for blocking + * @return the frame + */ + public Http2RstStream assertRstStream(int streamId, Duration timeout) { + Http2FrameData frame = assertNextFrame(Http2FrameType.RST_STREAM, timeout); + assertThat("Stream ID doesn't match.", frame.header().streamId(), Matchers.equalTo(streamId)); + return Http2RstStream.create(frame.data()); + } + + /** + * Wait for the next frame and assert its frame type to be SETTINGS. + * @param timeout timeout for blocking + * @return the frame + */ + public Http2Settings assertSettings(Duration timeout) { + Http2FrameData frame = assertNextFrame(Http2FrameType.SETTINGS, timeout); + return Http2Settings.create(frame.data()); + } + + /** + * Wait for the next frame and assert its frame type to be WINDOWS_UPDATE. + * @param streamId stream id asserted from retrieved WINDOWS_UPDATE frame. + * @param timeout timeout for blocking + * @return the frame + */ + public Http2WindowUpdate assertWindowsUpdate(int streamId, Duration timeout) { + Http2FrameData frame = assertNextFrame(Http2FrameType.WINDOW_UPDATE, timeout); + assertThat(frame.header().streamId(), Matchers.equalTo(streamId)); + return Http2WindowUpdate.create(frame.data()); + } + + /** + * Wait for the next frame and assert its frame type to be HEADERS. + * @param streamId stream id asserted from retrieved HEADERS frame. + * @param timeout timeout for blocking + * @return the frame + */ + public Http2Headers assertHeaders(int streamId, Duration timeout) { + Http2FrameData frame = assertNextFrame(Http2FrameType.HEADERS, timeout); + assertThat(frame.header().streamId(), Matchers.equalTo(streamId)); + return Http2Headers.create(null, requestDynamicTable, requestHuffman, frame); + } + + /** + * Wait for the next frame and assert its frame type. + * @param frameType expected type of frame + * @param timeout timeout for blocking + * @return the frame + */ + public Http2FrameData assertNextFrame(Http2FrameType frameType, Duration timeout) { + Http2FrameData frame = awaitNextFrame(timeout); + assertThat(frame.header().type(), Matchers.equalTo(frameType)); + return frame; + } + + /** + * Wait for the next frame and assert its frame type to be GO_AWAY. + * @param errorCode expected error code + * @param message expected go away message + * @param timeout timeout for blocking + * @return the frame + */ + public Http2FrameData assertGoAway(Http2ErrorCode errorCode, String message, Duration timeout) { + Http2FrameData frame = assertNextFrame(Http2FrameType.GO_AWAY, timeout); + + Http2GoAway goAway = Http2GoAway.create(frame.data()); + assertThat(goAway.errorCode(), is(errorCode)); + assertThat(frame.data().readString(frame.data().available()), is(message)); + return frame; + } + + @Override + public void close() { + readThread.interrupt(); + conn.closeResource(); + } + + /** + * Client uri used for connection, derived from Helidon test server. + * @return client uri + */ + public ClientUri clientUri() { + return clientUri; + } +} diff --git a/webserver/tests/http2/pom.xml b/webserver/tests/http2/pom.xml index e86c0bec124..878419fa2ef 100644 --- a/webserver/tests/http2/pom.xml +++ b/webserver/tests/http2/pom.xml @@ -33,6 +33,11 @@ io.helidon.webserver helidon-webserver-http2 + + io.helidon.logging + helidon-logging-jul + test + io.helidon.webserver.testing.junit5 helidon-webserver-testing-junit5 @@ -63,5 +68,10 @@ hamcrest-all test + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5-http2 + test + diff --git a/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/ContentLengthTest.java b/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/ContentLengthTest.java new file mode 100644 index 00000000000..4b62564c405 --- /dev/null +++ b/webserver/tests/http2/src/test/java/io/helidon/webserver/tests/http2/ContentLengthTest.java @@ -0,0 +1,185 @@ +/* + * Copyright (c) 2023, 2024 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.webserver.tests.http2; + +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import io.helidon.common.buffers.BufferData; +import io.helidon.http.HeaderNames; +import io.helidon.http.RequestException; +import io.helidon.http.Status; +import io.helidon.http.WritableHeaders; +import io.helidon.http.http2.Http2ErrorCode; +import io.helidon.http.http2.Http2FrameType; +import io.helidon.http.http2.Http2Headers; +import io.helidon.logging.common.LogConfig; +import io.helidon.webserver.WebServerConfig; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http2.Http2Config; +import io.helidon.webserver.http2.Http2Route; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpRoute; +import io.helidon.webserver.testing.junit5.SetUpServer; +import io.helidon.webserver.testing.junit5.http2.Http2TestClient; +import io.helidon.webserver.testing.junit5.http2.Http2TestConnection; + +import org.hamcrest.Matchers; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static io.helidon.http.Method.POST; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertFalse; + +@ServerTest +class ContentLengthTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(100); + private static CompletableFuture consumeExceptionFuture = new CompletableFuture<>(); + private static CompletableFuture sendExceptionFuture = new CompletableFuture<>(); + + static { + LogConfig.configureRuntime(); + } + + @SetUpRoute + static void router(HttpRouting.Builder router) { + router.route(Http2Route.route(POST, "/", (req, res) -> { + try { + req.content().consume(); + } catch (Exception e) { + consumeExceptionFuture.complete(e); + } + try { + res.send("pong"); + } catch (Exception e) { + sendExceptionFuture.complete(e); + } + })); + } + + @SetUpServer + static void setup(WebServerConfig.Builder server) { + server.addProtocol(Http2Config.builder() + .sendErrorDetails(true) + .maxConcurrentStreams(5) + .build()); + } + + @BeforeEach + void beforeEach() { + consumeExceptionFuture = new CompletableFuture<>(); + sendExceptionFuture = new CompletableFuture<>(); + } + + @Test + void shorterData(Http2TestClient client) { + Http2TestConnection h2conn = client.createConnection(); + + var headers = WritableHeaders.create(); + headers.add(HeaderNames.CONTENT_LENGTH, 5); + h2conn.request(1, POST, "/", headers, BufferData.create("fra")); + + h2conn.assertSettings(TIMEOUT); + h2conn.assertWindowsUpdate(0, TIMEOUT); + h2conn.assertSettings(TIMEOUT); + + Http2Headers http2Headers = h2conn.assertHeaders(1, TIMEOUT); + assertThat(http2Headers.status(), is(Status.OK_200)); + byte[] responseBytes = h2conn.assertNextFrame(Http2FrameType.DATA, TIMEOUT).data().readBytes(); + assertThat(new String(responseBytes), is("pong")); + + assertFalse(consumeExceptionFuture.isDone()); + assertFalse(sendExceptionFuture.isDone()); + } + + @Test + void longerData(Http2TestClient client) throws ExecutionException, InterruptedException, TimeoutException { + Http2TestConnection h2conn = client.createConnection(); + + assertFalse(consumeExceptionFuture.isDone()); + assertFalse(sendExceptionFuture.isDone()); + + var headers = WritableHeaders.create(); + headers.add(HeaderNames.CONTENT_LENGTH, 2); + h2conn.request(1, POST, "/", headers, BufferData.create("frank")); + + h2conn.assertSettings(TIMEOUT); + h2conn.assertWindowsUpdate(0, TIMEOUT); + h2conn.assertSettings(TIMEOUT); + + h2conn.assertRstStream(1, TIMEOUT); + h2conn.assertGoAway(Http2ErrorCode.ENHANCE_YOUR_CALM, + "Request data length doesn't correspond to the content-length header.", + TIMEOUT); + + // content length discrepancy is discovered when consuming request data + var e = consumeExceptionFuture.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(e, Matchers.instanceOf(RequestException.class)); + assertThat(e.getMessage(), is("Stream is closed.")); + + // stream is closed, sending is not possible + e = sendExceptionFuture.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(e, Matchers.instanceOf(IllegalStateException.class)); + assertThat(e.getMessage(), is("Stream is already closed.")); + } + + @Test + void longerDataSecondStream(Http2TestClient client) throws ExecutionException, InterruptedException, TimeoutException { + Http2TestConnection h2conn = client.createConnection(); + + // First send payload with proper data length + var headers = WritableHeaders.create(); + headers.add(HeaderNames.CONTENT_LENGTH, 5); + h2conn.request(1, POST, "/", headers, BufferData.create("frank")); + + h2conn.assertSettings(TIMEOUT); + h2conn.assertWindowsUpdate(0, TIMEOUT); + h2conn.assertSettings(TIMEOUT); + + h2conn.assertNextFrame(Http2FrameType.HEADERS, TIMEOUT); + h2conn.assertNextFrame(Http2FrameType.DATA, TIMEOUT); + + assertFalse(consumeExceptionFuture.isDone()); + assertFalse(sendExceptionFuture.isDone()); + + // Now send payload larger than advertised data length + headers = WritableHeaders.create(); + headers.add(HeaderNames.CONTENT_LENGTH, 2); + h2conn.request(3, POST, "/", headers, BufferData.create("frank")); + + h2conn.assertRstStream(3, TIMEOUT); + h2conn.assertGoAway(Http2ErrorCode.ENHANCE_YOUR_CALM, + "Request data length doesn't correspond to the content-length header.", + TIMEOUT); + + // content length discrepancy is discovered when consuming request data + var e = consumeExceptionFuture.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(e, Matchers.instanceOf(RequestException.class)); + assertThat(e.getMessage(), is("Stream is closed.")); + + // stream is closed, sending is not possible + e = sendExceptionFuture.get(TIMEOUT.toMillis(), TimeUnit.MILLISECONDS); + assertThat(e, Matchers.instanceOf(IllegalStateException.class)); + assertThat(e.getMessage(), is("Stream is already closed.")); + } +} diff --git a/webserver/tests/http2/src/test/resources/logging-test.properties b/webserver/tests/http2/src/test/resources/logging-test.properties index fa0e37bbca3..1fca2c71871 100644 --- a/webserver/tests/http2/src/test/resources/logging-test.properties +++ b/webserver/tests/http2/src/test/resources/logging-test.properties @@ -1,5 +1,5 @@ # -# Copyright (c) 2022, 2023 Oracle and/or its affiliates. +# Copyright (c) 2022, 2024 Oracle and/or its affiliates. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -20,3 +20,7 @@ java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS.%1$ # Global logging level. Can be overridden by specific loggers .level=INFO io.helidon.webserver.level=INFO + +#io.helidon.http.http2.level=FINEST +#io.helidon.http.http2.FlowControl.ifc.level=FINEST +#io.helidon.http.http2.FlowControl.ofc.level=FINEST \ No newline at end of file
ISO_8601 format examples: