Skip to main content

nd_300/diagnostics/
bufferbloat.rs

1use serde::Serialize;
2
3#[derive(Debug, Clone, Serialize)]
4pub struct BufferbloatResult {
5    pub unloaded_latency_ms: f64,
6    pub loaded_latency_ms: Option<f64>,
7    pub grade: String,
8    pub bloat_ms: Option<f64>,
9    pub description: String,
10}
11
12pub async fn collect() -> Option<BufferbloatResult> {
13    // Measure unloaded latency first
14    let unloaded = measure_latency("1.1.1.1", 3).await?;
15
16    // To properly measure loaded latency we'd need to ping during a speed test.
17    // Since the speed test already ran, we estimate based on unloaded latency.
18    // A proper implementation would measure during the download phase.
19
20    // For now, do a quick loaded test by downloading and pinging simultaneously
21    let loaded = measure_loaded_latency(unloaded).await;
22
23    let (grade, description, bloat) = match loaded {
24        Some(loaded_ms) => {
25            let diff = loaded_ms - unloaded;
26            let grade = if diff < 5.0 {
27                "A+"
28            } else if diff < 30.0 {
29                "A"
30            } else if diff < 60.0 {
31                "B"
32            } else if diff < 200.0 {
33                "C"
34            } else if diff < 400.0 {
35                "D"
36            } else {
37                "F"
38            };
39
40            let desc = match grade {
41                "A+" | "A" => "Excellent - minimal bufferbloat".to_string(),
42                "B" => "Good - minor bufferbloat".to_string(),
43                "C" => "Fair - moderate bufferbloat".to_string(),
44                "D" => "Poor - significant bufferbloat".to_string(),
45                _ => "Severe bufferbloat detected".to_string(),
46            };
47
48            (grade.to_string(), desc, Some(diff))
49        }
50        None => {
51            // Can't measure loaded, just report unloaded
52            let grade = if unloaded < 20.0 {
53                "A"
54            } else if unloaded < 50.0 {
55                "B"
56            } else {
57                "C"
58            };
59            (
60                grade.to_string(),
61                "Loaded latency not measured (run without --fast for full test)".to_string(),
62                None,
63            )
64        }
65    };
66
67    Some(BufferbloatResult {
68        unloaded_latency_ms: unloaded,
69        loaded_latency_ms: loaded,
70        grade,
71        bloat_ms: bloat,
72        description,
73    })
74}
75
76async fn measure_latency(host: &str, count: u32) -> Option<f64> {
77    #[cfg(windows)]
78    let output = {
79        let mut cmd = tokio::process::Command::new("ping");
80        cmd.args(["-n", &count.to_string(), "-w", "2000", host]);
81        super::util::run_with_timeout(cmd, super::util::SLOW).await
82    };
83
84    #[cfg(unix)]
85    let output = {
86        let mut cmd = tokio::process::Command::new("ping");
87        cmd.args(["-c", &count.to_string(), "-W", "2", host]);
88        super::util::run_with_timeout(cmd, super::util::SLOW).await
89    };
90
91    let output = output?;
92    if !output.status.success() {
93        return None;
94    }
95
96    let text = String::from_utf8_lossy(&output.stdout);
97    let mut times = Vec::new();
98
99    for line in text.lines() {
100        if let Some(pos) = line.find("time=") {
101            let after = &line[pos + 5..];
102            let num: String = after
103                .chars()
104                .take_while(|c| c.is_ascii_digit() || *c == '.')
105                .collect();
106            if let Ok(ms) = num.parse::<f64>() {
107                times.push(ms);
108            }
109        } else if let Some(pos) = line.find("time<") {
110            let after = &line[pos + 5..];
111            let num: String = after
112                .chars()
113                .take_while(|c| c.is_ascii_digit() || *c == '.')
114                .collect();
115            if let Ok(ms) = num.parse::<f64>() {
116                times.push(ms);
117            }
118        }
119    }
120
121    if times.is_empty() {
122        None
123    } else {
124        Some(times.iter().sum::<f64>() / times.len() as f64)
125    }
126}
127
128async fn measure_loaded_latency(_unloaded: f64) -> Option<f64> {
129    // Quick loaded test: download a chunk while pinging
130    let client = reqwest::Client::builder()
131        .timeout(std::time::Duration::from_secs(10))
132        .build()
133        .ok()?;
134
135    // Start a download in the background
136    let download = tokio::spawn(async move {
137        if let Ok(resp) = client
138            .get("https://speed.cloudflare.com/__down?bytes=25000000")
139            .send()
140            .await
141        {
142            let _ = resp.bytes().await;
143        }
144    });
145
146    // Ping during the download
147    tokio::time::sleep(std::time::Duration::from_millis(500)).await;
148    let loaded = measure_latency("1.1.1.1", 3).await;
149
150    let _ = download.await;
151
152    loaded
153}