Skip to main content

test_better_async/
lib.rs

1//! `test-better-async`: async and timing helpers.
2//!
3//! Home of the runtime-agnostic timeout abstraction that backs
4//! `check!(fut).completes_within(..)`.
5//!
6//! # The runtime gate
7//!
8//! Timing out a future needs a runtime-provided sleep. The runtime is chosen
9//! at compile time by the mutually-exclusive `tokio`, `async-std`, and `smol`
10//! cargo features (in that priority order if more than one is on, which only
11//! happens under `--all-features`). One private function, `selected_sleep`,
12//! is the single place that `cfg`-branches on them.
13//!
14//! If *no* runtime feature is enabled, the crate still compiles: the
15//! [`RuntimeAvailable`] marker trait simply has no implementation. Because
16//! [`run_within`] is bounded `where F: RuntimeAvailable` on its *generic*
17//! future type, that bound is deferred to the call site (a bound on a concrete
18//! type would be rejected at the definition instead). The user who calls
19//! `completes_within` without a runtime feature is the one who sees the
20//! error, and the `#[diagnostic::on_unimplemented]` message on
21//! `RuntimeAvailable` points them at the feature flags.
22//!
23//! # Polling: `eventually`
24//!
25//! [`eventually`] retries a `bool`-returning probe until it passes or a
26//! deadline is reached, sleeping with exponential [`Backoff`] in between. The
27//! async form needs the same runtime gate as the timeout (its inter-probe
28//! sleep is runtime-provided), so its probe closure carries the deferred
29//! [`RuntimeAvailable`] bound. [`eventually_blocking`] is the runtime-free
30//! sibling: it sleeps with `std::thread::sleep`, so a non-async codebase can
31//! use it with no runtime feature at all.
32
33use std::fmt;
34use std::future::{Future, poll_fn};
35use std::panic::Location;
36use std::pin::{Pin, pin};
37use std::task::Poll;
38use std::time::{Duration, Instant};
39
40use test_better_core::{ErrorKind, TestError, TestResult};
41
42/// The error returned by [`run_within`] when the future outlives its limit.
43///
44/// It carries the limit it tripped, so the caller can render a message like
45/// "did not complete within 50ms".
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub struct Elapsed {
48    /// The time limit the future failed to finish inside.
49    pub limit: Duration,
50}
51
52impl fmt::Display for Elapsed {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        write!(f, "future did not complete within {:?}", self.limit)
55    }
56}
57
58impl std::error::Error for Elapsed {}
59
60/// A marker trait, implemented for every type when (and only when) a runtime
61/// feature is enabled.
62///
63/// It carries no methods. Its only job is to be a *deferred* bound on
64/// [`run_within`] and `Subject::completes_within`, so that "no runtime
65/// feature" becomes an error at the user's call site rather than at the
66/// library's definition.
67#[diagnostic::on_unimplemented(
68    message = "this async timing assertion needs a runtime, but no runtime feature is enabled",
69    note = "enable exactly one runtime feature on `test-better`: `tokio`, `async-std`, or `smol`",
70    note = "or, for `eventually`, use the runtime-free `eventually_blocking`"
71)]
72pub trait RuntimeAvailable {}
73
74#[cfg(any(feature = "tokio", feature = "async-std", feature = "smol"))]
75impl<T: ?Sized> RuntimeAvailable for T {}
76
77/// Produces a future that completes after `duration`, using whichever runtime
78/// feature is enabled.
79///
80/// The no-runtime variant returns a never-completing future. It is dead code
81/// in practice: [`run_within`]'s `RuntimeAvailable` bound is unsatisfiable
82/// without a runtime feature, so it can never be reached.
83#[cfg(feature = "tokio")]
84fn selected_sleep(duration: Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> {
85    Box::pin(async move { tokio::time::sleep(duration).await })
86}
87
88#[cfg(all(feature = "async-std", not(feature = "tokio")))]
89fn selected_sleep(duration: Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> {
90    Box::pin(async move { async_std::task::sleep(duration).await })
91}
92
93#[cfg(all(feature = "smol", not(any(feature = "tokio", feature = "async-std"))))]
94fn selected_sleep(duration: Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> {
95    Box::pin(async move {
96        smol::Timer::after(duration).await;
97    })
98}
99
100#[cfg(not(any(feature = "tokio", feature = "async-std", feature = "smol")))]
101fn selected_sleep(_duration: Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> {
102    Box::pin(std::future::pending())
103}
104
105/// Polls `fut` and `timer` together; whichever resolves first wins. `fut` is
106/// polled first on every wake-up, so a future that is ready *now* beats a
107/// timer that is also ready now.
108///
109/// This is the runtime-agnostic core: it asks nothing of the runtime beyond
110/// the `timer` future it is handed, which is why it can be unit-tested with
111/// hand-built futures and no runtime at all.
112async fn race<F, S>(fut: F, timer: S) -> Result<F::Output, ()>
113where
114    F: Future,
115    S: Future<Output = ()>,
116{
117    let mut fut = pin!(fut);
118    let mut timer = pin!(timer);
119    poll_fn(move |cx| {
120        if let Poll::Ready(output) = fut.as_mut().poll(cx) {
121            return Poll::Ready(Ok(output));
122        }
123        if timer.as_mut().poll(cx).is_ready() {
124            return Poll::Ready(Err(()));
125        }
126        Poll::Pending
127    })
128    .await
129}
130
131/// Awaits `fut`, but gives up after `limit`.
132///
133/// Returns the future's output if it finishes in time, or [`Elapsed`] if the
134/// limit is reached first. The runtime is selected at compile time by the
135/// enabled feature; with none enabled, the `F: RuntimeAvailable` bound is what
136/// turns a call into the [`RuntimeAvailable`] diagnostic.
137pub async fn run_within<F>(limit: Duration, fut: F) -> Result<F::Output, Elapsed>
138where
139    F: Future + RuntimeAvailable,
140{
141    match race(fut, selected_sleep(limit)).await {
142        Ok(output) => Ok(output),
143        Err(()) => Err(Elapsed { limit }),
144    }
145}
146
147/// The inter-probe sleep schedule for [`eventually`] and
148/// [`eventually_blocking`].
149///
150/// After a failed probe the helper naps, then doubles (or multiplies by
151/// `factor`) the nap each time, never exceeding `ceiling`. The final nap before
152/// the deadline is additionally clamped to the remaining time, so the helper
153/// never oversleeps past its own timeout and skips a last probe.
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
155pub struct Backoff {
156    /// The first inter-probe nap.
157    pub initial: Duration,
158    /// The longest a single inter-probe nap may grow to.
159    pub ceiling: Duration,
160    /// The multiplier applied to the nap after each failed probe. A `factor`
161    /// of 1 makes the nap constant; values below 1 are not possible.
162    pub factor: u32,
163}
164
165impl Default for Backoff {
166    /// Starts at 1ms, doubles, and caps at 100ms: tight enough that a condition
167    /// which becomes true is noticed almost immediately, loose enough that a
168    /// multi-second wait is a few dozen probes, not thousands.
169    fn default() -> Self {
170        Self {
171            initial: Duration::from_millis(1),
172            ceiling: Duration::from_millis(100),
173            factor: 2,
174        }
175    }
176}
177
178impl Backoff {
179    /// The nap that follows `previous`, grown by `factor` and clamped to
180    /// `ceiling`.
181    fn next_nap(&self, previous: Duration) -> Duration {
182        previous.saturating_mul(self.factor).min(self.ceiling)
183    }
184}
185
186/// Builds the failure for an `eventually` probe that never passed: a
187/// [`ErrorKind::Timeout`] carrying how long it waited and how many times it
188/// probed.
189fn eventually_error(
190    timeout: Duration,
191    elapsed: Duration,
192    probes: u32,
193    location: &'static Location<'static>,
194) -> TestError {
195    let plural = if probes == 1 { "" } else { "s" };
196    TestError::new(ErrorKind::Timeout)
197        .with_message(format!(
198            "condition was not met within {timeout:?}: gave up after {probes} probe{plural} \
199             over {elapsed:?}"
200        ))
201        .with_location(location)
202}
203
204/// Retries `probe` until it returns `true` or `timeout` elapses, awaiting an
205/// inter-probe sleep that grows per `backoff`.
206async fn eventually_impl<F, Fut>(
207    timeout: Duration,
208    backoff: Backoff,
209    mut probe: F,
210    location: &'static Location<'static>,
211) -> TestResult
212where
213    F: FnMut() -> Fut,
214    Fut: Future<Output = bool>,
215{
216    let start = Instant::now();
217    let mut nap = backoff.initial;
218    let mut probes: u32 = 0;
219    loop {
220        probes = probes.saturating_add(1);
221        if probe().await {
222            return Ok(());
223        }
224        let elapsed = start.elapsed();
225        if elapsed >= timeout {
226            return Err(eventually_error(timeout, elapsed, probes, location));
227        }
228        selected_sleep(nap.min(timeout - elapsed)).await;
229        nap = backoff.next_nap(nap);
230    }
231}
232
233/// The `std::thread::sleep` twin of [`eventually_impl`], for the blocking API.
234fn eventually_blocking_impl<F>(
235    timeout: Duration,
236    backoff: Backoff,
237    mut probe: F,
238    location: &'static Location<'static>,
239) -> TestResult
240where
241    F: FnMut() -> bool,
242{
243    let start = Instant::now();
244    let mut nap = backoff.initial;
245    let mut probes: u32 = 0;
246    loop {
247        probes = probes.saturating_add(1);
248        if probe() {
249            return Ok(());
250        }
251        let elapsed = start.elapsed();
252        if elapsed >= timeout {
253            return Err(eventually_error(timeout, elapsed, probes, location));
254        }
255        std::thread::sleep(nap.min(timeout - elapsed));
256        nap = backoff.next_nap(nap);
257    }
258}
259
260/// Retries `probe` until it resolves to `true`, or fails once `timeout`
261/// elapses.
262///
263/// This is the cure for the `sleep + assert` flake: instead of guessing how
264/// long an asynchronous effect takes, state the *outcome* and a generous upper
265/// bound. The probe is re-run on an exponential [`Backoff::default`] schedule
266/// and the call returns the moment it passes, so the common case (the
267/// condition is already true, or becomes true quickly) costs almost nothing.
268///
269/// Like [`run_within`], the inter-probe sleep is runtime-provided, so the probe
270/// closure carries the deferred [`RuntimeAvailable`] bound: calling
271/// `eventually` with no runtime feature enabled is a compile error at the call
272/// site. Use [`eventually_blocking`] from non-async code.
273///
274/// The method is `#[track_caller]` and returns a future rather than being
275/// `async` itself, so the failure points at the `eventually` call, not at the
276/// `.await`.
277///
278/// ```ignore
279/// use std::time::Duration;
280/// use test_better::prelude::*;
281///
282/// # async fn run() -> TestResult {
283/// eventually(Duration::from_secs(2), || async { queue_is_drained().await }).await?;
284/// # Ok(())
285/// # }
286/// ```
287#[track_caller]
288pub fn eventually<F, Fut>(timeout: Duration, probe: F) -> impl Future<Output = TestResult>
289where
290    F: FnMut() -> Fut + RuntimeAvailable,
291    Fut: Future<Output = bool>,
292{
293    eventually_impl(timeout, Backoff::default(), probe, Location::caller())
294}
295
296/// [`eventually`] with an explicit [`Backoff`] schedule instead of the default.
297#[track_caller]
298pub fn eventually_with<F, Fut>(
299    timeout: Duration,
300    backoff: Backoff,
301    probe: F,
302) -> impl Future<Output = TestResult>
303where
304    F: FnMut() -> Fut + RuntimeAvailable,
305    Fut: Future<Output = bool>,
306{
307    eventually_impl(timeout, backoff, probe, Location::caller())
308}
309
310/// The runtime-free [`eventually`]: retries `probe` until it returns `true` or
311/// `timeout` elapses, sleeping between attempts with `std::thread::sleep`.
312///
313/// Because it never touches an async runtime, it needs no runtime feature and
314/// can be called from an ordinary `#[test]`.
315///
316/// ```
317/// use std::time::Duration;
318/// use test_better_async::eventually_blocking;
319/// use test_better_core::TestResult;
320///
321/// # fn main() -> TestResult {
322/// let mut polls = 0;
323/// // The probe passes on its third call, so polling stops there.
324/// eventually_blocking(Duration::from_secs(1), || {
325///     polls += 1;
326///     polls >= 3
327/// })?;
328/// # Ok(())
329/// # }
330/// ```
331#[track_caller]
332pub fn eventually_blocking<F>(timeout: Duration, probe: F) -> TestResult
333where
334    F: FnMut() -> bool,
335{
336    eventually_blocking_impl(timeout, Backoff::default(), probe, Location::caller())
337}
338
339/// [`eventually_blocking`] with an explicit [`Backoff`] schedule instead of the
340/// default.
341#[track_caller]
342pub fn eventually_blocking_with<F>(timeout: Duration, backoff: Backoff, probe: F) -> TestResult
343where
344    F: FnMut() -> bool,
345{
346    eventually_blocking_impl(timeout, backoff, probe, Location::caller())
347}
348
349#[cfg(test)]
350mod tests {
351    use super::*;
352    use std::cell::Cell;
353    use std::future::{pending, ready};
354
355    use test_better_matchers::{check, eq, ge, is_true};
356
357    #[test]
358    fn race_returns_the_future_output_when_it_is_ready_first() -> TestResult {
359        // `pending` never resolves, so the only way `race` returns `Ok` is by
360        // polling `fut` to completion.
361        let outcome = pollster::block_on(race(ready(7), pending::<()>()));
362        check!(outcome).satisfies(eq(Ok(7)))
363    }
364
365    #[test]
366    fn race_reports_the_timer_when_the_future_is_not_ready() -> TestResult {
367        let outcome = pollster::block_on(race(pending::<i32>(), ready(())));
368        check!(outcome).satisfies(eq(Err(())))
369    }
370
371    #[test]
372    fn race_prefers_the_future_when_both_are_ready() -> TestResult {
373        // Both arms are ready immediately; `fut` is polled first, so it wins.
374        let outcome = pollster::block_on(race(ready("done"), ready(())));
375        check!(outcome).satisfies(eq(Ok("done")))
376    }
377
378    #[test]
379    fn backoff_grows_by_factor_and_stops_at_the_ceiling() -> TestResult {
380        let backoff = Backoff {
381            initial: Duration::from_millis(10),
382            ceiling: Duration::from_millis(25),
383            factor: 2,
384        };
385        check!(backoff.next_nap(Duration::from_millis(10)))
386            .satisfies(eq(Duration::from_millis(20)))?;
387        // 20 * 2 = 40, clamped down to the 25ms ceiling.
388        check!(backoff.next_nap(Duration::from_millis(20))).satisfies(eq(Duration::from_millis(25)))
389    }
390
391    #[test]
392    fn eventually_blocking_stops_as_soon_as_the_probe_passes() -> TestResult {
393        let calls = Cell::new(0u32);
394        eventually_blocking(Duration::from_secs(5), || {
395            calls.set(calls.get() + 1);
396            calls.get() >= 3
397        })?;
398        // The probe passed on its third call; polling must not continue past it.
399        check!(calls.get()).satisfies(eq(3))
400    }
401
402    #[test]
403    fn eventually_blocking_passes_immediately_when_the_probe_is_already_true() -> TestResult {
404        let calls = Cell::new(0u32);
405        eventually_blocking(Duration::from_secs(5), || {
406            calls.set(calls.get() + 1);
407            true
408        })?;
409        check!(calls.get()).satisfies(eq(1))
410    }
411
412    #[test]
413    fn eventually_blocking_reports_elapsed_and_probe_count_on_timeout() -> TestResult {
414        let calls = Cell::new(0u32);
415        let error = eventually_blocking_with(
416            Duration::from_millis(40),
417            Backoff {
418                initial: Duration::from_millis(5),
419                ceiling: Duration::from_millis(5),
420                factor: 2,
421            },
422            || {
423                calls.set(calls.get() + 1);
424                false
425            },
426        )
427        .expect_err("a probe that is never true must time out");
428        let rendered = error.to_string();
429        check!(rendered.contains("was not met within")).satisfies(is_true())?;
430        // The message names how many times it probed; with a 5ms nap inside a
431        // 40ms budget that is at least a couple of attempts.
432        check!(calls.get()).satisfies(ge(2))?;
433        check!(rendered.contains(&format!("{} probe", calls.get()))).satisfies(is_true())
434    }
435
436    #[test]
437    fn eventually_blocking_failure_kind_is_timeout() -> TestResult {
438        let error = eventually_blocking(Duration::from_millis(1), || false)
439            .expect_err("an always-false probe times out");
440        check!(error.kind).satisfies(eq(ErrorKind::Timeout))
441    }
442}