Build1 publisher3 min readPublished
CPython's mtime-plus-size cache key hides a byte-identical mutation from the test suite
A reader pointed out that flipping < to > leaves source size and whole-second mtime unchanged, so Python can import the original bytecode and log the mutant as surviving. The unparseable-garbage canary changes the file size, invalidates the cache and reports success.
The Engineer · Build desk

What happened
- Vinh Nguyen told the harness author that CPython's default bytecode invalidation keys on source mtime in whole seconds plus source size, so flipping < to > leaves both header fields untouched.
- With a matching stale .pyc present, the mutant sits on disk while the original bytecode executes, the suite passes, and the mutant is recorded as surviving.
- A deliberately vacuous test suite, added as a negative control, killed 7 of 51 mutants for a 13.7 percent floor where the author had expected near zero.
Compiled by The EngineerSomething wrong?How this is made
Why it matters
- exposure Any harness that mutates a file in place beside an existing __pycache__ can log false survivals, and the error runs one way: measured kill rates come out lower than the tests deserve.
- constraint The new byte-size-preserving check reaches eight of twelve targets, leaving four at N/A, so a third of the harness still has no detection for this failure mode.
- decision The ignore_patterns argument was added to skip junk and is what keeps the results honest; deleting it in a cleanup now trips a canary on eight targets instead of quietly moving numbers.
- capability The 13.7 percent floor recalibrates every score the harness reports, because a generated suite below that number is indistinguishable from tests that assert nothing.
The import path is short enough to audit by hand. Python reads the header of the cached `.pyc` before it compiles anything, and that header holds the source mtime in whole seconds plus the source size [1]. If both fields match, the cached bytecode loads and no compile happens [2]. Flipping `<` to `>` changes neither field [1].
Three conditions have to hold together for that to reach a mutation run: a `.pyc` sitting beside the source, an edit that preserves byte size, and an mtime seconds field matching the header's. The author reproduced it by compiling a pyc from `a == b`, overwriting the source with `a != b` and forcing the mtime back; the original comparison ran on the first try [5]. Forcing the timestamp by hand is not the only route there. A harness that writes its mutant inside the same second the pyc was compiled meets the timestamp condition for free [3].
The old canary was blind to all three conditions by construction. It overwrote the module with unparseable garbage, and garbage has a different byte length, so the pyc really did invalidate, the suite really did fail, and the check reported success [4].
Direction matters if you read kill rates off a tool like this. A mutant that runs original bytecode passes the suite and is filed as surviving [3]. A false survival counts as a mutant the tests missed, so the measured kill rate lands below what the tests earn [2]. The nine bugs in the author's earlier post all leaned the other way, in favour of his results [18].
None of it reaches the harness in question. Every temp-copy call site already passes `ignore_patterns("__pycache__", "*.pyc", ...)` [6], and a target with 19 real pyc files copied across with zero of them arriving [7]. That argument was written to avoid copying junk. "The reason an instrument is correct is often not the reason you wrote the code that makes it correct," the author wrote [8]. The fix is a second canary that preserves byte size, running alongside the original: eight of twelve targets are eligible and all pass, and the remaining four report N/A instead of being skipped quietly [9].
The second reader finding came from Ahmet Ozel [19], who suggested keeping a deliberate negative control, a case where the score should be near zero and a high score is the alarm [10]. His reading of the earlier post was that "debugging is triggered by surprise" explains a one-directional bias without anyone needing to be dishonest [13]. The author built the control out of vacuous tests that import the module, call things and assert nothing meaningful, and it killed 7 of 51 mutants, 13.7 percent [11]. All seven kills came from the operator that makes a function return None; the suite caught zero constant mutations and zero comparison mutations [12].
So the single-character comparison flip is both the mutation that preserves byte size [1] and the mutation a suite asserting nothing never catches [12]. A control also has to fail loudly when it passes for a boring reason, because 0.000 is what you score when the module never loaded, and one of the original nine bugs produced three targets at 0.000 that read as a finding [16]. The control now asserts separately that the suite passes on clean source [17].
Ozel's other point lands on a practice the author was already following. Writing down an expected number, he argued, "does nothing when the instrument is broken in a way that produces the number you predicted" [14]. One of the nine was exactly that: a classifier read `self.assertEqual` as "no assertion" while the hypothesis under test was that models write assertion-free tests [15].
What to watch
- Whether the four targets currently reporting N/A ever get a byte-size-preserving canary, or stay uncovered for this failure mode.
- Whether the 13.7 percent vacuous floor moves when the mutant set's operator mix changes, since all seven kills came from one operator.
- Whether other mutation-testing tools mutate in place beside an existing __pycache__ instead of copying into a pyc-free directory.