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

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


The following commit(s) were added to refs/heads/main by this push:
     new c532ea043b fix: delay unfoldResourceAsync close until pending read 
completes (#3157)
c532ea043b is described below

commit c532ea043b9d16ef365b40dc63234b27fc71dc4e
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Wed Jun 24 13:01:48 2026 +0800

    fix: delay unfoldResourceAsync close until pending read completes (#3157)
    
    Motivation:
    Source.unfoldResourceAsync could call close(resource) from postStop while a 
readData(resource) Future was still running, allowing close and read to operate 
on the same resource concurrently.
    
    Modification:
    Track the current read Future with a null-sentinel field and, on postStop, 
defer close(resource) until that Future completes. Clear the field on 
synchronous and asynchronous read completion to keep the hot path simple and 
avoid retaining completed reads. Add a regression test that cancels while a 
read is pending and asserts close is not called until the read completes.
    
    Result:
    Cancellation no longer closes the resource concurrently with an in-flight 
readData call, while existing unfoldResourceAsync completion, restart, resume, 
and close behavior remains covered.
    
    Tests:
    - scalafmt --mode diff-ref=origin/main: passed (JDK 25 Unsafe deprecation 
warning only)
    - scalafmt --list --mode diff-ref=origin/main: passed (JDK 25 Unsafe 
deprecation warning only)
    - sbt "stream-tests / Test / testOnly 
org.apache.pekko.stream.scaladsl.UnfoldResourceAsyncSourceSpec -- -z \"wait for 
pending read before closing resource when stream is cancelled\"": passed
    - same focused test against old postStop behavior: failed as expected with 
close observing readCompleted=false
    - sbt "stream-tests / Test / testOnly 
org.apache.pekko.stream.scaladsl.UnfoldResourceAsyncSourceSpec": passed, 21 
tests
    - sbt checkCodeStyle: passed
    - sbt +headerCheckAll: passed
    - git diff --check: passed
    - sbt validatePullRequest: Not run - focused stream test, style, and header 
validation run locally
    
    References:
    Fixes #3134
---
 .../scaladsl/UnfoldResourceAsyncSourceSpec.scala   | 34 ++++++++++++++++++++++
 .../stream/impl/UnfoldResourceSourceAsync.scala    | 21 +++++++++----
 2 files changed, 50 insertions(+), 5 deletions(-)

diff --git 
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/UnfoldResourceAsyncSourceSpec.scala
 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/UnfoldResourceAsyncSourceSpec.scala
index 07a42be8d8..829762e0ac 100644
--- 
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/UnfoldResourceAsyncSourceSpec.scala
+++ 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/UnfoldResourceAsyncSourceSpec.scala
@@ -129,6 +129,40 @@ class UnfoldResourceAsyncSourceSpec extends 
StreamSpec(UnboundedMailboxConfig) {
       resource.closed.futureValue
     }
 
+    "wait for pending read before closing resource when stream is cancelled" 
in {
+      val readStarted = Promise[Done]()
+      val readCompleted = Promise[Option[Int]]()
+      val closeProbe = TestProbe()
+      val out = TestSubscriber.manualProbe[Int]()
+
+      val terminated = Source
+        .unfoldResourceAsync[Int, Unit](
+          () => Future.successful(()),
+          _ => {
+            readStarted.success(Done)
+            readCompleted.future
+          },
+          _ => {
+            closeProbe.ref ! readCompleted.isCompleted
+            Future.successful(Done)
+          })
+        .watchTermination(Keep.right)
+        .toMat(Sink.fromSubscriber(out))(Keep.left)
+        .run()
+
+      val subscription = out.expectSubscription()
+      subscription.request(1)
+      readStarted.future.futureValue
+
+      subscription.cancel()
+      terminated.futureValue
+      closeProbe.expectNoMessage(200.millis)
+
+      readCompleted.success(Some(1))
+      closeProbe.expectMsg(true)
+      closeProbe.expectNoMessage(200.millis)
+    }
+
     "fail when create throws exception" in {
       val probe = TestSubscriber.probe[Unit]()
       Source
diff --git 
a/stream/src/main/scala/org/apache/pekko/stream/impl/UnfoldResourceSourceAsync.scala
 
b/stream/src/main/scala/org/apache/pekko/stream/impl/UnfoldResourceSourceAsync.scala
index b0a74bfcb7..fff8b10a58 100644
--- 
a/stream/src/main/scala/org/apache/pekko/stream/impl/UnfoldResourceSourceAsync.scala
+++ 
b/stream/src/main/scala/org/apache/pekko/stream/impl/UnfoldResourceSourceAsync.scala
@@ -46,6 +46,7 @@ import pekko.util.OptionVal
       private lazy val decider = 
inheritedAttributes.mandatoryAttribute[SupervisionStrategy].decider
       private implicit def ec: ExecutionContext = materializer.executionContext
       private var maybeResource: OptionVal[R] = OptionVal.none
+      private var inFlightRead: Future[Option[T]] = _
 
       private val createdCallback = getAsyncCallback[Try[R]] {
         case Success(resource) =>
@@ -70,7 +71,10 @@ import pekko.util.OptionVal
           }
       }
 
-      private val readCallback = 
getAsyncCallback[Try[Option[T]]](handle).invoke _
+      private val readCallback = getAsyncCallback[Try[Option[T]]] { result =>
+        inFlightRead = null
+        handle(result)
+      }.invoke _
 
       private def handle(result: Try[Option[T]]): Unit = result match {
         case Success(data) =>
@@ -100,9 +104,12 @@ import pekko.util.OptionVal
         case OptionVal.Some(resource) =>
           try {
             val future = readData(resource)
+            inFlightRead = future
             future.value match {
-              case Some(value) => handle(value)
-              case None        => future.onComplete(readCallback)(parasitic)
+              case Some(value) =>
+                inFlightRead = null
+                handle(value)
+              case None => future.onComplete(readCallback)(parasitic)
             }
           } catch errorHandler
 
@@ -113,8 +120,12 @@ import pekko.util.OptionVal
       }
 
       override def postStop(): Unit = maybeResource match {
-        case OptionVal.Some(resource) => close(resource)
-        case _                        => // do nothing
+        case OptionVal.Some(resource) =>
+          val pendingRead = inFlightRead
+          inFlightRead = null
+          if (pendingRead ne null) pendingRead.onComplete(_ => 
close(resource))(parasitic)
+          else close(resource)
+        case _ => // do nothing
       }
 
       private def restartResource(): Unit = maybeResource match {


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

Reply via email to