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]
