Skip to content

Commit 4b0e0d9

Browse files
committed
Lock download file to avoid races
See #TBD for details. This is one of two possible implementations, the easier one is using a temporary directory or a unique filename to ensure processes don't collide. Using locks has less platform support, but OTOH means that we don't create stray temp files that don't get cleaned up (because we never know if one of those files is actually used by another process). This does not fix #TBD completely. It only fixed the downloaded, parallel `rustup-init` calls still fail at the transaction stage. This PR introduces a `LockedFile` primitive that's modeled after the uv type of the same name. It's currently very simple and locks forever. We can extend it with a timeout that makes rustup exit after a certain duration and print an error message, instead of an inscrutable waiting without any message.
1 parent 4ae2012 commit 4b0e0d9

File tree

3 files changed

+111
-13
lines changed

3 files changed

+111
-13
lines changed

src/dist/download.rs

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use url::Url;
1515
use crate::config::Cfg;
1616
use crate::dist::manifest::Manifest;
1717
use crate::dist::{Channel, DEFAULT_DIST_SERVER, ToolchainDesc, temp};
18-
use crate::download::{download_file, download_file_with_resume};
18+
use crate::download::{LockedFile, download_file, download_file_with_resume};
1919
use crate::errors::RustupError;
2020
use crate::process::Process;
2121
use crate::utils;
@@ -55,6 +55,18 @@ impl<'a> DownloadCfg<'a> {
5555
utils::ensure_dir_exists("Download Directory", self.download_dir)?;
5656
let target_file = self.download_dir.join(Path::new(hash));
5757

58+
let file_name = target_file
59+
.file_name()
60+
.map(|s| s.to_str().unwrap_or("_"))
61+
.unwrap_or("_")
62+
.to_owned();
63+
let locked_file = target_file.with_file_name(file_name.clone() + ".lock");
64+
65+
let _locked_file = LockedFile::create(&locked_file)
66+
.with_context(|| RustupError::CreateLockedFile(locked_file.clone()))?
67+
.lock()
68+
.with_context(|| RustupError::LockedFile(locked_file.clone()))?;
69+
5870
if target_file.exists() {
5971
let cached_result = file_hash(&target_file)?;
6072
if hash == cached_result {
@@ -67,14 +79,7 @@ impl<'a> DownloadCfg<'a> {
6779
}
6880
}
6981

70-
let partial_file_path = target_file.with_file_name(
71-
target_file
72-
.file_name()
73-
.map(|s| s.to_str().unwrap_or("_"))
74-
.unwrap_or("_")
75-
.to_owned()
76-
+ ".partial",
77-
);
82+
let partial_file_path = target_file.with_file_name(file_name + ".partial");
7883

7984
let partial_file_existed = partial_file_path.exists();
8085

src/download/mod.rs

Lines changed: 93 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
//! Easy file downloading
22
3-
use std::fs::remove_file;
3+
use std::fs::{File, OpenOptions, remove_file};
4+
use std::io;
45
use std::num::NonZero;
5-
use std::path::Path;
6+
use std::path::{Path, PathBuf};
67
use std::str::FromStr;
78
use std::time::Duration;
89

@@ -26,6 +27,94 @@ use crate::{dist::download::DownloadStatus, errors::RustupError, process::Proces
2627
#[cfg(test)]
2728
mod tests;
2829

30+
/// An OS lock on a file that unlocks when dropping the file.
31+
pub(crate) struct LockedFile {
32+
path: PathBuf,
33+
file: File,
34+
}
35+
36+
impl LockedFile {
37+
/// Creates the file if it does not exit, does not lock.
38+
#[cfg(unix)]
39+
pub(crate) fn create(path: impl Into<PathBuf>) -> Result<Self, io::Error> {
40+
use std::os::unix::fs::PermissionsExt;
41+
use tempfile::NamedTempFile;
42+
43+
let path = path.into();
44+
45+
// If path already exists, return it.
46+
if let Ok(file) = OpenOptions::new().read(true).write(true).open(&path) {
47+
return Ok(Self { path, file });
48+
}
49+
50+
// Otherwise, create a temporary file with 777 permissions, to handle races between
51+
// processes running under different UIDs (e.g., in Docker containers). We must set
52+
// permissions _after_ creating the file, to override the `umask`.
53+
let file = if let Some(parent) = path.parent() {
54+
NamedTempFile::new_in(parent)?
55+
} else {
56+
NamedTempFile::new()?
57+
};
58+
if let Err(err) = file
59+
.as_file()
60+
.set_permissions(std::fs::Permissions::from_mode(0o777))
61+
{
62+
warn!("failed to set permissions on temporary file: {err}");
63+
}
64+
65+
// Try to move the file to path, but if path exists now, just open path
66+
match file.persist_noclobber(&path) {
67+
Ok(file) => Ok(Self { path, file }),
68+
Err(err) => {
69+
if err.error.kind() == io::ErrorKind::AlreadyExists {
70+
let file = OpenOptions::new().read(true).write(true).open(&path)?;
71+
Ok(Self { path, file })
72+
} else {
73+
Err(err.error)
74+
}
75+
}
76+
}
77+
}
78+
79+
/// Creates the file if it does not exit, does not lock.
80+
#[cfg(not(unix))]
81+
pub(crate) fn create(path: impl AsRef<Path>) -> Result<Self, io::Error> {
82+
let file = OpenOptions::new()
83+
.read(true)
84+
.write(true)
85+
.create(true)
86+
.open(path.as_ref())?;
87+
Ok(Self { path, file })
88+
}
89+
90+
/// Acquire an exclusive lock on a file, blocking until the file is ready.
91+
pub(crate) fn lock(self) -> Result<Self, io::Error> {
92+
match self.file.lock() {
93+
Err(err) if err.kind() == io::ErrorKind::Unsupported => {
94+
warn!("locking files is not supported, running rustup in parallel may error");
95+
Ok(self)
96+
}
97+
Err(err) => Err(err),
98+
Ok(()) => Ok(self),
99+
}
100+
}
101+
}
102+
103+
impl Drop for LockedFile {
104+
/// Unlock the file.
105+
fn drop(&mut self) {
106+
if let Err(err) = self.file.unlock() {
107+
warn!(
108+
"failed to unlock {}; program may be stuck: {}",
109+
self.path.display(),
110+
err
111+
);
112+
} else {
113+
debug!("released lock at `{}`", self.path.display());
114+
}
115+
}
116+
}
117+
29118
pub(crate) async fn download_file(
30119
url: &Url,
31120
path: &Path,
@@ -48,7 +137,7 @@ pub(crate) async fn download_file_with_resume(
48137
match download_file_(url, path, hasher, resume_from_partial, status, process).await {
49138
Ok(_) => Ok(()),
50139
Err(e) => {
51-
if e.downcast_ref::<std::io::Error>().is_some() {
140+
if e.downcast_ref::<io::Error>().is_some() {
52141
return Err(e);
53142
}
54143
let is_client_error = match e.downcast_ref::<DEK>() {
@@ -722,7 +811,7 @@ enum DownloadError {
722811
#[error("{0}")]
723812
Message(String),
724813
#[error(transparent)]
725-
IoError(#[from] std::io::Error),
814+
IoError(#[from] io::Error),
726815
#[cfg(any(feature = "reqwest-rustls-tls", feature = "reqwest-native-tls"))]
727816
#[error(transparent)]
728817
Reqwest(#[from] ::reqwest::Error),

src/errors.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ pub struct OperationError(pub anyhow::Error);
2727
pub enum RustupError {
2828
#[error("partially downloaded file may have been damaged and was removed, please try again")]
2929
BrokenPartialFile,
30+
#[error("failed to create '{0}' as lockfile")]
31+
CreateLockedFile(PathBuf),
32+
#[error("failed to lock '{0}'")]
33+
LockedFile(PathBuf),
3034
#[error("component download failed for {0}")]
3135
ComponentDownloadFailed(String),
3236
#[error("failure removing component '{name}', directory does not exist: '{}'", .path.display())]

0 commit comments

Comments
 (0)