diff --git a/Cargo.lock b/Cargo.lock index f02ce552..cb0eb160 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1013,6 +1013,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "futures" version = "0.3.31" @@ -2786,6 +2796,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + [[package]] name = "sealed" version = "0.5.0" @@ -3668,8 +3684,12 @@ dependencies = [ "async-compression", "async_zip", "dirs", + "fs2", "futures-lite", + "hex", + "regex", "reqwest", + "seahash", "tokio", "tokio-tar", "tower-telemetry", diff --git a/Cargo.toml b/Cargo.toml index 2d965242..a2a20f99 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,20 +28,24 @@ config = { path = "crates/config" } crypto = { path = "crates/crypto" } ctrlc = "3" dirs = "5" +fs2 = "0.4" futures = "0.3" futures-util = "0.3" futures-lite = "2.6" glob = "0.3" +hex = "0.4" http = "1.1" indicatif = "0.17" nix = { version = "0.30", features = ["signal"] } pem = "3" promptly = "0.3" rand = "0.8" +regex = "1" reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } reqwest-eventsource = { version = "0.6" } rpassword = "7" rsa = "0.9" +seahash = "4.1" serde = "1" serde_json = "1.0" sha2 = "0.10" diff --git a/crates/tower-uv/Cargo.toml b/crates/tower-uv/Cargo.toml index b85e9d0c..5c42f83e 100644 --- a/crates/tower-uv/Cargo.toml +++ b/crates/tower-uv/Cargo.toml @@ -10,8 +10,12 @@ license = { workspace = true } async-compression = { workspace = true } async_zip = { workspace = true } dirs = { workspace = true } +fs2 = { workspace = true } futures-lite = { workspace = true } +hex = { workspace = true } +regex = { workspace = true } reqwest = { workspace = true } +seahash = { workspace = true } tokio = { workspace = true } tokio-tar = { workspace = true } tower-telemetry = { workspace = true } diff --git a/crates/tower-uv/src/lib.rs b/crates/tower-uv/src/lib.rs index 33daf16b..b334bc76 100644 --- a/crates/tower-uv/src/lib.rs +++ b/crates/tower-uv/src/lib.rs @@ -1,13 +1,19 @@ use std::collections::HashMap; -use std::path::PathBuf; +use std::fs::{self, OpenOptions}; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; use std::process::Stdio; + +use fs2::FileExt; +use regex::Regex; +use seahash::SeaHasher; use tokio::process::{Child, Command}; use tower_telemetry::debug; pub mod install; // UV_VERSION is the version of UV to download and install when setting up a local UV deployment. -pub const UV_VERSION: &str = "0.7.13"; +pub const UV_VERSION: &str = "0.9.27"; #[derive(Debug)] pub enum Error { @@ -106,6 +112,122 @@ fn normalize_env_vars(env_vars: &HashMap) -> HashMap.lock`) in the temp directory for concurrent operation +/// safety. These files are not automatically cleaned up when UV exits. This function finds all +/// such files and removes any that are not currently locked by another process. +pub fn cleanup_stale_uv_lock_files() { + let temp_dir = std::env::temp_dir(); + + let entries = match fs::read_dir(&temp_dir) { + Ok(entries) => entries, + Err(e) => { + debug!( + "Failed to read temp directory for lock file cleanup: {:?}", + e + ); + return; + } + }; + + for entry in entries.flatten() { + let path = entry.path(); + + // Only process files matching the uv-*.lock pattern + if let Some(file_name) = path.file_name() { + if is_uv_lock_file_name(&file_name) { + continue; + } + } else { + continue; + } + + // Try to open the file and acquire an exclusive lock + let file = match OpenOptions::new().read(true).write(true).open(&path) { + Ok(f) => f, + Err(e) => { + debug!("Failed to open lock file {:?}: {:?}", path, e); + continue + } + }; + + // Try to acquire an exclusive lock without blocking + if file.try_lock_exclusive().is_ok() { + // We got the lock, meaning no other process is using this file. + // Unlock and delete it. + let _ = FileExt::unlock(&file); + drop(file); // Close the file handle before deleting + + if let Err(e) = fs::remove_file(&path) { + debug!("Failed to remove stale lock file {:?}: {:?}", path, e); + } else { + debug!("Cleaned up stale UV lock file: {:?}", path); + } + } + // If we couldn't get the lock, another process is using it, so leave it alone + } +} + +fn is_uv_lock_file_name>(lock_name: S) -> bool { + // There isn't a really great way of _not_ instantiating this on each call, without using a + // LazyLock or some other synchronization method. So, we just take the runtime hit instead of + // the synchonization hit. + let uv_lock_pattern = Regex::new(r"^uv-[0-9a-f]{16}\.lock$").unwrap(); + let os_str = lock_name.as_ref(); + + os_str.to_str() + .map(|name| uv_lock_pattern.is_match(name)) + .unwrap_or(false) +} + +/// Computes the lock file path that uv will create for a given working directory. +/// +/// This replicates uv's lock file naming: `uv-{hash}.lock` where the hash is +/// a seahash of the workspace path. When running uv commands with a specific +/// working directory, this function can predict which lock file will be used. +pub fn compute_uv_lock_file_path(cwd: &Path) -> PathBuf { + let mut hasher = SeaHasher::new(); + cwd.hash(&mut hasher); + let hash = hasher.finish(); + let hash_hex = hex::encode(hash.to_le_bytes()); + + std::env::temp_dir().join(format!("uv-{}.lock", hash_hex)) +} + +/// Cleans up the lock file for a specific working directory after uv exits. +/// +/// This should be called after a uv process completes to clean up the lock file +/// it created. The lock file is only removed if no other process is using it. +/// +/// Returns `true` if the lock file was successfully removed, `false` otherwise +/// (either because it didn't exist, couldn't be opened, or is still locked). +pub fn cleanup_lock_file_for_cwd(cwd: &Path) -> bool { + let lock_path = compute_uv_lock_file_path(cwd); + + let file = match OpenOptions::new().read(true).write(true).open(&lock_path) { + Ok(f) => f, + Err(_) => return false, // File doesn't exist or can't be opened + }; + + // Only delete if we can acquire exclusive lock (no other process using it) + if file.try_lock_exclusive().is_ok() { + let _ = FileExt::unlock(&file); + drop(file); // Close the file handle before deleting + + if let Err(e) = fs::remove_file(&lock_path) { + debug!("Failed to remove lock file {:?}: {:?}", lock_path, e); + false + } else { + debug!("Cleaned up UV lock file for {:?}: {:?}", cwd, lock_path); + true + } + } else { + // Another process is still using this lock file + false + } +} + async fn test_uv_path(path: &PathBuf) -> Result<(), Error> { let res = Command::new(&path) .arg("--color") @@ -170,6 +292,11 @@ impl Uv { .arg("venv") .envs(env_vars); + #[cfg(unix)] + { + cmd.process_group(0); + } + if let Some(dir) = &self.cache_dir { cmd.arg("--cache-dir").arg(dir); } @@ -302,3 +429,105 @@ impl Uv { test_uv_path(&self.uv_path).await.is_ok() } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + + #[test] + fn test_compute_uv_lock_file_path_is_deterministic() { + let path = Path::new("/some/project/path"); + + let lock_path_1 = compute_uv_lock_file_path(path); + let lock_path_2 = compute_uv_lock_file_path(path); + + assert_eq!(lock_path_1, lock_path_2); + } + + #[test] + fn test_compute_uv_lock_file_path_different_paths_produce_different_hashes() { + let path_a = Path::new("/project/a"); + let path_b = Path::new("/project/b"); + + let lock_path_a = compute_uv_lock_file_path(path_a); + let lock_path_b = compute_uv_lock_file_path(path_b); + + assert_ne!(lock_path_a, lock_path_b); + } + + #[test] + fn test_compute_uv_lock_file_path_format() { + let path = Path::new("/test/path"); + let lock_path = compute_uv_lock_file_path(path); + + let file_name = lock_path.file_name().unwrap().to_str().unwrap(); + assert!(file_name.starts_with("uv-")); + assert!(file_name.ends_with(".lock")); + + // Hash should be 16 hex characters (8 bytes as hex) + let hash_part = &file_name[3..file_name.len() - 5]; + assert_eq!(hash_part.len(), 16); + assert!(hash_part.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn test_cleanup_lock_file_for_cwd_removes_unlocked_file() { + let temp_dir = std::env::temp_dir(); + let test_cwd = temp_dir.join("test-cleanup-cwd"); + + // Compute where the lock file would be + let lock_path = compute_uv_lock_file_path(&test_cwd); + + // Create the lock file manually + { + let mut file = fs::File::create(&lock_path).unwrap(); + file.write_all(b"test").unwrap(); + } + + assert!(lock_path.exists()); + + // Clean it up + let result = cleanup_lock_file_for_cwd(&test_cwd); + + assert!(result); + assert!(!lock_path.exists()); + } + + #[test] + fn test_cleanup_lock_file_for_cwd_returns_false_for_nonexistent() { + let nonexistent_cwd = Path::new("/nonexistent/path/that/does/not/exist"); + + let result = cleanup_lock_file_for_cwd(nonexistent_cwd); + + assert!(!result); + } + + #[test] + fn test_cleanup_lock_file_for_cwd_respects_lock() { + let temp_dir = std::env::temp_dir(); + let test_cwd = temp_dir.join("test-cleanup-locked-cwd"); + + let lock_path = compute_uv_lock_file_path(&test_cwd); + + // Create and hold a lock on the file + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(true) + .open(&lock_path) + .unwrap(); + file.lock_exclusive().unwrap(); + + // Try to clean up while locked - should fail + let result = cleanup_lock_file_for_cwd(&test_cwd); + + assert!(!result); + assert!(lock_path.exists()); + + // Release the lock and clean up + drop(file); + let _ = fs::remove_file(&lock_path); + } +}