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}