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

He-Pin pushed a commit to branch worktree-fix+stubbed-cancellable-3251
in repository https://gitbox.apache.org/repos/asf/pekko.git

commit 42e5550ca9c68acfc9c917e0f96ec4bdf843ff64
Author: 虎鸣 <[email protected]>
AuthorDate: Mon Jun 29 03:51:57 2026 +0800

    fix: return proper non-cancelled Cancellable from 
StubbedActorContext.scheduleOnce
    
    Motivation:
    StubbedActorContext.scheduleOnce returned a Cancellable with 
isCancelled=true
    and cancel()=false, violating the Cancellable contract which states 
isCancelled
    should be true "if and only if" the Cancellable has been successfully 
cancelled.
    A freshly scheduled task has not been cancelled, so isCancelled must start 
false.
    This caused test/production divergence: actors checking 
cancellable.isCancelled
    would take different code paths in tests vs production.
    
    Modification:
    Replace the pre-cancelled Cancellable stub with one that starts 
non-cancelled
    and supports proper cancellation semantics: cancel() returns true on first 
call
    and false on subsequent calls, while isCancelled reflects the actual state.
    
    Result:
    StubbedActorContext.scheduleOnce now returns a Cancellable that matches the
    Cancellable contract and the real production implementation behavior.
    
    Tests:
    - sbt "actor-testkit-typed / Test / testOnly 
org.apache.pekko.actor.testkit.typed.scaladsl.BehaviorTestKitSpec" - 35 passed
    - sbt "actor-testkit-typed / Test / testOnly 
org.apache.pekko.actor.testkit.typed.javadsl.BehaviorTestKitTest" - 17 passed, 
1 ignored
    
    References:
    Fixes #3251
---
 .../typed/internal/StubbedActorContext.scala       |  8 +++--
 .../testkit/typed/javadsl/BehaviorTestKitTest.java | 38 ++++++++++++++++++++++
 .../typed/scaladsl/BehaviorTestKitSpec.scala       | 30 +++++++++++++++++
 3 files changed, 74 insertions(+), 2 deletions(-)

diff --git 
a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/StubbedActorContext.scala
 
b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/StubbedActorContext.scala
index b765bd62a0..393c5ee8cf 100644
--- 
a/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/StubbedActorContext.scala
+++ 
b/actor-testkit-typed/src/main/scala/org/apache/pekko/actor/testkit/typed/internal/StubbedActorContext.scala
@@ -161,8 +161,12 @@ private[pekko] final class FunctionRef[-T](override val 
path: ActorPath, send: (
 
   override def scheduleOnce[U](delay: FiniteDuration, target: ActorRef[U], 
message: U): classic.Cancellable =
     new classic.Cancellable {
-      override def cancel() = false
-      override def isCancelled = true
+      private var _cancelled = false
+      override def cancel(): Boolean = {
+        if (!_cancelled) { _cancelled = true; true }
+        else false
+      }
+      override def isCancelled: Boolean = _cancelled
     }
 
   // TODO allow overriding of this
diff --git 
a/actor-testkit-typed/src/test/java/org/apache/pekko/actor/testkit/typed/javadsl/BehaviorTestKitTest.java
 
b/actor-testkit-typed/src/test/java/org/apache/pekko/actor/testkit/typed/javadsl/BehaviorTestKitTest.java
index fd67294a12..529b61cecc 100644
--- 
a/actor-testkit-typed/src/test/java/org/apache/pekko/actor/testkit/typed/javadsl/BehaviorTestKitTest.java
+++ 
b/actor-testkit-typed/src/test/java/org/apache/pekko/actor/testkit/typed/javadsl/BehaviorTestKitTest.java
@@ -61,6 +61,13 @@ public class BehaviorTestKitTest {
   public record AskForCookiesFrom(ActorRef<CookieDistributorCommand> 
distributor)
       implements Command {}
 
+  public record ScheduleOnceCommand(
+      Duration delay,
+      ActorRef<String> target,
+      String message,
+      ActorRef<org.apache.pekko.actor.Cancellable> replyTo)
+      implements Command {}
+
   public interface CookieDistributorCommand {}
 
   public record GiveMeCookies(int nrCookies, ActorRef<CookiesForYou> replyTo)
@@ -183,6 +190,15 @@ public class BehaviorTestKitTest {
                           });
                       return Behaviors.same();
                     })
+                .onMessage(
+                    ScheduleOnceCommand.class,
+                    message -> {
+                      org.apache.pekko.actor.Cancellable cancellable =
+                          context.scheduleOnce(
+                              message.delay(), message.target(), 
message.message());
+                      message.replyTo().tell(cancellable);
+                      return Behaviors.same();
+                    })
                 .build();
           });
 
