You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@hc.apache.org by rs...@apache.org on 2026/10/16 16:56:57 UTC
(httpcomponents-client) branch master updated: Fix spurious TCP resets when initiating TLS connection closure
This is an automated email from the ASF dual-hosted git repository.
rschmitt pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/httpcomponents-client.git
The following commit(s) were added to refs/heads/master by this push:
new e413d1d55 Fix spurious TCP resets when initiating TLS connection closure
e413d1d55 is described below
commit e413d1d55c8b101dc877ce0e71e32dcf8d6bc71b
Author: Ryan Schmitt <rs...@apache.org>
AuthorDate: Tue Oct 13 13:26:08 2026 -0700
Fix spurious TCP resets when initiating TLS connection closure
When an HTTP/1.1 response contains `Connection: close`, the async client
begins a graceful TLS shutdown. This causes `SSLIOSession.isOpen()` to
immediately start returning `false`, so at the completion of the HTTP
exchange when `AsyncProtocolExec` calls `isEndpointConnected()`, the
`InternalConnectionEndpoint.isConnected()` method sees that the pool
entry's underlying connection is reporting `!isOpen()` and calls
`poolEntry.discardConnection(CloseMode.IMMEDIATE)`, which sends a TCP
reset to the server.
This commit changes that `discardConnection` call site to use
`CloseMode.GRACEFUL`, and also provides a reproducer and integration
test demonstrating the problem.
---
.../testing/async/TestAsyncTlsConnectionClose.java | 238 +++++++++++++++++++++
.../nio/PoolingAsyncClientConnectionManager.java | 2 +-
2 files changed, 239 insertions(+), 1 deletion(-)
diff --git a/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncTlsConnectionClose.java b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncTlsConnectionClose.java
new file mode 100644
index 000000000..50f6dd050
--- /dev/null
+++ b/httpclient5-testing/src/test/java/org/apache/hc/client5/testing/async/TestAsyncTlsConnectionClose.java
@@ -0,0 +1,238 @@
+/*
+ * ====================================================================
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you 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.
+ * ====================================================================
+ *
+ * This software consists of voluntary contributions made by many
+ * individuals on behalf of the Apache Software Foundation. For more
+ * information on the Apache Software Foundation, please see
+ * <http://www.apache.org/>.
+ *
+ */
+package org.apache.hc.client5.testing.async;
+
+import java.io.Closeable;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.ServerSocket;
+import java.net.Socket;
+import java.nio.charset.StandardCharsets;
+import java.util.Locale;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Stream;
+
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLSocket;
+
+import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
+import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
+import org.apache.hc.client5.http.config.TlsConfig;
+import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
+import org.apache.hc.client5.http.impl.async.HttpAsyncClients;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManager;
+import org.apache.hc.client5.http.impl.nio.PoolingAsyncClientConnectionManagerBuilder;
+import org.apache.hc.client5.http.ssl.DefaultClientTlsStrategy;
+import org.apache.hc.client5.testing.SSLTestContexts;
+import org.apache.hc.core5.http.HttpHost;
+import org.apache.hc.core5.http.ssl.TLS;
+import org.apache.hc.core5.http2.HttpVersionPolicy;
+import org.apache.hc.core5.util.Timeout;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class TestAsyncTlsConnectionClose {
+
+ private static final Timeout TIMEOUT = Timeout.ofSeconds(5);
+
+ enum CloseInitiator {
+ // The origin sends Connection: close; HttpClient initiates TLS shutdown.
+ CLIENT,
+ // The origin sends close_notify after a reusable HTTP response.
+ SERVER;
+
+ @Override
+ public String toString() {
+ return name().toLowerCase(Locale.ROOT);
+ }
+ }
+
+ static Stream<Arguments> tlsCloseScenarios() {
+ return Stream.of(TLS.V_1_2, TLS.V_1_3)
+ .flatMap(tlsVersion -> Stream.of(CloseInitiator.CLIENT, CloseInitiator.SERVER)
+ .map(closeInitiator -> Arguments.of(tlsVersion, closeInitiator)));
+ }
+
+ @ParameterizedTest(name = "{0}, {1}-initiated TLS close")
+ @MethodSource("tlsCloseScenarios")
+ void testConnectionCloseCompletesTlsShutdownWithoutReset(
+ final TLS tlsVersion,
+ final CloseInitiator closeInitiator) throws Exception {
+ try (TlsConnectionCloseServer server = new TlsConnectionCloseServer(tlsVersion, closeInitiator);
+ PoolingAsyncClientConnectionManager connectionManager = PoolingAsyncClientConnectionManagerBuilder.create()
+ .setTlsStrategy(new DefaultClientTlsStrategy(SSLTestContexts.createClientSSLContext()))
+ .setDefaultTlsConfig(TlsConfig.custom()
+ .setSupportedProtocols(tlsVersion)
+ .setVersionPolicy(HttpVersionPolicy.FORCE_HTTP_1)
+ .build())
+ .build();
+ CloseableHttpAsyncClient client = HttpAsyncClients.custom()
+ .setConnectionManager(connectionManager)
+ .build()) {
+ server.start();
+ client.start();
+
+ final HttpHost target = new HttpHost(
+ "https", InetAddress.getByName("127.0.0.1"), "localhost", server.getPort());
+ final Future<SimpleHttpResponse> responseFuture = client.execute(
+ SimpleRequestBuilder.get()
+ .setHttpHost(target)
+ .setPath("/")
+ .build(),
+ null);
+
+ Assertions.assertEquals(200, responseFuture.get(
+ TIMEOUT.getDuration(), TIMEOUT.getTimeUnit()).getCode());
+ Assertions.assertTrue(server.awaitCleanClose(),
+ () -> "the client reset the TCP connection during the "
+ + tlsVersion.id + " " + closeInitiator
+ + "-initiated TLS close_notify exchange");
+ }
+ }
+
+ private static final class TlsConnectionCloseServer implements Closeable {
+
+ private final TLS tlsVersion;
+ private final CloseInitiator closeInitiator;
+ private final ServerSocket serverSocket;
+ private final CompletableFuture<Boolean> cleanCloseFuture;
+ private Thread worker;
+
+ TlsConnectionCloseServer(final TLS tlsVersion, final CloseInitiator closeInitiator) throws IOException {
+ this.tlsVersion = tlsVersion;
+ this.closeInitiator = closeInitiator;
+ this.serverSocket = new ServerSocket();
+ this.serverSocket.bind(new InetSocketAddress("127.0.0.1", 0));
+ this.cleanCloseFuture = new CompletableFuture<>();
+ }
+
+ void start() {
+ worker = new Thread(this::run, "tls-connection-close-server");
+ worker.setDaemon(true);
+ worker.start();
+ }
+
+ int getPort() {
+ return serverSocket.getLocalPort();
+ }
+
+ boolean awaitCleanClose() throws Exception {
+ return cleanCloseFuture.get(TIMEOUT.getDuration(), TIMEOUT.getTimeUnit());
+ }
+
+ private void run() {
+ try (Socket socket = serverSocket.accept()) {
+ socket.setSoTimeout(TIMEOUT.toMillisecondsIntBound());
+ final SSLContext sslContext = SSLTestContexts.createServerSSLContext();
+ try (SSLSocket sslSocket = (SSLSocket) sslContext.getSocketFactory().createSocket(
+ socket, "localhost", socket.getPort(), false)) {
+ sslSocket.setUseClientMode(false);
+ sslSocket.setEnabledProtocols(new String[] { tlsVersion.id });
+ sslSocket.startHandshake();
+ if (!tlsVersion.id.equals(sslSocket.getSession().getProtocol())) {
+ throw new IOException("Unexpected negotiated TLS protocol "
+ + sslSocket.getSession().getProtocol());
+ }
+
+ readRequestHead(sslSocket.getInputStream());
+ final OutputStream outputStream = sslSocket.getOutputStream();
+ outputStream.write(("HTTP/1.1 200 OK\r\n"
+ + "Content-Length: 0\r\n"
+ + (closeInitiator == CloseInitiator.CLIENT ? "Connection: close\r\n" : "")
+ + "\r\n").getBytes(StandardCharsets.US_ASCII));
+ outputStream.flush();
+
+ if (closeInitiator == CloseInitiator.CLIENT) {
+ // Connection: close causes the client to initiate TLS
+ // shutdown. Wait for its close_notify before returning ours.
+ if (sslSocket.getInputStream().read() != -1) {
+ throw new IOException("Unexpected application data after the request");
+ }
+ }
+ // Otherwise closing this SSLSocket sends the server's
+ // close_notify first.
+ }
+
+ // The layered SSLSocket does not own the TCP socket. Check the
+ // transport after TLS shutdown to distinguish FIN from RST.
+ cleanCloseFuture.complete(socket.getInputStream().read() == -1);
+ } catch (final IOException ex) {
+ if (isConnectionReset(ex)) {
+ cleanCloseFuture.complete(false);
+ } else {
+ cleanCloseFuture.completeExceptionally(ex);
+ }
+ } catch (final Exception ex) {
+ cleanCloseFuture.completeExceptionally(ex);
+ }
+ }
+
+ private static boolean isConnectionReset(final Throwable cause) {
+ for (Throwable current = cause; current != null; current = current.getCause()) {
+ if (current.getMessage() != null
+ && current.getMessage().toLowerCase(Locale.ROOT).contains("connection reset")) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static void readRequestHead(final InputStream inputStream) throws IOException {
+ int matched = 0;
+ while (matched < 4) {
+ final int b = inputStream.read();
+ if (b == -1) {
+ throw new IOException("Connection closed before the request head was complete");
+ }
+ if (b == "\r\n\r\n".charAt(matched)) {
+ matched++;
+ } else {
+ matched = b == '\r' ? 1 : 0;
+ }
+ }
+ }
+
+ @Override
+ public void close() throws IOException {
+ serverSocket.close();
+ if (worker != null) {
+ try {
+ worker.join(TimeUnit.SECONDS.toMillis(1));
+ } catch (final InterruptedException ex) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ }
+ }
+}
diff --git a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
index 712bdd8a1..c5215a415 100644
--- a/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
+++ b/httpclient5/src/main/java/org/apache/hc/client5/http/impl/nio/PoolingAsyncClientConnectionManager.java
@@ -804,7 +804,7 @@ public boolean isConnected() {
return false;
}
if (!connection.isOpen()) {
- poolEntry.discardConnection(CloseMode.IMMEDIATE);
+ poolEntry.discardConnection(CloseMode.GRACEFUL);
return false;
}
return true;