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 63d22b60fe Clean up stopped FSM transition listeners (#3096)
63d22b60fe is described below

commit 63d22b60fee9f2ca1fd5c61f6ea37860201bac09
Author: Minh Vu <[email protected]>
AuthorDate: Wed Jun 24 14:36:10 2026 +0200

    Clean up stopped FSM transition listeners (#3096)
    
    * Clean up stopped FSM transition listeners
    
    * mima issues
    
    ---------
    
    Co-authored-by: PJ Fanning <[email protected]>
---
 .../org/apache/pekko/actor/FSMTransitionSpec.scala | 28 +++++++++++++--
 .../fsm-transition-listeners.excludes              | 19 ++++++++++
 .../main/scala/org/apache/pekko/actor/FSM.scala    | 40 +++++++++++++++++-----
 docs/src/main/paradox/fsm.md                       |  5 ++-
 .../fsm-transition-listeners.excludes              | 19 ++++++++++
 .../pekko/persistence/fsm/PersistentFSM.scala      |  1 +
 .../pekko/persistence/fsm/PersistentFSMBase.scala  | 39 ++++++++++++++++-----
 .../pekko/persistence/fsm/PersistentFSMSpec.scala  | 30 ++++++++++++++--
 8 files changed, 158 insertions(+), 23 deletions(-)

diff --git 
a/actor-tests/src/test/scala/org/apache/pekko/actor/FSMTransitionSpec.scala 
b/actor-tests/src/test/scala/org/apache/pekko/actor/FSMTransitionSpec.scala
index aaca6064a3..65c11e4dc4 100644
--- a/actor-tests/src/test/scala/org/apache/pekko/actor/FSMTransitionSpec.scala
+++ b/actor-tests/src/test/scala/org/apache/pekko/actor/FSMTransitionSpec.scala
@@ -20,6 +20,8 @@ import pekko.testkit._
 
 object FSMTransitionSpec {
 
+  case object ListenerCount
+
   class Supervisor extends Actor {
     def receive = { case _ => }
   }
@@ -44,7 +46,8 @@ object FSMTransitionSpec {
       case Event("tick", _) => goto(0)
     }
     whenUnhandled {
-      case Event("reply", _) => stay().replying("reply")
+      case Event("reply", _)       => stay().replying("reply")
+      case Event(ListenerCount, _) => stay().replying(listeners.size)
     }
     initialize()
     override def preRestart(reason: Throwable, msg: Option[Any]): Unit = { 
target ! "restarted" }
@@ -107,11 +110,32 @@ class FSMTransitionSpec extends PekkoSpec with 
ImplicitSender {
       within(1.second) {
         fsm ! FSM.SubscribeTransitionCallBack(forward)
         expectMsg(FSM.CurrentState(fsm, 0))
-        pekko.pattern.gracefulStop(forward, 5.seconds)
+        watch(forward)
+        system.stop(forward)
+        expectTerminated(forward)
         fsm ! "tick"
         expectNoMessage()
       }
     }
+
+    "remove stopped listeners" in {
+      val forward = system.actorOf(Props(new Forwarder(testActor)))
+      val fsm = system.actorOf(Props(new MyFSM(testActor)))
+
+      within(3.seconds) {
+        fsm ! FSM.SubscribeTransitionCallBack(forward)
+        expectMsg(FSM.CurrentState(fsm, 0))
+
+        watch(forward)
+        system.stop(forward)
+        expectTerminated(forward)
+
+        awaitAssert {
+          fsm ! ListenerCount
+          expectMsg(0)
+        }
+      }
+    }
   }
 
   "A FSM" must {
diff --git 
a/actor/src/main/mima-filters/2.0.x.backwards.excludes/fsm-transition-listeners.excludes
 
b/actor/src/main/mima-filters/2.0.x.backwards.excludes/fsm-transition-listeners.excludes
new file mode 100644
index 0000000000..2c6aaef9bf
--- /dev/null
+++ 
b/actor/src/main/mima-filters/2.0.x.backwards.excludes/fsm-transition-listeners.excludes
@@ -0,0 +1,19 @@
+# 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.
+
+# Clean up stopped FSM transition listeners
+ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.actor.FSM.org$apache$pekko$actor$FSM$$watchedListeners*")
diff --git a/actor/src/main/scala/org/apache/pekko/actor/FSM.scala 
b/actor/src/main/scala/org/apache/pekko/actor/FSM.scala
index fd93a273b7..e7a2a10805 100644
--- a/actor/src/main/scala/org/apache/pekko/actor/FSM.scala
+++ b/actor/src/main/scala/org/apache/pekko/actor/FSM.scala
@@ -99,6 +99,7 @@ object FSM {
    * INTERNAL API
    */
   private final case class TimeoutMarker(generation: Long)
+  private final case class ListenerTerminated(actorRef: ActorRef)
 
   /** INTERNAL API */
   @InternalApi
@@ -406,9 +407,8 @@ object FSM {
  *
  * Another feature is that other actors may subscribe for transition events by
  * sending a <code>SubscribeTransitionCallback</code> message to this actor.
- * Stopping a listener without unregistering will not remove the listener from 
the
- * subscription list; use <code>UnsubscribeTransitionCallback</code> before 
stopping
- * the listener.
+ * Stopping a listener will remove it from the subscription list. Subscriptions
+ * may also be removed explicitly with 
<code>UnsubscribeTransitionCallback</code>.
  *
  * State timeouts set an upper bound to the time which may pass before another
  * message is received in the current state. If no external message is
@@ -779,11 +779,34 @@ trait FSM[S, D] extends Actor with Listeners with 
ActorLogging {
    * transition handling
    */
   private var transitionEvent: List[TransitionHandler] = Nil
+  private var watchedListeners: Set[ActorRef] = Set.empty
   private def handleTransition(prev: S, next: S): Unit = {
     val tuple = (prev, next)
     for (te <- transitionEvent) { if (te.isDefinedAt(tuple)) te(tuple) }
   }
 
+  private def addListener(actorRef: ActorRef): Unit = {
+    if (listeners.add(actorRef)) {
+      context.asInstanceOf[ActorCell].isWatching(actorRef) match {
+        case false =>
+          context.watchWith(actorRef, ListenerTerminated(actorRef))
+          watchedListeners += actorRef
+        case true =>
+          log.warning(
+            "Listener [{}] is already watched by this FSM; it will not be 
automatically removed on termination. " +
+            "Use UnsubscribeTransitionCallBack explicitly.",
+            actorRef)
+      }
+    }
+  }
+
+  private def removeListener(actorRef: ActorRef): Unit = {
+    if (listeners.remove(actorRef) && watchedListeners.contains(actorRef)) {
+      watchedListeners -= actorRef
+      context.unwatch(actorRef)
+    }
+  }
+
   /*
    * *******************************************
    *       Main actor receive() method
@@ -807,19 +830,20 @@ trait FSM[S, D] extends Actor with Listeners with 
ActorLogging {
         processMsg(msg, t)
       }
     case SubscribeTransitionCallBack(actorRef) =>
-      // TODO Use context.watch(actor) and receive Terminated(actor) to clean 
up list
-      listeners.add(actorRef)
+      addListener(actorRef)
       // send current state back as reference point
       actorRef ! CurrentState(self, currentState.stateName)
     case Listen(actorRef) =>
-      // TODO Use context.watch(actor) and receive Terminated(actor) to clean 
up list
-      listeners.add(actorRef)
+      addListener(actorRef)
       // send current state back as reference point
       actorRef ! CurrentState(self, currentState.stateName)
     case UnsubscribeTransitionCallBack(actorRef) =>
-      listeners.remove(actorRef)
+      removeListener(actorRef)
     case Deafen(actorRef) =>
+      removeListener(actorRef)
+    case ListenerTerminated(actorRef) =>
       listeners.remove(actorRef)
+      watchedListeners -= actorRef
     case value =>
       if (timeoutFuture.isDefined) {
         timeoutFuture.get.cancel()
diff --git a/docs/src/main/paradox/fsm.md b/docs/src/main/paradox/fsm.md
index c97df9f214..65f44037f0 100644
--- a/docs/src/main/paradox/fsm.md
+++ b/docs/src/main/paradox/fsm.md
@@ -417,9 +417,8 @@ transitions use `stay()` instead of `goto(S)`.
 External monitors may be unregistered by sending
 `UnsubscribeTransitionCallBack(actorRef)` to the `FSM` actor.
 
-Stopping a listener without unregistering will not remove the listener from the
-subscription list; use `UnsubscribeTransitionCallback` before stopping
-the listener.
+Stopping a listener will remove it from the subscription list. Subscriptions 
may
+also be removed explicitly with `UnsubscribeTransitionCallback`.
 
 @@@ div { .group-scala }
 
diff --git 
a/persistence/src/main/mima-filters/2.0.x.backwards.excludes/fsm-transition-listeners.excludes
 
b/persistence/src/main/mima-filters/2.0.x.backwards.excludes/fsm-transition-listeners.excludes
new file mode 100644
index 0000000000..3f1be9e8f1
--- /dev/null
+++ 
b/persistence/src/main/mima-filters/2.0.x.backwards.excludes/fsm-transition-listeners.excludes
@@ -0,0 +1,19 @@
+# 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.
+
+# Clean up stopped FSM transition listeners
+ProblemFilters.exclude[ReversedMissingMethodProblem]("org.apache.pekko.persistence.fsm.PersistentFSMBase.org$apache$pekko$persistence$fsm$PersistentFSMBase$$watchedListeners*")
diff --git 
a/persistence/src/main/scala/org/apache/pekko/persistence/fsm/PersistentFSM.scala
 
b/persistence/src/main/scala/org/apache/pekko/persistence/fsm/PersistentFSM.scala
index 78de37df71..18c8cbaac4 100644
--- 
a/persistence/src/main/scala/org/apache/pekko/persistence/fsm/PersistentFSM.scala
+++ 
b/persistence/src/main/scala/org/apache/pekko/persistence/fsm/PersistentFSM.scala
@@ -310,6 +310,7 @@ object PersistentFSM {
   /** INTERNAL API */
   @InternalApi
   private[persistence] final case class TimeoutMarker(generation: Long)
+  private[persistence] final case class ListenerTerminated(actorRef: ActorRef)
 
   /** INTERNAL API */
   @InternalApi
diff --git 
a/persistence/src/main/scala/org/apache/pekko/persistence/fsm/PersistentFSMBase.scala
 
b/persistence/src/main/scala/org/apache/pekko/persistence/fsm/PersistentFSMBase.scala
index 448f00ef52..a26c28db3e 100644
--- 
a/persistence/src/main/scala/org/apache/pekko/persistence/fsm/PersistentFSMBase.scala
+++ 
b/persistence/src/main/scala/org/apache/pekko/persistence/fsm/PersistentFSMBase.scala
@@ -85,9 +85,8 @@ import pekko.routing.{ Deafen, Listen, Listeners }
  *
  * Another feature is that other actors may subscribe for transition events by
  * sending a <code>SubscribeTransitionCallback</code> message to this actor.
- * Stopping a listener without unregistering will not remove the listener from 
the
- * subscription list; use <code>UnsubscribeTransitionCallback</code> before 
stopping
- * the listener.
+ * Stopping a listener will remove it from the subscription list. Subscriptions
+ * may also be removed explicitly with 
<code>UnsubscribeTransitionCallback</code>.
  *
  * State timeouts set an upper bound to the time which may pass before another
  * message is received in the current state. If no external message is
@@ -482,11 +481,34 @@ trait PersistentFSMBase[S, D, E] extends Actor with 
Listeners with ActorLogging
    * transition handling
    */
   private var transitionEvent: List[TransitionHandler] = Nil
+  private var watchedListeners: Set[ActorRef] = Set.empty
   private def handleTransition(prev: S, next: S): Unit = {
     val tuple = (prev, next)
     for (te <- transitionEvent) { if (te.isDefinedAt(tuple)) te(tuple) }
   }
 
+  private def addListener(actorRef: ActorRef): Unit = {
+    if (listeners.add(actorRef)) {
+      context.asInstanceOf[ActorCell].isWatching(actorRef) match {
+        case false =>
+          context.watchWith(actorRef, ListenerTerminated(actorRef))
+          watchedListeners += actorRef
+        case true =>
+          log.warning(
+            "Listener [{}] is already watched by this FSM; it will not be 
automatically removed on termination. " +
+            "Use UnsubscribeTransitionCallBack explicitly.",
+            actorRef)
+      }
+    }
+  }
+
+  private def removeListener(actorRef: ActorRef): Unit = {
+    if (listeners.remove(actorRef) && watchedListeners.contains(actorRef)) {
+      watchedListeners -= actorRef
+      context.unwatch(actorRef)
+    }
+  }
+
   /*
    * *******************************************
    *       Main actor receive() method
@@ -510,19 +532,20 @@ trait PersistentFSMBase[S, D, E] extends Actor with 
Listeners with ActorLogging
         processMsg(msg, t)
       }
     case SubscribeTransitionCallBack(actorRef) =>
-      // TODO Use context.watch(actor) and receive Terminated(actor) to clean 
up list
-      listeners.add(actorRef)
+      addListener(actorRef)
       // send current state back as reference point
       actorRef ! CurrentState(self, currentState.stateName, 
currentState.timeout)
     case Listen(actorRef) =>
-      // TODO Use context.watch(actor) and receive Terminated(actor) to clean 
up list
-      listeners.add(actorRef)
+      addListener(actorRef)
       // send current state back as reference point
       actorRef ! CurrentState(self, currentState.stateName, 
currentState.timeout)
     case UnsubscribeTransitionCallBack(actorRef) =>
-      listeners.remove(actorRef)
+      removeListener(actorRef)
     case Deafen(actorRef) =>
+      removeListener(actorRef)
+    case ListenerTerminated(actorRef) =>
       listeners.remove(actorRef)
+      watchedListeners -= actorRef
     case value =>
       if (timeoutFuture.isDefined) {
         timeoutFuture.get.cancel()
diff --git 
a/persistence/src/test/scala/org/apache/pekko/persistence/fsm/PersistentFSMSpec.scala
 
b/persistence/src/test/scala/org/apache/pekko/persistence/fsm/PersistentFSMSpec.scala
index c2d2708670..57f26babfb 100644
--- 
a/persistence/src/test/scala/org/apache/pekko/persistence/fsm/PersistentFSMSpec.scala
+++ 
b/persistence/src/test/scala/org/apache/pekko/persistence/fsm/PersistentFSMSpec.scala
@@ -73,6 +73,30 @@ abstract class PersistentFSMSpec(config: Config) extends 
PersistenceSpec(config)
       expectTerminated(fsmRef)
     }
 
+    "remove stopped transition listeners" in {
+      val persistenceId = name
+      val listener = TestProbe()
+      val fsmRef = system.actorOf(SimpleTransitionFSM.props(persistenceId, 
TestProbe().ref))
+
+      fsmRef ! SubscribeTransitionCallBack(listener.ref)
+      listener.expectMsg(CurrentState(fsmRef, LookingAround, None))
+      fsmRef ! ListenerCount
+      expectMsg(1)
+
+      watch(listener.ref)
+      system.stop(listener.ref)
+      expectTerminated(listener.ref)
+
+      awaitAssert {
+        fsmRef ! ListenerCount
+        expectMsg(0)
+      }
+
+      watch(fsmRef)
+      fsmRef ! PoisonPill
+      expectTerminated(fsmRef)
+    }
+
     "function as a regular FSM on state timeout" taggedAs TimingTest in {
       val persistenceId = name
       val fsmRef = system.actorOf(WebStoreCustomerFSM.props(persistenceId, 
dummyReportActorRef))
@@ -450,6 +474,7 @@ object PersistentFSMSpec {
   case object Leave extends Command
   case object GetCurrentCart extends Command
   // #customer-commands
+  case object ListenerCount
 
   // #customer-domain-events
   sealed trait DomainEvent
@@ -472,8 +497,9 @@ object PersistentFSMSpec {
     startWith(LookingAround, EmptyShoppingCart)
 
     when(LookingAround) {
-      case Event("stay", _) => stay()
-      case Event(_, _)      => goto(LookingAround)
+      case Event("stay", _)        => stay()
+      case Event(ListenerCount, _) => stay().replying(listeners.size)
+      case Event(_, _)             => goto(LookingAround)
     }
 
     onTransition {


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

Reply via email to