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 3fbb1848f3 fix: release completed graph interpreter references (#3030)
3fbb1848f3 is described below

commit 3fbb1848f32dd4acc1179fe9a188d28a063d4854
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Tue Jul 14 04:21:24 2026 +0800

    fix: release completed graph interpreter references (#3030)
    
    Motivation:
    Completed graph stages could remain reachable through interpreter logic, 
owner, handler, and connection payload references after finalization.
    
    Modification:
    Finalize completed stages with a bounded traversal, release their logic and 
connection references, retain only payloads that remain observable through 
grab(), and keep snapshots stable after owners are released. Add focused 
lifecycle and reference-release coverage.
    
    Result:
    Completed stages are released promptly without breaking cascading 
completion, initialization ordering, snapshots, or port semantics.
    
    Tests:
    - sbt "stream-tests / Test / testOnly 
org.apache.pekko.stream.impl.fusing.GraphInterpreterPortsSpec 
org.apache.pekko.stream.impl.fusing.GraphInterpreterSpec 
org.apache.pekko.stream.impl.GraphStageLogicSpec 
org.apache.pekko.stream.impl.fusing.LifecycleInterpreterSpec" (106 passed)
    - scalafmt --list --mode diff-ref=origin/main (passed)
    - sbt +headerCheckAll and checkCodeStyle (passed)
    - Qoder stdout review (No must-fix findings)
    - git diff --check origin/main..HEAD (passed)
    - +mimaReportBinaryIssues interrupted during final sbt reset after Scala 
2.13/3.3 sub-runs completed; full CI requested
    - validatePullRequest not run - user requested remote CI
    
    References:
    Refs #3030
---
 .../pekko/stream/impl/GraphStageLogicSpec.scala    |  33 ++++-
 .../stream/impl/fusing/GraphInterpreterSpec.scala  | 113 ++++++++++++++
 .../impl/fusing/LifecycleInterpreterSpec.scala     |  25 +++-
 .../stream/impl/fusing/GraphInterpreter.scala      | 162 ++++++++++++++++++---
 4 files changed, 306 insertions(+), 27 deletions(-)

diff --git 
a/stream-tests/src/test/scala/org/apache/pekko/stream/impl/GraphStageLogicSpec.scala
 
b/stream-tests/src/test/scala/org/apache/pekko/stream/impl/GraphStageLogicSpec.scala
index 28b7b9a524..82c04bcc0b 100644
--- 
a/stream-tests/src/test/scala/org/apache/pekko/stream/impl/GraphStageLogicSpec.scala
+++ 
b/stream-tests/src/test/scala/org/apache/pekko/stream/impl/GraphStageLogicSpec.scala
@@ -260,17 +260,42 @@ class GraphStageLogicSpec extends StreamSpec with 
GraphInterpreterSpecKit with S
 
       // note: a bit dangerous assumptions about connection and logic 
positions here
       // if anything around creating the logics and connections in the builder 
changes this may fail
+      // Save references before execute() since afterStageHasRun releases 
completed logics
+      val gLogic = interpreter.logics(1)
+      val passThroughLogic = interpreter.logics(2)
       interpreter.complete(interpreter.connections(0))
       interpreter.cancel(interpreter.connections(1), 
SubscriptionWithCancelException.NoMoreElementsNeeded)
-      interpreter.execute(2)
+      interpreter.execute(1)
 
       expectMsg("postStop2")
+      interpreter.isSuspended should ===(true)
+      interpreter.isStageCompleted(gLogic) should ===(true)
+      interpreter.isStageCompleted(passThroughLogic) should ===(false)
+      interpreter.logics(gLogic.stageId) should be(null)
+      interpreter.logics(passThroughLogic.stageId) shouldBe 
theSameInstanceAs(passThroughLogic)
+      interpreter.activeStage should be(null)
+
+      val upstreamConnection = interpreter.connections(0)
+      upstreamConnection.inOwner should be(null)
+      upstreamConnection.inHandler should be(null)
+
+      val partiallyReleasedConnection = interpreter.connections(1)
+      partiallyReleasedConnection.outOwner should be(null)
+      partiallyReleasedConnection.outHandler should be(null)
+      partiallyReleasedConnection.inOwner shouldBe 
theSameInstanceAs(passThroughLogic)
+      partiallyReleasedConnection.inHandler should not be null
+
+      val snapshot = interpreter.toSnapshot
+      val partiallyReleasedConnectionSnapshot = snapshot.connections(1)
+      partiallyReleasedConnectionSnapshot.out should 
===(snapshot.logics(gLogic.stageId))
+      partiallyReleasedConnectionSnapshot.in should 
===(snapshot.logics(passThroughLogic.stageId))
+      partiallyReleasedConnectionSnapshot.out.label should ===("<completed>")
+      partiallyReleasedConnectionSnapshot.in.label should not be "<completed>"
+
+      interpreter.execute(1)
       expectNoMessage(Duration.Zero)
-
       interpreter.isCompleted should ===(false)
       interpreter.isSuspended should ===(false)
-      interpreter.isStageCompleted(interpreter.logics(1)) should ===(true)
-      interpreter.isStageCompleted(interpreter.logics(2)) should ===(false)
     }
 
     "not allow push from constructor" in {
diff --git 
a/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreterSpec.scala
 
b/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreterSpec.scala
index 10bcb91d40..5e58a44213 100644
--- 
a/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreterSpec.scala
+++ 
b/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreterSpec.scala
@@ -315,6 +315,119 @@ class GraphInterpreterSpec extends StreamSpec with 
GraphInterpreterSpecKit {
       interpreter.isSuspended should be(false)
     }
 
+    "release all stage references when finishing an active interpreter" in new 
TestSetup {
+      val source = new UpstreamProbe[Int]("source")
+      val sink = new DownstreamProbe[Int]("sink")
+      val identityStage = GraphStages.identity[Int]
+
+      builder(identityStage)
+        .connect(source, identityStage.in)
+        .connect(identityStage.out, sink)
+        .init()
+
+      lastEvents() should ===(Set.empty[TestEvent])
+
+      sink.requestOne()
+      lastEvents() should ===(Set(RequestOne(source)))
+
+      // Leave an element queued so finish() also has to release connection 
payloads
+      source.onNext(1, eventLimit = 0)
+      interpreter.isSuspended should be(true)
+      lastEvents() should ===(Set.empty[TestEvent])
+      interpreter.activeStage should not be null
+      interpreter.connections.filter(_ ne null).exists(_.slot != 
GraphInterpreter.Empty) should be(true)
+
+      val logics = interpreter.logics
+
+      // All logics should be non-null initially
+      logics.foreach(logic => logic should not be null)
+
+      // Abort the still-running interpreter and release all stage references
+      interpreter.finish()
+
+      // After finish(), all stage logics should have been released (set to 
null)
+      logics.foreach(logic => logic should be(null))
+      interpreter.activeStage should be(null)
+
+      // Connection-level references should also be nulled to fully break 
Connection -> GraphStageLogic chains
+      interpreter.connections.filter(_ ne null).foreach { conn =>
+        conn.inOwner should be(null)
+        conn.outOwner should be(null)
+        conn.inHandler should be(null)
+        conn.outHandler should be(null)
+        conn.slot should be(GraphInterpreter.Empty)
+      }
+
+      val snapshot = interpreter.toSnapshot
+      snapshot.logics.map(_.label).toSet should ===(Set("<completed>"))
+      snapshot.connections should have size interpreter.connections.count(_ ne 
null)
+      snapshot.connections.foreach { connection =>
+        connection.in.label should ===("<completed>")
+        connection.out.label should ===("<completed>")
+      }
+    }
+
+    "snapshot connections whose stage ids cannot be resolved" in new TestSetup 
{
+      val source = new UpstreamProbe[Int]("source")
+      val sink = new DownstreamProbe[Int]("sink")
+      val identityStage = GraphStages.identity[Int]
+
+      builder(identityStage)
+        .connect(source, identityStage.in)
+        .connect(identityStage.out, sink)
+        .init()
+
+      interpreter.connections.head.id = -1
+
+      val connectionSnapshot = interpreter.toSnapshot.connections.head
+      connectionSnapshot.in.label should ===("<completed>")
+      connectionSnapshot.out.label should ===("<completed>")
+    }
+
+    "release references after cascading stage completion" in new TestSetup {
+      val source = new UpstreamProbe[Int]("source")
+      val detachStage = detacher[Int]
+      val identityStage = GraphStages.identity[Int]
+      val sink = new DownstreamProbe[Int]("sink")
+
+      builder(detachStage, identityStage)
+        .connect(source, detachStage.shape.in)
+        .connect(detachStage.shape.out, identityStage.in)
+        .connect(identityStage.out, sink)
+        .init()
+
+      lastEvents() should ===(Set.empty[TestEvent])
+
+      sink.requestOne()
+      lastEvents() should ===(Set(RequestOne(source)))
+
+      val logics = interpreter.logics
+
+      // All logics should be non-null initially
+      logics.foreach(logic => logic should not be null)
+
+      source.onNext(1)
+      lastEvents() should ===(Set(OnNext(sink, 1), RequestOne(source)))
+
+      // Complete the source - this triggers completion of detacher and 
identity stages.
+      // afterStageHasRun releases logics during normal stage completion (not 
just in finish()).
+      source.onComplete()
+      lastEvents() should ===(Set(OnComplete(sink)))
+      interpreter.isCompleted should be(true)
+
+      // After normal stage completion via afterStageHasRun, all stage logics 
should be released
+      logics.foreach(logic => logic should be(null))
+
+      // Connection-level references should also be nulled when both sides are 
finalized
+      interpreter.connections.filter(_ ne null).foreach { conn =>
+        conn.inOwner should be(null)
+        conn.outOwner should be(null)
+        conn.inHandler should be(null)
+        conn.outHandler should be(null)
+        conn.slot should be(GraphInterpreter.Empty)
+      }
+    }
+
   }
 
 }
