1use 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 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 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#[derive(Debug, Default)]
103pub struct Stats {
104 downloaded: AtomicU64,
106 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 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#[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 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 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
216pub struct SpeedMeter {
219 window: Duration,
220 samples: VecDeque<(Instant, u64)>,
221 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 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 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 pub fn smoothed_bps(&self) -> f64 {
288 self.smoothed_bps
289 }
290
291 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#[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 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 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 let avg = m.average_bps(6_000);
381 assert!(avg > 0.0);
382 assert!(m.average_bps(5_000) == 0.0);
383 }
384}