Skip to main content

rget/
engine.rs

1//! Download orchestration.
2//!
3//! The engine is a sequence of decisions, each of which can refuse to proceed:
4//! probe → destination → resume validation → reconcile → plan → transfer →
5//! barrier → verify. It owns no rendering and no argument parsing; it takes a
6//! [`DownloadRequest`] and publishes [`Event`]s.
7
8use 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
35/// How often the committer runs its durability barrier. This is the maximum
36/// amount of work a crash can cost us.
37const COMMIT_INTERVAL: Duration = Duration::from_millis(500);
38
39/// Benchmark-only override for [`COMMIT_INTERVAL`], in milliseconds. Lets a
40/// measurement price the durability barrier without a rebuild; unset or
41/// unparseable means the default.
42fn 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}
51/// How long we wait for workers to notice cancellation before committing
52/// anyway. PRD §26: do not wait indefinitely.
53const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
54
55#[derive(Debug, Clone)]
56pub struct DownloadRequest {
57    /// First entry is the primary; the rest are mirrors.
58    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    /// Discard existing progress and start over.
68    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    /// `None` when no checksum was requested.
87    pub verified: Option<bool>,
88    /// True when we stopped early because of Ctrl+C rather than finishing.
89    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    // -- 1. inspect the remote ------------------------------------------
102    let primary = req.primary().clone();
103    let policy = RetryPolicy {
104        max_attempts: req.retries.max(1),
105        ..RetryPolicy::default()
106    };
107    // Ask for the whole file when one connection is going to transfer it all,
108    // and for just the first range when we intend to fan out -- an open-ended
109    // primed body in a parallel download streams bytes no worker will ever read.
110    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    // Held until the plan exists, because only then do we know whether anything
117    // still needs byte 0. On a resume that is already past byte 0 this gets
118    // dropped, which costs one aborted response and saves nothing -- fresh
119    // downloads are the case worth optimising.
120    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    // -- 2. decide where it goes ----------------------------------------
136    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    // -- 3. find or create the record -----------------------------------
151    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    // -- 4. open the file, then check it is *our* file -------------------
199    //
200    // Look at what is on disk *before* opening: opening creates the file, which
201    // would make "the user deleted it" indistinguishable from "something
202    // replaced it".
203    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        // The file is gone, or empty. Either way there are no bytes to protect,
210        // so deleting it is a perfectly good way of saying "start over" — and
211        // refusing would leave the user stuck with a download they cannot run
212        // and cannot obviously fix.
213        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            // The file exists, holds data, and is not the one we were writing
233            // to. Its contents are somebody's, so we do not touch them.
234            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    // -- 5. is the remote still the same object? ------------------------
249    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    // Refresh what we know, including the file's identity now that it exists.
283    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    // -- 6. reconcile and plan -----------------------------------------
298    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    // -- 7. mirrors -----------------------------------------------------
334    let sources = Arc::new(
335        resolve_sources(&client, &req, &info, &reporter)
336            .await
337            .context("no usable source for this download")?,
338    );
339
340    // -- 8. transfer ----------------------------------------------------
341    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    // Only hand the primed body to the workers if byte 0 is still outstanding.
360    // A resume whose first range already has bytes on disk cannot splice this
361    // body in, so drop it and let the workers request what they actually need.
362    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                // One range is unrecoverable, so the download cannot finish.
415                // Stop the others rather than let them keep burning bandwidth.
416                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    // -- 9. final barrier ----------------------------------------------
428    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    // -- 10. finalise the file -----------------------------------------
476    let final_size = match info.size {
477        Some(total) => total,
478        None => {
479            // Unknown length: the transfer itself told us how big it was.
480            let discovered = ctx.discovered_size.load(Ordering::Acquire);
481            if discovered > 0 {
482                discovered
483            } else {
484                scheduler.written_bytes()
485            }
486        }
487    };
488    // Trim preallocation slack if the resource turned out shorter than planned.
489    if file.size()? > final_size {
490        file.truncate(final_size)?;
491    }
492    file.sync_data()?;
493
494    // -- 11. verify -----------------------------------------------------
495    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            // PRD Invariant 5: never report a mismatch as success.
510            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
542/// Probe, retrying transient failures. A 503 or a dropped connection on the
543/// very first request is exactly as transient as one halfway through, and
544/// failing the whole command over it would be the wrong call (PRD §14).
545async 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
583/// PRD §22: never clobber a file we know nothing about.
584fn 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    // A record for this destination under a different URL is still someone
592    // else's download; refusing protects both.
593    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/// How the fresh plan should be shaped: whether ranges may be split across
610/// connections at all, and how many leading bytes the priming probe already has
611/// in flight for the first range to pin itself to.
612#[derive(Debug, Clone, Copy)]
613struct PlanShape {
614    parallel: bool,
615    primed_len: u64,
616}
617
618/// Load, reconcile and if necessary rebuild the range plan.
619fn 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            // Pin the first range to the bytes the probe already has in flight.
633            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        // Persist immediately: an unpersisted plan means a crash a second later
641        // would find no ranges and start the whole file again.
642        let plan = fresh_plan();
643        store.replace_ranges(&record.id, &plan)?;
644        return Ok(plan);
645    }
646
647    // Without range support we cannot restart mid-file, so any partial progress
648    // has to be thrown away rather than resumed into.
649    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    // If the plan no longer describes the resource, rebuild it rather than
675    // patch it — a patched plan is how gaps get created.
676    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
691/// Probe every mirror and admit only those provably serving the same bytes.
692async 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
736/// Periodic durability barrier. See `docs/CRASH_CONSISTENCY.md`.
737async 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                // A failing store is serious but not a reason to corrupt the
757                // file; keep transferring and try again next tick.
758                warn!(%err, "checkpoint failed");
759                reporter.warn(format!("could not save progress: {err}"));
760            }
761        }
762    }
763}
764
765/// Snapshot → fsync → commit. The ordering is the whole point: nothing is
766/// recorded as durable until the bytes it describes are on stable storage.
767async 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    // 1. Snapshot what workers have handed to the kernel.
776    let snapshot = scheduler.snapshot();
777
778    // 2. Barrier. On a blocking thread: fsync can take seconds and must not
779    //    stall the runtime that is servicing the sockets.
780    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    // 3. Claim, in one transaction, only what the snapshot covered.
787    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            // Ranges were split; rewrite the whole plan so the partition on
793            // disk stays gapless.
794            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
822/// Wait for cancellation, then give workers a bounded grace period. Used by the
823/// CLI so Ctrl+C cannot hang the process (PRD §26).
824pub async fn grace_period(cancel: &Cancel) {
825    cancel.cancelled().await;
826    tokio::time::sleep(SHUTDOWN_GRACE).await;
827}