https://kotlinlang.org logo
#coroutines
Title
# coroutines
c

CLOVIS

11/06/2023, 10:28 AM
Is it possible to get notified when the virtual time changes in
runTest
? The standard way of integrating other systems with delay skipping is to inject them a
Clock
that reads from the virtual time. However, sometimes there are systems that cannot be injected a clock in this way, but expose an API method like
foo.setTime(…)
. It would be great if there was a way to register a callback that would be invoked each time the virtual time changes, to notify those services.
d

Dmitry Khalanskiy [JB]

11/06/2023, 10:33 AM
Is it possible to get notified when the virtual time changes in
runTest
?
It's not, and I don't think anyone asked for this before. If you're fully confident in your use case, please file an issue on Github; if not, we can discuss it here and maybe arrive at a solution that doesn't need such notifications.
c

CLOVIS

11/06/2023, 10:40 AM
If you have another idea of how to do it, I'm interested. I haven't managed to come up with one. The constraints are as follows: • The other system is a dependency of the SUT. At a very high level, it can be considered to be some sort of task scheduling system. • We cannot modify that system. • That system cannot be injected a
Clock
, and has no otherwise option to "ask what the current time is". • That system can be forced to accept a different current time than the real one. • We could mock it, but that would mean writing non-coroutine-based tests to ensure its correctness, and the coroutines virtual time is the best tool we have to reproduce complex timing issues, so we'd much rather use that if possible. Essentially, it's a system that can be pushed time events to, instead of the usually recommended approach of having a system that pulls time events (through the
Clock
or
TimeSource
).
The simplest solution I see for this use-case is something like:
Copy code
runTest {
    registerOnVirtualTimeAdvancement {
        // suspend callback called each time the virtual time changes
    }

    …
    delay(100) // the callback notifies the other systems that the time has changed
}
If you see another way to integrate such a system, I'm interested.
k

kevin.cianfarini

11/06/2023, 1:56 PM
Are you able to read time from this other system like you would a normal clock? If so, can you can just wrap this other thing with a custom implementation of
Clock
Eg.
Copy code
class MyThirdPartyClock(val thing: ThirdParty) : Clock { 
  override fun now() = thing.now().toinstant()
}
d

Dmitry Khalanskiy [JB]

11/06/2023, 1:59 PM
The idea is to go the other way: control
ThirdParty
automatically via normal calls to
delay
.
k

kevin.cianfarini

11/06/2023, 1:59 PM
Right. I'm wondering if a layer of custom abstraction from the third party dep will help. In this case that would be a custom impl of Clock
And then all you need is your SUT to fake a regular clock which is easy
d

Dmitry Khalanskiy [JB]

11/06/2023, 2:06 PM
Is there a known scheduling granularity to this third-party system? If it's large enough, maybe something like this would do the trick:
Copy code
backgroundScope.launch {
  while (true) {
    delay(50.milliseconds)
    thirdParty.advanceTime(50.milliseconds)
  }
}
If not, does the third-party system provide an interface like "this callback will be run when a new task is scheduled"?
Copy code
backgroundScope.launch {
  val taskChannel = Channel<suspend () -> Unit>()
  thirdParty.onTaskRegistration { task ->
    taskChannel.trySend {
      delay(task.shouldBeStartedIn)
    }
  }
  for (delayTask in taskChannel) {
    launch {
      delayTask()
      thirdParty.setTime(currentTime)
    }
  }
}
c

CLOVIS

11/06/2023, 2:43 PM
I'm wondering if a layer of custom abstraction from the third party dep will help. In this case that would be a custom impl of Clock
I know this would be better, but we can't modify the third party system.
If it's large enough, maybe something like this would do the trick:
[while true]
I think it would work, but wouldn't this break
advanceUntilIdle()
,
runTest
's
timeout
argument, etc?
does the third-party system provide an interface like "this callback will be run when a new task is scheduled"
I'll have to look into it. It's not a great workaround, but maybe it could work. Out of curiosity, are there any issues with the original proposal (other than the cost of adding something to the API)?
d