diff --git 
a/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/LifecycleInterpreterSpec.scala
 
b/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/LifecycleInterpreterSpec.scala
index 6419fdc90c..4cf3d3cd16 100644
--- 
a/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/LifecycleInterpreterSpec.scala
+++ 
b/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/LifecycleInterpreterSpec.scala
@@ -16,7 +16,7 @@ package org.apache.pekko.stream.impl.fusing
 import scala.concurrent.duration._
 
 import org.apache.pekko
-import pekko.stream.Attributes
+import pekko.stream.{ Attributes, ClosedShape }
 import pekko.stream.impl.fusing.GraphStages.SimpleLinearGraphStage
 import pekko.stream.stage._
 import pekko.stream.testkit.StreamSpec
@@ -90,6 +90,19 @@ class LifecycleInterpreterSpec extends StreamSpec with 
GraphInterpreterSpecKit {
       expectNoMessage(300.millis)
     }
 
+    "call preStart before postStop for zero-port stages" in new TestSetup {
+      builder(
+        ZeroPortStage(onStart = () => testActor ! "start-a", onStop = () => 
testActor ! "stop-a"),
+        ZeroPortStage(onStart = () => testActor ! "start-b", onStop = () => 
testActor ! "stop-b")).init()
+
+      expectMsg("start-a")
+      expectMsg("stop-a")
+      expectMsg("start-b")
+      expectMsg("stop-b")
+      expectNoMessage(300.millis)
+      interpreter.isCompleted should be(true)
+    }
+
     "onError when preStart fails" in new 
