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 44316a57f5 fix: release reference to initial interpreter shell after
it is shut down (#3031)
44316a57f5 is described below
commit 44316a57f5c5464137de694be970c93e61db1118
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Wed Jul 8 10:37:42 2026 +0800
fix: release reference to initial interpreter shell after it is shut down
(#3031)
* fix: release reference to initial interpreter shell after it is shut down
Motivation:
ActorGraphInterpreter retains a strong reference to its initial
GraphInterpreterShell via the _initial constructor val. When the actor
outlives the initial shell (e.g., while hosting subfused interpreters
registered via registerShell), the initial shell and all its logics
cannot be garbage collected for the lifetime of the actor.
Modification:
- Convert _initial from a constructor val to a private var
- Set _initial = null at the end of preStart() after tryInit completes
- Fix debug println in tryInit to reference the shell parameter instead
of _initial (which may be null after preStart)
- Add behavioral test using flatMapConcat which triggers subfusing via
SubFusingActorMaterializerImpl, verifying the actor continues
processing subfused shells after the initial shell is released
Result:
The initial GraphInterpreterShell and its stage logics become eligible
for garbage collection once the initial shell shuts down, reducing
memory retention in long-lived ActorGraphInterpreter actors.
Tests:
- stream-tests/testOnly ActorGraphInterpreterSpec: 12/12 passed
References:
None - memory leak fix
* test: improve ActorGraphInterpreter tests to verify actor lifecycle
Motivation:
Copilot CR review identified that the original tests did not actually
verify the _initial reference release post-condition or exercise the
subfusing scenario described in the test names.
Modification:
- Add test verifying actor termination when initial shell completes
without subfused interpreters (using Source.empty +
watch/expectTerminated)
- Rewrite subfusing test with TestPublisher.probe for precise control,
asserting both stream correctness and actor termination after all
shells (initial + subfused) complete
- Use the test-specific materializer's supervisor (not SystemMaterializer)
to locate the correct ActorGraphInterpreter actor
Result:
Tests now assert the actual post-condition the PR guarantees: the
ActorGraphInterpreter actor terminates after all shells complete,
proving the _initial reference release does not affect actor lifecycle.
Tests:
- stream-tests / Test / testOnly ActorGraphInterpreterSpec: 13/13 passed
References:
Refs #1235
---
.../impl/fusing/ActorGraphInterpreterSpec.scala | 59 +++++++++++++++++++++-
.../stream/impl/fusing/ActorGraphInterpreter.scala | 7 ++-
2 files changed, 62 insertions(+), 4 deletions(-)
diff --git
a/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreterSpec.scala
b/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreterSpec.scala
index b6919b4f1e..84375f78b8 100644
---
a/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreterSpec.scala
+++
b/stream-tests/src/test/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreterSpec.scala
@@ -22,6 +22,7 @@ import scala.concurrent.duration._
import org.apache.pekko
import pekko.Done
import pekko.stream._
+import pekko.stream.impl.{ PhasedFusingActorMaterializer, StreamSupervisor }
import pekko.stream.impl.ReactiveStreamsCompliance.SpecViolation
import pekko.stream.impl.fusing.GraphStages.SimpleLinearGraphStage
import pekko.stream.scaladsl._
@@ -33,8 +34,7 @@ import pekko.stream.testkit.StreamSpec
import pekko.stream.testkit.TestPublisher
import pekko.stream.testkit.TestSubscriber
import pekko.stream.testkit.Utils._
-import pekko.testkit.EventFilter
-import pekko.testkit.TestLatch
+import pekko.testkit.{ EventFilter, TestLatch }
import org.reactivestreams.Publisher
import org.reactivestreams.Subscriber
@@ -436,5 +436,60 @@ class ActorGraphInterpreterSpec extends StreamSpec {
done.future.futureValue // would throw on failure
}
+ "terminate actor graph interpreter when initial shell completes without
subfused interpreters" in {
+ val mat =
Materializer(system).asInstanceOf[PhasedFusingActorMaterializer]
+
+ Source.empty[Int].to(Sink.ignore).run()(mat)
+
+ // Wait briefly for the stream to complete and the actor to stop
+ mat.supervisor.tell(StreamSupervisor.GetChildren, testActor)
+ val children = expectMsgType[StreamSupervisor.Children]
+ // After Source.empty completes, the actor should have stopped already
+ // (preStart calls context.stop(self) when activeInterpreters is empty)
+ // If children is empty, actor already stopped; otherwise watch and
expect termination
+ if (children.children.nonEmpty) {
+ val interpreterRef = children.children.head
+ watch(interpreterRef)
+ expectTerminated(interpreterRef, remainingOrDefault)
+ }
+ }
+
+ "continue working with subfused interpreters after initial shell reference
is released" in {
+ val mat =
Materializer(system).asInstanceOf[PhasedFusingActorMaterializer]
+
+ // flatMapConcat triggers subfusing: the inner Source is materialized via
+ // SubFusingActorMaterializerImpl, which registers additional shells into
+ // the same ActorGraphInterpreter actor via registerShell.
+ val upstream = TestPublisher.probe[Int]()
+ val downstream = TestSubscriber.probe[Int]()
+
+ Source
+ .fromPublisher(upstream)
+ .flatMapConcat(i => Source.single(i * 10))
+ .to(Sink.fromSubscriber(downstream))
+ .run()(mat)
+
+ mat.supervisor.tell(StreamSupervisor.GetChildren, testActor)
+ val children = expectMsgType[StreamSupervisor.Children]
+ val interpreterRef = children.children.head
+ watch(interpreterRef)
+
+ // Process elements through subfused inner sources
+ downstream.request(3)
+ upstream.sendNext(1)
+ downstream.expectNext(10)
+ upstream.sendNext(2)
+ downstream.expectNext(20)
+ upstream.sendNext(3)
+ downstream.expectNext(30)
+
+ // Complete the upstream: initial shell completes, subfused shells finish
+ upstream.sendComplete()
+ downstream.expectComplete()
+
+ // Actor should terminate: all shells (initial + subfused) have completed
+ expectTerminated(interpreterRef, remainingOrDefault)
+ }
+
}
}
diff --git
a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreter.scala
b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreter.scala
index 53dc14b6ca..8110807300 100644
---
a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreter.scala
+++
b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/ActorGraphInterpreter.scala
@@ -894,7 +894,7 @@ import org.reactivestreams.Subscription
/**
* INTERNAL API
*/
-@InternalApi private[pekko] final class ActorGraphInterpreter(_initial:
GraphInterpreterShell)
+@InternalApi private[pekko] final class ActorGraphInterpreter(private var
_initial: GraphInterpreterShell)
extends Actor
with ActorLogging {
import ActorGraphInterpreter._
@@ -907,7 +907,7 @@ import org.reactivestreams.Subscription
try {
currentLimit = shell.init(self, subFusingMaterializerImpl,
enqueueToShortCircuit, currentLimit)
if (GraphInterpreter.Debug)
- println(s"registering new shell in ${_initial}\n
${shell.toString.replace("\n", "\n ")}")
+ println(s"registering new shell in ${shell}\n
${shell.toString.replace("\n", "\n ")}")
if (shell.isTerminated) false
else {
activeInterpreters += shell
@@ -955,6 +955,9 @@ import org.reactivestreams.Subscription
override def preStart(): Unit = {
tryInit(_initial)
+ // Release reference to initial shell to avoid keeping it alive after it
is shut down
+ // when the actor is still alive hosting other subfused interpreters
+ _initial = null
if (activeInterpreters.isEmpty) context.stop(self)
else if (shortCircuitBuffer ne null) shortCircuitBatch()
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]