use std::fmt::Display; use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use fs_err as fs; use fs2::FileExt; use tracing::{debug, error, info, trace}; use uv_cache_key::{CacheKey, cache_digest}; use uv_fs::Simplified; use uv_state::{StateBucket, StateStore}; use uv_static::EnvVars; /// Acquire a cross-process lock for files at the provided path. #[cfg(feature = "tokio")] pub async fn acquire_path(path: impl AsRef) -> Result { let locks = get_or_init_locks()?; let path = path.as_ref(); LockedFile::acquire(locks.join(cache_digest(&path)), path.display()).await } /// Acquire a cross-process lock for an arbitrary hashable resource (like a URL). #[cfg(feature = "tokio")] pub async fn acquire_resource( resource: T, ) -> Result { let locks = get_or_init_locks()?; LockedFile::acquire(locks.join(cache_digest(&resource)), resource).await } /// Get or initialize the global filesystem locks. fn get_or_init_locks() -> std::io::Result<&'static FilesystemLocks> { static FILESYSTEM_LOCKS: OnceLock = OnceLock::new(); // Return the existing filesystem locks, if they are already initialized. if let Some(locks) = FILESYSTEM_LOCKS.get() { return Ok(locks); } // Initialize the filesystem locks. let locks = FilesystemLocks::init()?; _ = FILESYSTEM_LOCKS.set(locks); Ok(FILESYSTEM_LOCKS.get().unwrap()) } /// Filesystem locks used to synchronize access to shared resources across processes. #[derive(Debug, Clone)] struct FilesystemLocks { /// The path to the top-level directory of the filesystem locks. root: PathBuf, } impl FilesystemLocks { /// Create a new [`FilesystemLocks`]. /// /// Prefer, in order: /// /// 1. The specific tool directory specified by the user, i.e., `UV_LOCK_DIR` /// 2. A directory in the system-appropriate user-level data directory, e.g., `~/.local/uv/locks` /// 3. A directory in the local data directory, e.g., `./.uv/locks` fn init() -> Result { let root = if let Some(lock_dir) = std::env::var_os(EnvVars::UV_LOCK_DIR).filter(|s| !s.is_empty()) { std::path::absolute(lock_dir)? } else { StateStore::from_settings(None)?.bucket(StateBucket::Locks) }; // Create the directory, if it doesn't exist. fs::create_dir_all(&root)?; // Add a .gitignore. match fs::OpenOptions::new() .write(true) .create_new(true) .open(root.join(".gitignore")) { Ok(mut file) => file.write_all(b"*")?, Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => (), Err(err) => return Err(err), } Ok(Self { root }) } /// Join a path to the root of the locks directory. fn join(&self, path: impl AsRef) -> PathBuf { self.root.join(path) } } /// A file lock that is automatically released when dropped. #[derive(Debug)] #[must_use] pub struct LockedFile(fs_err::File); impl LockedFile { /// Inner implementation for [`LockedFile::acquire_blocking`] and [`LockedFile::acquire`]. fn lock_file_blocking(file: fs_err::File, resource: &str) -> Result { trace!( "Checking lock for `{resource}` at `{}`", file.path().user_display() ); match file.file().try_lock_exclusive() { Ok(()) => { debug!("Acquired lock for `{resource}`"); Ok(Self(file)) } Err(err) => { // Log error code and enum kind to help debugging more exotic failures. if err.kind() != std::io::ErrorKind::WouldBlock { debug!("Try lock error: {err:?}"); } info!( "Waiting to acquire lock for `{resource}` at `{}`", file.path().user_display(), ); file.file().lock_exclusive().map_err(|err| { // Not an fs_err method, we need to build our own path context std::io::Error::other(format!( "Could not acquire lock for `{resource}` at `{}`: {}", file.path().user_display(), err )) })?; debug!("Acquired lock for `{resource}`"); Ok(Self(file)) } } } /// The same as [`LockedFile::acquire`], but for synchronous contexts. Do not use from an async /// context, as this can block the runtime while waiting for another process to release the /// lock. #[allow(dead_code)] fn acquire_blocking( path: impl AsRef, resource: impl Display, ) -> Result { let file = Self::create(path)?; let resource = resource.to_string(); Self::lock_file_blocking(file, &resource) } /// Acquire a cross-process lock for a resource using a file at the provided path. #[cfg(feature = "tokio")] async fn acquire( path: impl AsRef, resource: impl Display, ) -> Result { let file = Self::create(path)?; let resource = resource.to_string(); tokio::task::spawn_blocking(move || Self::lock_file_blocking(file, &resource)).await? } #[cfg(unix)] fn create(path: impl AsRef) -> Result { use std::os::unix::fs::PermissionsExt; use tempfile::NamedTempFile; use tracing::warn; // If path already exists, return it. if let Ok(file) = fs_err::OpenOptions::new() .read(true) .write(true) .open(path.as_ref()) { return Ok(file); } // Otherwise, create a temporary file with 777 permissions. We must set // permissions _after_ creating the file, to override the `umask`. let file = if let Some(parent) = path.as_ref().parent() { NamedTempFile::new_in(parent)? } else { NamedTempFile::new()? }; if let Err(err) = file .as_file() .set_permissions(std::fs::Permissions::from_mode(0o777)) { warn!("Failed to set permissions on temporary file: {err}"); } // Try to move the file to path, but if path exists now, just open path match file.persist_noclobber(path.as_ref()) { Ok(file) => Ok(fs_err::File::from_parts(file, path.as_ref())), Err(err) => { if err.error.kind() == std::io::ErrorKind::AlreadyExists { fs_err::OpenOptions::new() .read(true) .write(true) .open(path.as_ref()) } else { Err(err.error) } } } } #[cfg(not(unix))] fn create(path: impl AsRef) -> std::io::Result { fs_err::OpenOptions::new() .read(true) .write(true) .create(true) .open(path.as_ref()) } } impl Drop for LockedFile { fn drop(&mut self) { if let Err(err) = fs2::FileExt::unlock(self.0.file()) { error!( "Failed to unlock {}; program may be stuck: {}", self.0.path().display(), err ); } else { debug!("Released lock at `{}`", self.0.path().display()); } } }