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 cf6784b952 fix: return proper thread-safe Cancellable from
StubbedActorContext.scheduleOnce (#3268)
cf6784b952 is described below
commit cf6784b95253f2963750dbfd446970a295b424d8
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Mon Jun 29 08:32:55 2026 +0800
fix: return proper thread-safe Cancellable from
StubbedActorContext.scheduleOnce (#3268)
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 a thread-safe implementation
that mixes AtomicBoolean directly into the Cancellable:
new AtomicBoolean(false) with classic.Cancellable {
override def cancel(): Boolean = compareAndSet(false, true)
override def isCancelled: Boolean = get()
}
- compareAndSet(false, true) ensures at most one successful cancellation
even
under concurrent access, matching the thread-safety best practice noted in
the Cancellable trait Scaladoc
- Mixing AtomicBoolean directly avoids an extra field allocation
Result:
StubbedActorContext.scheduleOnce now returns a Cancellable that matches the
Cancellable contract and the real production implementation behavior. Actors
that check cancellable.isCancelled will behave consistently in tests and
production.
Tests:
- sbt "actor-testkit-typed / Test / testOnly *BehaviorTestKitSpec
*BehaviorTestKitTest" - 52 passed, 1 ignored
References:
Fixes #3251
---
.../typed/internal/StubbedActorContext.scala | 7 ++--
.../testkit/typed/javadsl/BehaviorTestKitTest.java | 38 ++++++++++++++++++++++
.../typed/scaladsl/BehaviorTestKitSpec.scala | 30 +++++++++++++++++
3 files changed, 72 insertions(+), 3 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..ca892ae39d 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
@@ -14,6 +14,7 @@
package org.apache.pekko.actor.testkit.typed.internal
import java.util.concurrent.ThreadLocalRandom.{ current => rnd }
+import java.util.concurrent.atomic.AtomicBoolean
import scala.collection.immutable.TreeMap
import scala.concurrent.ExecutionContextExecutor
@@ -160,9 +161,9 @@ 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
+ new AtomicBoolean(false) with classic.Cancellable {
+ override def cancel(): Boolean = compareAndSet(false, true)
+ override def isCancelled: Boolean = get()
}
// 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]