Skip to content

Commit 518f700

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. Partially Closes rust-lang#4910
1 parent ed94f61 commit 518f700

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};
@@ -46,9 +46,9 @@ impl<'a> DownloadCfg<'a> {
4646
}
4747

4848
/// 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.
49+
/// Partial downloads are stored in `self.download_dir` under unique names.
50+
/// If the target file already exists, then the hash is checked and it is
51+
/// returned immediately without re-downloading.
5252
pub(crate) async fn download(
5353
&self,
5454
url: &Url,
@@ -58,40 +58,29 @@ impl<'a> DownloadCfg<'a> {
5858
utils::ensure_dir_exists("Download Directory", self.download_dir)?;
5959
let target_file = self.download_dir.join(Path::new(hash));
6060

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-
}
61+
if let Some(file) = self.cached_file(&target_file, hash)? {
62+
debug!(url = url.as_ref(), "checksum passed");
63+
return Ok(file);
7164
}
7265

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();
66+
let partial = Self::partial_download(&target_file)?;
8367

8468
let mut hasher = Sha256::new();
8569
let mut download = DownloadOptions::try_from(self.process)?
86-
.start(url, &partial_file_path)
70+
.start(url, &partial.path)
8771
.with_hasher(&mut hasher)
8872
.with_status(status)
8973
.with_resume();
9074

9175
if let Err(e) = download.download().await {
9276
let is_network_failure = is_network_failure(&e);
77+
if is_network_failure {
78+
Self::keep_partial_for_resume(&partial);
79+
} else {
80+
utils::ensure_file_removed("partial download", &partial.path)?;
81+
}
9382
let err = Err(e);
94-
return match (partial_file_existed, is_network_failure) {
83+
return match (partial.existed, is_network_failure) {
9584
(true, true) => err.context(RustupError::IncompletePartialFile),
9685
(true, false) => err.context(RustupError::BrokenPartialFile),
9786
(false, _) => err,
@@ -102,8 +91,8 @@ impl<'a> DownloadCfg<'a> {
10291

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

@@ -457,6 +558,12 @@ fn file_hash(path: &Path) -> Result<String> {
457558
Ok(faster_hex::hex_string(&hasher.finalize()))
458559
}
459560

561+
struct PartialDownload {
562+
path: PathBuf,
563+
legacy_path: PathBuf,
564+
existed: bool,
565+
}
566+
460567
pub(crate) struct File {
461568
path: PathBuf,
462569
}
@@ -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)