Skip to main content

rget/
progress.rs

1//! Progress telemetry (PRD §17, §18).
2//!
3//! The transfer engine never renders anything. It emits [`Event`]s and bumps
4//! atomic counters in [`Stats`]; a consumer (CLI renderer, JSON writer, future
5//! TUI or daemon) subscribes and decides what a human should see.
6//!
7//! Byte-level progress deliberately does *not* travel through the channel one
8//! message per socket read — at multi-gigabit that is tens of thousands of
9//! messages a second. Workers bump a relaxed atomic on every write and emit a
10//! coalesced [`Event::BytesWritten`] at most every 100 ms. Consumers that want
11//! smooth output sample [`Stats`] on their own clock.
12
13use std::collections::VecDeque;
14use std::sync::Arc;
15use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
16use std::time::{Duration, Instant};
17
18use serde::Serialize;
19use tokio::sync::mpsc;
20
21#[derive(Debug, Clone, Serialize)]
22#[serde(tag = "event", rename_all = "snake_case")]
23pub enum Event {
24    DownloadStarted {
25        id: String,
26        filename: String,
27        url: String,
28        total_size: Option<u64>,
29        resumed_bytes: u64,
30        connections: usize,
31        parallel: bool,
32    },
33    RangeStarted {
34        index: u64,
35        start: u64,
36        end: u64,
37    },
38    BytesWritten {
39        index: u64,
40        bytes: u64,
41    },
42    RangeCompleted {
43        index: u64,
44    },
45    RangeSplit {
46        index: u64,
47        new_index: u64,
48        at: u64,
49    },
50    RetryScheduled {
51        index: Option<u64>,
52        attempt: u32,
53        delay_ms: u64,
54        reason: String,
55    },
56    /// A committer barrier completed: this many bytes are now durable.
57    Checkpointed {
58        durable_bytes: u64,
59    },
60    DownloadPaused {
61        downloaded: u64,
62        total_size: Option<u64>,
63    },
64    VerificationStarted {
65        algorithm: String,
66        total_size: u64,
67    },
68    VerificationProgress {
69        bytes: u64,
70        total_size: u64,
71    },
72    VerificationCompleted {
73        algorithm: String,
74        ok: bool,
75        expected: Option<String>,
76        actual: String,
77    },
78    DownloadCompleted {
79        downloaded: u64,
80        elapsed_ms: u64,
81        average_bps: u64,
82    },
83    DownloadFailed {
84        error: String,
85    },
86    /// Human-facing note that is not a lifecycle transition.
87    Note {
88        level: NoteLevel,
89        message: String,
90    },
91}
92
93#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
94#[serde(rename_all = "snake_case")]
95pub enum NoteLevel {
96    Info,
97    Warn,
98    Error,
99}
100
101/// Hot-path counters. Cheap to read, cheap to bump, safe to share.
102#[derive(Debug, Default)]
103pub struct Stats {
104    /// Bytes written to the file this run, plus whatever we resumed with.
105    downloaded: AtomicU64,
106    /// Bytes confirmed durable by a committer barrier.
107    durable: AtomicU64,
108    active_connections: AtomicUsize,
109    ranges_total: AtomicUsize,
110    ranges_complete: AtomicUsize,
111    retries: AtomicU64,
112}
113
114impl Stats {
115    pub fn add_downloaded(&self, n: u64) {
116        self.downloaded.fetch_add(n, Ordering::Relaxed);
117    }
118    pub fn set_downloaded(&self, n: u64) {
119        self.downloaded.store(n, Ordering::Relaxed);
120    }
121    pub fn downloaded(&self) -> u64 {
122        self.downloaded.load(Ordering::Relaxed)
123    }
124    pub fn set_durable(&self, n: u64) {
125        self.durable.store(n, Ordering::Relaxed);
126    }
127    pub fn durable(&self) -> u64 {
128        self.durable.load(Ordering::Relaxed)
129    }
130    pub fn connection_opened(&self) {
131        self.active_connections.fetch_add(1, Ordering::Relaxed);
132    }
133    pub fn connection_closed(&self) {
134        // Saturating: a double-close must not wrap to usize::MAX and make the
135        // UI claim 18 quintillion connections.
136        let _ = self
137            .active_connections
138            .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |v| {
139                Some(v.saturating_sub(1))
140            });
141    }
142    pub fn active_connections(&self) -> usize {
143        self.active_connections.load(Ordering::Relaxed)
144    }
145    pub fn set_ranges_total(&self, n: usize) {
146        self.ranges_total.store(n, Ordering::Relaxed);
147    }
148    pub fn set_ranges_complete(&self, n: usize) {
149        self.ranges_complete.store(n, Ordering::Relaxed);
150    }
151    pub fn ranges(&self) -> (usize, usize) {
152        (
153            self.ranges_complete.load(Ordering::Relaxed),
154            self.ranges_total.load(Ordering::Relaxed),
155        )
156    }
157    pub fn record_retry(&self) {
158        self.retries.fetch_add(1, Ordering::Relaxed);
159    }
160    pub fn retries(&self) -> u64 {
161        self.retries.load(Ordering::Relaxed)
162    }
163}
164
165/// The engine's handle for publishing telemetry. Cloneable into every worker.
166#[derive(Clone)]
167pub struct Reporter {
168    tx: Option<mpsc::UnboundedSender<Event>>,
169    pub stats: Arc<Stats>,
170}
171
172impl Reporter {
173    pub fn new() -> (Self, mpsc::UnboundedReceiver<Event>) {
174        let (tx, rx) = mpsc::unbounded_channel();
175        (
176            Self {
177                tx: Some(tx),
178                stats: Arc::new(Stats::default()),
179            },
180            rx,
181        )
182    }
183
184    /// A reporter that records counters but publishes nothing — used in tests
185    /// and by code paths with no consumer attached.
186    pub fn silent() -> Self {
187        Self {
188            tx: None,
189            stats: Arc::new(Stats::default()),
190        }
191    }
192
193    pub fn emit(&self, event: Event) {
194        if let Some(tx) = &self.tx {
195            // A closed consumer is not an error: the download outlives the UI.
196            let _ = tx.send(event);
197        }
198    }
199
200    pub fn note(&self, level: NoteLevel, message: impl Into<String>) {
201        self.emit(Event::Note {
202            level,
203            message: message.into(),
204        });
205    }
206
207    pub fn info(&self, message: impl Into<String>) {
208        self.note(NoteLevel::Info, message);
209    }
210
211    pub fn warn(&self, message: impl Into<String>) {
212        self.note(NoteLevel::Warn, message);
213    }
214}
215
216/// Rolling-window throughput. PRD §17: `downloaded / runtime` is a bad speed
217/// readout, because it cannot fall when the network stalls.
218pub struct SpeedMeter {
219    window: Duration,
220    samples: VecDeque<(Instant, u64)>,
221    /// Exponentially weighted average, used for ETA so the estimate does not
222    /// jump around with every sample.
223    smoothed_bps: f64,
224    started: Instant,
225    start_bytes: u64,
226}
227
228impl SpeedMeter {
229    pub fn new(window: Duration, start_bytes: u64) -> Self {
230        let now = Instant::now();
231        let mut samples = VecDeque::with_capacity(64);
232        samples.push_back((now, start_bytes));
233        Self {
234            window,
235            samples,
236            smoothed_bps: 0.0,
237            started: now,
238            start_bytes,
239        }
240    }
241
242    pub fn record(&mut self, total_downloaded: u64) {
243        let now = Instant::now();
244        let instant_bps = self.instant_from(now, total_downloaded);
245        self.samples.push_back((now, total_downloaded));
246        while let Some(&(t, _)) = self.samples.front() {
247            if now.duration_since(t) > self.window && self.samples.len() > 2 {
248                self.samples.pop_front();
249            } else {
250                break;
251            }
252        }
253        // ~3s time constant at a 10 Hz sample rate.
254        const ALPHA: f64 = 0.15;
255        self.smoothed_bps = if self.smoothed_bps == 0.0 {
256            instant_bps
257        } else {
258            ALPHA * instant_bps + (1.0 - ALPHA) * self.smoothed_bps
259        };
260    }
261
262    fn instant_from(&self, now: Instant, total: u64) -> f64 {
263        let Some(&(t0, b0)) = self.samples.front() else {
264            return 0.0;
265        };
266        let dt = now.duration_since(t0).as_secs_f64();
267        if dt <= 0.0 {
268            return 0.0;
269        }
270        (total.saturating_sub(b0)) as f64 / dt
271    }
272
273    /// Throughput over the rolling window.
274    pub fn rolling_bps(&self) -> f64 {
275        let (&(t0, b0), &(t1, b1)) = match (self.samples.front(), self.samples.back()) {
276            (Some(a), Some(b)) => (a, b),
277            _ => return 0.0,
278        };
279        let dt = t1.duration_since(t0).as_secs_f64();
280        if dt <= 0.0 {
281            return 0.0;
282        }
283        (b1.saturating_sub(b0)) as f64 / dt
284    }
285
286    /// Smoothed throughput — the right input for an ETA.
287    pub fn smoothed_bps(&self) -> f64 {
288        self.smoothed_bps
289    }
290
291    /// Whole-run average, for the completion summary only.
292    pub fn average_bps(&self, total_downloaded: u64) -> f64 {
293        let dt = self.started.elapsed().as_secs_f64();
294        if dt <= 0.0 {
295            return 0.0;
296        }
297        (total_downloaded.saturating_sub(self.start_bytes)) as f64 / dt
298    }
299
300    pub fn elapsed(&self) -> Duration {
301        self.started.elapsed()
302    }
303}
304
305/// What a renderer needs for one frame.
306#[derive(Debug, Clone, Serialize)]
307pub struct Snapshot {
308    pub filename: String,
309    pub downloaded: u64,
310    pub total_size: Option<u64>,
311    pub bps: f64,
312    pub smoothed_bps: f64,
313    pub eta_secs: Option<u64>,
314    pub elapsed_ms: u64,
315    pub active_connections: usize,
316    pub ranges_complete: usize,
317    pub ranges_total: usize,
318    pub retries: u64,
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn connection_count_never_wraps() {
327        let s = Stats::default();
328        s.connection_closed();
329        assert_eq!(s.active_connections(), 0);
330        s.connection_opened();
331        s.connection_opened();
332        assert_eq!(s.active_connections(), 2);
333        s.connection_closed();
334        assert_eq!(s.active_connections(), 1);
335    }
336
337    #[test]
338    fn silent_reporter_still_counts() {
339        let r = Reporter::silent();
340        r.emit(Event::RangeCompleted { index: 1 });
341        r.stats.add_downloaded(10);
342        assert_eq!(r.stats.downloaded(), 10);
343    }
344
345    #[test]
346    fn reporter_survives_dropped_consumer() {
347        let (r, rx) = Reporter::new();
348        drop(rx);
349        r.info("nobody is listening");
350        assert_eq!(r.stats.downloaded(), 0);
351    }
352
353    #[test]
354    fn rolling_speed_drops_when_transfer_stalls() {
355        let mut m = SpeedMeter::new(Duration::from_millis(300), 0);
356        // Simulate a burst then a stall by advancing real time.
357        m.record(1_000_000);
358        std::thread::sleep(Duration::from_millis(50));
359        m.record(2_000_000);
360        let fast = m.rolling_bps();
361        assert!(fast > 0.0);
362
363        // Stall: no new bytes for longer than the window.
364        for _ in 0..8 {
365            std::thread::sleep(Duration::from_millis(60));
366            m.record(2_000_000);
367        }
368        assert!(
369            m.rolling_bps() < fast / 2.0,
370            "rolling speed should collapse when stalled: {} vs {}",
371            m.rolling_bps(),
372            fast
373        );
374    }
375
376    #[test]
377    fn average_excludes_resumed_bytes() {
378        let m = SpeedMeter::new(Duration::from_secs(3), 5_000);
379        // Only the 1_000 bytes downloaded this run count towards the average.
380        let avg = m.average_bps(6_000);
381        assert!(avg > 0.0);
382        assert!(m.average_bps(5_000) == 0.0);
383    }
384}