1use serde::Serialize;
11use std::time::Duration;
12
13use super::ping;
14use crate::config::Config;
15
16const TARGET: &str = "1.1.1.1";
17const IDLE_PROBES: u32 = 5;
18const DOWNLOAD_PROBES: u32 = 12;
19const UPLOAD_PROBES: u32 = 8;
20const DOWNLOAD_STREAMS: usize = 4;
22const UPLOAD_STREAMS: usize = 2;
23const DOWNLOAD_URL: &str = "https://speed.cloudflare.com/__down?bytes=100000000";
24const UPLOAD_URL: &str = "https://speed.cloudflare.com/__up";
25const UPLOAD_CHUNK_BYTES: usize = 10_000_000;
26const MODULE_BUDGET: Duration = Duration::from_secs(35);
29
30#[derive(Debug, Clone, Serialize)]
31pub struct BufferbloatResult {
32 pub unloaded_latency_ms: f64,
34 pub loaded_latency_ms: Option<f64>,
36 pub grade: String,
38 pub bloat_ms: Option<f64>,
41 pub description: String,
42 #[serde(skip_serializing_if = "Option::is_none")]
44 pub download_bloat_ms: Option<f64>,
45 #[serde(skip_serializing_if = "Option::is_none")]
46 pub upload_loaded_latency_ms: Option<f64>,
47 #[serde(skip_serializing_if = "Option::is_none")]
48 pub upload_bloat_ms: Option<f64>,
49 #[serde(skip_serializing_if = "Option::is_none")]
50 pub download_grade: Option<String>,
51 #[serde(skip_serializing_if = "Option::is_none")]
52 pub upload_grade: Option<String>,
53 pub samples_idle: u32,
54 pub samples_download: u32,
55 pub samples_upload: u32,
56}
57
58pub async fn collect(config: &Config) -> Option<BufferbloatResult> {
59 tokio::time::timeout(MODULE_BUDGET, collect_inner(config))
60 .await
61 .unwrap_or_default()
62}
63
64async fn collect_inner(config: &Config) -> Option<BufferbloatResult> {
65 let idle_stats = run_burst(IDLE_PROBES).await?;
67 let idle = median(&idle_stats.times_ms)?;
68
69 if config.skip_speed {
73 let grade = if idle < 20.0 {
74 "A"
75 } else if idle < 50.0 {
76 "B"
77 } else {
78 "C"
79 };
80 return Some(BufferbloatResult {
81 unloaded_latency_ms: idle,
82 loaded_latency_ms: None,
83 grade: grade.to_string(),
84 bloat_ms: None,
85 description: "Loaded latency not measured (run without --fast for full test)"
86 .to_string(),
87 download_bloat_ms: None,
88 upload_loaded_latency_ms: None,
89 upload_bloat_ms: None,
90 download_grade: None,
91 upload_grade: None,
92 samples_idle: idle_stats.received(),
93 samples_download: 0,
94 samples_upload: 0,
95 });
96 }
97
98 let dl_stats = loaded_phase(Load::Download, DOWNLOAD_PROBES).await;
101 let ul_stats = loaded_phase(Load::Upload, UPLOAD_PROBES).await;
103
104 let dl_median = dl_stats.as_ref().and_then(|s| median(&s.times_ms));
105 let ul_median = ul_stats.as_ref().and_then(|s| median(&s.times_ms));
106
107 let dl_bloat = dl_median.map(|m| m - idle);
108 let ul_bloat = ul_median.map(|m| m - idle);
109
110 let dl_grade = dl_bloat.map(grade_delta);
111 let ul_grade = ul_bloat.map(grade_delta);
112 let overall = worse_grade(dl_grade.unwrap_or("A+"), ul_grade.unwrap_or("A+"));
113
114 let description = if dl_median.is_none() && ul_median.is_none() {
115 "Loaded latency could not be measured".to_string()
116 } else {
117 describe(overall).to_string()
118 };
119
120 Some(BufferbloatResult {
121 unloaded_latency_ms: idle,
122 loaded_latency_ms: dl_median,
123 grade: overall.to_string(),
124 bloat_ms: dl_bloat,
125 description,
126 download_bloat_ms: dl_bloat,
127 upload_loaded_latency_ms: ul_median,
128 upload_bloat_ms: ul_bloat,
129 download_grade: dl_grade.map(|g| g.to_string()),
130 upload_grade: ul_grade.map(|g| g.to_string()),
131 samples_idle: idle_stats.received(),
132 samples_download: dl_stats.map(|s| s.received()).unwrap_or(0),
133 samples_upload: ul_stats.map(|s| s.received()).unwrap_or(0),
134 })
135}
136
137enum Load {
138 Download,
139 Upload,
140}
141
142async fn loaded_phase(load: Load, probes: u32) -> Option<ping::PingStats> {
145 let client = reqwest::Client::builder()
146 .timeout(Duration::from_secs(30))
147 .build()
148 .ok()?;
149
150 let mut tasks = Vec::new();
151 let streams = match load {
152 Load::Download => DOWNLOAD_STREAMS,
153 Load::Upload => UPLOAD_STREAMS,
154 };
155 for _ in 0..streams {
156 let client = client.clone();
157 let handle = match load {
158 Load::Download => tokio::spawn(async move {
159 loop {
160 let Ok(mut resp) = client.get(DOWNLOAD_URL).send().await else {
161 continue;
162 };
163 while let Ok(Some(_chunk)) = resp.chunk().await {}
164 }
165 }),
166 Load::Upload => tokio::spawn(async move {
167 let payload = vec![0u8; UPLOAD_CHUNK_BYTES];
168 loop {
169 let _ = client.post(UPLOAD_URL).body(payload.clone()).send().await;
170 }
171 }),
172 };
173 tasks.push(handle);
174 }
175
176 tokio::time::sleep(Duration::from_millis(750)).await;
178 let stats = run_burst(probes).await;
179
180 for task in tasks {
181 task.abort();
182 }
183
184 stats
185}
186
187async fn run_burst(count: u32) -> Option<ping::PingStats> {
188 let stdout = ping::run_ping(TARGET, count).await?;
189 let stats = ping::parse_ping(&stdout, count);
190 if stats.received() == 0 {
191 None
192 } else {
193 Some(stats)
194 }
195}
196
197fn median(times: &[f64]) -> Option<f64> {
198 if times.is_empty() {
199 return None;
200 }
201 let mut sorted = times.to_vec();
202 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
203 Some(sorted[sorted.len() / 2])
204}
205
206fn grade_delta(delta_ms: f64) -> &'static str {
209 if delta_ms < 5.0 {
210 "A+"
211 } else if delta_ms < 30.0 {
212 "A"
213 } else if delta_ms < 60.0 {
214 "B"
215 } else if delta_ms < 200.0 {
216 "C"
217 } else if delta_ms < 400.0 {
218 "D"
219 } else {
220 "F"
221 }
222}
223
224fn grade_rank(grade: &str) -> u8 {
225 match grade {
226 "A+" => 0,
227 "A" => 1,
228 "B" => 2,
229 "C" => 3,
230 "D" => 4,
231 _ => 5,
232 }
233}
234
235fn worse_grade<'a>(a: &'a str, b: &'a str) -> &'a str {
236 if grade_rank(a) >= grade_rank(b) {
237 a
238 } else {
239 b
240 }
241}
242
243fn describe(grade: &str) -> &'static str {
244 match grade {
245 "A+" | "A" => "Excellent - minimal bufferbloat",
246 "B" => "Good - minor bufferbloat",
247 "C" => "Fair - moderate bufferbloat",
248 "D" => "Poor - significant bufferbloat",
249 _ => "Severe bufferbloat detected",
250 }
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn grade_delta_boundaries() {
259 assert_eq!(grade_delta(4.9), "A+");
260 assert_eq!(grade_delta(5.0), "A");
261 assert_eq!(grade_delta(29.9), "A");
262 assert_eq!(grade_delta(30.0), "B");
263 assert_eq!(grade_delta(59.9), "B");
264 assert_eq!(grade_delta(60.0), "C");
265 assert_eq!(grade_delta(199.9), "C");
266 assert_eq!(grade_delta(200.0), "D");
267 assert_eq!(grade_delta(399.9), "D");
268 assert_eq!(grade_delta(400.0), "F");
269 }
270
271 #[test]
272 fn worse_grade_picks_worse() {
273 assert_eq!(worse_grade("A+", "C"), "C");
274 assert_eq!(worse_grade("F", "A"), "F");
275 assert_eq!(worse_grade("B", "B"), "B");
276 }
277
278 #[test]
279 fn median_is_middle_element() {
280 assert_eq!(median(&[3.0, 1.0, 2.0]), Some(2.0));
281 assert_eq!(median(&[]), None);
282 assert_eq!(median(&[5.0]), Some(5.0));
283 }
284}