diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriQueryWriteable.java b/common/uri/src/main/java/io/helidon/common/uri/UriQueryWriteable.java index a32aab05db3..7de85f74cf1 100644 --- a/common/uri/src/main/java/io/helidon/common/uri/UriQueryWriteable.java +++ b/common/uri/src/main/java/io/helidon/common/uri/UriQueryWriteable.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. diff --git a/common/uri/src/main/java/io/helidon/common/uri/UriQueryWriteableImpl.java b/common/uri/src/main/java/io/helidon/common/uri/UriQueryWriteableImpl.java index 6ea4979ce5e..64b98cbffa3 100644 --- a/common/uri/src/main/java/io/helidon/common/uri/UriQueryWriteableImpl.java +++ b/common/uri/src/main/java/io/helidon/common/uri/UriQueryWriteableImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. diff --git a/nima/grpc/webserver/src/main/java/io/helidon/nima/grpc/webserver/GrpcProtocolHandler.java b/nima/grpc/webserver/src/main/java/io/helidon/nima/grpc/webserver/GrpcProtocolHandler.java index 4f455c49cd8..8d70c5ba092 100644 --- a/nima/grpc/webserver/src/main/java/io/helidon/nima/grpc/webserver/GrpcProtocolHandler.java +++ b/nima/grpc/webserver/src/main/java/io/helidon/nima/grpc/webserver/GrpcProtocolHandler.java @@ -151,7 +151,7 @@ public void sendHeaders(Metadata headers) { streamWriter.writeHeaders(http2Headers, streamId, Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS), - FlowControl.NOOP); + FlowControl.Outbound.NOOP); } @Override @@ -175,7 +175,8 @@ public void sendMessage(RES message) { Http2Flag.DataFlags.create(0), streamId); - streamWriter.write(new Http2FrameData(header, bufferData), FlowControl.NOOP); + //FIXME: FC and MAX_FRAME_SIZE + streamWriter.writeData(new Http2FrameData(header, bufferData), FlowControl.Outbound.NOOP); } @Override @@ -187,7 +188,7 @@ public void close(Status status, Metadata trailers) { streamWriter.writeHeaders(http2Headers, streamId, Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS | Http2Flag.END_OF_STREAM), - FlowControl.NOOP); + FlowControl.Outbound.NOOP); currentStreamState = Http2StreamState.HALF_CLOSED_LOCAL; } diff --git a/nima/grpc/webserver/src/main/java/io/helidon/nima/grpc/webserver/GrpcProtocolHandlerNotFound.java b/nima/grpc/webserver/src/main/java/io/helidon/nima/grpc/webserver/GrpcProtocolHandlerNotFound.java index da8f7315660..19e49811926 100644 --- a/nima/grpc/webserver/src/main/java/io/helidon/nima/grpc/webserver/GrpcProtocolHandlerNotFound.java +++ b/nima/grpc/webserver/src/main/java/io/helidon/nima/grpc/webserver/GrpcProtocolHandlerNotFound.java @@ -50,7 +50,7 @@ public void init() { streamWriter.writeHeaders(http2Headers, streamId, Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS | Http2Flag.END_OF_STREAM), - FlowControl.NOOP); + FlowControl.Outbound.NOOP); currentStreamState = Http2StreamState.HALF_CLOSED_LOCAL; } diff --git a/nima/http/media/media/src/main/java/io/helidon/nima/http/media/ReadableEntityBase.java b/nima/http/media/media/src/main/java/io/helidon/nima/http/media/ReadableEntityBase.java index 211482f1071..6460f4bb2c1 100644 --- a/nima/http/media/media/src/main/java/io/helidon/nima/http/media/ReadableEntityBase.java +++ b/nima/http/media/media/src/main/java/io/helidon/nima/http/media/ReadableEntityBase.java @@ -226,7 +226,7 @@ public int read() throws IOException { return -1; } ensureBuffer(512); - if (currentBuffer == null) { + if (finished || currentBuffer == null) { return -1; } return currentBuffer.read(); @@ -238,7 +238,7 @@ public int read(byte[] b, int off, int len) throws IOException { return -1; } ensureBuffer(len); - if (currentBuffer == null) { + if (finished || currentBuffer == null) { return -1; } return currentBuffer.read(b, off, len); diff --git a/nima/http2/http2/pom.xml b/nima/http2/http2/pom.xml index 9db9c531a8c..36b5495c78c 100644 --- a/nima/http2/http2/pom.xml +++ b/nima/http2/http2/pom.xml @@ -46,6 +46,21 @@ hamcrest-all test + + org.junit.jupiter + junit-jupiter-params + test + + + io.helidon.logging + helidon-logging-jul + test + + + org.mockito + mockito-core + test + diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/ConnectionFlowControl.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/ConnectionFlowControl.java new file mode 100644 index 00000000000..e26bdd2b248 --- /dev/null +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/ConnectionFlowControl.java @@ -0,0 +1,227 @@ +/* + * Copyright (c) 2023 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.nima.http2; + +import java.time.Duration; +import java.util.function.BiConsumer; + +import io.helidon.common.Builder; + +import static java.lang.System.Logger.Level.DEBUG; + +/** + * HTTP/2 Flow control for connection. + */ +public class ConnectionFlowControl { + + private static final System.Logger LOGGER_OUTBOUND = System.getLogger(FlowControl.class.getName() + ".ofc"); + + private final Type type; + private final BiConsumer windowUpdateWriter; + private final Duration timeout; + private final WindowSize.Inbound inboundConnectionWindowSize; + private final WindowSize.Outbound outboundConnectionWindowSize; + + private volatile int maxFrameSize = WindowSize.DEFAULT_MAX_FRAME_SIZE; + private volatile int initialWindowSize = WindowSize.DEFAULT_WIN_SIZE; + + private ConnectionFlowControl(Type type, + int initialWindowSize, + int maxFrameSize, + BiConsumer windowUpdateWriter, + Duration timeout) { + this.type = type; + this.windowUpdateWriter = windowUpdateWriter; + this.timeout = timeout; + this.inboundConnectionWindowSize = + WindowSize.createInbound(type, + 0, + initialWindowSize, + maxFrameSize, + windowUpdateWriter); + outboundConnectionWindowSize = + WindowSize.createOutbound(type, 0, this); + } + + /** + * Create connection HTTP/2 flow-control for server side. + * + * @param windowUpdateWriter method called for sending WINDOW_UPDATE frames to the client. + * @return Connection HTTP/2 flow-control + */ + public static ConnectionFlowControlBuilder serverBuilder(BiConsumer windowUpdateWriter) { + return new ConnectionFlowControlBuilder(Type.SERVER, windowUpdateWriter); + } + + /** + * Create connection HTTP/2 flow-control for client side. + * + * @param windowUpdateWriter method called for sending WINDOW_UPDATE frames to the server. + * @return Connection HTTP/2 flow-control + */ + public static ConnectionFlowControlBuilder clientBuilder(BiConsumer windowUpdateWriter) { + return new ConnectionFlowControlBuilder(Type.CLIENT, windowUpdateWriter); + } + + /** + * Create stream specific inbound and outbound flow control. + * + * @param streamId stream id + * @return stream flow control + */ + public StreamFlowControl createStreamFlowControl(int streamId) { + return new StreamFlowControl(type, streamId, this, windowUpdateWriter); + } + + /** + * Increment outbound connection flow control window, called when WINDOW_UPDATE is received. + * + * @param increment number of bytes other side has requested on top of actual demand + * @return outbound window size after increment + */ + public long incrementOutboundConnectionWindowSize(int increment) { + return outboundConnectionWindowSize.incrementWindowSize(increment); + } + + /** + * Decrement inbound connection flow control window, called when DATA frame is received. + * + * @param decrement received DATA frame size in bytes + * @return inbound window size after decrement + */ + public long decrementInboundConnectionWindowSize(int decrement) { + return inboundConnectionWindowSize.decrementWindowSize(decrement); + } + + /** + * Reset MAX_FRAME_SIZE for all streams, existing and future ones. + * + * @param maxFrameSize to split data frames according to when larger + */ + public void resetMaxFrameSize(int maxFrameSize) { + this.maxFrameSize = maxFrameSize; + } + + /** + * Reset an initial window size value for outbound flow control windows of a new streams. + * Don't forget to call stream.flowControl().outbound().resetStreamWindowSize(...) for each stream + * to align window size of existing streams. + * + * @param initialWindowSize INIT_WINDOW_SIZE received + */ + public void resetInitialWindowSize(int initialWindowSize) { + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + LOGGER_OUTBOUND.log(DEBUG, String.format("%s OFC STR *: Recv INIT_WINDOW_SIZE %s", type, initialWindowSize)); + } + this.initialWindowSize = initialWindowSize; + } + + /** + * Connection outbound flow control window, + * decrements when DATA are sent and increments when WINDOW_UPDATE or INIT_WINDOW_SIZE is received. + * Blocks sending when window is depleted. + * + * @return connection outbound flow control window + */ + public WindowSize.Outbound outbound() { + return outboundConnectionWindowSize; + } + + /** + * Connection inbound window is always manipulated by respective stream flow control, + * therefore package private is enough. + * + * @return connection inbound flow control window + */ + WindowSize.Inbound inbound() { + return inboundConnectionWindowSize; + } + + int maxFrameSize() { + return maxFrameSize; + } + + int initialWindowSize() { + return initialWindowSize; + } + + Duration timeout() { + return timeout; + } + + enum Type { + SERVER, CLIENT; + } + + /** + * Connection flow control builder. + */ + public static class ConnectionFlowControlBuilder implements Builder { + + private static final Duration DEFAULT_TIMEOUT = Duration.ofMillis(100); + private final Type type; + private final BiConsumer windowUpdateWriter; + private int initialWindowSize = WindowSize.DEFAULT_WIN_SIZE; + private int maxFrameSize = WindowSize.DEFAULT_MAX_FRAME_SIZE; + private Duration blockTimeout = DEFAULT_TIMEOUT; + + ConnectionFlowControlBuilder(Type type, BiConsumer windowUpdateWriter) { + this.type = type; + this.windowUpdateWriter = windowUpdateWriter; + } + + /** + * Outbound flow control INITIAL_WINDOW_SIZE setting for new HTTP/2 connections. + * + * @param initialWindowSize units of octets + * @return updated builder + */ + public ConnectionFlowControlBuilder initialWindowSize(int initialWindowSize) { + this.initialWindowSize = initialWindowSize; + return this; + } + + /** + * Initial MAX_FRAME_SIZE setting for new HTTP/2 connections. + * Maximum size of data frames in bytes we are prepared to accept from the other size. + * Default value is 2^14(16_384). + * + * @param maxFrameSize data frame size in bytes between 2^14(16_384) and 2^24-1(16_777_215) + * @return updated client + */ + public ConnectionFlowControlBuilder maxFrameSize(int maxFrameSize) { + this.maxFrameSize = maxFrameSize; + return this; + } + + /** + * Timeout for blocking between windows size check iterations. + * + * @param timeout duration + * @return updated builder + */ + public ConnectionFlowControlBuilder blockTimeout(Duration timeout) { + this.blockTimeout = timeout; + return this; + } + + @Override + public ConnectionFlowControl build() { + return new ConnectionFlowControl(type, initialWindowSize, maxFrameSize, windowUpdateWriter, blockTimeout); + } + } +} diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/FlowControl.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/FlowControl.java index 774fb70921b..ceaa4d20192 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/FlowControl.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/FlowControl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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,64 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package io.helidon.nima.http2; /** * Flow control used by HTTP/2 for backpressure. */ public interface FlowControl { - /** - * No-op flow control, used for connection related frames. - */ - FlowControl NOOP = new FlowControl() { - @Override - public void resetStreamWindowSize(long increment) { - } - - @Override - public void decrementWindowSize(int decrement) { - } - - @Override - public boolean incrementStreamWindowSize(int increment) { - return false; - } - - @Override - public int getRemainingWindowSize() { - return Integer.MAX_VALUE; - } - - @Override - public Http2FrameData[] split(Http2FrameData frame) { - return new Http2FrameData[] {frame}; - } - - @Override - public boolean blockTillUpdate() { - return false; - } - }; - - /** - * Create a flow control for a stream. - * - * @param streamId stream id - * @param streamInitialWindowSize initial window size for stream - * @param connectionWindowSize connection window size - * @return a new flow control - */ - static FlowControl create(int streamId, int streamInitialWindowSize, WindowSize connectionWindowSize) { - return new FlowControlImpl(streamId, streamInitialWindowSize, connectionWindowSize); - } - - /** - * Reset stream window size. - * - * @param increment increment - */ - void resetStreamWindowSize(long increment); /** * Decrement window size. @@ -80,12 +28,11 @@ static FlowControl create(int streamId, int streamInitialWindowSize, WindowSize void decrementWindowSize(int decrement); /** - * Increment stream window size. + * Reset stream window size. * - * @param increment increment in bytes - * @return {@code true} if succeeded, {@code false} if timed out + * @param size new window size */ - boolean incrementStreamWindowSize(int increment); + void resetStreamWindowSize(int size); /** * Remaining window size in bytes. @@ -95,17 +42,55 @@ static FlowControl create(int streamId, int streamInitialWindowSize, WindowSize int getRemainingWindowSize(); /** - * Split frame into frames that can be sent. - * - * @param frame frame to split - * @return result + * Inbound flow control used by HTTP/2 for backpressure. */ - Http2FrameData[] split(Http2FrameData frame); + interface Inbound extends FlowControl { + + /** + * Increment window size. + * + * @param increment increment in bytes + */ + void incrementWindowSize(int increment); + } /** - * Block until a window size update happens. - * - * @return {@code true} if window update happened, {@code false} in case of timeout + * Outbound flow control used by HTTP/2 for backpressure. */ - boolean blockTillUpdate(); + interface Outbound extends FlowControl { + + /** + * No-op outbound flow control, used for connection related frames. + */ + Outbound NOOP = new FlowControlNoop.Outbound(); + + /** + * Increment stream window size. + * + * @param increment increment in bytes + * @return {@code true} if succeeded, {@code false} if timed out + */ + long incrementStreamWindowSize(int increment); + + /** + * Split frame into frames that can be sent. + * + * @param frame frame to split + * @return result + */ + Http2FrameData[] cut(Http2FrameData frame); + + /** + * Block until a window size update happens. + * + */ + void blockTillUpdate(); + + /** + * MAX_FRAME_SIZE setting last received from the other side or default. + * @return MAX_FRAME_SIZE + */ + int maxFrameSize(); + } + } diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/FlowControlImpl.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/FlowControlImpl.java index d5f204e5514..14f5d29c75f 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/FlowControlImpl.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/FlowControlImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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,96 +13,183 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package io.helidon.nima.http2; -import io.helidon.common.buffers.BufferData; +import java.util.Objects; +import java.util.function.BiConsumer; + +import static java.lang.System.Logger.Level.DEBUG; + +abstract class FlowControlImpl implements FlowControl { -class FlowControlImpl implements FlowControl { + private static final System.Logger LOGGER_INBOUND = System.getLogger(FlowControl.class.getName() + ".ifc"); + private static final System.Logger LOGGER_OUTBOUND = System.getLogger(FlowControl.class.getName() + ".ofc"); private final int streamId; - private final WindowSize connectionWindowSize; - private final WindowSize streamWindowSize; - FlowControlImpl(int streamId, int streamInitialWindowSize, WindowSize connectionWindowSize) { + FlowControlImpl(int streamId) { this.streamId = streamId; - this.connectionWindowSize = connectionWindowSize; - this.streamWindowSize = new WindowSize(streamInitialWindowSize); } - @Override - public void resetStreamWindowSize(long increment) { - streamWindowSize.resetWindowSize(increment); - } + abstract WindowSize connectionWindowSize(); - @Override - public void decrementWindowSize(int decrement) { - connectionWindowSize.decrementWindowSize(decrement); - streamWindowSize.decrementWindowSize(decrement); - } + abstract WindowSize streamWindowSize(); @Override - public boolean incrementStreamWindowSize(int increment) { - boolean overflow = streamWindowSize.incrementWindowSize(increment); - connectionWindowSize.triggerUpdate(); - return overflow; + public void resetStreamWindowSize(int size) { + streamWindowSize().resetWindowSize(size); } @Override public int getRemainingWindowSize() { - return Integer.min(connectionWindowSize.getRemainingWindowSize(), streamWindowSize.getRemainingWindowSize()); - } - - @Override - public Http2FrameData[] split(Http2FrameData frame) { - return split(getRemainingWindowSize(), frame); - } - - @Override - public boolean blockTillUpdate() { - return connectionWindowSize.blockTillUpdate(); + return Math.max(0, + Integer.min( + connectionWindowSize().getRemainingWindowSize(), + streamWindowSize().getRemainingWindowSize() + ) + ); } @Override public String toString() { return "FlowControlImpl{" + "streamId=" + streamId - + ", connectionWindowSize=" + connectionWindowSize - + ", streamWindowSize=" + streamWindowSize + + ", connectionWindowSize=" + connectionWindowSize() + + ", streamWindowSize=" + streamWindowSize() + '}'; } - private Http2FrameData[] split(int size, Http2FrameData frame) { - int length = frame.header().length(); - if (length <= size || length == 0) { - return new Http2FrameData[] {frame}; + protected int streamId() { + return this.streamId; + } + + static class Inbound extends FlowControlImpl implements FlowControl.Inbound { + + private final WindowSize.Inbound connectionWindowSize; + private final WindowSize.Inbound streamWindowSize; + private final ConnectionFlowControl.Type type; + + Inbound(ConnectionFlowControl.Type type, + int streamId, + int streamInitialWindowSize, + int streamMaxFrameSize, + WindowSize.Inbound connectionWindowSize, + BiConsumer windowUpdateStreamWriter) { + super(streamId); + this.type = type; + if (streamInitialWindowSize == 0) { + throw new IllegalArgumentException("Window size in bytes for stream-level flow control was not set."); + } + Objects.requireNonNull(connectionWindowSize, "Window size in bytes for connection-level flow control was not set."); + Objects.requireNonNull(windowUpdateStreamWriter, "Stream-level window update writer was not set."); + this.connectionWindowSize = connectionWindowSize; + this.streamWindowSize = WindowSize.createInbound(type, + streamId, + streamInitialWindowSize, + streamMaxFrameSize, + windowUpdateStreamWriter); + } + + @Override + WindowSize connectionWindowSize() { + return connectionWindowSize; + } + + @Override + WindowSize streamWindowSize() { + return streamWindowSize; + } + + @Override + public void decrementWindowSize(int decrement) { + long strRemaining = streamWindowSize().decrementWindowSize(decrement); + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + LOGGER_INBOUND.log(DEBUG, String.format("%s IFC STR %d: -%d(%d)", type, streamId(), decrement, strRemaining)); + } + long connRemaining = connectionWindowSize().decrementWindowSize(decrement); + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + LOGGER_INBOUND.log(DEBUG, String.format("%s IFC STR 0: -%d(%d)", type, decrement, connRemaining)); + } + } + + @Override + public void incrementWindowSize(int increment) { + long strRemaining = streamWindowSize.incrementWindowSize(increment); + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + LOGGER_INBOUND.log(DEBUG, String.format("%s IFC STR %d: +%d(%d)", type, streamId(), increment, strRemaining)); + } + long conRemaining = connectionWindowSize.incrementWindowSize(increment); + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + LOGGER_INBOUND.log(DEBUG, String.format("%s IFC STR 0: +%d(%d)", type, increment, conRemaining)); + } + } + + } + + static class Outbound extends FlowControlImpl implements FlowControl.Outbound { + + private final ConnectionFlowControl.Type type; + private final ConnectionFlowControl connectionFlowControl; + private final WindowSize.Outbound streamWindowSize; + + Outbound(ConnectionFlowControl.Type type, + int streamId, + ConnectionFlowControl connectionFlowControl) { + super(streamId); + this.type = type; + this.connectionFlowControl = connectionFlowControl; + this.streamWindowSize = WindowSize.createOutbound(type, streamId, connectionFlowControl); } - if (size == 0) { - return new Http2FrameData[0]; + @Override + WindowSize connectionWindowSize() { + return connectionFlowControl.outbound(); } - byte[] data1 = new byte[size]; - byte[] data2 = new byte[length - size]; + @Override + WindowSize streamWindowSize() { + return streamWindowSize; + } - frame.data().read(data1); - frame.data().read(data2); + @Override + public void decrementWindowSize(int decrement) { + long strRemaining = streamWindowSize().decrementWindowSize(decrement); + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + LOGGER_OUTBOUND.log(DEBUG, String.format("%s OFC STR %d: -%d(%d)", type, streamId(), decrement, strRemaining)); + } - BufferData bufferData1 = BufferData.create(data1); - BufferData bufferData2 = BufferData.create(data2); + long connRemaining = connectionWindowSize().decrementWindowSize(decrement); + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + LOGGER_OUTBOUND.log(DEBUG, String.format("%s OFC STR 0: -%d(%d)", type, decrement, connRemaining)); - Http2FrameData frameData1 = new Http2FrameData(Http2FrameHeader.create(bufferData1.available(), - Http2FrameTypes.DATA, - Http2Flag.DataFlags.create(0), - frame.header().streamId()), - bufferData1); + } + } - Http2FrameData frameData2 = new Http2FrameData(Http2FrameHeader.create(bufferData2.available(), - Http2FrameTypes.DATA, - Http2Flag.DataFlags.create(0), - frame.header().streamId()), - bufferData2); + @Override + public long incrementStreamWindowSize(int increment) { + long remaining = streamWindowSize.incrementWindowSize(increment); + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + LOGGER_OUTBOUND.log(DEBUG, String.format("%s OFC STR %d: +%d(%d)", type, streamId(), increment, remaining)); + } + connectionFlowControl.outbound().triggerUpdate(); + return remaining; + } + + @Override + public Http2FrameData[] cut(Http2FrameData frame) { + return frame.cut(getRemainingWindowSize()); + } - return new Http2FrameData[] {frameData1, frameData2}; + @Override + public void blockTillUpdate() { + connectionFlowControl.outbound().blockTillUpdate(); + streamWindowSize.blockTillUpdate(); + } + + @Override + public int maxFrameSize() { + return connectionFlowControl.maxFrameSize(); + } } + } diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/FlowControlNoop.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/FlowControlNoop.java new file mode 100644 index 00000000000..14296f8fc8c --- /dev/null +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/FlowControlNoop.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022, 2023 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.nima.http2; + +import java.util.function.BiConsumer; + +class FlowControlNoop implements FlowControl { + + @Override + public void decrementWindowSize(int decrement) { + } + + @Override + public void resetStreamWindowSize(int size) { + } + + @Override + public int getRemainingWindowSize() { + return Integer.MAX_VALUE; + } + + + // Even NOOP sends WINDOW_UPDATE frames + static class Inbound extends FlowControlNoop implements FlowControl.Inbound { + + private final WindowSize.Inbound connectionWindowSize; + private final WindowSize.Inbound streamWindowSize; + + Inbound(int streamId, + WindowSize.Inbound connectionWindowSize, + BiConsumer windowUpdateStreamWriter) { + this.connectionWindowSize = connectionWindowSize; + this.streamWindowSize = WindowSize.createInboundNoop(streamId, windowUpdateStreamWriter); + } + + @Override + public void incrementWindowSize(int increment) { + streamWindowSize.incrementWindowSize(increment); + connectionWindowSize.incrementWindowSize(increment); + } + + } + + static class Outbound extends FlowControlNoop implements FlowControl.Outbound { + + @Override + public long incrementStreamWindowSize(int increment) { + return WindowSize.MAX_WIN_SIZE; + } + + @Override + public Http2FrameData[] cut(Http2FrameData frame) { + return new Http2FrameData[] {frame}; + } + + @Override + public void blockTillUpdate() { + } + + @Override + public int maxFrameSize() { + return WindowSize.DEFAULT_MAX_FRAME_SIZE; + } + } + +} diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ConnectionWriter.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ConnectionWriter.java index 74075aa7efa..998a3febee8 100755 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ConnectionWriter.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2ConnectionWriter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -35,7 +35,7 @@ public class Http2ConnectionWriter implements Http2StreamWriter { private final Lock streamLock = new ReentrantLock(true); private final SocketContext ctx; private final Http2FrameListener listener; - private final Http2Headers.DynamicTable responseDynamicTable; + private final Http2Headers.DynamicTable outboundDynamicTable; private final Http2HuffmanEncoder responseHuffman; private final BufferData headerBuffer = BufferData.growing(512); @@ -52,20 +52,24 @@ public Http2ConnectionWriter(SocketContext ctx, DataWriter writer, List { - noLockWrite(flowControl, frame); - return null; - }); + public void write(Http2FrameData frame) { + lockedWrite(frame); + } + + @Override + public void writeData(Http2FrameData frame, FlowControl.Outbound flowControl) { + for (Http2FrameData f : frame.split(flowControl.maxFrameSize())) { + splitAndWrite(f, flowControl); + } } @Override - public int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlags flags, FlowControl flowControl) { + public int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlags flags, 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 @@ -73,7 +77,7 @@ public int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlag return withStreamLock(() -> { int written = 0; headerBuffer.clear(); - headers.write(responseDynamicTable, responseHuffman, headerBuffer); + headers.write(outboundDynamicTable, responseHuffman, headerBuffer); Http2FrameHeader frameHeader = Http2FrameHeader.create(headerBuffer.available(), Http2FrameTypes.HEADERS, flags, @@ -81,7 +85,7 @@ public int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlag written += frameHeader.length(); written += Http2FrameHeader.LENGTH; - noLockWrite(flowControl, new Http2FrameData(frameHeader, headerBuffer)); + noLockWrite(new Http2FrameData(frameHeader, headerBuffer)); return written; }); @@ -92,7 +96,7 @@ public int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlags flags, Http2FrameData dataFrame, - FlowControl flowControl) { + 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 @@ -101,7 +105,7 @@ public int writeHeaders(Http2Headers headers, int bytesWritten = 0; headerBuffer.clear(); - headers.write(responseDynamicTable, responseHuffman, headerBuffer); + headers.write(outboundDynamicTable, responseHuffman, headerBuffer); bytesWritten += headerBuffer.available(); Http2FrameHeader frameHeader = Http2FrameHeader.create(headerBuffer.available(), @@ -110,8 +114,8 @@ public int writeHeaders(Http2Headers headers, streamId); bytesWritten += Http2FrameHeader.LENGTH; - noLockWrite(flowControl, new Http2FrameData(frameHeader, headerBuffer)); - noLockWrite(flowControl, dataFrame); + noLockWrite(new Http2FrameData(frameHeader, headerBuffer)); + writeData(dataFrame, flowControl); bytesWritten += Http2FrameHeader.LENGTH; bytesWritten += dataFrame.header().length(); @@ -127,7 +131,14 @@ public int writeHeaders(Http2Headers headers, */ public void updateHeaderTableSize(long newSize) throws InterruptedException { withStreamLock(() -> { - responseDynamicTable.protocolMaxTableSize(newSize); + outboundDynamicTable.protocolMaxTableSize(newSize); + return null; + }); + } + + private void lockedWrite(Http2FrameData frame) { + withStreamLock(() -> { + noLockWrite(frame); return null; }); } @@ -145,40 +156,7 @@ private T withStreamLock(Callable callable) { } } - private void noLockWrite(FlowControl flowControl, Http2FrameData frame) { - if (frame.header().type() == Http2FrameTypes.DATA.type()) { - splitAndWrite(frame, flowControl); - } else { - writeFrameInternal(frame); - } - } - - private void splitAndWrite(Http2FrameData frame, FlowControl flowControl) { - Http2FrameData[] splitFrames = flowControl.split(frame); - if (splitFrames.length == 1) { - // windows are wide enough - writeFrameInternal(frame); - flowControl.decrementWindowSize(frame.header().length()); - } else if (splitFrames.length == 0) { - // block until window update - if (!flowControl.blockTillUpdate()) { - // no timeout - splitAndWrite(frame, flowControl); - } - } else if (splitFrames.length == 2) { - // write send-able part and block until window update with the rest - writeFrameInternal(splitFrames[0]); - flowControl.decrementWindowSize(frame.header().length()); - if (!flowControl.blockTillUpdate()) { - // no timeout - splitAndWrite(splitFrames[1], flowControl); - } else { - //TODO discarded frames after timeout - } - } - } - - private void writeFrameInternal(Http2FrameData frame) { + private void noLockWrite(Http2FrameData frame) { Http2FrameHeader frameHeader = frame.header(); listener.frameHeader(ctx, frameHeader); @@ -194,6 +172,28 @@ private void writeFrameInternal(Http2FrameData frame) { } } + private void splitAndWrite(Http2FrameData frame, FlowControl.Outbound flowControl) { + Http2FrameData currFrame = frame; + while (true) { + Http2FrameData[] splitFrames = flowControl.cut(currFrame); + if (splitFrames.length == 1) { + // windows are wide enough + lockedWrite(currFrame); + flowControl.decrementWindowSize(currFrame.header().length()); + break; + } else if (splitFrames.length == 0) { + // block until window update + flowControl.blockTillUpdate(); + } 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.blockTillUpdate(); + currFrame = splitFrames[1]; + } + } + } + // TODO use for fastpath // private void noLockWrite(Http2FrameData... frames) { // List toWrite = new LinkedList<>(); diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2FrameData.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2FrameData.java index fe8ec9140fc..6cac83d87d3 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2FrameData.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2FrameData.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -25,4 +25,102 @@ * @param data frame data */ public record Http2FrameData(Http2FrameHeader header, BufferData data) { + + /** + * Split this frame to smaller frames of maximum frame size. + * + * @param size maximum frame size + * @return array of + */ + public Http2FrameData[] split(int size) { + int length = this.header().length(); + + // Already smaller than max size + if (length <= size || length == 0) { + return new Http2FrameData[] {this}; + } + + // Zero max size fast path + if (size == 0) { + return new Http2FrameData[0]; + } + + // End of stream flag is set only to the last frame in the array + boolean endOfStream = this.header().flags(Http2FrameTypes.DATA).endOfStream(); + + int lastFrameSize = length % size; + + // Avoid creating 0 length last frame + int allFrames = (length / size) + (lastFrameSize != 0 ? 1 : 0); + Http2FrameData[] splitFrames = new Http2FrameData[allFrames]; + + for (int i = 0; i < allFrames; i++) { + boolean lastFrame = allFrames == i + 1; + // only last frame can be smaller than max size + byte[] data = new byte[lastFrame ? (lastFrameSize != 0 ? lastFrameSize : size) : size]; + this.data().read(data); + BufferData bufferData = BufferData.create(data); + splitFrames[i] = new Http2FrameData( + Http2FrameHeader.create(bufferData.available(), + Http2FrameTypes.DATA, + Http2Flag.DataFlags.create(endOfStream && lastFrame + ? Http2Flag.END_OF_STREAM + : 0), + this.header().streamId()), + bufferData); + } + return splitFrames; + } + + /** + * Cut the frame of given size from larger frame, + * returns two frames, first of given size, second with the rest of the data. + * + * @param size maximum frame size of the first frame + * @return array of 0,1 or 2 frames + */ + public Http2FrameData[] cut(int size) { + int length = this.header().length(); + + // Already smaller than max size + if (length <= size || length == 0) { + return new Http2FrameData[] {this}; + } + + // Zero max size fast path + if (size == 0) { + return new Http2FrameData[0]; + } + + // End of stream flag is set only to the last frame in the array + boolean endOfStream = this.header.flags(Http2FrameTypes.DATA).endOfStream(); + + byte[] data1 = new byte[size]; + byte[] data2 = new byte[length - size]; + + this.data().read(data1); + this.data().read(data2); + + BufferData bufferData1 = BufferData.create(data1); + BufferData bufferData2 = BufferData.create(data2); + + Http2FrameData frameData1 = + new Http2FrameData(Http2FrameHeader.create(bufferData1.available(), + Http2FrameTypes.DATA, + Http2Flag.DataFlags.create(0), + this.header().streamId()), + bufferData1); + + Http2FrameData frameData2 = + new Http2FrameData(Http2FrameHeader.create(bufferData2.available(), + Http2FrameTypes.DATA, + Http2Flag.DataFlags.create(endOfStream + ? Http2Flag.END_OF_STREAM + : 0), + this.header().streamId()), + bufferData2); + + return new Http2FrameData[] {frameData1, frameData2}; + } + } diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Headers.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Headers.java index ae3c9d66010..e9ed7ac5513 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Headers.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Headers.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -779,12 +779,12 @@ enum StaticHeader implements IndexedHeaderRecord { for (StaticHeader predefinedHeader : StaticHeader.values()) { BY_INDEX.put(predefinedHeader.index(), predefinedHeader); maxIndex = Math.max(maxIndex, predefinedHeader.index); + // Indexed headers may be referenced either with or without value, so we need to store them in both tables if (predefinedHeader.hasValue()) { BY_NAME_VALUE.computeIfAbsent(predefinedHeader.headerName().lowerCase(), it -> new HashMap<>()) .put(predefinedHeader.value(), predefinedHeader); - } else { - BY_NAME_NO_VALUE.put(predefinedHeader.headerName().lowerCase(), predefinedHeader); } + BY_NAME_NO_VALUE.putIfAbsent(predefinedHeader.headerName().lowerCase(), predefinedHeader); } MAX_INDEX = maxIndex; diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Setting.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Setting.java index 401fad64f83..ce82da8770c 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Setting.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Setting.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -74,7 +74,7 @@ public interface Http2Setting { int identifier(); /** - * Typed default value of this settiing. + * Typed default value of this setting. * * @return default value */ diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Settings.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Settings.java index 0739c7aa5f5..8aa31644446 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Settings.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Settings.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -25,10 +25,13 @@ import io.helidon.common.buffers.BufferData; import io.helidon.common.socket.SocketContext; +import static java.lang.System.Logger.Level.DEBUG; + /** * HTTP settings frame. */ public final class Http2Settings implements Http2Frame { + private static final System.Logger LOGGER = System.getLogger(Http2Settings.class.getName()); private final Map values; Http2Settings(Map values) { @@ -36,7 +39,7 @@ public final class Http2Settings implements Http2Frame } /** - * Create emtpy settings frame. + * Create empty settings frame. * * @return settings frame */ @@ -81,6 +84,10 @@ public static Http2Settings create(BufferData frame) { return new Http2Settings(values); } + private static String toString(String setting, int code, Object value) { + return String.format("[%s (0x%02x):%s]", setting, code, value); + } + @SuppressWarnings("unchecked") @Override public Http2FrameData toFrameData(Http2Settings settings, int streamId, Http2Flag.SettingsFlags flags) { @@ -89,9 +96,11 @@ public Http2FrameData toFrameData(Http2Settings settings, int streamId, Http2Fla values.values().forEach(it -> { Object value = it.value(); Http2Setting setting = (Http2Setting) it.setting(); + if (LOGGER.isLoggable(DEBUG)) { + LOGGER.log(DEBUG, String.format(" - Http2Settings %s: %s", it.setting().toString(), it.value().toString())); + } setting.write(data, value); }); - Http2FrameHeader header = Http2FrameHeader.create(data.available(), frameTypes(), flags, @@ -171,10 +180,6 @@ public boolean hasValue(Http2Setting setting) { return values.containsKey(setting.identifier()); } - private static String toString(String setting, int code, Object value) { - return String.format("[%s (0x%02x):%s]", setting, code, value); - } - private record SettingValue(Http2Setting setting, Object value) { @Override public String toString() { diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Stream.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Stream.java index cd6ca8709bd..25ccf5df3dc 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Stream.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2Stream.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -75,9 +75,10 @@ public interface Http2Stream { Http2StreamState streamState(); /** - * Flow control of this stream. + * Outbound flow control of this stream. * * @return flow control */ - FlowControl flowControl(); + StreamFlowControl flowControl(); + } diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2StreamState.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2StreamState.java index f1c9291d2de..e53858d3720 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2StreamState.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2StreamState.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -270,6 +270,10 @@ private static Http2StreamState checkHeaders(Http2StreamState current, return current; //receiving headers in progress } } + // 5.1. half-closed (local): An endpoint can receive any type of frame in this state + if (current == HALF_CLOSED_LOCAL) { + return HALF_CLOSED_LOCAL; + } throw new Http2Exception(Http2ErrorCode.PROTOCOL, "Received " + type + " in invalid state: " + current); } } diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2StreamWriter.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2StreamWriter.java index 042d74db22a..34eade9838d 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2StreamWriter.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/Http2StreamWriter.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -23,10 +23,17 @@ public interface Http2StreamWriter { /** * Write a frame. * - * @param frame frame to write - * @param flowControl flow control + * @param frame frame to write + */ + void write(Http2FrameData frame); + + /** + * Write a frame with flow control. + * + * @param frame data frame + * @param flowControl outbound flow control */ - void write(Http2FrameData frame, FlowControl flowControl); + void writeData(Http2FrameData frame, FlowControl.Outbound flowControl); /** * Write headers with no (or streaming) entity. @@ -37,7 +44,7 @@ public interface Http2StreamWriter { * @param flowControl flow control * @return number of bytes written */ - int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlags flags, FlowControl flowControl); + int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlags flags, FlowControl.Outbound flowControl); /** * Write headers and entity. @@ -53,5 +60,5 @@ int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlags flags, Http2FrameData dataFrame, - FlowControl flowControl); + FlowControl.Outbound flowControl); } diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/StreamFlowControl.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/StreamFlowControl.java new file mode 100644 index 00000000000..0c9a93845a6 --- /dev/null +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/StreamFlowControl.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2023 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.nima.http2; + +import java.util.function.BiConsumer; + +/** + * Stream specific HTTP/2 flow control. + *
+ * Manages: + *
    + *
  • Inbound - counts the received data and sends WINDOWS_UPDATE frames to the other side when needed.
  • + *
  • Outbound - counts the sent data, monitors requested data by received WINDOWS_UPDATE frames + * and blocks the sending until enough data is requested.
  • + *
