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//! `expect!(fut).to_complete_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//! `to_complete_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::to_complete_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::{eq, expect, 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 expect!(outcome).to(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 expect!(outcome).to(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 expect!(outcome).to(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 expect!(backoff.next_nap(Duration::from_millis(10))).to(eq(Duration::from_millis(20)))?;
386 // 20 * 2 = 40, clamped down to the 25ms ceiling.
387 expect!(backoff.next_nap(Duration::from_millis(20))).to(eq(Duration::from_millis(25)))
388 }
389
390 #[test]
391 fn eventually_blocking_stops_as_soon_as_the_probe_passes() -> TestResult {
392 let calls = Cell::new(0u32);
393 eventually_blocking(Duration::from_secs(5), || {
394 calls.set(calls.get() + 1);
395 calls.get() >= 3
396 })?;
397 // The probe passed on its third call; polling must not continue past it.
398 expect!(calls.get()).to(eq(3))
399 }
400
401 #[test]
402 fn eventually_blocking_passes_immediately_when_the_probe_is_already_true() -> TestResult {
403 let calls = Cell::new(0u32);
404 eventually_blocking(Duration::from_secs(5), || {
405 calls.set(calls.get() + 1);
406 true
407 })?;
408 expect!(calls.get()).to(eq(1))
409 }
410
411 #[test]
412 fn eventually_blocking_reports_elapsed_and_probe_count_on_timeout() -> TestResult {
413 let calls = Cell::new(0u32);
414 let error = eventually_blocking_with(
415 Duration::from_millis(40),
416 Backoff {
417 initial: Duration::from_millis(5),
418 ceiling: Duration::from_millis(5),
419 factor: 2,
420 },
421 || {
422 calls.set(calls.get() + 1);
423 false
424 },
425 )
426 .expect_err("a probe that is never true must time out");
427 let rendered = error.to_string();
428 expect!(rendered.contains("was not met within")).to(is_true())?;
429 // The message names how many times it probed; with a 5ms nap inside a
430 // 40ms budget that is at least a couple of attempts.
431 expect!(calls.get()).to(ge(2))?;
432 expect!(rendered.contains(&format!("{} probe", calls.get()))).to(is_true())
433 }
434
435 #[test]
436 fn eventually_blocking_failure_kind_is_timeout() -> TestResult {
437 let error = eventually_blocking(Duration::from_millis(1), || false)
438 .expect_err("an always-false probe times out");
439 expect!(error.kind).to(eq(ErrorKind::Timeout))
440 }
441}