Skip to main content

dev_async/
lib.rs

1//! # dev-async
2//!
3//! Async-specific validation for Rust. Deadlocks, task leaks, hung
4//! futures, graceful shutdown. Part of the `dev-*` verification suite.
5//!
6//! Async Rust fails in subtle ways that synchronous unit tests can't
7//! catch: a future that never completes, a task that gets dropped
8//! without cleanup, a shutdown that hangs because one worker is stuck
9//! in a blocking call. `dev-async` provides primitives for catching
10//! these issues programmatically.
11//!
12//! ## Quick example
13//!
14//! Run a future with a hard timeout. If it doesn't finish in time, you
15//! get a `Fail` verdict, not a hang.
16//!
17//! ```no_run
18//! use dev_async::run_with_timeout;
19//! use std::time::Duration;
20//!
21//! # async fn example() {
22//! let check = run_with_timeout(
23//!     "user_login",
24//!     Duration::from_secs(2),
25//!     async { do_login().await }
26//! ).await;
27//!
28//! // check is a CheckResult: Pass if completed, Fail+Error if timed out.
29//! # }
30//! # async fn do_login() {}
31//! ```
32
33#![cfg_attr(docsrs, feature(doc_cfg))]
34#![warn(missing_docs)]
35#![warn(rust_2018_idioms)]
36
37use std::future::Future;
38use std::time::{Duration, Instant};
39
40use dev_report::{CheckResult, Severity};
41
42/// Run a future with a hard timeout. Produces a `CheckResult`.
43///
44/// If the future completes before the timeout, the verdict is `Pass`
45/// and the duration is recorded.
46///
47/// If the future does not complete in time, the verdict is `Fail` with
48/// severity `Error`. The future itself is dropped (cancelled) when the
49/// timeout expires.
50pub async fn run_with_timeout<F, T>(
51    name: impl Into<String>,
52    timeout: Duration,
53    fut: F,
54) -> CheckResult
55where
56    F: Future<Output = T>,
57{
58    let name = name.into();
59    let started = Instant::now();
60    match tokio::time::timeout(timeout, fut).await {
61        Ok(_value) => {
62            let elapsed = started.elapsed();
63            CheckResult::pass(format!("async::{name}"))
64                .with_duration_ms(elapsed.as_millis() as u64)
65        }
66        Err(_elapsed) => CheckResult::fail(format!("async::{name}"), Severity::Error)
67            .with_detail(format!("future did not complete within {timeout:?}")),
68    }
69}
70
71/// Verify that all spawned tasks finish within the given timeout.
72///
73/// Pass a vector of `JoinHandle`s. Returns one `CheckResult` per task,
74/// plus an aggregate verdict.
75pub async fn join_all_with_timeout<T>(
76    name: impl Into<String>,
77    timeout: Duration,
78    handles: Vec<tokio::task::JoinHandle<T>>,
79) -> Vec<CheckResult> {
80    let name = name.into();
81    let mut results = Vec::with_capacity(handles.len());
82    for (i, h) in handles.into_iter().enumerate() {
83        let task_name = format!("async::{name}::task{i}");
84        let started = Instant::now();
85        match tokio::time::timeout(timeout, h).await {
86            Ok(Ok(_)) => {
87                let elapsed = started.elapsed();
88                results.push(
89                    CheckResult::pass(task_name).with_duration_ms(elapsed.as_millis() as u64),
90                );
91            }
92            Ok(Err(join_err)) => {
93                results.push(
94                    CheckResult::fail(task_name, Severity::Critical)
95                        .with_detail(format!("task panicked or was cancelled: {join_err}")),
96                );
97            }
98            Err(_) => {
99                results.push(
100                    CheckResult::fail(task_name, Severity::Error)
101                        .with_detail(format!("task did not complete within {timeout:?}")),
102                );
103            }
104        }
105    }
106    results
107}
108
109/// A trait for any async harness that produces a verdict via a future.
110pub trait AsyncCheck {
111    /// Output of the check. Typically `CheckResult`.
112    type Output;
113    /// The future returned by `run`.
114    type Fut: Future<Output = Self::Output>;
115    /// Run the check.
116    fn run(self) -> Self::Fut;
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[tokio::test]
124    async fn timeout_pass_fast_future() {
125        let check = run_with_timeout("fast", Duration::from_millis(500), async {}).await;
126        assert!(matches!(check.verdict, dev_report::Verdict::Pass));
127    }
128
129    #[tokio::test]
130    async fn timeout_fail_slow_future() {
131        let check = run_with_timeout("slow", Duration::from_millis(10), async {
132            tokio::time::sleep(Duration::from_millis(200)).await;
133        })
134        .await;
135        assert!(matches!(check.verdict, dev_report::Verdict::Fail));
136    }
137
138    #[tokio::test]
139    async fn join_all_basic() {
140        let h1 = tokio::spawn(async { 1 });
141        let h2 = tokio::spawn(async { 2 });
142        let results = join_all_with_timeout("g", Duration::from_secs(1), vec![h1, h2]).await;
143        assert_eq!(results.len(), 2);
144        assert!(results
145            .iter()
146            .all(|r| matches!(r.verdict, dev_report::Verdict::Pass)));
147    }
148}