diff --git a/changes.md b/changes.md index 753c374a9c..30ee1c7b3f 100644 --- a/changes.md +++ b/changes.md @@ -68,6 +68,8 @@ Changes log `Response.toString()` now shows it. - Added request and response trailers to the HTTP connectors, supporting HTTP/1.1 chunked transfers and HTTP/2 trailing headers. + - HTTP/2 clients receive a response as soon as its headers arrive, while the request entity + continues to be written on a virtual thread. - Bugs fixed - Slf4j bridge reports wrong line number and caller class in log4j. Issue #233. - When GET is an allowed method of a server resource, HEAD is now also listed (e.g. in the Allow header of a 405 response). Issue #266. diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java index 2a9abc787c..c26afc6cca 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyClientCall.java @@ -31,7 +31,9 @@ import java.io.PushbackInputStream; import java.net.ConnectException; import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.ReentrantLock; import java.util.logging.Level; @@ -51,13 +53,15 @@ /** * HTTP client exchange based on Netty, mapping a Restlet request and response to Netty messages. - * The calling thread blocks while the request is written and until the response head is received; - * the response body is then streamed from the connection. + * The calling thread waits until the response head is received; on HTTP/2, the request body is + * written concurrently. The response body is then streamed from the connection. * *
This class is internal to the Netty connectors.
*/
class NettyClientCall {
+ static final String ASYNC_REQUEST_BODY_ATTRIBUTE = "org.restlet.engine.netty.asyncRequestBody";
+
/** The parent helper. */
private final NettyClientHelper helper;
@@ -86,6 +90,8 @@ class NettyClientCall {
private final Condition headReceived = lock.newCondition();
+ private final Condition requestSentCondition = lock.newCondition();
+
/** The kind of channel carrying the exchange, set by the helper when it is acquired. */
private volatile StreamKind kind = StreamKind.HTTP1;
@@ -116,6 +122,14 @@ class NettyClientCall {
/** The failure before the response head, if any. */
private Throwable failure;
+ /** The failure while writing the request body, if any. */
+ private Throwable requestFailure;
+
+ /** Signals whether the background writer or the redirect handler releases the entity. */
+ private volatile CompletableFuture
+ * The content of this file is subject to the terms of the Apache 2.0 open
+ * source license available at https://www.opensource.org/licenses/apache-2.0
+ *
+ * Restlet is a registered trademark of QlikTech International AB.
+ */
+package org.restlet.engine.connector;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.restlet.Client;
+import org.restlet.Context;
+import org.restlet.Request;
+import org.restlet.Response;
+import org.restlet.Restlet;
+import org.restlet.Server;
+import org.restlet.data.MediaType;
+import org.restlet.data.Method;
+import org.restlet.data.Protocol;
+import org.restlet.engine.Engine;
+import org.restlet.representation.OutputRepresentation;
+
+class Http2FullDuplexTestCase {
+
+ private Server server;
+
+ private Client client;
+
+ @BeforeEach
+ void setUp() {
+ Engine.clearThreadLocalVariables();
+ Engine.register(false);
+ Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null));
+ Engine.getInstance().getRegisteredClients().add(new HttpClientHelper(null));
+ }
+
+ @AfterEach
+ void tearDown() throws Exception {
+ if (client != null) {
+ client.stop();
+ }
+ if (server != null) {
+ server.stop();
+ }
+ Engine.clearThreadLocalVariables();
+ }
+
+ @Test
+ void receivesResponseWhileRequestEntityIsStillBeingWritten() throws Exception {
+ CountDownLatch requestWriteStarted = new CountDownLatch(1);
+ CountDownLatch continueRequestWrite = new CountDownLatch(1);
+ CountDownLatch requestWriteFinished = new CountDownLatch(1);
+
+ Restlet earlyResponse =
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ response.setEntity("early response", MediaType.TEXT_PLAIN);
+ }
+ };
+ Context serverContext = new Context();
+ serverContext.getParameters().add("http.transport.protocol", "HTTP2");
+ server = new Server(serverContext, Protocol.HTTP, 0, earlyResponse);
+ server.start();
+
+ Context clientContext = new Context();
+ clientContext.getParameters().add("httpClientTransportMode", "HTTP2");
+ client = new Client(clientContext, Protocol.HTTP);
+ client.start();
+
+ OutputRepresentation entity =
+ new OutputRepresentation(MediaType.TEXT_PLAIN) {
+ @Override
+ public void write(OutputStream output) throws IOException {
+ try {
+ output.write("first".getBytes(StandardCharsets.UTF_8));
+ output.flush();
+ requestWriteStarted.countDown();
+ if (!continueRequestWrite.await(10, TimeUnit.SECONDS)) {
+ throw new IOException("Timed out waiting to continue the request");
+ }
+ output.write("second".getBytes(StandardCharsets.UTF_8));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted while writing the request", e);
+ } finally {
+ requestWriteFinished.countDown();
+ }
+ }
+ };
+ Request request =
+ new Request(
+ Method.POST, "http://localhost:" + server.getActualPort() + "/", entity);
+
+ try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
+ var responseFuture = executor.submit(() -> client.handle(request));
+ try {
+ assertTrue(requestWriteStarted.await(5, TimeUnit.SECONDS));
+ Response response = responseFuture.get(5, TimeUnit.SECONDS);
+ assertEquals("early response", response.getEntityAsText());
+ assertFalse(
+ requestWriteFinished.await(100, TimeUnit.MILLISECONDS),
+ "The response must arrive before the request entity is finished");
+ } finally {
+ continueRequestWrite.countDown();
+ }
+ assertTrue(requestWriteFinished.await(5, TimeUnit.SECONDS));
+ }
+ }
+}