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
20 changes: 20 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions crates/tower-uv/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
233 changes: 231 additions & 2 deletions crates/tower-uv/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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";
Copy link
Contributor

Choose a reason for hiding this comment

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

Wonder if we should make this a runtime config

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think we want to lock it down at least for now because we want to ensure it works with our software.


#[derive(Debug)]
pub enum Error {
Expand Down Expand Up @@ -106,6 +112,122 @@ fn normalize_env_vars(env_vars: &HashMap<String, String>) -> HashMap<String, Str
env_vars
}

/// Cleans up stale UV lock files from the system temp directory.
///
/// UV creates lock files (e.g., `uv-<hash>.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
Copy link
Contributor

Choose a reason for hiding this comment

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

if we assume one runner is running on one device, maybe we should clean up all locks on runner startup, regardless of whether they're POSIX locked?

}
}

fn is_uv_lock_file_name<S: AsRef<std::ffi::OsStr>>(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")
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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);
}
}