Skip to content

Vendored requirements.txt after the user removes or bumps a vendored pin: the rescan re-adds the removed package as a "(transitive)" line (exit 0), or exits 1 forever after a bump, and scan --prune never reverts the entry #786

Description

[agent] Found by the scheduled pip / requirements.txt bug-hunt routine (ledger #309).

Summary

#541 / #543 taught vendored_ledger_supplement to skip vendor-ledger entries whose dependency has left the lock: no re-vendor, a vendor_ledger_entry_unwired warning, and scan --prune reverts them with exit 0. The filter asks dispatch_in_use_one, which only has probes for npm and cargo. For pypi it returns None ("can't tell"), and the supplement treats that as "still wired". So on a requirements.txt project, a vendored entry is always rediscovered after the user removes or bumps the package. Two outcomes follow:

  1. Removal (six==… line deleted, package uninstalled): the next scan --mode vendored re-vendors six@1.16.0 and appends ./.socket/vendor/pypi/<uuid>/six-1.16.0-…whl # socket-patch vendor: six==1.16.0 (transitive) to requirements.txt. It exits 0, with no warning. A dependency the user deliberately removed goes back into their requirements file, and a fresh pip install -r requirements.txt installs it again. The ledger also keeps the stale requirements.txt:1 "rewritten" wiring next to the new "added" one.
  2. Bump (six==1.16.0 → six==1.17.0, pip install run): every later scan --mode vendored exits 1 (partial_failure, pypi_requirement_not_pinned: "six is not pinned to ==1.16.0; pin it exactly or use agent mode"), which is the Vendored vlt scan exits 1 after the patched dependency is upgraded or uninstalled, and even scan --prune exits 1 while it reverts the stale entry #541 symptom. --prune doesn't help: gc.revertedVendoredEntries is [] and it still exits 1, on every run. --dry-run --prune previews already_vendored, exit 0 and revertableVendoredEntries: [], so the preview disagrees with the wet run.

In both cases scan --prune (the documented reconcile) never reverts the entry, so the dead uuid dir and wheel stay committed.

Impact

  • Removal: socket-patch silently re-adds a removed (patched) dependency to requirements.txt. Anyone who drops a package from a vendored pip project gets it back on the next scheduled scan, and CI stays green.
  • Bump: a scheduled scan --mode vendored goes red permanently after a routine upgrade of a vendored package, with misleading advice. The only way out is a manual socket-patch vendor --revert (that works: it skips the drifted line as vendor_revert_line_drifted and removes .socket/).

Repro (Linux, main 045d7ec, pip 26.2.1 / CPython 3.13)

This uses a local mock. Patch discovery (batch / by-package) answers only for six@1.16.0, the view/<uuid> route serves a one-line marker patch on the real installed six.py, and the vendored artifact comes from prebuilt_common::mount_view_from_source. It ran through a throwaway, uncommitted test driver.

python3.13 -m venv venv && venv/bin/pip install pip==26.2.1 six==1.16.0
export VIRTUAL_ENV=$PWD/venv
printf 'six==1.16.0\nidna==3.7\n' > requirements.txt
F="--json --yes --api-url $MOCK --api-token fake --org test-org --vendor-url $MOCK --patch-server-url $MOCK"
socket-patch scan --mode vendored $F        # exit 0; line 1 -> ./.socket/vendor/pypi/<uuid>/six-1.16.0-py2.py3-none-any.whl  # socket-patch vendor: six==1.16.0

# Case A: the user removes six
printf 'idna==3.7\n' > requirements.txt && venv/bin/pip uninstall -y six
socket-patch scan --mode vendored $F        # exit 0, vendor event "applied"; requirements.txt gains the "(transitive)" six line
socket-patch scan --mode vendored --prune $F  # exit 0, gc.revertedVendoredEntries: [], the line stays
python3.13 -m venv fresh && fresh/bin/pip install -r requirements.txt   # installs six 1.16.0 (the patched wheel)

# Case B (fresh project): the user bumps six
printf 'six==1.17.0\nidna==3.7\n' > requirements.txt && venv/bin/pip install six==1.17.0
socket-patch scan --mode vendored $F          # exit 1, pypi_requirement_not_pinned
socket-patch scan --mode vendored --prune $F  # exit 1, gc.revertedVendoredEntries: [] (and on every later run)
socket-patch scan --mode vendored --prune --dry-run $F   # exit 0, "already_vendored", revertableVendoredEntries: []

Both cases reproduced on two separate runs.

Expected vs actual

CLI_CONTRACT.md, scan --vendor paragraph: "an entry the lockfile in-use probe (the one --prune reverts by) proves unwired, because the dependency was upgraded or removed, is NOT discovered and so is never re-vendored. A run without a non-hosted --prune reports it through the run-level vendor_ledger_entry_unwired warning; a --prune run reverts it in its GC and exits 0." The scan --prune paragraph, leg (b): "EVERY ledger entry whose dependency is no longer in the lockfile graph is reverted … a missing or undeterminable lockfile keeps the entry".

Here the lockfile (requirements.txt) is present and readable, and the dependency is plainly gone from it, or pinned to another version. Expected: no re-vendor, vendor_ledger_entry_unwired, exit 0; and --prune reverts the entry, exit 0. Actual: the removed package gets re-added (A), or the run exits 1 forever (B), and --prune reverts nothing.

Keeping an entry when the probe can't decide is a reasonable fail-safe for the GC. The problem is that the discovery supplement turns that "keep" into "re-discover and re-vendor", which actively re-wires the project.

OS × version

OS pip / Python Case A (removal) Case B (bump)
Linux 26.2.1 / 3.13 reproduces reproduces
macOS / Windows — not probed (OS-independent: discovery and text logic, no path handling involved) not probed

First bad version

Not bisected. The npm- and cargo-only probe set comes from #543 (pinned by the unit test in_use_probe_is_none_for_unprobed_ecosystems, crates/socket-patch-cli/src/commands/vendor.rs:5374, which lists pypi). PyPI has never had a probe.

Suspect code

  • crates/socket-patch-cli/src/commands/vendor.rs:260 dispatch_in_use_one: it has no "pypi" arm, so it returns None.
  • crates/socket-patch-cli/src/commands/scan/discovery.rs:205 vendored_ledger_supplement: it treats None the same as Some(true) and supplements the entry.
  • The prune leg (vendor.rs:3494) has the same None → keep behaviour.

The other PyPI vendored flavors (Poetry, Pipenv, uv, Hatch, PDM) go through the same dispatch and probably behave the same way. I haven't verified that; it's for their routines.

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