Skip to content

Vendored uv with a user-authored override-dependencies: after any relock (uv add, uv lock --upgrade-package), vendor --revert / remove revert pyproject.toml but keep the vendored [manifest] overrides entry in uv.lock, so uv sync --locked fails (vendor --revert exits 0) #806

Description

[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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions