use futures::FutureExt;
use std::time::Duration;
use thiserror::Error;
#[derive(Debug, Error, Clone, PartialEq, Eq)]
pub enum TestClockError {
#[error(
"TestClock requires a paused Tokio runtime; use \
`#[tokio::test(start_paused = true)]` or `tokio::time::pause()`"
)]
RuntimeNotPaused,
}
#[derive(Debug, Error)]
pub enum SettleSchedulerError {
#[error(transparent)]
Clock(#[from] TestClockError),
#[error("scheduler did not settle after {max_yields} yields")]
DidNotSettle { max_yields: usize },
#[error("observation failed")]
ObservationFailed {
#[source]
source: Box<dyn std::error::Error + Send + Sync>,
},
}
#[derive(Debug)]
pub struct TestClock {
_private: (),
}
impl TestClock {
pub async fn new() -> Result<Self, TestClockError> {
verify_paused().await?;
Ok(Self { _private: () })
}
pub async fn advance(&self, duration: Duration) -> Result<(), TestClockError> {
verify_paused().await?;
tokio::time::advance(duration).await;
Ok(())
}
pub async fn sleep(&self, duration: Duration) -> Result<(), TestClockError> {
verify_paused().await?;
tokio::time::sleep(duration).await;
Ok(())
}
pub async fn settle_scheduler<T, E, F, Fut>(mut observe: F) -> Result<T, SettleSchedulerError>
where
T: Clone + PartialEq,
E: std::error::Error + Send + Sync + 'static,
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
{
verify_paused().await?;
const MAX_YIELDS: usize = 16;
let mut last: Option<T> = None;
let mut stable_streak: usize = 0;
for turn in 0..MAX_YIELDS {
tokio::task::yield_now().await;
let value = observe()
.await
.map_err(|e| SettleSchedulerError::ObservationFailed { source: e.into() })?;
if let Some(prev) = &last {
if prev == &value {
stable_streak += 1;
} else {
stable_streak = 0;
}
}
last = Some(value.clone());
if turn >= 1 && stable_streak >= 1 {
return Ok(value);
}
}
Err(SettleSchedulerError::DidNotSettle {
max_yields: MAX_YIELDS,
})
}
}
async fn verify_paused() -> Result<(), TestClockError> {
if tokio::runtime::Handle::try_current().is_err() {
return Err(TestClockError::RuntimeNotPaused);
}
let probe = std::panic::AssertUnwindSafe(tokio::time::advance(Duration::ZERO))
.catch_unwind()
.await;
match probe {
Ok(()) => Ok(()),
Err(_) => Err(TestClockError::RuntimeNotPaused),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn new_succeeds_under_paused_runtime() {
let clock = TestClock::new()
.await
.expect("paused runtime should allow TestClock::new");
clock
.advance(Duration::from_millis(100))
.await
.expect("advance under paused runtime should succeed");
}
#[tokio::test(flavor = "current_thread")]
async fn new_fails_when_runtime_is_not_paused() {
let result = TestClock::new().await;
assert!(
matches!(result, Err(TestClockError::RuntimeNotPaused)),
"expected RuntimeNotPaused outside a paused runtime, got {result:?}"
);
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn sleep_resolves_after_advance() {
let clock = TestClock::new().await.expect("paused runtime");
let target = Duration::from_secs(60);
let sleeper = tokio::spawn(async move {
tokio::time::sleep(target).await;
});
clock.advance(target).await.expect("advance");
sleeper.await.expect("sleeper should complete");
}
}