diff --git a/changes.md b/changes.md index b11842b2dd..3f022cfe03 100644 --- a/changes.md +++ b/changes.md @@ -232,6 +232,11 @@ Changes log - The gRPC client could reject a call with `RESOURCE_EXHAUSTED` ("gRPC call limit exceeded") right after the end of the previous one: the call slot was released after the caller was notified. Issue #1622. + - The HTTP/1.1 server connector no longer closes a kept-alive connection without notice when + the application answers without reading the request body (for example an early 401, 403 + or 406). The unread body is discarded when it is already received, or when at most + 256 KiB of it remain in transit; otherwise the response announces `Connection: close`. + Clients reusing the connection failed with a "Communication Error" (1001). - Misc - Fixed JDK HTTP client accessibility in core and WebSocket tests in Eclipse and VS Code, without adding a production module dependency. diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/ChannelInputStream.java b/org.restlet/src/main/java/org/restlet/engine/netty/ChannelInputStream.java index 5323a4f659..b28906f0f5 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/ChannelInputStream.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/ChannelInputStream.java @@ -58,6 +58,12 @@ final class ChannelInputStream extends InputStream { private boolean readPaused; + /** The total number of content bytes offered, consumed or not. */ + private long receivedBytes; + + /** Whether content offered after closing is discarded without pausing channel reads. */ + private boolean discarding; + /** * Constructor. * @@ -79,9 +85,10 @@ public ChannelInputStream(Channel channel, long readTimeoutMs) { public boolean offer(ByteBuf buf) { lock.lock(); try { + receivedBytes += buf.readableBytes(); if (closed || endOfStream || !buf.isReadable()) { buf.release(); - return !closed; + return !closed || discarding; } buffers.addLast(buf); bufferedBytes += buf.readableBytes(); @@ -128,17 +135,46 @@ public void fail(Throwable cause) { } /** - * Indicates whether the whole content has been received and consumed. + * Indicates whether the whole content has been received, consumed or not. * - * @return True if the whole content has been received and consumed. + * @return True if the whole content has been received. + */ + public boolean isFullyReceived() { + lock.lock(); + try { + return endOfStream; + } finally { + lock.unlock(); + } + } + + /** + * Returns the number of content bytes received so far, consumed or not. + * + * @return The number of content bytes received so far. + */ + public long getReceivedBytes() { + lock.lock(); + try { + return receivedBytes; + } finally { + lock.unlock(); + } + } + + /** + * Closes the stream and discards the rest of the content: buffered content is released, and + * content offered later is released as it arrives, without pausing channel reads. The caller is + * responsible for bounding the content still expected. */ - public boolean isFullyConsumed() { + public void discard() { lock.lock(); try { - return endOfStream && buffers.isEmpty(); + discarding = true; } finally { lock.unlock(); } + close(); } @Override diff --git a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java index c8834fbb5c..717164abde 100644 --- a/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java +++ b/org.restlet/src/main/java/org/restlet/engine/netty/NettyServerCall.java @@ -80,6 +80,12 @@ */ class NettyServerCall { + /** + * Maximum number of unread HTTP/1.1 request body bytes still in transit that a response can + * discard while keeping the connection alive. + */ + static final long MAX_DISCARDED_BYTES = ChannelInputStream.HIGH_WATER_MARK; + /** The parent helper. */ private final NettyServerHelper helper; @@ -125,6 +131,9 @@ class NettyServerCall { /** Indicates whether the response head was written. */ private volatile boolean responseHeadWritten; + /** Indicates whether the HTTP/1.1 response head announced a persistent connection. */ + private volatile boolean keepAliveAnnounced; + /** Indicates that an empty multiplexed response ended with its initial headers. */ private boolean responseEndedWithHead; @@ -699,9 +708,33 @@ private void setHttp1Framing( headers.set(HttpHeaderNames.CONTENT_LENGTH, 0); } } - if (!HttpUtil.isKeepAlive(request)) { + if (!HttpUtil.isKeepAlive(request) || !isRequestBodyDiscardable()) { headers.set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); } + keepAliveAnnounced = HttpUtil.isKeepAlive(head); + } + + /** + * Indicates whether the rest of the HTTP/1.1 request body, if left unread by the application, + * can be discarded without closing the connection. It can when it was fully received, or when + * its declared length leaves at most {@link #MAX_DISCARDED_BYTES} bytes to receive. A chunked + * body still in transit has an unknown length, so the connection must then be closed. + * + *
This is decided when the response head is written, so that a connection which can't be + * reused is announced with a "Connection: close" header instead of being closed without notice + * after the response. + * + * @return True if the rest of the request body can be discarded. + */ + private boolean isRequestBodyDiscardable() { + if (requestBody.isFullyReceived()) { + return true; + } + if (HttpUtil.isTransferEncodingChunked(request)) { + return false; + } + long length = HttpUtil.getContentLength(request, -1L); + return length >= 0 && length - requestBody.getReceivedBytes() <= MAX_DISCARDED_BYTES; } /** Completes the exchange: ends the response and frees the connection for the next request. */ @@ -734,7 +767,9 @@ void complete() { return; } - boolean keepAlive = HttpUtil.isKeepAlive(request) && requestBody.isFullyConsumed(); + // The response head announced whether the connection persists. If so, the unread + // request body is discarded: it is either already received or bounded. + boolean keepAlive = !kind.isStream() && keepAliveAnnounced; LastHttpContent lastContent = LastHttpContent.EMPTY_LAST_CONTENT; if (responseTrailersEnabled) { DefaultLastHttpContent withTrailers = @@ -743,7 +778,11 @@ void complete() { lastContent = withTrailers; } ChannelFuture future = trackDelivery(channel.writeAndFlush(lastContent)); - requestBody.close(); + if (keepAlive) { + requestBody.discard(); + } else { + requestBody.close(); + } if (!kind.isStream()) { completeHttp1(future, keepAlive); diff --git a/org.restlet/src/test/java/org/restlet/engine/netty/Http1UnreadBodyKeepAliveTestCase.java b/org.restlet/src/test/java/org/restlet/engine/netty/Http1UnreadBodyKeepAliveTestCase.java new file mode 100644 index 0000000000..711fc7e907 --- /dev/null +++ b/org.restlet/src/test/java/org/restlet/engine/netty/Http1UnreadBodyKeepAliveTestCase.java @@ -0,0 +1,293 @@ +/** + * Copyright 2005-2026 Qlik + *
+ * 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.netty;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.netty.buffer.Unpooled;
+import io.netty.channel.embedded.EmbeddedChannel;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.Socket;
+import java.net.SocketTimeoutException;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.Map;
+import java.util.TreeMap;
+import java.util.concurrent.CountDownLatch;
+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.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.data.Status;
+import org.restlet.engine.Engine;
+import org.restlet.engine.connector.HttpServerHelper;
+
+/**
+ * Checks that an HTTP/1.1 response sent without reading the request body either keeps the
+ * connection usable for the next request, or announces that it closes it.
+ */
+class Http1UnreadBodyKeepAliveTestCase {
+
+ private Server server;
+
+ /** Counted down when the server Restlet handles a request. */
+ private volatile CountDownLatch handled;
+
+ @BeforeEach
+ void startServer() throws Exception {
+ Engine.register(false);
+ Engine.getInstance().getRegisteredServers().add(new HttpServerHelper(null));
+ handled = new CountDownLatch(1);
+ server =
+ new Server(
+ Protocol.HTTP,
+ 0,
+ new Restlet() {
+ @Override
+ public void handle(Request request, Response response) {
+ String path = request.getResourceRef().getPath();
+ if ("/echo".equals(path)) {
+ // Streams the request body back from the response entity.
+ response.setEntity(request.getEntity());
+ } else if (Method.POST.equals(request.getMethod())) {
+ // Rejects the request without reading its body.
+ response.setStatus(Status.CLIENT_ERROR_FORBIDDEN);
+ response.setEntity("forbidden", MediaType.TEXT_PLAIN);
+ } else {
+ response.setEntity("ok", MediaType.TEXT_PLAIN);
+ }
+ handled.countDown();
+ }
+ });
+ server.start();
+ }
+
+ @AfterEach
+ void stopServer() throws Exception {
+ server.stop();
+ }
+
+ @Test
+ void discardsAReceivedUnreadBodyAndKeepsTheConnection() throws Exception {
+ try (Socket socket = connect()) {
+ send(socket, post("/reject", "Content-Length: 5") + "hello");
+ HttpMessage rejected = HttpMessage.read(socket.getInputStream());
+ assertEquals(403, rejected.status);
+ assertNull(rejected.headers.get("connection"));
+
+ send(socket, get("/next"));
+ HttpMessage next = HttpMessage.read(socket.getInputStream());
+ assertEquals(200, next.status);
+ assertEquals("ok", next.body);
+ }
+ }
+
+ @Test
+ void discardsASmallUnreadBodyStillInTransitAndKeepsTheConnection() throws Exception {
+ try (Socket socket = connect()) {
+ send(socket, post("/reject", "Content-Length: 10") + "hello");
+ HttpMessage rejected = HttpMessage.read(socket.getInputStream());
+ assertEquals(403, rejected.status);
+ assertNull(rejected.headers.get("connection"));
+
+ // The rest of the body arrives after the response, followed by the next request.
+ send(socket, "world" + get("/next"));
+ HttpMessage next = HttpMessage.read(socket.getInputStream());
+ assertEquals(200, next.status);
+ assertEquals("ok", next.body);
+ }
+ }
+
+ @Test
+ void announcesCloseForALargeUnreadBodyStillInTransit() throws Exception {
+ try (Socket socket = connect()) {
+ send(
+ socket,
+ post("/reject", "Content-Length: " + (NettyServerCall.MAX_DISCARDED_BYTES + 6))
+ + "hello");
+ HttpMessage rejected = HttpMessage.read(socket.getInputStream());
+ assertEquals(403, rejected.status);
+ assertEquals("close", rejected.headers.get("connection"));
+ assertClosed(socket);
+ }
+ }
+
+ @Test
+ void announcesCloseForAnUnreadChunkedBodyStillInTransit() throws Exception {
+ try (Socket socket = connect()) {
+ send(socket, post("/reject", "Transfer-Encoding: chunked") + "5\r\nhello\r\n");
+ HttpMessage rejected = HttpMessage.read(socket.getInputStream());
+ assertEquals(403, rejected.status);
+ assertEquals("close", rejected.headers.get("connection"));
+ assertClosed(socket);
+ }
+ }
+
+ @Test
+ void discardsAReceivedUnreadChunkedBodyAndKeepsTheConnection() throws Exception {
+ try (Socket socket = connect()) {
+ send(socket, post("/reject", "Transfer-Encoding: chunked") + "5\r\nhello\r\n0\r\n\r\n");
+ HttpMessage rejected = HttpMessage.read(socket.getInputStream());
+ assertEquals(403, rejected.status);
+
+ if (rejected.headers.get("connection") == null) {
+ send(socket, get("/next"));
+ HttpMessage next = HttpMessage.read(socket.getInputStream());
+ assertEquals(200, next.status);
+ } else {
+ // The last chunk may arrive after the response head was written.
+ assertEquals("close", rejected.headers.get("connection"));
+ assertClosed(socket);
+ }
+ }
+ }
+
+ @Test
+ void keepsTheConnectionOfAStreamingEchoReadingTheBodyAfterTheHead() throws Exception {
+ try (Socket socket = connect()) {
+ send(socket, post("/echo", "Content-Length: 10") + "hello");
+ assertTrue(handled.await(5, TimeUnit.SECONDS));
+ Thread.sleep(100);
+ send(socket, "world");
+ HttpMessage echoed = HttpMessage.read(socket.getInputStream());
+ assertEquals(200, echoed.status);
+ assertEquals("helloworld", echoed.body);
+ assertNull(echoed.headers.get("connection"));
+
+ send(socket, get("/next"));
+ HttpMessage next = HttpMessage.read(socket.getInputStream());
+ assertEquals(200, next.status);
+ assertEquals("ok", next.body);
+ }
+ }
+
+ @Test
+ void discardedStreamReleasesLaterContentWithoutPausingReads() {
+ var channel = new EmbeddedChannel();
+ try {
+ var body = new ChannelInputStream(channel, 0);
+ var buffered = Unpooled.buffer(3).writeBytes(new byte[] {1, 2, 3});
+ assertTrue(body.offer(buffered));
+ assertFalse(body.isFullyReceived());
+ body.discard();
+ assertEquals(0, buffered.refCnt());
+
+ var late = Unpooled.buffer(2).writeBytes(new byte[] {4, 5});
+ assertTrue(body.offer(late), "a discarded stream keeps reading the content");
+ assertEquals(0, late.refCnt());
+ assertEquals(5, body.getReceivedBytes());
+ body.end();
+ assertTrue(body.isFullyReceived());
+
+ var closed = new ChannelInputStream(channel, 0);
+ closed.close();
+ var ignored = Unpooled.buffer(1).writeByte(1);
+ assertFalse(closed.offer(ignored), "a closed stream stops reading the content");
+ assertEquals(0, ignored.refCnt());
+ } finally {
+ channel.finishAndReleaseAll();
+ }
+ }
+
+ private Socket connect() throws IOException {
+ Socket socket = new Socket("localhost", server.getActualPort());
+ socket.setSoTimeout(5000);
+ return socket;
+ }
+
+ private static String post(String path, String framing) {
+ return "POST "
+ + path
+ + " HTTP/1.1\r\nHost: localhost\r\nContent-Type: text/plain\r\n"
+ + framing
+ + "\r\n\r\n";
+ }
+
+ private static String get(String path) {
+ return "GET " + path + " HTTP/1.1\r\nHost: localhost\r\n\r\n";
+ }
+
+ private static void send(Socket socket, String data) throws IOException {
+ OutputStream out = socket.getOutputStream();
+ out.write(data.getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+ }
+
+ /** Checks that the server closes the connection, without sending anything more. */
+ private static void assertClosed(Socket socket) throws IOException {
+ try {
+ assertEquals(-1, socket.getInputStream().read());
+ } catch (SocketTimeoutException e) {
+ throw new AssertionError("The connection was not closed", e);
+ } catch (IOException _) {
+ // A connection reset also closes the connection.
+ }
+ }
+
+ /** A minimal HTTP/1.1 response parser. */
+ private record HttpMessage(int status, Map