+ * + * Each, inbound and inbound keeps 2 separate credit windows, connection one which is common to all streams + * and stream window. + */ +public class StreamFlowControl { + private final FlowControl.Outbound outboundFlowControl; + private final FlowControl.Inbound inboundFlowControl; + + StreamFlowControl(ConnectionFlowControl.Type type, + int streamId, + ConnectionFlowControl connectionFlowControl, + BiConsumer writeUpdate) { + + outboundFlowControl = + new FlowControlImpl.Outbound(type, + streamId, + connectionFlowControl); + + inboundFlowControl = + new FlowControlImpl.Inbound(type, + streamId, + WindowSize.DEFAULT_WIN_SIZE, //FIXME: configurable initial win size + WindowSize.DEFAULT_MAX_FRAME_SIZE, //FIXME: configurable initial max frame size + connectionFlowControl.inbound(), + writeUpdate); + } + + /** + * Outbound flow control, ensures that no more than requested + * amount of data is sent to the other side. + * + * @return outbound flow control + */ + public FlowControl.Outbound outbound() { + return outboundFlowControl; + } + + /** + * Inbound flow control, monitors received but by handler unconsumed data and requests + * more when there is enough space in the buffer. + * + * @return inbound flow control + */ + public FlowControl.Inbound inbound() { + return inboundFlowControl; + } +} diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/WindowSize.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/WindowSize.java index ebbbdbb628b..1759405acd4 100644 --- a/nima/http2/http2/src/main/java/io/helidon/nima/http2/WindowSize.java +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/WindowSize.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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,110 +13,128 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - package io.helidon.nima.http2; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.ExecutionException; -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; /** * Window size container, used with {@link io.helidon.nima.http2.FlowControl}. */ -public class WindowSize { +public interface WindowSize { + /** * Default window size. */ - public static final int DEFAULT_WIN_SIZE = 65_535; + int DEFAULT_WIN_SIZE = 65_535; /** - * Maximal window size. + * Default and smallest possible setting for MAX_FRAME_SIZE (2^14). */ - public static final int MAX_WIN_SIZE = Integer.MAX_VALUE; + int DEFAULT_MAX_FRAME_SIZE = 16384; + /** + * Largest possible setting for MAX_FRAME_SIZE (2^24-1). + */ + int MAX_MAX_FRAME_SIZE = 16_777_215; - private final AtomicInteger remainingWindowSize; + /** + * Maximal window size. + */ + int MAX_WIN_SIZE = Integer.MAX_VALUE; - private final AtomicReference> updated = new AtomicReference<>(new CompletableFuture<>()); + /** + * Create inbound window size container with initial window size set. + * + * @param type + * @param streamId + * @param initialWindowSize initial window size + * @param maxFrameSize maximal frame size + * @param windowUpdateWriter writer method for HTTP/2 WINDOW_UPDATE frame + * @return a new window size container + */ + static WindowSize.Inbound createInbound(ConnectionFlowControl.Type type, + int streamId, + int initialWindowSize, + int maxFrameSize, + BiConsumer windowUpdateWriter) { + return new WindowSizeImpl.Inbound(type, streamId, initialWindowSize, maxFrameSize, windowUpdateWriter); + } - WindowSize(int initialWindowSize) { - remainingWindowSize = new AtomicInteger(initialWindowSize); + /** + * Create outbound window size container with initial window size set. + * + * @param type Server or client + * @param streamId stream id + * @param connectionFlowControl connection flow control + * @return a new window size container + */ + static WindowSize.Outbound createOutbound(ConnectionFlowControl.Type type, + int streamId, + ConnectionFlowControl connectionFlowControl) { + return new WindowSizeImpl.Outbound(type, streamId, connectionFlowControl); } /** - * Window size with default initial size. + * Create inbound window size container with flow control turned off. + * + * @param streamId stream id or 0 for connection + * @param windowUpdateWriter WINDOW_UPDATE frame writer + * @return a new window size container */ - public WindowSize() { - remainingWindowSize = new AtomicInteger(DEFAULT_WIN_SIZE); + static WindowSize.Inbound createInboundNoop(int streamId, BiConsumer windowUpdateWriter) { + return new WindowSizeImpl.InboundNoop(streamId, windowUpdateWriter); } /** * Reset window size. * - * @param n window size + * @param size new window size */ - public void resetWindowSize(long n) { - // When the value of SETTINGS_INITIAL_WINDOW_SIZE changes, - // a receiver MUST adjust the size of all stream flow-control windows that - // it maintains by the difference between the new value and the old value - remainingWindowSize.updateAndGet(o -> (int) n - o); - } + void resetWindowSize(int size); /** * Increment window size. * - * @param n increment + * @param increment increment * @return whether the increment succeeded */ - public boolean incrementWindowSize(int n) { - int old = remainingWindowSize.getAndUpdate(r -> MAX_WIN_SIZE - r > n ? n + r : MAX_WIN_SIZE); - triggerUpdate(); - return MAX_WIN_SIZE - old <= n; - } + long incrementWindowSize(int increment); /** * Decrement window size. * * @param decrement decrement + * @return remaining size */ - public void decrementWindowSize(int decrement) { - remainingWindowSize.updateAndGet(operand -> operand - decrement); - } + int decrementWindowSize(int decrement); /** * Remaining window size. * - * @return remaining sze + * @return remaining size */ - public int getRemainingWindowSize() { - return remainingWindowSize.get(); - } + int getRemainingWindowSize(); + // Does not add anything new but having a separate name makes code more human-readable. /** - * Block until window size update. - * - * @return whether update happened before timeout + * Inbound window size container. */ - public boolean blockTillUpdate() { - try { - //TODO configurable timeout - updated.get().get(10, TimeUnit.SECONDS); - return false; - } catch (InterruptedException | ExecutionException | TimeoutException e) { - return true; - } + interface Inbound extends WindowSize { } /** - * Trigger update of window size. + * Outbound window size container. */ - public void triggerUpdate() { - updated.getAndSet(new CompletableFuture<>()).complete(null); - } + interface Outbound extends WindowSize { + + /** + * Trigger update of window size. + */ + void triggerUpdate(); - @Override - public String toString() { - return String.valueOf(remainingWindowSize.get()); + /** + * Block until window size update. + * + */ + void blockTillUpdate(); } + } diff --git a/nima/http2/http2/src/main/java/io/helidon/nima/http2/WindowSizeImpl.java b/nima/http2/http2/src/main/java/io/helidon/nima/http2/WindowSizeImpl.java new file mode 100644 index 00000000000..58f27cde1e0 --- /dev/null +++ b/nima/http2/http2/src/main/java/io/helidon/nima/http2/WindowSizeImpl.java @@ -0,0 +1,338 @@ +/* + * Copyright (c) 2022, 2023 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.nima.http2; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +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; + +/** + * Window size container, used with {@link io.helidon.nima.http2.FlowControl}. + */ +abstract class WindowSizeImpl implements WindowSize { + + private static final System.Logger LOGGER_INBOUND = System.getLogger(FlowControl.class.getName() + ".ifc"); + private static final System.Logger LOGGER_OUTBOUND = System.getLogger(FlowControl.class.getName() + ".ofc"); + + private final ConnectionFlowControl.Type type; + private final int streamId; + private final AtomicInteger remainingWindowSize; + private int windowSize; + + private WindowSizeImpl(ConnectionFlowControl.Type type, int streamId, int initialWindowSize) { + this.type = type; + this.streamId = streamId; + this.windowSize = initialWindowSize; + this.remainingWindowSize = new AtomicInteger(initialWindowSize); + } + + @Override + public void resetWindowSize(int size) { + // When the value of SETTINGS_INITIAL_WINDOW_SIZE changes, + // a receiver MUST adjust the size of all stream flow-control windows that + // it maintains by the difference between the new value and the old value + remainingWindowSize.updateAndGet(o -> o + size - windowSize); + windowSize = size; + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + LOGGER_OUTBOUND.log(DEBUG, String.format("%s OFC STR %d: Recv INITIAL_WINDOW_SIZE %d(%d)", + type, streamId, windowSize, remainingWindowSize.get())); + } + } + + @Override + public long incrementWindowSize(int increment) { + int remaining = remainingWindowSize + .getAndUpdate(r -> r < 0 || MAX_WIN_SIZE - r > increment + ? increment + r + : MAX_WIN_SIZE); + + return remaining + increment; + } + + @Override + public int decrementWindowSize(int decrement) { + return remainingWindowSize.updateAndGet(operand -> operand - decrement); + } + + @Override + public int getRemainingWindowSize() { + return remainingWindowSize.get(); + } + + @Override + public String toString() { + return String.valueOf(remainingWindowSize.get()); + } + + /** + * Inbound window size container. + */ + static final class Inbound extends WindowSizeImpl implements WindowSize.Inbound { + + private final Strategy strategy; + private final ConnectionFlowControl.Type type; + private final int streamId; + + Inbound(ConnectionFlowControl.Type type, + int streamId, + int initialWindowSize, + int maxFrameSize, + BiConsumer windowUpdateWriter) { + super(type, streamId, initialWindowSize); + this.type = type; + this.streamId = streamId; + // Strategy selection based on initialWindowSize and maxFrameSize + this.strategy = Strategy.create(new Strategy.Context(maxFrameSize, initialWindowSize), streamId, windowUpdateWriter); + } + + @Override + public long incrementWindowSize(int increment) { + // 6.9 + // A receiver MUST treat the receipt of a WINDOW_UPDATE frame + // with a flow-control window increment of 0 as a stream error + if (increment > 0) { + long result = super.incrementWindowSize(increment); + strategy.windowUpdate(this.type, this.streamId, increment); + return result; + } + return super.getRemainingWindowSize(); + } + + } + + /** + * Outbound window size container. + */ + static final class Outbound extends WindowSizeImpl implements WindowSize.Outbound { + + private final AtomicReference> updated = new AtomicReference<>(new CompletableFuture<>()); + private final ConnectionFlowControl.Type type; + private final int streamId; + private final long timeoutMillis; + + Outbound(ConnectionFlowControl.Type type, int streamId, ConnectionFlowControl connectionFlowControl) { + super(type, streamId, connectionFlowControl.initialWindowSize()); + this.type = type; + this.streamId = streamId; + this.timeoutMillis = connectionFlowControl.timeout().toMillis(); + } + + @Override + public long incrementWindowSize(int increment) { + long remaining = super.incrementWindowSize(increment); + if (LOGGER_OUTBOUND.isLoggable(DEBUG)) { + LOGGER_OUTBOUND.log(DEBUG, String.format("%s OFC STR %d: +%d(%d)", type, streamId, increment, remaining)); + } + triggerUpdate(); + return remaining; + } + + @Override + public void triggerUpdate() { + updated.getAndSet(new CompletableFuture<>()).complete(null); + } + + @Override + public void blockTillUpdate() { + 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)); + } + } + } + } + } + + /** + * Inbound window size container with flow control turned off. + */ + public static final class InboundNoop implements WindowSize.Inbound { + + private static final int WIN_SIZE_WATERMARK = MAX_WIN_SIZE / 2; + private final int streamId; + private final BiConsumer windowUpdateWriter; + private int delayedIncrement; + + InboundNoop(int streamId, BiConsumer windowUpdateWriter) { + this.streamId = streamId; + this.windowUpdateWriter = windowUpdateWriter; + this.delayedIncrement = 0; + } + + @Override + public long incrementWindowSize(int increment) { + // Send WINDOW_UPDATE frame joined for at least 1/2 of the maximum space + delayedIncrement += increment; + if (delayedIncrement > WIN_SIZE_WATERMARK) { + windowUpdateWriter.accept(streamId, new Http2WindowUpdate(delayedIncrement)); + delayedIncrement = 0; + } + return getRemainingWindowSize(); + } + + @Override + public void resetWindowSize(int size) { + } + + @Override + public int decrementWindowSize(int decrement) { + return WindowSize.MAX_WIN_SIZE; + } + + @Override + public int getRemainingWindowSize() { + return MAX_WIN_SIZE; + } + + @Override + public String toString() { + return String.valueOf(MAX_WIN_SIZE); + } + + } + + abstract static class Strategy { + + // Strategy Type to instance mapping array + private static final StrategyConstructor[] CREATORS = new StrategyConstructor[] { + Simple::new, + Bisection::new + }; + private final Context context; + private final int streamId; + private final BiConsumer windowUpdateWriter; + + private Strategy(Context context, int streamId, BiConsumer windowUpdateWriter) { + this.context = context; + this.streamId = streamId; + this.windowUpdateWriter = windowUpdateWriter; + } + + // Strategy implementation factory + private static Strategy create(Context context, int streamId, BiConsumer windowUpdateWriter) { + return CREATORS[Type.select(context).ordinal()] + .create(context, streamId, windowUpdateWriter); + } + + abstract void windowUpdate(ConnectionFlowControl.Type type, int increment, int i); + + Context context() { + return context; + } + + int streamId() { + return this.streamId; + } + + BiConsumer windowUpdateWriter() { + return windowUpdateWriter; + } + + private enum Type { + /** + * Simple WINDOW_UPDATE strategy. + * Sends update frames as soon as buffer space is restored. + */ + SIMPLE, + /** + * Buffer space bisection strategy. + * Sends update frames when at least half of the buffer space is consumed. + */ + BISECTION; + + private static Type select(Context context) { + // Bisection strategy requires at least 4 frames to be placed inside window + return context.maxFrameSize * 4 <= context.initialWindowSize ? BISECTION : SIMPLE; + } + + } + + private interface StrategyConstructor { + Strategy create(Context context, int streamId, BiConsumer windowUpdateWriter); + } + + private record Context( + int maxFrameSize, + int initialWindowSize) { + } + + /** + * Simple update strategy. + * Sends update frames as soon as buffer space is restored. + */ + private static final class Simple extends Strategy { + + private Simple(Context context, int streamId, BiConsumer windowUpdateWriter) { + super(context, streamId, windowUpdateWriter); + } + + @Override + void windowUpdate(ConnectionFlowControl.Type type, int streamId, int increment) { + if (LOGGER_INBOUND.isLoggable(DEBUG)) { + LOGGER_INBOUND.log(DEBUG, String.format("%s IFC STR %d: Send WINDOW_UPDATE %s", type, streamId, increment)); + } + windowUpdateWriter().accept(streamId(), new Http2WindowUpdate(increment)); + } + + } + + /** + * Buffer space bisection strategy. + * Sends update frames when at least half of the buffer space is consumed. + */ + private static final class Bisection extends Strategy { + + private final int watermark; + private int delayedIncrement; + + private Bisection(Context context, int streamId, BiConsumer windowUpdateWriter) { + super(context, streamId, windowUpdateWriter); + this.delayedIncrement = 0; + this.watermark = context().initialWindowSize() / 2; + } + + @Override + void windowUpdate(ConnectionFlowControl.Type type, int streamId, int increment) { + if (LOGGER_INBOUND.isLoggable(DEBUG)) { + LOGGER_INBOUND.log(DEBUG, String.format("%s IFC STR %d: Deferred WINDOW_UPDATE %d, total %d, watermark %d", + type, streamId, increment, delayedIncrement, watermark)); + } + delayedIncrement += increment; + if (delayedIncrement > watermark) { + if (LOGGER_INBOUND.isLoggable(DEBUG)) { + LOGGER_INBOUND.log(DEBUG, String.format("%s IFC STR %d: Send WINDOW_UPDATE %d, watermark %d", + type, streamId, delayedIncrement, watermark)); + } + windowUpdateWriter().accept(streamId(), new Http2WindowUpdate(delayedIncrement)); + delayedIncrement = 0; + } + } + + } + + } + +} diff --git a/nima/http2/http2/src/test/java/io/helidon/nima/http2/Http2HeadersTest.java b/nima/http2/http2/src/test/java/io/helidon/nima/http2/Http2HeadersTest.java index de51441e3e8..d2c3e247a4a 100644 --- a/nima/http2/http2/src/test/java/io/helidon/nima/http2/Http2HeadersTest.java +++ b/nima/http2/http2/src/test/java/io/helidon/nima/http2/Http2HeadersTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -28,6 +28,7 @@ import io.helidon.nima.http2.Http2Headers.HeaderRecord; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; @@ -163,7 +164,7 @@ void testC_3() { @Test void testC_4_toBytes() { DynamicTable dynamicTable = DynamicTable.create(Http2Settings.create()); - WritableHeaders headers = WritableHeaders.create(); + WritableHeaders headers = WritableHeaders.create(); Http2Headers http2Headers = Http2Headers.create(headers); http2Headers.method(Http.Method.GET); http2Headers.scheme("http"); @@ -256,74 +257,12 @@ private Http2Headers headers(String hexEncoded, DynamicTable dynamicTable) { Http2FrameTypes.HEADERS, Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS), 1); - return Http2Headers.create(stream(), + + Http2Stream stream = Mockito.mock(Http2Stream.class); + + return Http2Headers.create(stream, dynamicTable, new Http2HuffmanDecoder(), new Http2FrameData(header, data)); } - - private Http2Stream stream() { - return new Http2Stream() { - @Override - public void rstStream(Http2RstStream rstStream) { - - } - - @Override - public void windowUpdate(Http2WindowUpdate windowUpdate) { - - } - - @Override - public void headers(Http2Headers headers, boolean endOfStream) { - - } - - @Override - public void data(Http2FrameHeader header, BufferData data) { - - } - - @Override - public void priority(Http2Priority http2Priority) { - - } - - @Override - public int streamId() { - return 1; - } - - @Override - public Http2StreamState streamState() { - return Http2StreamState.IDLE; - } - - @Override - public FlowControl flowControl() { - return FlowControl.NOOP; - } - }; - } - - private static class DevNullWriter implements Http2StreamWriter { - - @Override - public void write(Http2FrameData frame, FlowControl flowControl) { - } - - @Override - public int writeHeaders(Http2Headers headers, int streamId, Http2Flag.HeaderFlags flags, FlowControl flowControl) { - return 0; - } - - @Override - public int writeHeaders(Http2Headers headers, - int streamId, - Http2Flag.HeaderFlags flags, - Http2FrameData dataFrame, - FlowControl flowControl) { - return 0; - } - } } \ No newline at end of file diff --git a/nima/http2/http2/src/test/java/io/helidon/nima/http2/MaxFrameSizeSplitTest.java b/nima/http2/http2/src/test/java/io/helidon/nima/http2/MaxFrameSizeSplitTest.java new file mode 100644 index 00000000000..badecf1fa0a --- /dev/null +++ b/nima/http2/http2/src/test/java/io/helidon/nima/http2/MaxFrameSizeSplitTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2023 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.nima.http2; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.stream.Stream; + +import io.helidon.common.buffers.BufferData; +import io.helidon.logging.common.LogConfig; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; + +import static java.lang.System.Logger.Level.DEBUG; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class MaxFrameSizeSplitTest { + + private static final System.Logger LOGGER = System.getLogger(MaxFrameSizeSplitTest.class.getName()); + + private static final String TEST_STRING = "Helidon data!!!!"; + private static final byte[] TEST_DATA = TEST_STRING.getBytes(StandardCharsets.UTF_8); + + @BeforeAll + static void beforeAll() { + LogConfig.configureRuntime(); + } + + private static Stream splitMultiple() { + return Stream.of(new SplitTest(17, 1, 16), + new SplitTest(16, 1, 16), + new SplitTest(15, 2, 1), + new SplitTest(14, 2, 2), + new SplitTest(13, 2, 3), + new SplitTest(12, 2, 4), + new SplitTest(11, 2, 5), + new SplitTest(10, 2, 6), + new SplitTest(9, 2, 7), + new SplitTest(8, 2, 8), + new SplitTest(7, 3, 2), + new SplitTest(6, 3, 4), + new SplitTest(5, 4, 1), + new SplitTest(4, 4, 4), + new SplitTest(3, 6, 1), + new SplitTest(2, 8, 2), + new SplitTest(1, 16, 1) + ); + } + + @ParameterizedTest + @MethodSource + void splitMultiple(SplitTest args) { + Http2FrameData frameData = createFrameData(TEST_DATA); + Http2FrameData[] split = frameData.split(args.sizeOfFrames()); + assertThat("Unexpected number of frames", split.length, is(args.numberOfFrames())); + + BufferData joined = Stream.of(split) + .collect(() -> BufferData.create(TEST_DATA.length), + (bb, b) -> bb.write(b.data()), + (bb, bb2) -> { + }); + + assertThat("Result after split and join differs", + joined.readString(joined.available(), StandardCharsets.UTF_8), + is(TEST_STRING)); + + // Reload data depleted by previous test + split = createFrameData(TEST_DATA).split(args.sizeOfFrames()); + + for (int i = 0; i < args.numberOfFrames() - 1; i++) { + Http2FrameData frame = split[i]; + assertThat("Only last frame can have endOfStream flag", + frame.header().flags(Http2FrameTypes.DATA).endOfStream(), + is(false)); + + byte[] bytes = toBytes(frame); + LOGGER.log(DEBUG, i + ". frame: " + Arrays.toString(bytes)); + assertThat("Unexpected size of frame " + i, bytes.length, is(args.sizeOfFrames())); + } + + Http2FrameData lastFrame = split[args.numberOfFrames() - 1]; + assertThat("Last frame is missing endOfStream flag", + lastFrame.header().flags(Http2FrameTypes.DATA).endOfStream(), + is(true)); + + byte[] bytes = toBytes(lastFrame); + LOGGER.log(DEBUG, args.numberOfFrames() - 1 + ". frame: " + Arrays.toString(bytes)); + assertThat("Unexpected size of the last frame", bytes.length, is(args.sizeOfLastFrame())); + } + + private Http2FrameData createFrameData(byte[] data) { + Http2FrameHeader http2FrameHeader = Http2FrameHeader.create(data.length, + Http2FrameTypes.DATA, + Http2Flag.DataFlags.create(Http2Flag.DataFlags.END_OF_STREAM), + 1); + return new Http2FrameData(http2FrameHeader, BufferData.create(data)); + } + + private byte[] toBytes(Http2FrameData frameData) { + return toBytes(frameData.data()); + } + + private byte[] toBytes(BufferData data) { + byte[] b = new byte[data.available()]; + data.read(b); + return b; + } + + private record SplitTest(int sizeOfFrames, + int numberOfFrames, + int sizeOfLastFrame) { + + } +} diff --git a/nima/http2/http2/src/test/resources/logging-test.properties b/nima/http2/http2/src/test/resources/logging-test.properties new file mode 100644 index 00000000000..12ca00d657e --- /dev/null +++ b/nima/http2/http2/src/test/resources/logging-test.properties @@ -0,0 +1,23 @@ +# +# Copyright (c) 2023 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. +# +handlers=io.helidon.logging.jul.HelidonConsoleHandler +# HelidonConsoleHandler uses a SimpleFormatter subclass that replaces "!thread!" with the current thread +java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS %4$s %3$s !thread!: %5$s%6$s%n +#java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS.%1$tL %5$s%6$s%n +# Global logging level. Can be overridden by specific loggers +.level=INFO +io.helidon.nima.level=INFO +#io.helidon.nima.http2.MaxFrameSizeSplitTest.level=ALL diff --git a/nima/http2/webclient/pom.xml b/nima/http2/webclient/pom.xml index 6ff8dbca21e..1f544a7e635 100644 --- a/nima/http2/webclient/pom.xml +++ b/nima/http2/webclient/pom.xml @@ -60,6 +60,22 @@ hamcrest-all test + + io.helidon.nima.http2 + helidon-nima-http2-webserver + test + + + io.helidon.nima.testing.junit5 + + helidon-nima-testing-junit5-webserver + test + + + org.junit.jupiter + junit-jupiter-params + test + diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java index d1c4e456e47..dbcde109033 100644 --- a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ClientRequestImpl.java @@ -20,12 +20,14 @@ import java.io.UncheckedIOException; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.time.Duration; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.function.Consumer; import java.util.function.Function; +import io.helidon.common.Version; import io.helidon.common.buffers.BufferData; import io.helidon.common.http.ClientRequestHeaders; import io.helidon.common.http.Http; @@ -38,37 +40,46 @@ import io.helidon.nima.http2.Http2Headers; import io.helidon.nima.webclient.ClientConnection; import io.helidon.nima.webclient.ClientRequest; -import io.helidon.nima.webclient.ConnectionKey; import io.helidon.nima.webclient.UriHelper; class ClientRequestImpl implements Http2ClientRequest { + static final HeaderValue USER_AGENT_HEADER = Header.create(Header.USER_AGENT, "Helidon Nima " + Version.VERSION); + //todo Gracefully close connections in channel cache private static final Map CHANNEL_CACHE = new ConcurrentHashMap<>(); - - private WritableHeaders explicitHeaders = WritableHeaders.create(); - private final Http2ClientImpl client; - private final ExecutorService executor; private final Http.Method method; private final UriHelper uri; private final UriQueryWriteable query; + private final int initialWindowSize; + private final int maxFrameSize; + private final long maxHeaderListSize; + private final int connectionPrefetch; + + private WritableHeaders explicitHeaders = WritableHeaders.create(); private Tls tls; private int priority; private boolean priorKnowledge; + private int requestPrefetch = 0; private ClientConnection explicitConnection; + private Duration flowControlTimeout = Duration.ofMillis(100); + private Duration timeout = Duration.ofSeconds(10); ClientRequestImpl(Http2ClientImpl client, ExecutorService executor, Http.Method method, UriHelper helper, - boolean priorKnowledge, Tls tls, UriQueryWriteable query) { this.client = client; this.executor = executor; this.method = method; this.uri = helper; - this.priorKnowledge = priorKnowledge; + this.priorKnowledge = client.priorKnowledge(); + this.initialWindowSize = client.initialWindowSize(); + this.maxFrameSize = client.maxFrameSize(); + this.maxHeaderListSize = client.maxHeaderListSize(); + this.connectionPrefetch = client.prefetch(); this.tls = tls == null || !tls.enabled() ? null : tls; this.query = query; } @@ -104,7 +115,8 @@ public Http2ClientRequest pathParam(String name, String value) { @Override public Http2ClientRequest queryParam(String name, String... values) { - throw new UnsupportedOperationException("Not implemented"); + query.set(name, values); + return this; } @Override @@ -127,9 +139,13 @@ public Http2ClientResponse submit(Object entity) { entityBytes = entityBytes(entity); } headers.set(Header.create(Header.CONTENT_LENGTH, entityBytes.length)); + headers.setIfAbsent(USER_AGENT_HEADER); Http2Headers http2Headers = prepareHeaders(headers); stream.write(http2Headers, entityBytes.length == 0); + + stream.flowControl().inbound().incrementWindowSize(requestPrefetch); + if (entityBytes.length != 0) { stream.writeData(BufferData.create(entityBytes), true); } @@ -190,6 +206,24 @@ public Http2ClientRequest priorKnowledge(boolean priorKnowledge) { return this; } + @Override + public Http2ClientRequest requestPrefetch(int requestPrefetch) { + this.requestPrefetch = requestPrefetch; + return this; + } + + @Override + public Http2ClientRequest timeout(Duration timeout) { + this.timeout = timeout; + return this; + } + + @Override + public Http2ClientRequest flowControlTimeout(Duration timeout) { + this.flowControlTimeout = timeout; + return this; + } + UriHelper uriHelper() { return uri; } @@ -226,9 +260,19 @@ private Http2Headers prepareHeaders(WritableHeaders headers) { private Http2ClientStream reserveStream() { if (explicitConnection == null) { - ConnectionKey connectionKey = new ConnectionKey(uri.scheme(), + return newStream(uri); + } else { + throw new UnsupportedOperationException("Explicit connection not (yet) supported for HTTP/2 client"); + } + } + + private Http2ClientStream newStream(UriHelper uri) { + try { + ConnectionKey connectionKey = new ConnectionKey(method, + uri.scheme(), uri.host(), uri.port(), + priorKnowledge, tls, client.dnsResolver(), client.dnsAddressLookup()); @@ -237,11 +281,20 @@ private Http2ClientStream reserveStream() { return CHANNEL_CACHE.computeIfAbsent(connectionKey, key -> new Http2ClientConnectionHandler(executor, SocketOptions.builder().build(), + uri.path(), key)) // this statement may block a single connection key - .newStream(priorKnowledge, priority); - } else { - throw new UnsupportedOperationException("Explicit connection not (yet) supported for HTTP/2 client"); + .newStream(new ConnectionContext(priority, + priorKnowledge, + initialWindowSize, + maxFrameSize, + maxHeaderListSize, + connectionPrefetch, + requestPrefetch, + flowControlTimeout, + timeout)); + } catch (UpgradeRedirectException e) { + return newStream(UriHelper.create(URI.create(e.redirectUri()), UriQueryWriteable.create())); } } diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ConnectionContext.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ConnectionContext.java new file mode 100644 index 00000000000..2b5ecc354ce --- /dev/null +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ConnectionContext.java @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 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.nima.http2.webclient; + +import java.time.Duration; + +record ConnectionContext(int priority, + boolean priorKnowledge, + int initialWindowSize, + int maxFrameSize, + long maxHeaderListSize, + int connectionPrefetch, + int requestPrefetch, + Duration flowControlTimeout, + Duration timeout) { +} diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ConnectionKey.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ConnectionKey.java new file mode 100644 index 00000000000..9edcb1d4758 --- /dev/null +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/ConnectionKey.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022, 2023 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.nima.http2.webclient; + +import io.helidon.common.http.Http; +import io.helidon.nima.common.tls.Tls; +import io.helidon.nima.webclient.DnsAddressLookup; +import io.helidon.nima.webclient.spi.DnsResolver; + +record ConnectionKey(Http.Method method, String scheme, String host, int port, boolean priorKnowledge, Tls tls, + DnsResolver dnsResolver, DnsAddressLookup dnsAddressLookup) { +} diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2Client.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2Client.java index 0f1a8de6517..010e164c5dd 100644 --- a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2Client.java +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2Client.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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,7 @@ package io.helidon.nima.http2.webclient; +import io.helidon.nima.http2.WindowSize; import io.helidon.nima.webclient.HttpClient; import io.helidon.nima.webclient.WebClient; @@ -39,6 +40,10 @@ static Http2ClientBuilder builder() { class Http2ClientBuilder extends WebClient.Builder { private boolean priorKnowledge; + private int maxFrameSize = WindowSize.DEFAULT_MAX_FRAME_SIZE; + private long maxHeaderListSize = -1; + private int initialWindowSize = WindowSize.DEFAULT_WIN_SIZE; + private int prefetch = 33554432; private Http2ClientBuilder() { } @@ -65,6 +70,59 @@ public Http2ClientBuilder priorKnowledge(boolean priorKnowledge) { return this; } + /** + * Configure initial MAX_FRAME_SIZE setting for new HTTP/2 connections. + * Maximum size of data frames in bytes the client is prepared to accept from the server. + * Default value is 2^14(16_384). + * + * @param maxFrameSize data frame size in bytes between 2^14(16_384) and 2^24-1(16_777_215) + * @return updated client + */ + public Http2ClientBuilder maxFrameSize(int maxFrameSize) { + if (maxFrameSize < WindowSize.DEFAULT_MAX_FRAME_SIZE || maxFrameSize > WindowSize.MAX_MAX_FRAME_SIZE) { + throw new IllegalArgumentException( + "Max frame size needs to be a number between 2^14(16_384) and 2^24-1(16_777_215)" + ); + } + this.maxFrameSize = maxFrameSize; + return this; + } + + /** + * Configure initial MAX_HEADER_LIST_SIZE setting for new HTTP/2 connections. + * Sends to the server the maximum header field section size client is prepared to accept. + * + * @param maxHeaderListSize units of octets + * @return updated client + */ + public Http2ClientBuilder maxHeaderListSize(long maxHeaderListSize){ + this.maxHeaderListSize = maxHeaderListSize; + return this; + } + + /** + * Configure INITIAL_WINDOW_SIZE setting for new HTTP/2 connections. + * Sends to the server the size of the largest frame payload client is willing to receive. + * + * @param initialWindowSize units of octets + * @return updated client + */ + public Http2ClientBuilder initialWindowSize(int initialWindowSize){ + this.initialWindowSize = initialWindowSize; + return this; + } + + /** + * First connection window update increment sent right after the connection is established. + * + * @param prefetch number of bytes the client is prepared to receive as data from all the streams combined + * @return updated client + */ + public Http2ClientBuilder prefetch(int prefetch) { + this.prefetch = prefetch; + return this; + } + @Override public Http2Client build() { return new Http2ClientImpl(this); @@ -73,5 +131,21 @@ public Http2Client build() { boolean priorKnowledge() { return priorKnowledge; } + + long maxHeaderListSize() { + return maxHeaderListSize; + } + + int initialWindowSize() { + return initialWindowSize; + } + + int prefetch() { + return prefetch; + } + + int maxFrameSize() { + return this.maxFrameSize; + } } } diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnection.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnection.java index 68fa36f01d1..6ba8c32c75a 100644 --- a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnection.java +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnection.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -17,55 +17,120 @@ package io.helidon.nima.http2.webclient; import java.io.IOException; -import java.io.InputStream; import java.io.UncheckedIOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.Socket; +import java.nio.charset.StandardCharsets; import java.security.cert.Certificate; import java.security.cert.X509Certificate; +import java.util.Base64; +import java.util.HashMap; import java.util.HexFormat; import java.util.List; +import java.util.Map; import java.util.concurrent.ExecutorService; +import java.util.concurrent.Future; import javax.net.ssl.SSLPeerUnverifiedException; import javax.net.ssl.SSLSession; import javax.net.ssl.SSLSocket; +import io.helidon.common.buffers.BufferData; +import io.helidon.common.buffers.DataReader; import io.helidon.common.buffers.DataWriter; +import io.helidon.common.http.Http; +import io.helidon.common.http.Http1HeadersParser; +import io.helidon.common.http.WritableHeaders; import io.helidon.common.socket.PlainSocket; import io.helidon.common.socket.SocketOptions; import io.helidon.common.socket.SocketWriter; import io.helidon.common.socket.TlsSocket; +import io.helidon.nima.http2.ConnectionFlowControl; import io.helidon.nima.http2.Http2ConnectionWriter; -import io.helidon.nima.webclient.ConnectionKey; +import io.helidon.nima.http2.Http2ErrorCode; +import io.helidon.nima.http2.Http2Exception; +import io.helidon.nima.http2.Http2Flag; +import io.helidon.nima.http2.Http2FrameData; +import io.helidon.nima.http2.Http2FrameHeader; +import io.helidon.nima.http2.Http2FrameListener; +import io.helidon.nima.http2.Http2FrameTypes; +import io.helidon.nima.http2.Http2GoAway; +import io.helidon.nima.http2.Http2Headers; +import io.helidon.nima.http2.Http2LoggingFrameListener; +import io.helidon.nima.http2.Http2Ping; +import io.helidon.nima.http2.Http2RstStream; +import io.helidon.nima.http2.Http2Setting; +import io.helidon.nima.http2.Http2Settings; +import io.helidon.nima.http2.Http2WindowUpdate; +import io.helidon.nima.http2.WindowSize; import io.helidon.nima.webclient.spi.DnsResolver; import static java.lang.System.Logger.Level.DEBUG; import static java.lang.System.Logger.Level.TRACE; +import static java.lang.System.Logger.Level.WARNING; class Http2ClientConnection { private static final System.Logger LOGGER = System.getLogger(Http2ClientConnection.class.getName()); - + private static final int FRAME_HEADER_LENGTH = 9; + private static final String UPGRADE_REQ_MASK = """ + %s %s HTTP/1.1\r + Host: %s:%s\r + Connection: Upgrade, HTTP2-Settings\r + Upgrade: h2c\r + HTTP2-Settings: %s\r\n\r + """; + private static final byte[] PRIOR_KNOWLEDGE_PREFACE = + "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".getBytes(StandardCharsets.UTF_8); + private final Http2FrameListener sendListener = new Http2LoggingFrameListener("cl-send"); + private final Http2FrameListener recvListener = new Http2LoggingFrameListener("cl-recv"); private final ExecutorService executor; private final SocketOptions socketOptions; private final ConnectionKey connectionKey; - private final boolean priorKnowledge; - + private final String primaryPath; + private final LockingStreamIdSequence streamIdSeq = new LockingStreamIdSequence(); + private final Map streams = new HashMap<>(); + private final ConnectionFlowControl connectionFlowControl; + private final ConnectionContext connectionContext; + private final Http2Headers.DynamicTable inboundDynamicTable = + Http2Headers.DynamicTable.create(Http2Setting.HEADER_TABLE_SIZE.defaultValue()); + private Http2Settings serverSettings = Http2Settings.builder() + .build(); private String channelId; private Socket socket; private PlainSocket helidonSocket; private Http2ConnectionWriter writer; - private InputStream inputStream; + private DataReader reader; + private DataWriter dataWriter; + private Future handleTask; Http2ClientConnection(ExecutorService executor, SocketOptions socketOptions, ConnectionKey connectionKey, - boolean priorKnowledge) { + String primaryPath, + ConnectionContext connectionContext) { this.executor = executor; this.socketOptions = socketOptions; this.connectionKey = connectionKey; - this.priorKnowledge = priorKnowledge; + this.primaryPath = primaryPath; + this.connectionContext = connectionContext; + this.connectionFlowControl = ConnectionFlowControl.clientBuilder(this::writeWindowsUpdate) + .maxFrameSize(connectionContext.maxFrameSize()) + .initialWindowSize(connectionContext.initialWindowSize()) + .blockTimeout(connectionContext.timeout()) + .build(); + } + + Http2ConnectionWriter writer() { + return writer; + } + + Http2Headers.DynamicTable getInboundDynamicTable() { + return this.inboundDynamicTable; + } + + ConnectionFlowControl flowControl() { + return this.connectionFlowControl; } Http2ClientConnection connect() { @@ -85,30 +150,170 @@ Http2ClientConnection connect() { return this; } - Http2ClientStream stream(int priority) { - return null; + Http2ClientStream stream(int streamId) { + return streams.get(streamId); + } + + Http2ClientStream createStream(int priority) { + //FIXME: priority + return new Http2ClientStream(this, + serverSettings, + helidonSocket, + connectionContext, + streamIdSeq); + } + + void addStream(int streamId, Http2ClientStream stream) { + this.streams.put(streamId, stream); + } + + void removeStream(int streamId) { + this.streams.remove(streamId); } Http2ClientStream tryStream(int priority) { try { - return stream(priority); - } catch (IllegalStateException e) { + return createStream(priority); + } catch (IllegalStateException | UncheckedIOException e) { return null; } } void close() { try { + handleTask.cancel(true); socket.close(); } catch (IOException e) { e.printStackTrace(); } } + private void writeWindowsUpdate(int streamId, Http2WindowUpdate windowUpdateFrame) { + writer.write(windowUpdateFrame.toFrameData(serverSettings, streamId, Http2Flag.NoFlags.create())); + } + + private void handle() { + this.reader.ensureAvailable(); + BufferData frameHeaderBuffer = this.reader.readBuffer(FRAME_HEADER_LENGTH); + Http2FrameHeader frameHeader = Http2FrameHeader.create(frameHeaderBuffer); + frameHeader.type().checkLength(frameHeader.length()); + BufferData data; + if (frameHeader.length() != 0) { + data = this.reader.readBuffer(frameHeader.length()); + } else { + data = BufferData.empty(); + } + + int streamId = frameHeader.streamId(); + + switch (frameHeader.type()) { + case GO_AWAY: + Http2GoAway http2GoAway = Http2GoAway.create(data); + recvListener.frameHeader(helidonSocket, frameHeader); + recvListener.frame(helidonSocket, http2GoAway); + this.close(); + throw new IllegalStateException("Connection closed by the other side, error code: " + + http2GoAway.errorCode() + + " lastStreamId: " + http2GoAway.lastStreamId()); + + case SETTINGS: + serverSettings = Http2Settings.create(data); + recvListener.frameHeader(helidonSocket, frameHeader); + recvListener.frame(helidonSocket, serverSettings); + // §4.3.1 Endpoint communicates the size chosen by its HPACK decoder context + inboundDynamicTable.protocolMaxTableSize(serverSettings.value(Http2Setting.HEADER_TABLE_SIZE)); + if (serverSettings.hasValue(Http2Setting.MAX_FRAME_SIZE)) { + connectionFlowControl.resetMaxFrameSize(serverSettings.value(Http2Setting.MAX_FRAME_SIZE).intValue()); + } + // §6.5.2 Update initial window size for new streams and window sizes of all already existing streams + if (serverSettings.hasValue(Http2Setting.INITIAL_WINDOW_SIZE)) { + Long initWinSizeLong = serverSettings.value(Http2Setting.INITIAL_WINDOW_SIZE); + if (initWinSizeLong > WindowSize.MAX_WIN_SIZE) { + goAway(streamId, Http2ErrorCode.FLOW_CONTROL, "Window size too big. Max: "); + throw new Http2Exception(Http2ErrorCode.PROTOCOL, + "Received too big INITIAL_WINDOW_SIZE " + initWinSizeLong); + } + int initWinSize = initWinSizeLong.intValue(); + connectionFlowControl.resetInitialWindowSize(initWinSize); + streams.values().forEach(stream -> stream.flowControl().outbound().resetStreamWindowSize(initWinSize)); + + } + // §6.5.3 Settings Synchronization + ackSettings(); + //FIXME: Max number of concurrent streams + return; + + case WINDOW_UPDATE: + Http2WindowUpdate windowUpdate = Http2WindowUpdate.create(data); + recvListener.frameHeader(helidonSocket, frameHeader); + recvListener.frame(helidonSocket, windowUpdate); + // Outbound flow-control window update + if (streamId == 0) { + int increment = windowUpdate.windowSizeIncrement(); + boolean overflow; + // overall connection + if (increment == 0) { + Http2GoAway frame = new Http2GoAway(0, Http2ErrorCode.PROTOCOL, "Window size 0"); + writer.write(frame.toFrameData(serverSettings, 0, Http2Flag.NoFlags.create())); + } + overflow = connectionFlowControl.incrementOutboundConnectionWindowSize(increment) > WindowSize.MAX_WIN_SIZE; + if (overflow) { + Http2GoAway frame = new Http2GoAway(0, Http2ErrorCode.FLOW_CONTROL, "Window size too big. Max: "); + writer.write(frame.toFrameData(serverSettings, 0, Http2Flag.NoFlags.create())); + } + + } else { + stream(streamId) + .windowUpdate(windowUpdate); + } + return; + case PING: + if (streamId != 0) { + throw new Http2Exception(Http2ErrorCode.PROTOCOL, + "Received ping for a stream " + streamId); + } + if (frameHeader.length() != 8) { + throw new Http2Exception(Http2ErrorCode.FRAME_SIZE, + "Received ping with wrong size. Should be 8 bytes, is " + frameHeader.length()); + } + if (!frameHeader.flags(Http2FrameTypes.PING).ack()) { + Http2Ping ping = Http2Ping.create(data); + recvListener.frame(helidonSocket, ping); + BufferData frame = ping.data(); + Http2FrameHeader header = Http2FrameHeader.create(frame.available(), + Http2FrameTypes.PING, + Http2Flag.PingFlags.create(Http2Flag.ACK), + 0); + writer.write(new Http2FrameData(header, frame)); + } + break; + + case RST_STREAM: + Http2RstStream rstStream = Http2RstStream.create(data); + recvListener.frame(helidonSocket, rstStream); + stream(streamId).rstStream(rstStream); + break; + + case DATA: + Http2ClientStream stream = stream(streamId); + stream.flowControl().inbound().decrementWindowSize(frameHeader.length()); + stream.push(new Http2FrameData(frameHeader, data)); + break; + + case HEADERS, CONTINUATION: + stream(streamId).push(new Http2FrameData(frameHeader, data)); + return; + + default: + LOGGER.log(WARNING, "Unsupported frame type!! " + frameHeader.type()); + } + + } + private void doConnect() throws IOException { - SSLSocket sslSocket = connectionKey.tls() == null - ? null - : connectionKey.tls().createSocket("h2"); + boolean useTls = "https".equals(connectionKey.scheme()) && connectionKey.tls() != null; + + SSLSocket sslSocket = useTls ? connectionKey.tls().createSocket("h2") : null; socket = sslSocket == null ? new Socket() : sslSocket; socketOptions.configureSocket(socket); @@ -126,9 +331,9 @@ private void doConnect() throws IOException { ? PlainSocket.client(socket, channelId) : TlsSocket.client(sslSocket, channelId); - DataWriter writer = SocketWriter.create(executor, helidonSocket, 32); - inputStream = socket.getInputStream(); - this.writer = new Http2ConnectionWriter(helidonSocket, writer, List.of()); + dataWriter = SocketWriter.create(executor, helidonSocket, 32); + this.reader = new DataReader(helidonSocket); + this.writer = new Http2ConnectionWriter(helidonSocket, dataWriter, List.of()); if (sslSocket != null) { sslSocket.startHandshake(); @@ -141,6 +346,99 @@ private void doConnect() throws IOException { throw new IllegalStateException("Failed to negotiate h2 protocol. Protocol from socket: " + negotiatedProtocol); } } + + if (!connectionContext.priorKnowledge() && !useTls) { + httpUpgrade(); + // Settings are part of the HTTP/1 upgrade request + sendPreface(false); + } else { + sendPreface(true); + } + + handleTask = executor.submit(() -> { + while (!Thread.interrupted()) { + handle(); + } + LOGGER.log(DEBUG, () -> "Client listener interrupted"); + }); + } + + private void ackSettings() { + Http2Flag.SettingsFlags flags = Http2Flag.SettingsFlags.create(Http2Flag.ACK); + Http2Settings http2Settings = Http2Settings.create(); + Http2FrameData frameData = http2Settings.toFrameData(null, 0, flags); + sendListener.frameHeader(helidonSocket, frameData.header()); + sendListener.frame(helidonSocket, http2Settings); + writer.write(frameData); + } + + private void goAway(int streamId, Http2ErrorCode errorCode, String msg) { + Http2Settings http2Settings = Http2Settings.create(); + Http2GoAway frame = new Http2GoAway(streamId, errorCode, msg); + writer.write(frame.toFrameData(http2Settings, 0, Http2Flag.NoFlags.create())); + } + + private void sendPreface(boolean sendSettings) { + dataWriter.writeNow(BufferData.create(PRIOR_KNOWLEDGE_PREFACE)); + if (sendSettings) { + // §3.5 Preface bytes must be followed by setting frame + Http2Settings.Builder b = Http2Settings.builder(); + if (connectionContext.maxHeaderListSize() > 0) { + b.add(Http2Setting.MAX_HEADER_LIST_SIZE, connectionContext.maxHeaderListSize()); + } + Http2Settings http2Settings = b + .add(Http2Setting.INITIAL_WINDOW_SIZE, (long) connectionContext.initialWindowSize()) + .add(Http2Setting.MAX_FRAME_SIZE, (long) connectionContext.maxFrameSize()) + .add(Http2Setting.ENABLE_PUSH, false) + .build(); + + Http2Flag.SettingsFlags flags = Http2Flag.SettingsFlags.create(0); + Http2FrameData frameData = http2Settings.toFrameData(null, 0, flags); + sendListener.frameHeader(helidonSocket, frameData.header()); + sendListener.frame(helidonSocket, http2Settings); + writer.write(frameData); + } + // First connection window update, with prefetch increment + Http2WindowUpdate windowUpdate = new Http2WindowUpdate(connectionContext.connectionPrefetch()); + Http2Flag.NoFlags flags = Http2Flag.NoFlags.create(); + Http2FrameData frameData = windowUpdate.toFrameData(null, 0, flags); + sendListener.frameHeader(helidonSocket, frameData.header()); + sendListener.frame(helidonSocket, windowUpdate); + writer.write(frameData); + } + + private void httpUpgrade() { + Http2FrameData settingsFrame = Http2Settings.create().toFrameData(null, 0, Http2Flag.SettingsFlags.create(0)); + BufferData upgradeRequest = createUpgradeRequest(settingsFrame); + sendListener.frame(helidonSocket, upgradeRequest); + dataWriter.writeNow(upgradeRequest); + reader.skip("HTTP/1.1 ".length()); + String status = reader.readAsciiString(3); + String message = reader.readLine(); + WritableHeaders headers = Http1HeadersParser.readHeaders(reader, 8192, false); + switch (status) { + case "101": + break; + case "301": + throw new UpgradeRedirectException(headers.get(Http.Header.LOCATION).value()); + default: + close(); + throw new IllegalStateException("Upgrade to HTTP/2 failed: " + message); + } + } + + private BufferData createUpgradeRequest(Http2FrameData settingsFrame) { + BufferData settingsData = settingsFrame.data(); + byte[] b = new byte[settingsData.available()]; + settingsData.write(b); + + return BufferData.create(UPGRADE_REQ_MASK.formatted( + connectionKey.method().text(), + this.primaryPath != null && !"".equals(this.primaryPath) ? primaryPath : "/", + connectionKey.host(), + connectionKey.port(), + Base64.getEncoder().encodeToString(b) + )); } private void debugTls(SSLSocket sslSocket) { diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnectionHandler.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnectionHandler.java index 8d7eed678b9..f9ecc29f166 100644 --- a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnectionHandler.java +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientConnectionHandler.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -23,7 +23,6 @@ import java.util.concurrent.atomic.AtomicReference; import io.helidon.common.socket.SocketOptions; -import io.helidon.nima.webclient.ConnectionKey; // a representation of a single remote endpoint // this may use one or more connections (depending on parallel streams) @@ -33,20 +32,23 @@ class Http2ClientConnectionHandler { private final ExecutorService executor; private final SocketOptions socketOptions; + private String primaryPath; private final ConnectionKey connectionKey; private final AtomicReference activeConnection = new AtomicReference<>(); - // simple solutio for now + // simple solution for now private final Semaphore semaphore = new Semaphore(1); Http2ClientConnectionHandler(ExecutorService executor, SocketOptions socketOptions, + String primaryPath, ConnectionKey connectionKey) { this.executor = executor; this.socketOptions = socketOptions; + this.primaryPath = primaryPath; this.connectionKey = connectionKey; } - public Http2ClientStream newStream(boolean priorKnowledge, int priority) { + public Http2ClientStream newStream(ConnectionContext ctx) { try { semaphore.acquire(); } catch (InterruptedException e) { @@ -57,13 +59,13 @@ public Http2ClientStream newStream(boolean priorKnowledge, int priority) { Http2ClientConnection conn = activeConnection.get(); Http2ClientStream stream; if (conn == null) { - conn = createConnection(connectionKey, priorKnowledge); - stream = conn.stream(priority); + conn = createConnection(connectionKey, ctx); + stream = conn.createStream(ctx.priority()); } else { - stream = conn.tryStream(priority); + stream = conn.tryStream(ctx.priority()); if (stream == null) { - conn = createConnection(connectionKey, priorKnowledge); - stream = conn.stream(priority); + conn = createConnection(connectionKey, ctx); + stream = conn.createStream(ctx.priority()); } } @@ -73,8 +75,9 @@ public Http2ClientStream newStream(boolean priorKnowledge, int priority) { } } - private Http2ClientConnection createConnection(ConnectionKey connectionKey, boolean priorKnowledge) { - Http2ClientConnection conn = new Http2ClientConnection(executor, socketOptions, connectionKey, priorKnowledge); + private Http2ClientConnection createConnection(ConnectionKey connectionKey, ConnectionContext connectionContext) { + Http2ClientConnection conn = + new Http2ClientConnection(executor, socketOptions, connectionKey, primaryPath, connectionContext); conn.connect(); activeConnection.set(conn); fullConnections.add(conn); diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientImpl.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientImpl.java index 0df67d7e7a5..def89bde5e4 100644 --- a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientImpl.java +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientImpl.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -23,11 +23,19 @@ class Http2ClientImpl extends LoomClient implements Http2Client { - private boolean priorKnowledge; + private final int maxFrameSize; + private final long maxHeaderListSize; + private final int initialWindowSize; + private final int prefetch; + private final boolean priorKnowledge; Http2ClientImpl(Http2ClientBuilder builder) { super(builder); this.priorKnowledge = builder.priorKnowledge(); + this.maxFrameSize = builder.maxFrameSize(); + this.maxHeaderListSize = builder.maxHeaderListSize(); + this.initialWindowSize = builder.initialWindowSize(); + this.prefetch = builder.prefetch(); } @Override @@ -35,6 +43,26 @@ public Http2ClientRequest method(Http.Method method) { UriQueryWriteable query = UriQueryWriteable.create(); UriHelper helper = (uri() == null) ? UriHelper.create() : UriHelper.create(uri(), query); - return new ClientRequestImpl(this, executor(), method, helper, priorKnowledge, tls(), query); + return new ClientRequestImpl(this, executor(), method, helper, tls(), query); + } + + long maxHeaderListSize() { + return maxHeaderListSize; + } + + int initialWindowSize() { + return initialWindowSize; + } + + int prefetch() { + return prefetch; + } + + boolean priorKnowledge() { + return priorKnowledge; + } + + int maxFrameSize() { + return this.maxFrameSize; } } diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientRequest.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientRequest.java index 5c48a8aa5b1..c464e45198d 100644 --- a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientRequest.java +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/Http2ClientRequest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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,8 @@ package io.helidon.nima.http2.webclient; +import java.time.Duration; + import io.helidon.nima.webclient.ClientRequest; /** @@ -39,4 +41,29 @@ public interface Http2ClientRequest extends ClientRequest continuationData = new ArrayList<>(); + private final StreamBuffer buffer; - Http2ClientStream(Http2ClientConnection myConnection, SocketContext ctx, int streamId) { - this.myConnection = myConnection; + private Http2StreamState state = Http2StreamState.IDLE; + private Http2Headers currentHeaders; + private StreamFlowControl flowControl; + private int streamId; + + Http2ClientStream(Http2ClientConnection connection, + Http2Settings serverSettings, + SocketContext ctx, + ConnectionContext connectionContext, + LockingStreamIdSequence streamIdSeq) { + this.connection = connection; + this.serverSettings = serverSettings; this.ctx = ctx; - this.streamId = streamId; + this.streamIdSeq = streamIdSeq; + this.buffer = new StreamBuffer(streamId, connectionContext.timeout()); + } + + @Override + public int streamId() { + return streamId; + } + + @Override + public Http2StreamState streamState() { + return state; + } + + @Override + public void headers(Http2Headers headers, boolean endOfStream) { + currentHeaders = headers; + } + + @Override + public void rstStream(Http2RstStream rstStream) { + if (state == Http2StreamState.IDLE) { + throw new Http2Exception(Http2ErrorCode.PROTOCOL, + "Received RST_STREAM for stream " + + streamId + " in IDLE state"); + } + this.state = Http2StreamState.checkAndGetState(this.state, + Http2FrameType.RST_STREAM, + false, + false, + false); + + throw new RuntimeException("Reset of " + streamId + " stream received!"); + } + + @Override + public void windowUpdate(Http2WindowUpdate windowUpdate) { + this.state = Http2StreamState.checkAndGetState(this.state, + Http2FrameType.WINDOW_UPDATE, + false, + false, + false); + + int increment = windowUpdate.windowSizeIncrement(); + + //6.9/2 + if (increment == 0) { + Http2RstStream frame = new Http2RstStream(Http2ErrorCode.PROTOCOL); + connection.writer().write(frame.toFrameData(serverSettings, streamId, Http2Flag.NoFlags.create())); + } + //6.9.1/3 + if (flowControl.outbound().incrementStreamWindowSize(increment) > WindowSize.MAX_WIN_SIZE) { + Http2RstStream frame = new Http2RstStream(Http2ErrorCode.FLOW_CONTROL); + connection.writer().write(frame.toFrameData(serverSettings, streamId, Http2Flag.NoFlags.create())); + } + + flowControl() + .outbound() + .incrementStreamWindowSize(increment); + } + + @Override + public void data(Http2FrameHeader header, BufferData data) { + flowControl.inbound().incrementWindowSize(header.length()); + } + + @Override + public void priority(Http2Priority http2Priority) { + //FIXME: priority + } + + @Override + public StreamFlowControl flowControl() { + return flowControl; } void cancel() { @@ -60,42 +164,146 @@ void cancel() { } ReadableEntityBase entity() { - return null; + return ClientResponseEntity.create( + ContentDecoder.NO_OP, + this::read, + this::close, + ClientRequestHeaders.create(WritableHeaders.create()), + ClientResponseHeaders.create(WritableHeaders.create()), + MediaContext.create() + ); + } + + void close() { + connection.removeStream(streamId); + } + + /** + * Push data or header frame in to stream buffer. + * + * @param frameData data or header frame + */ + void push(Http2FrameData frameData) { + buffer.push(frameData); + } + + BufferData read(int i) { + while (state == Http2StreamState.HALF_CLOSED_LOCAL) { + Http2FrameData frameData = readOne(); + if (frameData != null) { + return frameData.data(); + } + } + return BufferData.empty(); } void write(Http2Headers http2Headers, boolean endOfStream) { this.state = Http2StreamState.checkAndGetState(this.state, Http2FrameType.HEADERS, true, endOfStream, true); - //myConnection.writeHeaders(streamId, sendListener, http2Headers, endOfStream); + Http2Flag.HeaderFlags flags; + if (endOfStream) { + flags = Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS | Http2Flag.END_OF_STREAM); + } else { + flags = Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS); + } + + sendListener.headers(ctx, http2Headers); + try { + // Keep ascending streamId order among concurrent streams + // §5.1.1 - The identifier of a newly established stream MUST be numerically + // greater than all streams that the initiating endpoint has opened or reserved. + this.streamId = streamIdSeq.lockAndNext(); + this.flowControl = connection.flowControl().createStreamFlowControl(streamId); + this.connection.addStream(streamId, this); + // First call to the server-starting stream, needs to be increasing sequence of odd numbers + connection.writer().writeHeaders(http2Headers, streamId, flags, flowControl.outbound()); + } finally { + streamIdSeq.unlock(); + } } void writeData(BufferData entityBytes, boolean endOfStream) { - // todo split to frames if bigger than max frame size - // todo handle flow control Http2FrameHeader frameHeader = Http2FrameHeader.create(entityBytes.available(), Http2FrameTypes.DATA, Http2Flag.DataFlags.create(endOfStream ? Http2Flag.END_OF_STREAM : 0), streamId); - sendListener.frameHeader(ctx, frameHeader); - sendListener.frame(ctx, entityBytes); - write(new Http2FrameData(frameHeader, entityBytes), endOfStream); + Http2FrameData frameData = new Http2FrameData(frameHeader, entityBytes); + splitAndWrite(frameData); } Http2Headers readHeaders() { - return null; + while (currentHeaders == null) { + Http2FrameData frameData = readOne(); + if (frameData != null) { + throw new IllegalStateException("Unexpected frame type " + frameData.header() + ", HEADERS are expected."); + } + } + return currentHeaders; } ClientOutputStream outputStream() { return new ClientOutputStream(); } + private Http2FrameData readOne() { + Http2FrameData frameData = buffer.poll(); + + if (frameData != null) { + + recvListener.frameHeader(ctx, frameData.header()); + recvListener.frame(ctx, frameData.data()); + + int flags = frameData.header().flags(); + boolean endOfStream = (flags & Http2Flag.END_OF_STREAM) == Http2Flag.END_OF_STREAM; + boolean endOfHeaders = (flags & Http2Flag.END_OF_HEADERS) == Http2Flag.END_OF_HEADERS; + + this.state = Http2StreamState.checkAndGetState(this.state, + frameData.header().type(), + false, + endOfStream, + endOfHeaders); + + switch (frameData.header().type()) { + case DATA: + data(frameData.header(), frameData.data()); + return frameData; + case HEADERS, CONTINUATION: + continuationData.add(frameData); + if (endOfHeaders) { + var requestHuffman = new Http2HuffmanDecoder(); + Http2Headers http2Headers = Http2Headers.create(this, + connection.getInboundDynamicTable(), + requestHuffman, + continuationData.toArray(new Http2FrameData[0])); + this.headers(http2Headers, endOfStream); + } + break; + default: + LOGGER.log(DEBUG, "Dropping frame " + frameData.header() + " expected header or data."); + } + } + return null; + } + + private void splitAndWrite(Http2FrameData frameData) { + int maxFrameSize = this.serverSettings.value(Http2Setting.MAX_FRAME_SIZE).intValue(); + + // Split to frames if bigger than max frame size + Http2FrameData[] frames = frameData.split(maxFrameSize); + for (Http2FrameData frame : frames) { + write(frame, frame.header().flags(Http2FrameTypes.DATA).endOfStream()); + } + } + private void write(Http2FrameData frameData, boolean endOfStream) { this.state = Http2StreamState.checkAndGetState(this.state, frameData.header().type(), true, endOfStream, false); + connection.writer().writeData(frameData, + flowControl().outbound()); } class ClientOutputStream extends OutputStream { diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/LockingStreamIdSequence.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/LockingStreamIdSequence.java new file mode 100644 index 00000000000..32bb2d50725 --- /dev/null +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/LockingStreamIdSequence.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2023 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.nima.http2.webclient; + +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +class LockingStreamIdSequence { + + private final AtomicInteger streamIdSeq = new AtomicInteger(0); + private final Lock lock = new ReentrantLock(); + + int lockAndNext() { + lock.lock(); + return streamIdSeq.updateAndGet(o -> o % 2 == 0 ? o + 1 : o + 2); + } + + void unlock(){ + lock.unlock(); + } +} diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/StreamBuffer.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/StreamBuffer.java new file mode 100644 index 00000000000..d9e0a0eb0e3 --- /dev/null +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/StreamBuffer.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2023 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.nima.http2.webclient; + +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Queue; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import io.helidon.nima.http2.Http2FrameData; + +class StreamBuffer { + + private final Lock streamLock = new ReentrantLock(); + private final Semaphore dequeSemaphore = new Semaphore(1); + private final Queue buffer = new ArrayDeque<>(); + private final int streamId; + private final Duration timeout; + + StreamBuffer(int streamId, Duration timeout) { + this.streamId = streamId; + this.timeout = timeout; + } + + Http2FrameData poll() { + try { + // Block deque thread when queue is empty + // avoid CPU burning + if (!dequeSemaphore.tryAcquire(timeout.toMillis(), TimeUnit.MILLISECONDS)) { + throw new StreamTimeoutException(streamId, timeout); + } + streamLock.lock(); + return buffer.poll(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } finally { + streamLock.unlock(); + } + } + + void push(Http2FrameData frameData) { + try { + streamLock.lock(); + buffer.add(frameData); + } finally { + streamLock.unlock(); + // Release deque threads + dequeSemaphore.release(); + } + } +} diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/StreamTimeoutException.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/StreamTimeoutException.java new file mode 100644 index 00000000000..e8214036c07 --- /dev/null +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/StreamTimeoutException.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 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.nima.http2.webclient; + +import java.time.Duration; + +/** + * Thrown when no data are received over the stream within configured request timeout. + */ +public class StreamTimeoutException extends RuntimeException { + StreamTimeoutException(int streamId, Duration timeout) { + super("No data received on stream " + streamId + " within the timeout " + timeout); + } +} diff --git a/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/UpgradeRedirectException.java b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/UpgradeRedirectException.java new file mode 100644 index 00000000000..6fef921112b --- /dev/null +++ b/nima/http2/webclient/src/main/java/io/helidon/nima/http2/webclient/UpgradeRedirectException.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2023 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.nima.http2.webclient; + +class UpgradeRedirectException extends RuntimeException { + private final String redirectUri; + + UpgradeRedirectException(String redirectUri) { + this.redirectUri = redirectUri; + } + + String redirectUri() { + return redirectUri; + } +} diff --git a/nima/http2/webclient/src/main/java/module-info.java b/nima/http2/webclient/src/main/java/module-info.java index d8a06403a8c..282ef887e82 100644 --- a/nima/http2/webclient/src/main/java/module-info.java +++ b/nima/http2/webclient/src/main/java/module-info.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2023 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. @@ -31,6 +31,7 @@ requires transitive io.helidon.nima.http2; requires transitive io.helidon.nima.webclient; + requires transitive io.helidon.common.pki; exports io.helidon.nima.http2.webclient; } diff --git a/nima/http2/webclient/src/test/java/io/helidon/nima/http2/webclient/Http2WebClientTest.java b/nima/http2/webclient/src/test/java/io/helidon/nima/http2/webclient/Http2WebClientTest.java new file mode 100644 index 00000000000..191379ba321 --- /dev/null +++ b/nima/http2/webclient/src/test/java/io/helidon/nima/http2/webclient/Http2WebClientTest.java @@ -0,0 +1,265 @@ +/* + * Copyright (c) 2023 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.nima.http2.webclient; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.function.Consumer; +import java.util.stream.Stream; + +import io.helidon.common.LazyValue; +import io.helidon.common.configurable.Resource; +import io.helidon.common.http.Http; +import io.helidon.common.pki.KeyConfig; +import io.helidon.nima.common.tls.Tls; +import io.helidon.nima.http2.webserver.DefaultHttp2Config; +import io.helidon.nima.http2.webserver.Http2ConnectionProvider; +import io.helidon.nima.http2.webserver.Http2Route; +import io.helidon.nima.testing.junit5.webserver.ServerTest; +import io.helidon.nima.testing.junit5.webserver.SetUpServer; +import io.helidon.nima.webserver.WebServer; +import io.helidon.nima.webserver.http1.Http1Route; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import static io.helidon.common.http.Http.Header.USER_AGENT; +import static io.helidon.common.http.Http.Method.GET; +import static io.helidon.common.http.Http.Method.POST; +import static io.helidon.common.http.Http.Method.PUT; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +@ServerTest +class Http2WebClientTest { + + private static final Http.HeaderName CLIENT_CUSTOM_HEADER_NAME = Http.Header.create("client-custom-header"); + private static final Http.HeaderName SERVER_CUSTOM_HEADER_NAME = Http.Header.create("server-custom-header"); + private static final Http.HeaderName SERVER_HEADER_FROM_PARAM_NAME = Http.Header.create("header-from-param"); + private static final Http.HeaderName CLIENT_USER_AGENT_HEADER_NAME = Http.Header.create("client-user-agent"); + private static ExecutorService executorService; + private static int plainPort; + private static final LazyValue priorKnowledgeClient = LazyValue.create(() -> Http2Client.builder() + .priorKnowledge(true) + .baseUri("http://localhost:" + plainPort + "/versionspecific") + .build()); + private static final LazyValue upgradeClient = LazyValue.create(() -> Http2Client.builder() + .baseUri("http://localhost:" + plainPort + "/versionspecific") + .build()); + private static int tlsPort; + private static final LazyValue tlsClient = LazyValue.create(() -> Http2Client.builder() + .baseUri("https://localhost:" + tlsPort + "/versionspecific") + .tls(Tls.builder() + .enabled(true) + .trustAll(true) + .endpointIdentificationAlgorithm(Tls.ENDPOINT_IDENTIFICATION_NONE) + .build()) + .build()); + + Http2WebClientTest(WebServer server) { + this.plainPort = server.port(); + this.tlsPort = server.port("https"); + } + + @SetUpServer + static void setUpServer(WebServer.Builder serverBuilder) { + executorService = Executors.newFixedThreadPool(5); + + KeyConfig privateKeyConfig = + KeyConfig.keystoreBuilder() + .keystore(Resource.create("certificate.p12")) + .keystorePassphrase("helidon") + .build(); + + Tls tls = Tls.builder() + .privateKey(privateKeyConfig.privateKey().get()) + .privateKeyCertChain(privateKeyConfig.certChain()) + .build(); + + serverBuilder + .defaultSocket(builder -> builder.port(-1) + .host("localhost") + ) + .addConnectionProvider(Http2ConnectionProvider.builder() + .http2Config(DefaultHttp2Config.builder() + .initialWindowSize(10)) + .build()) + .socket("https", + builder -> builder.port(-1) + .host("localhost") + .tls(tls) + .receiveBufferSize(4096) + .backlog(8192) + ) + .routing(router -> router + .get("/", (req, res) -> res.send("Hello world!")) + .route(Http1Route.route(GET, "/versionspecific", (req, res) -> res.send("HTTP/1.1 route"))) + .route(Http2Route.route(GET, "/versionspecific", (req, res) -> { + res.header(CLIENT_USER_AGENT_HEADER_NAME, req.headers().get(USER_AGENT).value()); + res.header(SERVER_HEADER_FROM_PARAM_NAME, req.query().value("custQueryParam")); + res.send("HTTP/2 route"); + })) + .route(Http2Route.route(PUT, "/versionspecific", (req, res) -> { + res.header(SERVER_CUSTOM_HEADER_NAME, req.headers().get(CLIENT_CUSTOM_HEADER_NAME).value()); + res.header(CLIENT_USER_AGENT_HEADER_NAME, req.headers().get(USER_AGENT).value()); + res.header(SERVER_HEADER_FROM_PARAM_NAME, req.query().value("custQueryParam")); + res.send("PUT " + req.content().as(String.class)); + })) + .route(Http2Route.route(POST, "/versionspecific", (req, res) -> { + res.header(SERVER_CUSTOM_HEADER_NAME, req.headers().get(CLIENT_CUSTOM_HEADER_NAME).value()); + res.header(CLIENT_USER_AGENT_HEADER_NAME, req.headers().get(USER_AGENT).value()); + res.header(SERVER_HEADER_FROM_PARAM_NAME, req.query().value("custQueryParam")); + res.send("POST " + req.content().as(String.class)); + })) + .route(Http2Route.route(GET, "/versionspecific/h2streaming", (req, res) -> { + res.status(Http.Status.OK_200); + String execId = req.query().value("execId"); + try (OutputStream os = res.outputStream()) { + for (int i = 0; i < 5; i++) { + os.write(String.format(execId + "BAF%03d", i).getBytes()); + Thread.sleep(10); + } + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + })) + ); + } + + static Stream clientTypes() { + return Stream.of( + Arguments.of("priorKnowledge", priorKnowledgeClient), + Arguments.of("upgrade", upgradeClient), + Arguments.of("tls", tlsClient) + ); + } + + @AfterAll + static void afterAll() throws InterruptedException { + executorService.shutdown(); + if (!executorService.awaitTermination(30, TimeUnit.SECONDS)) { + executorService.shutdownNow(); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("clientTypes") + void clientGet(String name, LazyValue client) { + try (Http2ClientResponse response = client.get() + .get() + .queryParam("custQueryParam", "test-get") + .request()) { + + assertThat(response.status(), is(Http.Status.OK_200)); + assertThat(response.as(String.class), is("HTTP/2 route")); + assertThat(response.headers().get(CLIENT_USER_AGENT_HEADER_NAME).value(), + is(ClientRequestImpl.USER_AGENT_HEADER.value())); + assertThat(response.headers().get(SERVER_HEADER_FROM_PARAM_NAME).value(), + is("test-get")); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("clientTypes") + void clientPut(String clientType, LazyValue client) { + + String payload = clientType + " payload"; + String custHeaderValue = clientType + " header value"; + + try (Http2ClientResponse response = client.get() + .method(PUT) + .queryParam("custQueryParam", "test-put") + .header(CLIENT_CUSTOM_HEADER_NAME, custHeaderValue) + .submit(payload)) { + + assertThat(response.status(), is(Http.Status.OK_200)); + assertThat(response.as(String.class), is("PUT " + payload)); + assertThat(response.headers().get(CLIENT_USER_AGENT_HEADER_NAME).value(), + is(ClientRequestImpl.USER_AGENT_HEADER.value())); + assertThat(response.headers().get(SERVER_CUSTOM_HEADER_NAME).value(), + is(custHeaderValue)); + assertThat(response.headers().get(SERVER_HEADER_FROM_PARAM_NAME).value(), + is("test-put")); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("clientTypes") + void clientPost(String clientType, LazyValue client) { + + String payload = clientType + " payload"; + String custHeaderValue = clientType + " header value"; + + try (Http2ClientResponse response = client.get() + .method(POST) + .queryParam("custQueryParam", "test-post") + .header(CLIENT_CUSTOM_HEADER_NAME, custHeaderValue) + .submit(payload)) { + + assertThat(response.status(), is(Http.Status.OK_200)); + assertThat(response.as(String.class), is("POST " + payload)); + assertThat(response.headers().get(CLIENT_USER_AGENT_HEADER_NAME).value(), + is(ClientRequestImpl.USER_AGENT_HEADER.value())); + assertThat(response.headers().get(SERVER_CUSTOM_HEADER_NAME).value(), + is(custHeaderValue)); + assertThat(response.headers().get(SERVER_HEADER_FROM_PARAM_NAME).value(), + is("test-post")); + } + } + + @ParameterizedTest(name = "{0}") + @MethodSource("clientTypes") + void multiplexParallelStreamsGet(String clientType, LazyValue client) + throws ExecutionException, InterruptedException, TimeoutException { + Consumer callable = id -> { + try (Http2ClientResponse response = client.get() + .get("/h2streaming") + .queryParam("execId", id.toString()) + .request() + ) { + + InputStream is = response.inputStream(); + for (int i = 0; ; i++) { + byte[] bytes = is.readNBytes("0BAF000".getBytes().length); + if (bytes.length == 0) { + break; + } + String message = new String(bytes); + assertThat(message, is(String.format(id + "BAF%03d", i))); + } + + } catch (IOException e) { + throw new RuntimeException(e); + } + }; + + CompletableFuture.allOf( + CompletableFuture.runAsync(() -> callable.accept(1), executorService) + , CompletableFuture.runAsync(() -> callable.accept(2), executorService) + , CompletableFuture.runAsync(() -> callable.accept(3), executorService) + , CompletableFuture.runAsync(() -> callable.accept(4), executorService) + ).get(5, TimeUnit.MINUTES); + } +} diff --git a/nima/http2/webclient/src/test/resources/certificate.p12 b/nima/http2/webclient/src/test/resources/certificate.p12 new file mode 100644 index 00000000000..b2cb83427d1 Binary files /dev/null and b/nima/http2/webclient/src/test/resources/certificate.p12 differ diff --git a/nima/http2/webclient/src/test/resources/logging-test.properties b/nima/http2/webclient/src/test/resources/logging-test.properties new file mode 100644 index 00000000000..f9bd523dd3a --- /dev/null +++ b/nima/http2/webclient/src/test/resources/logging-test.properties @@ -0,0 +1,27 @@ +# +# Copyright (c) 2022, 2023 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. +# + +# Send messages to the console +handlers=java.util.logging.ConsoleHandler +java.util.logging.ConsoleHandler.level=INFO +java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter +# java.util.logging.SimpleFormatter.format = [%1$tc] %5$s %6$s%n +java.util.logging.SimpleFormatter.format=%1$tH:%1$tM:%1$tS %5$s%6$s%n +#java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS %4$s %3$s %5$s%6$s%n + +io.helidon.nima.level=INFO +# Global logging level. Can be overridden by specific loggers +.level=INFO diff --git a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Config.java b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Config.java index a7464b8a4bf..305b80ada7a 100644 --- a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Config.java +++ b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Config.java @@ -16,6 +16,8 @@ package io.helidon.nima.http2.webserver; +import java.time.Duration; + import io.helidon.builder.Builder; import io.helidon.builder.config.ConfigBean; import io.helidon.common.http.RequestedUriDiscoveryContext; @@ -29,12 +31,13 @@ public interface Http2Config { /** * The size of the largest frame payload that the sender is willing to receive in bytes. + * Default value is {@code 16384} and maximum value is 224-1 = 16777215 bytes. * See RFC 9113 section 6.5.2 for details. * * @return maximal frame size */ - @ConfiguredOption("16_384") - long maxFrameSize(); + @ConfiguredOption("16384") + int maxFrameSize(); /** * The maximum field section size that the sender is prepared to accept in bytes. @@ -46,14 +49,6 @@ public interface Http2Config { @ConfiguredOption("0xFFFFFFFFL") long maxHeaderListSize(); - /** - * Initial maximal size of client frames. - * - * @return maximal size in bytes - */ - @ConfiguredOption("16384") - int maxClientFrameSize(); - /** * Maximum number of concurrent streams that the server will allow. * Defaults to {@code 8192}. This limit is directional: it applies to the number of streams that the sender @@ -66,6 +61,38 @@ public interface Http2Config { @ConfiguredOption("8192") long maxConcurrentStreams(); + /** + * This setting indicates the sender's maximum window size in bytes for connection-level flow control. + * Default and maximum value is 231-1 = 2147483647 bytes. This setting affects the window size + * of HTTP/2 connection. + * Any value greater than 2147483647 causes an error. Any value smaller than initial window size causes an error. + * See RFC 9113 section 6.9.1 for details. + * + * @return maximum window size in bytes + */ + @ConfiguredOption("65635") + int initialWindowSize(); + + /** + * 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}. + * + * + * + * + * + * + *
ISO_8601 format examples:
PT0.1S100 milliseconds
PT0.5S500 milliseconds
PT2S2 seconds
+ * + * @return duration + * @see ISO_8601 Durations + */ + @ConfiguredOption("java.time.Duration.ofMillis(100L)") + Duration flowControlTimeout(); + /** * Whether to send error message over HTTP to client. * Defaults to {@code false}, as exception message may contain internal information that could be used as an diff --git a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Connection.java b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Connection.java index 5a9e3edb8c4..8e8ab3922fa 100755 --- a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Connection.java +++ b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Connection.java @@ -20,7 +20,6 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Optional; import io.helidon.common.buffers.BufferData; import io.helidon.common.buffers.DataReader; @@ -30,7 +29,7 @@ import io.helidon.common.http.Http.HeaderValues; import io.helidon.common.http.HttpPrologue; import io.helidon.common.task.InterruptableTask; -import io.helidon.nima.http2.FlowControl; +import io.helidon.nima.http2.ConnectionFlowControl; import io.helidon.nima.http2.Http2ConnectionWriter; import io.helidon.nima.http2.Http2ErrorCode; import io.helidon.nima.http2.Http2Exception; @@ -86,11 +85,10 @@ public class Http2Connection implements ServerConnection, InterruptableTask subProviders; - private final WindowSize connectionWindowSize = new WindowSize(); private final DataReader reader; - private final Http2Settings serverSettings; private final boolean sendErrorDetails; + private final ConnectionFlowControl flowControl; // initial client settings, until we receive real ones private Http2Settings clientSettings = Http2Settings.builder() @@ -104,9 +102,7 @@ public class Http2Connection implements ServerConnection, InterruptableTask subProviders) { this.ctx = ctx; @@ -115,15 +111,38 @@ public class Http2Connection implements ServerConnection, InterruptableTask settingsUpdate(http2Config, builder)) .add(Http2Setting.ENABLE_PUSH, false) .build(); - this.connectionWriter = new Http2ConnectionWriter(ctx, ctx.dataWriter(), List.of(new Http2LoggingFrameListener("send"))); + this.connectionWriter = new Http2ConnectionWriter(ctx, + ctx.dataWriter(), + List.of(new Http2LoggingFrameListener("send"))); this.subProviders = subProviders; - this.requestDynamicTable = Http2Headers.DynamicTable.create(serverSettings.value(Http2Setting.HEADER_TABLE_SIZE)); + this.requestDynamicTable = Http2Headers.DynamicTable.create( + serverSettings.value(Http2Setting.HEADER_TABLE_SIZE)); this.requestHuffman = new Http2HuffmanDecoder(); this.routing = ctx.router().routing(HttpRouting.class, HttpRouting.empty()); this.reader = ctx.dataReader(); this.sendErrorDetails = http2Config.sendErrorDetails(); - this.maxClientFrameSize = http2Config.maxClientFrameSize(); this.maxClientConcurrentStreams = http2Config.maxConcurrentStreams(); + + // Flow control is initialized by RFC 9113 default values + this.flowControl = ConnectionFlowControl.serverBuilder(this::writeWindowUpdateFrame) + .initialWindowSize(http2Config.initialWindowSize()) + .blockTimeout(http2Config.flowControlTimeout()) + .maxFrameSize(http2Config.maxFrameSize()) + .build(); + } + + private static void settingsUpdate(Http2Config config, Http2Settings.Builder builder) { + applySetting(builder, config.maxFrameSize(), Http2Setting.MAX_FRAME_SIZE); + applySetting(builder, config.maxHeaderListSize(), Http2Setting.MAX_HEADER_LIST_SIZE); + applySetting(builder, config.maxConcurrentStreams(), Http2Setting.MAX_CONCURRENT_STREAMS); + applySetting(builder, config.initialWindowSize(), Http2Setting.INITIAL_WINDOW_SIZE); + } + + // Add value to the builder only when differs from default + private static void applySetting(Http2Settings.Builder builder, long value, Http2Setting settings) { + if (value != settings.defaultValue()) { + builder.add(settings, value); + } } @Override @@ -144,7 +163,7 @@ public void handle() throws InterruptedException { Http2GoAway frame = new Http2GoAway(0, e.code(), sendErrorDetails ? e.getMessage() : ""); - connectionWriter.write(frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create()), FlowControl.NOOP); + connectionWriter.write(frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create())); state = State.FINISHED; } catch (CloseConnectionException | InterruptedException e) { throw e; @@ -156,22 +175,79 @@ public void handle() throws InterruptedException { Http2GoAway frame = new Http2GoAway(0, Http2ErrorCode.INTERNAL, sendErrorDetails ? e.getClass().getName() + ": " + e.getMessage() : ""); - connectionWriter.write(frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create()), FlowControl.NOOP); + connectionWriter.write(frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create())); state = State.FINISHED; throw e; } } /** - * Client settings, obtained from HTTP/2 upgrade request. + * Client settings, obtained from SETTINGS frame or HTTP/2 upgrade request. * * @param http2Settings client settings to use */ public void clientSettings(Http2Settings http2Settings) { this.clientSettings = http2Settings; + this.receiveFrameListener.frame(ctx, clientSettings); if (this.clientSettings.hasValue(Http2Setting.HEADER_TABLE_SIZE)) { updateHeaderTableSize(clientSettings.value(Http2Setting.HEADER_TABLE_SIZE)); } + + if (this.clientSettings.hasValue(Http2Setting.INITIAL_WINDOW_SIZE)) { + Long initialWindowSize = clientSettings.value(Http2Setting.INITIAL_WINDOW_SIZE); + + //6.9.2/3 - legal range for the increment to the flow-control window is 1 to 2^31-1 (2,147,483,647) octets. + if (initialWindowSize > WindowSize.MAX_WIN_SIZE) { + Http2GoAway frame = new Http2GoAway(0, + Http2ErrorCode.FLOW_CONTROL, + "Window " + initialWindowSize + " size too large"); + connectionWriter.write(frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create())); + } + + //6.9.1/1 - changing the flow-control window for streams that are not yet active + flowControl.resetInitialWindowSize(initialWindowSize.intValue()); + + //6.9.2/1 - SETTINGS frame can alter the initial flow-control + // window size for streams with active flow-control windows (that is, + // streams in the "open" or "half-closed (remote)" state) + for (StreamContext sctx : streams.values()) { + Http2StreamState streamState = sctx.stream.streamState(); + if (streamState == Http2StreamState.OPEN || streamState == Http2StreamState.HALF_CLOSED_REMOTE) { + sctx.stream.flowControl().outbound().resetStreamWindowSize(initialWindowSize.intValue()); + } + } + + // Unblock frames waiting for update + this.flowControl.outbound().triggerUpdate(); + } + + if (this.clientSettings.hasValue(Http2Setting.MAX_FRAME_SIZE)) { + Long maxFrameSize = this.clientSettings.value(Http2Setting.MAX_FRAME_SIZE); + // specification defines, that the frame size must be between the initial size (16384) and 2^24-1 + if (maxFrameSize < WindowSize.DEFAULT_MAX_FRAME_SIZE || maxFrameSize > WindowSize.MAX_MAX_FRAME_SIZE) { + throw new Http2Exception(Http2ErrorCode.PROTOCOL, + "Frame size must be between 2^14 and 2^24-1, but is: " + maxFrameSize); + } + + flowControl.resetMaxFrameSize(maxFrameSize.intValue()); + } + + // Set server MAX_CONCURRENT_STREAMS limit when client sends number lower than hard limit + // from configuration. Refuse settings if client sends larger number than is configured. + this.clientSettings.presentValue(Http2Setting.MAX_CONCURRENT_STREAMS) + .ifPresent(it -> { + if (http2Config.maxConcurrentStreams() >= it) { + maxClientConcurrentStreams = it; + } else { + Http2GoAway frame = + new Http2GoAway(0, + Http2ErrorCode.PROTOCOL, + "Value of maximum concurrent streams limit " + it + + " exceeded hard limit value " + + http2Config.maxConcurrentStreams()); + connectionWriter.write(frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create())); + } + }); } /** @@ -212,19 +288,6 @@ Http2Settings serverSettings() { return serverSettings; } - private static void settingsUpdate(Http2Config config, Http2Settings.Builder builder) { - applySetting(builder, config.maxFrameSize(), Http2Setting.MAX_FRAME_SIZE); - applySetting(builder, config.maxHeaderListSize(), Http2Setting.MAX_HEADER_LIST_SIZE); - applySetting(builder, config.maxConcurrentStreams(), Http2Setting.MAX_CONCURRENT_STREAMS); - } - - // Add value to the builder only when differs from default - private static void applySetting(Http2Settings.Builder builder, long value, Http2Setting settings) { - if (value != settings.defaultValue()) { - builder.add(settings, value); - } - } - private void doHandle() throws InterruptedException { while (state != State.FINISHED) { @@ -240,28 +303,35 @@ private void doHandle() throws InterruptedException { // no data to read -> connection is closed throw new CloseConnectionException("Connection closed by client", e); } - } - switch (state) { - case CONTINUATION -> doContinuation(); - case WRITE_SERVER_SETTINGS -> writeServerSettings(); - case WINDOW_UPDATE -> windowUpdateFrame(); - case SETTINGS -> doSettings(); - case ACK_SETTINGS -> ackSettings(); - case DATA -> dataFrame(); - case HEADERS -> doHeaders(); - case PRIORITY -> doPriority(); - case READ_PUSH_PROMISE -> throw new Http2Exception(Http2ErrorCode.REFUSED_STREAM, "Push promise not supported"); - case PING -> pingFrame(); - case SEND_PING_ACK -> writePingAck(); - case GO_AWAY -> - // todo we may need to do graceful shutdown to process the last stream - goAwayFrame(); - case RST_STREAM -> rstStream(); - default -> unknownFrame(); + dispatchHandler(); + + } else { + dispatchHandler(); } } } + private void dispatchHandler() { + switch (state) { + case CONTINUATION -> doContinuation(); + case WRITE_SERVER_SETTINGS -> writeServerSettings(); + case WINDOW_UPDATE -> readWindowUpdateFrame(); + case SETTINGS -> doSettings(); + case ACK_SETTINGS -> ackSettings(); + case DATA -> dataFrame(); + case HEADERS -> doHeaders(); + case PRIORITY -> doPriority(); + case READ_PUSH_PROMISE -> throw new Http2Exception(Http2ErrorCode.REFUSED_STREAM, "Push promise not supported"); + case PING -> pingFrame(); + case SEND_PING_ACK -> writePingAck(); + case GO_AWAY -> + // todo we may need to do graceful shutdown to process the last stream + goAwayFrame(); + case RST_STREAM -> rstStream(); + default -> unknownFrame(); + } + } + private void readPreface() { BufferData preface = reader.readBuffer(PREFACE_LENGTH); byte[] bytes = new byte[PREFACE_LENGTH]; @@ -274,6 +344,7 @@ private void readPreface() { } private void readFrame() { + BufferData frameHeaderBuffer = reader.readBuffer(FRAME_HEADER_LENGTH); receiveFrameListener.frameHeader(ctx, frameHeaderBuffer); @@ -342,33 +413,33 @@ private void doContinuation() { } private void writeServerSettings() { - connectionWriter.write(serverSettings.toFrameData(serverSettings, 0, Http2Flag.SettingsFlags.create(0)), - FlowControl.NOOP); + connectionWriter.write(serverSettings.toFrameData(serverSettings, 0, Http2Flag.SettingsFlags.create(0))); state = State.READ_FRAME; } - private void windowUpdateFrame() { + private void readWindowUpdateFrame() { Http2WindowUpdate windowUpdate = Http2WindowUpdate.create(inProgressFrame()); receiveFrameListener.frame(ctx, windowUpdate); state = State.READ_FRAME; boolean overflow; + int increment = windowUpdate.windowSizeIncrement(); + int streamId = frameHeader.streamId(); - if (frameHeader.streamId() == 0) { + if (streamId == 0) { // overall connection - // todo implement - if (windowUpdate.windowSizeIncrement() == 0) { + if (increment == 0) { Http2GoAway frame = new Http2GoAway(0, Http2ErrorCode.PROTOCOL, "Window size 0"); - connectionWriter.write(frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create()), FlowControl.NOOP); + connectionWriter.write(frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create())); } - overflow = connectionWindowSize.incrementWindowSize(windowUpdate.windowSizeIncrement()); + overflow = flowControl.incrementOutboundConnectionWindowSize(increment) > WindowSize.MAX_WIN_SIZE; if (overflow) { Http2GoAway frame = new Http2GoAway(0, Http2ErrorCode.FLOW_CONTROL, "Window size too big. Max: "); - connectionWriter.write(frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create()), FlowControl.NOOP); + connectionWriter.write(frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create())); } } else { try { - StreamContext stream = stream(frameHeader.streamId()); + StreamContext stream = stream(streamId); stream.stream().windowUpdate(windowUpdate); } catch (Http2Exception ignored) { // stream closed @@ -376,6 +447,11 @@ private void windowUpdateFrame() { } } + // Used in inbound flow control instance to write WINDOW_UPDATE frame. + private void writeWindowUpdateFrame(int streamId, Http2WindowUpdate windowUpdateFrame) { + connectionWriter.write(windowUpdateFrame.toFrameData(clientSettings, streamId, Http2Flag.NoFlags.create())); + } + private void doSettings() { if (frameHeader.streamId() != 0) { throw new Http2Exception(Http2ErrorCode.PROTOCOL, "Settings must use stream ID 0, but use " + frameHeader.streamId()); @@ -388,67 +464,9 @@ private void doSettings() { throw new Http2Exception(Http2ErrorCode.FRAME_SIZE, "Settings with ACK should not have payload."); } } else { - this.clientSettings = Http2Settings.create(inProgressFrame()); - this.receiveFrameListener.frame(ctx, clientSettings); - - this.clientSettings.presentValue(Http2Setting.HEADER_TABLE_SIZE) - .ifPresent(this::updateHeaderTableSize); - - Optional windowSize = this.clientSettings.presentValue(Http2Setting.INITIAL_WINDOW_SIZE); - if (windowSize.isPresent()) { - long it = windowSize.get(); - - //6.9.2/3 - legal range for the increment to the flow-control window is 1 to 2^31-1 (2,147,483,647) octets. - if (it > WindowSize.MAX_WIN_SIZE) { - Http2GoAway frame = new Http2GoAway(0, Http2ErrorCode.FLOW_CONTROL, "Window " + it + " size too large"); - connectionWriter.write(frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create()), FlowControl.NOOP); - } - - //6.9.1/1 - changing the flow-control window for streams that are not yet active - streamInitialWindowSize = (int) it; - - //6.9.2/1 - SETTINGS frame can alter the initial flow-control - // window size for streams with active flow-control windows (that is, - // streams in the "open" or "half-closed (remote)" state) - for (StreamContext sctx : streams.values()) { - Http2StreamState streamState = sctx.stream.streamState(); - if (streamState == Http2StreamState.OPEN || streamState == Http2StreamState.HALF_CLOSED_REMOTE) { - sctx.stream.flowControl().resetStreamWindowSize(it); - } - } - - // Unblock frames waiting for update - this.connectionWindowSize.triggerUpdate(); - } - - this.clientSettings.presentValue(Http2Setting.MAX_FRAME_SIZE) - .ifPresent(it -> this.maxClientFrameSize = it); - - // specification defines, that the frame size must be between the initial size (16384) and 2^24-1 - if (this.maxClientFrameSize < 16_384 || this.maxClientFrameSize > 16_777_215) { - throw new Http2Exception(Http2ErrorCode.PROTOCOL, "Frame size must be between 2^14 and 2^24-1, but is: " - + maxClientFrameSize); - } - - // Set server MAX_CONCURRENT_STREAMS limit when client sends number lower than hard limit - // from configuration. Refuse settings if client sends larger number than is configured. - this.clientSettings.presentValue(Http2Setting.MAX_CONCURRENT_STREAMS) - .ifPresent(it -> { - if (http2Config.maxConcurrentStreams() >= it) { - maxClientConcurrentStreams = it; - } else { - Http2GoAway frame = new Http2GoAway(0, Http2ErrorCode.PROTOCOL, - "Value of maximum concurrent streams limit " + it - + " exceeded hard limit value " + http2Config.maxConcurrentStreams()); - connectionWriter.write( - frame.toFrameData(clientSettings, 0, Http2Flag.NoFlags.create()), - FlowControl.NOOP); - - } - }); + clientSettings(Http2Settings.create(inProgressFrame())); // TODO for each - // Http2Setting.MAX_CONCURRENT_STREAMS; // Http2Setting.MAX_HEADER_LIST_SIZE; state = State.ACK_SETTINGS; } @@ -457,7 +475,7 @@ private void doSettings() { private void ackSettings() { Http2Flag.SettingsFlags flags = Http2Flag.SettingsFlags.create(Http2Flag.ACK); Http2FrameHeader header = Http2FrameHeader.create(0, Http2FrameTypes.SETTINGS, flags, 0); - connectionWriter.write(new Http2FrameData(header, BufferData.empty()), FlowControl.NOOP); + connectionWriter.write(new Http2FrameData(header, BufferData.empty())); state = State.READ_FRAME; if (upgradeHeaders != null) { @@ -478,11 +496,22 @@ private void ackSettings() { private void dataFrame() { BufferData buffer; - StreamContext stream = stream(frameHeader.streamId()); + int streamId = frameHeader.streamId(); + StreamContext stream = stream(streamId); stream.stream().checkDataReceivable(); - // todo we need to have some information about how much data is buffered for a stream - // to prevent OOM (use flow control!) + // Flow-control: reading frameHeader.length() bytes from HTTP2 socket for known stream ID. + int length = frameHeader.length(); + if (length > 0) { + if (streamId > 0 && frameHeader.type() != Http2FrameType.HEADERS) { + // Stream ID > 0: update connection and stream + stream.stream() + .flowControl() + .inbound() + .decrementWindowSize(length); + } + } + if (frameHeader.flags(Http2FrameTypes.DATA).padded()) { BufferData frameData = inProgressFrame(); int padLength = frameData.read(); @@ -623,7 +652,7 @@ private void writePingAck() { Http2Flag.PingFlags.create(Http2Flag.ACK), 0); ping = null; - connectionWriter.write(new Http2FrameData(header, frame), FlowControl.NOOP); + connectionWriter.write(new Http2FrameData(header, frame)); state = State.READ_FRAME; } @@ -680,12 +709,12 @@ private StreamContext stream(int streamId) { } } - // MAX_CONCURRENT_STREAMS limit check - according to RFC 9113 section 5.1.2 endpoint MUST treat this - // as a stream error (section 5.4.2) of type PROTOCOL_ERROR or REFUSED_STREAM. + // 5.1.2 MAX_CONCURRENT_STREAMS limit check - stream error of type PROTOCOL_ERROR or REFUSED_STREAM if (streams.size() > maxClientConcurrentStreams) { throw new Http2Exception(Http2ErrorCode.REFUSED_STREAM, - "Maximum concurrent streams limit " + maxClientConcurrentStreams + " exceeded"); + "Maximum concurrent streams limit " + maxClientConcurrentStreams + " exceeded"); } + streamContext = new StreamContext(streamId, new Http2Stream(ctx, routing, @@ -695,9 +724,7 @@ private StreamContext stream(int streamId) { serverSettings, clientSettings, connectionWriter, - FlowControl.create(streamId, - streamInitialWindowSize, - connectionWindowSize))); + flowControl)); streams.put(streamId, streamContext); } diff --git a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2ServerResponse.java b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2ServerResponse.java index 366919c83a6..f0aa6b065b5 100644 --- a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2ServerResponse.java +++ b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2ServerResponse.java @@ -44,7 +44,7 @@ class Http2ServerResponse extends ServerResponseBase { private final Http2StreamWriter writer; private final int streamId; private final ServerResponseHeaders headers; - private final FlowControl flowControl; + private final FlowControl.Outbound flowControl; private boolean isSent; private boolean streamingEntity; @@ -55,7 +55,7 @@ class Http2ServerResponse extends ServerResponseBase { Http2ServerRequest request, Http2StreamWriter writer, int streamId, - FlowControl flowControl) { + FlowControl.Outbound flowControl) { super(ctx, request); this.ctx = ctx; this.writer = writer; @@ -177,7 +177,7 @@ private static class BlockingOutputStream extends OutputStream { private final ServerResponseHeaders headers; private final Http2StreamWriter writer; private final int streamId; - private final FlowControl flowControl; + private final FlowControl.Outbound flowControl; private final Http.Status status; private final Runnable responseCloseRunnable; @@ -189,7 +189,7 @@ private static class BlockingOutputStream extends OutputStream { private BlockingOutputStream(ServerResponseHeaders headers, Http2StreamWriter writer, int streamId, - FlowControl flowControl, + FlowControl.Outbound flowControl, Http.Status status, Runnable responseCloseRunnable) { @@ -328,7 +328,7 @@ private void writeChunk(BufferData buffer) { bytesWritten += frameData.header().length(); bytesWritten += Http2FrameHeader.LENGTH; - writer.write(frameData, flowControl); + writer.writeData(frameData, flowControl); } private void sendEndOfStream() { @@ -340,7 +340,7 @@ private void sendEndOfStream() { bytesWritten += frameData.header().length(); bytesWritten += Http2FrameHeader.LENGTH; - writer.write(frameData, flowControl); + writer.writeData(frameData, flowControl); } } } diff --git a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Stream.java b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Stream.java index fe4c698b2e1..d6cc6f3f72c 100644 --- a/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Stream.java +++ b/nima/http2/webserver/src/main/java/io/helidon/nima/http2/webserver/Http2Stream.java @@ -29,7 +29,7 @@ import io.helidon.common.http.ServerResponseHeaders; import io.helidon.common.socket.SocketWriterException; import io.helidon.nima.http.encoding.ContentDecoder; -import io.helidon.nima.http2.FlowControl; +import io.helidon.nima.http2.ConnectionFlowControl; import io.helidon.nima.http2.Http2ErrorCode; import io.helidon.nima.http2.Http2Exception; import io.helidon.nima.http2.Http2Flag; @@ -43,6 +43,8 @@ import io.helidon.nima.http2.Http2StreamState; import io.helidon.nima.http2.Http2StreamWriter; import io.helidon.nima.http2.Http2WindowUpdate; +import io.helidon.nima.http2.StreamFlowControl; +import io.helidon.nima.http2.WindowSize; import io.helidon.nima.http2.webserver.spi.Http2SubProtocolSelector; import io.helidon.nima.http2.webserver.spi.SubProtocolResult; import io.helidon.nima.webserver.CloseConnectionException; @@ -61,7 +63,6 @@ public class Http2Stream implements Runnable, io.helidon.nima.http2.Http2Stream 0), BufferData.empty()); private static final System.Logger LOGGER = System.getLogger(Http2Stream.class.getName()); - private final FlowControl flowControl; private final ConnectionContext ctx; private final Http2Config http2Config; private final List subProviders; @@ -71,6 +72,8 @@ public class Http2Stream implements Runnable, io.helidon.nima.http2.Http2Stream private final Http2StreamWriter writer; private final Router router; private final ArrayBlockingQueue inboundData = new ArrayBlockingQueue<>(32); + private final StreamFlowControl flowControl; + private boolean wasLastDataFrame = false; private volatile Http2Headers headers; private volatile Http2Priority priority; @@ -84,15 +87,15 @@ public class Http2Stream implements Runnable, io.helidon.nima.http2.Http2Stream /** * A new HTTP/2 server stream. * - * @param ctx connection context - * @param routing HTTP routing - * @param http2Config HTTP/2 configuration + * @param ctx connection context + * @param routing HTTP routing + * @param http2Config HTTP/2 configuration * @param subProviders - * @param streamId stream id - * @param serverSettings server settings - * @param clientSettings client settings - * @param writer writer - * @param flowControl flow control + * @param streamId stream id + * @param serverSettings server settings + * @param clientSettings client settings + * @param writer writer + * @param connectionFlowControl connection flow control */ public Http2Stream(ConnectionContext ctx, HttpRouting routing, @@ -102,7 +105,7 @@ public Http2Stream(ConnectionContext ctx, Http2Settings serverSettings, Http2Settings clientSettings, Http2StreamWriter writer, - FlowControl flowControl) { + ConnectionFlowControl connectionFlowControl) { this.ctx = ctx; this.routing = routing; this.http2Config = http2Config; @@ -112,7 +115,7 @@ public Http2Stream(ConnectionContext ctx, this.clientSettings = clientSettings; this.writer = writer; this.router = ctx.router(); - this.flowControl = flowControl; + this.flowControl = connectionFlowControl.createStreamFlowControl(streamId); } /** @@ -185,12 +188,12 @@ public void windowUpdate(Http2WindowUpdate windowUpdate) { //6.9/2 if (windowUpdate.windowSizeIncrement() == 0) { Http2RstStream frame = new Http2RstStream(Http2ErrorCode.PROTOCOL); - writer.write(frame.toFrameData(clientSettings, streamId, Http2Flag.NoFlags.create()), FlowControl.NOOP); + writer.write(frame.toFrameData(clientSettings, streamId, Http2Flag.NoFlags.create())); } //6.9.1/3 - if (flowControl.incrementStreamWindowSize(windowUpdate.windowSizeIncrement())) { + if (flowControl.outbound().incrementStreamWindowSize(windowUpdate.windowSizeIncrement()) > WindowSize.MAX_WIN_SIZE) { Http2RstStream frame = new Http2RstStream(Http2ErrorCode.FLOW_CONTROL); - writer.write(frame.toFrameData(clientSettings, streamId, Http2Flag.NoFlags.create()), FlowControl.NOOP); + writer.write(frame.toFrameData(clientSettings, streamId, Http2Flag.NoFlags.create())); } } @@ -216,7 +219,7 @@ public void data(Http2FrameHeader header, BufferData data) { if (expectedLength != -1 && expectedLength < header.length()) { state = Http2StreamState.CLOSED; Http2RstStream rst = new Http2RstStream(Http2ErrorCode.PROTOCOL); - writer.write(rst.toFrameData(clientSettings, streamId, Http2Flag.NoFlags.create()), flowControl); + writer.write(rst.toFrameData(clientSettings, streamId, Http2Flag.NoFlags.create())); return; } if (expectedLength != -1) { @@ -249,8 +252,8 @@ public Http2StreamState streamState() { } @Override - public FlowControl flowControl() { - return flowControl; + public StreamFlowControl flowControl() { + return this.flowControl; } @Override @@ -262,7 +265,7 @@ public void run() { handle(); } catch (SocketWriterException | CloseConnectionException | UncheckedIOException e) { Http2RstStream rst = new Http2RstStream(Http2ErrorCode.STREAM_CLOSED); - writer.write(rst.toFrameData(serverSettings, streamId, Http2Flag.NoFlags.create()), flowControl); + writer.write(rst.toFrameData(serverSettings, streamId, Http2Flag.NoFlags.create())); // no sense in throwing an exception, as this is invoked from an executor service directly } catch (RequestException e) { DirectHandler handler = ctx.listenerContext() @@ -284,7 +287,7 @@ public void run() { writer.writeHeaders(http2Headers, streamId, Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS | Http2Flag.END_OF_STREAM), - flowControl); + flowControl.outbound()); } else { Http2FrameHeader dataHeader = Http2FrameHeader.create(message.length, Http2FrameTypes.DATA, @@ -294,7 +297,7 @@ public void run() { streamId, Http2Flag.HeaderFlags.create(Http2Flag.END_OF_HEADERS), new Http2FrameData(dataHeader, BufferData.create(message)), - flowControl); + flowControl.outbound()); } } finally { headers = null; @@ -314,6 +317,7 @@ private BufferData readEntityFromPipeline() { DataFrame frame; try { frame = inboundData.take(); + flowControl.inbound().incrementWindowSize(frame.header().length()); } catch (InterruptedException e) { // this stream was interrupted, does not make sense to do anything else return BufferData.empty(); @@ -374,7 +378,7 @@ private void handle() { decoder, streamId, this::readEntityFromPipeline); - Http2ServerResponse response = new Http2ServerResponse(ctx, request, writer, streamId, flowControl); + Http2ServerResponse response = new Http2ServerResponse(ctx, request, writer, streamId, flowControl.outbound()); try { routing.route(ctx, request, response); } finally { diff --git a/nima/http2/webserver/src/test/java/io/helidon/nima/http2/webserver/ConnectionConfigTest.java b/nima/http2/webserver/src/test/java/io/helidon/nima/http2/webserver/ConnectionConfigTest.java index 0b88d3359b7..7d2ec907800 100644 --- a/nima/http2/webserver/src/test/java/io/helidon/nima/http2/webserver/ConnectionConfigTest.java +++ b/nima/http2/webserver/src/test/java/io/helidon/nima/http2/webserver/ConnectionConfigTest.java @@ -16,6 +16,7 @@ package io.helidon.nima.http2.webserver; +import java.time.Duration; import java.util.List; import java.util.function.Function; @@ -52,7 +53,7 @@ void testConnectionConfig() { WebServer.builder().addConnectionProvider(provider).build(); assertThat(provider.isConfig(), is(true)); Http2Config http2Config = provider.config(); - assertThat(http2Config.maxFrameSize(), is(8192L)); + assertThat(http2Config.maxFrameSize(), is(8192)); assertThat(http2Config.maxHeaderListSize(), is(4096L)); } @@ -62,7 +63,7 @@ void testProviderConfigBuilder() { Http2ConnectionSelector provider = (Http2ConnectionSelector) Http2ConnectionProvider.builder() .http2Config(DefaultHttp2Config.builder() - .maxFrameSize(4096L) + .maxFrameSize(4096) .maxHeaderListSize(2048L) .build()) .build() @@ -70,7 +71,7 @@ void testProviderConfigBuilder() { Http2Connection conn = (Http2Connection) provider.connection(mockContext()); // Verify values to be updated from configuration file - assertThat(conn.config().maxFrameSize(), is(4096L)); + assertThat(conn.config().maxFrameSize(), is(4096)); assertThat(conn.config().maxHeaderListSize(), is(2048L)); // Verify Http2Settings values to be updated from configuration file assertThat(conn.serverSettings().value(Http2Setting.MAX_FRAME_SIZE), is(4096L)); @@ -99,6 +100,37 @@ void testConfigValidatePath() { assertThat(http2Config.validatePath(), is(false)); } + // Verify that HTTP/2 maximum connection-level window size is properly configured from configuration file + @Test + void testInitialWindowSize() { + // This will pick up application.yaml from the classpath as default configuration file + TestProvider provider = new TestProvider(); + WebServer.builder().addConnectionProvider(provider).build(); + assertThat(provider.isConfig(), is(true)); + Http2Config http2Config = provider.config(); + assertThat(http2Config.initialWindowSize(), is(8192)); + } + + @Test + void maxFrameSize() { + // This will pick up application.yaml from the classpath as default configuration file + TestProvider provider = new TestProvider(); + WebServer.builder().addConnectionProvider(provider).build(); + assertThat(provider.isConfig(), is(true)); + Http2Config http2Config = provider.config(); + assertThat(http2Config.maxFrameSize(), is(8192)); + } + + @Test + void flowControlTimeout() { + // This will pick up application.yaml from the classpath as default configuration file + TestProvider provider = new TestProvider(); + WebServer.builder().addConnectionProvider(provider).build(); + assertThat(provider.isConfig(), is(true)); + Http2Config http2Config = provider.config(); + assertThat(http2Config.flowControlTimeout(), is(Duration.ofMillis(700))); + } + private static class TestProvider implements ServerConnectionProvider { private Http2Config http2Config = null; diff --git a/nima/http2/webserver/src/test/resources/application.yaml b/nima/http2/webserver/src/test/resources/application.yaml index 08918614969..33f71e48390 100644 --- a/nima/http2/webserver/src/test/resources/application.yaml +++ b/nima/http2/webserver/src/test/resources/application.yaml @@ -23,4 +23,6 @@ server: max-frame-size: 8192 max-header-list-size: 4096 max-concurrent-streams: 16384 + initial-window-size: 8192 + flow-control-timeout: PT0.7S validate-path: false diff --git a/nima/tests/integration/http2/client/pom.xml b/nima/tests/integration/http2/client/pom.xml index f1ad9871b87..13302101d9f 100644 --- a/nima/tests/integration/http2/client/pom.xml +++ b/nima/tests/integration/http2/client/pom.xml @@ -51,5 +51,10 @@ hamcrest-all test + + io.vertx + vertx-core + test + diff --git a/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/ClientFlowControlTest.java b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/ClientFlowControlTest.java new file mode 100644 index 00000000000..1263207ebfb --- /dev/null +++ b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/ClientFlowControlTest.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2023 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.nima.tests.integration.http2.client; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; + +import io.helidon.common.http.Http; +import io.helidon.logging.common.LogConfig; +import io.helidon.nima.http2.WindowSize; +import io.helidon.nima.http2.webclient.Http2; +import io.helidon.nima.http2.webclient.Http2Client; +import io.helidon.nima.http2.webclient.Http2ClientResponse; +import io.helidon.nima.webclient.WebClient; + +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerRequest; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import static java.lang.System.Logger.Level.DEBUG; +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class ClientFlowControlTest { + + private static final System.Logger LOGGER = System.getLogger(ClientFlowControlTest.class.getName()); + private static final Duration TIMEOUT = Duration.ofSeconds(10); + private static final String DATA_10_K = "Helidon!!!".repeat(1_000); + private static final byte[] BYTES_10_K = DATA_10_K.getBytes(StandardCharsets.UTF_8); + private static final String DATA_10_K_UP_CASE = DATA_10_K.toUpperCase(); + private static final byte[] BYTES_10_K_UP_CASE = DATA_10_K_UP_CASE.getBytes(StandardCharsets.UTF_8); + private static final String EXPECTED = DATA_10_K.repeat(5) + DATA_10_K_UP_CASE.repeat(5); + private static final AtomicLong inboundServerSentData = new AtomicLong(); + private static final Vertx vertx = Vertx.vertx(); + private static final ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor(); + private static HttpServer server; + private static CompletableFuture outboundTestServerRequestRef = new CompletableFuture<>(); + private static Http2Client client; + + @BeforeAll + static void beforeAll() throws ExecutionException, InterruptedException, TimeoutException { + LogConfig.configureRuntime(); + server = vertx.createHttpServer() + .requestHandler(req -> { + switch (req.path()) { + case "/in" -> { + for (int i = 0; i < 5; i++) { + req.response().write(DATA_10_K) + .andThen(event -> LOGGER.log(DEBUG, "Vertx server sent " + + inboundServerSentData.addAndGet(BYTES_10_K.length))); + } + for (int i = 5; i < 10; i++) { + req.response().write(DATA_10_K.toUpperCase()) + .andThen(event -> LOGGER.log(DEBUG, "Vertx server sent " + + inboundServerSentData.addAndGet(BYTES_10_K_UP_CASE.length))); + } + req.end(); + } + case "/out" -> { + req.pause(); + outboundTestServerRequestRef.complete(req); + req.handler(e -> { + req.response().write(e); + }).endHandler(event -> req.response().end()); + } + } + }) + .listen(-1) + .toCompletionStage() + .toCompletableFuture() + .get(TIMEOUT.toMillis(), MILLISECONDS); + + client = WebClient.builder(Http2.PROTOCOL) + .baseUri("http://localhost:" + server.actualPort() + "/") + .prefetch(10000) + .build(); + } + + @AfterAll + static void afterAll() { + server.close(); + vertx.close(); + exec.shutdown(); + try { + if (!exec.awaitTermination(TIMEOUT.toMillis(), MILLISECONDS)) { + exec.shutdownNow(); + } + } catch (InterruptedException e) { + exec.shutdownNow(); + } + } + + @Test + void clientOutbound() throws InterruptedException, ExecutionException, TimeoutException { + outboundTestServerRequestRef = new CompletableFuture<>(); + + //Chunk of the frame size * 2 so frame splitting is tested too + int chunkSize = WindowSize.DEFAULT_MAX_FRAME_SIZE * 2; + + AtomicLong clientSentData = new AtomicLong(); + + ByteArrayInputStream baos = new ByteArrayInputStream(EXPECTED.getBytes()); + CompletableFuture clientFuture = CompletableFuture.supplyAsync(() -> { + try (Http2ClientResponse res = client + .method(Http.Method.PUT) + .path("/out") + .priorKnowledge(true) + .outputStream(out -> { + while (baos.available() > 0) { + byte[] chunk = baos.readNBytes(chunkSize); + clientSentData.addAndGet(chunk.length); + out.write(chunk); + } + out.close(); + })) { + return res.as(String.class); + } catch (Exception e) { + throw new RuntimeException(e); + } + }, exec); + HttpServerRequest req = outboundTestServerRequestRef.get(TIMEOUT.toMillis(), MILLISECONDS); + req.fetch(1); + awaitSize("Two client chunks should fit to default window size", clientSentData, chunkSize * 2L); + // Vertx translates frames to chunks of default MAX_FRAME_SIZE 16384 + // 4 * 16384 depletes Vertx's windows size and force it to send update + req.fetch(3); + awaitSize("Three client chunks should have been sent now", clientSentData, chunkSize * 3L); + req.fetch(Long.MAX_VALUE); + awaitSize("Three client chunks should have been sent now", + clientSentData, + EXPECTED.getBytes(StandardCharsets.UTF_8).length); + assertThat("Echo endpoint should have returned exactly same data", + clientFuture.get(TIMEOUT.toMillis(), MILLISECONDS), + is(EXPECTED)); + } + + @Test + void clientInbound() throws InterruptedException { + + AtomicLong receivedByteSize = new AtomicLong(); + try (Http2ClientResponse res = client + .method(Http.Method.GET) + .path("/in") + .priorKnowledge(true) + .request()) { + + final ByteBuffer bb = ByteBuffer.allocate(EXPECTED.getBytes().length); + final InputStream is = res.inputStream(); + + // Accept only 10k out of initial win size (64k) + Semaphore semaphore = new Semaphore(10_000); + CompletableFuture.runAsync(() -> { + try { + int b = -1; + for (int i = 0; i < bb.capacity(); i++) { + semaphore.acquire(); + b = is.read(); + if (b == -1) { + break; + } + receivedByteSize.incrementAndGet(); + bb.put((byte) b); + } + is.close(); + } catch (IOException | InterruptedException e) { + throw new RuntimeException(e); + } + }, exec); + + awaitSize("Server should have accepted 70k, one 10k chunk over 64k(initial win size)", + inboundServerSentData, 70_000); + // Give it time to make a mistake and send more if FC doesn't work correctly + Thread.sleep(300); + assertThat("", inboundServerSentData.get(), is(70_000L)); + + // Unblock and accept the rest of the data + semaphore.release(Integer.MAX_VALUE); + awaitSize("Rest of the data 100k should have been received", receivedByteSize, EXPECTED.getBytes().length); + + String result = new String(bb.array(), StandardCharsets.UTF_8); + assertThat("Echo endpoint should have returned exactly same data", result, is(EXPECTED)); + } + } + + private void awaitSize(String msg, AtomicLong actualSize, long expected) + throws InterruptedException { + + for (int i = 10; i < 5000 && actualSize.get() < expected; i *= 10) { + Thread.sleep(i); + } + assertThat(msg, actualSize.get(), is(expected)); + } +} diff --git a/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/GetTest.java b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/GetTest.java index df427752a98..62a66446200 100644 --- a/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/GetTest.java +++ b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/GetTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -40,7 +40,6 @@ import io.helidon.nima.webserver.http.ServerRequest; import io.helidon.nima.webserver.http.ServerResponse; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static org.hamcrest.CoreMatchers.is; @@ -115,8 +114,6 @@ void testByteRoute() throws IOException, InterruptedException { } @Test - @Disabled - // todo this times out void testChunkedRoute() throws IOException, InterruptedException { HttpRequest request = HttpRequest.newBuilder() .GET() diff --git a/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/HeadersTest.java b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/HeadersTest.java new file mode 100644 index 00000000000..1d93aa5d603 --- /dev/null +++ b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/HeadersTest.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2023 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.nima.tests.integration.http2.client; + +import java.time.Duration; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeoutException; + +import io.helidon.common.http.Headers; +import io.helidon.common.http.Http; +import io.helidon.logging.common.LogConfig; +import io.helidon.nima.http2.webclient.Http2; +import io.helidon.nima.http2.webclient.Http2ClientResponse; +import io.helidon.nima.webclient.WebClient; + +import io.vertx.core.Vertx; +import io.vertx.core.http.HttpServer; +import io.vertx.core.http.HttpServerResponse; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class HeadersTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(10); + private static final String DATA = "Helidon!!!".repeat(10); + private static final Vertx vertx = Vertx.vertx(); + private static final ExecutorService exec = Executors.newVirtualThreadPerTaskExecutor(); + private static HttpServer server; + private static int port; + + @BeforeAll + static void beforeAll() throws ExecutionException, InterruptedException, TimeoutException { + LogConfig.configureRuntime(); + server = vertx.createHttpServer() + .requestHandler(req -> { + HttpServerResponse res = req.response(); + switch (req.path()) { + case "/trailer" -> { + res.putHeader("test", "before"); + res.write(DATA); + res.putTrailer("Trailer-header", "trailer-test"); + res.end(); + } + case "/cont" -> { + for (int i = 0; i < 500; i++) { + res.headers().add("test-header-" + i, DATA); + } + res.write(DATA); + res.end(); + } + default -> res.setStatusCode(404).end(); + } + }) + .listen(0) + .toCompletionStage() + .toCompletableFuture() + .get(TIMEOUT.toMillis(), MILLISECONDS); + + port = server.actualPort(); + } + + @AfterAll + static void afterAll() { + server.close(); + vertx.close(); + exec.shutdown(); + try { + if (!exec.awaitTermination(TIMEOUT.toMillis(), MILLISECONDS)) { + exec.shutdownNow(); + } + } catch (InterruptedException e) { + exec.shutdownNow(); + } + } + + @Test + //FIXME: trailer headers are not implemented yet + @Disabled + void trailerHeader() { + try (Http2ClientResponse res = WebClient.builder(Http2.PROTOCOL) + .baseUri("http://localhost:" + port + "/") + .build() + .method(Http.Method.GET) + .path("/trailer") + .priorKnowledge(true) + .request()) { + Headers h = res.headers(); + assertThat(h.first(Http.Header.create("test")).orElse(null), is("before")); + assertThat(res.as(String.class), is(DATA)); + assertThat(h.first(Http.Header.create("Trailer-header")).orElse(null), is("trailer-test")); + } + } + + @Test + void continuation() { + try (Http2ClientResponse res = WebClient.builder(Http2.PROTOCOL) + .baseUri("http://localhost:" + port + "/") + .build() + .method(Http.Method.GET) + .path("/cont") + .priorKnowledge(true) + .request()) { + + Headers h = res.headers(); + for (int i = 0; i < 500; i++) { + String name = "test-header-" + i; + assertThat("Headers " + name, h.first(Http.Header.create(name)).orElse(null), is(DATA)); + } + + assertThat(res.as(String.class), is(DATA)); + } + } +} diff --git a/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/Http2ClientTest.java b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/Http2ClientTest.java index 3601188558a..18b01b42ea2 100644 --- a/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/Http2ClientTest.java +++ b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/Http2ClientTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -35,7 +35,6 @@ import io.helidon.nima.webserver.WebServer; import io.helidon.nima.webserver.http.HttpRouting; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static org.hamcrest.CoreMatchers.is; @@ -43,11 +42,10 @@ @ServerTest class Http2ClientTest { - public static final String MESSAGE = "Hello World!"; + private static final String MESSAGE = "Hello World!"; private static final String TEST_HEADER_NAME = "custom_header"; private static final String TEST_HEADER_VALUE = "as!fd"; private static final HeaderValue TEST_HEADER = Header.create(Header.create(TEST_HEADER_NAME), TEST_HEADER_VALUE); - private final Http1Client http1Client; private final Http2Client tlsClient; private final Http2Client plainClient; @@ -66,7 +64,7 @@ class Http2ClientTest { .tls(insecureTls) .build(); this.plainClient = WebClient.builder(Http2.PROTOCOL) - .baseUri("https://localhost:" + plainPort + "/") + .baseUri("http://localhost:" + plainPort + "/") .build(); } @@ -96,47 +94,51 @@ static void router(HttpRouting.Builder router) { @Test void testHttp1() { // make sure the HTTP/1 route is not working + try (Http1ClientResponse response = http1Client + .get("/") + .request()) { - Http1ClientResponse response = http1Client.get("/") - .request(); - - assertThat(response.status(), is(Http.Status.NOT_FOUND_404)); + assertThat(response.status(), is(Http.Status.NOT_FOUND_404)); + } } @Test - @Disabled("HTTP/2 Client not yet implemented") void testUpgrade() { - Http2ClientResponse response = plainClient.get("/") - .request(); - - assertThat(response.status(), is(Http.Status.OK_200)); - assertThat(response.as(String.class), is(MESSAGE)); - assertThat(TEST_HEADER + " header must be present in response", - response.headers().contains(TEST_HEADER), is(true)); + try (Http2ClientResponse response = plainClient + .get("/") + .request()) { + + assertThat(response.status(), is(Http.Status.OK_200)); + assertThat(response.as(String.class), is(MESSAGE)); + assertThat(TEST_HEADER + " header must be present in response", + response.headers().contains(TEST_HEADER), is(true)); + } } @Test - @Disabled("HTTP/2 Client not yet implemented") void testAppProtocol() { - Http2ClientResponse response = tlsClient.get("/") - .request(); - - assertThat(response.status(), is(Http.Status.OK_200)); - assertThat(response.as(String.class), is(MESSAGE)); - assertThat(TEST_HEADER + " header must be present in response", - response.headers().contains(TEST_HEADER), is(true)); + try (Http2ClientResponse response = tlsClient + .get("/") + .request()) { + + assertThat(response.status(), is(Http.Status.OK_200)); + assertThat(response.as(String.class), is(MESSAGE)); + assertThat(TEST_HEADER + " header must be present in response", + response.headers().contains(TEST_HEADER), is(true)); + } } @Test - @Disabled("HTTP/2 Client not yet implemented") void testPriorKnowledge() { - Http2ClientResponse response = tlsClient.get("/") + try (Http2ClientResponse response = tlsClient + .get("/") .priorKnowledge(true) - .request(); + .request()) { - assertThat(response.status(), is(Http.Status.OK_200)); - assertThat(response.as(String.class), is(MESSAGE)); - assertThat(TEST_HEADER + " header must be present in response", - response.headers().contains(TEST_HEADER), is(true)); + assertThat(response.status(), is(Http.Status.OK_200)); + assertThat(response.as(String.class), is(MESSAGE)); + assertThat(TEST_HEADER + " header must be present in response", + response.headers().contains(TEST_HEADER), is(true)); + } } } diff --git a/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/PostTest.java b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/PostTest.java index 36e91c1f2ff..9ffb51334ac 100644 --- a/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/PostTest.java +++ b/nima/tests/integration/http2/client/src/test/java/io/helidon/nima/tests/integration/http2/client/PostTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -28,9 +28,8 @@ import io.helidon.common.http.Http.Header; import io.helidon.common.http.Http.HeaderName; import io.helidon.common.http.Http.HeaderValue; -import io.helidon.common.http.Http.HeaderValues; import io.helidon.nima.http2.webclient.Http2; -import io.helidon.nima.http2.webclient.Http2ClientRequest; +import io.helidon.nima.http2.webclient.Http2Client; import io.helidon.nima.http2.webclient.Http2ClientResponse; import io.helidon.nima.webclient.WebClient; import io.helidon.nima.webserver.WebServer; @@ -41,15 +40,12 @@ import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static io.helidon.common.testing.http.junit5.HttpHeaderMatcher.hasHeader; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; -// todo implement http/2 client -@Disabled class PostTest { private static final byte[] BYTES = new byte[256]; private static final HeaderName REQUEST_HEADER_NAME = Header.create("X-REquEst-HEADeR"); @@ -61,7 +57,7 @@ class PostTest { RESPONSE_HEADER_VALUE_STRING); private static WebServer server; - private static Http2ClientRequest request; + private static Http2Client client; static { Random random = new Random(); @@ -72,6 +68,7 @@ class PostTest { static void startServer() { server = WebServer.builder() .host("localhost") + .port(-1) .addRouting(HttpRouting.builder() .route(Http.Method.POST, "/string", Handler.create(String.class, Routes::string)) .route(Http.Method.POST, "/bytes", Handler.create(byte[].class, Routes::bytes)) @@ -82,11 +79,10 @@ static void startServer() { .build() .start(); - request = WebClient.builder(Http2.PROTOCOL) + client = WebClient.builder(Http2.PROTOCOL) .baseUri("http://localhost:" + server.port()) .priorKnowledge(true) - .build() - .method(Http.Method.POST); + .build(); } @AfterAll @@ -98,7 +94,9 @@ static void stopServer() { @Test void testStringRoute() { - Http2ClientResponse response = request.uri("/string") + Http2ClientResponse response = client + .method(Http.Method.POST) + .uri("/string") .submit("Hello"); assertThat(response.status(), is(Http.Status.OK_200)); @@ -106,12 +104,13 @@ void testStringRoute() { assertThat(entity, is("Hello")); Headers headers = response.headers(); assertThat("Should have correct length", headers.contentLength(), is(OptionalLong.of(5))); - assertThat(headers, hasHeader(HeaderValues.CONNECTION_KEEP_ALIVE)); } @Test void testByteRoute() { - Http2ClientResponse response = request.uri("/bytes") + Http2ClientResponse response = client + .method(Http.Method.POST) + .uri("/bytes") .submit(BYTES); assertThat(response.status(), is(Http.Status.OK_200)); @@ -119,12 +118,13 @@ void testByteRoute() { assertThat(entity, is(BYTES)); Headers headers = response.headers(); assertThat(headers.contentLength(), is(OptionalLong.of(BYTES.length))); - assertThat(headers, hasHeader(HeaderValues.CONNECTION_KEEP_ALIVE)); } @Test void testChunkedRoute() { - Http2ClientResponse response = request.uri("/chunked") + Http2ClientResponse response = client + .method(Http.Method.POST) + .uri("/chunked") .outputStream(outputStream -> { outputStream.write(BYTES); outputStream.close(); @@ -133,14 +133,13 @@ void testChunkedRoute() { assertThat(response.status(), is(Http.Status.OK_200)); byte[] entity = response.entity().as(byte[].class); assertThat(entity, is(BYTES)); - Headers headers = response.headers(); - assertThat(headers, hasHeader(HeaderValues.TRANSFER_ENCODING_CHUNKED)); - assertThat(headers, hasHeader(HeaderValues.CONNECTION_KEEP_ALIVE)); } @Test void testHeadersRoute() { - Http2ClientResponse response = request.uri("/headers") + Http2ClientResponse response = client + .method(Http.Method.POST) + .uri("/headers") .header(REQUEST_HEADER_VALUE) .submit("Hello"); @@ -149,26 +148,24 @@ void testHeadersRoute() { assertThat(entity, is("Hello")); Headers headers = response.headers(); assertThat(headers.contentLength(), is(OptionalLong.of(5))); - assertThat(headers, hasHeader(HeaderValues.CONNECTION_KEEP_ALIVE)); assertThat(headers, hasHeader(REQUEST_HEADER_VALUE)); assertThat(headers, hasHeader(RESPONSE_HEADER_VALUE)); } @Test void testCloseRoute() { - Http2ClientResponse response = request.uri("/close") + Http2ClientResponse response = client + .method(Http.Method.POST) + .uri("/close") .submit("Hello"); assertThat(response.status(), is(Http.Status.NO_CONTENT_204)); String entity = response.entity().as(String.class); assertThat(entity, is("")); - Headers headers = response.headers(); - assertThat(headers, hasHeader(HeaderValues.CONNECTION_CLOSE)); } private static class Routes { public static void close(ServerRequest req, ServerResponse res) { - res.header(HeaderValues.CONNECTION_CLOSE); res.status(Http.Status.NO_CONTENT_204); res.send(); } diff --git a/nima/tests/integration/http2/client/src/test/resources/logging-test.properties b/nima/tests/integration/http2/client/src/test/resources/logging-test.properties new file mode 100644 index 00000000000..eba7d6f4be5 --- /dev/null +++ b/nima/tests/integration/http2/client/src/test/resources/logging-test.properties @@ -0,0 +1,21 @@ +# +# Copyright (c) 2023 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. +# +handlers=java.util.logging.ConsoleHandler +java.util.logging.ConsoleHandler.level=FINEST +java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS.%1$tL %5$s%6$s%n +# Global logging level. Can be overridden by specific loggers +.level=INFO +io.helidon.nima.level=INFO diff --git a/nima/tests/integration/http2/server/pom.xml b/nima/tests/integration/http2/server/pom.xml index 2183e0fcf0c..dc2b6c10af8 100644 --- a/nima/tests/integration/http2/server/pom.xml +++ b/nima/tests/integration/http2/server/pom.xml @@ -37,6 +37,11 @@ helidon-nima-testing-junit5-webserver test + + io.helidon.common + helidon-common-reactive + test + org.junit.jupiter junit-jupiter-api @@ -47,5 +52,10 @@ hamcrest-all test + + io.helidon.nima.http2 + helidon-nima-http2-webclient + test + diff --git a/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/FlowControlTest.java b/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/FlowControlTest.java new file mode 100644 index 00000000000..7606a94cdd1 --- /dev/null +++ b/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/FlowControlTest.java @@ -0,0 +1,256 @@ +/* + * Copyright (c) 2023 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.nima.tests.integration.http2.webserver; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.ByteBuffer; +import java.time.Duration; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Level; + +import io.helidon.common.reactive.BufferedEmittingPublisher; +import io.helidon.common.reactive.Multi; +import io.helidon.nima.http2.WindowSize; +import io.helidon.nima.http2.webclient.Http2Client; +import io.helidon.nima.http2.webserver.DefaultHttp2Config; +import io.helidon.nima.http2.webserver.Http2ConnectionProvider; +import io.helidon.nima.http2.webserver.Http2Route; +import io.helidon.nima.testing.junit5.webserver.ServerTest; +import io.helidon.nima.testing.junit5.webserver.SetUpServer; +import io.helidon.nima.webserver.WebServer; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; + +import static java.lang.System.Logger.Level.DEBUG; + +import static io.helidon.common.http.Http.Method.GET; +import static io.helidon.common.http.Http.Method.PUT; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.lessThan; + +@ServerTest +class FlowControlTest { + + private static final System.Logger LOGGER = System.getLogger(FlowControlTest.class.getName()); + private static final ExecutorService exec = Executors.newCachedThreadPool(); + private static final String DATA_10_K = "Helidon!!!".repeat(1_000); + private static final String EXPECTED = DATA_10_K.repeat(5) + DATA_10_K.toUpperCase().repeat(5); + private static final int TIMEOUT_SEC = 150; + + private static volatile CompletableFuture flowControlServerLatch = new CompletableFuture<>(); + private static volatile CompletableFuture flowControlClientLatch = new CompletableFuture<>(); + + private final WebServer server; + + FlowControlTest(WebServer server) { + this.server = server; + } + + @SetUpServer + static void setUpServer(WebServer.Builder serverBuilder) { + serverBuilder + .addConnectionProvider(Http2ConnectionProvider.builder() + .http2Config(DefaultHttp2Config.builder() + .initialWindowSize(WindowSize.DEFAULT_WIN_SIZE) + ) + .build()) + .defaultSocket(builder -> builder + .host("localhost") + ) + .routing(router -> router + .route(Http2Route.route(GET, "/", (req, res) -> res.send("OK"))) + .route(Http2Route.route(PUT, "/flow-control", (req, res) -> { + StringBuilder sb = new StringBuilder(); + AtomicLong cnt = new AtomicLong(); + InputStream is = req.content().inputStream(); + for (byte[] b = is.readNBytes(5_000); + b.length != 0; + b = is.readNBytes(5_000)) { + int lastLength = b.length; + long receivedData = cnt.updateAndGet(o -> o + lastLength); + if (receivedData > 0) { + // Unblock client to assert sent data + flowControlClientLatch.complete(null); + // Block server, give client time to assert + flowControlServerLatch.join(); + } + sb.append(new String(b)); + } + is.close(); + res.send(sb.toString()); + + })) + .route(Http2Route.route(GET, "/flow-control", (req, res) -> { + res.send(EXPECTED); + })) + ).port(8080); + } + + @AfterAll + static void afterAll() throws InterruptedException { + exec.shutdown(); + if (!exec.awaitTermination(TIMEOUT_SEC, TimeUnit.SECONDS)) { + exec.shutdownNow(); + } + } + + @Test + void flowControlWebClientInOut() throws ExecutionException, InterruptedException, TimeoutException { + flowControlServerLatch = new CompletableFuture<>(); + flowControlClientLatch = new CompletableFuture<>(); + AtomicLong sentData = new AtomicLong(); + + var client = Http2Client.builder() + .priorKnowledge(true) + .initialWindowSize(WindowSize.DEFAULT_WIN_SIZE) + .baseUri("http://localhost:" + server.port()) + .build(); + + var req = client.method(PUT) + .path("/flow-control"); + + CompletableFuture responded = new CompletableFuture<>(); + + exec.submit(() -> { + try (var res = req + .outputStream( + out -> { + for (int i = 0; i < 5; i++) { + byte[] bytes = DATA_10_K.getBytes(); + LOGGER.log(DEBUG, + () -> String.format("CL: Sending %d bytes", bytes.length)); + out.write(bytes); + sentData.updateAndGet(o -> o + bytes.length); + } + for (int i = 0; i < 5; i++) { + byte[] bytes = DATA_10_K.toUpperCase().getBytes(); + LOGGER.log(DEBUG, + () -> String.format("CL: Sending %d bytes", bytes.length)); + out.write(bytes); + sentData.updateAndGet(o -> o + bytes.length); + } + out.close(); + } + )) { + responded.complete(res.as(String.class)); + } + }); + + flowControlClientLatch.get(TIMEOUT_SEC, TimeUnit.SECONDS); + // Now client can't send more, because server didn't ask for it (Window update) + // Wait a bit if more than allowed is sent + Thread.sleep(300); + // Depends on the win update strategy, can't be full 100k + assertThat(sentData.get(), is(70_000L)); + // Let server ask for the rest of the data + flowControlServerLatch.complete(null); + String response = responded.get(TIMEOUT_SEC, TimeUnit.SECONDS); + assertThat(sentData.get(), is(100_000L)); + assertThat(response, is(EXPECTED)); + } + + @Test + void flowControlWebClientInbound() { + var client = Http2Client.builder() + .priorKnowledge(true) + .baseUri("http://localhost:" + server.port()) + .build(); + + try (var res = client.method(GET) + .path("/flow-control") + .request()) { + assertThat(res.entity().as(String.class), is(EXPECTED)); + } + } + + @Test + void flowControlHttpClientInOut() throws ExecutionException, InterruptedException, TimeoutException, IOException { + flowControlServerLatch = new CompletableFuture<>(); + flowControlClientLatch = new CompletableFuture<>(); + AtomicLong sentData = new AtomicLong(); + + BufferedEmittingPublisher publisher = BufferedEmittingPublisher.create(); + + HttpClient cl = HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_2) + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + cl.send(HttpRequest.newBuilder() + .uri(URI.create("http://localhost:" + server.port())) + .GET().build(), HttpResponse.BodyHandlers.discarding()); + + CompletableFuture responded = new CompletableFuture<>(); + + exec.submit(() -> { + try { + HttpResponse response = + cl.send(HttpRequest.newBuilder() + .timeout(Duration.ofSeconds(5)) + .uri(URI.create("http://localhost:" + server.port() + "/flow-control")) + .PUT(HttpRequest.BodyPublishers.fromPublisher( + Multi.create(publisher) + .peek(bb -> sentData.updateAndGet( + o -> o + bb.array().length)) + .log(Level.FINE) + )) + .build(), + HttpResponse.BodyHandlers.ofString()); + responded.complete(response.body()); + } catch (IOException | InterruptedException e) { + responded.completeExceptionally(e); + } + }); + + for (int i = 0; i < 5; i++) { + byte[] bytes = DATA_10_K.getBytes(); + LOGGER.log(DEBUG, () -> String.format("CL: Sending %d bytes", bytes.length)); + publisher.emit(ByteBuffer.wrap(bytes)); + } + for (int i = 0; i < 5; i++) { + byte[] bytes = DATA_10_K.toUpperCase().getBytes(); + LOGGER.log(DEBUG, () -> String.format("CL: Sending %d bytes", bytes.length)); + publisher.emit(ByteBuffer.wrap(bytes)); + } + + publisher.complete(); + + flowControlClientLatch.get(TIMEOUT_SEC, TimeUnit.SECONDS); + // Now client can't send more, because server didn't ask for it (Window update) + // Wait a bit if more than allowed is sent + Thread.sleep(300); + // Depends on the win update strategy, can't be full 100k + assertThat(sentData.get(), lessThan(99_000L)); + // Let server ask for the rest of the data + flowControlServerLatch.complete(null); + String response = responded.get(TIMEOUT_SEC, TimeUnit.SECONDS); + assertThat(sentData.get(), is(100_000L)); + assertThat(response, is(EXPECTED)); + } +} diff --git a/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/Http2ServerTest.java b/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/Http2ServerTest.java index ef5be6bdb20..34f125237d7 100644 --- a/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/Http2ServerTest.java +++ b/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/Http2ServerTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -41,7 +41,6 @@ import io.helidon.nima.webserver.http.ServerRequest; import io.helidon.nima.webserver.http.ServerResponse; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; import static io.helidon.common.http.Http.Method.GET; @@ -179,10 +178,4 @@ void testAppProtocol2() throws IOException, InterruptedException { assertThat(response.body(), is("paramValue")); System.clearProperty("jdk.internal.httpclient.disableHostnameVerification"); } - - @Test - @Disabled("No way to test using JDK HTTP Client, Should be part of HTTP/2 client tests") - void testPriorKnowledge() { - - } } diff --git a/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/Http2WebServerStopIdleTest.java b/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/Http2WebServerStopIdleTest.java index 810ebd5cb4d..8377564bbec 100644 --- a/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/Http2WebServerStopIdleTest.java +++ b/nima/tests/integration/http2/server/src/test/java/io/helidon/nima/tests/integration/http2/webserver/Http2WebServerStopIdleTest.java @@ -79,6 +79,6 @@ void stopWhenIdleExpectTimelyStopHttp2() throws IOException, InterruptedExceptio long startMillis = System.currentTimeMillis(); webServer.stop(); int stopExecutionTimeInMillis = (int) (System.currentTimeMillis() - startMillis); - assertThat(stopExecutionTimeInMillis, is(lessThan(500))); + assertThat(stopExecutionTimeInMillis, is(lessThan(550))); } } diff --git a/nima/tests/integration/http2/server/src/test/resources/logging-test.properties b/nima/tests/integration/http2/server/src/test/resources/logging-test.properties index 1a57678ee5e..aff4b62fc13 100644 --- a/nima/tests/integration/http2/server/src/test/resources/logging-test.properties +++ b/nima/tests/integration/http2/server/src/test/resources/logging-test.properties @@ -1,5 +1,5 @@ # -# Copyright (c) 2022 Oracle and/or its affiliates. +# Copyright (c) 2022, 2023 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,6 +15,7 @@ # handlers=java.util.logging.ConsoleHandler java.util.logging.ConsoleHandler.level=FINEST +java.util.logging.ConsoleHandler.formatter=java.util.logging.SimpleFormatter java.util.logging.SimpleFormatter.format=%1$tY.%1$tm.%1$td %1$tH:%1$tM:%1$tS.%1$tL %5$s%6$s%n # Global logging level. Can be overridden by specific loggers .level=INFO diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/UriHelper.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/UriHelper.java index 9c5e2910294..cc19bf45ad8 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/UriHelper.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/UriHelper.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -17,6 +17,7 @@ package io.helidon.nima.webclient; import java.net.URI; +import java.util.Map; import io.helidon.common.uri.UriEncoding; import io.helidon.common.uri.UriQuery; @@ -26,6 +27,11 @@ * Helper for client URI handling. */ public class UriHelper { + + private static final Map DEFAULT_PORTS = Map.of( + "http", 80, + "https", 443 + ); private final String baseScheme; private final String baseAuthority; private final String basePath; @@ -162,6 +168,9 @@ public String host() { * @return port */ public int port() { + if (this.port == -1) { + return DEFAULT_PORTS.getOrDefault(this.scheme, -1); + } return port; } diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java index a0a8cd0e408..ae3e6058386 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ClientRequestImpl.java @@ -50,7 +50,6 @@ import io.helidon.nima.http.media.EntityWriter; import io.helidon.nima.http.media.MediaContext; import io.helidon.nima.webclient.ClientConnection; -import io.helidon.nima.webclient.ConnectionKey; import io.helidon.nima.webclient.UriHelper; import static java.lang.System.Logger.Level.DEBUG; diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/ConnectionKey.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ConnectionKey.java similarity index 86% rename from nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/ConnectionKey.java rename to nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ConnectionKey.java index 1c26859a261..dff31fb59d3 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/ConnectionKey.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/ConnectionKey.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -14,9 +14,10 @@ * limitations under the License. */ -package io.helidon.nima.webclient; +package io.helidon.nima.webclient.http1; import io.helidon.nima.common.tls.Tls; +import io.helidon.nima.webclient.DnsAddressLookup; import io.helidon.nima.webclient.spi.DnsResolver; /** @@ -29,7 +30,7 @@ * @param dnsResolver DNS resolver to be used * @param dnsAddressLookup DNS address lookup strategy */ -public record ConnectionKey(String scheme, +record ConnectionKey(String scheme, String host, int port, Tls tls, diff --git a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java index 7c41ece8541..f1d72ddb2bf 100644 --- a/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java +++ b/nima/webclient/webclient/src/main/java/io/helidon/nima/webclient/http1/Http1ClientConnection.java @@ -39,7 +39,6 @@ import io.helidon.common.socket.SocketOptions; import io.helidon.common.socket.TlsSocket; import io.helidon.nima.webclient.ClientConnection; -import io.helidon.nima.webclient.ConnectionKey; import io.helidon.nima.webclient.spi.DnsResolver; import static java.lang.System.Logger.Level.DEBUG; diff --git a/nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/UriHelperTest.java b/nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/UriHelperTest.java index b16295ed985..8d91470d5d3 100644 --- a/nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/UriHelperTest.java +++ b/nima/webclient/webclient/src/test/java/io/helidon/nima/webclient/UriHelperTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Oracle and/or its affiliates. + * Copyright (c) 2022, 2023 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. @@ -34,10 +34,22 @@ void testDefaults() { assertThat(helper.authority(), is("localhost")); assertThat(helper.host(), is("localhost")); assertThat(helper.path(), is("")); - assertThat(helper.port(), is(-1)); + assertThat(helper.port(), is(80)); assertThat(helper.scheme(), is("http")); } + @Test + void testDefaultsHttps() { + UriQueryWriteable query = UriQueryWriteable.create(); + UriHelper helper = UriHelper.create(URI.create("https://localhost"), query); + + assertThat(helper.authority(), is("localhost")); + assertThat(helper.host(), is("localhost")); + assertThat(helper.path(), is("")); + assertThat(helper.port(), is(443)); + assertThat(helper.scheme(), is("https")); + } + @Test void testNonDefaults() { UriQueryWriteable query = UriQueryWriteable.create(); diff --git a/pom.xml b/pom.xml index f9aab8d0c3b..a5a58418d7f 100644 --- a/pom.xml +++ b/pom.xml @@ -82,6 +82,7 @@ 3.1.6 3.0.0.Final 1.23 + 4.3.8 2.26.3 3.10 4.8.154 @@ -987,6 +988,11 @@ reactive-streams-tck-flow ${version.lib.reactivestreams} + + io.vertx + vertx-core + ${version.lib.vertx-core} + org.apache.maven.wagon wagon-provider-api