32131b0ccb
recommended_max_viewers() promises "at least 1", but a NaN safe_mbps cast to 0 and an infinite one to u32::MAX. Guard non-finite / non-positive inputs up front. Add unit tests covering the normal path, the floor, and the NaN/Inf/negative degenerate cases. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
106 lines
3.8 KiB
Rust
106 lines
3.8 KiB
Rust
//! One-shot upstream bandwidth measurement against Cloudflare's open
|
|
//! speed-test endpoint. POST a fixed payload, time it, derive Mbps.
|
|
//!
|
|
//! Run via `tokio::task::spawn_blocking` from async contexts — ureq is a
|
|
//! blocking client and we don't want to wedge the tokio runtime during
|
|
//! the test.
|
|
|
|
use anyhow::{Context, Result};
|
|
use std::time::{Duration, Instant};
|
|
|
|
const ENDPOINT: &str = "https://speed.cloudflare.com/__up";
|
|
const PAYLOAD_BYTES: usize = 5 * 1024 * 1024; // 5 MiB
|
|
const HTTP_TIMEOUT: Duration = Duration::from_secs(30);
|
|
/// Multiplier applied to the raw measurement. TCP slow-start, ramp-up, and
|
|
/// real-world contention all mean a one-shot upstream test slightly
|
|
/// overestimates sustainable throughput; clamp to 80% for headroom.
|
|
const SAFETY_FACTOR: f64 = 0.80;
|
|
|
|
/// Result of a successful measurement.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Measurement {
|
|
/// Raw measured throughput in megabits per second.
|
|
pub raw_mbps: f64,
|
|
/// `raw_mbps * SAFETY_FACTOR` — the value to use when sizing things.
|
|
pub safe_mbps: f64,
|
|
/// How long the upload took.
|
|
pub elapsed: Duration,
|
|
}
|
|
|
|
/// Blocking upload-speed test. Call from a `spawn_blocking` task.
|
|
pub fn measure_upstream_blocking() -> Result<Measurement> {
|
|
let payload = vec![0u8; PAYLOAD_BYTES];
|
|
let agent = ureq::Agent::config_builder()
|
|
.timeout_global(Some(HTTP_TIMEOUT))
|
|
.build()
|
|
.new_agent();
|
|
|
|
let start = Instant::now();
|
|
let response = agent
|
|
.post(ENDPOINT)
|
|
.content_type("application/octet-stream")
|
|
.send(&payload[..])
|
|
.context("upload request to Cloudflare failed")?;
|
|
let elapsed = start.elapsed();
|
|
|
|
let status = response.status();
|
|
if !status.is_success() {
|
|
anyhow::bail!("Cloudflare returned HTTP {status}");
|
|
}
|
|
|
|
let bits = (PAYLOAD_BYTES as f64) * 8.0;
|
|
let seconds = elapsed.as_secs_f64().max(0.001);
|
|
let raw_mbps = bits / seconds / 1_000_000.0;
|
|
let safe_mbps = raw_mbps * SAFETY_FACTOR;
|
|
|
|
Ok(Measurement {
|
|
raw_mbps,
|
|
safe_mbps,
|
|
elapsed,
|
|
})
|
|
}
|
|
|
|
/// Convert a safe-upstream Mbps figure plus the host's per-viewer bitrate
|
|
/// (kbps for video, ignoring audio + protocol overhead which we account for
|
|
/// via SAFETY_FACTOR) into a recommended viewer count. Floors to at least 1.
|
|
pub fn recommended_max_viewers(safe_mbps: f64, bitrate_kbps: u32) -> u32 {
|
|
let per_viewer_mbps = (bitrate_kbps as f64) / 1000.0;
|
|
// Guard non-finite / non-positive inputs (only reachable from a corrupted
|
|
// config): a NaN safe_mbps would cast to 0 and an infinite one to u32::MAX,
|
|
// both of which break the "at least 1" contract.
|
|
if !safe_mbps.is_finite() || safe_mbps <= 0.0 || per_viewer_mbps <= 0.0 {
|
|
return 1;
|
|
}
|
|
let n = (safe_mbps / per_viewer_mbps).floor();
|
|
if n < 1.0 { 1 } else { n as u32 }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::recommended_max_viewers;
|
|
|
|
#[test]
|
|
fn divides_bandwidth_by_per_viewer_bitrate() {
|
|
// 8 Mbps safe / 2 Mbps each = 4 viewers.
|
|
assert_eq!(recommended_max_viewers(8.0, 2000), 4);
|
|
// Floors the fractional part: 7.9 / 2 = 3.95 -> 3.
|
|
assert_eq!(recommended_max_viewers(7.9, 2000), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn floors_to_at_least_one() {
|
|
// Not even enough for one viewer still allows one (best effort).
|
|
assert_eq!(recommended_max_viewers(0.5, 2000), 1);
|
|
// Zero / unknown bitrate can't size a budget; floor to one.
|
|
assert_eq!(recommended_max_viewers(8.0, 0), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn degenerate_inputs_floor_to_one() {
|
|
// A corrupted config must not yield 0 (NaN) or u32::MAX (Inf).
|
|
assert_eq!(recommended_max_viewers(f64::NAN, 2000), 1);
|
|
assert_eq!(recommended_max_viewers(f64::INFINITY, 2000), 1);
|
|
assert_eq!(recommended_max_viewers(-5.0, 2000), 1);
|
|
}
|
|
}
|