OneBoundedSetup[String](PreStartFailer(() => throw boom)) {
       lastEvents() should ===(Set(Cancel(boom), OnError(boom)))
     }
@@ -188,6 +201,16 @@ class LifecycleInterpreterSpec extends StreamSpec with 
GraphInterpreterSpecKit {
     override def toString = "PreStartAndPostStopIdentity"
   }
 
+  private[pekko] case class ZeroPortStage(onStart: () => Unit, onStop: () => 
Unit) extends GraphStage[ClosedShape] {
+    override val shape: ClosedShape = ClosedShape
+
+    override def createLogic(attributes: Attributes): GraphStageLogic =
+      new GraphStageLogic(shape) {
+        override def preStart(): Unit = onStart()
+        override def postStop(): Unit = onStop()
+      }
+  }
+
   private[pekko] case class PreStartFailer[T](pleaseThrow: () => Unit) extends 
SimpleLinearGraphStage[T] {
 
     override def createLogic(attributes: Attributes): GraphStageLogic =
diff --git 
a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
 
b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
index 7c7db1bca0..10f06936c9 100644
--- 
a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
+++ 
b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/GraphInterpreter.scala
@@ -244,6 +244,28 @@ import pekko.stream.stage._
   // Marks whether a stage has been finalized (finalizeStage been called) or 
not
   private val finalizedMark = Array.fill(logics.length)(false)
 
+  // Number of stages whose preStart has been invoked. Zero-port stages are 
completed from construction, so
+  // finalization scans during init must not include later stages that have 
not been started yet.
+  private var initializedStages = 0
+
+  // Connection owners are released when their stages complete, but snapshots 
still need stable endpoint identities.
+  private val connectionInStageIds = connectionStageIds(isInput = true)
+  private val connectionOutStageIds = connectionStageIds(isInput = false)
+
+  private def connectionStageIds(isInput: Boolean): Array[Int] = {
+    val stageIds = new Array[Int](connections.length)
+    var i = 0
+    while (i < connections.length) {
+      val connection = connections(i)
+      if (connection ne null) {
+        val owner = if (isInput) connection.inOwner else connection.outOwner
+        stageIds(i) = if (owner ne null) owner.stageId else -1
+      } else stageIds(i) = -1
+      i += 1
+    }
+    stageIds
+  }
+
   private var _subFusingMaterializer: Materializer = _
   private lazy val defaultErrorReportingLogLevel = 
LogLevels.defaultErrorLevel(materializer.system)
 
@@ -330,6 +352,7 @@ import pekko.stream.stage._
           log.error(e, "Error during preStart in [{}]: {}", logic.toString, 
e.getMessage)
           logic.failStage(e)
       }
