Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 49 additions & 7 deletions crates/uv-cache/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Self, Self> {
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
Expand Down Expand Up @@ -419,17 +446,32 @@ impl Cache {

/// Clear the cache, removing all entries.
pub fn clear(self, reporter: Box<dyn CleanReporter>) -> Result<Removal, io::Error> {
// 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)
}

Expand Down
7 changes: 7 additions & 0 deletions crates/uv-cli/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,13 @@ pub enum CacheCommand {
pub struct CleanArgs {
/// The packages to remove from the cache.
pub package: Vec<PackageName>,

/// 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)]
Expand Down
51 changes: 49 additions & 2 deletions crates/uv-fs/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,20 @@ pub fn is_virtualenv_base(path: impl AsRef<Path>) -> 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]
Expand All @@ -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!(
Expand All @@ -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<Self> {
trace!(
"Checking lock for `{resource}` at `{}`",
file.path().user_display()
);
match file.file().try_lock_exclusive() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should loop on EINTR, and probably in the blocking cases too, unless I'm missing something that already does that.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring to #15996

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(
Expand All @@ -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!(
Expand Down Expand Up @@ -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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we not propagate the error?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could, but we treat all errors as "the lock could not be acquired" so I don't think the error has semantic meaning to a consumer at this time.

pub fn acquire_no_wait(path: impl AsRef<Path>, resource: impl Display) -> Option<Self> {
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<Path>) -> Result<fs_err::File, std::io::Error> {
use std::os::unix::fs::PermissionsExt;
Expand Down
16 changes: 15 additions & 1 deletion crates/uv/src/commands/cache_clean.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<ExitStatus> {
Expand All @@ -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!(
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1010,7 +1010,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
})
| 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),
Expand Down
53 changes: 53 additions & 0 deletions crates/uv/tests/it/cache_clean.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -5944,6 +5944,8 @@ uv cache clean [OPTIONS] [PACKAGE]...
<p>May also be set with the <code>UV_CONFIG_FILE</code> environment variable.</p></dd><dt id="uv-cache-clean--directory"><a href="#uv-cache-clean--directory"><code>--directory</code></a> <i>directory</i></dt><dd><p>Change to the given directory prior to running the command.</p>
<p>Relative paths are resolved with the given directory as the base.</p>
<p>See <code>--project</code> to only change the project root directory.</p>
</dd><dt id="uv-cache-clean--force"><a href="#uv-cache-clean--force"><code>--force</code></a></dt><dd><p>Force removal of the cache, ignoring in-use checks.</p>
<p>By default, <code>uv cache clean</code> will block until no process is reading the cache. When <code>--force</code> is used, <code>uv cache clean</code> will proceed without taking a lock.</p>
</dd><dt id="uv-cache-clean--help"><a href="#uv-cache-clean--help"><code>--help</code></a>, <code>-h</code></dt><dd><p>Display the concise help for this command</p>
</dd><dt id="uv-cache-clean--managed-python"><a href="#uv-cache-clean--managed-python"><code>--managed-python</code></a></dt><dd><p>Require use of uv-managed Python versions.</p>
<p>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.</p>
Expand Down
Loading