Skip to main content

reth_cli_commands/download/
progress.rs

1use eyre::Result;
2use reth_cli_util::cancellation::CancellationToken;
3use std::{
4    io::{self, Read, Write},
5    sync::{
6        atomic::{AtomicU64, Ordering},
7        Arc, Condvar, Mutex,
8    },
9    time::{Duration, Instant},
10};
11use tokio::sync::Notify;
12use tracing::info;
13
14const BYTE_UNITS: [&str; 4] = ["B", "KB", "MB", "GB"];
15
16/// Tracks download progress and throttles display updates to every 100ms.
17pub(crate) struct DownloadProgress {
18    /// Bytes copied so far for this single download.
19    pub(crate) downloaded: u64,
20    /// Total bytes expected for this single download.
21    total_size: u64,
22    /// Time when the progress line was last printed.
23    last_displayed: Instant,
24    /// Time when this progress tracker started.
25    started_at: Instant,
26}
27
28impl DownloadProgress {
29    /// Creates new progress tracker with given total size
30    pub(crate) fn new(total_size: u64) -> Self {
31        let now = Instant::now();
32        Self { downloaded: 0, total_size, last_displayed: now, started_at: now }
33    }
34
35    /// Converts bytes to human readable format (B, KB, MB, GB)
36    pub(crate) fn format_size(size: u64) -> String {
37        let mut size = size as f64;
38        let mut unit_index = 0;
39
40        while size >= 1024.0 && unit_index < BYTE_UNITS.len() - 1 {
41            size /= 1024.0;
42            unit_index += 1;
43        }
44
45        format!("{:.2} {}", size, BYTE_UNITS[unit_index])
46    }
47
48    /// Format duration as human readable string
49    pub(crate) fn format_duration(duration: Duration) -> String {
50        let secs = duration.as_secs();
51        if secs < 60 {
52            format!("{secs}s")
53        } else if secs < 3600 {
54            format!("{}m {}s", secs / 60, secs % 60)
55        } else {
56            format!("{}h {}m", secs / 3600, (secs % 3600) / 60)
57        }
58    }
59
60    /// Updates progress bar (for single-archive legacy downloads)
61    pub(crate) fn update(&mut self, chunk_size: u64) -> Result<()> {
62        self.downloaded += chunk_size;
63
64        if self.last_displayed.elapsed() >= Duration::from_millis(100) {
65            let formatted_downloaded = Self::format_size(self.downloaded);
66            let formatted_total = Self::format_size(self.total_size);
67            let progress = (self.downloaded as f64 / self.total_size as f64) * 100.0;
68
69            let elapsed = self.started_at.elapsed();
70            let eta = if self.downloaded > 0 {
71                let remaining = self.total_size.saturating_sub(self.downloaded);
72                let speed = self.downloaded as f64 / elapsed.as_secs_f64();
73                if speed > 0.0 {
74                    Duration::from_secs_f64(remaining as f64 / speed)
75                } else {
76                    Duration::ZERO
77                }
78            } else {
79                Duration::ZERO
80            };
81            let eta_str = Self::format_duration(eta);
82
83            print!(
84                "\rDownloading and extracting... {progress:.2}% ({formatted_downloaded} / {formatted_total}) ETA: {eta_str}     ",
85            );
86            io::stdout().flush()?;
87            self.last_displayed = Instant::now();
88        }
89
90        Ok(())
91    }
92}
93
94#[derive(Debug, Clone, Copy)]
95struct PhaseStart {
96    started_at: Instant,
97    baseline_bytes: u64,
98}
99
100/// Shared progress counters for parallel downloads.
101pub(crate) struct SharedProgress {
102    /// Raw HTTP bytes fetched during this session, including retries.
103    pub(crate) session_fetched_bytes: AtomicU64,
104    /// Compressed bytes from archives that have fully downloaded.
105    pub(crate) completed_download_bytes: AtomicU64,
106    /// Compressed bytes written for currently active archive download attempts.
107    pub(crate) active_download_bytes: AtomicU64,
108    /// Total compressed bytes expected across all planned archives.
109    pub(crate) total_download_bytes: u64,
110    /// Plain-output bytes from archives that have fully verified.
111    pub(crate) completed_output_bytes: AtomicU64,
112    /// Plain-output bytes unpacked by currently active extractions.
113    pub(crate) active_extracted_output_bytes: AtomicU64,
114    /// Plain-output bytes hashed by currently active verifications.
115    pub(crate) active_verified_output_bytes: AtomicU64,
116    /// Total plain-output bytes expected across all planned archives.
117    pub(crate) total_output_bytes: u64,
118    /// Total number of planned archives.
119    pub(crate) total_archives: u64,
120    /// Time when the modular download job started.
121    pub(crate) started_at: Instant,
122    /// Time and baseline when the current extraction phase started.
123    extraction_phase: Mutex<Option<PhaseStart>>,
124    /// Time and baseline when the current verification phase started.
125    verification_phase: Mutex<Option<PhaseStart>>,
126    /// Number of archives that have fully finished.
127    pub(crate) archives_done: AtomicU64,
128    /// Number of archives currently in the fetch phase.
129    pub(crate) active_downloads: AtomicU64,
130    /// Number of in-flight HTTP requests.
131    pub(crate) active_download_requests: AtomicU64,
132    /// Number of archives currently extracting.
133    pub(crate) active_extractions: AtomicU64,
134    /// Number of archives currently verifying extracted outputs.
135    pub(crate) active_verifications: AtomicU64,
136    /// Wakes the background progress task to print the final summary and exit.
137    pub(crate) done: Notify,
138    /// Cancellation token shared by the whole command.
139    cancel_token: CancellationToken,
140}
141
142impl SharedProgress {
143    /// Creates the shared progress state for a modular download job.
144    pub(crate) fn new(
145        total_download_bytes: u64,
146        total_output_bytes: u64,
147        total_archives: u64,
148        cancel_token: CancellationToken,
149    ) -> Arc<Self> {
150        Arc::new(Self {
151            session_fetched_bytes: AtomicU64::new(0),
152            completed_download_bytes: AtomicU64::new(0),
153            active_download_bytes: AtomicU64::new(0),
154            total_download_bytes,
155            completed_output_bytes: AtomicU64::new(0),
156            active_extracted_output_bytes: AtomicU64::new(0),
157            active_verified_output_bytes: AtomicU64::new(0),
158            total_output_bytes,
159            total_archives,
160            started_at: Instant::now(),
161            extraction_phase: Mutex::new(None),
162            verification_phase: Mutex::new(None),
163            archives_done: AtomicU64::new(0),
164            active_downloads: AtomicU64::new(0),
165            active_download_requests: AtomicU64::new(0),
166            active_extractions: AtomicU64::new(0),
167            active_verifications: AtomicU64::new(0),
168            done: Notify::new(),
169            cancel_token,
170        })
171    }
172
173    /// Returns whether the whole command has been cancelled.
174    pub(crate) fn is_cancelled(&self) -> bool {
175        self.cancel_token.is_cancelled()
176    }
177
178    /// Adds raw session traffic bytes without affecting logical progress.
179    pub(crate) fn record_session_fetched_bytes(&self, bytes: u64) {
180        self.session_fetched_bytes.fetch_add(bytes, Ordering::Relaxed);
181    }
182
183    pub(crate) fn add_active_download_bytes(&self, bytes: u64) {
184        self.active_download_bytes.fetch_add(bytes, Ordering::Relaxed);
185    }
186
187    pub(crate) fn sub_active_download_bytes(&self, bytes: u64) {
188        sub_bytes(&self.active_download_bytes, bytes);
189    }
190
191    fn add_active_extracted_output_bytes(&self, bytes: u64) {
192        self.active_extracted_output_bytes.fetch_add(bytes, Ordering::Relaxed);
193    }
194
195    fn sub_active_extracted_output_bytes(&self, bytes: u64) {
196        sub_bytes(&self.active_extracted_output_bytes, bytes);
197    }
198
199    fn add_active_verified_output_bytes(&self, bytes: u64) {
200        self.active_verified_output_bytes.fetch_add(bytes, Ordering::Relaxed);
201    }
202
203    fn sub_active_verified_output_bytes(&self, bytes: u64) {
204        sub_bytes(&self.active_verified_output_bytes, bytes);
205    }
206
207    /// Records an archive whose outputs were already present locally.
208    pub(crate) fn record_reused_archive(&self, download_bytes: u64, output_bytes: u64) {
209        self.completed_download_bytes.fetch_add(download_bytes, Ordering::Relaxed);
210        self.completed_output_bytes.fetch_add(output_bytes, Ordering::Relaxed);
211        self.archives_done.fetch_add(1, Ordering::Relaxed);
212    }
213
214    /// Records an archive whose compressed download completed successfully.
215    pub(crate) fn record_archive_download_complete(&self, bytes: u64) {
216        self.completed_download_bytes.fetch_add(bytes, Ordering::Relaxed);
217    }
218
219    /// Records an archive whose extracted outputs have fully verified.
220    pub(crate) fn record_archive_output_complete(&self, bytes: u64) {
221        self.completed_output_bytes.fetch_add(bytes, Ordering::Relaxed);
222        self.archives_done.fetch_add(1, Ordering::Relaxed);
223    }
224
225    /// Returns logical compressed download progress.
226    pub(crate) fn logical_downloaded_bytes(&self) -> u64 {
227        (self.completed_download_bytes.load(Ordering::Relaxed) +
228            self.active_download_bytes.load(Ordering::Relaxed))
229        .min(self.total_download_bytes)
230    }
231
232    /// Returns verified plain-output bytes.
233    pub(crate) fn verified_output_bytes(&self) -> u64 {
234        self.completed_output_bytes.load(Ordering::Relaxed).min(self.total_output_bytes)
235    }
236
237    /// Returns plain-output bytes currently represented by extraction progress.
238    pub(crate) fn extracting_output_bytes(&self) -> u64 {
239        (self.completed_output_bytes.load(Ordering::Relaxed) +
240            self.active_extracted_output_bytes.load(Ordering::Relaxed))
241        .min(self.total_output_bytes)
242    }
243
244    /// Returns plain-output bytes currently represented by verification progress.
245    pub(crate) fn verifying_output_bytes(&self) -> u64 {
246        (self.completed_output_bytes.load(Ordering::Relaxed) +
247            self.active_verified_output_bytes.load(Ordering::Relaxed))
248        .min(self.total_output_bytes)
249    }
250
251    fn restart_phase(slot: &Mutex<Option<PhaseStart>>, baseline_bytes: u64) {
252        *slot.lock().unwrap() = Some(PhaseStart { started_at: Instant::now(), baseline_bytes });
253    }
254
255    fn phase_eta(
256        slot: &Mutex<Option<PhaseStart>>,
257        current_bytes: u64,
258        total_bytes: u64,
259    ) -> Option<Duration> {
260        let phase = *slot.lock().unwrap();
261        let phase = phase?;
262        let done = current_bytes.saturating_sub(phase.baseline_bytes);
263        let total = total_bytes.saturating_sub(phase.baseline_bytes);
264        eta_from_progress(phase.started_at.elapsed(), done, total)
265    }
266
267    fn extraction_eta(&self, current_bytes: u64) -> Option<Duration> {
268        Self::phase_eta(&self.extraction_phase, current_bytes, self.total_output_bytes)
269    }
270
271    fn verification_eta(&self, current_bytes: u64) -> Option<Duration> {
272        Self::phase_eta(&self.verification_phase, current_bytes, self.total_output_bytes)
273    }
274
275    /// Marks one archive as actively downloading.
276    pub(crate) fn download_started(&self) {
277        self.active_downloads.fetch_add(1, Ordering::Relaxed);
278    }
279
280    /// Marks one archive download as finished.
281    pub(crate) fn download_finished(&self) {
282        sub_bytes(&self.active_downloads, 1);
283    }
284
285    /// Marks one HTTP request as in flight.
286    pub(crate) fn request_started(&self) {
287        self.active_download_requests.fetch_add(1, Ordering::Relaxed);
288    }
289
290    /// Marks one HTTP request as finished.
291    pub(crate) fn request_finished(&self) {
292        sub_bytes(&self.active_download_requests, 1);
293    }
294
295    /// Marks one archive as actively extracting.
296    pub(crate) fn extraction_started(&self) {
297        if self.active_extractions.fetch_add(1, Ordering::Relaxed) == 0 {
298            Self::restart_phase(
299                &self.extraction_phase,
300                self.completed_output_bytes.load(Ordering::Relaxed),
301            );
302        }
303    }
304
305    /// Marks one archive extraction as finished.
306    pub(crate) fn extraction_finished(&self) {
307        sub_bytes(&self.active_extractions, 1);
308    }
309
310    /// Marks one archive as actively verifying outputs.
311    pub(crate) fn verification_started(&self) {
312        if self.active_verifications.fetch_add(1, Ordering::Relaxed) == 0 {
313            Self::restart_phase(
314                &self.verification_phase,
315                self.completed_output_bytes.load(Ordering::Relaxed),
316            );
317        }
318    }
319
320    /// Marks one archive verification as finished.
321    pub(crate) fn verification_finished(&self) {
322        sub_bytes(&self.active_verifications, 1);
323    }
324}
325
326fn sub_bytes(counter: &AtomicU64, bytes: u64) {
327    let _ = counter.try_update(Ordering::Relaxed, Ordering::Relaxed, |current| {
328        Some(current.saturating_sub(bytes))
329    });
330}
331
332fn eta_from_progress(elapsed: Duration, done: u64, total: u64) -> Option<Duration> {
333    if done == 0 || done >= total {
334        return None;
335    }
336
337    let secs = elapsed.as_secs_f64();
338    if secs <= 0.0 {
339        return None;
340    }
341
342    let speed = done as f64 / secs;
343    if speed <= 0.0 {
344        return None;
345    }
346
347    Some(Duration::from_secs_f64((total - done) as f64 / speed))
348}
349
350fn format_percent(done: u64, total: u64) -> String {
351    if total == 0 {
352        return "100.0%".to_string();
353    }
354
355    format!("{:.1}%", (done as f64 / total as f64) * 100.0)
356}
357
358fn format_eta(eta: Option<Duration>) -> String {
359    eta.map(DownloadProgress::format_duration).unwrap_or_else(|| "unknown".to_string())
360}
361
362/// Global request limit for the blocking downloader.
363///
364/// This uses `Mutex + Condvar` because the segmented path runs blocking reqwest
365/// clients on OS threads.
366pub(crate) struct DownloadRequestLimiter {
367    /// Maximum number of in-flight HTTP requests.
368    limit: usize,
369    /// Current number of acquired request slots.
370    active: Mutex<usize>,
371    /// Wakes blocked threads when a slot is released.
372    notify: Condvar,
373}
374
375impl DownloadRequestLimiter {
376    /// Creates the shared request limiter.
377    pub(crate) fn new(limit: usize) -> Arc<Self> {
378        Arc::new(Self { limit: limit.max(1), active: Mutex::new(0), notify: Condvar::new() })
379    }
380
381    /// Returns the configured request limit.
382    pub(crate) fn max_concurrency(&self) -> usize {
383        self.limit
384    }
385
386    pub(crate) fn acquire<'a>(
387        &'a self,
388        progress: Option<&'a Arc<SharedProgress>>,
389        cancel_token: &CancellationToken,
390    ) -> Result<DownloadRequestPermit<'a>> {
391        let mut active = self.active.lock().unwrap();
392        loop {
393            if cancel_token.is_cancelled() {
394                return Err(eyre::eyre!("Download cancelled"));
395            }
396
397            if *active < self.limit {
398                *active += 1;
399                if let Some(progress) = progress {
400                    progress.request_started();
401                }
402                return Ok(DownloadRequestPermit { limiter: self, progress });
403            }
404
405            // Wake periodically so cancellation can interrupt waiters even if
406            // no request finishes.
407            let (next_active, _) =
408                self.notify.wait_timeout(active, Duration::from_millis(100)).unwrap();
409            active = next_active;
410        }
411    }
412}
413
414/// RAII permit for one in-flight HTTP request.
415///
416/// Dropping the permit releases a slot in the shared request limit and updates
417/// the live progress counters.
418pub(crate) struct DownloadRequestPermit<'a> {
419    /// Limiter that owns the request slot.
420    limiter: &'a DownloadRequestLimiter,
421    /// Shared progress counters updated when the permit drops.
422    progress: Option<&'a Arc<SharedProgress>>,
423}
424
425impl Drop for DownloadRequestPermit<'_> {
426    /// Releases the request slot and updates shared progress counters.
427    fn drop(&mut self) {
428        let mut active = self.limiter.active.lock().unwrap();
429        *active = active.saturating_sub(1);
430        drop(active);
431        self.limiter.notify.notify_one();
432
433        if let Some(progress) = self.progress {
434            progress.request_finished();
435        }
436    }
437}
438
439/// Tracks one active archive download attempt.
440pub(crate) struct ArchiveDownloadProgress<'a> {
441    progress: Option<&'a Arc<SharedProgress>>,
442    downloaded: u64,
443    completed: bool,
444}
445
446impl<'a> ArchiveDownloadProgress<'a> {
447    /// Starts tracking one archive download attempt.
448    pub(crate) fn new(progress: Option<&'a Arc<SharedProgress>>) -> Self {
449        if let Some(progress) = progress {
450            progress.download_started();
451        }
452        Self { progress, downloaded: 0, completed: false }
453    }
454
455    /// Adds logical compressed bytes written by this attempt.
456    pub(crate) fn record_downloaded(&mut self, bytes: u64) {
457        self.downloaded += bytes;
458        if let Some(progress) = self.progress {
459            progress.add_active_download_bytes(bytes);
460        }
461    }
462
463    /// Returns whether this tracker has recorded any logical bytes itself.
464    pub(crate) fn has_tracked_bytes(&self) -> bool {
465        self.downloaded > 0
466    }
467
468    /// Moves this archive from active download bytes into completed download bytes.
469    pub(crate) fn complete(&mut self, total_bytes: u64) {
470        if self.completed {
471            return;
472        }
473        if let Some(progress) = self.progress {
474            progress.sub_active_download_bytes(self.downloaded);
475            progress.record_archive_download_complete(total_bytes);
476        }
477        self.downloaded = 0;
478        self.completed = true;
479    }
480}
481
482impl Drop for ArchiveDownloadProgress<'_> {
483    fn drop(&mut self) {
484        if let Some(progress) = self.progress {
485            progress.sub_active_download_bytes(self.downloaded);
486            progress.download_finished();
487        }
488    }
489}
490
491/// Tracks one active archive extraction attempt.
492pub(crate) struct ArchiveExtractionProgress {
493    progress: Option<Arc<SharedProgress>>,
494    extracted: Arc<AtomicU64>,
495    finished: bool,
496}
497
498/// Cloneable handle for reporting extracted bytes from background monitoring.
499#[derive(Clone)]
500pub(crate) struct ArchiveExtractionProgressHandle {
501    progress: Arc<SharedProgress>,
502    extracted: Arc<AtomicU64>,
503}
504
505impl ArchiveExtractionProgress {
506    /// Starts tracking one archive extraction attempt.
507    pub(crate) fn new(progress: Option<&Arc<SharedProgress>>) -> Self {
508        if let Some(progress) = progress {
509            progress.extraction_started();
510        }
511        Self {
512            progress: progress.cloned(),
513            extracted: Arc::new(AtomicU64::new(0)),
514            finished: false,
515        }
516    }
517
518    /// Returns a cloneable handle that can report extraction progress from another thread.
519    pub(crate) fn handle(&self) -> Option<ArchiveExtractionProgressHandle> {
520        Some(ArchiveExtractionProgressHandle {
521            progress: Arc::clone(self.progress.as_ref()?),
522            extracted: Arc::clone(&self.extracted),
523        })
524    }
525
526    /// Adds plain-output bytes extracted by this attempt.
527    pub(crate) fn record_extracted(&mut self, bytes: u64) {
528        if let Some(handle) = self.handle() {
529            handle.record_extracted(bytes);
530        }
531    }
532
533    /// Ends extraction tracking before verification begins.
534    pub(crate) fn finish(&mut self) {
535        if self.finished {
536            return;
537        }
538        if let Some(progress) = &self.progress {
539            progress.sub_active_extracted_output_bytes(self.extracted.swap(0, Ordering::Relaxed));
540        }
541        self.finished = true;
542    }
543}
544
545impl Drop for ArchiveExtractionProgress {
546    fn drop(&mut self) {
547        if let Some(progress) = &self.progress {
548            progress.sub_active_extracted_output_bytes(self.extracted.swap(0, Ordering::Relaxed));
549            progress.extraction_finished();
550        }
551    }
552}
553
554impl ArchiveExtractionProgressHandle {
555    /// Adds plain-output bytes extracted by this attempt.
556    pub(crate) fn record_extracted(&self, bytes: u64) {
557        self.extracted.fetch_add(bytes, Ordering::Relaxed);
558        self.progress.add_active_extracted_output_bytes(bytes);
559    }
560}
561
562/// Tracks one active archive verification attempt.
563pub(crate) struct ArchiveVerificationProgress<'a> {
564    progress: Option<&'a Arc<SharedProgress>>,
565    verified: u64,
566    completed: bool,
567}
568
569impl<'a> ArchiveVerificationProgress<'a> {
570    /// Starts tracking one archive verification attempt.
571    pub(crate) fn new(progress: Option<&'a Arc<SharedProgress>>) -> Self {
572        if let Some(progress) = progress {
573            progress.verification_started();
574        }
575        Self { progress, verified: 0, completed: false }
576    }
577
578    /// Adds plain-output bytes hashed by this verification attempt.
579    pub(crate) fn record_verified(&mut self, bytes: u64) {
580        self.verified += bytes;
581        if let Some(progress) = self.progress {
582            progress.add_active_verified_output_bytes(bytes);
583        }
584    }
585
586    /// Moves this archive from active verification bytes into completed output bytes.
587    pub(crate) fn complete(&mut self, total_bytes: u64) {
588        if self.completed {
589            return;
590        }
591        if let Some(progress) = self.progress {
592            progress.sub_active_verified_output_bytes(self.verified);
593            progress.record_archive_output_complete(total_bytes);
594        }
595        self.verified = 0;
596        self.completed = true;
597    }
598}
599
600impl Drop for ArchiveVerificationProgress<'_> {
601    fn drop(&mut self) {
602        if let Some(progress) = self.progress {
603            progress.sub_active_verified_output_bytes(self.verified);
604            progress.verification_finished();
605        }
606    }
607}
608
609/// Adapter to track progress while reading (used for extraction in legacy path)
610pub(crate) struct ProgressReader<R> {
611    /// Wrapped reader that provides archive bytes.
612    reader: R,
613    /// Per-download progress tracker for legacy paths.
614    progress: DownloadProgress,
615    /// Cancellation token checked between reads.
616    cancel_token: CancellationToken,
617}
618
619impl<R: Read> ProgressReader<R> {
620    /// Wraps a reader with per-download progress tracking.
621    pub(crate) fn new(reader: R, total_size: u64, cancel_token: CancellationToken) -> Self {
622        Self { reader, progress: DownloadProgress::new(total_size), cancel_token }
623    }
624}
625
626impl<R: Read> Read for ProgressReader<R> {
627    /// Reads bytes, checks cancellation, and updates the local progress bar.
628    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
629        if self.cancel_token.is_cancelled() {
630            return Err(io::Error::new(io::ErrorKind::Interrupted, "download cancelled"));
631        }
632        let bytes = self.reader.read(buf)?;
633        if bytes > 0 &&
634            let Err(error) = self.progress.update(bytes as u64)
635        {
636            return Err(io::Error::other(error));
637        }
638        Ok(bytes)
639    }
640}
641
642/// Wrapper that bumps a shared atomic counter while writing data.
643/// Used for parallel downloads where a single display task shows aggregated progress.
644pub(crate) struct SharedProgressWriter<'a, W> {
645    /// Wrapped writer receiving downloaded bytes.
646    pub(crate) inner: W,
647    /// Shared counters updated as bytes are written.
648    pub(crate) progress: Arc<SharedProgress>,
649    /// Optional callback for logical bytes written by the current archive attempt.
650    pub(crate) on_written: Option<&'a mut dyn FnMut(u64)>,
651}
652
653impl<W: Write> Write for SharedProgressWriter<'_, W> {
654    /// Writes bytes and records them in shared progress.
655    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
656        if self.progress.is_cancelled() {
657            return Err(io::Error::new(io::ErrorKind::Interrupted, "download cancelled"));
658        }
659        let n = self.inner.write(buf)?;
660        self.progress.record_session_fetched_bytes(n as u64);
661        if let Some(on_written) = self.on_written.as_deref_mut() {
662            on_written(n as u64);
663        }
664        Ok(n)
665    }
666
667    /// Flushes the wrapped writer.
668    fn flush(&mut self) -> io::Result<()> {
669        self.inner.flush()
670    }
671}
672
673/// Wrapper that bumps a shared atomic counter while reading data.
674/// Used for streaming downloads where a single display task shows aggregated progress.
675pub(crate) struct SharedProgressReader<R> {
676    /// Wrapped reader producing streamed bytes.
677    pub(crate) inner: R,
678    /// Shared counters updated as bytes are read.
679    pub(crate) progress: Arc<SharedProgress>,
680}
681
682impl<R: Read> Read for SharedProgressReader<R> {
683    /// Reads bytes and records them in shared progress.
684    fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
685        if self.progress.is_cancelled() {
686            return Err(io::Error::new(io::ErrorKind::Interrupted, "download cancelled"));
687        }
688        let n = self.inner.read(buf)?;
689        self.progress.record_session_fetched_bytes(n as u64);
690        Ok(n)
691    }
692}
693
694/// Spawns a background task that prints aggregated download progress.
695/// Returns a handle; drop it (or call `.abort()`) to stop.
696pub(crate) fn spawn_progress_display(progress: Arc<SharedProgress>) -> tokio::task::JoinHandle<()> {
697    tokio::spawn(async move {
698        let mut interval = tokio::time::interval(Duration::from_secs(3));
699        interval.tick().await;
700        loop {
701            tokio::select! {
702                _ = interval.tick() => {}
703                _ = progress.done.notified() => break,
704            }
705
706            let download_total = progress.total_download_bytes;
707            let output_total = progress.total_output_bytes;
708            if download_total == 0 && output_total == 0 {
709                continue;
710            }
711
712            let done = progress.archives_done.load(Ordering::Relaxed);
713            let all = progress.total_archives;
714            let active_downloads = progress.active_downloads.load(Ordering::Relaxed);
715            let active_requests = progress.active_download_requests.load(Ordering::Relaxed);
716            let active_extractions = progress.active_extractions.load(Ordering::Relaxed);
717            let active_verifications = progress.active_verifications.load(Ordering::Relaxed);
718            let downloaded = progress.logical_downloaded_bytes();
719            let extracted = progress.extracting_output_bytes();
720            let verified = progress.verifying_output_bytes();
721            let elapsed = DownloadProgress::format_duration(progress.started_at.elapsed());
722            let download_total_display = DownloadProgress::format_size(download_total);
723            let output_total_display = DownloadProgress::format_size(output_total);
724            let downloaded_display = DownloadProgress::format_size(downloaded);
725            let extracted_display = DownloadProgress::format_size(extracted);
726            let active_download_phase = active_downloads > 0 || active_requests > 0;
727
728            if active_download_phase {
729                info!(target: "reth::cli",
730                    archives = format_args!("{done}/{all}"),
731                    progress = %format_percent(downloaded, download_total),
732                    elapsed = %elapsed,
733                    eta = %format_eta(eta_from_progress(progress.started_at.elapsed(), downloaded, download_total)),
734                    bytes = format_args!("{downloaded_display}/{download_total_display}"),
735                    "Downloading snapshot archives"
736                );
737            } else if active_extractions > 0 {
738                info!(target: "reth::cli",
739                    archives = format_args!("{done}/{all}"),
740                    progress = %format_percent(extracted, output_total),
741                    elapsed = %elapsed,
742                    eta = %format_eta(progress.extraction_eta(extracted)),
743                    bytes = format_args!("{extracted_display}/{output_total_display}"),
744                    "Extracting snapshot archives"
745                );
746            } else if active_verifications > 0 {
747                info!(target: "reth::cli",
748                    archives = format_args!("{done}/{all}"),
749                    progress = %format_percent(verified, output_total),
750                    elapsed = %elapsed,
751                    eta = %format_eta(progress.verification_eta(verified)),
752                    bytes = format_args!("{}/{output_total_display}", DownloadProgress::format_size(verified)),
753                    "Verifying snapshot archives"
754                );
755            } else {
756                continue;
757            }
758        }
759
760        let completed = progress.verified_output_bytes();
761        let completed_display = DownloadProgress::format_size(completed);
762        let output_total = DownloadProgress::format_size(progress.total_output_bytes);
763        info!(target: "reth::cli",
764            archives = format_args!("{}/{}", progress.total_archives, progress.total_archives),
765            progress = "100.0%",
766            elapsed = %DownloadProgress::format_duration(progress.started_at.elapsed()),
767            eta = "0s",
768            bytes = format_args!("{completed_display}/{output_total}"),
769            "Snapshot archive processing complete"
770        );
771    })
772}
773
774#[cfg(test)]
775mod tests {
776    use super::*;
777    use std::sync::atomic::Ordering;
778
779    #[test]
780    fn shared_progress_separates_session_fetch_from_logical_progress() {
781        let progress = SharedProgress::new(10, 20, 1, CancellationToken::new());
782
783        progress.record_session_fetched_bytes(10);
784        progress.record_session_fetched_bytes(10);
785        progress.record_archive_download_complete(10);
786        progress.record_archive_output_complete(20);
787
788        assert_eq!(progress.session_fetched_bytes.load(Ordering::Relaxed), 20);
789        assert_eq!(progress.logical_downloaded_bytes(), 10);
790        assert_eq!(progress.verified_output_bytes(), 20);
791        assert_eq!(progress.archives_done.load(Ordering::Relaxed), 1);
792    }
793
794    #[test]
795    fn archive_download_progress_rolls_back_unfinished_attempts() {
796        let progress = SharedProgress::new(10, 20, 1, CancellationToken::new());
797
798        {
799            let mut download = ArchiveDownloadProgress::new(Some(&progress));
800            download.record_downloaded(4);
801            assert_eq!(progress.logical_downloaded_bytes(), 4);
802        }
803
804        assert_eq!(progress.logical_downloaded_bytes(), 0);
805        assert_eq!(progress.active_downloads.load(Ordering::Relaxed), 0);
806    }
807
808    #[test]
809    fn extraction_phase_baseline_restarts_after_idle() {
810        let progress = SharedProgress::new(10, 100, 1, CancellationToken::new());
811
812        progress.extraction_started();
813        assert_eq!(progress.extraction_phase.lock().unwrap().as_ref().unwrap().baseline_bytes, 0);
814
815        progress.completed_output_bytes.store(25, Ordering::Relaxed);
816        progress.extraction_started();
817        assert_eq!(progress.extraction_phase.lock().unwrap().as_ref().unwrap().baseline_bytes, 0);
818
819        progress.extraction_finished();
820        progress.extraction_finished();
821        progress.extraction_started();
822        assert_eq!(progress.extraction_phase.lock().unwrap().as_ref().unwrap().baseline_bytes, 25);
823    }
824
825    #[test]
826    fn verification_phase_baseline_restarts_after_idle() {
827        let progress = SharedProgress::new(10, 100, 1, CancellationToken::new());
828
829        progress.verification_started();
830        assert_eq!(progress.verification_phase.lock().unwrap().as_ref().unwrap().baseline_bytes, 0);
831
832        progress.completed_output_bytes.store(40, Ordering::Relaxed);
833        progress.verification_started();
834        assert_eq!(progress.verification_phase.lock().unwrap().as_ref().unwrap().baseline_bytes, 0);
835
836        progress.verification_finished();
837        progress.verification_finished();
838        progress.verification_started();
839        assert_eq!(
840            progress.verification_phase.lock().unwrap().as_ref().unwrap().baseline_bytes,
841            40
842        );
843    }
844
845    #[tokio::test]
846    async fn progress_display_exits_when_done() {
847        let progress = SharedProgress::new(10, 20, 1, CancellationToken::new());
848        let handle = spawn_progress_display(Arc::clone(&progress));
849
850        progress.done.notify_one();
851
852        // The task must exit right away instead of on its next 3s progress tick.
853        tokio::time::timeout(Duration::from_secs(1), handle).await.unwrap().unwrap();
854    }
855}