@@ -366,4 +382,26 @@ public class BehaviorTestKitTest {
 
     // Other functionality is tested in the scaladsl
   }
+
+  @Test
+  public void scheduleOnceReturnsNonCancelledCancellable() {
+    BehaviorTestKit<Command> test = BehaviorTestKit.create(behavior);
+    TestInbox<String> target = TestInbox.create("target");
+    TestInbox<org.apache.pekko.actor.Cancellable> replyTo = 
TestInbox.create("cancellable");
+
+    test.run(
+        new ScheduleOnceCommand(
+            Duration.ofSeconds(42), target.getRef(), "hello", 
replyTo.getRef()));
+
+    test.expectEffectClass(Effect.Scheduled.class);
+
+    org.apache.pekko.actor.Cancellable cancellable = replyTo.receiveMessage();
+    assertFalse(cancellable.isCancelled(), "Freshly scheduled Cancellable 
should not be cancelled");
+
+    assertTrue(cancellable.cancel(), "First cancel() should return true");
+    assertTrue(cancellable.isCancelled(), "isCancelled should be true after 
cancel()");
+
+    assertFalse(cancellable.cancel(), "Second cancel() should return false");
+    assertTrue(cancellable.isCancelled(), "isCancelled should remain true");
+  }
 }
diff --git 
a/actor-testkit-typed/src/test/scala/org/apache/pekko/actor/testkit/typed/scaladsl/BehaviorTestKitSpec.scala
 
b/actor-testkit-typed/src/test/scala/org/apache/pekko/actor/testkit/typed/scaladsl/BehaviorTestKitSpec.scala
index 4f13e0d415..b49ab7c3bb 100644
--- 
a/actor-testkit-typed/src/test/scala/org/apache/pekko/actor/testkit/typed/scaladsl/BehaviorTestKitSpec.scala
+++ 
b/actor-testkit-typed/src/test/scala/org/apache/pekko/actor/testkit/typed/scaladsl/BehaviorTestKitSpec.scala
@@ -64,6 +64,8 @@ object BehaviorTestKitSpec {
     case class CancelScheduleCommand(key: Any) extends Command
     case class IsTimerActive(key: Any, replyTo: ActorRef[Boolean]) extends 
Command
     case class AskForCookiesFrom(distributor: 
ActorRef[CookieDistributor.Command]) extends Command
+    case class ScheduleOnceCommand(delay: FiniteDuration, target: 
ActorRef[String], message: String,
+        replyTo: ActorRef[pekko.actor.Cancellable]) extends Command
 
     val init: Behavior[Command] = Behaviors.withTimers { timers =>
       Behaviors
@@ -165,6 +167,10 @@ object BehaviorTestKitSpec {
                 case scala.util.Failure(ex)  => Log(s"Failed to get cookies: 
${ex.getMessage}")
               }
               Behaviors.same
+            case ScheduleOnceCommand(delay, target, message, replyTo) =>
+              val cancellable = context.scheduleOnce(delay, target, message)
+              replyTo ! cancellable
+              Behaviors.same
             case unexpected =>
               throw new RuntimeException(s"Unexpected command: $unexpected")
           }
@@ -529,6 +535,30 @@ class BehaviorTestKitSpec extends AnyWordSpec with 
Matchers with LogCapturing {
     }
   }
 
+  "BehaviorTestKit's scheduleOnce" must {
+    "return a Cancellable that follows the Cancellable contract" in {
+      val testkit = BehaviorTestKit[Parent.Command](Parent.init)
+      val target = TestInbox[String]("target")
+      val replyTo = TestInbox[pekko.actor.Cancellable]("cancellable")
+      val delay = 42.seconds
+
+      testkit.run(ScheduleOnceCommand(delay, target.ref, "hello", replyTo.ref))
+
+      testkit.expectEffectPF {
+        case Effect.Scheduled(`delay`, _, "hello") =>
+      }
+
+      val cancellable = replyTo.receiveMessage()
+      cancellable.isCancelled shouldBe false
+
+      cancellable.cancel() shouldBe true
+      cancellable.isCancelled shouldBe true
+
+      cancellable.cancel() shouldBe false
+      cancellable.isCancelled shouldBe true
+    }
+  }
+
   "BehaviorTestKit's ask" must {
     "reify the ask for inspection" in {
       import BehaviorTestKitSpec.CookieDistributor


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

Reply via email to