blob: 7cebd656d6f5c482628501d39a80a76a1a5b2541 [file] [log] [blame]
//! Provides facilities for file locks on unix and windows
use crate::{Error, Path, PathBuf};
use std::{fs, time::Duration};
#[cfg_attr(unix, path = "flock/unix.rs")]
#[cfg_attr(windows, path = "flock/windows.rs")]
mod sys;
/// An error pertaining to a failed file lock
#[derive(Debug, thiserror::Error)]
#[error("failed to obtain lock file '{path}'")]
pub struct FileLockError {
/// The path of the file lock
pub path: PathBuf,
/// The underlying failure reason
pub source: LockError,
}
/// Errors that can occur when attempting to acquire a [`FileLock`]
#[derive(Debug, thiserror::Error)]
pub enum LockError {
/// An I/O error occurred attempting to open the lock file
#[error(transparent)]
Open(std::io::Error),
/// Exclusive locks cannot be take on read-only file systems
#[error("attempted to take an exclusive lock on a read-only path")]
Readonly,
/// Failed to create parents directories to lock file
#[error("failed to create parent directories for lock path")]
CreateDir(std::io::Error),
/// Locking is not supported if the lock file is on an NFS, though note this
/// is a bit more nuanced as NFSv4 _does_ support file locking, but is out
/// of scope, at least for now
#[error("NFS do not support locking")]
Nfs,
/// This could happen on eg. _extremely_ old and outdated OSes or some filesystems
/// and is only present for completeness
#[error("locking is not supported on the filesystem and/or in the kernel")]
NotSupported,
/// An I/O error occurred attempting to un/lock the file
#[error("failed to acquire or release file lock")]
Lock(std::io::Error),
/// The lock could not be acquired within the caller provided timeout
#[error("failed to acquire lock within the specified duration")]
TimedOut,
/// The lock is currently held by another
#[error("the lock is currently held by another")]
Contested,
}
/// Provides options for creating a [`FileLock`]
pub struct LockOptions<'pb> {
path: std::borrow::Cow<'pb, Path>,
exclusive: bool,
shared_fallback: bool,
}
impl<'pb> LockOptions<'pb> {
/// Creates a new [`Self`] for the specified path
#[inline]
pub fn new(path: &'pb Path) -> Self {
Self {
path: path.into(),
exclusive: false,
shared_fallback: false,
}
}
/// Creates a new [`Self`] for locking cargo's global package lock
///
/// If specified, the path is used as the root, otherwise it is rooted at
/// the path determined by `$CARGO_HOME`
#[inline]
pub fn cargo_package_lock(root: Option<PathBuf>) -> Result<Self, Error> {
let mut path = if let Some(root) = root {
root
} else {
crate::utils::cargo_home()?
};
path.push(".package-cache");
Ok(Self {
path: path.into(),
exclusive: true,
shared_fallback: false,
})
}
/// Will attempt to acquire a shared lock rather than an exclusive one
#[inline]
pub fn shared(mut self) -> Self {
self.exclusive = false;
self
}
/// Will attempt to acquire an exclusive lock, which can optionally fallback
/// to a shared lock if the lock file is for a read only filesystem
#[inline]
pub fn exclusive(mut self, shared_fallback: bool) -> Self {
self.exclusive = true;
self.shared_fallback = shared_fallback;
self
}
/// Attempts to acquire a lock, but fails immediately if the lock is currently
/// held
#[inline]
pub fn try_lock(&self) -> Result<FileLock, Error> {
self.open_and_lock(Option::<fn(&Path) -> Option<Duration>>::None)
}
/// Attempts to acquire a lock, waiting if the lock is currently held.
///
/// Unlike [`Self::try_lock`], if the lock is currently held, the specified
/// callback is called to inform the caller that a wait is about to
/// be performed, then waits for the amount of time specified by the return
/// of the callback, or infinitely in the case of `None`.
#[inline]
pub fn lock(&self, wait: impl Fn(&Path) -> Option<Duration>) -> Result<FileLock, Error> {
self.open_and_lock(Some(wait))
}
fn open(&self, opts: &fs::OpenOptions) -> Result<fs::File, FileLockError> {
opts.open(self.path.as_std_path()).or_else(|err| {
if err.kind() == std::io::ErrorKind::NotFound && self.exclusive {
fs::create_dir_all(self.path.parent().unwrap()).map_err(|e| FileLockError {
path: self.path.parent().unwrap().to_owned(),
source: LockError::CreateDir(e),
})?;
self.open(opts)
} else {
// Note we just use the 30 EROFS constant here, which won't work on WASI, Haiku, or some other
// niche targets, but none of them are intended targets for this crate, but can be fixed later
// if someone actually uses them
let source = if err.kind() == std::io::ErrorKind::PermissionDenied
|| cfg!(unix) && err.raw_os_error() == Some(30 /* EROFS */)
{
LockError::Readonly
} else {
LockError::Open(err)
};
Err(FileLockError {
path: self.path.as_ref().to_owned(),
source,
})
}
})
}
fn open_and_lock(
&self,
wait: Option<impl Fn(&Path) -> Option<Duration>>,
) -> Result<FileLock, Error> {
let (state, file) = if self.exclusive {
match self.open(&sys::open_opts(true)) {
Ok(file) => (LockState::Exclusive, file),
Err(err) => {
// If the user requested it, check if the error is due to a read only error,
// and if so, fallback to a shared lock instead of an exclusive lock, just
// as cargo does
//
// https://github.com/rust-lang/cargo/blob/0b6cc3c75f1813df857fb54421edf7f8fee548e3/src/cargo/util/config/mod.rs#L1907-L1935
if self.shared_fallback && matches!(err.source, LockError::Readonly) {
(LockState::Shared, self.open(&sys::open_opts(false))?)
} else {
return Err(err.into());
}
}
}
} else {
(LockState::Shared, self.open(&sys::open_opts(false))?)
};
self.do_lock(state, &file, wait)
.map_err(|source| FileLockError {
path: self.path.as_ref().to_owned(),
source,
})?;
Ok(FileLock {
file: Some(file),
state,
})
}
fn do_lock(
&self,
state: LockState,
file: &fs::File,
wait: Option<impl Fn(&Path) -> Option<std::time::Duration>>,
) -> Result<(), LockError> {
#[cfg(all(target_os = "linux", not(target_env = "musl")))]
fn is_on_nfs_mount(path: &crate::Path) -> bool {
use std::os::unix::prelude::*;
let path = match std::ffi::CString::new(path.as_os_str().as_bytes()) {
Ok(path) => path,
Err(_) => return false,
};
#[allow(unsafe_code)]
unsafe {
let mut buf: libc::statfs = std::mem::zeroed();
let r = libc::statfs(path.as_ptr(), &mut buf);
r == 0 && buf.f_type as u32 == libc::NFS_SUPER_MAGIC as u32
}
}
#[cfg(any(not(target_os = "linux"), target_env = "musl"))]
fn is_on_nfs_mount(_path: &crate::Path) -> bool {
false
}
// File locking on Unix is currently implemented via `flock`, which is known
// to be broken on NFS. We could in theory just ignore errors that happen on
// NFS, but apparently the failure mode [1] for `flock` on NFS is **blocking
// forever**, even if the "non-blocking" flag is passed!
//
// As a result, we just skip all file locks entirely on NFS mounts. That
// should avoid calling any `flock` functions at all, and it wouldn't work
// there anyway.
//
// [1]: https://github.com/rust-lang/cargo/issues/2615
if is_on_nfs_mount(&self.path) {
return Err(LockError::Nfs);
}
match sys::try_lock(file, state) {
Ok(()) => return Ok(()),
// In addition to ignoring NFS which is commonly not working we also
// just ignore locking on filesystems that look like they don't
// implement file locking.
Err(e) if sys::is_unsupported(&e) => return Err(LockError::NotSupported),
Err(e) => {
if !sys::is_contended(&e) {
return Err(LockError::Lock(e));
}
}
}
// Signal to the caller that we are about to enter a blocking operation
// and whether they want to assign a timeout to it
if let Some(wait) = wait {
let timeout = wait(&self.path);
sys::lock(file, state, timeout).map_err(|e| {
if sys::is_timed_out(&e) {
LockError::TimedOut
} else {
LockError::Lock(e)
}
})
} else {
Err(LockError::Contested)
}
}
}
#[derive(PartialEq, Copy, Clone, Debug)]
enum LockState {
Exclusive,
Shared,
Unlocked,
}
/// A currently held file lock.
///
/// The lock is released when this is dropped, or the program exits for any reason,
/// including `SIGKILL` or power loss
pub struct FileLock {
file: Option<std::fs::File>,
state: LockState,
}
impl FileLock {
/// Creates a [`Self`] in an unlocked state.
///
/// This allows for easy testing or use in situations where you don't care
/// about file locking, or have other ways to ensure something is uncontested
pub fn unlocked() -> Self {
Self {
file: None,
state: LockState::Unlocked,
}
}
}
impl Drop for FileLock {
fn drop(&mut self) {
if self.state != LockState::Unlocked {
if let Some(f) = self.file.take() {
let _ = sys::unlock(&f);
}
}
}
}