Skip to content

Commit 734e7e0

Browse files
committed
fix(download): avoid shared partial cache files
After going back and forth a bit for a few hours and perusing the history of this issue, I figured it would at the least, make sense to change cached component downloads such that they use unique partial filenames instead of sharing `<hash>.partial` across processes. This fixes the specific download-cache race seen in rust-lang#4910, where parallel auto-installs can fail while renaming a shared partial file. It is an _alternative_ to the locking approach in rust-lang#4606, using unique partial paths instead of file locks. This does not attempt to solve the broader concurrency problem in rust-lang#988, since concurrent toolchain transactions can still race after downloads complete. I don't think I want to pretend to know where to start there, though I think this is a decent start and at the least covers the issue reported in rust-lang#4910. Existing legacy `<hash>.partial` files are still claimed for resume, so interrupted downloads remain recoverable. Closes rust-lang#4910
1 parent ed94f61 commit 734e7e0

1 file changed

Lines changed: 220 additions & 35 deletions

File tree

src/dist/download.rs

Lines changed: 220 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use std::borrow::Cow;
22
use std::fs;
3-
use std::io::Read;
3+
use std::io::{self, Read};
44
use std::ops;
55
use std::path::{Path, PathBuf};
66
use std::sync::{Arc, Mutex};
@@ -30,6 +30,12 @@ pub struct DownloadCfg<'a> {
3030
pub process: &'a Process,
3131
}
3232

