Skip to main content

nd_300/diagnostics/
ping.rs

1//! Shared ping invocation and output parsing.
2//!
3//! The same ping command construction and reply/loss parsing used to live in
4//! three places (`gateway`, `latency`, `bufferbloat`) with slight drift. This
5//! module is the single home: build args per platform, run with a budget
6//! sized to the probe count, and parse replies into [`PingStats`].
7
8use super::util;
9
10/// Parsed result of one ping burst.
11#[derive(Debug, Clone)]
12pub struct PingStats {
13    /// Probes sent (the `-n`/`-c` count requested).
14    pub sent: u32,
15    /// Round-trip times of the replies that came back, in milliseconds.
16    pub times_ms: Vec<f64>,
17    /// Packet loss percentage — parsed from the summary line when present,
18    /// otherwise computed from `sent` vs replies.
19    pub packet_loss_pct: f64,
20}
21
22impl PingStats {
23    /// An all-lost burst (used when the subprocess fails or times out).
24    pub fn all_lost(sent: u32) -> Self {
25        Self {
26            sent,
27            times_ms: Vec::new(),
28            packet_loss_pct: 100.0,
29        }
30    }
31
32    pub fn received(&self) -> u32 {
33        self.times_ms.len() as u32
34    }
35
36    pub fn avg_ms(&self) -> Option<f64> {
37        if self.times_ms.is_empty() {
38            None
39        } else {
40            Some(self.times_ms.iter().sum::<f64>() / self.times_ms.len() as f64)
41        }
42    }
43
44    pub fn min_ms(&self) -> Option<f64> {
45        self.times_ms.iter().cloned().reduce(f64::min)
46    }
47
48    pub fn max_ms(&self) -> Option<f64> {
49        self.times_ms.iter().cloned().reduce(f64::max)
50    }
51
52    /// Average of absolute differences between consecutive replies — the same
53    /// jitter definition the latency module has always reported.
54    pub fn jitter_ms(&self) -> Option<f64> {
55        if self.times_ms.len() > 1 {
56            let diffs: Vec<f64> = self
57                .times_ms
58                .windows(2)
59                .map(|w| (w[1] - w[0]).abs())
60                .collect();
61            Some(diffs.iter().sum::<f64>() / diffs.len() as f64)
62        } else {
63            None
64        }
65    }
66
67    /// Combine two bursts against the same target (e.g. a confirmation burst
68    /// after a fully-lost first burst). Loss is recomputed from the combined
69    /// counts.
70    pub fn merged_with(mut self, other: PingStats) -> PingStats {
71        self.sent += other.sent;
72        self.times_ms.extend(other.times_ms);
73        self.packet_loss_pct = if self.sent == 0 {
74            0.0
75        } else {
76            (self.sent.saturating_sub(self.received())) as f64 / self.sent as f64 * 100.0
77        };
78        self
79    }
80}
81
82/// Platform-specific ping arguments for `count` probes with a 2s per-reply
83/// timeout. Moved verbatim from the latency module.
84pub fn ping_args(host: &str, count: u32) -> Vec<String> {
85    #[cfg(windows)]
86    {
87        vec![
88            "-n".to_string(),
89            count.to_string(),
90            "-w".to_string(),
91            "2000".to_string(),
92            host.to_string(),
93        ]
94    }
95
96    #[cfg(target_os = "macos")]
97    {
98        vec![
99            "-c".to_string(),
100            count.to_string(),
101            "-W".to_string(),
102            "2000".to_string(),
103            host.to_string(),
104        ]
105    }
106
107    #[cfg(all(unix, not(target_os = "macos")))]
108    {
109        vec![
110            "-c".to_string(),
111            count.to_string(),
112            "-W".to_string(),
113            "2".to_string(),
114            host.to_string(),
115        ]
116    }
117}
118
119/// Run a ping burst of `count` probes against `host`.
120///
121/// Returns the stdout transcript when the process finished and reported
122/// success (at least one reply on every supported platform), `None` on a
123/// spawn failure, timeout, or zero replies. The budget scales with the count
124/// via [`util::ping_budget`] so long bursts aren't truncated by a fixed cap.
125pub async fn run_ping(host: &str, count: u32) -> Option<String> {
126    let mut cmd = tokio::process::Command::new("ping");
127    cmd.args(ping_args(host, count));
128    let output = util::run_with_timeout(cmd, util::ping_budget(count)).await?;
129    if output.status.success() {
130        Some(String::from_utf8_lossy(&output.stdout).into_owned())
131    } else {
132        None
133    }
134}
135
136/// Parse a ping transcript into [`PingStats`].
137///
138/// Reply times come from `time=`/`time<` markers (Windows/Linux/macOS); the
139/// loss percentage comes from the summary line (`25% loss`, `Lost = 1 (25%
140/// loss)`, `25% packet loss`), falling back to a count-based computation when
141/// no summary parsed.
142pub fn parse_ping(stdout: &str, sent: u32) -> PingStats {
143    let mut times: Vec<f64> = Vec::new();
144    let mut summary_loss: Option<f64> = None;
145
146    for line in stdout.lines() {
147        if let Some(time) = extract_time(line) {
148            times.push(time);
149        }
150        if (line.contains("loss") || line.contains("Lost")) && summary_loss.is_none() {
151            summary_loss = extract_loss_pct(line);
152        }
153    }
154
155    let packet_loss_pct = summary_loss.unwrap_or_else(|| {
156        if sent == 0 {
157            0.0
158        } else {
159            (sent.saturating_sub(times.len() as u32)) as f64 / sent as f64 * 100.0
160        }
161    });
162
163    PingStats {
164        sent,
165        times_ms: times,
166        packet_loss_pct,
167    }
168}
169
170fn extract_time(line: &str) -> Option<f64> {
171    // "time=1.23ms" or "time=1ms" or "time<1ms"
172    if let Some(pos) = line.find("time=") {
173        let after = &line[pos + 5..];
174        let num: String = after
175            .chars()
176            .take_while(|c| c.is_ascii_digit() || *c == '.')
177            .collect();
178        return num.parse().ok();
179    }
180    if let Some(pos) = line.find("time<") {
181        let after = &line[pos + 5..];
182        let num: String = after
183            .chars()
184            .take_while(|c| c.is_ascii_digit() || *c == '.')
185            .collect();
186        return num.parse().ok();
187    }
188    None
189}
190
191fn extract_loss_pct(line: &str) -> Option<f64> {
192    // "0% loss" or "(0% loss)" or "0% packet loss"
193    if let Some(pos) = line.find('%') {
194        // Walk backwards to find the number
195        let before = &line[..pos];
196        let num_str: String = before
197            .chars()
198            .rev()
199            .take_while(|c| c.is_ascii_digit() || *c == '.')
200            .collect::<String>()
201            .chars()
202            .rev()
203            .collect();
204        return num_str.parse().ok();
205    }
206    None
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    #[cfg(target_os = "macos")]
215    fn macos_ping_timeout_uses_milliseconds() {
216        assert_eq!(
217            ping_args("1.1.1.1", 4),
218            vec!["-c", "4", "-W", "2000", "1.1.1.1"]
219        );
220    }
221
222    #[test]
223    #[cfg(all(unix, not(target_os = "macos")))]
224    fn linux_ping_timeout_uses_seconds() {
225        assert_eq!(
226            ping_args("1.1.1.1", 4),
227            vec!["-c", "4", "-W", "2", "1.1.1.1"]
228        );
229    }
230
231    #[test]
232    #[cfg(windows)]
233    fn windows_ping_timeout_uses_milliseconds() {
234        assert_eq!(
235            ping_args("1.1.1.1", 4),
236            vec!["-n", "4", "-w", "2000", "1.1.1.1"]
237        );
238    }
239
240    const WINDOWS_TRANSCRIPT: &str = "\
241Pinging 1.1.1.1 with 32 bytes of data:
242Reply from 1.1.1.1: bytes=32 time=12ms TTL=58
243Reply from 1.1.1.1: bytes=32 time=11ms TTL=58
244Reply from 1.1.1.1: bytes=32 time=14ms TTL=58
245Request timed out.
246
247Ping statistics for 1.1.1.1:
248    Packets: Sent = 4, Received = 3, Lost = 1 (25% loss),
249Approximate round trip times in milli-seconds:
250    Minimum = 11ms, Maximum = 14ms, Average = 12ms";
251
252    const LINUX_TRANSCRIPT: &str = "\
253PING 1.1.1.1 (1.1.1.1) 56(84) bytes of data.
25464 bytes from 1.1.1.1: icmp_seq=1 ttl=58 time=12.3 ms
25564 bytes from 1.1.1.1: icmp_seq=2 ttl=58 time=11.8 ms
25664 bytes from 1.1.1.1: icmp_seq=4 ttl=58 time=13.1 ms
257
258--- 1.1.1.1 ping statistics ---
2594 packets transmitted, 3 received, 25% packet loss, time 3004ms
260rtt min/avg/max/mdev = 11.800/12.400/13.100/0.535 ms";
261
262    const MACOS_TRANSCRIPT: &str = "\
263PING 1.1.1.1 (1.1.1.1): 56 data bytes
26464 bytes from 1.1.1.1: icmp_seq=0 ttl=58 time=12.345 ms
26564 bytes from 1.1.1.1: icmp_seq=1 ttl=58 time=11.872 ms
266
267--- 1.1.1.1 ping statistics ---
2682 packets transmitted, 2 packets received, 0.0% packet loss
269round-trip min/avg/max/stddev = 11.872/12.108/12.345/0.236 ms";
270
271    #[test]
272    fn parses_windows_transcript() {
273        let stats = parse_ping(WINDOWS_TRANSCRIPT, 4);
274        assert_eq!(stats.received(), 3);
275        assert_eq!(stats.packet_loss_pct, 25.0);
276        assert_eq!(stats.min_ms(), Some(11.0));
277        assert_eq!(stats.max_ms(), Some(14.0));
278    }
279
280    #[test]
281    fn parses_linux_transcript() {
282        let stats = parse_ping(LINUX_TRANSCRIPT, 4);
283        assert_eq!(stats.received(), 3);
284        assert_eq!(stats.packet_loss_pct, 25.0);
285        assert!((stats.avg_ms().unwrap() - 12.4).abs() < 0.01);
286    }
287
288    #[test]
289    fn parses_macos_transcript() {
290        let stats = parse_ping(MACOS_TRANSCRIPT, 2);
291        assert_eq!(stats.received(), 2);
292        assert_eq!(stats.packet_loss_pct, 0.0);
293        assert!(stats.jitter_ms().unwrap() > 0.0);
294    }
295
296    #[test]
297    fn zero_reply_transcript_is_all_lost() {
298        let stats = parse_ping("Request timed out.\nRequest timed out.", 2);
299        assert_eq!(stats.received(), 0);
300        assert!(stats.avg_ms().is_none());
301        // No summary line parsed -> computed from counts.
302        assert_eq!(stats.packet_loss_pct, 100.0);
303    }
304
305    #[test]
306    fn sub_millisecond_reply_parses() {
307        let stats = parse_ping("Reply from 192.168.1.1: bytes=32 time<1ms TTL=64", 1);
308        assert_eq!(stats.received(), 1);
309        assert_eq!(stats.times_ms[0], 1.0);
310    }
311
312    #[test]
313    fn merged_bursts_recompute_loss() {
314        let first = PingStats::all_lost(3);
315        let second = parse_ping("64 bytes from 1.1.1.1: icmp_seq=1 ttl=58 time=10.0 ms", 3);
316        let merged = first.merged_with(second);
317        assert_eq!(merged.sent, 6);
318        assert_eq!(merged.received(), 1);
319        assert!((merged.packet_loss_pct - 83.333).abs() < 0.01);
320    }
321}