[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.
[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-modeapplycreates that directory withcreate_dir_all(crates/socket-patch-core/src/patch/apply.rs:478).rollbackandremoveundo the file with a singleremove_file(crates/socket-patch-core/src/patch/rollback.rs:500), but they never remove the directories thatapplycreated.In a Python site-packages directory, that matters: since PEP 420, an empty directory on
sys.pathis an importable namespace package. After a "successful" rollback:import six_safestill succeeds (six_safe.__path__is a_NamespacePath);importlib.util.find_spec("six_safe.sub")still finds the nested directory;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,
rollbackrow).A related point: files added by a patch are not listed in
.dist-info/RECORD, sopip uninstallleaves 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)
socket-patch remove pkg:pypi/six@1.16.0 --offlineleaves the same tree.pip uninstall -y six(before or after) leavessix_safe/too.Expected vs actual
applycreated only for patch-added files should be removed once it's empty, deepest first. That includes a__pycache__/holding only bytecode for the deleted.pyfiles.applycreated stays, and Python imports it as a namespace package. Exit 0, no warning.Matrix (Linux, main
045d7ec)six_safe/__init__.py)six_safe/sub/__init__.py)removemacOS 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 callsremove_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.pycas empty.