pre-flight: bandwidth test + persistent config

First-run host launch now offers a one-time upstream measurement
against speed.cloudflare.com/__up via ureq (~5 MB POST, ~5s). The
result lives at ~/.config/pixelpass/config.toml under [bandwidth]
and feeds the default --max-viewers calculation on subsequent runs.

Sticky semantics for the dialog:
- Unmeasured: first-run prompt (Run / Skip)
- Measured / Skipped: silent — never re-prompts
- Failed: ask again on next launch (Retry / give up → Skipped)

`pixelpass --reconfigure` re-runs the test unconditionally for users
whose connection has changed (new ISP, moved house, etc.).

--max-viewers is now Option<u32>. When unset, host startup loads the
saved measurement, runs recommended_max_viewers(safe_mbps, bitrate),
and surfaces the source in the banner: "max viewers : N (auto: X.X
Mbps measured upstream)" — or user-specified / default fallback.

User verified end-to-end on 2026-05-21 16:54 EDT: first-run dialog,
skip path, run path, --reconfigure refresh, and banner integration
all work as expected.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-21 16:55:11 -04:00
parent ffe5a90686
commit 153febe078
9 changed files with 439 additions and 16 deletions
+73
View File
@@ -0,0 +1,73 @@
//! 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;
if per_viewer_mbps <= 0.0 {
return 1;
}
let n = (safe_mbps / per_viewer_mbps).floor();
if n < 1.0 { 1 } else { n as u32 }
}