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:
@@ -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 }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Persistent user-level config at `~/.config/pixelpass/config.toml`.
|
||||
//!
|
||||
//! Right now this only tracks the bandwidth pre-flight result. Future
|
||||
//! preferences (default player, default bitrate, etc.) can hang off the
|
||||
//! same file under their own `[section]`.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Utc};
|
||||
use directories::ProjectDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Config {
|
||||
#[serde(default)]
|
||||
pub bandwidth: BandwidthEntry,
|
||||
}
|
||||
|
||||
/// Result of the first-run upstream measurement.
|
||||
///
|
||||
/// `status = "unmeasured"` means we've never asked the user — show the
|
||||
/// first-run dialog. `"measured"` means we have a number. `"skipped"`
|
||||
/// means the user opted out (sticky — don't ask again). `"failed"`
|
||||
/// means the last attempt errored and we should ask the user on next
|
||||
/// interactive launch whether to retry.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct BandwidthEntry {
|
||||
#[serde(default = "default_status")]
|
||||
pub status: BandwidthStatus,
|
||||
#[serde(default)]
|
||||
pub upstream_mbps: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub measured_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum BandwidthStatus {
|
||||
Unmeasured,
|
||||
Measured,
|
||||
Skipped,
|
||||
Failed,
|
||||
}
|
||||
|
||||
impl Default for BandwidthStatus {
|
||||
fn default() -> Self {
|
||||
Self::Unmeasured
|
||||
}
|
||||
}
|
||||
|
||||
fn default_status() -> BandwidthStatus {
|
||||
BandwidthStatus::Unmeasured
|
||||
}
|
||||
|
||||
/// Returns `~/.config/pixelpass/config.toml` (or the XDG equivalent on other
|
||||
/// platforms). The parent directory is created lazily by [`save`].
|
||||
pub fn config_path() -> Result<PathBuf> {
|
||||
let dirs = ProjectDirs::from("", "", "pixelpass")
|
||||
.context("could not locate a config directory for pixelpass")?;
|
||||
Ok(dirs.config_dir().join("config.toml"))
|
||||
}
|
||||
|
||||
/// Returns the loaded config, or a `Default` instance if the file doesn't
|
||||
/// exist yet. Bubble up parse errors so we don't silently overwrite a
|
||||
/// hand-edited config the user is debugging.
|
||||
pub fn load() -> Result<Config> {
|
||||
let path = config_path()?;
|
||||
match fs::read_to_string(&path) {
|
||||
Ok(s) => toml::from_str::<Config>(&s)
|
||||
.with_context(|| format!("failed to parse {}", path.display())),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Config::default()),
|
||||
Err(e) => Err(e).with_context(|| format!("failed to read {}", path.display())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Atomic write via tempfile-in-same-dir + rename.
|
||||
pub fn save(cfg: &Config) -> Result<()> {
|
||||
let path = config_path()?;
|
||||
let parent = path
|
||||
.parent()
|
||||
.context("config path has no parent directory")?;
|
||||
fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create {}", parent.display()))?;
|
||||
|
||||
let serialized =
|
||||
toml::to_string_pretty(cfg).context("failed to serialize config to TOML")?;
|
||||
|
||||
let tmp = parent.join(format!(".config.toml.tmp.{}", std::process::id()));
|
||||
{
|
||||
let mut f = fs::File::create(&tmp)
|
||||
.with_context(|| format!("failed to create {}", tmp.display()))?;
|
||||
f.write_all(serialized.as_bytes())
|
||||
.with_context(|| format!("failed to write {}", tmp.display()))?;
|
||||
f.sync_all().ok();
|
||||
}
|
||||
fs::rename(&tmp, &path)
|
||||
.with_context(|| format!("failed to rename {} -> {}", tmp.display(), path.display()))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod alpn;
|
||||
pub mod bandwidth;
|
||||
pub mod config;
|
||||
pub mod deps;
|
||||
pub mod display;
|
||||
pub mod process;
|
||||
|
||||
Reference in New Issue
Block a user