+      initializedStages = i + 1
       afterStageHasRun(logic)
       i += 1
     }
@@ -342,25 +365,61 @@ import pekko.stream.stage._
     var i = 0
     while (i < logics.length) {
       val logic = logics(i)
-      if (!isStageCompleted(logic) && !isStageFinalized(logic)) {
-        markStageFinalized(logic)
-        finalizeStage(logic)
+      if (logic ne null) {
+        if (!isStageFinalized(logic)) {
+          markStageFinalized(logic)
+          finalizeStage(logic)
+        }
+        releaseStage(logic)
       }
       i += 1
     }
+    activeStage = null
+    pendingFinalization = false
+
+    // Release connection references and any in-flight payloads retained by 
the stopped interpreter
+    var j = 0
+    while (j < connections.length) {
+      val conn = connections(j)
+      if (conn ne null) {
+        conn.inHandler = null
+        conn.outHandler = null
+        conn.inOwner = null
+        conn.outOwner = null
+        conn.slot = Empty
+      }
+      j += 1
+    }
   }
 
   // Debug name for a connections input part
-  private def inOwnerName(connection: Connection): String = 
connection.inOwner.toString
+  private def inOwnerName(connection: Connection): String =
+    if (connection.inOwner ne null) connection.inOwner.toString else 
"<completed>"
 
   // Debug name for a connections output part
