Skip to main content

nd_300/diagnostics/
util.rs

1//! Timeout wrappers for diagnostic subprocess calls and DNS resolution.
2//!
3//! Diagnostics frequently shell out (`ping`, `netsh`, `ipconfig`, `scutil`,
4//! `nmcli`, …) or resolve hostnames. On a *broken* network — the exact
5//! condition the tool exists to diagnose — those calls can block far longer
6//! than is useful, or effectively hang (a black-holed resolver, a `ping`
7//! waiting on a dead gateway, a subprocess stuck on a wedged service). This
8//! module bounds every such call with `tokio::time::timeout` so a degraded
9//! network surfaces a clean "unreachable/empty" result instead of a stall.
10//!
11//! The contract mirrors `actions/fix/cmd.rs::run_cmd`: take a
12//! [`tokio::process::Command`] by value, race it against a timeout, and on
13//! either a timeout *or* a spawn error return `None`. `None` is intentionally
14//! byte-identical to the pre-existing `.ok()` == `None` / spawn-failure path
15//! every caller already handles, so wrapping a call never changes behavior on
16//! a healthy network — it only caps the worst case on a broken one.
17
18use std::time::Duration;
19use tokio::process::Command;
20
21/// Budget for DNS resolution (`tokio::net::lookup_host`). Resolution should be
22/// fast on a healthy network; a black-holed resolver is exactly what we cap.
23pub const RESOLVE: Duration = Duration::from_secs(5);
24
25/// Budget for fast, local subprocess calls that query OS state (registry,
26/// `netsh` show, `ifconfig`, `netstat`, `scutil`, `nmcli`, `ip`, `arp`,
27/// `route`, …). These don't touch the network and should return promptly.
28pub const QUICK: Duration = Duration::from_secs(5);
29
30/// Budget for subprocess calls that themselves perform network I/O and can
31/// legitimately take several seconds (`ping`, `nslookup`, `dig`,
32/// `resolvectl`). Wider than [`QUICK`] so we don't truncate a slow-but-working
33/// probe.
34pub const SLOW: Duration = Duration::from_secs(10);
35
36/// Budget for single long-running probe subprocesses that legitimately take
37/// tens of seconds end-to-end (`tracert`/`traceroute`/`tracepath`, sustained
38/// multi-count `ping` bursts). These are still individually bounded so a
39/// wedged probe can't hold the whole run hostage.
40pub const TRACE: Duration = Duration::from_secs(60);
41
42/// Budget for heavyweight but local macOS inventory tools such as
43/// `system_profiler`. These can legitimately take several seconds even on a
44/// healthy machine, especially on the first invocation after boot.
45pub const PROFILE: Duration = Duration::from_secs(30);
46
47/// Per-command budget for an N-probe ping burst: 2s reply timeout per probe
48/// plus 4s of process/DNS overhead. The fixed [`SLOW`] cap silently truncates
49/// bursts of more than ~3 probes (e.g. a 6-probe burst can take ~12s on a
50/// degraded link), flipping a slow-but-working target to "unreachable" —
51/// size the budget to the burst instead.
52pub fn ping_budget(count: u32) -> Duration {
53    Duration::from_secs(2 * count as u64 + 4)
54}
55
56/// Retry an async probe until it returns `Some`, up to `attempts` total tries
57/// with a fixed `delay` between them.
58///
59/// For first-success probes (DNS lookups, TCP connects) where a transient
60/// blip shouldn't flip a verdict. Ping bursts intentionally do NOT use this —
61/// they measure loss across a fixed sample count instead.
62pub async fn retry_probe<T, F, Fut>(attempts: u32, delay: Duration, mut op: F) -> Option<T>
63where
64    F: FnMut() -> Fut,
65    Fut: std::future::Future<Output = Option<T>>,
66{
67    for attempt in 0..attempts {
68        if let Some(v) = op().await {
69            return Some(v);
70        }
71        if attempt + 1 < attempts {
72            tokio::time::sleep(delay).await;
73        }
74    }
75    None
76}
77
78/// Abort `handle` and return its value if it finished before the abort,
79/// else `fallback`.
80///
81/// Used by the diagnostics deadline harvester: when the wall-clock cap fires,
82/// each still-running check is aborted and replaced with a fallback result so
83/// the user always gets a complete (if partially degraded) result set.
84pub async fn harvest_or<T>(handle: tokio::task::JoinHandle<T>, fallback: T) -> T {
85    handle.abort();
86    match handle.await {
87        Ok(v) => v,
88        Err(_) => fallback,
89    }
90}
91
92/// Run a subprocess with a timeout.
93///
94/// Consumes `cmd`, races `cmd.output()` against `dur`, and returns:
95/// - `Some(output)` if the process finished within `dur` — **regardless of its
96///   own exit code** (callers inspect `output.status` exactly as before).
97/// - `None` on timeout **or** spawn error (binary missing, OS-level failure).
98///
99/// The `None` case is identical to today's bare `cmd.output().await.ok()`
100/// returning `None`, so existing fallback branches handle it unchanged.
101pub async fn run_with_timeout(mut cmd: Command, dur: Duration) -> Option<std::process::Output> {
102    // Dropping `Command::output()` on timeout otherwise leaves the spawned OS
103    // process running in the background. Diagnostics are often timed out
104    // precisely because a platform utility is wedged, so guarantee cleanup.
105    cmd.kill_on_drop(true);
106    match tokio::time::timeout(dur, cmd.output()).await {
107        Ok(Ok(output)) => Some(output),
108        // Spawn / run error (e.g. binary not found) — same as `.ok()` == None.
109        Ok(Err(_)) => None,
110        // Timed out — treat as an unreachable/empty result.
111        Err(_) => None,
112    }
113}
114
115/// Resolve a host:port string with a timeout.
116///
117/// Races `tokio::net::lookup_host(addr)` against `dur`. Returns:
118/// - `Some(addrs)` with the collected [`SocketAddr`]s on success.
119/// - `None` on resolver error **or** timeout.
120///
121/// The `None` case matches the resolver's pre-existing `Err(_)` arm, so callers
122/// can map it onto their existing failure path verbatim.
123///
124/// [`SocketAddr`]: std::net::SocketAddr
125pub async fn lookup_host_timeout(addr: String, dur: Duration) -> Option<Vec<std::net::SocketAddr>> {
126    match tokio::time::timeout(dur, tokio::net::lookup_host(addr)).await {
127        Ok(Ok(addrs)) => Some(addrs.collect()),
128        // Resolver error — same as the bare `lookup_host(..).await` Err arm.
129        Ok(Err(_)) => None,
130        // Timed out (black-holed resolver) — treat as a resolution failure.
131        Err(_) => None,
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    /// A command that completes well within budget returns `Some`.
140    #[tokio::test]
141    async fn fast_command_returns_some() {
142        #[cfg(windows)]
143        let mut cmd = {
144            let mut c = Command::new("cmd");
145            c.args(["/C", "exit", "0"]);
146            c
147        };
148        #[cfg(unix)]
149        let mut cmd = Command::new("true");
150
151        // Suppress unused-mut on the unix branch where we don't push args.
152        let _ = &mut cmd;
153
154        let out = run_with_timeout(cmd, QUICK).await;
155        assert!(out.is_some(), "fast command should finish within budget");
156    }
157
158    /// A command that sleeps longer than the (tiny) budget returns `None`.
159    #[tokio::test]
160    async fn slow_command_times_out_to_none() {
161        #[cfg(windows)]
162        let cmd = {
163            // `timeout` is interactive/redirect-sensitive on Windows; use
164            // `ping -n 3 127.0.0.1` which takes ~2s, well over our 1ms budget.
165            let mut c = Command::new("cmd");
166            c.args(["/C", "ping", "-n", "3", "127.0.0.1"]);
167            c
168        };
169        #[cfg(unix)]
170        let cmd = {
171            let mut c = Command::new("sleep");
172            c.arg("2");
173            c
174        };
175
176        let out = run_with_timeout(cmd, Duration::from_millis(1)).await;
177        assert!(out.is_none(), "command exceeding budget should yield None");
178    }
179
180    #[cfg(unix)]
181    #[tokio::test]
182    async fn timed_out_command_is_killed() {
183        let pid_file = std::env::temp_dir().join(format!(
184            "nd300-timeout-child-{}-{}.pid",
185            std::process::id(),
186            std::time::SystemTime::now()
187                .duration_since(std::time::UNIX_EPOCH)
188                .unwrap_or_default()
189                .as_nanos()
190        ));
191        let script = format!("echo $$ > '{}'; exec sleep 10", pid_file.display());
192        let mut cmd = Command::new("sh");
193        cmd.args(["-c", &script]);
194        assert!(run_with_timeout(cmd, Duration::from_millis(200))
195            .await
196            .is_none());
197        let pid: i32 = tokio::fs::read_to_string(&pid_file)
198            .await
199            .expect("child should write pid before timeout")
200            .trim()
201            .parse()
202            .unwrap();
203        for _ in 0..20 {
204            // Signal 0 checks existence without changing process state.
205            if unsafe { libc::kill(pid, 0) } == -1 {
206                break;
207            }
208            tokio::time::sleep(Duration::from_millis(25)).await;
209        }
210        assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
211        let _ = tokio::fs::remove_file(pid_file).await;
212    }
213
214    /// A missing binary returns `None` (spawn error), not a hang.
215    #[tokio::test]
216    async fn missing_binary_returns_none() {
217        let cmd = Command::new("nd300-definitely-not-a-real-binary-xyz");
218        let out = run_with_timeout(cmd, QUICK).await;
219        assert!(out.is_none(), "missing binary should yield None");
220    }
221
222    /// A normal resolve of localhost succeeds within budget.
223    #[tokio::test]
224    async fn localhost_resolves_to_some() {
225        let addrs = lookup_host_timeout("localhost:80".to_string(), RESOLVE).await;
226        assert!(
227            addrs.is_some_and(|v| !v.is_empty()),
228            "localhost:80 should resolve to at least one address"
229        );
230    }
231
232    // The resolver timeout-elapse → `None` path is covered deterministically by
233    // `slow_command_times_out_to_none` above: both `run_with_timeout` and
234    // `lookup_host_timeout` wrap the same `tokio::time::timeout`, so the
235    // elapse → `None` branch is identical. A prior test raced a real
236    // `lookup_host("example.com")` against a 1ns budget, which is
237    // non-deterministic — `tokio::time::timeout` polls the inner future first, so
238    // a fast/cached resolve can return `Some` before the timer fires (observed
239    // flaking under concurrent test load). It was removed to keep the release
240    // `cargo test` gate stable.
241
242    use std::sync::atomic::{AtomicU32, Ordering};
243    use std::sync::Arc;
244
245    #[tokio::test]
246    async fn retry_probe_first_success_calls_once() {
247        let calls = Arc::new(AtomicU32::new(0));
248        let c = calls.clone();
249        let out = retry_probe(3, Duration::from_millis(1), move || {
250            let c = c.clone();
251            async move {
252                c.fetch_add(1, Ordering::SeqCst);
253                Some(42)
254            }
255        })
256        .await;
257        assert_eq!(out, Some(42));
258        assert_eq!(calls.load(Ordering::SeqCst), 1);
259    }
260
261    #[tokio::test]
262    async fn retry_probe_retries_then_succeeds() {
263        let calls = Arc::new(AtomicU32::new(0));
264        let c = calls.clone();
265        let out = retry_probe(3, Duration::from_millis(1), move || {
266            let c = c.clone();
267            async move {
268                let n = c.fetch_add(1, Ordering::SeqCst);
269                if n == 0 {
270                    None
271                } else {
272                    Some("ok")
273                }
274            }
275        })
276        .await;
277        assert_eq!(out, Some("ok"));
278        assert_eq!(calls.load(Ordering::SeqCst), 2);
279    }
280
281    #[tokio::test]
282    async fn retry_probe_exhausts_to_none() {
283        let calls = Arc::new(AtomicU32::new(0));
284        let c = calls.clone();
285        let out: Option<u8> = retry_probe(2, Duration::from_millis(1), move || {
286            let c = c.clone();
287            async move {
288                c.fetch_add(1, Ordering::SeqCst);
289                None
290            }
291        })
292        .await;
293        assert_eq!(out, None);
294        assert_eq!(calls.load(Ordering::SeqCst), 2);
295    }
296
297    #[test]
298    fn ping_budget_scales_with_count() {
299        assert_eq!(ping_budget(1), Duration::from_secs(6));
300        assert_eq!(ping_budget(6), Duration::from_secs(16));
301        assert_eq!(ping_budget(30), Duration::from_secs(64));
302    }
303
304    #[tokio::test]
305    async fn harvest_or_returns_finished_value() {
306        let handle = tokio::spawn(async { 42 });
307        // Give the task a chance to complete before harvesting.
308        tokio::task::yield_now().await;
309        let v = harvest_or(handle, 0).await;
310        assert_eq!(v, 42);
311    }
312
313    #[tokio::test]
314    async fn harvest_or_falls_back_for_pending() {
315        let handle = tokio::spawn(std::future::pending::<u32>());
316        let v = harvest_or(handle, 7).await;
317        assert_eq!(v, 7);
318    }
319}