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 9861865b3 fix: cancel the pending slot state timeout when a pool slot 
is shut down (#1283)
9861865b3 is described below

commit 9861865b37710310b1cbce45a324505e341eaf9a
Author: PJ Fanning <[email protected]>
AuthorDate: Fri Sep 11 09:43:29 2026 +0100

    fix: cancel the pending slot state timeout when a pool slot is shut down 
(#1283)
    
    Motivation:
    Every state transition in `NewHostConnectionPool`'s slot state machine
    cancels the timeout of the state it leaves and, if the new state defines
    one, schedules a fresh `materializer.scheduleOnce` task for it. Those
    tasks are not bound to the stage lifecycle, so they keep running after
    the stage has stopped.
    
    `Slot.shutdown()`, which `postStop` calls for every slot, closed the
    connection and ran `state.onShutdown` but never cancelled that timeout.
    A pool that stops while a slot is in a state with a finite timeout -
    waiting for a connection, for a response entity subscription, or for the
    keep-alive timeout - therefore leaves a scheduled task behind that keeps
    the slot, and through it the whole pool logic with its connections and
    request contexts, reachable until the timeout elapses. Firing does
    nothing useful either: the task only invokes an async callback on a
    stage that is already gone.
    
    Modification:
    Cancel the current timeout at the start of `Slot.shutdown()`, like every
    state transition and the slot error path already do.
    
    Result:
    Shutting a pool down no longer leaves scheduled state timeouts, and the
    pool becomes collectable as soon as it stops.
    
    Tests:
    - sbt "http-core/testOnly 
org.apache.pekko.http.impl.engine.client.pool.NewHostConnectionPoolSpec" - pass 
(1 test). Reverting only the `cancelCurrentTimeout()` call makes it fail, as 
the scheduled task is still live after the pool stopped.
    - sbt "http-core/mimaReportBinaryIssues" - pass.
    - scalafmt --mode diff-ref=upstream/main --test - pass.
    
    References:
    None - found while auditing `src/main` for resource leaks
---
 .../engine/client/pool/NewHostConnectionPool.scala |   3 +
 .../client/pool/NewHostConnectionPoolSpec.scala    | 110 +++++++++++++++++++++
 2 files changed, 113 insertions(+)

diff --git 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/NewHostConnectionPool.scala
 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/NewHostConnectionPool.scala
index dd7e1fcdb..d125bb244 100644
--- 
a/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/NewHostConnectionPool.scala
+++ 
b/http-core/src/main/scala/org/apache/pekko/http/impl/engine/client/pool/NewHostConnectionPool.scala
@@ -226,6 +226,9 @@ private[client] object NewHostConnectionPool {
           def isIdle: Boolean = state.isIdle
           def isConnected: Boolean = state.isConnected
           def shutdown(): Unit = {
+            // the state timeout is scheduled on the materializer, so it 
outlives this stage unless it is cancelled here
+            cancelCurrentTimeout()
+
             // if the connection is idle, we just complete it regularly, 
otherwise, we forcibly tear it down
             // with an error (which will be logged in 
OutgoingConnectionBlueprint, see `mapError` there).
             val reason =
diff --git 
a/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/pool/NewHostConnectionPoolSpec.scala
 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/pool/NewHostConnectionPoolSpec.scala
new file mode 100644
index 000000000..139ac9d68
--- /dev/null
+++ 
b/http-core/src/test/scala/org/apache/pekko/http/impl/engine/client/pool/NewHostConnectionPoolSpec.scala
@@ -0,0 +1,110 @@
+/*
+ * 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.pool
+
+import java.net.InetSocketAddress
+import java.util.concurrent.CopyOnWriteArrayList
+import java.util.concurrent.ThreadFactory
+
+import com.typesafe.config.Config
+
+import org.apache.pekko
+import pekko.actor.{ Cancellable, LightArrayRevolverScheduler }
+import pekko.event.LoggingAdapter
+import pekko.http.impl.engine.client.PoolFlow.{ RequestContext, 
ResponseContext }
+import pekko.http.impl.util._
+import pekko.http.scaladsl.Http
+import pekko.http.scaladsl.model.{ ContentTypes, HttpEntity, HttpRequest, 
HttpResponse }
+import pekko.http.scaladsl.settings.ConnectionPoolSettings
+import pekko.stream.scaladsl.{ Flow, Keep, Sink, Source }
+import pekko.stream.testkit.{ TestPublisher, TestSubscriber }
+import pekko.stream.testkit.scaladsl.{ TestSink, TestSource }
+import pekko.util.ByteString
+
+import scala.concurrent.{ ExecutionContext, Future, Promise }
+import scala.concurrent.duration._
+import scala.jdk.CollectionConverters._
+
+object NewHostConnectionPoolSpec {
+
+  /** A delay no other part of the machinery schedules with, so that slot 
state timeouts can be told apart */
+  val SlotStateTimeout = 7331.millis
+
+  val slotStateTimeouts = new CopyOnWriteArrayList[Cancellable]
+
+  /** Records the tasks scheduled for [[SlotStateTimeout]] so that a test can 
check whether they are cancelled */
+  final class TrackingScheduler(config: Config, log: LoggingAdapter, 
threadFactory: ThreadFactory)
+      extends LightArrayRevolverScheduler(config, log, threadFactory) {
+    override def scheduleOnce(delay: FiniteDuration, runnable: Runnable)(
+        implicit executor: ExecutionContext): Cancellable = {
+      val cancellable = super.scheduleOnce(delay, runnable)
+      if (delay == SlotStateTimeout) slotStateTimeouts.add(cancellable)
+      cancellable
+    }
+  }
+}
+
+class NewHostConnectionPoolSpec extends PekkoSpecWithMaterializer(
+      """
+    pekko.scheduler.implementation = 
"org.apache.pekko.http.impl.engine.client.pool.NewHostConnectionPoolSpec$TrackingScheduler"
+                                                                 """) {
+  import NewHostConnectionPoolSpec._
+
+  "The host connection pool" should {
+
+    "cancel a pending slot state timeout when the pool is shut down" in {
+      slotStateTimeouts.clear()
+
+      val settings =
+        ConnectionPoolSettings(system)
+          .withMaxConnections(1)
+          .withMinConnections(0)
+          .withResponseEntitySubscriptionTimeout(SlotStateTimeout)
+
+      val connectionRequests = TestSubscriber.probe[HttpRequest]()
+      val connectionResponses = TestPublisher.probe[HttpResponse]()
+      val connectionFlow =
+        Flow.fromSinkAndSource(Sink.fromSubscriber(connectionRequests), 
Source.fromPublisher(connectionResponses))
+          .mapMaterializedValue(_ => 
Future.successful(Http.OutgoingConnection(address, address)))
+
+      val (requestsIn, responsesOut) =
+        TestSource[RequestContext]()
+          .via(NewHostConnectionPool(connectionFlow, settings, system.log))
+          .toMat(TestSink[ResponseContext]())(Keep.both)
+          .run()
+
+      responsesOut.request(1)
+      requestsIn.sendNext(RequestContext(HttpRequest(uri = "/"), 
Promise[HttpResponse](), 0))
+
+      connectionRequests.requestNext()
+      // a response whose entity is never subscribed to, which is what puts 
the slot on a state timeout
+      connectionResponses.sendNext(
+        HttpResponse(entity = 
HttpEntity.Chunked.fromData(ContentTypes.`text/plain(UTF-8)`, 
Source.maybe[ByteString])))
+      responsesOut.expectNext()
+
+      awaitCond(slotStateTimeouts.size == 1)
+
+      // tear the pool down while the slot is still waiting for that 
subscription
+      responsesOut.cancel()
+
+      awaitCond(slotStateTimeouts.asScala.forall(_.isCancelled))
+    }
+  }
+
+  private def address = new InetSocketAddress("127.0.0.1", 1)
+}


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

Reply via email to