Skip to main content

reth_cli_commands/download/
fetch.rs

1use super::{
2    progress::{
3        ArchiveDownloadProgress, DownloadProgress, DownloadRequestLimiter, SharedProgress,
4        SharedProgressWriter,
5    },
6    session::DownloadSession,
7    RETRY_BACKOFF_SECS,
8};
9use eyre::Result;
10use reqwest::{blocking::Client as BlockingClient, header::RANGE, StatusCode};
11use reth_cli_util::cancellation::CancellationToken;
12use reth_fs_util as fs;
13use std::{
14    any::Any,
15    collections::VecDeque,
16    fs::OpenOptions,
17    io::{self, BufWriter, Read, Write},
18    path::{Path, PathBuf},
19    sync::{
20        atomic::{AtomicBool, AtomicU64, Ordering},
21        Arc, Mutex,
22    },
23    time::Duration,
24};
25use tracing::info;
26use url::Url;
27
28/// Maximum retry attempts for a single download segment.
29const SEGMENT_RETRY_ATTEMPTS: u32 = 3;
30
31/// Minimum archive size that benefits from segmented downloads.
32const SEGMENTED_DOWNLOAD_MIN_FILE_SIZE: u64 = 128 * 1024 * 1024;
33
34/// Piece sizes are large so big downloads do not create too many requests while
35/// still giving multiple workers enough work to do.
36const SEGMENTED_DOWNLOAD_SMALL_PIECE_SIZE: u64 = 32 * 1024 * 1024;
37const SEGMENTED_DOWNLOAD_LARGE_PIECE_SIZE: u64 = 64 * 1024 * 1024;
38
39/// Cap exponential piece retry backoff to avoid overly long stalls.
40const SEGMENTED_DOWNLOAD_MAX_BACKOFF_SECS: u64 = 30;
41
42/// Segmented piece requests should time out quickly enough to recover from slow or stalled
43/// requests.
44const SEGMENTED_DOWNLOAD_REQUEST_TIMEOUT_SECS: u64 = 120;
45
46/// Paths for one downloaded archive and its `.part` file.
47#[derive(Debug, Clone)]
48struct DownloadPaths {
49    /// User-facing archive file name derived from the URL.
50    file_name: String,
51    /// Final path for the completed archive file.
52    final_path: PathBuf,
53    /// Temporary path used while the archive is still downloading.
54    part_path: PathBuf,
55}
56
57impl DownloadPaths {
58    /// Builds the final and partial download paths from the archive URL.
59    fn from_url(url: &str, target_dir: &Path) -> Self {
60        let file_name = Url::parse(url)
61            .ok()
62            .and_then(|u| u.path_segments()?.next_back().map(|s| s.to_string()))
63            .unwrap_or_else(|| "snapshot.tar".to_string());
64
65        Self {
66            final_path: target_dir.join(&file_name),
67            part_path: target_dir.join(format!("{file_name}.part")),
68            file_name,
69        }
70    }
71
72    /// Returns the user-facing file name derived from the archive URL.
73    fn file_name(&self) -> &str {
74        &self.file_name
75    }
76
77    /// Returns the final on-disk path for the completed archive.
78    fn final_path(&self) -> &Path {
79        &self.final_path
80    }
81
82    /// Returns the partial download path used while the archive is still in flight.
83    fn part_path(&self) -> &Path {
84        &self.part_path
85    }
86
87    /// Promotes the partial file into the final archive path.
88    fn finalize(&self) -> Result<()> {
89        fs::rename(&self.part_path, &self.final_path)?;
90        Ok(())
91    }
92
93    /// Removes only the partial `.part` file for the current archive.
94    fn cleanup_partial(&self) {
95        let _ = fs::remove_file(&self.part_path);
96    }
97
98    /// Removes both final and partial archive files so a fresh attempt can restart cleanly.
99    fn cleanup_all(&self) {
100        let _ = fs::remove_file(&self.final_path);
101        self.cleanup_partial();
102    }
103}
104
105/// Fetches one archive to disk and chooses sequential or segmented download.
106pub(crate) struct ArchiveFetcher {
107    /// Remote archive URL.
108    url: String,
109    /// On-disk paths used for this archive download.
110    paths: DownloadPaths,
111    /// Shared command-scoped download state.
112    session: DownloadSession,
113}
114
115impl ArchiveFetcher {
116    /// Creates a fetcher for one archive URL under the given target directory.
117    pub(crate) fn new(url: impl Into<String>, target_dir: &Path, session: DownloadSession) -> Self {
118        let url = url.into();
119        let paths = DownloadPaths::from_url(&url, target_dir);
120        Self { url, paths, session }
121    }
122
123    /// Downloads the archive using the best strategy supported by the remote source.
124    pub(crate) fn download(
125        &self,
126        download_progress: Option<&mut ArchiveDownloadProgress<'_>>,
127    ) -> Result<DownloadedArchive> {
128        if let Some(path) = archive_file_url_path(&self.url)? {
129            let size = fs::metadata(&path)?.len();
130            if !self.quiet() {
131                info!(target: "reth::cli",
132                    file = %path.display(),
133                    size = %DownloadProgress::format_size(size),
134                    "Using local archive"
135                );
136            }
137            return Ok(DownloadedArchive { path, size })
138        }
139
140        let Some(request_limiter) = self.session.request_limiter() else {
141            return self.download_sequential(super::MAX_DOWNLOAD_RETRIES, download_progress)
142        };
143
144        let client = BlockingClient::builder().connect_timeout(Duration::from_secs(30)).build()?;
145        let probe = self.probe(&client)?;
146
147        match choose_fetch_strategy(probe, request_limiter.max_concurrency()) {
148            FetchStrategy::Sequential(reason) => {
149                self.log_sequential_fallback(reason, probe.total_size);
150                self.download_sequential(super::MAX_DOWNLOAD_RETRIES, download_progress)
151            }
152            FetchStrategy::Segmented(plan) => {
153                self.download_segmented(probe.total_size, plan, download_progress)
154            }
155        }
156    }
157
158    /// Removes any archive files created by this fetcher.
159    pub(crate) fn cleanup_downloaded_files(&self) {
160        self.paths.cleanup_all();
161    }
162
163    /// Probes the remote source for file size and HTTP range support.
164    fn probe(&self, client: &BlockingClient) -> Result<RemoteArchiveProbe> {
165        let probe = client
166            .get(&self.url)
167            .header(RANGE, "bytes=0-0")
168            .send()
169            .and_then(|response| response.error_for_status());
170
171        let (supports_ranges, total_size) = match probe {
172            Ok(response) if response.status() == StatusCode::PARTIAL_CONTENT => {
173                let total = response
174                    .headers()
175                    .get("Content-Range")
176                    .and_then(|value| value.to_str().ok())
177                    .and_then(|value| value.split('/').next_back())
178                    .and_then(|value| value.parse::<u64>().ok());
179                (true, total)
180            }
181            _ => {
182                let response = client.head(&self.url).send()?.error_for_status()?;
183                (false, response.content_length())
184            }
185        };
186
187        Ok(RemoteArchiveProbe {
188            total_size: total_size.ok_or_else(|| eyre::eyre!("Server did not return file size"))?,
189            supports_ranges,
190        })
191    }
192
193    /// Downloads the archive as a single resumable stream using one request at a time.
194    fn download_sequential(
195        &self,
196        max_download_retries: u32,
197        mut download_progress: Option<&mut ArchiveDownloadProgress<'_>>,
198    ) -> Result<DownloadedArchive> {
199        let quiet = self.quiet();
200
201        if !quiet {
202            info!(target: "reth::cli", file = %self.paths.file_name(), "Connecting to download server");
203        }
204
205        let client = BlockingClient::builder().timeout(Duration::from_secs(30)).build()?;
206        let mut total_size: Option<u64> = None;
207        let mut last_error: Option<eyre::Error> = None;
208
209        for attempt in 1..=max_download_retries {
210            let existing_size =
211                fs::metadata(self.paths.part_path()).map(|meta| meta.len()).unwrap_or(0);
212
213            if let Some(total) = total_size &&
214                existing_size >= total
215            {
216                return self.finalize_download(total)
217            }
218
219            if attempt > 1 {
220                info!(target: "reth::cli",
221                    file = %self.paths.file_name(),
222                    "Retry attempt {}/{} - resuming from {} bytes",
223                    attempt, max_download_retries, existing_size
224                );
225            }
226
227            let mut request = client.get(&self.url);
228            if existing_size > 0 {
229                request = request.header(RANGE, format!("bytes={existing_size}-"));
230                if !quiet && attempt == 1 {
231                    info!(target: "reth::cli", file = %self.paths.file_name(), "Resuming from {} bytes", existing_size);
232                }
233            }
234
235            let _request_permit = self
236                .session
237                .request_limiter()
238                .map(|limiter| {
239                    limiter.acquire(self.session.progress(), self.session.cancel_token())
240                })
241                .transpose()?;
242
243            let response = match request.send().and_then(|response| response.error_for_status()) {
244                Ok(response) => response,
245                Err(error) => {
246                    last_error = Some(error.into());
247                    if attempt < max_download_retries {
248                        info!(target: "reth::cli",
249                            file = %self.paths.file_name(),
250                            retry_delay = ?self.session.retry_delay(Duration::from_secs(RETRY_BACKOFF_SECS)),
251                            "Download failed, retrying"
252                        );
253                        std::thread::sleep(
254                            self.session.retry_delay(Duration::from_secs(RETRY_BACKOFF_SECS)),
255                        );
256                    }
257                    continue;
258                }
259            };
260
261            let is_partial = response.status() == StatusCode::PARTIAL_CONTENT;
262            let size = if is_partial {
263                response
264                    .headers()
265                    .get("Content-Range")
266                    .and_then(|value| value.to_str().ok())
267                    .and_then(|value| value.split('/').next_back())
268                    .and_then(|value| value.parse().ok())
269            } else {
270                response.content_length()
271            };
272
273            if total_size.is_none() {
274                total_size = size;
275                if !quiet && let Some(size) = size {
276                    info!(target: "reth::cli",
277                        file = %self.paths.file_name(),
278                        size = %DownloadProgress::format_size(size),
279                        "Downloading"
280                    );
281                }
282            }
283
284            let current_total = total_size.ok_or_else(|| {
285                eyre::eyre!("Server did not provide Content-Length or Content-Range header")
286            })?;
287
288            let file = if is_partial && existing_size > 0 {
289                OpenOptions::new()
290                    .append(true)
291                    .open(self.paths.part_path())
292                    .map_err(|error| fs::FsPathError::open(error, self.paths.part_path()))?
293            } else {
294                fs::create_file(self.paths.part_path())?
295            };
296
297            let start_offset = if is_partial { existing_size } else { 0 };
298            let mut reader = response;
299
300            let copy_result;
301            let flush_result;
302
303            if let Some(progress) = self.session.progress() {
304                let mut on_written = |bytes| {
305                    if let Some(download_progress) = download_progress.as_deref_mut() {
306                        download_progress.record_downloaded(bytes);
307                    }
308                };
309                let mut writer = SharedProgressWriter {
310                    inner: BufWriter::new(file),
311                    progress: Arc::clone(progress),
312                    on_written: Some(&mut on_written),
313                };
314                copy_result = io::copy(&mut reader, &mut writer);
315                flush_result = writer.inner.flush();
316            } else {
317                let mut progress = DownloadProgress::new(current_total);
318                progress.downloaded = start_offset;
319                let mut writer = ProgressWriter {
320                    inner: BufWriter::new(file),
321                    progress,
322                    cancel_token: self.session.cancel_token().clone(),
323                };
324                copy_result = io::copy(&mut reader, &mut writer);
325                flush_result = writer.inner.flush();
326                println!();
327            }
328
329            if let Err(error) = copy_result.and(flush_result) {
330                last_error = Some(error.into());
331                if attempt < max_download_retries {
332                    info!(target: "reth::cli",
333                        file = %self.paths.file_name(),
334                        retry_delay = ?self.session.retry_delay(Duration::from_secs(RETRY_BACKOFF_SECS)),
335                        "Download interrupted, retrying"
336                    );
337                    std::thread::sleep(
338                        self.session.retry_delay(Duration::from_secs(RETRY_BACKOFF_SECS)),
339                    );
340                }
341                continue;
342            }
343
344            return self.finalize_download(current_total)
345        }
346
347        Err(last_error.unwrap_or_else(|| {
348            eyre::eyre!("Download failed after {} attempts", max_download_retries)
349        }))
350    }
351
352    /// Downloads the archive by splitting it into large range-request pieces.
353    fn download_segmented(
354        &self,
355        total_size: u64,
356        plan: SegmentedDownloadPlan,
357        download_progress: Option<&mut ArchiveDownloadProgress<'_>>,
358    ) -> Result<DownloadedArchive> {
359        let request_limiter = self.session.require_request_limiter()?;
360        info!(target: "reth::cli",
361            total_size = %DownloadProgress::format_size(total_size),
362            piece_size = %DownloadProgress::format_size(plan.piece_size),
363            pieces = plan.piece_count,
364            workers = plan.worker_count,
365            max_concurrent_requests = request_limiter.max_concurrency(),
366            "Starting queued segmented download"
367        );
368
369        SegmentedDownload::new(
370            self.url.clone(),
371            self.paths.clone(),
372            total_size,
373            plan,
374            self.session.clone(),
375            download_progress,
376        )
377        .run()
378    }
379
380    /// Logs why this archive must fall back to the sequential fetch path.
381    fn log_sequential_fallback(&self, reason: SequentialDownloadFallback, total_size: u64) {
382        match reason {
383            SequentialDownloadFallback::NoRangeSupport => {
384                info!(target: "reth::cli",
385                    file = %self.paths.file_name(),
386                    "Server does not support Range requests, falling back to sequential download"
387                );
388            }
389            SequentialDownloadFallback::EmptyFile => {
390                info!(target: "reth::cli",
391                    file = %self.paths.file_name(),
392                    "Remote archive is empty, falling back to sequential download"
393                );
394            }
395            SequentialDownloadFallback::TooSmall => {
396                let _ = total_size;
397            }
398        }
399    }
400
401    /// Finalizes the downloaded archive and returns its on-disk location and size.
402    fn finalize_download(&self, size: u64) -> Result<DownloadedArchive> {
403        self.paths.finalize()?;
404        if !self.quiet() {
405            info!(target: "reth::cli", file = %self.paths.file_name(), "Download complete");
406        }
407        Ok(DownloadedArchive { path: self.paths.final_path().to_path_buf(), size })
408    }
409
410    /// Returns `true` when this fetch should stay quiet because shared progress is active.
411    fn quiet(&self) -> bool {
412        self.session.progress().is_some()
413    }
414}
415
416/// Resolves a `file://` archive URL to its local path.
417fn archive_file_url_path(url: &str) -> Result<Option<PathBuf>> {
418    let Ok(parsed) = Url::parse(url) else { return Ok(None) };
419    if parsed.scheme() != "file" {
420        return Ok(None)
421    }
422
423    parsed
424        .to_file_path()
425        .map(Some)
426        .map_err(|_| eyre::eyre!("Invalid file:// archive URL path: {url}"))
427}
428
429/// The final path and size of one archive fetched to disk.
430#[derive(Debug, Clone)]
431pub(crate) struct DownloadedArchive {
432    /// Final on-disk path for the downloaded archive.
433    pub(crate) path: PathBuf,
434    /// Total archive size in bytes.
435    pub(crate) size: u64,
436}
437
438/// Remote metadata used to choose between sequential and segmented download.
439#[derive(Debug, Clone, Copy)]
440struct RemoteArchiveProbe {
441    /// Total archive size reported by the remote source.
442    total_size: u64,
443    /// Whether the remote source supports byte-range requests.
444    supports_ranges: bool,
445}
446
447/// Reasons the fetcher may choose the sequential download path.
448#[derive(Debug, Clone, Copy, PartialEq, Eq)]
449enum SequentialDownloadFallback {
450    /// The remote source does not support byte-range requests.
451    NoRangeSupport,
452    /// The remote source reported an empty archive.
453    EmptyFile,
454    /// The archive is too small to benefit from segmented download.
455    TooSmall,
456}
457
458/// The fetch strategy chosen after probing the remote source.
459#[derive(Debug)]
460enum FetchStrategy {
461    /// Use the single-stream download path.
462    Sequential(SequentialDownloadFallback),
463    /// Use the segmented download path.
464    Segmented(SegmentedDownloadPlan),
465}
466
467/// Chooses the fetch strategy from the remote probe and available worker budget.
468fn choose_fetch_strategy(probe: RemoteArchiveProbe, max_workers: usize) -> FetchStrategy {
469    if !probe.supports_ranges {
470        return FetchStrategy::Sequential(SequentialDownloadFallback::NoRangeSupport)
471    }
472
473    if probe.total_size == 0 {
474        return FetchStrategy::Sequential(SequentialDownloadFallback::EmptyFile)
475    }
476
477    plan_segmented_download(probe.total_size, max_workers)
478        .map(FetchStrategy::Segmented)
479        .unwrap_or(FetchStrategy::Sequential(SequentialDownloadFallback::TooSmall))
480}
481
482/// Wrapper that tracks download progress while writing data.
483/// Used with [`io::copy`] to display progress during downloads.
484struct ProgressWriter<W> {
485    /// Wrapped writer receiving downloaded bytes.
486    inner: W,
487    /// Per-download progress tracker for the legacy path.
488    progress: DownloadProgress,
489    /// Cancellation token checked between writes.
490    cancel_token: CancellationToken,
491}
492
493impl<W: Write> Write for ProgressWriter<W> {
494    /// Writes bytes, checks cancellation, and updates local download progress.
495    fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
496        if self.cancel_token.is_cancelled() {
497            return Err(io::Error::new(io::ErrorKind::Interrupted, "download cancelled"));
498        }
499        let n = self.inner.write(buf)?;
500        let _ = self.progress.update(n as u64);
501        Ok(n)
502    }
503
504    /// Flushes the wrapped writer.
505    fn flush(&mut self) -> io::Result<()> {
506        self.inner.flush()
507    }
508}
509
510/// One queued byte range for a segmented archive download.
511#[derive(Debug, Clone, Copy, PartialEq, Eq)]
512struct DownloadPiece {
513    /// Inclusive start byte for this piece.
514    start: u64,
515    /// Inclusive end byte for this piece.
516    end: u64,
517}
518
519/// Fixed plan for a segmented archive: piece size, piece count, and worker count.
520#[derive(Debug)]
521struct SegmentedDownloadPlan {
522    /// Bytes assigned to each piece, except possibly the last.
523    piece_size: u64,
524    /// Number of pieces created for this archive.
525    piece_count: usize,
526    /// Number of worker threads used for this archive.
527    worker_count: usize,
528    /// Queue of pieces to download.
529    pieces: VecDeque<DownloadPiece>,
530}
531
532/// Runs the segmented download workers and piece retries for one archive.
533struct SegmentedDownload {
534    /// Remote archive URL.
535    url: String,
536    /// On-disk paths used for this archive download.
537    paths: DownloadPaths,
538    /// Total archive size in bytes.
539    total_size: u64,
540    /// Piece and worker plan for this archive.
541    plan: SegmentedDownloadPlan,
542    /// Shared command-scoped download state.
543    session: DownloadSession,
544}
545
546/// Shared inputs each segmented download worker needs while draining the piece queue.
547#[derive(Clone, Copy)]
548struct SegmentedWorkerContext<'a> {
549    /// Remote archive URL.
550    url: &'a str,
551    /// Partial file path where pieces are written.
552    part_path: &'a Path,
553    /// Shared progress counters for the whole command, when enabled.
554    shared: Option<&'a Arc<SharedProgress>>,
555    /// Shared cap for in-flight HTTP requests.
556    request_limiter: &'a DownloadRequestLimiter,
557    /// Cancellation token shared by the whole command.
558    cancel_token: &'a CancellationToken,
559    /// Session carrying the retry-delay override.
560    session: &'a DownloadSession,
561}
562
563impl SegmentedDownload {
564    /// Creates the segmented download state for one archive.
565    fn new(
566        url: String,
567        paths: DownloadPaths,
568        total_size: u64,
569        plan: SegmentedDownloadPlan,
570        session: DownloadSession,
571        _download_progress: Option<&mut ArchiveDownloadProgress<'_>>,
572    ) -> Self {
573        Self { url, paths, total_size, plan, session }
574    }
575
576    /// Runs the segmented download to completion or returns the first fatal error.
577    fn run(self) -> Result<DownloadedArchive> {
578        let Self { url, paths, total_size, plan, session } = self;
579        {
580            let file = fs::create_file(paths.part_path())?;
581            file.set_len(total_size)?;
582        }
583
584        let worker_count = plan.worker_count;
585        let state = Arc::new(SegmentedDownloadState::new(plan.pieces));
586        let terminal_failure = Arc::new(TerminalFailure::default());
587        let piece_progress_bytes = Arc::new(AtomicU64::new(0));
588        let worker_client = BlockingClient::builder()
589            .connect_timeout(Duration::from_secs(30))
590            .timeout(Duration::from_secs(SEGMENTED_DOWNLOAD_REQUEST_TIMEOUT_SECS))
591            .build()?;
592        let request_limiter = Arc::clone(session.require_request_limiter()?);
593        let shared = session.progress();
594        let cancel_token = session.cancel_token();
595        let url = url.as_str();
596        let worker_context = SegmentedWorkerContext {
597            url,
598            part_path: paths.part_path(),
599            session: &session,
600            shared,
601            request_limiter: request_limiter.as_ref(),
602            cancel_token,
603        };
604
605        std::thread::scope(|scope| {
606            let mut handles = Vec::with_capacity(worker_count);
607
608            for _ in 0..worker_count {
609                let state = Arc::clone(&state);
610                let terminal_failure = Arc::clone(&terminal_failure);
611                let piece_progress_bytes = Arc::clone(&piece_progress_bytes);
612                let client = worker_client.clone();
613
614                handles.push(scope.spawn(move || {
615                    Self::worker_loop(
616                        &client,
617                        worker_context,
618                        state,
619                        terminal_failure,
620                        piece_progress_bytes,
621                    );
622                }));
623            }
624
625            for handle in handles {
626                if let Err(payload) = handle.join() {
627                    state.note_terminal_failure();
628                    terminal_failure.record(eyre::eyre!(
629                        "Segmented download worker panicked: {}",
630                        panic_payload_message(payload)
631                    ));
632                }
633            }
634        });
635
636        if let Some(error) = terminal_failure.take() {
637            if let Some(shared) = shared {
638                shared.sub_active_download_bytes(piece_progress_bytes.load(Ordering::Relaxed));
639            }
640            paths.cleanup_partial();
641            return Err(error.wrap_err("Parallel download failed"))
642        }
643
644        if let Some(shared) = shared {
645            shared.sub_active_download_bytes(piece_progress_bytes.load(Ordering::Relaxed));
646            shared.record_archive_download_complete(total_size);
647        }
648
649        paths.finalize()?;
650        info!(target: "reth::cli", file = %paths.file_name(), "Download complete");
651        Ok(DownloadedArchive { path: paths.final_path().to_path_buf(), size: total_size })
652    }
653
654    /// Runs one worker until there are no pieces left or another worker fails.
655    fn worker_loop(
656        client: &BlockingClient,
657        context: SegmentedWorkerContext<'_>,
658        state: Arc<SegmentedDownloadState>,
659        terminal_failure: Arc<TerminalFailure>,
660        piece_progress_bytes: Arc<AtomicU64>,
661    ) {
662        let file = match OpenOptions::new().write(true).open(context.part_path) {
663            Ok(file) => file,
664            Err(error) => {
665                state.note_terminal_failure();
666                terminal_failure.record(error.into());
667                return;
668            }
669        };
670
671        while let Some(piece) = state.next_piece(context.cancel_token) {
672            if let Err(error) = Self::download_piece_with_retries(
673                client,
674                context.url,
675                &file,
676                piece,
677                context.shared,
678                &piece_progress_bytes,
679                context.request_limiter,
680                context.cancel_token,
681                context.session,
682            ) {
683                state.note_terminal_failure();
684                terminal_failure.record(error);
685                return;
686            }
687        }
688    }
689
690    /// Downloads one queued piece with per-piece retry/backoff.
691    ///
692    /// Each attempt acquires a permit from the shared request limit so whole-file and
693    /// piece downloads use the same fixed number of HTTP request slots.
694    #[expect(clippy::too_many_arguments)]
695    fn download_piece_with_retries(
696        client: &BlockingClient,
697        url: &str,
698        file: &std::fs::File,
699        piece: DownloadPiece,
700        shared: Option<&Arc<SharedProgress>>,
701        piece_progress_bytes: &AtomicU64,
702        request_limiter: &DownloadRequestLimiter,
703        cancel_token: &CancellationToken,
704        session: &DownloadSession,
705    ) -> Result<()> {
706        for attempt in 1..=SEGMENT_RETRY_ATTEMPTS {
707            if cancel_token.is_cancelled() {
708                return Err(eyre::eyre!("Download cancelled"))
709            }
710
711            let _request_permit = request_limiter.acquire(shared, cancel_token)?;
712            match Self::download_piece_once(
713                client,
714                url,
715                file,
716                piece,
717                shared,
718                piece_progress_bytes,
719                cancel_token,
720            ) {
721                Ok(()) => return Ok(()),
722                Err(PieceAttemptFailure::Retryable { error: _, throttled })
723                    if attempt < SEGMENT_RETRY_ATTEMPTS =>
724                {
725                    std::thread::sleep(
726                        session.retry_delay(piece_retry_backoff(attempt, throttled)),
727                    );
728                }
729                Err(PieceAttemptFailure::Retryable { error, .. }) => return Err(error),
730                Err(PieceAttemptFailure::Terminal(error)) => return Err(error),
731            }
732        }
733
734        Err(eyre::eyre!("Piece download failed after {SEGMENT_RETRY_ATTEMPTS} attempts"))
735    }
736
737    /// Downloads one queued piece once.
738    fn download_piece_once(
739        client: &BlockingClient,
740        url: &str,
741        file: &std::fs::File,
742        piece: DownloadPiece,
743        shared: Option<&Arc<SharedProgress>>,
744        piece_progress_bytes: &AtomicU64,
745        cancel_token: &CancellationToken,
746    ) -> std::result::Result<(), PieceAttemptFailure> {
747        use std::os::unix::fs::FileExt;
748
749        let expected_len = piece.end - piece.start + 1;
750
751        let response = match client
752            .get(url)
753            .header(RANGE, format!("bytes={}-{}", piece.start, piece.end))
754            .send()
755        {
756            Ok(response) if response.status() == StatusCode::PARTIAL_CONTENT => response,
757            Ok(response) if should_retry_piece_status(response.status()) => {
758                return Err(PieceAttemptFailure::Retryable {
759                    error: eyre::eyre!(
760                        "Server returned {} for piece {}-{}",
761                        response.status(),
762                        piece.start,
763                        piece.end
764                    ),
765                    throttled: is_throttle_piece_status(response.status()),
766                });
767            }
768            Ok(response) => {
769                return Err(PieceAttemptFailure::Terminal(eyre::eyre!(
770                    "Server returned {} instead of 206 for Range request",
771                    response.status()
772                )));
773            }
774            Err(error) => {
775                return Err(PieceAttemptFailure::Retryable {
776                    throttled: is_throttle_piece_error(&error),
777                    error: error.into(),
778                });
779            }
780        };
781
782        let mut buf = [0u8; 64 * 1024];
783        let mut reader = response.take(expected_len);
784        let mut offset = piece.start;
785
786        loop {
787            if cancel_token.is_cancelled() {
788                return Err(PieceAttemptFailure::Terminal(eyre::eyre!("Download cancelled")));
789            }
790
791            match reader.read(&mut buf) {
792                Ok(0) => break,
793                Ok(n) => {
794                    file.write_all_at(&buf[..n], offset)
795                        .map_err(|error| PieceAttemptFailure::Terminal(error.into()))?;
796                    offset += n as u64;
797                    if let Some(progress) = shared {
798                        progress.record_session_fetched_bytes(n as u64);
799                    }
800                }
801                Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
802                Err(error) => {
803                    return Err(PieceAttemptFailure::Retryable {
804                        throttled: error.kind() == io::ErrorKind::TimedOut,
805                        error: error.into(),
806                    });
807                }
808            }
809        }
810
811        let downloaded_len = offset - piece.start;
812        if downloaded_len == expected_len {
813            if let Some(progress) = shared {
814                progress.add_active_download_bytes(expected_len);
815            }
816            piece_progress_bytes.fetch_add(expected_len, Ordering::Relaxed);
817            return Ok(())
818        }
819
820        Err(PieceAttemptFailure::Retryable {
821            error: eyre::eyre!(
822                "Piece {}-{} ended early: expected {} bytes, downloaded {}",
823                piece.start,
824                piece.end,
825                expected_len,
826                downloaded_len
827            ),
828            throttled: false,
829        })
830    }
831}
832
833/// Shared queue state for one segmented archive download.
834///
835/// Workers pull pieces until the queue is empty or one worker fails the whole attempt.
836struct SegmentedDownloadState {
837    /// Remaining pieces waiting to be downloaded.
838    pieces: Mutex<VecDeque<DownloadPiece>>,
839    /// Set once a worker hits a fatal error.
840    failed: AtomicBool,
841}
842
843impl SegmentedDownloadState {
844    /// Creates the shared queue state for one segmented archive attempt.
845    fn new(pieces: VecDeque<DownloadPiece>) -> Self {
846        Self { pieces: Mutex::new(pieces), failed: AtomicBool::new(false) }
847    }
848
849    /// Returns the next piece unless cancellation or a fatal error stopped the attempt.
850    fn next_piece(&self, cancel_token: &CancellationToken) -> Option<DownloadPiece> {
851        if cancel_token.is_cancelled() || self.failed.load(Ordering::Relaxed) {
852            return None;
853        }
854
855        self.pieces.lock().unwrap().pop_front()
856    }
857
858    /// Marks the entire segmented attempt as failed so workers stop taking more pieces.
859    fn note_terminal_failure(&self) {
860        self.failed.store(true, Ordering::Relaxed);
861    }
862}
863
864/// Stores the first fatal error seen across segmented download workers.
865#[derive(Default)]
866struct TerminalFailure {
867    /// First fatal worker error, if any.
868    error: Mutex<Option<eyre::Error>>,
869}
870
871impl TerminalFailure {
872    /// Stores the first fatal error and ignores later ones from other workers.
873    fn record(&self, error: eyre::Error) {
874        let mut slot = self.error.lock().unwrap();
875        if slot.is_none() {
876            *slot = Some(error);
877        }
878    }
879
880    /// Returns the stored fatal error after worker execution finishes.
881    fn take(&self) -> Option<eyre::Error> {
882        self.error.lock().unwrap().take()
883    }
884}
885
886/// Splits an archive into contiguous byte ranges for segmented download.
887fn build_download_pieces(total_size: u64, piece_size: u64) -> VecDeque<DownloadPiece> {
888    let mut pieces = VecDeque::new();
889    let mut start = 0;
890
891    while start < total_size {
892        let end = (start + piece_size).min(total_size) - 1;
893        pieces.push_back(DownloadPiece { start, end });
894        start = end + 1;
895    }
896
897    pieces
898}
899
900/// Chooses the fixed piece size for a large archive.
901///
902/// Smaller large files use 32 MiB pieces so there are enough pieces for several workers.
903/// Very large files use 64 MiB pieces to keep the request count down.
904fn segmented_piece_size(total_size: u64) -> u64 {
905    if total_size < 2 * 1024 * 1024 * 1024 {
906        SEGMENTED_DOWNLOAD_SMALL_PIECE_SIZE
907    } else {
908        SEGMENTED_DOWNLOAD_LARGE_PIECE_SIZE
909    }
910}
911
912/// Builds the segmented download plan for one archive.
913///
914/// Small files stay single-stream. Larger files are split into fixed pieces and
915/// can use up to the shared request limit.
916fn plan_segmented_download(total_size: u64, max_workers: usize) -> Option<SegmentedDownloadPlan> {
917    if max_workers == 0 || total_size < SEGMENTED_DOWNLOAD_MIN_FILE_SIZE {
918        return None;
919    }
920
921    let piece_size = segmented_piece_size(total_size);
922    if total_size <= piece_size {
923        return None;
924    }
925
926    let pieces = build_download_pieces(total_size, piece_size);
927    let piece_count = pieces.len();
928    let worker_count = max_workers.min(piece_count).max(1);
929
930    Some(SegmentedDownloadPlan { piece_size, piece_count, worker_count, pieces })
931}
932
933/// Returns the retry backoff for one piece attempt.
934fn piece_retry_backoff(attempt: u32, throttled: bool) -> Duration {
935    let base = if throttled { 2 } else { RETRY_BACKOFF_SECS };
936    let multiplier = 1u64 << attempt.saturating_sub(1).min(3);
937    Duration::from_secs(base.saturating_mul(multiplier).min(SEGMENTED_DOWNLOAD_MAX_BACKOFF_SECS))
938}
939
940/// Returns whether an HTTP status should retry the current piece.
941fn is_retryable_piece_status(status: StatusCode) -> bool {
942    matches!(
943        status,
944        StatusCode::REQUEST_TIMEOUT |
945            StatusCode::TOO_MANY_REQUESTS |
946            StatusCode::INTERNAL_SERVER_ERROR |
947            StatusCode::BAD_GATEWAY |
948            StatusCode::SERVICE_UNAVAILABLE |
949            StatusCode::GATEWAY_TIMEOUT
950    )
951}
952
953/// Returns whether a piece request should retry after the given status.
954fn should_retry_piece_status(status: StatusCode) -> bool {
955    status == StatusCode::OK || is_retryable_piece_status(status)
956}
957
958/// Returns whether an HTTP status looks like throttling or timeout.
959fn is_throttle_piece_status(status: StatusCode) -> bool {
960    matches!(
961        status,
962        StatusCode::REQUEST_TIMEOUT |
963            StatusCode::TOO_MANY_REQUESTS |
964            StatusCode::SERVICE_UNAVAILABLE |
965            StatusCode::GATEWAY_TIMEOUT
966    )
967}
968
969/// Returns whether a reqwest error looks like throttling or timeout.
970fn is_throttle_piece_error(error: &reqwest::Error) -> bool {
971    error.is_timeout() || matches!(error.status(), Some(status) if is_throttle_piece_status(status))
972}
973
974/// The result of one piece download attempt.
975enum PieceAttemptFailure {
976    /// The piece can be retried.
977    Retryable { error: eyre::Error, throttled: bool },
978    /// The piece failed in a way that should stop the archive.
979    Terminal(eyre::Error),
980}
981
982/// Converts a thread panic payload into a readable message.
983fn panic_payload_message(payload: Box<dyn Any + Send + 'static>) -> String {
984    if let Some(message) = payload.downcast_ref::<&'static str>() {
985        (*message).to_string()
986    } else if let Some(message) = payload.downcast_ref::<String>() {
987        message.clone()
988    } else {
989        "unknown panic payload".to_string()
990    }
991}
992
993#[cfg(test)]
994mod tests {
995    use super::*;
996    use reqwest::StatusCode;
997    use reth_cli_util::cancellation::CancellationToken;
998    use std::io::Write;
999
1000    #[test]
1001    fn segmented_plan_skips_small_files() {
1002        assert!(plan_segmented_download(SEGMENTED_DOWNLOAD_MIN_FILE_SIZE - 1, 16).is_none());
1003    }
1004
1005    #[test]
1006    fn segmented_plan_uses_large_pieces_and_adaptive_workers() {
1007        let total_size = 512 * 1024 * 1024;
1008        let plan = plan_segmented_download(total_size, 32).unwrap();
1009
1010        assert_eq!(plan.piece_size, SEGMENTED_DOWNLOAD_SMALL_PIECE_SIZE);
1011        assert_eq!(plan.piece_count, 16);
1012        assert_eq!(plan.worker_count, 16);
1013    }
1014
1015    #[test]
1016    fn build_download_pieces_covers_entire_file() {
1017        let pieces = build_download_pieces(10, 4).into_iter().collect::<Vec<_>>();
1018
1019        assert_eq!(
1020            pieces,
1021            vec![
1022                DownloadPiece { start: 0, end: 3 },
1023                DownloadPiece { start: 4, end: 7 },
1024                DownloadPiece { start: 8, end: 9 },
1025            ]
1026        );
1027    }
1028
1029    #[test]
1030    fn piece_status_retry_policy_retries_200_ok() {
1031        assert!(should_retry_piece_status(StatusCode::OK));
1032        assert!(should_retry_piece_status(StatusCode::TOO_MANY_REQUESTS));
1033        assert!(!should_retry_piece_status(StatusCode::NOT_FOUND));
1034    }
1035
1036    #[test]
1037    fn choose_fetch_strategy_uses_segmented_when_ranges_are_supported() {
1038        let strategy = choose_fetch_strategy(
1039            RemoteArchiveProbe { total_size: 512 * 1024 * 1024, supports_ranges: true },
1040            16,
1041        );
1042
1043        assert!(matches!(strategy, FetchStrategy::Segmented(_)));
1044    }
1045
1046    #[test]
1047    fn choose_fetch_strategy_falls_back_without_ranges() {
1048        let strategy = choose_fetch_strategy(
1049            RemoteArchiveProbe { total_size: 512 * 1024 * 1024, supports_ranges: false },
1050            16,
1051        );
1052
1053        assert!(matches!(
1054            strategy,
1055            FetchStrategy::Sequential(SequentialDownloadFallback::NoRangeSupport)
1056        ));
1057    }
1058
1059    #[test]
1060    fn archive_fetcher_uses_file_url_archive_directly() {
1061        let dir = tempfile::tempdir().unwrap();
1062        let archive_path = dir.path().join("state.tar.zst");
1063        {
1064            let mut archive = std::fs::File::create(&archive_path).unwrap();
1065            archive.write_all(b"local archive bytes").unwrap();
1066        }
1067
1068        let cache_dir = dir.path().join("cache");
1069        std::fs::create_dir(&cache_dir).unwrap();
1070        let url = Url::from_file_path(&archive_path).unwrap().to_string();
1071        let session = DownloadSession::new(None, None, CancellationToken::new());
1072        let fetcher = ArchiveFetcher::new(url, &cache_dir, session);
1073
1074        let downloaded = fetcher.download(None).unwrap();
1075
1076        assert_eq!(downloaded.path, archive_path);
1077        assert_eq!(downloaded.size, b"local archive bytes".len() as u64);
1078        assert!(!cache_dir.join("state.tar.zst").exists());
1079        assert!(!cache_dir.join("state.tar.zst.part").exists());
1080    }
1081}