33+
struct PartialDownload {
34+
path: PathBuf,
35+
legacy_path: PathBuf,
36+
existed: bool,
37+
}
38+
3339
impl<'a> DownloadCfg<'a> {
3440
/// construct a download configuration
3541
pub(crate) fn new(cfg: &'a Cfg<'a>) -> Self {
@@ -46,9 +52,9 @@ impl<'a> DownloadCfg<'a> {
4652
}
4753

4854
/// Downloads a file and validates its hash. Resumes interrupted downloads.
49-
/// Partial downloads are stored in `self.download_dir`, keyed by hash. If the
50-
/// target file already exists, then the hash is checked and it is returned
51-
/// immediately without re-downloading.
55+
/// Partial downloads are stored in `self.download_dir` under unique names.
56+
/// If the target file already exists, then the hash is checked and it is
57+
/// returned immediately without re-downloading.
5258
pub(crate) async fn download(
5359
&self,
5460
url: &Url,
@@ -58,40 +64,29 @@ impl<'a> DownloadCfg<'a> {
5864
utils::ensure_dir_exists("Download Directory", self.download_dir)?;
5965
let target_file = self.download_dir.join(Path::new(hash));
6066

61-
if target_file.exists() {
62-
let cached_result = file_hash(&target_file)?;
63-
if hash == cached_result {
64-
debug!("reusing previously downloaded file");
65-
debug!(url = url.as_ref(), "checksum passed");
66-
return Ok(File { path: target_file });
67-
} else {
68-
warn!("bad checksum for cached download");
69-
fs::remove_file(&target_file).context("cleaning up previous download")?;
70-
}
67+
if let Some(file) = self.cached_file(&target_file, hash)? {
68+
debug!(url = url.as_ref(), "checksum passed");
69+
return Ok(file);
7170
}
7271

73-
let partial_file_path = target_file.with_file_name(
74-
target_file
75-
.file_name()
76-
.map(|s| s.to_str().unwrap_or("_"))
77-
.unwrap_or("_")
78-
.to_owned()
79-
+ ".partial",
80-
);
81-
82-
let partial_file_existed = partial_file_path.exists();
72+
let partial = Self::partial_download(&target_file)?;
8373

8474
let mut hasher = Sha256::new();
8575
let mut download = DownloadOptions::try_from(self.process)?
86-
.start(url, &partial_file_path)
76+
.start(url, &partial.path)
8777
.with_hasher(&mut hasher)
8878
.with_status(status)
8979
.with_resume();
9080

9181
if let Err(e) = download.download().await {
9282
let is_network_failure = is_network_failure(&e);
83+
if is_network_failure {
84+
Self::keep_partial_for_resume(&partial);
85+
} else {
86+
utils::ensure_file_removed("partial download", &partial.path)?;
87+
}
9388
let err = Err(e);
94-
return match (partial_file_existed, is_network_failure) {
89+
return match (partial.existed, is_network_failure) {
9590
(true, true) => err.context(RustupError::IncompletePartialFile),
9691
(true, false) => err.context(RustupError::BrokenPartialFile),
9792
(false, _) => err,
@@ -102,8 +97,8 @@ impl<'a> DownloadCfg<'a> {
10297

10398
if hash != actual_hash {
10499
// Incorrect hash
105-
if partial_file_existed {
106-
self.clean(&[hash.to_string() + ".partial"])?;
100+
utils::ensure_file_removed("partial download", &partial.path)?;
101+
if partial.existed {
107102
Err(anyhow!(RustupError::BrokenPartialFile))
108103
} else {
109104
Err(RustupError::ChecksumFailed {
@@ -115,13 +110,125 @@ impl<'a> DownloadCfg<'a> {
115110
}
116111
} else {
117112
debug!(url = url.as_ref(), "checksum passed");
118-
utils::rename(
119-
"downloaded",
120-
&partial_file_path,
121-
&target_file,
122-
self.permit_copy_rename,
123-
)?;
124-
Ok(File { path: target_file })
113+
self.finish_download(&partial.path, &target_file, hash)
114+
}
115+
}
116+
117+
fn cached_file(&self, target_file: &Path, hash: &str) -> Result<Option<File>> {
118+
if target_file.exists() {
119+
let cached_result = file_hash(target_file)?;
120+
if hash == cached_result {
121+
debug!("reusing previously downloaded file");
122+
return Ok(Some(File {
123+
path: target_file.to_path_buf(),
124+
}));
125+
} else {
126+
warn!("bad checksum for cached download");
127+
fs::remove_file(target_file).context("cleaning up previous download")?;
128+
}
129+
}
130+
131+
Ok(None)
132+
}
133+
134+
fn partial_download(target_file: &Path) -> Result<PartialDownload> {
135+
let legacy_path = Self::legacy_partial_path(target_file);
136+
let path = Self::unique_partial_path(target_file);
137+
138+
let existed = match fs::rename(&legacy_path, &path) {
139+
Ok(()) => true,
140+
Err(e) if e.kind() == io::ErrorKind::NotFound => false,
141+
Err(e) => {
142+
return Err(e).with_context(|| {
143+
format!(
144+
"claiming partial download '{}' for '{}'",
145+
legacy_path.display(),
146+
path.display()
147+
)
148+
});
149+
}
150+
};
151+
152+
Ok(PartialDownload {
153+
path,
154+
legacy_path,
155+
existed,
156+
})
157+
}
158+
159+
fn legacy_partial_path(target_file: &Path) -> PathBuf {
160+
target_file.with_file_name(
161+
target_file
162+
.file_name()
163+
.map(|s| s.to_str().unwrap_or("_"))
164+
.unwrap_or("_")
165+
.to_owned()
166+
+ ".partial",
167+
)
168+
}
169+
170+
fn unique_partial_path(target_file: &Path) -> PathBuf {
171+
let file_name = target_file
172+
.file_name()
173+
.map(|s| s.to_str().unwrap_or("_"))
174+
.unwrap_or("_");
175+
target_file.with_file_name(format!(
176+
"{file_name}.{}.partial",
177+
utils::raw::random_string(16)
178+
))
179+
}
180+
181+
fn keep_partial_for_resume(partial: &PartialDownload) {
182+
if !utils::path_exists(&partial.path) {
183+
return;
184+
}
185+
186+
if utils::path_exists(&partial.legacy_path) {
187+
if let Err(e) = utils::ensure_file_removed("partial download", &partial.path) {
188+
warn!(
189+
"could not remove duplicate partial download {} ({e})",
190+
partial.path.display()
191+
);
192+
}
193+
return;
194+
}
195+
196+
if let Err(e) = fs::rename(&partial.path, &partial.legacy_path) {
197+
warn!(
198+
"could not keep partial download {} for resumption at {} ({e})",
199+
partial.path.display(),
200+
partial.legacy_path.display()
201+
);
202+
}
203+
}
204+
205+
fn finish_download(
206+
&self,
207+
partial_file_path: &Path,
208+
target_file: &Path,
209+
hash: &str,
210+
) -> Result<File> {
211+
if let Some(file) = self.cached_file(target_file, hash)? {
212+
utils::ensure_file_removed("partial download", partial_file_path)?;
213+
return Ok(file);
214+
}
215+
216+
match utils::rename(
217+
"downloaded",
218+
partial_file_path,
219+
target_file,
220+
self.permit_copy_rename,
221+
) {
222+
Ok(()) => Ok(File {
223+
path: target_file.to_path_buf(),
224+
}),
225+
Err(e) => match self.cached_file(target_file, hash)? {
226+
Some(file) => {
227+
utils::ensure_file_removed("partial download", partial_file_path)?;
228+
Ok(file)
229+
}
230+
None => Err(e),
231+
},
125232
}
126233
}
127234

@@ -468,3 +575,81 @@ impl ops::Deref for File {
468575
self.path.as_path()
469576
}
470577
}
578+
579+
#[cfg(test)]
580+
mod tests {
581+
use std::sync::Arc;
582+
583+
use sha2::{Digest, Sha256};
584+
585+
use super::*;
586+
use crate::process::TestProcess;
587+
588+
#[test]
589+
fn partial_download_claims_legacy_partial_for_resume() {
590+
let tempdir = tempfile::Builder::new().prefix("rustup").tempdir().unwrap();
591+
let target_file = tempdir.path().join("abc123");
592+
let legacy_partial = DownloadCfg::legacy_partial_path(&target_file);
593+
fs::write(&legacy_partial, b"partial contents").unwrap();
594+
595+
let partial = DownloadCfg::partial_download(&target_file).unwrap();
596+
597+
assert!(partial.existed);
598+
assert_ne!(partial.path, legacy_partial);
599+
assert!(!legacy_partial.exists());
600+
assert_eq!(fs::read(&partial.path).unwrap(), b"partial contents");
601+
assert!(
602+
partial
603+
.path
604+
.file_name()
605+
.unwrap()
606+
.to_str()
607+
.unwrap()
608+
.starts_with("abc123.")
609+
);
610+
assert!(
611+
partial
612+
.path
613+
.file_name()
614+
.unwrap()
615+
.to_str()
616+
.unwrap()
617+
.ends_with(".partial")
618+
);
619+
}
620+
621+
#[test]
622+
fn finish_download_reuses_valid_cache_from_race() {
623+
let tempdir = tempfile::Builder::new().prefix("rustup").tempdir().unwrap();
624+
let download_dir = tempdir.path().join("downloads");
625+
utils::ensure_dir_exists("download dir", &download_dir).unwrap();
626+
627+
let content = b"cached component contents";
628+
let hash = faster_hex::hex_string(&Sha256::digest(content));
629+
let target_file = download_dir.join(&hash);
630+
let partial_file = download_dir.join(format!("{hash}.other-process.partial"));
631+
fs::write(&target_file, content).unwrap();
632+
fs::write(&partial_file, content).unwrap();
633+
634+
let tp = TestProcess::default();
635+
let tmp_cx = Arc::new(temp::Context::new(
636+
tempdir.path().join("tmp"),
637+
DEFAULT_DIST_SERVER,
638+
));
639+
let cfg = DownloadCfg {
640+
tmp_cx,
641+
download_dir: &download_dir,
642+
tracker: DownloadTracker::new(false, &tp.process),
643+
permit_copy_rename: tp.process.permit_copy_rename(),
644+
process: &tp.process,
645+
};
646+
647+
let file = cfg
648+
.finish_download(&partial_file, &target_file, &hash)
649+
.unwrap();
650+
651+
assert_eq!(&*file, target_file.as_path());
652+
assert!(!partial_file.exists());
653+
assert_eq!(fs::read(&target_file).unwrap(), content);
654+
}
655+
}

0 commit comments

Comments
 (0)