-  private def outOwnerName(connection: Connection): String = 
connection.outOwner.toString
+  private def outOwnerName(connection: Connection): String =
+    if (connection.outOwner ne null) connection.outOwner.toString else 
"<completed>"
+
+  private def stageIdFor(stageIds: Array[Int], connection: Connection): Int = {
+    val connectionId = connection.id
+    if (connectionId >= 0 && connectionId < stageIds.length) 
stageIds(connectionId) else -1
+  }
+
+  private def connectionInStageId(connection: Connection): Int = 
stageIdFor(connectionInStageIds, connection)
+
+  private def connectionOutStageId(connection: Connection): Int = 
stageIdFor(connectionOutStageIds, connection)
+
+  private def logicName(stageId: Int): String =
+    if (stageId >= 0 && stageId < logics.length) {
+      val logic = logics(stageId)
+      if (logic ne null) logic.toString else "<completed>"
+    } else "<completed>"
 
   // Debug name for a connections input part
-  private def inLogicName(connection: Connection): String = 
logics(connection.inOwner.stageId).toString
+  private def inLogicName(connection: Connection): String = 
logicName(connectionInStageId(connection))
 
   // Debug name for a connections output part
-  private def outLogicName(connection: Connection): String = 
logics(connection.outOwner.stageId).toString
+  private def outLogicName(connection: Connection): String = 
logicName(connectionOutStageId(connection))
 
   private def shutdownCounters: String =
     shutdownCounter.map(x => if (x >= KeepGoingFlag) s"${x & 
KeepGoingMask}(KeepGoing)" else x.toString).mkString(",")
@@ -435,7 +494,6 @@ import pekko.stream.stage._
           case NonFatal(e) => reportStageError(e)
         }
         if (pendingFinalization) {
-          pendingFinalization = false
           afterStageHasRun(activeStage)
         }
 
@@ -471,7 +529,6 @@ import pekko.stream.stage._
             case NonFatal(e) => reportStageError(e)
           }
           if (pendingFinalization) {
-            pendingFinalization = false
             afterStageHasRun(activeStage)
           }
         }
@@ -485,7 +542,6 @@ import pekko.stream.stage._
             case NonFatal(e) => reportStageError(e)
           }
           if (pendingFinalization) {
-            pendingFinalization = false
             afterStageHasRun(activeStage)
           }
         }
@@ -630,12 +686,67 @@ import pekko.stream.stage._
     queueTail += 1
   }
 
-  def afterStageHasRun(logic: GraphStageLogic): Unit =
-    if (isStageCompleted(logic) && !isStageFinalized(logic)) {
-      markStageFinalized(logic)
-      runningStages -= 1
-      finalizeStage(logic)
+  def afterStageHasRun(logic: GraphStageLogic): Unit = {
+    if ((logic ne null) && isStageCompleted(logic) && !isStageFinalized(logic))
+      pendingFinalization = true
+
+    while (pendingFinalization) {
+      pendingFinalization = false
+      var stageId = 0
+      while (stageId < initializedStages) {
+        val completedLogic = logics(stageId)
+        if ((completedLogic ne null) && isStageCompleted(completedLogic) && 
!isStageFinalized(completedLogic)) {
+          markStageFinalized(completedLogic)
+          runningStages -= 1
+          finalizeStage(completedLogic)
+          releaseStage(completedLogic)
+        }
+        stageId += 1
+      }
     }
+  }
+
+  private def hasAvailableElement(connection: Connection): Boolean = {
+    val state = connection.portState
+    val normalArrived = (state & (InReady | InFailed | InClosed)) == InReady
+
+    if (normalArrived) connection.slot.asInstanceOf[AnyRef] ne Empty
+    else if ((state & (InReady | InClosed | InFailed)) == (InReady | InClosed))
+      connection.slot match {
+        case Empty | _: Cancelled => false
+        case _                    => true
+      }
+    else if ((state & (InReady | InFailed)) == (InReady | InFailed))
+      connection.slot match {
+        case Failed(_, elem) => elem.asInstanceOf[AnyRef] ne Empty
+        case _               => false
+      }
+    else false
+  }
+
+  private def releaseStage(logic: GraphStageLogic): Unit = {
+    var i = 0
+    while (i < logic.portToConn.length) {
+      val connection = logic.portToConn(i)
+      if (connection ne null) {
+        if (connection.inOwner eq logic) {
+          connection.inOwner = null
+          connection.inHandler = null
+        }
+        if (connection.outOwner eq logic) {
+          connection.outOwner = null
+          connection.outHandler = null
+        }
+        // Completion and failure may follow an onPush before the element is 
grabbed. Keep that element available
+        // even after both stages have stopped, while still dropping payloads 
that can no longer be observed.
+        if ((connection.inOwner eq null) && (connection.outOwner eq null) && 
!hasAvailableElement(connection))
+          connection.slot = Empty
+      }
+      i += 1
+    }
+    if (activeStage eq logic) activeStage = null
+    logics(logic.stageId) = null
+  }
 
   // Returns true if the given stage is already completed
   def isStageCompleted(stage: GraphStageLogic): Boolean = (stage ne null) && 
shutdownCounter(stage.stageId) == 0
@@ -701,7 +812,8 @@ import pekko.stream.stage._
       enqueue(connection)
     } else if ((currentState & (InClosed | Pushing | Pulling | OutClosed)) == 
0) enqueue(connection)
 
