[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
Take a uv project that already declares its own [tool.uv] override-dependencies (one entry is enough, for example ["attrs>=20"]) and has a patched package that arrives transitively (six through python-dateutil). Vendored mode wires six by:
- rewriting the user's
override-dependencies array in pyproject.toml (a uv_override / Rewritten record);
- appending
{ name = "six", path = ".socket/vendor/…" } to the end of uv.lock's existing [manifest] overrides array (a uv_lock_manifest_overrides / Rewritten record, pypi_uv.rs:1540 / :1546).
The lock that socket-patch writes is accepted (uv lock --check and uv sync --locked pass). But uv serializes [manifest] overrides itself: sorted by name, and multi-line once there are two or more entries. Any later relock that rewrites the array (uv add idna, or uv lock --upgrade-package attrs on uv 0.8.17) leaves the six entry semantically unchanged but in a different spelling or position.
On the next vendor --revert (or remove pkg:pypi/six@1.16.0, or rollback):
- The uv.lock record no longer matches byte-for-byte (
replace_fragment, pypi_uv.rs:868), so it is drift-kept (vendor_lock_entry_drifted, vendor_artifact_kept, vendor_revert_kept), and uv.lock still routes six through .socket/vendor/.
- The pyproject.toml record still matches, so it is reverted (
pypi_uv.rs:920, written at :969). The six override disappears from override-dependencies.
The project is left half-reverted. uv.lock says six is overridden to the vendored wheel, and pyproject.toml no longer asks for it, so uv sync --locked fails with "The lockfile at uv.lock needs to be updated". vendor --revert reports status: "success" and exits 0. remove / rollback exit 1 with partialFailure, but they have already rewritten pyproject.toml. A re-run can't heal it: the remedy text says "undo the drift", but the user never edited the fragment, and uv will keep its own spelling.
Without a user-authored override-dependencies (socket-patch creates the overrides key itself), the same relock sequences revert cleanly on every OS and version below. Hosted mode on the same shape also rolls back cleanly.
Impact
- After an ordinary
uv add, reverting a vendored patch breaks the project's frozen/locked install (CI using uv sync --locked goes red), while vendor --revert reports success.
- A plain
uv sync then re-locks from the now-unwired pyproject.toml and silently drops the override. The ledger entry and .socket/vendor/pypi/<uuid> are kept forever, because the drift never resolves.
- It contradicts docs/testing/uv-compatibility.md:176-178: "Script and lock edits are treated as a pair: conflicting changes preserve both files and their recovery state rather than restoring only one side."
Repro
The mock serves POST /patch/package and the patched six 1.16.0 wheel, with SRI sha512 (the same harness as the earlier uv issues, e.g. #788). uv resolves python-dateutil from PyPI.
mkdir app && cd app
cat > pyproject.toml <<'EOF'
[project]
name = "app"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = ["python-dateutil==2.9.0.post0"]
[tool.uv]
constraint-dependencies = ["six==1.16.0"]
override-dependencies = ["attrs>=20"]
EOF
uv lock && uv sync
SP="socket-patch --api-url $MOCK --api-token fake --org acme --patch-server-url $MOCK --vendor-url $MOCK"
$SP scan --mode vendored --yes # uv.lock: overrides = [{ name = "attrs", … }, { name = "six", path = ".socket/vendor/…" }]
uv sync --locked # ok, six patched
uv add idna==3.7 # uv rewrites [manifest] overrides as a sorted multi-line array
$SP vendor --revert --yes --json # exit 0, status success, events: vendor_lock_entry_drifted / vendor_revert_kept
grep -c socket/vendor uv.lock # 1 (six override still points at .socket/vendor)
grep -c socket/vendor pyproject.toml # 0 (pyproject override already reverted)
uv sync --locked # error: The lockfile at `uv.lock` needs to be updated, but `--locked` was provided.
Expected vs actual
- Expected: uv-compatibility.md:176-178 says revert treats the pair as one unit: on conflicting changes, both files and their recovery state are preserved, never one side restored. CLI_CONTRACT.md (
vendor --revert, around line 713) says fragments a user re-resolved are left alone with vendor_lock_entry_drifted. Here, though, the user did not re-resolve six; uv only re-serialized the array. So either the revert recognizes the override entry semantically (it's the same { name = "six", path = … } element) and unwinds both files, or it keeps both files wired so uv sync --locked still passes.
- Actual: pyproject.toml is reverted and uv.lock is kept,
uv sync --locked fails, and vendor --revert exits 0 with status: "success".
Matrix
On main 045d7ec, "fail" means uvlock_refs=1 pyproject_refs=0 post_locked=1. Every cell ran twice on Linux and once per probe job.
| OS |
uv |
user overrides |
relock |
vendor --revert |
remove |
| Linux, macOS, Windows |
0.8.17 |
["attrs>=20"] and ["attrs>=20", "zipp>=3"] |
uv add idna==3.7 |
fail (exit 0, success) |
fail (exit 1) |
| Linux, macOS, Windows |
0.8.17 |
same |
uv lock --upgrade-package attrs |
fail |
fail |
| Linux, macOS, Windows |
0.12.23 |
same |
uv add idna==3.7 |
fail |
fail |
| Linux, macOS, Windows |
0.12.23 |
same |
uv lock --upgrade-package attrs (lock unchanged on 0.12.23) |
pass |
pass |
| Linux |
0.5.31 |
["attrs>=20"] |
uv add / --upgrade-package |
fail |
– |
| Linux, macOS, Windows |
0.8.17 / 0.12.23 |
none (socket-patch creates the key) |
both |
pass |
pass |
| Linux |
0.8.17 / 0.12.23 |
["attrs>=20"], hosted scan + rollback after uv add |
– |
pass |
– |
| Linux |
0.5.31 / 0.8.17 / 0.12.23 |
["attrs>=20"], no relock (uv lock no-op, uv sync) |
– |
pass |
– |
rollback behaves like remove (exit 1, pyproject.toml reverted, uv.lock kept).
First bad version: not bisected. The published v4.0.0 can't vendor this shape against the harness (no_local_source, the pre-v5 vendor flow), so there's no release to compare against.
Suspect code
crates/socket-patch-core/src/vendor/pypi_uv.rs:1540 / :1546 (add_manifest_override): the vendored element is appended at the end of the user's array, not in uv's sorted / multi-line spelling. So the first relock that touches the array makes the recorded new text stale.
crates/socket-patch-core/src/vendor/pypi_uv.rs:850-890: the uv_lock_manifest_overrides Rewritten arm only matches the exact recorded array text. A re-serialized array that still holds the same element is treated as drift.
crates/socket-patch-core/src/vendor/pypi_uv.rs:920-940 and :969: the uv_override record is reverted and pyproject.toml is written even when its lock partner drift-kept. There's no pair gate like the one the docs promise for script + lock.
Probe runs
[agent] Found by the scheduled uv bug-hunt routine (ledger #310).
Summary
Take a uv project that already declares its own
[tool.uv] override-dependencies(one entry is enough, for example["attrs>=20"]) and has a patched package that arrives transitively (six through python-dateutil). Vendored mode wires six by:override-dependenciesarray in pyproject.toml (auv_override/Rewrittenrecord);{ name = "six", path = ".socket/vendor/…" }to the end of uv.lock's existing[manifest] overridesarray (auv_lock_manifest_overrides/Rewrittenrecord,pypi_uv.rs:1540/:1546).The lock that socket-patch writes is accepted (
uv lock --checkanduv sync --lockedpass). But uv serializes[manifest] overridesitself: sorted by name, and multi-line once there are two or more entries. Any later relock that rewrites the array (uv add idna, oruv lock --upgrade-package attrson uv 0.8.17) leaves the six entry semantically unchanged but in a different spelling or position.On the next
vendor --revert(orremove pkg:pypi/six@1.16.0, orrollback):replace_fragment,pypi_uv.rs:868), so it is drift-kept (vendor_lock_entry_drifted,vendor_artifact_kept,vendor_revert_kept), and uv.lock still routes six through.socket/vendor/.pypi_uv.rs:920, written at:969). The six override disappears fromoverride-dependencies.The project is left half-reverted. uv.lock says six is overridden to the vendored wheel, and pyproject.toml no longer asks for it, so
uv sync --lockedfails with "The lockfile atuv.lockneeds to be updated".vendor --revertreportsstatus: "success"and exits 0.remove/rollbackexit 1 withpartialFailure, but they have already rewritten pyproject.toml. A re-run can't heal it: the remedy text says "undo the drift", but the user never edited the fragment, and uv will keep its own spelling.Without a user-authored
override-dependencies(socket-patch creates theoverrideskey itself), the same relock sequences revert cleanly on every OS and version below. Hosted mode on the same shape also rolls back cleanly.Impact
uv add, reverting a vendored patch breaks the project's frozen/locked install (CI usinguv sync --lockedgoes red), whilevendor --revertreports success.uv syncthen re-locks from the now-unwired pyproject.toml and silently drops the override. The ledger entry and.socket/vendor/pypi/<uuid>are kept forever, because the drift never resolves.Repro
The mock serves
POST /patch/packageand the patched six 1.16.0 wheel, with SRI sha512 (the same harness as the earlier uv issues, e.g. #788). uv resolves python-dateutil from PyPI.Expected vs actual
vendor --revert, around line 713) says fragments a user re-resolved are left alone withvendor_lock_entry_drifted. Here, though, the user did not re-resolve six; uv only re-serialized the array. So either the revert recognizes the override entry semantically (it's the same{ name = "six", path = … }element) and unwinds both files, or it keeps both files wired souv sync --lockedstill passes.uv sync --lockedfails, andvendor --revertexits 0 withstatus: "success".Matrix
On main
045d7ec, "fail" meansuvlock_refs=1 pyproject_refs=0 post_locked=1. Every cell ran twice on Linux and once per probe job.vendor --revertremove["attrs>=20"]and["attrs>=20", "zipp>=3"]uv add idna==3.7uv lock --upgrade-package attrsuv add idna==3.7uv lock --upgrade-package attrs(lock unchanged on 0.12.23)["attrs>=20"]uv add/--upgrade-package["attrs>=20"], hosted scan + rollback afteruv add["attrs>=20"], no relock (uv lockno-op,uv sync)rollbackbehaves likeremove(exit 1, pyproject.toml reverted, uv.lock kept).First bad version: not bisected. The published v4.0.0 can't vendor this shape against the harness (
no_local_source, the pre-v5 vendor flow), so there's no release to compare against.Suspect code
crates/socket-patch-core/src/vendor/pypi_uv.rs:1540/:1546(add_manifest_override): the vendored element is appended at the end of the user's array, not in uv's sorted / multi-line spelling. So the first relock that touches the array makes the recordednewtext stale.crates/socket-patch-core/src/vendor/pypi_uv.rs:850-890: theuv_lock_manifest_overridesRewrittenarm only matches the exact recorded array text. A re-serialized array that still holds the same element is treated as drift.crates/socket-patch-core/src/vendor/pypi_uv.rs:920-940and:969: theuv_overriderecord is reverted and pyproject.toml is written even when its lock partner drift-kept. There's no pair gate like the one the docs promise for script + lock.Probe runs
bughunt/uv/20261004-override-drift, ubuntu / macos / windows × uv 0.8.17 / 0.12.23)