Skip to content

Agent-mode rollback / remove of a PyPI patch that added a file in a new directory leaves the empty directory in site-packages, so Python still imports it as a namespace package #838

Description

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

Summary

A patch can add files (manifest entries with an empty beforeHash). When such a file lives in a directory that didn't exist before, agent-mode apply creates that directory with create_dir_all (crates/socket-patch-core/src/patch/apply.rs:478). rollback and remove undo the file with a single remove_file (crates/socket-patch-core/src/patch/rollback.rs:500), but they never remove the directories that apply created.

In a Python site-packages directory, that matters: since PEP 420, an empty directory on sys.path is an importable namespace package. After a "successful" rollback:

  • import six_safe still succeeds (six_safe.__path__ is a _NamespacePath);
  • importlib.util.find_spec("six_safe.sub") still finds the nested directory;
  • if the import happened before the rollback, six_safe/__pycache__/ is left behind too.

Both commands exit 0, and the manifest entry is dropped, so nothing ever cleans up the leftover. The tree isn't back in its original state, even though the contract says rollback "restore[s] original files" (crates/socket-patch-cli/CLI_CONTRACT.md, rollback row).

A related point: files added by a patch are not listed in .dist-info/RECORD, so pip uninstall leaves them too (along with their directory). Before a rollback they stay importable with their content after the package itself has been uninstalled.

Impact

Code that detects an optional module or a feature by importing it (try: import x except ImportError, find_spec) sees a package that isn't there anymore. Stray directories also pile up in site-packages. This only affects patches that add files under a new directory. Hosted and vendored modes aren't affected, because there pip installs or uninstalls the whole wheel.

Repro (Linux, real pip install, offline manifest staging)

pip download -q --no-deps six==1.16.0 -d dl
python3 - <<'EOF'
import hashlib,json,os,zipfile
orig=zipfile.ZipFile('dl/six-1.16.0-py2.py3-none-any.whl').read('six.py')
pat=orig.replace(b'Benjamin Peterson <benjamin',b'PATCHED Peterson <benjamin',1)
new=b'SAFE = True\n'
g=lambda b: hashlib.sha256(b'blob %d\0'%len(b)+b).hexdigest()
os.makedirs('pr/.socket/blobs',exist_ok=True)
json.dump({"patches":{"pkg:pypi/six@1.16.0":{"uuid":"5c3e1a2b-7d4f-4e6a-9b8c-1d2e3f4a5b6c","exportedAt":"2026-01-01T00:00:00Z",
  "files":{"six.py":{"beforeHash":g(orig),"afterHash":g(pat)},"six_safe/sub/__init__.py":{"beforeHash":"","afterHash":g(new)}},
  "vulnerabilities":{},"description":"x","license":"MIT","tier":"free"}}},open('pr/.socket/manifest.json','w'))
for b in (orig,pat,new): open('pr/.socket/blobs/'+g(b),'wb').write(b)
EOF
python3 -m venv pr/.venv && pr/.venv/bin/pip install -q --no-index dl/six-1.16.0-py2.py3-none-any.whl
socket-patch apply --offline --cwd "$PWD/pr"            # 1 of 1 applied
socket-patch rollback --offline --cwd "$PWD/pr"; echo $? # 0
(cd pr/.venv/lib/python3*/site-packages && find six_safe) # six_safe  six_safe/sub   <- left behind
pr/.venv/bin/python -c 'import six_safe, importlib.util as u; print(six_safe.__path__, u.find_spec("six_safe.sub"))'
# _NamespacePath([...site-packages/six_safe]) ModuleSpec(name='six_safe.sub', ...)   <- still importable

socket-patch remove pkg:pypi/six@1.16.0 --offline leaves the same tree. pip uninstall -y six (before or after) leaves six_safe/ too.

Expected vs actual

  • Expected: rollback / remove return the package directory to its pre-apply state. Per CLI_CONTRACT.md, rollback "restore[s] original files". Any directory that apply created only for patch-added files should be removed once it's empty, deepest first. That includes a __pycache__/ holding only bytecode for the deleted .py files.
  • Actual: the file is deleted, but every directory apply created stays, and Python imports it as a namespace package. Exit 0, no warning.

Matrix (Linux, main 045d7ec)

Python / pip top-level new dir (six_safe/__init__.py) nested new dir (six_safe/sub/__init__.py) remove
CPython 3.11 / pip 26.2.1 fail (dir left, importable) fail —
CPython 3.13 / pip 26.2.1 fail fail fail
CPython 3.14.0rc2 / pip 26.2.1 fail fail —

macOS and Windows weren't probed. The rollback delete path is OS-independent, and PEP 420 namespace-package import behaves the same on every OS. Not bisected.

Suspect code

  • crates/socket-patch-core/src/patch/apply.rs:478: create_dir_all(parent) materialises missing parents for a new file, without recording which directories it created.
  • crates/socket-patch-core/src/patch/rollback.rs:500: the new-file rollback path only calls remove_file. It should prune parents that are now empty, up to (but not including) the package directory / site-packages root, and treat a __pycache__ holding only that file's .pyc as empty.

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