Lock during uv sync, uv add and uv remove to avoid race conditions (#13869)

Surprisingly, we weren't locking during `uv sync` so far, so running `uv
sync` in parallel could cause errors in filesystem races.

I've also added locks to `uv add` and `uv remove` which concurrently
modify `pyproject.toml`. These locks only apply after we determined the
interpreter, so they don't actually prevent computing the same thing
twice when running `uv add` in parallel.

All other subcommands that I checked were already locking (with no claim
to exhaustiveness)

Fixes #12751

# Test Plan

I don't have CI-sized reproducer for this.

```toml
[project]
name = "debug"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
  "boto3>=1.38.30",
  "fastapi>=0.115.12",
  "numba>=0.61.2",
  "polars>=1.30.0",
  "protobuf>=6.31.1",
  "pyarrow>=20.0.0",
  "pydantic>=2.11.5",
  "requests>=2.32.3",
  "urllib3>=2.4.0",
  "scikit-learn>=1.6.1",
  "jupyter>=1.1.1",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
```

```
rm -rf .venv && parallel -n0 "uv sync -q" ::: {1..10}
```
This commit is contained in:
konsti 2025-06-06 14:16:40 +02:00 committed by GitHub
parent b865f76b78
commit bf96c60e3e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 44 additions and 22 deletions

View file

@ -1,5 +1,4 @@
use std::borrow::Cow;
use std::env;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
@ -8,7 +7,6 @@ use owo_colors::OwoColorize;
use tracing::debug;
use uv_cache::Cache;
use uv_cache_key::cache_digest;
use uv_fs::{LockedFile, Simplified};
use uv_pep440::Version;
@ -316,23 +314,7 @@ impl PythonEnvironment {
/// Grab a file lock for the environment to prevent concurrent writes across processes.
pub async fn lock(&self) -> Result<LockedFile, std::io::Error> {
if let Some(target) = self.0.interpreter.target() {
// If we're installing into a `--target`, use a target-specific lockfile.
LockedFile::acquire(target.root().join(".lock"), target.root().user_display()).await
} else if let Some(prefix) = self.0.interpreter.prefix() {
// Likewise, if we're installing into a `--prefix`, use a prefix-specific lockfile.
LockedFile::acquire(prefix.root().join(".lock"), prefix.root().user_display()).await
} else if self.0.interpreter.is_virtualenv() {
// If the environment a virtualenv, use a virtualenv-specific lockfile.
LockedFile::acquire(self.0.root.join(".lock"), self.0.root.user_display()).await
} else {
// Otherwise, use a global lockfile.
LockedFile::acquire(
env::temp_dir().join(format!("uv-{}.lock", cache_digest(&self.0.root))),
self.0.root.user_display(),
)
.await
}
self.0.interpreter.lock().await
}
/// Return the [`Interpreter`] for this environment.

View file

@ -1,10 +1,10 @@
use std::borrow::Cow;
use std::env::consts::ARCH;
use std::fmt::{Display, Formatter};
use std::io;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
use std::sync::OnceLock;
use std::{env, io};
use configparser::ini::Ini;
use fs_err as fs;
@ -17,7 +17,7 @@ use tracing::{debug, trace, warn};
use uv_cache::{Cache, CacheBucket, CachedByTimestamp, Freshness};
use uv_cache_info::Timestamp;
use uv_cache_key::cache_digest;
use uv_fs::{PythonExt, Simplified, write_atomic_sync};
use uv_fs::{LockedFile, PythonExt, Simplified, write_atomic_sync};
use uv_install_wheel::Layout;
use uv_pep440::Version;
use uv_pep508::{MarkerEnvironment, StringVersion};
@ -581,6 +581,31 @@ impl Interpreter {
.into_iter()
.any(|default_name| name == default_name.to_string())
}
/// Grab a file lock for the environment to prevent concurrent writes across processes.
pub async fn lock(&self) -> Result<LockedFile, io::Error> {
if let Some(target) = self.target() {
// If we're installing into a `--target`, use a target-specific lockfile.
LockedFile::acquire(target.root().join(".lock"), target.root().user_display()).await
} else if let Some(prefix) = self.prefix() {
// Likewise, if we're installing into a `--prefix`, use a prefix-specific lockfile.
LockedFile::acquire(prefix.root().join(".lock"), prefix.root().user_display()).await
} else if self.is_virtualenv() {
// If the environment a virtualenv, use a virtualenv-specific lockfile.
LockedFile::acquire(
self.sys_prefix.join(".lock"),
self.sys_prefix.user_display(),
)
.await
} else {
// Otherwise, use a global lockfile.
LockedFile::acquire(
env::temp_dir().join(format!("uv-{}.lock", cache_digest(&self.sys_executable))),
self.sys_prefix.user_display(),
)
.await
}
}
}
/// The `EXTERNALLY-MANAGED` file in a Python installation.

View file

@ -26,7 +26,7 @@ use uv_distribution_types::{
Index, IndexName, IndexUrls, NameRequirementSpecification, Requirement, RequirementSource,
UnresolvedRequirement, VersionId,
};
use uv_fs::Simplified;
use uv_fs::{LockedFile, Simplified};
use uv_git::GIT_STORE;
use uv_git_types::GitReference;
use uv_normalize::{DEV_DEPENDENCIES, DefaultExtras, PackageName};
@ -277,6 +277,8 @@ pub(crate) async fn add(
}
};
let _lock = target.acquire_lock().await?;
let client_builder = BaseClientBuilder::new()
.connectivity(network_settings.connectivity)
.native_tls(network_settings.native_tls)
@ -1152,6 +1154,15 @@ impl<'lock> From<&'lock AddTarget> for LockTarget<'lock> {
}
impl AddTarget {
/// Acquire a file lock mapped to the underlying interpreter to prevent concurrent
/// modifications.
pub(super) async fn acquire_lock(&self) -> Result<LockedFile, io::Error> {
match self {
Self::Script(_, interpreter) => interpreter.lock().await,
Self::Project(_, python_target) => python_target.interpreter().lock().await,
}
}
/// Returns the [`Interpreter`] for the target.
pub(super) fn interpreter(&self) -> &Interpreter {
match self {

View file

@ -268,6 +268,8 @@ pub(crate) async fn remove(
}
};
let _lock = target.acquire_lock().await?;
// Determine the lock mode.
let mode = if locked {
LockMode::Locked(target.interpreter())

View file

@ -166,6 +166,8 @@ pub(crate) async fn sync(
),
};
let _lock = environment.lock().await?;
// Notify the user of any environment changes.
match &environment {
SyncEnvironment::Project(ProjectEnvironment::Existing(environment))