From f7c5496ddbc1f66ae792a03a232ececf7bd7b2b0 Mon Sep 17 00:00:00 2001 From: Dirkjan Ochtman Date: Fri, 30 Oct 2026 10:33:45 +0100 Subject: [PATCH 1/7] download: keep partial file when the response body is interrupted --- src/download/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/download/mod.rs b/src/download/mod.rs index 7f94d10ca9..1f56b6c230 100644 --- a/src/download/mod.rs +++ b/src/download/mod.rs @@ -316,7 +316,7 @@ impl<'a> Download<'a> { let mut stream = res.bytes_stream(); while let Some(item) = stream.next().await { - let bytes = item.map_err(DownloadError::Reqwest)?; + let bytes = item.map_err(DownloadError::Interrupted)?; file.write_all(&bytes) .context("unable to write download to disk")?; self.data_received(&bytes); @@ -338,6 +338,8 @@ pub(crate) fn is_network_failure(err: &anyhow::Error) -> bool { match err.downcast_ref::() { #[cfg(any(feature = "reqwest-rustls-tls", feature = "reqwest-native-tls"))] Some(DownloadError::Reqwest(e)) => e.is_timeout() || e.is_connect(), + #[cfg(any(feature = "reqwest-rustls-tls", feature = "reqwest-native-tls"))] + Some(DownloadError::Interrupted(_)) => true, _ => false, } } @@ -447,6 +449,9 @@ enum DownloadError { #[cfg(any(feature = "reqwest-rustls-tls", feature = "reqwest-native-tls"))] #[error(transparent)] Reqwest(#[from] reqwest::Error), + #[cfg(any(feature = "reqwest-rustls-tls", feature = "reqwest-native-tls"))] + #[error(transparent)] + Interrupted(reqwest::Error), #[cfg(feature = "reqwest-rustls-tls")] #[error("failed to initialize platform verifier")] PlatformVerifierInit(#[source] rustls::Error), From af5acb0e82e62c7982ec8d021cbec2981c799bd5 Mon Sep 17 00:00:00 2001 From: Dirkjan Ochtman Date: Fri, 30 Oct 2026 10:33:45 +0100 Subject: [PATCH 2/7] dist: retry downloads that kept a partial file --- src/dist/manifestation.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/dist/manifestation.rs b/src/dist/manifestation.rs index 82eb59ec47..f7644fd5de 100644 --- a/src/dist/manifestation.rs +++ b/src/dist/manifestation.rs @@ -784,6 +784,7 @@ impl<'a> ComponentBinary<'a> { // retry only known retriable cases match e.downcast_ref::() { Some(RustupError::BrokenPartialFile) + | Some(RustupError::IncompletePartialFile) | Some(RustupError::DownloadingFile { .. }) => { self.status.retrying(); true From 07a2ef748bd23a6aa1e83b536349e1694c7ee791 Mon Sep 17 00:00:00 2001 From: Dirkjan Ochtman Date: Fri, 30 Oct 2026 10:33:45 +0100 Subject: [PATCH 3/7] dist: start download progress at the resumed offset --- src/dist/download.rs | 3 ++- src/download/mod.rs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/dist/download.rs b/src/dist/download.rs index 01dc0d2942..2e027800c6 100644 --- a/src/dist/download.rs +++ b/src/dist/download.rs @@ -366,9 +366,10 @@ pub(crate) struct DownloadStatus { } impl DownloadStatus { - pub(crate) fn received_length(&self, len: u64) { + pub(crate) fn received_length(&self, len: u64, resumed: u64) { self.progress.reset(); self.progress.set_length(len); + self.progress.set_position(resumed); } pub(crate) fn received_data(&self, len: usize) { diff --git a/src/download/mod.rs b/src/download/mod.rs index 1f56b6c230..e517c70d7d 100644 --- a/src/download/mod.rs +++ b/src/download/mod.rs @@ -310,7 +310,7 @@ impl<'a> Download<'a> { if let Some(len) = res.content_length() { let len = len + resume_from; if let Some(status) = self.status { - status.received_length(len); + status.received_length(len, resume_from); } } From c9de488b12beb6361065a6fe890a40135607ac0d Mon Sep 17 00:00:00 2001 From: Dirkjan Ochtman Date: Fri, 30 Oct 2026 10:33:45 +0100 Subject: [PATCH 4/7] tests: cover resuming an interrupted download --- src/download/tests.rs | 58 +++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 56 insertions(+), 2 deletions(-) diff --git a/src/download/tests.rs b/src/download/tests.rs index 81a5d5f6ec..32cb1bd17e 100644 --- a/src/download/tests.rs +++ b/src/download/tests.rs @@ -2,7 +2,7 @@ use std::{ convert::Infallible, env::remove_var, fs, io, - net::SocketAddr, + net::{SocketAddr, TcpListener}, path::Path, sync::{ LazyLock, @@ -30,7 +30,7 @@ mod reqwest { use reqwest::{Client, Proxy}; use url::Url; - use super::{scrub_env, serve_file, tmp_dir, write_file}; + use super::{scrub_env, serve_file, serve_truncated, tmp_dir, write_file}; use crate::download::{DownloadOptions, Tls}; const OPTIONS: DownloadOptions = DownloadOptions { @@ -170,6 +170,35 @@ mod reqwest { assert!(target_path.exists(), "partial file should not be deleted"); assert_eq!(std::fs::read_to_string(&target_path).unwrap(), "123"); } + + #[tokio::test] + async fn interrupted_download_resumes_from_partial_file() { + let _guard = scrub_env().await; + let tmpdir = tmp_dir(); + let target_path = tmpdir.path().join("downloaded.partial"); + + let addr = serve_truncated(b"123".to_vec(), 5); + let from_url = format!("http://{addr}").parse().unwrap(); + OPTIONS + .start(&from_url, &target_path) + .with_resume() + .download() + .await + .expect_err("download should fail if the body is cut short"); + + assert_eq!(std::fs::read_to_string(&target_path).unwrap(), "123"); + + let addr = serve_file(b"12345".to_vec(), true); + let from_url = format!("http://{addr}").parse().unwrap(); + OPTIONS + .start(&from_url, &target_path) + .with_resume() + .download() + .await + .expect("resumed download should succeed"); + + assert_eq!(std::fs::read_to_string(&target_path).unwrap(), "12345"); + } } pub fn tmp_dir() -> TempDir { @@ -284,6 +313,31 @@ fn serve_contents( res } +/// Serve a single response that announces `len` bytes, then closes the connection after `contents` +pub fn serve_truncated(contents: Vec, len: usize) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").expect("can not bind"); + let addr = listener.local_addr().unwrap(); + + thread::spawn(move || { + let (mut stream, _) = listener.accept().expect("could not accept connection"); + let mut request = Vec::new(); + let mut buf = [0; 1024]; + loop { + let n = io::Read::read(&mut stream, &mut buf).expect("could not read request"); + request.extend_from_slice(&buf[..n]); + if n == 0 || request.ends_with(b"\r\n\r\n") { + break; + } + } + + let head = format!("HTTP/1.1 200 OK\r\nContent-Length: {len}\r\n\r\n"); + io::Write::write_all(&mut stream, head.as_bytes()).expect("could not write response"); + io::Write::write_all(&mut stream, &contents).expect("could not write response"); + }); + + addr +} + /// Clear proxy-related environment variables /// /// Every test using a proxy-sensitive URL should call this and hold the returned guard, From 78c3be89826aff3c5c66348e28f1a799d07eb257 Mon Sep 17 00:00:00 2001 From: Dirkjan Ochtman Date: Fri, 30 Oct 2026 10:48:03 +0100 Subject: [PATCH 5/7] dist: replace tokio-retry with a plain retry loop --- Cargo.lock | 12 --------- Cargo.toml | 1 - src/dist/manifestation.rs | 55 +++++++++++++++++++++------------------ 3 files changed, 30 insertions(+), 38 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3ff358a3a7..ae8f97b699 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2120,7 +2120,6 @@ dependencies = [ "thiserror", "threadpool", "tokio", - "tokio-retry", "tokio-rustls", "tokio-stream", "toml", @@ -2584,17 +2583,6 @@ dependencies = [ "tokio", ] -[[package]] -name = "tokio-retry" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" -dependencies = [ - "pin-project-lite", - "rand 0.10.3", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.6" diff --git a/Cargo.toml b/Cargo.toml index 23807c7ad4..4107ad57c6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -92,7 +92,6 @@ tempfile = "3.8" thiserror = "2" threadpool = "1" tokio = { version = "1.26.0", default-features = false, features = ["macros", "rt-multi-thread", "sync"] } -tokio-retry = "0.3.0" tokio-stream = "0.1.14" toml = "1.0" tracing = "0.1" diff --git a/src/dist/manifestation.rs b/src/dist/manifestation.rs index f7644fd5de..7b428decdf 100644 --- a/src/dist/manifestation.rs +++ b/src/dist/manifestation.rs @@ -770,33 +770,38 @@ impl<'a> ComponentBinary<'a> { })) } - async fn download(self, max_retries: usize) -> anyhow::Result<(ComponentInstall, &'a str)> { - use tokio_retry::{RetryIf, strategy::FixedInterval}; - + async fn download(self, mut retries: usize) -> anyhow::Result<(ComponentInstall, &'a str)> { let url = self.download_cfg.url(&self.binary.url)?; - let installer = RetryIf::start( - FixedInterval::from_millis(0).take(max_retries), - || { - self.download_cfg - .download(&url, &self.binary.hash, &self.status) - }, - |e: &anyhow::Error| { - // retry only known retriable cases - match e.downcast_ref::() { - Some(RustupError::BrokenPartialFile) - | Some(RustupError::IncompletePartialFile) - | Some(RustupError::DownloadingFile { .. }) => { - self.status.retrying(); - true - } - _ => false, + let installer = loop { + let future = self + .download_cfg + .download(&url, &self.binary.hash, &self.status); + let err = match future.await { + Ok(file) => break file, + Err(err) => err, + }; + + // retry only known retriable cases + match err.downcast_ref::() { + Some(RustupError::BrokenPartialFile) + | Some(RustupError::IncompletePartialFile) + | Some(RustupError::DownloadingFile { .. }) => {} + _ => { + return Err(err).with_context(|| { + RustupError::ComponentDownloadFailed(self.manifest.name(&self.component)) + }); } - }, - ) - .await - .with_context(|| { - RustupError::ComponentDownloadFailed(self.manifest.name(&self.component)) - })?; + } + + if retries == 0 { + return Err(err).with_context(|| { + RustupError::ComponentDownloadFailed(self.manifest.name(&self.component)) + }); + } + + retries -= 1; + self.status.retrying(); + }; let install = ComponentInstall { status: self.status, From eb86fb32534fbed753fe7013004ee7e6aaf4ce36 Mon Sep 17 00:00:00 2001 From: Dirkjan Ochtman Date: Fri, 30 Oct 2026 11:04:21 +0100 Subject: [PATCH 6/7] dist: simplify partial file path calculation --- src/dist/download.rs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/dist/download.rs b/src/dist/download.rs index 2e027800c6..55cfb13a1b 100644 --- a/src/dist/download.rs +++ b/src/dist/download.rs @@ -64,6 +64,8 @@ impl<'a> DownloadCfg<'a> { ) -> anyhow::Result { utils::ensure_dir_exists("Download Directory", self.download_dir)?; let target_file = self.download_dir.join(Path::new(hash)); + let partial_file_path = self.download_dir.join(format!("{hash}.partial")); + let partial_file_existed = partial_file_path.exists(); if target_file.exists() { let cached_result = file_hash(&target_file)?; @@ -77,17 +79,6 @@ impl<'a> DownloadCfg<'a> { } } - let partial_file_path = target_file.with_file_name( - target_file - .file_name() - .map(|s| s.to_str().unwrap_or("_")) - .unwrap_or("_") - .to_owned() - + ".partial", - ); - - let partial_file_existed = partial_file_path.exists(); - let mut hasher = Sha256::new(); let mut download = DownloadOptions::try_from(self.process)? .start(url, &partial_file_path) From de883d7f2ddff81fd297dfc3e3859550a6471b1a Mon Sep 17 00:00:00 2001 From: Dirkjan Ochtman Date: Fri, 30 Oct 2026 10:48:03 +0100 Subject: [PATCH 7/7] dist: don't count download attempts that make progress as retries --- doc/dev-guide/src/tips-and-tricks.md | 4 +++- src/dist/download.rs | 14 +++++++++++++- src/dist/manifestation.rs | 19 ++++++++++++++----- 3 files changed, 30 insertions(+), 7 deletions(-) diff --git a/doc/dev-guide/src/tips-and-tricks.md b/doc/dev-guide/src/tips-and-tricks.md index 47773502c0..fc4053a9cc 100644 --- a/doc/dev-guide/src/tips-and-tricks.md +++ b/doc/dev-guide/src/tips-and-tricks.md @@ -52,7 +52,9 @@ should one arise with the backtrack limits. When downloading a file, rustup will retry the download a number of times. The default is 3 times, but if this variable is set to a valid usize then it is the max retry count. A value of `0` means no retries, thus the default of `3` will -mean a download is tried a total of four times before failing out. +mean a download is tried a total of four times before failing out. Attempts +that download at least 1 MiB of the file before failing are resumed by the +next attempt and don't count towards this limit. ## `RUSTUP_BACKTRACE` diff --git a/src/dist/download.rs b/src/dist/download.rs index 55cfb13a1b..07ef22d8a5 100644 --- a/src/dist/download.rs +++ b/src/dist/download.rs @@ -64,7 +64,7 @@ impl<'a> DownloadCfg<'a> { ) -> anyhow::Result { utils::ensure_dir_exists("Download Directory", self.download_dir)?; let target_file = self.download_dir.join(Path::new(hash)); - let partial_file_path = self.download_dir.join(format!("{hash}.partial")); + let partial_file_path = self.partial_file_path(hash); let partial_file_existed = partial_file_path.exists(); if target_file.exists() { @@ -123,6 +123,18 @@ impl<'a> DownloadCfg<'a> { } } + /// The number of bytes already downloaded for the file with the given hash + pub(crate) fn partial_len(&self, hash: &str) -> u64 { + match fs::metadata(self.partial_file_path(hash)) { + Ok(metadata) => metadata.len(), + Err(_) => 0, + } + } + + fn partial_file_path(&self, hash: &str) -> PathBuf { + self.download_dir.join(format!("{hash}.partial")) + } + pub(crate) fn clean(&self, hashes: &[impl AsRef]) -> anyhow::Result<()> { for hash in hashes.iter() { let used_file = self.download_dir.join(hash); diff --git a/src/dist/manifestation.rs b/src/dist/manifestation.rs index 7b428decdf..d10d202a41 100644 --- a/src/dist/manifestation.rs +++ b/src/dist/manifestation.rs @@ -773,6 +773,7 @@ impl<'a> ComponentBinary<'a> { async fn download(self, mut retries: usize) -> anyhow::Result<(ComponentInstall, &'a str)> { let url = self.download_cfg.url(&self.binary.url)?; let installer = loop { + let downloaded = self.download_cfg.partial_len(&self.binary.hash); let future = self .download_cfg .download(&url, &self.binary.hash, &self.status); @@ -793,13 +794,19 @@ impl<'a> ComponentBinary<'a> { } } - if retries == 0 { - return Err(err).with_context(|| { - RustupError::ComponentDownloadFailed(self.manifest.name(&self.component)) - }); + let progress = self + .download_cfg + .partial_len(&self.binary.hash) + .saturating_sub(downloaded); + if progress < Self::MIN_PROGRESS { + if retries == 0 { + return Err(err).with_context(|| { + RustupError::ComponentDownloadFailed(self.manifest.name(&self.component)) + }); + } + retries -= 1; } - retries -= 1; self.status.retrying(); }; @@ -818,6 +825,8 @@ impl<'a> ComponentBinary<'a> { Ok((install, &self.binary.hash)) } + + const MIN_PROGRESS: u64 = 1024 * 1024; } struct ComponentInstall {