diff --git a/crates/uv-cache/src/lib.rs b/crates/uv-cache/src/lib.rs index 817e8db86733d..afb51f6774a46 100644 --- a/crates/uv-cache/src/lib.rs +++ b/crates/uv-cache/src/lib.rs @@ -7,7 +7,7 @@ use std::str::FromStr; use std::sync::Arc; use rustc_hash::FxHashMap; -use tracing::{debug, warn}; +use tracing::{debug, trace, warn}; use uv_cache_info::Timestamp; use uv_fs::{LockedFile, Simplified, cachedir, directories}; @@ -209,6 +209,33 @@ impl Cache { }) } + /// Acquire a lock that allows removing entries from the cache, if available. + /// + /// If the lock is not immediately available, returns [`Err`] with self. + pub fn with_exclusive_lock_no_wait(self) -> Result { + let Self { + root, + refresh, + temp_dir, + lock_file, + } = self; + + match LockedFile::acquire_no_wait(root.join(".lock"), root.simplified_display()) { + Some(lock_file) => Ok(Self { + root, + refresh, + temp_dir, + lock_file: Some(Arc::new(lock_file)), + }), + None => Err(Self { + root, + refresh, + temp_dir, + lock_file, + }), + } + } + /// Return the root of the cache. pub fn root(&self) -> &Path { &self.root @@ -419,17 +446,32 @@ impl Cache { /// Clear the cache, removing all entries. pub fn clear(self, reporter: Box) -> Result { - // Remove everything but `.lock`, for Windows locked file special cases. + // Remove everything but `.lock`, Windows does not allow removal of a locked file let mut removal = Remover::new(reporter).rm_rf(&self.root, true)?; let Self { root, lock_file, .. } = self; - // Unlock `.lock` - drop(lock_file); - fs_err::remove_file(root.join(".lock"))?; + + // Remove the `.lock` file, unlocking it first + if let Some(lock) = lock_file { + drop(lock); + fs_err::remove_file(root.join(".lock"))?; + } removal.num_files += 1; - fs_err::remove_dir(root)?; - removal.num_dirs += 1; + + // Remove the root directory + match fs_err::remove_dir(root) { + Ok(()) => { + removal.num_dirs += 1; + } + // On Windows, when `--force` is used, the `.lock` file can exist and be unremovable, + // so we make this non-fatal + Err(err) if err.kind() == io::ErrorKind::DirectoryNotEmpty => { + trace!("Failed to remove root cache directory: not empty"); + } + Err(err) => return Err(err), + } + Ok(removal) } diff --git a/crates/uv-cli/src/lib.rs b/crates/uv-cli/src/lib.rs index 206e4ded044f5..0ee5a848e9459 100644 --- a/crates/uv-cli/src/lib.rs +++ b/crates/uv-cli/src/lib.rs @@ -775,6 +775,13 @@ pub enum CacheCommand { pub struct CleanArgs { /// The packages to remove from the cache. pub package: Vec, + + /// Force removal of the cache, ignoring in-use checks. + /// + /// By default, `uv cache clean` will block until no process is reading the cache. When + /// `--force` is used, `uv cache clean` will proceed without taking a lock. + #[arg(long)] + pub force: bool, } #[derive(Args, Debug)] diff --git a/crates/uv-fs/src/lib.rs b/crates/uv-fs/src/lib.rs index ad319b9f2971f..c73f486f814cf 100644 --- a/crates/uv-fs/src/lib.rs +++ b/crates/uv-fs/src/lib.rs @@ -652,6 +652,20 @@ pub fn is_virtualenv_base(path: impl AsRef) -> bool { path.as_ref().join("pyvenv.cfg").is_file() } +/// Whether the error is due to a lock being held. +fn is_known_already_locked_error(err: &std::io::Error) -> bool { + if matches!(err.kind(), std::io::ErrorKind::WouldBlock) { + return true; + } + + // On Windows, we've seen: Os { code: 33, kind: Uncategorized, message: "The process cannot access the file because another process has locked a portion of the file." } + if cfg!(windows) && err.raw_os_error() == Some(33) { + return true; + } + + false +} + /// A file lock that is automatically released when dropped. #[derive(Debug)] #[must_use] @@ -671,7 +685,7 @@ impl LockedFile { } Err(err) => { // Log error code and enum kind to help debugging more exotic failures. - if err.kind() != std::io::ErrorKind::WouldBlock { + if !is_known_already_locked_error(&err) { debug!("Try lock error: {err:?}"); } info!( @@ -693,6 +707,28 @@ impl LockedFile { } } + /// Inner implementation for [`LockedFile::acquire_no_wait`]. + fn lock_file_no_wait(file: fs_err::File, resource: &str) -> Option { + trace!( + "Checking lock for `{resource}` at `{}`", + file.path().user_display() + ); + match file.file().try_lock_exclusive() { + Ok(()) => { + debug!("Acquired lock for `{resource}`"); + Some(Self(file)) + } + Err(err) => { + // Log error code and enum kind to help debugging more exotic failures. + if !is_known_already_locked_error(&err) { + debug!("Try lock error: {err:?}"); + } + debug!("Lock is busy for `{resource}`"); + None + } + } + } + /// Inner implementation for [`LockedFile::acquire_shared_blocking`] and /// [`LockedFile::acquire_blocking`]. fn lock_file_shared_blocking( @@ -711,7 +747,7 @@ impl LockedFile { } Err(err) => { // Log error code and enum kind to help debugging more exotic failures. - if err.kind() != std::io::ErrorKind::WouldBlock { + if !is_known_already_locked_error(&err) { debug!("Try lock error: {err:?}"); } info!( @@ -782,6 +818,17 @@ impl LockedFile { .await? } + /// Acquire a cross-process lock for a resource using a file at the provided path + /// + /// Unlike [`LockedFile::acquire`] this function will not wait for the lock to become available. + /// + /// If the lock is not immediately available, [`None`] is returned. + pub fn acquire_no_wait(path: impl AsRef, resource: impl Display) -> Option { + let file = Self::create(path).ok()?; + let resource = resource.to_string(); + Self::lock_file_no_wait(file, &resource) + } + #[cfg(unix)] fn create(path: impl AsRef) -> Result { use std::os::unix::fs::PermissionsExt; diff --git a/crates/uv/src/commands/cache_clean.rs b/crates/uv/src/commands/cache_clean.rs index 040aa809a4842..da0f5c599c4ce 100644 --- a/crates/uv/src/commands/cache_clean.rs +++ b/crates/uv/src/commands/cache_clean.rs @@ -2,6 +2,7 @@ use std::fmt::Write; use anyhow::{Context, Result}; use owo_colors::OwoColorize; +use tracing::debug; use uv_cache::{Cache, Removal}; use uv_fs::Simplified; @@ -14,6 +15,7 @@ use crate::printer::Printer; /// Clear the cache, removing all entries or those linked to specific packages. pub(crate) fn cache_clean( packages: &[PackageName], + force: bool, cache: Cache, printer: Printer, ) -> Result { @@ -25,7 +27,19 @@ pub(crate) fn cache_clean( )?; return Ok(ExitStatus::Success); } - let cache = cache.with_exclusive_lock()?; + + let cache = if force { + // If `--force` is used, attempt to acquire the exclusive lock but do not block. + match cache.with_exclusive_lock_no_wait() { + Ok(cache) => cache, + Err(cache) => { + debug!("Cache is currently in use, proceeding due to `--force`"); + cache + } + } + } else { + cache.with_exclusive_lock()? + }; let summary = if packages.is_empty() { writeln!( diff --git a/crates/uv/src/lib.rs b/crates/uv/src/lib.rs index bdb5f113c5dd0..bfd00ab667a60 100644 --- a/crates/uv/src/lib.rs +++ b/crates/uv/src/lib.rs @@ -1010,7 +1010,7 @@ async fn run(mut cli: Cli) -> Result { }) | Commands::Clean(args) => { show_settings!(args); - commands::cache_clean(&args.package, cache, printer) + commands::cache_clean(&args.package, args.force, cache, printer) } Commands::Cache(CacheNamespace { command: CacheCommand::Prune(args), diff --git a/crates/uv/tests/it/cache_clean.rs b/crates/uv/tests/it/cache_clean.rs index 574be3c4f2b15..d948d2bff6aca 100644 --- a/crates/uv/tests/it/cache_clean.rs +++ b/crates/uv/tests/it/cache_clean.rs @@ -35,6 +35,59 @@ fn clean_all() -> Result<()> { Ok(()) } +#[test] +fn clean_force() -> Result<()> { + let context = TestContext::new("3.12").with_filtered_counts(); + + let requirements_txt = context.temp_dir.child("requirements.txt"); + requirements_txt.write_str("typing-extensions\niniconfig")?; + + // Install a requirement, to populate the cache. + context + .pip_sync() + .arg("requirements.txt") + .assert() + .success(); + + // When unlocked, `--force` should still take a lock + uv_snapshot!(context.filters(), context.clean().arg("--verbose").arg("--force"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + DEBUG uv [VERSION] ([COMMIT] DATE) + DEBUG Acquired lock for `[CACHE_DIR]/` + Clearing cache at: [CACHE_DIR]/ + DEBUG Released lock at `[CACHE_DIR]/.lock` + Removed [N] files ([SIZE]) + "); + + // Install a requirement, to re-populate the cache. + context + .pip_sync() + .arg("requirements.txt") + .assert() + .success(); + + // When locked, `--force` should proceed without blocking + let _cache = uv_cache::Cache::from_path(context.cache_dir.path()).with_exclusive_lock(); + uv_snapshot!(context.filters(), context.clean().arg("--verbose").arg("--force"), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + DEBUG uv [VERSION] ([COMMIT] DATE) + DEBUG Lock is busy for `[CACHE_DIR]/` + DEBUG Cache is currently in use, proceeding due to `--force` + Clearing cache at: [CACHE_DIR]/ + Removed [N] files ([SIZE]) + "); + + Ok(()) +} + /// `cache clean iniconfig` should remove a single package (`iniconfig`). #[test] fn clean_package_pypi() -> Result<()> { diff --git a/docs/reference/cli.md b/docs/reference/cli.md index af702fb5b6a1b..5f1792e22dd4d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -5944,6 +5944,8 @@ uv cache clean [OPTIONS] [PACKAGE]...

May also be set with the UV_CONFIG_FILE environment variable.

--directory directory

Change to the given directory prior to running the command.

Relative paths are resolved with the given directory as the base.

See --project to only change the project root directory.

+
--force

Force removal of the cache, ignoring in-use checks.

+

By default, uv cache clean will block until no process is reading the cache. When --force is used, uv cache clean will proceed without taking a lock.

--help, -h

Display the concise help for this command

--managed-python

Require use of uv-managed Python versions.

By default, uv prefers using Python versions it manages. However, it will use system Python versions if a uv-managed Python is not installed. This option disables use of system Python versions.