Skip to content

[bug] Concurrent python environment setup races on the shared workspace .venv (uv venv collision + uv sync member churn) #2653

Description

@kibumpark-carbonsix

Describe the bug

On a cold workspace (no .venv, no prior .moon/cache state), running moon ci with enough
parallel targets makes multiple python environment actors race on the workspace .venv, and one
dies:

▮▮▮▮ uv venv .venv --no-managed-python --no-python-downloads --no-progress
▮▮▮▮ uv venv .venv --no-managed-python --no-python-downloads --no-progress
...
Creating virtual environment at: .venv
error: Failed to create virtual environment
  Caused by: A virtual environment already exists at: .venv
hint: Use the `--clear` flag or set `UV_VENV_CLEAR=1` to replace the existing virtual environment

A timestamped CI failure trace shows three overlapping writer classes, not just the two uv venv
invocations:

07:59:25.42  ▮▮▮▮ uv venv .venv                    # announced
07:59:25.83  ▮▮▮▮ uv venv .venv                    # announced
07:59:28.27  Creating virtual environment at: .venv                          # project-scoped actor, its own dir (cwd=<project>)
07:59:28.71  Creating virtual environment at: /abs/…/<workspace>/.venv       # from `uv sync` — implicit creation when missing
07:59:28.99  Creating virtual environment at: .venv
             error: … A virtual environment already exists at: .venv          # workspace-scoped `uv venv` loses

So the colliding pair in our trace is the workspace-scoped SetupEnvironment's uv venv against
an InstallDependencies' uv sync (workspace-scoped, or a project-scoped one whose uv sync
resolves upward to the workspace env), which implicitly creates the venv when it is missing.
The python toolchain already skips emitting uv venv when the directory exists
(moonrepo/plugins#142), but on a cold start every actor samples "missing" before any of them
creates it — a TOCTOU window the existence check cannot close.

The two environment-setup identities, from .moon/cache/hashes/*.json:

root=''       projectId=None      toolchainId=unstable_python   # workspace-scoped
root='tools'  projectId='tools'   toolchainId=unstable_python   # project-scoped

The per-command lock in crates/actions/src/plugins/commands.rs
(create_lock("{prefix}-{cmd.get_cache_key()}")) does not serialize these: setup-environment and
install-dependencies prefixes (and differing commands) take different locks. It is a
command-identity lock, not a resource lock on the resolved venv path.

This looks like the unresolved half of #2524 / #2525. Those were reported as an indefinite hang
(two actions contending for one per-toolchain lock) and were closed by making the locks more
unique — which turned the deadlock into this race. Your own comment on #2524 describes the missing
half:

the setup environment action either a) should have different hashes across projects, or b) share
the same setup environment so it only runs once. The fact that it doesn't seem to be doing either
of those feels like a bug.

(a) landed; (b) did not, and (b) — or serialization on the resolved venv path — is what a shared
.venv needs.

Still present in the latest release

Checked against v2.4.6 source: crates/actions/src/actions/setup_environment.rs, the plugin
command lock, and the action-graph builder are unchanged since v2.4.3, and the bundled plugin
versions are identical (python_toolchain 0.2.0, python_uv_toolchain 0.1.3), so upgrading does
not change the behavior.

Steps to reproduce

  1. A uv workspace whose root pyproject.toml owns the .venv, with unstable_python +
    unstable_uv in .moon/toolchains.yml.
  2. A second project that acquires the python toolchain (declared on a task, or detected from a
    python3 … / uv … task command).
  3. Remove .venv and .moon/cache/hashes so all environment actors start cold.
  4. Run enough parallel targets to overlap the actors, e.g. moon ci on a change selecting many
    projects. With 8 resolved targets we saw one uv venv; with 20, overlapping writers and the
    failure. Collision is timing-dependent — locally the actors often serialize by chance.

Second face — the same race class survives venv creation: member uninstall under running tasks

After we contained the uv venv collision (below), the same underlying gap resurfaced through
uv sync scope divergence. A project-scoped InstallDependencies runs a default-scope
uv sync (root project only), while tasks run uv run --all-packages; the desired member sets
differ, so the action's sync uninstalls the installed workspace members — and uv's
project-environment lock serializes syncs against each other, not against already-running
processes. Timestamped CI evidence:

08:47:56  <member-app>:test starts (pytest, ~30s)
08:48:04  project-scoped InstallDependencies `uv sync` → "Uninstalled 38 packages in 30ms"
08:48:26  test ends: 122 passed, 5 errors — importlib.metadata lookups of a workspace member
          (pkg_version("<member-app>")) fail with PackageNotFoundError mid-run

We contained this too (aligning syncArgs to --all-packages), but both faces share one root
cause, which is the actual report here.

Expected behavior

Environment actors that resolve to the same environment should either be deduplicated into one
action or serialized on that resource — and mutations of a shared environment should not run
concurrently with tasks that use it. Per-toolchain/per-command hashing and locking are
insufficient, because the mutable resource is the resolved environment (venv path + installed
member set), not the toolchain or command identity. uv sync's implicit venv creation and its
member-set reconciliation both participate in the race even when no second uv venv is emitted.

Workaround we ship (containment, not a fix)

Two parts. First, serialize the cold provisioning before the parallel run:

- run: MOON_CONCURRENCY=1 moon run <cheap workspace-member python task>
- run: moon ci --include-relations --downstream deep

The warm-up creates the venv serially; every later actor then samples "exists" and emits no
uv venv (the plugins#142 path). Second, align the sync scope so InstallDependencies stops
fighting the tasks over the member set:

unstable_uv:
  syncArgs: ["--all-packages", "--no-managed-python", "--no-python-downloads", "--no-progress"]

(Note for other readers: syncArgs replaces the plugin's fallback args, so the fallback flags
must be restated.) Both are verified in our CI. We rejected --clear / UV_VENV_CLEAR=1 (the
hint uv prints — two concurrent clears race destructively) and venvArgs: ["--allow-existing"]
(permits the overlay instead of serializing the writers).

Environment

  • moon 2.4.3 (behavior also confirmed unchanged in v2.4.6 source)
  • unstable_python 3.13, unstable_uv 0.11.25
  • Linux (CI: Ubuntu x86_64 on RunsOn; also reproduced locally on Linux)

Additional context

Analysis and this report are AI-assisted; the evidence above (action hashes, timestamped CI logs,
target counts, source references) is from real runs in our repository and from reading the moon
v2.4.3/v2.4.6 and moonrepo/plugins sources.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions