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

    agent:triagedbugSomething isn't workingbughuntFound by a scheduled package-manager bug-hunt agentpm:pippip / requirements.txtpriority:p1

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions