Skip to content

Commit e69fb23

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 e69fb23

File tree

3 files changed

+112
-13
lines changed

3 files changed

+112
-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: 94 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,95 @@ 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 Into<Path>) -> Result<Self, io::Error> {
82+
let path = path.into();
83+
let file = OpenOptions::new()
84+
.read(true)
85+
.write(true)
86+
.create(true)
87+
.open(&path)?;
88+
Ok(Self { path, file })
89+
}
90+
91+
/// Acquire an exclusive lock on a file, blocking until the file is ready.
92+
pub(crate) fn lock(self) -> Result<Self, io::Error> {
93+
match self.file.lock() {
94+
Err(err) if err.kind() == io::ErrorKind::Unsupported => {
95+
warn!("locking files is not supported, running rustup in parallel may error");
96+
Ok(self)
97+
}
98+
Err(err) => Err(err),
99+
Ok(()) => Ok(self),
100+
}
101+
}
102+
}
103+
104+
impl Drop for LockedFile {
105+
/// Unlock the file.
106+
fn drop(&mut self) {
107+
if let Err(err) = self.file.unlock() {
108+
warn!(
109+
"failed to unlock {}; program may be stuck: {}",
110+
self.path.display(),
111+
err
112+
);
113+
} else {
114+
debug!("released lock at `{}`", self.path.display());
115+
}
116+
}
117+
}
118+
29119
pub(crate) async fn download_file(
30120
url: &Url,
31121
path: &Path,
@@ -48,7 +138,7 @@ pub(crate) async fn download_file_with_resume(
48138
match download_file_(url, path, hasher, resume_from_partial, status, process).await {
49139
Ok(_) => Ok(()),
50140
Err(e) => {
51-
if e.downcast_ref::<std::io::Error>().is_some() {
141+
if e.downcast_ref::<io::Error>().is_some() {
52142
return Err(e);
53143
}
54144
let is_client_error = match e.downcast_ref::<DEK>() {
@@ -722,7 +812,7 @@ enum DownloadError {
722812
#[error("{0}")]
723813
Message(String),
724814
#[error(transparent)]
725-
IoError(#[from] std::io::Error),
815+
IoError(#[from] io::Error),
726816
#[cfg(any(feature = "reqwest-rustls-tls", feature = "reqwest-native-tls"))]
727817
#[error(transparent)]
728818
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)