Hi, I am writing asynchronous code with Futures 0.3 that requires to respond to the passage of time. For example, keep-alive messages needs to be sent to a remote host if no messages were sent for a while.
I have a problem simulating the passage of time in some of my tests with my chosen design. This problem is described below.
I chose the following design to handle time: An incoming stream of ticks: Stream<Item=TimerTick>
.
A tick happens every constant amount of time (For example, one second). A task that needs to be time sensitive is given an incoming stream of ticks as argument. By responding to incoming TimerTick
-s the task can respond to the passage of time.
For most unit tests this approach was very easy to deal with. I could advance time carefully tick by tick and expect certain thing to happen (For example, expect messages to be sent through certain mpsc channels when a timeout occurs). However, when writing large integration tests I have an issue.
Consider two operations that should be separated by a passage of some time. A naive approach would be something like this (Code taken from the offst project).
// Do first operation
// Wait for 0x100 time ticks:
for _ in 0 .. 0x100usize {
await!(tick_sender.send(())).unwrap();
// Do second operation
In the code above: We created ahead of time some kind of time service which we can control manually by sending ticks through tick_sender
(which is of type mpsc::Sender
). There are many tasks that were spawned to a ThreadPool. Many of those tasks contain an incoming mpsc::Receiver
of timer ticks.
The code written above for sending the ticks does not work very well in most cases. This is because the tasks that received the timer ticks were not given enough poll iterations to respond to the incoming timer ticks. In other words, this code doesn't really simulate the passage of time, because the tasks did not finish what they usually finish in this amount of time.
My next idea to solve this problem was to add some kind of yield mechanism:
// Based on:
// - https://rust-lang-nursery.github.io/futures-api-docs/0.3.0-alpha.13/src/futures_test/future/pending_once.rs.html#14-17
// - https://github.com/rust-lang-nursery/futures-rs/issues/869
pub struct Yield(usize);
impl Yield {
pub fn new(num_yields: usize) -> Self {
Yield(num_yields)
}
}
impl Future for Yield {
type Output = ();
fn poll(mut self: Pin<&mut Self>, waker: &Waker) -> Poll<Self::Output> {
let count = &mut self.as_mut().0;
*count = count.saturating_sub(1);
if *count == 0 {
Poll::Ready(())
} else {
waker.wake();
Poll::Pending
}
}
}
This mechanism should allow our main test task to let other tasks run for a while.
We can now change the time passage for loop to something of this form:
for _ in 0 .. 0x100usize {
await!(tick_sender.send(())).unwrap();
await!(Yield::new(YIELD_ITERS));
}
Note: In my tests, YIELD_ITERS = 0x1000
.
This time most of the tasks will finish handling the passage of time correctly, because 0x1000 is a pretty large amount of yields. However, this solution is pretty hacky. There is no one correct number to put as YIELD_ITERS, because in different occasions we might need to wait a different amount of polls to let all the other tasks complete.
A possible solution would be to be able to let the Executor to run until no more progress can be made. My tests do not involve any external communication, and no more timer ticks are sent, so this state is guaranteed to happen at some point. I am not sure if this is possible to do, and if so, how to do it.
My current workaround is to put a pretty large number in YIELD_ITERS. It makes the tests run very slowly, and sometimes I get a non-deterministic glitch (Maybe sometimes YIELD_ITERS polls is not enough?)
I used to have different solutions to this problem in the past when I was writing python code. In Twisted I had mechanisms to test scheduling. When I used asyncio I wrote a dedicated executor to allow time travel, used mostly for testing.
I would appreciate any ideas on this. Mostly:
- What would be a good way to design tasks that deal with the passage of time?
- Is there a way to wait for an executor to run until no more progress can be made?
Thanks!