Skip to content

Agent-mode apply of a same-size PyPI patch in the same second as pip install leaves pip's __pycache__ bytecode valid, so Python keeps running the unpatched code while apply reports it patched #819

Description

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

Summary

By default pip byte-compiles every installed .py file into __pycache__/*.pyc. Without SOURCE_DATE_EPOCH, those .pyc files are timestamp-validated: CPython reuses the bytecode as long as the source file's mtime in whole seconds and its size match the values in the .pyc header.

Agent-mode apply replaces the source by stage + rename (apply_file_patch_at, crates/socket-patch-core/src/patch/apply.rs:422). It doesn't touch __pycache__. Suppose the patched file is the same size as the original (a flipped constant or operator, or a same-length string), and apply runs within the same wall-clock second as the install, as it does in pip install -r requirements.txt && socket-patch apply for packages extracted near the end of the install. Then the .pyc header still matches, and every later import runs the unpatched bytecode.

Meanwhile:

  • the patched source is on disk;
  • apply reports Patched packages: pkg:pypi/six@1.16.0;
  • a re-run says (already patched), so nothing ever repairs it;
  • anything that hash-checks the source (verification, vex) treats the package as patched.

The stale .pyc stays in place until the source's mtime changes again. The same applies in reverse to rollback of such a patch: the source is restored, but the patched bytecode keeps running.

Impact

The fix silently doesn't take effect at runtime, with no warning. Conditions: pip's default byte-compilation, a same-size patch, and apply within the same second as the install. A size-changing patch in the same second is fine (control below), and so is a same-size patch applied in a later second.

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

# Stage .socket/manifest.json + blobs for pkg:pypi/six@1.16.0 whose six.py patch is size-preserving
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'PATCHEDX Peterson <benjamin',1); assert len(pat)==len(orig)
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)}},"vulnerabilities":{},"description":"x","license":"MIT","tier":"free"}}},
  open('pr/.socket/manifest.json','w'))
for b in (orig,pat): open('pr/.socket/blobs/'+g(b),'wb').write(b)
EOF
python3 -m venv pr/.venv
python3 -c 'import time;time.sleep(1-time.time()%1+0.02)'      # start of a second
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"
S=pr/.venv/lib/python3*/site-packages
stat -c '%Y %s %n' $S/six.py $S/__pycache__/six*.pyc                 # same second
grep -c PATCHEDX $S/six.py                                        # 1: patched on disk
pr/.venv/bin/python -c 'import six; print(six.__author__)'       # Benjamin Peterson …  ← unpatched at runtime
pr/.venv/bin/python -X pycache_prefix=/tmp/fresh -c 'import six; print(six.__author__)'   # PATCHEDX Peterson …
socket-patch apply --offline --cwd "$PWD/pr"                      # "(already patched)"

Expected vs actual

  • Expected: after a successful agent-mode apply, the installed package runs the patched code. CLI_CONTRACT.md / README describe agent mode as applying the patch in place to the installed files. Python's import system decides what actually runs, so apply (and rollback) should invalidate the matching __pycache__/<stem>.*.pyc, or make sure the new mtime can't collide with the one in the header.
  • Actual: the source is patched, but the timestamp-validated .pyc stays valid, and the unpatched bytecode runs. Exit 0, no warning.

Matrix (Linux, main 045d7ec)

Python / pip same second, same-size patch same second, size-changing patch later second, same-size patch
CPython 3.8 / pip 20.3.4 fail (runtime unpatched) — —
CPython 3.11 / pip 26.2.1 fail (2 of 2) pass pass
CPython 3.13 / pip 24.0 fail — —

macOS and Windows weren't probed. This is CPython's timestamp .pyc rule, which is the same on every OS, and NTFS / APFS mtimes behave the same way at one-second granularity. Not affected: SOURCE_DATE_EPOCH set (pip then writes checked-hash .pyc files), pip install --no-compile, uv without --compile-bytecode, and hosted / vendored mode (the wheel is compiled from patched source).

Suspect code

  • crates/socket-patch-core/src/patch/apply.rs:422 (apply_file_patch_at): it writes the new .py with no __pycache__ invalidation. The rollback write path is the same.
  • Possible fixes: after committing a *.py file in a PyPI package, remove __pycache__/<stem>.*.pyc (and the legacy <stem>.pyc). Or, if the new mtime's second equals the old one and the size is unchanged, bump the mtime.

No probe runs: the behaviour is CPython-defined, and the sandbox can't delete probe branches.

Activity

  1. mikolalysenko commented on Oct 5, 2026

    @mikolalysenko
    CollaboratorAuthor

    [agent] Triaged as priority:p1 (PyPI/pip). No duplicate or existing fix found: nothing under crates/ touches __pycache__ on the agent-mode apply/rollback write path (only npm_crawler.rs and a uv e2e helper mention it). Root cause is standalone: the agent-mode file commit never invalidates CPython's timestamp-validated bytecode for the .py it replaces. Eligible for a fix.


    Generated by Claude Code

  2. mikolalysenko commented on Oct 9, 2026

    @mikolalysenko
    CollaboratorAuthor

    v5 triage: P2, not a release blocker. Retain same-second/same-size Python bytecode invalidation at P2. It can occur without concurrency, so do not close it as imaginary, but it is not the hosted/vendored release gate.

    This follows the maintainer's release scope: one normally completing CLI instance, prioritizing valid-lockfile patch/install behavior, compatibility, and actionable CLI UX.

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:p2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions