Improve cache initialization failure error message (#17469)

Adds an error chain to improve clarity when we fail to initialize the
cache directory.

Related to https://github.com/astral-sh/uv/issues/17465

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Zanie Blue
2026-01-15 07:23:10 -06:00
committed by GitHub
parent ed50e40d05
commit 681e8e060f
5 changed files with 119 additions and 3 deletions
+4 -2
View File
@@ -40,6 +40,8 @@ pub const ARCHIVE_VERSION: u8 = 0;
pub enum Error {
#[error(transparent)]
Io(#[from] io::Error),
#[error("Failed to initialize cache at `{}`", _0.user_display())]
Init(PathBuf, #[source] io::Error),
#[error("Could not make the path absolute")]
Absolute(#[source] io::Error),
#[error("Could not acquire lock")]
@@ -455,7 +457,7 @@ impl Cache {
pub async fn init(self) -> Result<Self, Error> {
let root = &self.root;
Self::create_base_files(root)?;
Self::create_base_files(root).map_err(|err| Error::Init(root.clone(), err))?;
// Block cache removal operations from interfering.
let lock_file = match LockedFile::acquire(
@@ -491,7 +493,7 @@ impl Cache {
pub fn init_no_wait(self) -> Result<Option<Self>, Error> {
let root = &self.root;
Self::create_base_files(root)?;
Self::create_base_files(root).map_err(|err| Error::Init(root.clone(), err))?;
// Block cache removal operations from interfering.
let Some(lock_file) = LockedFile::acquire_no_wait(
+70
View File
@@ -0,0 +1,70 @@
#[cfg(unix)]
use anyhow::Result;
#[cfg(unix)]
use assert_fs::prelude::*;
#[cfg(unix)]
use std::process::Command;
#[cfg(unix)]
use crate::common::{TestContext, get_bin, uv_snapshot};
/// When the cache directory cannot be created (e.g., due to permissions), we should show a
/// chained error message that indicates we failed to initialize the cache.
#[test]
#[cfg(unix)]
fn cache_init_failure() -> Result<()> {
use crate::common::ReadOnlyDirectoryGuard;
let context = TestContext::new("3.12");
let pyproject_toml = context.temp_dir.child("pyproject.toml");
pyproject_toml.write_str(
r#"
[project]
name = "project"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = ["iniconfig"]
"#,
)?;
// Create a read-only directory that will serve as the parent of the cache.
// The guard sets it to read-only and restores original permissions on drop (including panic).
let cache_parent = context.temp_dir.child("cache_parent");
fs_err::create_dir(&cache_parent)?;
let _guard = ReadOnlyDirectoryGuard::new(cache_parent.path())?;
// Point the cache to a subdirectory within the read-only parent
let cache_dir = cache_parent.child("cache");
let mut filters = context.filters();
// Filter both the relative path (in the first line) and absolute path (in the cause)
filters.push((r"cache_parent/cache", "[CACHE_DIR]"));
filters.push((
r"failed to create directory `.*`",
"failed to create directory `[CACHE_DIR]`",
));
// Build the sync command manually to use our custom cache directory.
// We can't use context.sync() because it adds --cache-dir with the default cache.
let mut command = Command::new(get_bin());
command
.arg("sync")
.arg("--cache-dir")
.arg(cache_dir.path())
.current_dir(context.temp_dir.path());
context.add_shared_env(&mut command, false);
// Running a command should fail with a chained error about cache initialization
uv_snapshot!(&filters, command, @r"
success: false
exit_code: 2
----- stdout -----
----- stderr -----
error: Failed to initialize cache at `[CACHE_DIR]`
Caused by: failed to create directory `[CACHE_DIR]`: Permission denied (os error 13)
");
Ok(())
}
+40
View File
@@ -2151,6 +2151,46 @@ pub async fn download_to_disk(url: &str, path: &Path) {
file.sync_all().await.unwrap();
}
/// A guard that sets a directory to read-only and restores original permissions when dropped.
///
/// This is useful for tests that need to make a directory read-only and ensure
/// the permissions are restored even if the test panics.
#[cfg(unix)]
pub struct ReadOnlyDirectoryGuard {
path: PathBuf,
original_mode: u32,
}
#[cfg(unix)]
impl ReadOnlyDirectoryGuard {
/// Sets the directory to read-only (removes write permission) and returns a guard
/// that will restore the original permissions when dropped.
pub fn new(path: impl Into<PathBuf>) -> std::io::Result<Self> {
use std::os::unix::fs::PermissionsExt;
let path = path.into();
let metadata = fs_err::metadata(&path)?;
let original_mode = metadata.permissions().mode();
// Remove write permissions (keep read and execute)
let readonly_mode = original_mode & !0o222;
fs_err::set_permissions(&path, std::fs::Permissions::from_mode(readonly_mode))?;
Ok(Self {
path,
original_mode,
})
}
}
#[cfg(unix)]
impl Drop for ReadOnlyDirectoryGuard {
fn drop(&mut self) {
use std::os::unix::fs::PermissionsExt;
let _ = fs_err::set_permissions(
&self.path,
std::fs::Permissions::from_mode(self.original_mode),
);
}
}
/// Utility macro to return the name of the current function.
///
/// https://stackoverflow.com/a/40234666/3549270
+3
View File
@@ -13,6 +13,9 @@ mod build;
#[cfg(feature = "python")]
mod build_backend;
#[cfg(all(feature = "python", feature = "pypi"))]
mod cache;
#[cfg(all(feature = "python", feature = "pypi"))]
mod cache_clean;
+2 -1
View File
@@ -1379,7 +1379,8 @@ fn path_with_trailing_space_gives_proper_error() {
----- stdout -----
----- stderr -----
error: failed to open file `[CACHE_DIR]/ /CACHEDIR.TAG`: The system cannot find the path specified. (os error 3)
error: Failed to initialize cache at `[CACHE_DIR]/ `
Caused by: failed to open file `[CACHE_DIR]/ /CACHEDIR.TAG`: The system cannot find the path specified. (os error 3)
"###
);
// Note the extra trailing `/` in the snapshot is due to the filters, not the actual output.