-    if ((currentState & OutClosed) == 0) 
completeConnection(connection.outOwner.stageId)
+    if ((currentState & OutClosed) == 0 && (connection.outOwner ne null))
+      completeConnection(connection.outOwner.stageId)
   }
 
   @InternalStableApi
@@ -720,7 +832,8 @@ import pekko.stream.stage._
         enqueue(connection)
       }
     }
-    if ((currentState & OutClosed) == 0) 
completeConnection(connection.outOwner.stageId)
+    if ((currentState & OutClosed) == 0 && (connection.outOwner ne null))
+      completeConnection(connection.outOwner.stageId)
   }
 
   @InternalStableApi
@@ -738,7 +851,7 @@ import pekko.stream.stage._
         enqueue(connection)
       }
     }
-    if ((currentState & InClosed) == 0) 
completeConnection(connection.inOwner.stageId)
+    if ((currentState & InClosed) == 0 && (connection.inOwner ne null)) 
completeConnection(connection.inOwner.stageId)
   }
 
   /**
@@ -751,15 +864,20 @@ import pekko.stream.stage._
   def toSnapshot: RunningInterpreter = {
 
     val logicSnapshots = logics.zipWithIndex.map {
-      case (logic, idx) =>
+      case (logic, idx) if logic ne null =>
         LogicSnapshotImpl(idx, logic.toString, logic.attributes)
+      case (_, idx) =>
+        LogicSnapshotImpl(idx, "<completed>", Attributes.none)
     }
-    val logicIndexes = logics.zipWithIndex.map { case (stage, idx) => stage -> 
idx }.toMap
+    def safeLogicSnapshot(stageId: Int): LogicSnapshotImpl =
+      if (stageId >= 0 && stageId < logicSnapshots.length) 
logicSnapshots(stageId)
+      else LogicSnapshotImpl(stageId, "<completed>", Attributes.none)
+
     val connectionSnapshots = connections.filter(_ ne null).map { connection =>
       ConnectionSnapshotImpl(
         connection.id,
-        logicSnapshots(logicIndexes(connection.inOwner)),
-        logicSnapshots(logicIndexes(connection.outOwner)),
+        safeLogicSnapshot(connectionInStageId(connection)),
+        safeLogicSnapshot(connectionOutStageId(connection)),
         connection.portState match {
           case InReady | Pushing                                           => 
ConnectionSnapshot.ShouldPull
           case OutReady | Pulling                                          => 
ConnectionSnapshot.ShouldPush


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

Reply via email to