fix: bytecode cache invalidation for moved test files (#14552) - #14989
RonnyPfannschmidt merged 3 commits into
Conversation
RonnyPfannschmidt
left a comment
There was a problem hiding this comment.
Thanks for starting this
Unfortunately the test doesn't validate the invariant holding
| pytester.path.joinpath("test1").rename(pytester.path.joinpath("test2")) | ||
|
|
||
| second = pytester.runpytest_subprocess("-s", "test2/test_a.py") | ||
| second.assert_outcomes(passed=1) |
There was a problem hiding this comment.
This test doesn't validate the invatiant
There was a problem hiding this comment.
I don't understand what is missing here for you.
- The tests fails without the fix.
- It reproduces the incorrect behaviour by moving the source and cached bytecode.
Which invariant do you mean?
There was a problem hiding this comment.
The test would pass without changes when one disables bytecode writing for example
There was a problem hiding this comment.
Ill show a more detailed example once I get back to the computer
There was a problem hiding this comment.
I added a check for the compiled bytecode. let me know what you think.
411751e to
bb33e5c
Compare
So @15r10nk, I have to apologize. Back when I worked in this area I entirely missed how Stale
|
| Interpreter | _imp._fix_co_filename |
Recursive | Skips foreign code objects |
|---|---|---|---|
| CPython 3.3 and later | yes (C call in the import path since 2.6 / 3.0) | yes | yes |
| PyPy 3.11.13 | yes | yes, verified | yes, verified |
Every interpreter pytest supports has it.
History: bpo-1180193, "broken pyc files"
Now python/cpython#41838.
- 2005, Armin Rigo files the issue and proposes both options in the first message:
discard the pyc whenco_filenamedoes not match, or rewrite the filenames on load. - 2007, Martin von Löwis objects that shipping only pycs is legitimate. That rules
out the discard option: it would refuse a valid sourceless pyc. - 2007, Armin Rigo, on why the fixed pyc must not be written back: "the same .py
files are accessed from what appears to be two different paths, e.g. over NFS. This
would cause .pyc files to be rewritten all the time. Two python processes trying to
write different data to the same .pyc file at the same time are going to create a
mess." - January 2009, Antoine Pitrou commits Jean-Paul Calderone's patch to 2.6, 2.7, 3.0
and py3k: "safer and simpler not to rewrite the pyc file when the filenames have been
changed." - 3.3: exposed as
_imp._fix_co_filenamewhen importlib became the import system.
Frank's #14551 comment cited this issue. The review did not follow the link.
Relationship to hash-based pycs
The hash and the filename answer different questions, and importlib applies both, in
this order:
- The source hash answers "is the content current?" PEP 552 flags word bit 0 marks
a hash-based pyc, bit 1 marks it as checked.SourceLoader.get_codevalidates the
hash unless the interpreter runs with--check-hash-based-pycs never. - The filename fix answers "where does this content live now?" After the hash
passes,marshal.loads, then_fix_co_filename.
pytest has been at step 1 since #14905: _write_pyc_fp writes flags 0b11, checked
hash, and _read_pyc validates the hash itself. The "file content and runtime state
diverge" concern raised in #14551 is fully covered by that hash. The only path-bearing
state in a rewritten pyc is co_filename. The agent checked the rewriter: the module
path is used only to locate a warning at rewrite time (AssertionRewriter, the
tuple-assertion warning) and is never embedded in the generated code.
The interaction that matters is with #14905 itself. Its purpose was a restored CI cache
surviving a fresh checkout. #14989 turns that cache into a total miss whenever the
checkout path differs between builds, which is the normal case on Jenkins and GitLab
runners.
Measurements
Cost per load
testing/test_assertrewrite.py, 126 KiB marshaled, 209 code objects, CPython 3.10.19,
best of three:
| Path | Per load |
|---|---|
marshal.loads alone |
0.41 ms |
marshal + _imp._fix_co_filename |
0.42 ms |
marshal + pure-Python code.replace recursion (#14551 as submitted) |
1.05 ms |
recompile via _rewrite_test (#14989 on a mismatch) |
157.85 ms |
The C fixer is measurement noise. The pure-Python version is still trivial. Recompiling
is about 375 times slower.
Rewrite churn under #14989
Same file reached through two spellings, a real directory and a symlink to it, running
pytest real/test_a.py and pytest link/test_a.py alternately on the #14989 branch
(CPython 3.14): the pyc's mtime changed on every single run, six out of six. Each run
paid the full recompile and rewrote the cache. With the #14551 design the cache is
reused and only the in-memory names change.
Test behaviour
| Test version | With fix | Without fix, bytecode on | Without fix, PYTHONDONTWRITEBYTECODE=1 |
|---|---|---|---|
| #14989 first push | pass | fail | pass |
| #14989 current head | pass | fail | fail |
The review remark that the first test would pass with bytecode writing disabled was
correct for that push. The current head fixed it by deleting the variable and asserting
that the pyc exists before and after the move.
Recommended implementation
Reach for the _imp implementation when it exists, fall back to the pure-Python
rebuild from #14551 otherwise, and never write the pyc back:
try:
from _imp import _fix_co_filename
except ImportError: # pragma: no cover
_fix_co_filename = None
def _replace_code_filenames(co: types.CodeType, filename: str) -> types.CodeType:
"""Pure-Python fallback: rebuild the code object tree with *filename*."""
return co.replace(
co_filename=filename,
co_consts=tuple(
_replace_code_filenames(c, filename) if isinstance(c, types.CodeType) else c
for c in co.co_consts
),
)
def _fix_code_filename(co: types.CodeType, filename: str) -> types.CodeType:
"""Point *co* and its nested code objects at *filename*.
Mirrors what importlib does for every pyc it loads: the cache stays valid,
only the in-memory location is corrected.
"""
if co.co_filename == filename:
return co
if _fix_co_filename is not None:
_fix_co_filename(co, filename) # in place, recursive, C
return co
return _replace_code_filenames(co, filename)In _read_pyc, after the isinstance(co, types.CodeType) check:
return _fix_code_filename(co, str(source))Test
The current test proves the symptom is gone but never looks inside the pyc. A direct
check of the invariant, in addition to the existing subprocess runs:
- After the first run, open the pyc, skip the 16-byte header,
marshal.load, and
assert the storedco_filenameis the original path. This documents why the test
exists. - Move the directory, run again, assert the test passed and that the pyc's mtime
did not change. This proves the fix happened in memory and the cache was reused.
What was and was not verified
- Verified: C semantics by reading
import.candcodeobject.con the 3.14 branch;
recursion and foreign-code skipping on CPython 3.10 and PyPy 3.11.13; the timing
table; the churn experiment; the test matrix. - Not verified: whether typeshed's
_impstub declares_fix_co_filename. If mypy
objects, a narrow# type: ignore[attr-defined]on the import is the fix. - Not measured: PyPy timing. On PyPy the fallback is never taken, so it does not matter
for the recommendation.
|
meh - claude had to mess up the import naming suggestion 😮💨 |
|
I also did not know about _imp._fix_co_filename. I changed this pr. I hope this is ok. I also used AI for parts of the implementation. I hope this is ok. |
|
Please ensure the attribution |
…d use a pure python implementation as fallback Co-authored-by: Grok Build <grok@x.ai>
03f89b7 to
3261273
Compare
Backport to 9.1.x: 💔 cherry-picking failed — conflicts found❌ Failed to cleanly apply f8ad1f6 on top of patchback/backports/9.1.x/f8ad1f6f95307ed5b7da9b73f1c585695bf2b51a/pr-14989 Backporting merged PR #14989 into main
🤖 @patchback |
|
thank you, should I backport it? |
|
that would be awesome, thanks |
|
I have problems with the backport. |
|
problem solved ... :-) |
solves #14552