This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/main by this push:
     new aa9e3f1a784 SOLR-17707: HttpJdkSolrClient fix thread leak when async 
request fails (#4639)
aa9e3f1a784 is described below

commit aa9e3f1a7841e765d3806951e2d6f0460f6a057d
Author: Serhiy Bzhezytskyy <[email protected]>
AuthorDate: Thu Jul 16 21:28:05 2026 +0300

    SOLR-17707: HttpJdkSolrClient fix thread leak when async request fails 
(#4639)
    
    Fixed HttpJdkSolrClient leaking an executor thread when an async request's 
connection failed while its body was still being written; enough such failures 
could exhaust the client's thread pool.
---
 .../SOLR-17707-httpjdk-async-thread-leak.yml       |  11 ++
 .../solr/client/solrj/impl/HttpJdkSolrClient.java  |  37 +++++-
 .../client/solrj/impl/HttpJdkSolrClientTest.java   | 135 +++++++++++++++++++++
 3 files changed, 177 insertions(+), 6 deletions(-)

diff --git a/changelog/unreleased/SOLR-17707-httpjdk-async-thread-leak.yml 
b/changelog/unreleased/SOLR-17707-httpjdk-async-thread-leak.yml
new file mode 100644
index 00000000000..f015a99b5f3
--- /dev/null
+++ b/changelog/unreleased/SOLR-17707-httpjdk-async-thread-leak.yml
@@ -0,0 +1,11 @@
+# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
+
+title: >
+  Fixed HttpJdkSolrClient leaking an executor thread when an async request's 
connection failed while
+  its body was still being written; enough such failures could exhaust the 
client's thread pool.
+type: fixed
+authors:
+  - name: Serhiy Bzhezytskyy
+links:
+  - name: SOLR-17707
+    url: https://issues.apache.org/jira/browse/SOLR-17707
diff --git 
a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpJdkSolrClient.java 
b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpJdkSolrClient.java
index 1f88adc519f..111bfd1bc92 100644
--- 
a/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpJdkSolrClient.java
+++ 
b/solr/solrj/src/java/org/apache/solr/client/solrj/impl/HttpJdkSolrClient.java
@@ -144,8 +144,10 @@ public class HttpJdkSolrClient extends HttpSolrClient {
   protected CompletableFuture<HttpResponse<InputStream>> 
requestInputStreamAsync(
       String baseUrl, final SolrRequest<?> solrRequest, String collection) {
     try {
-      HttpRequest httpRequest = prepareRequest(baseUrl, solrRequest, 
collection).reqb.build();
-      return httpClient.sendAsync(httpRequest, 
HttpResponse.BodyHandlers.ofInputStream());
+      PreparedRequest pReq = prepareRequest(baseUrl, solrRequest, collection);
+      return httpClient
+          .sendAsync(pReq.reqb.build(), 
HttpResponse.BodyHandlers.ofInputStream())
+          .whenComplete((httpResponse, throwable) -> 
releaseContentWriting(pReq));
     } catch (Exception e) {
       CompletableFuture<HttpResponse<InputStream>> cf = new 
CompletableFuture<>();
       cf.completeExceptionally(e);
@@ -160,6 +162,7 @@ public class HttpJdkSolrClient extends HttpSolrClient {
       PreparedRequest pReq = prepareRequest(null, solrRequest, collection);
       return httpClient
           .sendAsync(pReq.reqb.build(), 
HttpResponse.BodyHandlers.ofInputStream())
+          .whenComplete((httpResponse, throwable) -> 
releaseContentWriting(pReq))
           .thenApply(
               httpResponse -> {
                 try {
@@ -176,6 +179,21 @@ public class HttpJdkSolrClient extends HttpSolrClient {
     }
   }
 
+  private void releaseContentWriting(PreparedRequest pReq) {
+    if (pReq.contentWritingFuture != null) {
+      pReq.contentWritingFuture.cancel(true);
+    }
+    // Closing the sink is what unblocks a writer already stuck in the pipe; 
cancel() alone does
+    // not.
+    if (pReq.contentWritingSink != null) {
+      try {
+        pReq.contentWritingSink.close();
+      } catch (IOException e) {
+        log.warn("Could not close content-writing pipe", e);
+      }
+    }
+  }
+
   @Override
   public NamedList<Object> requestWithBaseUrl(
       String baseUrl, SolrRequest<?> solrRequest, String collection)
@@ -271,7 +289,7 @@ public class HttpJdkSolrClient extends HttpSolrClient {
     reqb.GET();
     decorateRequest(reqb, solrRequest);
     reqb.uri(new URI(url + queryParams.toQueryString()));
-    return new PreparedRequest(reqb, null);
+    return new PreparedRequest(reqb, null, null);
   }
 
   private PreparedRequest preparePutOrPost(
@@ -303,6 +321,7 @@ public class HttpJdkSolrClient extends HttpSolrClient {
 
     HttpRequest.BodyPublisher bodyPublisher;
     Future<?> contentWritingFuture = null;
+    PipedInputStream contentWritingSink = null;
     if (contentWriter != null) {
       boolean success = maybeTryHeadRequest(url);
       if (!success) {
@@ -310,7 +329,8 @@ public class HttpJdkSolrClient extends HttpSolrClient {
       }
 
       final PipedOutputStream source = new PipedOutputStream();
-      final PipedInputStream sink = new PipedInputStream(source);
+      contentWritingSink = new PipedInputStream(source);
+      final PipedInputStream sink = contentWritingSink;
       bodyPublisher = HttpRequest.BodyPublishers.ofInputStream(() -> sink);
 
       contentWritingFuture =
@@ -352,20 +372,25 @@ public class HttpJdkSolrClient extends HttpSolrClient {
     URI uriWithQueryParams = new URI(url + queryParams.toQueryString());
     reqb.uri(uriWithQueryParams);
 
-    return new PreparedRequest(reqb, contentWritingFuture);
+    return new PreparedRequest(reqb, contentWritingFuture, contentWritingSink);
   }
 
   protected static class PreparedRequest {
     Future<?> contentWritingFuture;
+    PipedInputStream contentWritingSink;
     HttpRequest.Builder reqb;
 
     ResponseParser parserToUse;
 
     String url;
 
-    PreparedRequest(HttpRequest.Builder reqb, Future<?> contentWritingFuture) {
+    PreparedRequest(
+        HttpRequest.Builder reqb,
+        Future<?> contentWritingFuture,
+        PipedInputStream contentWritingSink) {
       this.reqb = reqb;
       this.contentWritingFuture = contentWritingFuture;
+      this.contentWritingSink = contentWritingSink;
     }
   }
 
diff --git 
a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpJdkSolrClientTest.java
 
b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpJdkSolrClientTest.java
index 73ff00474bb..79bfdb63e9c 100644
--- 
a/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpJdkSolrClientTest.java
+++ 
b/solr/solrj/src/test/org/apache/solr/client/solrj/impl/HttpJdkSolrClientTest.java
@@ -20,15 +20,23 @@ package org.apache.solr.client.solrj.impl;
 import java.io.IOException;
 import java.net.CookieHandler;
 import java.net.CookieManager;
+import java.net.ServerSocket;
+import java.net.Socket;
 import java.net.URI;
 import java.net.URISyntaxException;
 import java.net.http.HttpClient;
+import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
 import java.util.stream.Collectors;
 import org.apache.lucene.util.NamedThreadFactory;
 import org.apache.solr.client.api.util.SolrVersion;
@@ -38,6 +46,7 @@ import org.apache.solr.client.solrj.SolrServerException;
 import org.apache.solr.client.solrj.request.JavaBinRequestWriter;
 import org.apache.solr.client.solrj.request.QueryRequest;
 import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.request.UpdateRequest;
 import org.apache.solr.client.solrj.request.XMLRequestWriter;
 import org.apache.solr.client.solrj.response.JavaBinResponseParser;
 import org.apache.solr.client.solrj.response.ResponseParser;
@@ -132,6 +141,132 @@ public class HttpJdkSolrClientTest extends 
HttpSolrClientTestBase {
     }
   }
 
+  /**
+   * A large content-writing request whose connection drops while the body is 
still being written
+   * must not leave the body-writing thread blocked forever in {@code
+   * PipedInputStream.awaitSpace()}. The server accepts the connection but 
never reads the body, so
+   * the pipe buffer fills and the writer blocks; when the connection is then 
reset the writer must
+   * be released (SOLR-17707).
+   */
+  @Test
+  public void testStuckContentWritingThreadIsReleasedOnFailure() throws 
Exception {
+    // A body far larger than the PipedInputStream buffer and the socket 
buffers, so the writer is
+    // still blocked in awaitSpace() when the connection drops.
+    StringBuilder big = new StringBuilder();
+    while (big.length() < 8 * 1024 * 1024) {
+      big.append("id_").append(big.length()).append(' ');
+    }
+    UpdateRequest req = new UpdateRequest();
+    req.deleteByQuery(big.toString());
+
+    ExecutorService executor =
+        ExecutorUtil.newMDCAwareCachedThreadPool(new 
NamedThreadFactory("solr-17707-writer"));
+    try (StallThenResetServer server = new StallThenResetServer();
+        HttpJdkSolrClient client =
+            
builder(server.baseUrl()).useHttp1_1(true).withExecutor(executor).build()) {
+
+      CompletableFuture<?> cf = client.requestAsync(req, null);
+      server.awaitConnected(30, TimeUnit.SECONDS);
+      Thread.sleep(1000); // let the writer fill the pipe and block in 
awaitSpace()
+      server.resetAll(); // drop the connection under the in-flight write
+
+      // The request settles (normally exceptionally, from the dropped 
connection); either way the
+      // point of the test is the thread check below, so just wait for it to 
complete.
+      cf.handle((r, t) -> null).get(30, TimeUnit.SECONDS);
+
+      // The writer thread must not remain blocked in the pipe after the 
request has failed.
+      assertTrue(
+          "content-writing thread leaked, still blocked after failure",
+          waitForNoBlockedWriter(15, TimeUnit.SECONDS));
+    } finally {
+      ExecutorUtil.shutdownAndAwaitTermination(executor);
+    }
+  }
+
+  private static boolean waitForNoBlockedWriter(long timeout, TimeUnit unit)
+      throws InterruptedException {
+    long deadline = System.nanoTime() + unit.toNanos(timeout);
+    while (System.nanoTime() < deadline) {
+      if (!hasBlockedWriterThread()) {
+        return true;
+      }
+      Thread.sleep(100);
+    }
+    return !hasBlockedWriterThread();
+  }
+
+  private static boolean hasBlockedWriterThread() {
+    Thread[] threads = new Thread[Thread.activeCount() * 2];
+    int n = Thread.enumerate(threads);
+    for (int i = 0; i < n; i++) {
+      Thread t = threads[i];
+      if (t != null && t.getName().startsWith("solr-17707-writer")) {
+        for (StackTraceElement el : t.getStackTrace()) {
+          if ("awaitSpace".equals(el.getMethodName())) {
+            return true;
+          }
+        }
+      }
+    }
+    return false;
+  }
+
+  /** A TCP server that accepts a connection, never reads the body, then 
resets on demand. */
+  private static class StallThenResetServer implements AutoCloseable {
+    private final ServerSocket serverSocket;
+    private final List<Socket> accepted = Collections.synchronizedList(new 
ArrayList<>());
+    private final CountDownLatch connected = new CountDownLatch(1);
+    private final AtomicBoolean closed = new AtomicBoolean(false);
+
+    StallThenResetServer() throws IOException {
+      this.serverSocket = new ServerSocket(0);
+      Thread acceptThread =
+          new Thread(
+              () -> {
+                while (!serverSocket.isClosed()) {
+                  try {
+                    Socket s = serverSocket.accept();
+                    s.setSoLinger(true, 0); // close() sends RST rather than 
FIN
+                    accepted.add(s);
+                    connected.countDown();
+                    // never read the body: the client's send buffer and the 
pipe fill up
+                  } catch (IOException ignored) {
+                    return;
+                  }
+                }
+              },
+              "solr-17707-stall-server");
+      acceptThread.setDaemon(true);
+      acceptThread.start();
+    }
+
+    String baseUrl() {
+      return "http://127.0.0.1:"; + serverSocket.getLocalPort() + "/solr";
+    }
+
+    void awaitConnected(long timeout, TimeUnit unit) throws 
InterruptedException {
+      connected.await(timeout, unit);
+    }
+
+    void resetAll() {
+      for (Socket s : accepted) {
+        try {
+          s.close();
+        } catch (IOException ignored) {
+          // ignore
+        }
+      }
+    }
+
+    @Override
+    public void close() throws IOException {
+      if (closed.compareAndSet(false, true)) {
+        resetAll();
+        serverSocket.close();
+      }
+    }
+  }
+
   @Override
   protected void testQuerySetup(SolrRequest.METHOD method, ResponseParser rp) 
throws Exception {
     DebugServlet.clear();

Reply via email to