use crate::{Event, ProgressSample, Tx, core::state::PartialDownloadStateInner};
use fast_down::Total;
use parking_lot::Mutex;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::spawn;
use tokio::time::sleep;
#[derive(Default)]
pub(super) struct RateEstimator {
last: Option<(Instant, u64)>,
ema_bps: f64,
}
impl RateEstimator {
const TAU: Duration = Duration::from_secs(3);
pub fn observe(&mut self, now: Instant, downloaded: u64) -> u64 {
if let Some((t, b)) = self.last {
let dt = now.duration_since(t);
if !dt.is_zero() {
#[allow(clippy::cast_precision_loss)]
let raw = downloaded.saturating_sub(b) as f64 / dt.as_secs_f64();
let alpha = 1.0 - (-dt.as_secs_f64() / Self::TAU.as_secs_f64()).exp();
self.ema_bps = alpha.mul_add(raw - self.ema_bps, self.ema_bps);
}
}
self.last = Some((now, downloaded));
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
{
self.ema_bps as u64
}
}
}
#[derive(Clone)]
pub(super) struct ProgressReporter {
state: Arc<Mutex<PartialDownloadStateInner>>,
total: u64,
start: Instant,
loaded_elapsed: Duration,
}
impl ProgressReporter {
pub fn new(
loaded_elapsed: Duration,
total: u64,
state: Arc<Mutex<PartialDownloadStateInner>>,
) -> Self {
Self {
state,
total,
start: Instant::now(),
loaded_elapsed,
}
}
#[must_use]
pub fn elapsed_now(&self, now: Instant) -> Duration {
self.loaded_elapsed
.saturating_add(now.duration_since(self.start))
}
#[must_use]
pub fn compute(&self, now: Instant, rate: Option<&mut RateEstimator>) -> ProgressSample {
let progress = self
.state
.lock()
.config
.as_ref()
.and_then(|c| c.downloaded_chunk.clone())
.unwrap_or_default();
let downloaded = progress.total();
let total = self.total;
let bps = rate.map_or(0, |r| r.observe(now, downloaded));
let elapsed = self.elapsed_now(now);
let elapsed_ms = u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX);
let avg_bps = downloaded
.saturating_mul(1000)
.checked_div(elapsed_ms)
.unwrap_or(0);
let percent = if total == 0 {
0.0
} else {
#[allow(clippy::cast_precision_loss)]
{
downloaded as f64 / total as f64 * 100.0
}
};
let eta = {
let remaining = total.saturating_sub(downloaded);
let rate = if bps > 0 { bps } else { avg_bps };
if rate == 0 {
None
} else {
remaining
.saturating_mul(1000)
.checked_div(rate)
.map(Duration::from_millis)
}
};
ProgressSample {
progress,
bps,
avg_bps,
downloaded,
percent,
total,
elapsed,
eta,
}
}
#[must_use]
pub fn spawn(self, tx: &Tx, gap: Duration) -> tokio::task::JoinHandle<()> {
let tx = tx.clone();
spawn(async move {
let mut rate = RateEstimator::default();
loop {
let sample = self.compute(Instant::now(), Some(&mut rate));
let _ = tx.send(Event::Progress(sample));
sleep(gap).await;
}
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::too_many_lines)]
use super::{ProgressReporter, RateEstimator};
use crate::PartialConfig;
use crate::core::state::DownloadState;
use fast_down::UrlInfo;
use std::path::Path;
use std::time::{Duration, Instant};
use url::Url;
#[test]
fn compute_reports_zero_percent_when_total_is_zero() {
let url = Url::parse("https://example.com/x").unwrap();
let info = UrlInfo {
size: 0,
raw_name: "x".to_string(),
supports_range: false,
fast_download: false,
final_url: url.clone(),
file_id: fast_down::FileId::new(None, None),
content_type: None,
};
let state = DownloadState::new(
&url,
&info,
&PartialConfig::default(),
Path::new("/tmp/_pr_zero_total.fd"),
);
let reporter = ProgressReporter::new(Duration::ZERO, 0, state.share_inner());
let sample = reporter.compute(Instant::now(), None);
assert_eq!(sample.total, 0);
assert!(
(sample.percent).abs() < f64::EPSILON,
"percent must be 0.0 when total is 0, got {}",
sample.percent
);
assert_eq!(sample.downloaded, 0);
assert!(sample.eta.is_none());
}
#[test]
#[allow(clippy::single_range_in_vec_init)]
fn compute_reports_half_progress_fields() {
let url = Url::parse("https://example.com/x").unwrap();
let info = UrlInfo {
size: 1000,
raw_name: "x".to_string(),
supports_range: true,
fast_download: true,
final_url: url.clone(),
file_id: fast_down::FileId::new(None, None),
content_type: None,
};
let state = DownloadState::new(
&url,
&info,
&PartialConfig::default(),
Path::new("/tmp/_pr_fields.fd"),
);
state.update(|inner| {
inner.config.get_or_insert_default().downloaded_chunk = Some(vec![0u64..500]);
});
let reporter = ProgressReporter::new(Duration::from_secs(1), 1000, state.share_inner());
let sample = reporter.compute(Instant::now(), None);
assert_eq!(sample.total, 1000);
assert_eq!(sample.downloaded, 500);
assert!(
(sample.percent - 50.0).abs() < f64::EPSILON,
"percent must be 50.0 for half-downloaded file, got {}",
sample.percent
);
assert_eq!(sample.bps, 0, "no rate estimator => bps forced to 0");
assert!(
sample.avg_bps > 0,
"avg_bps must be positive with non-zero elapsed"
);
assert!(
sample.eta.is_some(),
"eta must be computable once bytes are downloaded"
);
}
#[test]
fn rate_estimator_smooths_and_lags() {
let mut est = RateEstimator::default();
let t0 = Instant::now();
assert_eq!(
est.observe(t0, 0),
0,
"first observation has no interval => 0"
);
let e1 = est.observe(t0 + Duration::from_secs(1), 100);
assert!(
e1 > 0 && e1 < 100,
"EMA must lag the 100 bps raw rate, got {e1}"
);
let e2 = est.observe(t0 + Duration::from_secs(2), 200);
assert!(
e2 > e1,
"EMA must increase toward the steady rate, got {e2}"
);
assert!(
e2 <= 100,
"EMA must stay at/below the steady 100 bps, got {e2}"
);
}
#[test]
fn rate_estimator_zero_dt_skips_update_and_advances_last() {
let mut est = RateEstimator::default();
let t0 = Instant::now();
assert_eq!(est.observe(t0, 0), 0);
assert_eq!(
est.observe(t0, 500),
0,
"zero dt must leave the EMA (still 0) untouched"
);
let r = est.observe(t0 + Duration::from_secs(1), 600);
assert!(
r > 0 && r < 100,
"next interval must measure from the advanced baseline, got {r}"
);
}
#[test]
fn rate_estimator_converges_toward_steady_rate() {
let mut est = RateEstimator::default();
let t0 = Instant::now();
est.observe(t0, 0);
let mut last = 0u64;
for i in 1u64..=10 {
last = est.observe(t0 + Duration::from_secs(i), i * 100);
}
assert!(
(90..=100).contains(&last),
"EMA must converge to the steady 100 bps, got {last}"
);
}
#[test]
fn rate_estimator_decays_when_download_stalls() {
let mut est = RateEstimator::default();
let t0 = Instant::now();
est.observe(t0, 0);
let warm = est.observe(t0 + Duration::from_secs(1), 100);
assert!(warm > 0, "first interval must heat the EMA up");
let decayed = est.observe(t0 + Duration::from_secs(2), 100);
assert!(
decayed < warm,
"a stalled download must decay the EMA: {decayed} >= {warm}"
);
}
#[test]
fn elapsed_now_includes_loaded_elapsed() {
let url = Url::parse("https://example.com/x").unwrap();
let info = UrlInfo {
size: 1,
raw_name: "x".to_string(),
supports_range: false,
fast_download: false,
final_url: url.clone(),
file_id: fast_down::FileId::new(None, None),
content_type: None,
};
let state = DownloadState::new(
&url,
&info,
&PartialConfig::default(),
Path::new("/tmp/_pr_elapsed.fd"),
);
let loaded = Duration::from_secs(5);
let reporter = ProgressReporter::new(loaded, 1, state.share_inner());
let now = Instant::now();
let elapsed = reporter.elapsed_now(now);
assert!(
elapsed >= loaded,
"elapsed_now must include loaded_elapsed, got {elapsed:?}"
);
assert!(
elapsed < loaded + Duration::from_secs(1),
"elapsed must be ~loaded + tiny run time, got {elapsed:?}"
);
}
}