nd_300/diagnostics/
ping.rs1use super::util;
9
10#[derive(Debug, Clone)]
12pub struct PingStats {
13 pub sent: u32,
15 pub times_ms: Vec<f64>,
17 pub packet_loss_pct: f64,
20}
21
22impl PingStats {
23 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 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 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
82pub 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
119pub 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
136pub 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 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 if let Some(pos) = line.find('%') {
194 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 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}