1use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::sync::atomic::{AtomicU64, Ordering};
11use std::time::{Duration, Instant};
12
13use anyhow::{Context, Result, anyhow, bail};
14use tokio::task::JoinSet;
15use tracing::{debug, info, warn};
16use url::Url;
17
18use crate::error::TransferError;
19use crate::file::DestFile;
20use crate::http::{self, HttpConfig, RemoteInfo};
21use crate::integrity::{self, Checksum};
22use crate::limit::RateLimiter;
23use crate::mirror::{self, Admission, Source, SourceSet};
24use crate::naming;
25use crate::progress::{Event, Reporter};
26use crate::resume::{self, Identity, Validation};
27use crate::retry::RetryPolicy;
28use crate::scheduler::{self, Scheduler};
29use crate::shutdown::Cancel;
30use crate::storage::{
31 DownloadRecord, ProgressUpdate, RangeRecord, RangeState, Status, Store, mint_cookie, now,
32};
33use crate::worker::{self, WorkerCtx, WorkerOutcome};
34
35const COMMIT_INTERVAL: Duration = Duration::from_millis(500);
38
39fn commit_interval() -> Duration {
43 match std::env::var("RGET_BENCH_COMMIT_INTERVAL_MS") {
44 Ok(raw) => match raw.trim().parse::<u64>() {
45 Ok(ms) => Duration::from_millis(ms),
46 Err(_) => COMMIT_INTERVAL,
47 },
48 Err(_) => COMMIT_INTERVAL,
49 }
50}
51const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
54
55#[derive(Debug, Clone)]
56pub struct DownloadRequest {
57 pub urls: Vec<Url>,
59 pub output: Option<String>,
60 pub dir: Option<String>,
61 pub connections: usize,
62 pub checksum: Option<Checksum>,
63 pub limit: Option<u64>,
64 pub http: HttpConfig,
65 pub retries: u32,
66 pub overwrite: bool,
67 pub restart: bool,
69 pub preallocate: bool,
70}
71
72impl DownloadRequest {
73 pub fn primary(&self) -> &Url {
74 &self.urls[0]
75 }
76}
77
78#[derive(Debug, Clone)]
79pub struct DownloadReport {
80 pub id: String,
81 pub path: PathBuf,
82 pub filename: String,
83 pub downloaded: u64,
84 pub total: Option<u64>,
85 pub elapsed: Duration,
86 pub verified: Option<bool>,
88 pub paused: bool,
90}
91
92pub async fn download(
93 store: Arc<Store>,
94 req: DownloadRequest,
95 reporter: Reporter,
96 cancel: Cancel,
97) -> Result<DownloadReport> {
98 let started = Instant::now();
99 let client = http::build_client(&req.http)?;
100
101 let primary = req.primary().clone();
103 let policy = RetryPolicy {
104 max_attempts: req.retries.max(1),
105 ..RetryPolicy::default()
106 };
107 let prime = (req.connections > 1).then_some(scheduler::MIN_CHUNK);
111 let primed = probe_with_retry(&client, &primary, prime, &policy, &reporter, &cancel)
112 .await
113 .with_context(|| format!("cannot reach {}", http::redact(&primary)))?;
114 let info = primed.info.clone();
115 let primed_len = primed.body_len;
116 let mut primed_body = primed.body;
121 debug!(
122 url = %http::redact(&info.final_url),
123 size = ?info.size,
124 ranges = info.accept_ranges,
125 etag = ?info.etag,
126 "probed remote"
127 );
128 if let Some(encoding) = &info.content_encoding {
129 reporter.warn(format!(
130 "server is sending `Content-Encoding: {encoding}`; the saved file will be in that \
131 encoded form"
132 ));
133 }
134
135 let dest = naming::choose(
137 req.output.as_deref(),
138 req.dir.as_deref(),
139 info.content_disposition.as_deref(),
140 &info.final_url,
141 )?;
142 let parent = dest
143 .path
144 .parent()
145 .map(Path::to_path_buf)
146 .unwrap_or_else(|| PathBuf::from("."));
147 naming::assert_within(&parent, &dest.path)?;
148 debug!(path = %dest.path.display(), source = ?dest.source, "chose destination");
149
150 let existing = store.find_for(primary.as_str(), &dest.path)?;
152 let mut record = match existing {
153 Some(rec) => rec,
154 None => {
155 guard_existing_file(&store, &dest.path, &req)?;
156 let id = store.mint_id(primary.as_str())?;
157 let rec = DownloadRecord {
158 id,
159 original_url: primary.to_string(),
160 resolved_url: Some(info.final_url.to_string()),
161 mirrors: req.urls.iter().skip(1).map(|u| u.to_string()).collect(),
162 destination: dest.path.to_string_lossy().to_string(),
163 filename: dest.filename.clone(),
164 total_size: info.size,
165 etag: info.etag.clone(),
166 last_modified: info.last_modified.clone(),
167 content_type: info.content_type.clone(),
168 accept_ranges: info.accept_ranges,
169 expected_checksum: req.checksum.as_ref().map(|c| c.expected.clone()),
170 checksum_algorithm: req.checksum.as_ref().map(|c| c.algorithm.to_string()),
171 file_cookie: mint_cookie(),
172 file_dev: None,
173 file_ino: None,
174 durable_bytes: 0,
175 status: Status::Pending,
176 error: None,
177 created_at: now(),
178 updated_at: now(),
179 completed_at: None,
180 };
181 store.insert(&rec)?;
182 rec
183 }
184 };
185
186 if req.restart {
187 reporter.info("--restart: discarding previous progress");
188 store.reset(&record.id)?;
189 record = store
190 .get(&record.id)?
191 .ok_or_else(|| anyhow!("download record vanished"))?;
192 if dest.path.exists() {
193 std::fs::remove_file(&dest.path)
194 .with_context(|| format!("cannot remove {}", dest.path.display()))?;
195 }
196 }
197
198 let bytes_on_disk = std::fs::metadata(&dest.path).ok().map(|m| m.len());
204 let file = Arc::new(DestFile::open(&dest.path)?);
205 let file_len = file.size()?;
206 let mut had_progress = record.durable_bytes > 0;
207
208 if had_progress && bytes_on_disk.unwrap_or(0) == 0 {
209 reporter.warn(format!(
214 "{} {}, so there is nothing to resume; starting over",
215 dest.path.display(),
216 if bytes_on_disk.is_none() {
217 "was removed"
218 } else {
219 "is empty"
220 }
221 ));
222 store.reset(&record.id)?;
223 record = store
224 .get(&record.id)?
225 .ok_or_else(|| anyhow!("download record vanished"))?;
226 had_progress = false;
227 }
228
229 if had_progress {
230 match resume::check_identity(&record, &file) {
231 Identity::Same => {}
232 Identity::Replaced => bail!(
235 "{} holds {} but is not the file this download was writing to.\n\
236 It was replaced or recreated by something else since the last run.\n\
237 Use --restart to download it again, --output <different-name> to keep both,\n\
238 or delete the file if you do not need it.",
239 dest.path.display(),
240 crate::fmt::bytes(bytes_on_disk.unwrap_or(0)),
241 ),
242 Identity::Unrecorded => {
243 reporter.warn("no recorded file identity for this download; relying on size checks")
244 }
245 }
246 }
247
248 if had_progress {
250 match resume::validate(&record, &info) {
251 Validation::Unchanged => reporter.info("remote file unchanged"),
252 Validation::Unverifiable(reason) => reporter.warn(format!(
253 "cannot confirm the remote file is unchanged: {reason}"
254 )),
255 Validation::Changed {
256 reason,
257 previous,
258 current,
259 } => {
260 store.set_status(&record.id, Status::Failed, Some(&reason))?;
261 bail!(
262 "Remote file changed since the previous download.\n\
263 Previous:\n ETag: {}\n Size: {}\n\
264 Current:\n ETag: {}\n Size: {}\n\
265 Refusing to resume because this could corrupt the file.\n\
266 Re-download from scratch with --restart.",
267 previous.etag.as_deref().unwrap_or("(none)"),
268 previous
269 .size
270 .map(crate::fmt::bytes)
271 .unwrap_or_else(|| "(unknown)".into()),
272 current.etag.as_deref().unwrap_or("(none)"),
273 current
274 .size
275 .map(crate::fmt::bytes)
276 .unwrap_or_else(|| "(unknown)".into()),
277 );
278 }
279 }
280 }
281
282 let identity = file.identity().ok();
284 record.resolved_url = Some(info.final_url.to_string());
285 record.total_size = info.size;
286 record.etag = info.etag.clone();
287 record.last_modified = info.last_modified.clone();
288 record.content_type = info.content_type.clone();
289 record.accept_ranges = info.accept_ranges;
290 record.mirrors = req.urls.iter().skip(1).map(|u| u.to_string()).collect();
291 record.expected_checksum = req.checksum.as_ref().map(|c| c.expected.clone());
292 record.checksum_algorithm = req.checksum.as_ref().map(|c| c.algorithm.to_string());
293 record.file_dev = identity.map(|i| i.dev);
294 record.file_ino = identity.map(|i| i.ino);
295 store.update_remote_metadata(&record)?;
296
297 let parallel = info.supports_parallel() && req.connections > 1;
299 let ranges = build_plan(
300 &store,
301 &record,
302 &info,
303 &req,
304 file_len,
305 PlanShape {
306 parallel,
307 primed_len,
308 },
309 &reporter,
310 )?;
311 let resumed_bytes: u64 = ranges
312 .iter()
313 .map(|r| {
314 if r.state == RangeState::Complete {
315 r.size()
316 } else {
317 r.bytes_written
318 }
319 })
320 .sum();
321
322 if req.preallocate && parallel {
323 if let Some(total) = info.size {
324 file.preallocate(total).with_context(|| {
325 format!(
326 "cannot reserve {} for the download",
327 crate::fmt::bytes(total)
328 )
329 })?;
330 }
331 }
332
333 let sources = Arc::new(
335 resolve_sources(&client, &req, &info, &reporter)
336 .await
337 .context("no usable source for this download")?,
338 );
339
340 let scheduler = Arc::new(Scheduler::from_ranges(&ranges));
342 let (done, total_ranges) = scheduler.counts();
343 reporter.stats.set_ranges_complete(done);
344 reporter.stats.set_ranges_total(total_ranges);
345 reporter.stats.set_downloaded(resumed_bytes);
346 reporter.stats.set_durable(resumed_bytes);
347
348 store.set_status(&record.id, Status::Downloading, None)?;
349 reporter.emit(Event::DownloadStarted {
350 id: record.id.clone(),
351 filename: record.filename.clone(),
352 url: http::redact(&info.final_url),
353 total_size: info.size,
354 resumed_bytes,
355 connections: req.connections,
356 parallel,
357 });
358
359 let needs_byte_zero = ranges
363 .iter()
364 .any(|r| r.start == 0 && r.state != RangeState::Complete && r.bytes_written == 0);
365 if !needs_byte_zero {
366 primed_body = None;
367 }
368 let primed_body = primed_body.map(|response| worker::PrimedBody {
369 url: info.final_url.clone(),
370 response,
371 });
372 debug!(primed = primed_body.is_some(), "priming probe body");
373
374 let ctx = Arc::new(WorkerCtx {
375 client: client.clone(),
376 sources: sources.clone(),
377 file: file.clone(),
378 scheduler: scheduler.clone(),
379 reporter: reporter.clone(),
380 retry: policy,
381 limiter: req.limit.map(|bps| Arc::new(RateLimiter::new(bps))),
382 read_timeout: req.http.timeout,
383 expected_total: info.size,
384 ranges_supported: info.accept_ranges,
385 discovered_size: Arc::new(AtomicU64::new(0)),
386 cancel: cancel.clone(),
387 primed: std::sync::Mutex::new(primed_body),
388 });
389
390 let committer = tokio::spawn(commit_loop(
391 store.clone(),
392 record.id.clone(),
393 file.clone(),
394 scheduler.clone(),
395 reporter.clone(),
396 cancel.clone(),
397 ranges.len(),
398 ));
399
400 let worker_count = if parallel { req.connections } else { 1 };
401 let mut workers = JoinSet::new();
402 for id in 0..worker_count {
403 workers.spawn(worker::run(ctx.clone(), id));
404 }
405
406 let mut fatal: Option<TransferError> = None;
407 while let Some(joined) = workers.join_next().await {
408 match joined {
409 Ok(WorkerOutcome::Finished) => {}
410 Ok(WorkerOutcome::Fatal(err)) => {
411 if fatal.is_none() {
412 fatal = Some(err);
413 }
414 cancel.cancel();
417 }
418 Err(join_err) => {
419 if fatal.is_none() {
420 fatal = Some(TransferError::Io(format!("worker panicked: {join_err}")));
421 }
422 cancel.cancel();
423 }
424 }
425 }
426
427 committer.abort();
429 let _ = committer.await;
430 let durable = checkpoint(
431 &store,
432 &record.id,
433 &file,
434 &scheduler,
435 &reporter,
436 ranges.len(),
437 )
438 .await?;
439 debug!(durable, "final checkpoint written");
440
441 let cancelled = cancel.is_cancelled() && fatal.is_none();
442 let downloaded = reporter.stats.downloaded();
443
444 if let Some(err) = fatal {
445 store.set_status(&record.id, Status::Failed, Some(&err.to_string()))?;
446 reporter.emit(Event::DownloadFailed {
447 error: err.to_string(),
448 });
449 return Err(anyhow!(err));
450 }
451
452 if cancelled {
453 store.set_status(&record.id, Status::Paused, None)?;
454 reporter.emit(Event::DownloadPaused {
455 downloaded: durable,
456 total_size: info.size,
457 });
458 return Ok(DownloadReport {
459 id: record.id,
460 path: dest.path,
461 filename: record.filename,
462 downloaded: durable,
463 total: info.size,
464 elapsed: started.elapsed(),
465 verified: None,
466 paused: true,
467 });
468 }
469
470 if !scheduler.is_finished() {
471 store.set_status(&record.id, Status::Paused, None)?;
472 bail!("download stopped with ranges outstanding; run the same command to continue");
473 }
474
475 let final_size = match info.size {
477 Some(total) => total,
478 None => {
479 let discovered = ctx.discovered_size.load(Ordering::Acquire);
481 if discovered > 0 {
482 discovered
483 } else {
484 scheduler.written_bytes()
485 }
486 }
487 };
488 if file.size()? > final_size {
490 file.truncate(final_size)?;
491 }
492 file.sync_data()?;
493
494 let mut verified = None;
496 if let Some(checksum) = req.checksum.clone() {
497 store.set_status(&record.id, Status::Verifying, None)?;
498 let outcome = integrity::verify(&dest.path, checksum, reporter.clone(), cancel.clone())
499 .await
500 .context("verification failed to run")?;
501 verified = Some(outcome.ok());
502 if !outcome.ok() {
503 let msg = match &outcome {
504 integrity::Outcome::Mismatch { expected, actual } => {
505 format!("checksum mismatch: expected {expected}, got {actual}")
506 }
507 integrity::Outcome::Match { .. } => unreachable!(),
508 };
509 store.set_status(&record.id, Status::Failed, Some(&msg))?;
511 reporter.emit(Event::DownloadFailed { error: msg.clone() });
512 bail!("{msg}");
513 }
514 }
515
516 store.set_status(&record.id, Status::Complete, None)?;
517 let elapsed = started.elapsed();
518 let this_run = downloaded.saturating_sub(resumed_bytes);
519 reporter.emit(Event::DownloadCompleted {
520 downloaded: final_size,
521 elapsed_ms: elapsed.as_millis() as u64,
522 average_bps: if elapsed.as_secs_f64() > 0.0 {
523 (this_run as f64 / elapsed.as_secs_f64()) as u64
524 } else {
525 0
526 },
527 });
528 info!(id = %record.id, path = %dest.path.display(), "download complete");
529
530 Ok(DownloadReport {
531 id: record.id,
532 path: dest.path,
533 filename: record.filename,
534 downloaded: final_size,
535 total: Some(final_size),
536 elapsed,
537 verified,
538 paused: false,
539 })
540}
541
542async fn probe_with_retry(
546 client: &reqwest::Client,
547 url: &Url,
548 prime: Option<u64>,
549 policy: &RetryPolicy,
550 reporter: &Reporter,
551 cancel: &Cancel,
552) -> Result<http::Primed, TransferError> {
553 let mut attempts = 0u32;
554 loop {
555 if cancel.is_cancelled() {
556 return Err(TransferError::Cancelled);
557 }
558 match http::probe_priming(client, url, prime).await {
559 Ok(primed) => return Ok(primed),
560 Err(err) => {
561 attempts += 1;
562 match policy.decide(&err, attempts) {
563 crate::retry::Decision::Retry { delay, attempt } => {
564 reporter.stats.record_retry();
565 reporter.emit(Event::RetryScheduled {
566 index: None,
567 attempt,
568 delay_ms: delay.as_millis() as u64,
569 reason: err.to_string(),
570 });
571 tokio::select! {
572 _ = tokio::time::sleep(delay) => {}
573 _ = cancel.cancelled() => return Err(TransferError::Cancelled),
574 }
575 }
576 crate::retry::Decision::GiveUp => return Err(err),
577 }
578 }
579 }
580 }
581}
582
583fn guard_existing_file(store: &Store, path: &Path, req: &DownloadRequest) -> Result<()> {
585 if !path.exists() {
586 return Ok(());
587 }
588 if req.overwrite || req.restart {
589 return Ok(());
590 }
591 let owner = store.find_by_destination(path)?;
594 let hint = match owner {
595 Some(rec) if rec.status.is_resumable() => format!(
596 "\n{} is the destination of download {} ({}).",
597 path.display(),
598 rec.id,
599 rec.original_url
600 ),
601 _ => String::new(),
602 };
603 bail!(
604 "{} already exists.{hint}\nUse:\n --overwrite\n --output <different-name>",
605 path.display()
606 );
607}
608
609#[derive(Debug, Clone, Copy)]
613struct PlanShape {
614 parallel: bool,
615 primed_len: u64,
616}
617
618fn build_plan(
620 store: &Store,
621 record: &DownloadRecord,
622 info: &RemoteInfo,
623 req: &DownloadRequest,
624 file_len: u64,
625 shape: PlanShape,
626 reporter: &Reporter,
627) -> Result<Vec<RangeRecord>> {
628 let persisted = store.load_ranges(&record.id)?;
629
630 let fresh_plan = || -> Vec<RangeRecord> {
631 if shape.parallel {
632 scheduler::plan_primed(info.size.unwrap_or(0), req.connections, shape.primed_len)
634 } else {
635 scheduler::plan_sequential(info.size)
636 }
637 };
638
639 if persisted.is_empty() {
640 let plan = fresh_plan();
643 store.replace_ranges(&record.id, &plan)?;
644 return Ok(plan);
645 }
646
647 if !info.accept_ranges {
650 let progress: u64 = persisted.iter().map(|r| r.bytes_written).sum();
651 if progress > 0 {
652 reporter.warn(format!(
653 "server does not support resuming, so the {} already downloaded must be fetched \
654 again",
655 crate::fmt::bytes(progress)
656 ));
657 }
658 let plan = fresh_plan();
659 store.replace_ranges(&record.id, &plan)?;
660 return Ok(plan);
661 }
662
663 let reconciled = resume::reconcile(&persisted, file_len, info.size);
664 for note in &reconciled.notes {
665 reporter.warn(note.clone());
666 }
667 if reconciled.discarded_bytes > 0 {
668 reporter.warn(format!(
669 "re-downloading {} that could not be trusted",
670 crate::fmt::bytes(reconciled.discarded_bytes)
671 ));
672 }
673
674 let intact = match info.size {
677 Some(total) => resume::plan_is_intact(&reconciled.ranges, total),
678 None => reconciled.ranges.len() == 1,
679 };
680 if !intact {
681 reporter.warn("previous range plan no longer matches the remote file; replanning");
682 let plan = fresh_plan();
683 store.replace_ranges(&record.id, &plan)?;
684 return Ok(plan);
685 }
686
687 store.replace_ranges(&record.id, &reconciled.ranges)?;
688 Ok(reconciled.ranges)
689}
690
691async fn resolve_sources(
693 client: &reqwest::Client,
694 req: &DownloadRequest,
695 primary_info: &RemoteInfo,
696 reporter: &Reporter,
697) -> Result<SourceSet> {
698 let mut sources = vec![Source::new(
699 req.primary().clone(),
700 Admission::Primary,
701 primary_info.validator(),
702 )];
703 let has_checksum = req.checksum.is_some();
704
705 for url in req.urls.iter().skip(1) {
706 match http::probe(client, url).await {
707 Ok(info) => {
708 let admission = mirror::classify(primary_info, &info, has_checksum);
709 match &admission {
710 Admission::Rejected(reason) => {
711 reporter.warn(format!("ignoring mirror {}: {reason}", http::redact(url)))
712 }
713 Admission::ChecksumGuarded => reporter.info(format!(
714 "using mirror {} on the strength of the supplied checksum",
715 http::redact(url)
716 )),
717 _ => reporter.info(format!("mirror {} verified", http::redact(url))),
718 }
719 let validator = info.validator();
720 sources.push(Source::new(url.clone(), admission, validator));
721 }
722 Err(err) => reporter.warn(format!(
723 "ignoring unreachable mirror {}: {err}",
724 http::redact(url)
725 )),
726 }
727 }
728
729 let set = SourceSet::new(sources);
730 if set.is_empty() {
731 bail!("every source was rejected");
732 }
733 Ok(set)
734}
735
736async fn commit_loop(
738 store: Arc<Store>,
739 id: String,
740 file: Arc<DestFile>,
741 scheduler: Arc<Scheduler>,
742 reporter: Reporter,
743 cancel: Cancel,
744 initial_range_count: usize,
745) {
746 let mut known_ranges = initial_range_count;
747 let interval = commit_interval();
748 loop {
749 tokio::select! {
750 _ = tokio::time::sleep(interval) => {}
751 _ = cancel.cancelled() => break,
752 }
753 match checkpoint(&store, &id, &file, &scheduler, &reporter, known_ranges).await {
754 Ok(_) => known_ranges = scheduler.snapshot().len(),
755 Err(err) => {
756 warn!(%err, "checkpoint failed");
759 reporter.warn(format!("could not save progress: {err}"));
760 }
761 }
762 }
763}
764
765async fn checkpoint(
768 store: &Arc<Store>,
769 id: &str,
770 file: &Arc<DestFile>,
771 scheduler: &Arc<Scheduler>,
772 reporter: &Reporter,
773 known_ranges: usize,
774) -> Result<u64> {
775 let snapshot = scheduler.snapshot();
777
778 let f = file.clone();
781 tokio::task::spawn_blocking(move || f.sync_data())
782 .await
783 .context("fsync task panicked")?
784 .context("fsync of the destination file failed")?;
785
786 let store = store.clone();
788 let id = id.to_string();
789 let structural_change = snapshot.len() != known_ranges;
790 let durable = tokio::task::spawn_blocking(move || -> Result<u64> {
791 if structural_change {
792 store.replace_ranges(&id, &snapshot)?;
795 Ok(snapshot.iter().map(|r| r.bytes_written).sum())
796 } else {
797 let updates: Vec<ProgressUpdate> = snapshot
798 .iter()
799 .map(|r| ProgressUpdate {
800 idx: r.idx,
801 bytes_written: r.bytes_written.min(r.size()),
802 state: match r.state {
803 RangeState::Complete => RangeState::Complete,
804 _ if r.bytes_written > 0 => RangeState::Downloading,
805 other => other,
806 },
807 })
808 .collect();
809 store.commit_progress(&id, &updates)
810 }
811 })
812 .await
813 .context("checkpoint task panicked")??;
814
815 reporter.stats.set_durable(durable);
816 reporter.emit(Event::Checkpointed {
817 durable_bytes: durable,
818 });
819 Ok(durable)
820}
821
822pub async fn grace_period(cancel: &Cancel) {
825 cancel.cancelled().await;
826 tokio::time::sleep(SHUTDOWN_GRACE).await;
827}