Dmitry Khalanskiy [JB]

11/06/2023, 2:50 PM
wouldn't this break
advanceUntilIdle()
Nope, if the only tasks available are the ones in
backgroundScope
,
advanceUntilIdle
will stop.
runTest
's
timeout
argument, etc?
`runTest`'s timeout is in terms of the real time, not virtual time.
are there any issues with the original proposal
Yes. It's a brittle API that's easy to misuse. For example,
Copy code
thirdParty.scheduleAfter(1.seconds) {
  println(1)
  thirdParty.scheduleAfter(1.seconds) {
    println(2)
    thirdParty.scheduleAfter(1.seconds) {
      println(3)
    }
  }
}
delay(5.seconds)
What do we expect to happen? I'd expect
1
,
2
,
3
to be printed, but in fact, only
1
will. Why? It's because
thirdParty
will only be notified when
5.seconds
pass. Only then will it run the block scheduled to run after one second, and only then will the new block get scheduled.
The two solutions I proposed don't suffer from this flaw: • With the "spin everything constantly" approach, there's obviously no issues except for needing to run a bunch of extra computations, the time is always in sync. If we have a way of knowing when the next "interesting" event happens, spinning for 10000 iterations is unpleasant. • The second approach, the one that asks the third-party system when interesting events will happen, isn't wasteful, but also it will know to take a stop after one second in the example above, then to take a stop after one more second, etc, while delaying for 5 seconds.
c

CLOVIS

11/06/2023, 3:01 PM
> Nope, if the only tasks available are the ones in
backgroundScope
,
advanceUntilIdle
will stop. Oh, that's great to know. I haven't seen this mentioned anywhere in the documentation. > `runTest`'s timeout is in terms of the real time, not virtual time. doesn't your comment here imply the opposite?
d

Dmitry Khalanskiy [JB]

11/06/2023, 3:04 PM
I haven't seen this mentioned anywhere in the documentation.
https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/kotlinx.coroutines.test/-test-scope/background-scope.html
doesn't your comment here imply the opposite?
That's a comment about how
runTest
used to operate, but we changed that since then.
👍 1
c

CLOVIS

11/06/2023, 3:11 PM
It's a brittle API that's easy to misuse. For example,
I think there was a misunderstanding, this example does not look like what I had in mind. My thinking process was more along the lines of:
Copy code
runTest {
    registerOnVirtualTimeChange { println("foo") }
    // from now on, each time the virtual time changes, "foo" is printed

    somethingHappens()
    delay(2.minutes)
    somethingElse()
}
The assumption is that the correct usage would be something like
Copy code
runTest {
    val system = …
    registerOnVirtualTimeChange { system.setTime(currentTime) }

    system.scheduleAt(currentTime + 1.minutes) {
        …
        system.scheduleAt(currentTime + 1.minutes) {
            …
        }
    }

    advanceUntilIdle()
}
d

Dmitry Khalanskiy [JB]

11/06/2023, 3:13 PM
But see, here's the issue: the
currentTime
inside the block will only be evaluated when
currentTime
is already 5 seconds. As I said, brittle!
c

CLOVIS

11/06/2023, 3:14 PM
Ah, I understand. Indeed, it's very brittle 😕
Thanks for your answers 👍
d

Dmitry Khalanskiy [JB]

11/06/2023, 3:16 PM
Sure. If eventually you understand something new about the problem you're facing or realize that some API from our side would make your job easier, please share your thoughts.
👍 1
c

CLOVIS

11/06/2023, 4:29 PM
I've thought about it some more, and the fact that
advanceUntilIdle()
etc ignores tasks scheduled in
backgroundScope
does in fact make this entire use-case trivial via the infinite-loop+notify solution.