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

pjfanning pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko-http.git


The following commit(s) were added to refs/heads/main by this push:
     new 20fb7386e fix: fail requests still queued in a pool interface that 
stops (#1282)
20fb7386e is described below

commit 20fb7386e12a988e916b78df29ee2699377a44b2
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Sep 11 09:41:47 2026 +0100

    fix: fail requests still queued in a pool interface that stops (#1282)
    
    Motivation:
    `PoolInterface.Logic` keeps requests that the pool cannot take yet in an
    `ArrayDeque` of `RequestContext`s, each holding the promise that the
    caller of `Http().singleRequest` (or of a cached host connection pool
    flow) is waiting on.
    
    When the stage stops without having dispatched them - the pool flow
    failing or cancelling, or the materializer shutting down - `postStop`
    only failed `shutdownPromise`. Nothing else holds those queued contexts:
    they were never handed to a connection, so no response, no error and no
    retry through the pool master can ever reach them, and the callers'
    `Future[HttpResponse]`s stay uncompleted for the lifetime of the
    process.
    
    Modification:
    Drain the buffer in `postStop` and fail every queued response promise
    with the same `IllegalStateException` that is used for the shutdown
    promise.
    
    Result:
    A pool that stops unexpectedly fails the requests it never dispatched
    instead of leaving their callers waiting forever.
    
    Tests:
    - sbt "http-core/testOnly 
org.apache.pekko.http.impl.engine.client.PoolInterfaceSpec" - pass (1 test). 
Reverting only the `postStop` change makes it fail, as the queued promises are 
never completed.
    - sbt "http-core/mimaReportBinaryIssues" - pass.
    - scalafmt --mode diff-ref=upstream/main --test - pass.
    
    References:
    None - found while auditing `src/main` for resource leaks
---
 .../http/impl/engine/client/PoolInterface.scala    |  9 ++-
 .../impl/engine/client/PoolInterfaceSpec.scala     | 66 ++++++++++++++++++++++
 2 files changed, 74 insertions(+), 1 deletion(-)

diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/PoolInterface.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/PoolInterface.scala
index 107aeab04..a787c90d7 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/PoolInterface.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/PoolInterface.scala
@@ -219,7 +219,14 @@ private[http] object PoolInterface {
       !shuttingDown && remainingRequested == 0 && idleTimeout.isFinite && 
hcps.setup.settings.minConnections == 0
 
     override def onUpstreamFailure(ex: Throwable): Unit = 
shutdownPromise.tryFailure(ex)
-    override def postStop(): Unit = shutdownPromise.tryFailure(new 
IllegalStateException("Pool shutdown unexpectedly"))
+    override def postStop(): Unit = {
+      val shutdownException = new IllegalStateException("Pool shutdown 
unexpectedly")
+      shutdownPromise.tryFailure(shutdownException)
+      // Whatever is still queued here was never dispatched to a connection, 
so nothing else is ever going to complete
+      // these promises. Fail them instead of leaving the callers waiting for 
a response forever.
+      while (!buffer.isEmpty)
+        buffer.removeFirst().responsePromise.tryFailure(shutdownException)
+    }
 
     // PoolInterface implementations
     override def request(request: HttpRequest, responsePromise: 
Promise[HttpResponse]): Unit =
diff --git 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/PoolInterfaceSpec.scala
 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/PoolInterfaceSpec.scala
new file mode 100644
index 000000000..f359cf467
--- /dev/null
+++ 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/PoolInterfaceSpec.scala
@@ -0,0 +1,66 @@
+/*
+ * 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.
+ */
+
+package org.apache.pekko.http.impl.engine.client
+
+import org.apache.pekko
+import pekko.http.impl.engine.client.PoolFlow.{ RequestContext, 
ResponseContext }
+import pekko.http.impl.settings.{ ConnectionPoolSetup, HostConnectionPoolSetup 
}
+import pekko.http.impl.util._
+import pekko.http.scaladsl.Http
+import pekko.http.scaladsl.model.{ HttpRequest, HttpResponse }
+import pekko.http.scaladsl.settings.ConnectionPoolSettings
+import pekko.stream.scaladsl.{ Flow, Sink, Source }
+import pekko.stream.testkit.{ TestPublisher, TestSubscriber }
+import pekko.testkit._
+
+import scala.concurrent.{ Await, Promise }
+import scala.concurrent.duration._
+
+class PoolInterfaceSpec extends PekkoSpecWithMaterializer {
+
+  "The pool interface" should {
+
+    "fail requests that are still queued when the pool stops" in {
+      val settings = 
ConnectionPoolSettings(system).withMaxConnections(1).withMaxOpenRequests(4)
+      // nothing is ever dispatched to this endpoint, it only identifies the 
pool
+      val poolId = new PoolId(
+        HostConnectionPoolSetup("127.0.0.1", 1, ConnectionPoolSetup(settings, 
log = system.log)),
+        PoolId.newUniquePool())
+
+      val requestsToPool = TestSubscriber.probe[RequestContext]()
+      val responsesFromPool = TestPublisher.probe[ResponseContext]()
+      val poolInterface =
+        Flow.fromGraph(
+          new PoolInterface.PoolInterfaceStage(poolId, Http().poolMaster, 
settings.maxOpenRequests, system.log))
+          .join(Flow.fromSinkAndSource(Sink.fromSubscriber(requestsToPool), 
Source.fromPublisher(responsesFromPool)))
+          .run()
+
+      // the pool never signals demand, so these requests only ever reach the 
interface's buffer
+      val queuedRequests = Vector.fill(3)(Promise[HttpResponse]())
+      queuedRequests.foreach(poolInterface.request(HttpRequest(uri = "/"), _))
+
+      // the pool flow going away takes the interface with it
+      responsesFromPool.sendComplete()
+      requestsToPool.expectSubscriptionAndComplete()
+
+      queuedRequests.foreach { responsePromise =>
+        Await.result(responsePromise.future.failed, 3.seconds.dilated) 
shouldBe an[IllegalStateException]
+      }
+    }
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to