1pub(crate) mod adaptive;
2pub mod applenq;
3pub mod cachefly;
4pub mod cloudflare;
5pub mod display;
6pub mod fastcom;
7pub mod librespeed;
8pub mod msak;
9pub mod ndt7;
10pub mod stat_primitives;
11pub mod statistics;
12pub mod vultr;
13
14#[cfg(test)]
15mod golden_tests;
16
17use serde::Serialize;
18use stat_primitives::Pcg32;
19use statistics::{merge_providers, BcaInterval, MergeProviderInput};
20use std::collections::HashMap;
21use std::sync::{Arc, Mutex as StdMutex};
22use std::time::{Duration, Instant};
23
24pub const METHODOLOGY_VERSION: &str = "4.0";
27
28pub(crate) const FAST_HARD_CAP: Duration = Duration::from_secs(25);
35
36const FAST_PER_DIR_SECS: u64 = 8;
44
45pub fn dense_probe_count(duration_secs: f64) -> u32 {
48 (duration_secs * 3.3).round().clamp(50.0, 200.0) as u32
49}
50
51pub const DENSE_PROBE_INTERVAL: Duration = Duration::from_millis(50);
53
54pub const DENSE_PROBE_WARMUP: usize = 3;
57
58#[derive(Debug, Clone)]
60pub enum TestDuration {
61 Seconds(u64),
63 Auto,
65}
66
67#[derive(Debug, Clone, Copy, PartialEq)]
69pub enum ProviderSet {
70 All,
73 Fast,
76 Diagnostic,
78}
79
80#[derive(Debug, Clone)]
82pub struct SpeedTestConfig {
83 pub duration: TestDuration,
85 pub fastcom_duration: TestDuration,
87 pub latency_probes: u32,
89 pub provider_set: ProviderSet,
91 pub msak_enabled: bool,
93 pub apple_enabled: bool,
95 pub use_colors: bool,
97}
98
99impl Default for SpeedTestConfig {
100 fn default() -> Self {
101 Self {
102 duration: TestDuration::Seconds(30),
103 fastcom_duration: TestDuration::Auto,
104 latency_probes: 20,
105 provider_set: ProviderSet::All,
106 msak_enabled: true,
107 apple_enabled: true,
108 use_colors: true,
109 }
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq)]
115pub enum Phase {
116 CfLatency,
117 CfDownload,
118 CfUpload,
119 Ndt7Discovery,
120 Ndt7Download,
121 Ndt7Upload,
122 LsDiscovery,
123 LsDownload,
124 LsUpload,
125 FcDiscovery,
126 FcDownload,
127 FcUpload,
128 MsakDiscovery,
129 MsakDownload,
130 MsakUpload,
131 CfyLatency,
132 CfyDownload,
133 VultrDiscovery,
134 VultrLatency,
135 VultrDownload,
136 AnqDiscovery,
137 AnqDownload,
138 AnqUpload,
139 Computing,
140}
141
142#[derive(Debug, Clone, Default, Serialize)]
144pub struct BandwidthSamples {
145 pub download: Vec<f64>,
146 pub upload: Vec<f64>,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
153#[serde(rename_all = "kebab-case")]
154pub enum ProviderAvailability {
155 Ran,
156 Failed,
157 UnavailablePlatform,
158}
159
160#[derive(Debug, Clone, Default, Serialize)]
164pub struct LatencyStats {
165 #[serde(skip)]
166 pub samples: Vec<f64>,
167 pub p50: f64,
168 pub p75: f64,
169 pub p95: f64,
170 pub p99: f64,
171 pub min: f64,
172 pub max: f64,
173 pub mean: f64,
174 pub stddev: f64,
175 pub pdv: f64,
177 pub jitter_mad: f64,
178 pub jitter_rfc3550: f64,
180}
181
182impl LatencyStats {
183 pub fn from_rtts(rtts: &[f64]) -> Option<Self> {
185 if rtts.is_empty() {
186 return None;
187 }
188 let mut sorted = rtts.to_vec();
189 sorted.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
190 Some(Self {
191 samples: rtts.to_vec(),
192 p50: statistics::percentile(&sorted, 0.50),
193 p75: statistics::percentile(&sorted, 0.75),
194 p95: statistics::percentile(&sorted, 0.95),
195 p99: statistics::percentile(&sorted, 0.99),
196 min: sorted[0],
197 max: sorted[sorted.len() - 1],
198 mean: statistics::mean(rtts),
199 stddev: statistics::stddev(rtts),
200 pdv: statistics::pdv(rtts),
201 jitter_mad: statistics::jitter_mad(rtts),
202 jitter_rfc3550: statistics::jitter_rfc3550(rtts),
203 })
204 }
205}
206
207#[derive(Debug, Clone, Default, Serialize)]
210pub struct LoadedLatency {
211 pub download: Vec<f64>,
212 pub upload: Vec<f64>,
213}
214
215#[derive(Debug, Clone, Serialize)]
217pub struct MergedDirection {
218 pub download: f64,
219 pub upload: f64,
220 #[serde(skip_serializing_if = "Option::is_none")]
221 pub download_ci: Option<statistics::CiBounds>,
222 #[serde(skip_serializing_if = "Option::is_none")]
223 pub upload_ci: Option<statistics::CiBounds>,
224}
225
226#[derive(Debug, Clone, Serialize)]
228pub struct AgreementInfo {
229 #[serde(skip_serializing_if = "Option::is_none")]
230 pub i2: Option<f64>,
231 pub band: statistics::AgreementBand,
232}
233
234#[derive(Debug, Clone, Serialize)]
236pub struct BufferbloatSummary {
237 pub grade: statistics::BufferbloatGrade,
238 pub delta_ms: f64,
239 pub ratio: f64,
240}
241
242#[derive(Debug, Clone, Serialize)]
244pub struct StabilityMetrics {
245 pub download_cv: f64,
246 pub upload_cv: f64,
247 pub download_stable: bool,
248 pub upload_stable: bool,
249}
250
251#[derive(Debug, Clone, Serialize)]
253pub struct ProviderDivergence {
254 pub download: f64,
255 pub upload: f64,
256 pub significant: bool,
257}
258
259#[derive(Debug, Clone, Serialize)]
262pub struct MergeExclusion {
263 pub provider: String,
264 pub direction: &'static str,
266 pub samples: usize,
267}
268
269#[derive(Debug, Clone, Serialize)]
274pub struct ConfidenceIntervals {
275 #[serde(skip_serializing_if = "Option::is_none")]
276 pub download: Option<statistics::BootstrapCI>,
277 #[serde(skip_serializing_if = "Option::is_none")]
278 pub upload: Option<statistics::BootstrapCI>,
279 pub confidence_level: f64,
280}
281
282#[derive(Debug, Clone, Serialize)]
284pub struct ProviderResult {
285 pub provider: String,
286 pub server: String,
287 #[serde(skip_serializing_if = "Option::is_none")]
288 pub location: Option<String>,
289 #[serde(skip_serializing_if = "Option::is_none")]
290 pub ping_ms: Option<f64>,
291 #[serde(skip_serializing_if = "Option::is_none")]
292 pub jitter_ms: Option<f64>,
293 #[serde(skip_serializing_if = "Option::is_none")]
294 pub download_mbps: Option<f64>,
295 #[serde(skip_serializing_if = "Option::is_none")]
296 pub upload_mbps: Option<f64>,
297 pub download_bytes: u64,
298 pub upload_bytes: u64,
299 pub download_duration_s: f64,
300 pub upload_duration_s: f64,
301 #[serde(skip_serializing_if = "Option::is_none")]
302 pub packet_loss_pct: Option<f64>,
303 #[serde(skip_serializing_if = "Option::is_none")]
304 pub error: Option<String>,
305 #[serde(skip_serializing_if = "Option::is_none")]
306 pub bandwidth_samples: Option<BandwidthSamples>,
307 pub availability: ProviderAvailability,
309 #[serde(skip_serializing_if = "Option::is_none")]
311 pub latency_stats: Option<LatencyStats>,
312 #[serde(skip_serializing_if = "Option::is_none")]
314 pub loaded_latency: Option<LoadedLatency>,
315}
316
317#[derive(Debug, Clone, Serialize)]
323pub struct SpeedTestResult {
324 pub methodology_version: &'static str,
326 pub platform: &'static str,
328 pub provider_set: &'static str,
330
331 #[serde(skip_serializing_if = "Option::is_none")]
333 pub ping_ms: Option<f64>,
334 #[serde(skip_serializing_if = "Option::is_none")]
336 pub jitter_ms: Option<f64>,
337 #[serde(skip_serializing_if = "Option::is_none")]
339 pub jitter_rfc3550: Option<f64>,
340 pub download_mbps: f64,
342 pub upload_mbps: f64,
344 #[serde(skip_serializing_if = "Option::is_none")]
345 pub packet_loss_pct: Option<f64>,
346
347 pub capacity: MergedDirection,
349 pub consensus: MergedDirection,
351 pub agreement: AgreementInfo,
353 #[serde(skip_serializing_if = "Option::is_none")]
355 pub upload_agreement: Option<AgreementInfo>,
356 #[serde(skip_serializing_if = "Option::is_none")]
358 pub rpm: Option<f64>,
359 #[serde(skip_serializing_if = "Option::is_none")]
361 pub bufferbloat: Option<BufferbloatSummary>,
362 #[serde(skip_serializing_if = "Option::is_none")]
364 pub latency_stats: Option<LatencyStats>,
365
366 pub providers: Vec<ProviderResult>,
367 pub duration_s: f64,
368 #[serde(skip_serializing_if = "Option::is_none")]
369 pub stability: Option<StabilityMetrics>,
370 #[serde(skip_serializing_if = "Option::is_none")]
371 pub provider_divergence: Option<ProviderDivergence>,
372 #[serde(skip_serializing_if = "Option::is_none")]
373 pub confidence_intervals: Option<ConfidenceIntervals>,
374 #[serde(skip_serializing_if = "Vec::is_empty")]
375 pub merge_exclusions: Vec<MergeExclusion>,
376}
377
378fn registry_rank(display_name: &str) -> usize {
382 match display_name {
383 "Cloudflare" => 0,
384 "M-Lab NDT7" => 1,
385 "M-Lab MSAK" => 2,
386 "LibreSpeed" => 3,
387 "fast.com" => 4,
388 "CacheFly" => 5,
389 "Vultr" => 6,
390 "Apple networkQuality" => 7,
391 _ => usize::MAX,
392 }
393}
394
395fn registry_key(display_name: &str) -> &'static str {
398 match display_name {
399 "Cloudflare" => "cloudflare",
400 "M-Lab NDT7" => "ndt7",
401 "M-Lab MSAK" => "msak",
402 "LibreSpeed" => "librespeed",
403 "fast.com" => "fastcom",
404 "CacheFly" => "cachefly",
405 "Vultr" => "vultr",
406 "Apple networkQuality" => "applenq",
407 _ => "unknown",
408 }
409}
410
411fn display_name_for_key(key: &str) -> String {
414 match key {
415 "cloudflare" => "Cloudflare",
416 "ndt7" => "M-Lab NDT7",
417 "msak" => "M-Lab MSAK",
418 "librespeed" => "LibreSpeed",
419 "fastcom" => "fast.com",
420 "cachefly" => "CacheFly",
421 "vultr" => "Vultr",
422 "applenq" => "Apple networkQuality",
423 other => other,
424 }
425 .to_string()
426}
427
428fn clean_direction(raw: &[f64], is_upload: bool) -> Vec<f64> {
432 let sane = statistics::sanitize(raw);
433 if sane.is_empty() {
434 return Vec::new();
435 }
436 let cut = statistics::plateau_start(&sane).min(sane.len());
437 let after_plateau = &sane[cut..];
438 if is_upload {
439 let mut desc = after_plateau.to_vec();
441 desc.sort_by(|a, b| b.partial_cmp(a).unwrap_or(std::cmp::Ordering::Equal));
442 let keep = ((desc.len() as f64) / 2.0).ceil() as usize;
443 let top = &desc[..keep.min(desc.len())];
444 statistics::filter_outliers_iqr(top, 1.5)
445 } else {
446 statistics::filter_outliers_iqr(after_plateau, 1.5)
447 }
448}
449
450struct AggregateResult {
452 ping: Option<f64>,
453 jitter: Option<f64>,
454 jitter_rfc3550: Option<f64>,
455 download: f64,
456 upload: f64,
457 packet_loss: Option<f64>,
458 capacity: MergedDirection,
459 consensus: MergedDirection,
460 agreement: AgreementInfo,
461 upload_agreement: AgreementInfo,
462 rpm: Option<f64>,
463 bufferbloat: Option<BufferbloatSummary>,
464 latency_stats: Option<LatencyStats>,
465 stability: Option<StabilityMetrics>,
466 divergence: Option<ProviderDivergence>,
467 confidence: Option<ConfidenceIntervals>,
468 exclusions: Vec<MergeExclusion>,
469}
470
471fn empty_direction() -> MergedDirection {
472 MergedDirection {
473 download: 0.0,
474 upload: 0.0,
475 download_ci: None,
476 upload_ci: None,
477 }
478}
479
480fn aggregate(providers: &[ProviderResult]) -> AggregateResult {
492 let mut ordered: Vec<&ProviderResult> =
494 providers.iter().filter(|p| p.error.is_none()).collect();
495 ordered.sort_by_key(|p| registry_rank(&p.provider));
496
497 if ordered.is_empty() {
498 return AggregateResult {
499 ping: None,
500 jitter: None,
501 jitter_rfc3550: None,
502 download: 0.0,
503 upload: 0.0,
504 packet_loss: None,
505 capacity: empty_direction(),
506 consensus: empty_direction(),
507 agreement: AgreementInfo {
508 i2: None,
509 band: statistics::AgreementBand::Insufficient,
510 },
511 upload_agreement: AgreementInfo {
512 i2: None,
513 band: statistics::AgreementBand::Insufficient,
514 },
515 rpm: None,
516 bufferbloat: None,
517 latency_stats: None,
518 stability: None,
519 divergence: None,
520 confidence: None,
521 exclusions: Vec::new(),
522 };
523 }
524
525 let mut rng = Pcg32::new();
527 let mut dl_inputs: Vec<MergeProviderInput> = Vec::new();
528 let mut ul_inputs: Vec<MergeProviderInput> = Vec::new();
529 let mut pooled_dl: Vec<f64> = Vec::new();
530 let mut pooled_ul: Vec<f64> = Vec::new();
531 let mut points: HashMap<String, (Option<f64>, Option<f64>)> = HashMap::new();
533
534 for p in &ordered {
535 let (dl_raw, ul_raw): (&[f64], &[f64]) = match &p.bandwidth_samples {
536 Some(bs) => (&bs.download, &bs.upload),
537 None => (&[], &[]),
538 };
539 let dl_clean = clean_direction(dl_raw, false);
540 let ul_clean = clean_direction(ul_raw, true);
541 let dl_boot = statistics::circular_block_bootstrap(&dl_clean, &mut rng, 2000);
542 let ul_boot = statistics::circular_block_bootstrap(&ul_clean, &mut rng, 2000);
543 let key = registry_key(&p.provider);
544
545 dl_inputs.push(MergeProviderInput {
546 name: key.to_string(),
547 y: dl_boot.theta_hat,
548 v: Some(dl_boot.variance),
549 samples: dl_clean.len(),
550 capability: None,
551 bca: Some(BcaInterval {
552 lower: dl_boot.ci_lower,
553 upper: dl_boot.ci_upper,
554 }),
555 });
556 ul_inputs.push(MergeProviderInput {
557 name: key.to_string(),
558 y: ul_boot.theta_hat,
559 v: Some(ul_boot.variance),
560 samples: ul_clean.len(),
561 capability: None,
562 bca: Some(BcaInterval {
563 lower: ul_boot.ci_lower,
564 upper: ul_boot.ci_upper,
565 }),
566 });
567
568 points.insert(
569 p.provider.clone(),
570 (
571 (!dl_clean.is_empty()).then_some(dl_boot.theta_hat),
572 (!ul_clean.is_empty()).then_some(ul_boot.theta_hat),
573 ),
574 );
575 pooled_dl.extend_from_slice(&dl_clean);
576 pooled_ul.extend_from_slice(&ul_clean);
577 }
578
579 let dl_merge = merge_providers(&dl_inputs);
580 let ul_merge = merge_providers(&ul_inputs);
581
582 let fallback_max = |download: bool| -> f64 {
585 points
586 .values()
587 .filter_map(|(dl, ul)| if download { *dl } else { *ul })
588 .fold(0.0_f64, f64::max)
589 };
590 let download = if dl_merge.capacity > 0.0 {
591 dl_merge.capacity
592 } else {
593 fallback_max(true)
594 };
595 let upload = if ul_merge.capacity > 0.0 {
596 ul_merge.capacity
597 } else {
598 fallback_max(false)
599 };
600
601 let mut ping = f64::INFINITY;
603 for p in &ordered {
604 if let Some(pg) = p.ping_ms {
605 if pg > 0.0 && pg < ping {
606 ping = pg;
607 }
608 }
609 if let Some(ls) = &p.latency_stats {
610 if ls.min > 0.0 && ls.min < ping {
611 ping = ls.min;
612 }
613 }
614 }
615 let ping = ping.is_finite().then_some(ping);
616
617 let latency_stats = ordered.iter().find_map(|p| p.latency_stats.clone());
620
621 let jitter = latency_stats.as_ref().map(|ls| ls.pdv).or_else(|| {
623 let js: Vec<f64> = ordered
624 .iter()
625 .filter_map(|p| p.jitter_ms)
626 .filter(|j| *j > 0.0)
627 .collect();
628 (!js.is_empty()).then(|| statistics::mean(&js))
629 });
630 let jitter_rfc3550 = latency_stats
631 .as_ref()
632 .map(|ls| ls.jitter_rfc3550)
633 .or_else(|| ordered.iter().find_map(|p| p.jitter_ms));
634
635 let mut bufferbloat = None;
637 let mut rpm = None;
638 if let Some(cf) = ordered.iter().find(|p| p.provider == "Cloudflare") {
639 if let Some(loaded) = &cf.loaded_latency {
640 let mut loaded_pooled = loaded.download.clone();
641 loaded_pooled.extend_from_slice(&loaded.upload);
642 if !loaded_pooled.is_empty() {
643 let idle = cf
644 .latency_stats
645 .as_ref()
646 .map(|ls| ls.samples.clone())
647 .unwrap_or_default();
648 let bb = statistics::bufferbloat_delta(&idle, &loaded_pooled);
649 rpm = Some(statistics::rpm(&loaded_pooled));
650 bufferbloat = Some(BufferbloatSummary {
651 grade: bb.grade,
652 delta_ms: bb.delta_ms,
653 ratio: bb.ratio,
654 });
655 }
656 }
657 }
658
659 let packet_loss = ordered
661 .iter()
662 .find(|p| p.provider == "Cloudflare")
663 .and_then(|p| p.packet_loss_pct);
664
665 let stability = if pooled_dl.len() > 2 || pooled_ul.len() > 2 {
667 let dl_cv = statistics::coefficient_of_variation(&pooled_dl);
668 let ul_cv = statistics::coefficient_of_variation(&pooled_ul);
669 Some(StabilityMetrics {
670 download_cv: dl_cv,
671 upload_cv: ul_cv,
672 download_stable: dl_cv < 0.15,
673 upload_stable: ul_cv < 0.15,
674 })
675 } else {
676 None
677 };
678
679 let agreement = AgreementInfo {
681 i2: dl_merge.i2,
682 band: dl_merge.band,
683 };
684 let upload_agreement = AgreementInfo {
685 i2: ul_merge.i2,
686 band: ul_merge.band,
687 };
688 let band_low = |b: statistics::AgreementBand| {
689 matches!(
690 b,
691 statistics::AgreementBand::Low | statistics::AgreementBand::VeryLow
692 )
693 };
694 let divergence = Some(ProviderDivergence {
695 download: dl_merge.i2.unwrap_or(0.0),
696 upload: ul_merge.i2.unwrap_or(0.0),
697 significant: band_low(dl_merge.band) || band_low(ul_merge.band),
698 });
699
700 let capacity = MergedDirection {
702 download,
703 upload,
704 download_ci: (dl_merge.k > 0).then_some(dl_merge.capacity_ci),
705 upload_ci: (ul_merge.k > 0).then_some(ul_merge.capacity_ci),
706 };
707 let consensus = MergedDirection {
708 download: dl_merge.consensus,
709 upload: ul_merge.consensus,
710 download_ci: (dl_merge.k > 0).then_some(dl_merge.consensus_ci),
711 upload_ci: (ul_merge.k > 0).then_some(ul_merge.consensus_ci),
712 };
713
714 let ci_from = |value: f64, ci: statistics::CiBounds| statistics::BootstrapCI {
716 estimate: value,
717 lower: ci.lower,
718 upper: ci.upper,
719 margin: ((ci.upper - ci.lower) / 2.0).max(0.0),
720 };
721 let confidence = if (dl_merge.k > 0 && download > 0.0) || (ul_merge.k > 0 && upload > 0.0) {
722 Some(ConfidenceIntervals {
723 download: (dl_merge.k > 0 && download > 0.0)
724 .then(|| ci_from(download, dl_merge.capacity_ci)),
725 upload: (ul_merge.k > 0 && upload > 0.0).then(|| ci_from(upload, ul_merge.capacity_ci)),
726 confidence_level: 0.95,
727 })
728 } else {
729 None
730 };
731
732 let mut exclusions: Vec<MergeExclusion> = Vec::new();
734 for e in &dl_merge.exclusions {
735 exclusions.push(MergeExclusion {
736 provider: display_name_for_key(&e.name),
737 direction: "download",
738 samples: e.samples,
739 });
740 }
741 for e in &ul_merge.exclusions {
742 exclusions.push(MergeExclusion {
743 provider: display_name_for_key(&e.name),
744 direction: "upload",
745 samples: e.samples,
746 });
747 }
748
749 AggregateResult {
750 ping,
751 jitter,
752 jitter_rfc3550,
753 download,
754 upload,
755 packet_loss,
756 capacity,
757 consensus,
758 agreement,
759 upload_agreement,
760 rpm,
761 bufferbloat,
762 latency_stats,
763 stability,
764 divergence,
765 confidence,
766 exclusions,
767 }
768}
769
770pub type ProviderCompleteCallback = Arc<dyn Fn(&ProviderResult) + Send + Sync>;
772
773fn failed_provider(provider: &str, msg: &str) -> ProviderResult {
775 ProviderResult {
776 provider: provider.to_string(),
777 server: "unknown".to_string(),
778 location: None,
779 ping_ms: None,
780 jitter_ms: None,
781 download_mbps: None,
782 upload_mbps: None,
783 download_bytes: 0,
784 upload_bytes: 0,
785 download_duration_s: 0.0,
786 upload_duration_s: 0.0,
787 packet_loss_pct: None,
788 error: Some(msg.to_string()),
789 bandwidth_samples: None,
790 availability: ProviderAvailability::Failed,
791 latency_stats: None,
792 loaded_latency: None,
793 }
794}
795
796const PROVIDER_STALL_SECS: u64 = 60;
806
807async fn run_provider_future(
808 fast: bool,
809 provider_name: &str,
810 last_activity: Arc<StdMutex<Instant>>,
811 fut: impl std::future::Future<Output = ProviderResult>,
812) -> ProviderResult {
813 let stall = Duration::from_secs(PROVIDER_STALL_SECS);
814 let watchdog = async {
815 loop {
816 tokio::time::sleep(Duration::from_secs(1)).await;
817 let idle = last_activity
818 .lock()
819 .map(|t| t.elapsed())
820 .unwrap_or_default();
821 if idle >= stall {
822 break;
823 }
824 }
825 };
826 let bounded = async {
827 tokio::select! {
828 r = fut => Some(r),
829 _ = watchdog => None,
830 }
831 };
832 let outcome = if fast {
833 match tokio::time::timeout(FAST_HARD_CAP, bounded).await {
834 Ok(o) => o,
835 Err(_) => return failed_provider(provider_name, "FAST 25s hard cap reached"),
836 }
837 } else {
838 bounded.await
839 };
840 outcome.unwrap_or_else(|| {
841 failed_provider(
842 provider_name,
843 &format!("stalled — no progress for {PROVIDER_STALL_SECS}s (non-network wedge)"),
844 )
845 })
846}
847
848fn stamped_progress<F>(
851 progress: Arc<F>,
852 last_activity: Arc<StdMutex<Instant>>,
853) -> impl Fn(Phase, f64) + Send + Sync
854where
855 F: Fn(Phase, f64) + Send + Sync + 'static,
856{
857 move |phase, p| {
858 if let Ok(mut t) = last_activity.lock() {
859 *t = Instant::now();
860 }
861 progress(phase, p)
862 }
863}
864
865pub async fn run<F>(
869 config: SpeedTestConfig,
870 progress: F,
871 on_provider_complete: Option<ProviderCompleteCallback>,
872) -> SpeedTestResult
873where
874 F: Fn(Phase, f64) + Send + Sync + 'static,
875{
876 let start = Instant::now();
877 let mut providers = Vec::new();
878 let progress = Arc::new(progress);
879 let fast = config.provider_set == ProviderSet::Fast;
880
881 let provider_config = if fast {
888 SpeedTestConfig {
889 duration: TestDuration::Seconds(FAST_PER_DIR_SECS),
890 ..config.clone()
891 }
892 } else {
893 config.clone()
894 };
895
896 let record = |providers: &mut Vec<ProviderResult>, result: ProviderResult| {
897 if let Some(ref cb) = on_provider_complete {
898 cb(&result);
899 }
900 providers.push(result);
901 };
902
903 {
907 let last = Arc::new(StdMutex::new(Instant::now()));
908 let cb = stamped_progress(progress.clone(), last.clone());
909 let r = run_provider_future(
910 fast,
911 "Cloudflare",
912 last,
913 cloudflare::run(&provider_config, cb),
914 )
915 .await;
916 record(&mut providers, r);
917 }
918
919 {
921 let last = Arc::new(StdMutex::new(Instant::now()));
922 let cb = stamped_progress(progress.clone(), last.clone());
923 let r =
924 run_provider_future(fast, "M-Lab NDT7", last, ndt7::run(&provider_config, cb)).await;
925 record(&mut providers, r);
926 }
927
928 let run_msak = match config.provider_set {
930 ProviderSet::Fast => true,
931 ProviderSet::All => config.msak_enabled,
932 ProviderSet::Diagnostic => false,
933 };
934 if run_msak {
935 let last = Arc::new(StdMutex::new(Instant::now()));
936 let cb = stamped_progress(progress.clone(), last.clone());
937 let r =
938 run_provider_future(fast, "M-Lab MSAK", last, msak::run(&provider_config, cb)).await;
939 record(&mut providers, r);
940 }
941
942 if config.provider_set == ProviderSet::All {
945 {
946 let last = Arc::new(StdMutex::new(Instant::now()));
947 let cb = stamped_progress(progress.clone(), last.clone());
948 let r = run_provider_future(
949 fast,
950 "LibreSpeed",
951 last,
952 librespeed::run(&provider_config, cb),
953 )
954 .await;
955 record(&mut providers, r);
956 }
957 {
958 let last = Arc::new(StdMutex::new(Instant::now()));
959 let cb = stamped_progress(progress.clone(), last.clone());
960 let r = run_provider_future(fast, "fast.com", last, fastcom::run(&provider_config, cb))
961 .await;
962 record(&mut providers, r);
963 }
964 {
965 let last = Arc::new(StdMutex::new(Instant::now()));
966 let cb = stamped_progress(progress.clone(), last.clone());
967 let r =
968 run_provider_future(fast, "CacheFly", last, cachefly::run(&provider_config, cb))
969 .await;
970 record(&mut providers, r);
971 }
972 {
973 let last = Arc::new(StdMutex::new(Instant::now()));
974 let cb = stamped_progress(progress.clone(), last.clone());
975 let r =
976 run_provider_future(fast, "Vultr", last, vultr::run(&provider_config, cb)).await;
977 record(&mut providers, r);
978 }
979 if config.apple_enabled {
980 let last = Arc::new(StdMutex::new(Instant::now()));
981 let cb = stamped_progress(progress.clone(), last.clone());
982 let r = run_provider_future(
983 fast,
984 "Apple networkQuality",
985 last,
986 applenq::run(&provider_config, cb),
987 )
988 .await;
989 record(&mut providers, r);
990 }
991 }
992
993 progress(Phase::Computing, 1.0);
994
995 let agg = aggregate(&providers);
996 let duration = start.elapsed().as_secs_f64();
997
998 let provider_set = match config.provider_set {
999 ProviderSet::All => "full",
1000 ProviderSet::Fast => "fast",
1001 ProviderSet::Diagnostic => "diagnostic",
1002 };
1003
1004 SpeedTestResult {
1005 methodology_version: METHODOLOGY_VERSION,
1006 platform: "cli",
1007 provider_set,
1008 ping_ms: agg.ping,
1009 jitter_ms: agg.jitter,
1010 jitter_rfc3550: agg.jitter_rfc3550,
1011 download_mbps: agg.download,
1012 upload_mbps: agg.upload,
1013 packet_loss_pct: agg.packet_loss,
1014 capacity: agg.capacity,
1015 consensus: agg.consensus,
1016 agreement: agg.agreement,
1017 upload_agreement: Some(agg.upload_agreement),
1018 rpm: agg.rpm,
1019 bufferbloat: agg.bufferbloat,
1020 latency_stats: agg.latency_stats,
1021 providers,
1022 duration_s: duration,
1023 stability: agg.stability,
1024 provider_divergence: agg.divergence,
1025 confidence_intervals: agg.confidence,
1026 merge_exclusions: agg.exclusions,
1027 }
1028}
1029
1030pub fn format_mbps(mbps: f64) -> String {
1032 if mbps >= 1000.0 {
1033 format!("{:.1} Gbps", mbps / 1000.0)
1034 } else if mbps >= 100.0 {
1035 format!("{:.0} Mbps", mbps)
1036 } else if mbps >= 10.0 {
1037 format!("{:.1} Mbps", mbps)
1038 } else {
1039 format!("{:.2} Mbps", mbps)
1040 }
1041}
1042
1043pub fn format_bytes(bytes: u64) -> String {
1045 const KB: u64 = 1024;
1046 const MB: u64 = 1024 * KB;
1047 const GB: u64 = 1024 * MB;
1048
1049 if bytes >= GB {
1050 format!("{:.2} GB", bytes as f64 / GB as f64)
1051 } else if bytes >= MB {
1052 format!("{:.1} MB", bytes as f64 / MB as f64)
1053 } else if bytes >= KB {
1054 format!("{:.1} KB", bytes as f64 / KB as f64)
1055 } else {
1056 format!("{} B", bytes)
1057 }
1058}
1059
1060#[cfg(test)]
1061mod tests {
1062 use super::*;
1063
1064 fn provider_with_samples(name: &str, download: Vec<f64>, upload: Vec<f64>) -> ProviderResult {
1065 ProviderResult {
1066 provider: name.to_string(),
1067 server: "test".to_string(),
1068 location: None,
1069 ping_ms: None,
1070 jitter_ms: None,
1071 download_mbps: download.last().copied(),
1072 upload_mbps: upload.last().copied(),
1073 download_bytes: 1,
1074 upload_bytes: 1,
1075 download_duration_s: 1.0,
1076 upload_duration_s: 1.0,
1077 packet_loss_pct: None,
1078 error: None,
1079 bandwidth_samples: Some(BandwidthSamples { download, upload }),
1080 availability: ProviderAvailability::Ran,
1081 latency_stats: None,
1082 loaded_latency: None,
1083 }
1084 }
1085
1086 fn dense(center: f64) -> Vec<f64> {
1089 (0..16).map(|i| center + (i % 4) as f64 - 1.5).collect()
1090 }
1091
1092 #[test]
1093 fn dense_probe_count_clamps() {
1094 assert_eq!(dense_probe_count(15.0), 50); assert_eq!(dense_probe_count(30.0), 99); assert_eq!(dense_probe_count(60.0), 198);
1097 assert_eq!(dense_probe_count(1000.0), 200); assert_eq!(dense_probe_count(1.0), 50); }
1100
1101 #[test]
1104 fn capacity_headline_tracks_well_sampled_providers() {
1105 let providers = vec![
1106 provider_with_samples("Cloudflare", dense(100.0), dense(20.0)),
1107 provider_with_samples("M-Lab NDT7", dense(100.0), dense(20.0)),
1108 ];
1109 let agg = aggregate(&providers);
1110 assert!(
1111 (agg.download - 100.0).abs() < 10.0,
1112 "capacity should track ~100, got {}",
1113 agg.download
1114 );
1115 assert_eq!(agg.capacity.download, agg.download);
1116 assert!(agg.capacity.download_ci.is_some());
1117 }
1118
1119 #[test]
1122 fn download_only_provider_recorded_as_upload_exclusion() {
1123 let providers = vec![
1124 provider_with_samples("Cloudflare", dense(100.0), dense(20.0)),
1125 provider_with_samples("CacheFly", dense(400.0), vec![]),
1126 ];
1127 let agg = aggregate(&providers);
1128 assert!(
1129 agg.exclusions
1130 .iter()
1131 .any(|e| e.provider == "CacheFly" && e.direction == "upload"),
1132 "CacheFly should be an upload exclusion: {:?}",
1133 agg.exclusions
1134 .iter()
1135 .map(|e| (&e.provider, e.direction, e.samples))
1136 .collect::<Vec<_>>()
1137 );
1138 }
1139
1140 #[test]
1143 fn low_sample_provider_excluded_from_merge() {
1144 let providers = vec![
1145 provider_with_samples("Cloudflare", dense(100.0), dense(20.0)),
1146 provider_with_samples("LibreSpeed", vec![1000.0], vec![1000.0]),
1147 ];
1148 let agg = aggregate(&providers);
1149 assert!(
1150 (agg.download - 100.0).abs() < 12.0,
1151 "merge should track the well-sampled provider, got {}",
1152 agg.download
1153 );
1154 assert!(
1155 agg.exclusions
1156 .iter()
1157 .any(|e| e.provider == "LibreSpeed" && e.direction == "download"),
1158 "LibreSpeed download exclusion should be recorded: {:?}",
1159 agg.exclusions
1160 .iter()
1161 .map(|e| (&e.provider, e.direction, e.samples))
1162 .collect::<Vec<_>>()
1163 );
1164 }
1165
1166 #[test]
1169 fn degraded_all_sparse_still_nonzero() {
1170 let providers = vec![
1171 provider_with_samples("Cloudflare", vec![100.0, 102.0], vec![20.0, 21.0]),
1172 provider_with_samples("LibreSpeed", vec![110.0, 108.0], vec![22.0, 23.0]),
1173 ];
1174 let agg = aggregate(&providers);
1175 assert!(agg.download > 0.0, "degraded merge must not return 0.0");
1176 }
1177
1178 #[test]
1181 fn headline_ping_is_min_rtt() {
1182 let mut cf = provider_with_samples("Cloudflare", dense(100.0), dense(20.0));
1183 cf.ping_ms = Some(30.0);
1184 let mut ndt = provider_with_samples("M-Lab NDT7", dense(100.0), dense(20.0));
1185 ndt.ping_ms = Some(12.0);
1186 let agg = aggregate(&[cf, ndt]);
1187 assert_eq!(agg.ping, Some(12.0));
1188 }
1189
1190 #[test]
1192 fn headline_jitter_is_pdv_from_latency_block() {
1193 let mut cf = provider_with_samples("Cloudflare", dense(100.0), dense(20.0));
1194 cf.latency_stats =
1195 LatencyStats::from_rtts(&[10.0, 11.0, 12.0, 20.0, 10.5, 30.0, 10.2, 11.5]);
1196 let agg = aggregate(&[cf]);
1197 let ls = agg.latency_stats.as_ref().expect("latency block present");
1198 assert_eq!(agg.jitter, Some(ls.pdv));
1199 assert!(agg.jitter.unwrap() >= 0.0);
1200 }
1201
1202 #[test]
1204 fn bufferbloat_and_rpm_from_loaded_latency() {
1205 let mut cf = provider_with_samples("Cloudflare", dense(100.0), dense(20.0));
1206 cf.latency_stats = LatencyStats::from_rtts(&[10.0; 8]);
1207 cf.loaded_latency = Some(LoadedLatency {
1208 download: vec![50.0; 5],
1209 upload: vec![55.0; 5],
1210 });
1211 let agg = aggregate(&[cf]);
1212 let bb = agg.bufferbloat.expect("bufferbloat present");
1213 assert!(
1214 bb.delta_ms > 30.0,
1215 "loaded 50-55 vs idle 10, got {}",
1216 bb.delta_ms
1217 );
1218 assert!(agg.rpm.expect("rpm present") > 0.0);
1219 }
1220
1221 #[test]
1223 fn empty_run_is_zeroed() {
1224 let mut failed = provider_with_samples("Cloudflare", vec![], vec![]);
1225 failed.error = Some("boom".to_string());
1226 let agg = aggregate(&[failed]);
1227 assert_eq!(agg.download, 0.0);
1228 assert_eq!(agg.agreement.band, statistics::AgreementBand::Insufficient);
1229 assert!(agg.ping.is_none());
1230 }
1231}