From 5efd199f4590178df5817a4db54dee12a7a1b9c7 Mon Sep 17 00:00:00 2001 From: Ruslan Shevchenko Date: Tue, 26 May 2026 16:26:21 +0300 Subject: [PATCH 1/5] Use cats-effect Clock instead of System.currentTimeMillis in ReceiveTimeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ReceiveTimeout recorded timestamps via System.currentTimeMillis(), which cats.effect.testkit.TestControl cannot advance. As a result actor receive-timeouts never fire under virtual time, forcing actor tests onto the real runtime (and real wall-clock waits). Switch to Clock[F].realTime — the class already requires Sync[F], which extends Clock[F], so no new constraint is added. On the real runtime Clock[F].realTime is the wall clock, so behaviour is unchanged; under TestControl it now advances with virtual time and timeouts fire deterministically and instantly. Generated by Claude Opus 4.7 --- .../actor/dungeon/ReceiveTimeout.scala | 59 +++++++++++-------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala b/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala index 5a45a23..24eb571 100644 --- a/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala +++ b/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala @@ -17,8 +17,9 @@ package com.suprnation.actor.dungeon import cats.Applicative -import cats.effect.{Ref, Sync} +import cats.effect.{Clock, Ref, Sync} import cats.syntax.flatMap._ +import cats.syntax.functor._ import com.suprnation.actor.dungeon.ReceiveTimeout.ReceiveTimeoutContext import scala.concurrent.duration.FiniteDuration @@ -35,42 +36,52 @@ class ReceiveTimeout[F[_]: Sync, Request]( receiveTimeoutContextRef: Ref[F, ReceiveTimeout.ReceiveTimeoutContext[Request]] ) { + // Use the cats-effect clock instead of System.currentTimeMillis so that receive-timeouts honour + // virtual time under cats.effect.testkit.TestControl (deterministic, instant tests). On the real + // runtime Clock[F].realTime is the wall clock, so behaviour is unchanged. + private def nowMillis: F[Long] = Clock[F].realTime.map(_.toMillis) + def setReceiveTimeout(timeout: FiniteDuration, onTimeout: => Request): F[Unit] = - receiveTimeoutContextRef.set( - ReceiveTimeoutContext[Request]( - Some(timeout), - Some(System.currentTimeMillis()), - Some(onTimeout) + nowMillis.flatMap { now => + receiveTimeoutContextRef.set( + ReceiveTimeoutContext[Request]( + Some(timeout), + Some(now), + Some(onTimeout) + ) ) - ) + } def cancelReceiveTimeout: F[Unit] = receiveTimeoutContextRef.update(_.copy(receiveTimeout = None, message = None)) def markLastMessageTimestamp: F[Unit] = - receiveTimeoutContextRef.update { receiveTimeoutContext => - if (receiveTimeoutContext.receiveTimeout.isDefined) { - receiveTimeoutContext.copy(lastMessageTimestamp = Some(System.currentTimeMillis())) - } else { - receiveTimeoutContext + nowMillis.flatMap { now => + receiveTimeoutContextRef.update { receiveTimeoutContext => + if (receiveTimeoutContext.receiveTimeout.isDefined) { + receiveTimeoutContext.copy(lastMessageTimestamp = Some(now)) + } else { + receiveTimeoutContext + } } } def checkTimeout(action: Request => F[Any]): F[Any] = receiveTimeoutContextRef.get.flatMap { case ReceiveTimeoutContext(Some(timeout), Some(timestamp), Some(message)) => - val timeoutTime: Long = timestamp + timeout.toMillis - val currentTime: Long = System.currentTimeMillis() - if (timeoutTime <= currentTime) { - receiveTimeoutContextRef.set( - ReceiveTimeout.ReceiveTimeoutContext( - Some(timeout), - Some(System.currentTimeMillis()), - Some(message) - ) - ) >> action(message) - } else { - Applicative[F].pure(()) + nowMillis.flatMap { currentTime => + val timeoutTime: Long = timestamp + timeout.toMillis + if (timeoutTime <= currentTime) { + receiveTimeoutContextRef.set( + ReceiveTimeout.ReceiveTimeoutContext( + Some(timeout), + Some(currentTime), + Some(message) + ) + ) >> action(message) + } else { + Applicative[F].pure(()) + } } case _ => Applicative[F].pure(()) } From de61465301706894ccea0dcf1122a6631d6e063f Mon Sep 17 00:00:00 2001 From: Ruslan Shevchenko Date: Tue, 26 May 2026 16:36:33 +0300 Subject: [PATCH 2/5] test(ReceiveTimeout): verify receive-timeout fires under TestControl virtual time Adds a regression test driving the existing constantFlowActor under cats.effect.testkit.TestControl: IO.sleep is advanced virtually (no real wait) and the receive timeout must fire. Verified to fail before the Clock[F] change ("0 was not greater than or equal to 1") and pass after. Adds cats-effect-testkit as a Test dependency. Generated by Claude Opus 4.7 --- build.sbt | 1 + .../actor/timeout/ReceiveTimeoutSpec.scala | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/build.sbt b/build.sbt index 6312c1f..ef1bf5a 100644 --- a/build.sbt +++ b/build.sbt @@ -68,6 +68,7 @@ lazy val catsActors = crossProject(JSPlatform, JVMPlatform, NativePlatform) libraryDependencies ++= Seq( "org.typelevel" %%% "cats-effect" % "3.7.0", "org.scalatest" %%% "scalatest" % "3.2.18" % Test, + "org.typelevel" %%% "cats-effect-testkit" % "3.7.0" % Test, "org.typelevel" %%% "cats-effect-testing-scalatest" % "1.8.0" % Test ) ) diff --git a/shared/src/test/scala/com/suprnation/actor/timeout/ReceiveTimeoutSpec.scala b/shared/src/test/scala/com/suprnation/actor/timeout/ReceiveTimeoutSpec.scala index a237c82..6782e6c 100644 --- a/shared/src/test/scala/com/suprnation/actor/timeout/ReceiveTimeoutSpec.scala +++ b/shared/src/test/scala/com/suprnation/actor/timeout/ReceiveTimeoutSpec.scala @@ -16,6 +16,7 @@ package com.suprnation.actor.timeout +import cats.effect.testkit.TestControl import cats.effect.{IO, Ref} import com.suprnation.actor.Actor.ReplyingReceive import com.suprnation.actor._ @@ -198,4 +199,31 @@ class ReceiveTimeoutSpec extends CatsActorFlatSpec { b1 should be(List(2, 1)) } } + + // Regression test for the Clock-based ReceiveTimeout (see ReceiveTimeout.scala). Under + // cats.effect.testkit.TestControl the receive timeout must fire in *virtual* time, with no real + // waiting: TestControl advances IO.sleep instantly. Before the fix (System.currentTimeMillis), + // virtual time advanced but the wall clock did not, so the timeout never fired here. + it should "fire a receive timeout under TestControl (virtual time, no real waiting)" in { + val program = for { + counter <- Ref.of[IO, Int](0) + buffer <- Ref.of[IO, List[Int]](List.empty) + result <- ActorSystem[IO]("TestControlTimeout", (_: Any) => IO.unit).use { system => + for { + helloActor <- system.replyingActorOf( + constantFlowActor(1 second, counter, buffer), + name = "tc-timeout-actor" + ) + _ <- IO.sleep(2 seconds) // advanced instantly by TestControl, no real wall-clock wait + result1 <- helloActor ? Get + } yield result1 + } + } yield result + + // executeEmbed runs the whole program in simulated time and fails on non-termination. + TestControl.executeEmbed(program).map { case (timeouts, msgs) => + timeouts should be >= 1 + msgs should be(List.empty) + } + } } From 70e4d3368cbd4d98dc158ab1ba1dcf104b042305 Mon Sep 17 00:00:00 2001 From: Ruslan Shevchenko Date: Tue, 26 May 2026 16:44:59 +0300 Subject: [PATCH 3/5] review: use cats.syntax.all._ to match codebase convention Per Copilot review: align with dungeon/engine files that import cats.syntax.all._ rather than the narrower flatMap/functor imports. Generated by Claude Opus 4.7 --- .../scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala b/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala index 24eb571..a400300 100644 --- a/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala +++ b/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala @@ -18,8 +18,7 @@ package com.suprnation.actor.dungeon import cats.Applicative import cats.effect.{Clock, Ref, Sync} -import cats.syntax.flatMap._ -import cats.syntax.functor._ +import cats.syntax.all._ import com.suprnation.actor.dungeon.ReceiveTimeout.ReceiveTimeoutContext import scala.concurrent.duration.FiniteDuration From b3beaa1de172f14cc3738469d32f81fd35e6e774 Mon Sep 17 00:00:00 2001 From: Ruslan Shevchenko Date: Tue, 26 May 2026 16:52:45 +0300 Subject: [PATCH 4/5] test(ReceiveTimeout): long (1-hour) timeout fires in virtual time A scenario that was impossible to test before the Clock[F] change: a 1-hour receive timeout. With a wall clock this could only be verified by waiting an hour; under TestControl the hour of IO.sleep is advanced virtually, so the test runs in milliseconds and deterministically. Generated by Claude Opus 4.7 --- .../actor/timeout/ReceiveTimeoutSpec.scala | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/shared/src/test/scala/com/suprnation/actor/timeout/ReceiveTimeoutSpec.scala b/shared/src/test/scala/com/suprnation/actor/timeout/ReceiveTimeoutSpec.scala index 6782e6c..9915ddc 100644 --- a/shared/src/test/scala/com/suprnation/actor/timeout/ReceiveTimeoutSpec.scala +++ b/shared/src/test/scala/com/suprnation/actor/timeout/ReceiveTimeoutSpec.scala @@ -226,4 +226,28 @@ class ReceiveTimeoutSpec extends CatsActorFlatSpec { msgs should be(List.empty) } } + + // A test that was impossible before this change: a long (1 hour) receive timeout. With a real + // clock this could only be verified by actually waiting an hour; under TestControl the hour of + // IO.sleep is advanced virtually, so it runs in milliseconds and is fully deterministic. + it should "fire a 1-hour receive timeout in virtual time (a real-clock test would wait an hour)" in { + val program = for { + counter <- Ref.of[IO, Int](0) + buffer <- Ref.of[IO, List[Int]](List.empty) + result <- ActorSystem[IO]("LongVirtualTimeout", (_: Any) => IO.unit).use { system => + for { + helloActor <- system.replyingActorOf( + constantFlowActor(1 hour, counter, buffer), + name = "long-timeout-actor" + ) + _ <- IO.sleep(65 minutes) // > 1 hour of virtual time, advanced instantly + result1 <- helloActor ? Get + } yield result1 + } + } yield result + + TestControl.executeEmbed(program).map { case (timeouts, _) => + timeouts should be >= 1 + } + } } From e03024985fbdb7290013dc0a546ac7a4fbccd062 Mon Sep 17 00:00:00 2001 From: Ruslan Shevchenko Date: Tue, 26 May 2026 17:19:56 +0300 Subject: [PATCH 5/5] review: use Clock[F].monotonic for elapsed timeout measurement Per review: a receive timeout measures *elapsed* time, so monotonic is the correct clock (immune to NTP / wall-clock jumps) rather than realTime. The elapsed comparison (timestamp + timeout <= now) is origin-independent, and both clocks are TestControl-controllable, so the virtual-time tests still pass. Generated by Claude Opus 4.7 --- .../com/suprnation/actor/dungeon/ReceiveTimeout.scala | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala b/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala index a400300..af96cd9 100644 --- a/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala +++ b/shared/src/main/scala/com/suprnation/actor/dungeon/ReceiveTimeout.scala @@ -36,9 +36,10 @@ class ReceiveTimeout[F[_]: Sync, Request]( ) { // Use the cats-effect clock instead of System.currentTimeMillis so that receive-timeouts honour - // virtual time under cats.effect.testkit.TestControl (deterministic, instant tests). On the real - // runtime Clock[F].realTime is the wall clock, so behaviour is unchanged. - private def nowMillis: F[Long] = Clock[F].realTime.map(_.toMillis) + // virtual time under cats.effect.testkit.TestControl (deterministic, instant tests). We use + // `monotonic` (not `realTime`): a timeout measures *elapsed* time, so it must be immune to + // wall-clock jumps (NTP, manual clock changes). TestControl advances monotonic with virtual time. + private def nowMillis: F[Long] = Clock[F].monotonic.map(_.toMillis) def setReceiveTimeout(timeout: FiniteDuration, onTimeout: => Request): F[Unit] = nowMillis.flatMap { now =>