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
- A uv workspace whose root
pyproject.toml owns the .venv, with unstable_python +
unstable_uv in .moon/toolchains.yml.
- A second project that acquires the python toolchain (declared on a task, or detected from a
python3 … / uv … task command).
- Remove
.venv and .moon/cache/hashes so all environment actors start cold.
- 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.
Describe the bug
On a cold workspace (no
.venv, no prior.moon/cachestate), runningmoon ciwith enoughparallel targets makes multiple python environment actors race on the workspace
.venv, and onedies:
A timestamped CI failure trace shows three overlapping writer classes, not just the two
uv venvinvocations:
So the colliding pair in our trace is the workspace-scoped
SetupEnvironment'suv venvagainstan
InstallDependencies'uv sync(workspace-scoped, or a project-scoped one whoseuv syncresolves upward to the workspace env), which implicitly creates the venv when it is missing.
The python toolchain already skips emitting
uv venvwhen 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:The per-command lock in
crates/actions/src/plugins/commands.rs(
create_lock("{prefix}-{cmd.get_cache_key()}")) does not serialize these:setup-environmentandinstall-dependenciesprefixes (and differing commands) take different locks. It is acommand-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:
(a) landed; (b) did not, and (b) — or serialization on the resolved venv path — is what a shared
.venvneeds.Still present in the latest release
Checked against v2.4.6 source:
crates/actions/src/actions/setup_environment.rs, the plugincommand 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 doesnot change the behavior.
Steps to reproduce
pyproject.tomlowns the.venv, withunstable_python+unstable_uvin.moon/toolchains.yml.python3 …/uv …task command)..venvand.moon/cache/hashesso all environment actors start cold.moon cion a change selecting manyprojects. With 8 resolved targets we saw one
uv venv; with 20, overlapping writers and thefailure. 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 venvcollision (below), the same underlying gap resurfaced throughuv syncscope divergence. A project-scopedInstallDependenciesruns a default-scopeuv sync(root project only), while tasks runuv run --all-packages; the desired member setsdiffer, 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:
We contained this too (aligning
syncArgsto--all-packages), but both faces share one rootcause, 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 itsmember-set reconciliation both participate in the race even when no second
uv venvis emitted.Workaround we ship (containment, not a fix)
Two parts. First, serialize the cold provisioning before the parallel run:
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 stopsfighting the tasks over the member set:
(Note for other readers:
syncArgsreplaces the plugin's fallback args, so the fallback flagsmust be restated.) Both are verified in our CI. We rejected
--clear/UV_VENV_CLEAR=1(thehint uv prints — two concurrent clears race destructively) and
venvArgs: ["--allow-existing"](permits the overlay instead of serializing the writers).
Environment
unstable_python3.13,unstable_uv0.11.25Additional 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.