use super::adaptive::adaptive_chunk_bytes;
use super::{
statistics, BandwidthSamples, Phase, ProviderAvailability, ProviderResult, SpeedTestConfig,
TestDuration,
};
use reqwest::Client;
use std::time::{Duration, Instant};
const WARMUP_URL: &str = "https://cachefly.cachefly.net/1mb.test";
const MID_URL: &str = "https://cachefly.cachefly.net/10mb.test";
const LARGE_URL: &str = "https://cachefly.cachefly.net/100mb.test";
const LATENCY_URL: &str = "https://cachefly.cachefly.net/1mb.test";
const WARMUP_REQUESTS: u32 = 2;
const MIN_MID_TIER_REQUESTS: u32 = 2;
const MAX_MID_TIER_REQUESTS: u32 = 6;
const MAX_LARGE_TIER_REQUESTS: u32 = 500;
const TARGET_REQUEST_SECS: f64 = 2.0;
const RANGE_MIN_BYTES: u64 = 2_000_000;
const RANGE_MAX_BYTES: u64 = 100_000_000;
const SAMPLE_TICK_MS: f64 = 250.0;
const MAX_CONSECUTIVE_FAILURES: u32 = 3;
const DOWNLOAD_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
const RETRY_BACKOFF: Duration = Duration::from_millis(200);
const DEFAULT_DOWNLOAD_SECS: u64 = 15;
const LATENCY_PROBES: u32 = 10;
const LATENCY_WARMUP_DISCARD: usize = 2;
const LATENCY_PROBE_TIMEOUT: Duration = Duration::from_secs(5);
pub async fn run<F>(config: &SpeedTestConfig, progress: F) -> ProviderResult
where
F: Fn(Phase, f64) + Send + Sync,
{
match run_inner(config, &progress).await {
Ok(result) => result,
Err(e) => error_result(e.to_string()),
}
}
async fn run_inner<F>(config: &SpeedTestConfig, progress: &F) -> Result<ProviderResult, String>
where
F: Fn(Phase, f64) + Send + Sync,
{
let client = Client::builder()
.timeout(Duration::from_secs(120))
.build()
.map_err(|e| format!("HTTP client error: {e}"))?;
progress(Phase::CfyLatency, 0.0);
let mut rtt_samples: Vec<f64> = Vec::with_capacity(LATENCY_PROBES as usize);
for i in 0..LATENCY_PROBES {
let frac = i as f64 / LATENCY_PROBES as f64;
progress(Phase::CfyLatency, frac);
if let Some(rtt) = probe_latency_once(&client, LATENCY_URL).await {
rtt_samples.push(rtt);
}
}
progress(Phase::CfyLatency, 1.0);
let measured_rtts: &[f64] = if rtt_samples.len() > LATENCY_WARMUP_DISCARD {
&rtt_samples[LATENCY_WARMUP_DISCARD..]
} else {
&rtt_samples
};
let has_latency = !measured_rtts.is_empty();
let ping_ms = measured_rtts
.iter()
.copied()
.min_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let jitter_ms = (measured_rtts.len() >= 2).then(|| statistics::jitter_rfc3550(measured_rtts));
progress(Phase::CfyDownload, 0.0);
let total_secs = match &config.duration {
TestDuration::Seconds(s) => *s,
TestDuration::Auto => DEFAULT_DOWNLOAD_SECS,
};
let total_secs_f = total_secs as f64;
let phase_start = Instant::now();
let deadline = phase_start + Duration::from_secs(total_secs);
let mut last_request_mbps: f64 = 0.0;
let mut total_download_bytes: u64 = 0;
for _ in 0..WARMUP_REQUESTS {
if Instant::now() >= deadline {
break;
}
if let Some(resp) = raw_get(&client, WARMUP_URL, None, DOWNLOAD_REQUEST_TIMEOUT).await {
if let Some((bytes, elapsed_ms)) = time_whole_response(resp).await {
last_request_mbps = bytes_to_mbps(bytes as f64, elapsed_ms);
total_download_bytes += bytes;
let elapsed = phase_start.elapsed().as_secs_f64();
progress(Phase::CfyDownload, (elapsed / total_secs_f).min(1.0));
}
}
}
let mut raw_samples: Vec<f64> = Vec::new();
let mut ticker = DownloadTicker::new(Instant::now());
let mut report_tick = |_mbps: f64| {
let elapsed = phase_start.elapsed().as_secs_f64();
progress(Phase::CfyDownload, (elapsed / total_secs_f).min(1.0));
};
let mut mid_requests: u32 = 0;
let mut mid_failures: u32 = 0;
while Instant::now() < deadline && mid_requests < MAX_MID_TIER_REQUESTS {
let Some(resp) = raw_get(&client, MID_URL, None, DOWNLOAD_REQUEST_TIMEOUT).await else {
mid_failures += 1;
if mid_failures >= MAX_CONSECUTIVE_FAILURES {
break;
}
tokio::time::sleep(RETRY_BACKOFF).await;
continue;
};
mid_requests += 1;
let Some((bytes, elapsed_ms)) =
drain_into_ticker(resp, &mut ticker, &mut raw_samples, &mut report_tick).await
else {
mid_failures += 1;
if mid_failures >= MAX_CONSECUTIVE_FAILURES {
break;
}
continue;
};
mid_failures = 0;
last_request_mbps = bytes_to_mbps(bytes as f64, elapsed_ms);
total_download_bytes += bytes;
if mid_requests >= MIN_MID_TIER_REQUESTS && elapsed_ms < TARGET_REQUEST_SECS * 1000.0 {
break;
}
}
let mut large_requests: u32 = 0;
let mut large_failures: u32 = 0;
while Instant::now() < deadline && large_requests < MAX_LARGE_TIER_REQUESTS {
let range_bytes = adaptive_chunk_bytes(
last_request_mbps,
TARGET_REQUEST_SECS,
RANGE_MIN_BYTES,
RANGE_MAX_BYTES,
);
let Some(resp) = raw_get(
&client,
LARGE_URL,
Some(range_bytes),
DOWNLOAD_REQUEST_TIMEOUT,
)
.await
else {
large_failures += 1;
if large_failures >= MAX_CONSECUTIVE_FAILURES {
break;
}
tokio::time::sleep(RETRY_BACKOFF).await;
continue;
};
large_requests += 1;
let Some((bytes, elapsed_ms)) =
drain_into_ticker(resp, &mut ticker, &mut raw_samples, &mut report_tick).await
else {
large_failures += 1;
if large_failures >= MAX_CONSECUTIVE_FAILURES {
break;
}
continue;
};
large_failures = 0;
last_request_mbps = bytes_to_mbps(bytes as f64, elapsed_ms);
total_download_bytes += bytes;
}
ticker.flush(Instant::now(), true, &mut raw_samples, &mut report_tick); let download_duration_s = phase_start.elapsed().as_secs_f64();
if !has_latency && raw_samples.is_empty() && total_download_bytes == 0 {
return Err("CacheFly: no successful latency or download transfers".to_string());
}
let after_plateau: Vec<f64> = if raw_samples.is_empty() {
Vec::new()
} else {
raw_samples[statistics::plateau_start(&raw_samples).min(raw_samples.len())..].to_vec()
};
let iqr_basis = if after_plateau.is_empty() {
raw_samples.clone()
} else {
after_plateau
};
let cleaned = statistics::filter_outliers_iqr(&iqr_basis, 1.5);
let basis = if cleaned.is_empty() {
iqr_basis
} else {
cleaned
};
let download_speed = if basis.is_empty() {
0.0
} else {
statistics::modified_trimean(&basis)
};
let hl = if basis.is_empty() {
0.0
} else {
statistics::hodges_lehmann(&basis)
};
let _unstable = download_speed > 0.0 && (hl - download_speed).abs() / download_speed > 0.15;
progress(Phase::CfyDownload, 1.0);
Ok(ProviderResult {
provider: "CacheFly".to_string(),
server: "CacheFly CDN".to_string(),
location: None,
ping_ms,
jitter_ms,
download_mbps: if raw_samples.is_empty() {
None
} else {
Some(download_speed)
},
upload_mbps: None,
download_bytes: total_download_bytes,
upload_bytes: 0,
download_duration_s,
upload_duration_s: 0.0,
packet_loss_pct: None,
error: None,
bandwidth_samples: Some(BandwidthSamples {
download: raw_samples,
upload: Vec::new(),
}),
availability: ProviderAvailability::Ran,
latency_stats: None,
loaded_latency: None,
})
}
fn cache_bust_url(url: &str) -> String {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default();
format!("{url}?_t={}&_r={}", now.as_millis(), now.as_nanos())
}
fn bytes_to_mbps(bytes: f64, elapsed_ms: f64) -> f64 {
if elapsed_ms <= 0.0 || bytes <= 0.0 {
return 0.0;
}
(bytes * 8.0) / (elapsed_ms / 1000.0) / 1_000_000.0
}
async fn raw_get(
client: &Client,
url: &str,
range_bytes: Option<u64>,
timeout: Duration,
) -> Option<reqwest::Response> {
let mut req = client.get(cache_bust_url(url)).timeout(timeout);
if let Some(n) = range_bytes {
if n > 0 {
req = req.header("Range", format!("bytes=0-{}", n.saturating_sub(1)));
}
}
match req.send().await {
Ok(resp) if resp.status().is_success() => Some(resp),
_ => None,
}
}
async fn time_whole_response(resp: reqwest::Response) -> Option<(u64, f64)> {
let start = Instant::now();
match resp.bytes().await {
Ok(buf) if !buf.is_empty() => {
Some((buf.len() as u64, start.elapsed().as_secs_f64() * 1000.0))
}
_ => None,
}
}
async fn probe_latency_once(client: &Client, url: &str) -> Option<f64> {
let start = Instant::now();
client
.head(cache_bust_url(url))
.header("Range", "bytes=0-0")
.timeout(LATENCY_PROBE_TIMEOUT)
.send()
.await
.ok()?;
Some(start.elapsed().as_secs_f64() * 1000.0)
}
struct DownloadTicker {
interval_start: Instant,
interval_bytes: u64,
}
impl DownloadTicker {
fn new(start: Instant) -> Self {
Self {
interval_start: start,
interval_bytes: 0,
}
}
fn add_bytes(&mut self, n: u64) {
self.interval_bytes += n;
}
fn flush(
&mut self,
now: Instant,
force: bool,
raw_samples: &mut Vec<f64>,
on_tick: &mut dyn FnMut(f64),
) {
let elapsed_ms = now
.saturating_duration_since(self.interval_start)
.as_secs_f64()
* 1000.0;
if !force && elapsed_ms < SAMPLE_TICK_MS {
return;
}
if elapsed_ms <= 0.0 {
return;
}
if self.interval_bytes > 0 {
let mbps = bytes_to_mbps(self.interval_bytes as f64, elapsed_ms);
raw_samples.push(mbps);
on_tick(mbps);
}
self.interval_bytes = 0;
self.interval_start = now;
}
}
async fn drain_into_ticker(
mut resp: reqwest::Response,
ticker: &mut DownloadTicker,
raw_samples: &mut Vec<f64>,
on_tick: &mut dyn FnMut(f64),
) -> Option<(u64, f64)> {
let req_start = Instant::now();
let mut bytes: u64 = 0;
loop {
match resp.chunk().await {
Ok(Some(chunk)) => {
let n = chunk.len() as u64;
if n > 0 {
bytes += n;
ticker.add_bytes(n);
}
ticker.flush(Instant::now(), false, raw_samples, on_tick);
}
Ok(None) => break,
Err(_) => break, }
}
let elapsed_ms = req_start.elapsed().as_secs_f64() * 1000.0;
(bytes > 0).then_some((bytes, elapsed_ms))
}
fn error_result(msg: String) -> ProviderResult {
ProviderResult {
provider: "CacheFly".to_string(),
server: "CacheFly CDN".to_string(),
location: None,
ping_ms: None,
jitter_ms: None,
download_mbps: None,
upload_mbps: None,
download_bytes: 0,
upload_bytes: 0,
download_duration_s: 0.0,
upload_duration_s: 0.0,
packet_loss_pct: None,
error: Some(msg),
bandwidth_samples: None,
availability: ProviderAvailability::Failed,
latency_stats: None,
loaded_latency: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bytes_to_mbps_matches_hand_computed() {
assert!((bytes_to_mbps(10_000_000.0, 1000.0) - 80.0).abs() < 1e-9);
}
#[test]
fn bytes_to_mbps_guards_non_positive_inputs() {
assert_eq!(bytes_to_mbps(0.0, 1000.0), 0.0);
assert_eq!(bytes_to_mbps(100.0, 0.0), 0.0);
assert_eq!(bytes_to_mbps(-5.0, 1000.0), 0.0);
}
#[test]
fn ticker_flushes_only_after_real_interval() {
let start = Instant::now();
let mut ticker = DownloadTicker::new(start);
let mut samples = Vec::new();
ticker.add_bytes(1_000_000);
ticker.flush(start, false, &mut samples, &mut |_| {});
assert!(samples.is_empty());
let later = start + Duration::from_millis(10);
ticker.flush(later, true, &mut samples, &mut |_| {});
assert_eq!(samples.len(), 1);
}
#[test]
fn error_result_has_no_measurements() {
let r = error_result("boom".to_string());
assert_eq!(r.provider, "CacheFly");
assert!(r.error.is_some());
assert!(r.download_mbps.is_none());
assert!(r.upload_mbps.is_none());
assert_eq!(r.download_bytes, 0);
}
}