作者在自研变异测试框架中发现 Python 按源文件 mtime 秒级精度+文件大小判断是否重新编译,字节级相同修改会被 .pyc 缓存跳过,静默执行旧代码。
上周我发了一篇帖子,讲的是我在自己的评测工具中发现九个测量 bug,巧的是它们全都对结果有利。两位读者回复了我没有发现的东西。
两处都是关于我的 checks 而不是我的数字。我都验证了,也都修复了。但我的数字一动没动。
结果这反而成了有趣的部分。
先交代一下背景。工具在一个临时副本里对源文件做变异,然后跑测试套件来验证。为了证明变异确实能到达解释器,我设置了一只"金丝雀":用无法解析的垃圾内容覆盖模块,然后断言套件应该失败。
Vinh Nguyen 指出,CPython 的默认字节码失效机制以源文件的秒级 mtime 加上源文件大小作为 key。一个把 < 翻成 > 的变异前后的字节大小完全相同。如果一个过期的 .pyc 文件恰好有匹配的头部,Python 会跳过重新编译,直接导入原始字节码。
变异写到了磁盘。执行的是原始行为。测试套件通过了。变异体被记录为存活。
这就是我的金丝雀无法捕获的原因——无法解析的垃圾内容字节长度不同,所以 .pyc 确实会失效,金丝雀确实会触发,报告成功。它回答的根本不是我问的问题。
我先检查了机制:
# 从这个编译出一个 pyc
a == b
# 用字节大小完全相同的变异覆盖,把 mtime 强改回去
a != b
# import:过期字节码执行,原始比较运算运行
第一次尝试就复现了。
然后我检查了这是否影响我的工具。没有。每一个临时副本调用点都传了 ignore_patterns("__pycache__", "*.pyc", ...),所以根本没有 .pyc 会进入副本。我通过复制一个带有 19 个真实 .pyc 文件的目标来验证,确认零个到达。
这部分我一直在想。我写那行代码是为了避免复制垃圾。结果它默默地承担了正确性的工作,而我在有人指出机制之前完全不知道。
一个工具之所以正确的原因,往往不是你写那段代码的原因。一行为了整洁加上去的代码,暗地里做着安全方面的工作——这行代码会在某次清理中被删掉,而删除的人完全不知道它一直在撑着什么东西。
我在原来的金丝雀旁边加了一个保持字节大小的金丝雀。十二个目标中有八个符合条件,全部通过。其余四个报告 N/A,而不是默默跳过。
Ahmet Özel 从另一个方向切入。他读了我原文后的结论是:"调试由惊讶触发"这一解释了在没有人故意造假的情况下单向偏差,这就是我在论证的观点。
他的反制措施是我没有的:保持一个刻意的负向控制。一个分数应该接近零的情况。
在工具的其他地方,高是好的,低促使调查。在负向控制上,高才是警报。这就给你一个地方,在这个地方一个讨喜的失败才是令人惊讶的——而这正是调试可靠触发的唯一条件。
他还提出了一个更锐利的关于预注册的论点,我确实在做预注册。写下你预期的数字,在你错了的时候有帮助。当仪器以一种产生你预测数字的方式损坏时,它毫无作用。
我原始九个中的一个恰恰就是这种情况。一个分类器误把 self.assertEqual 读成"没有断言",而我当时正在检验模型会写没有断言的测试这个假设。它本来会把我的预测连同确认一起递给我。
所以我建了这个控制组。一组刻意的空测试。导入模块,调用函数,不做有意义的断言。我预期接近零。
它干掉了 51 个变异体中的 7 个。13.7%。
这不是 bug。assert x is not None 是一个真实的检测器,只是非常狭窄。它只捕获一类变异:原本返回 None 的函数变成了不返回 None。全部七次击杀都是那个操作符。零次常数变异,零次比较变异。
所以一套空测试有一个非零的底数,而不是零。这是我评分系统的一个校准事实,如果那个控制组干净地回来了,我永远学不到这个。
设计约束是实际的部分,而且我第二次尝试才做对。负向控制必须在它因为无聊的原因通过时大声失败。接近零的分数也是模块从未加载时得到的东西——而这恰恰是我上篇文章中 bug 1 产生的结果。三个目标 0.000 分,看起来像是一个发现而不是一个故障。
所以控制组分别断言:套件在干净源码上通过、模块实际导入并执行了(覆盖确认,37 行)、以及两个金丝雀仍然触发。
Vinh 在我描述了修复之后又找回来,发现了一个漏洞。
我的回归测试断言临时副本中 pyc_count == 0。但设置了 PYTHONPYCACHEPREFIX 后,字节码会进入一个以副本绝对路径为 key 的中央目录树。零个 pyc 文件到达副本,而过期的读取仍然会发生。
我的测试会在工具撒谎的时候通过。
他跑了三个分支来隔离真正的依赖关系:
无 prefix,固定 work path:新鲜
有 prefix,相同固定 work path:过期,副本中零个 pyc
有 prefix,每个变异体独立目录:新鲜
我复现了全部三种。真正保护我的是第二个意外:每一个副本调用点都用了 tempfile.TemporaryDirectory(),它给每个变异体一个独立路径,也就是第三个分支。我在每一个调用点都验证了这一点,并在设置了 prefix 的情况下跑了完整的十二目标扫描。它与提交的结果字节对比无差异。
两个意外的防护。都不是因为它工作的原因而写的。ignore_patterns 是为了避免垃圾。TemporaryDirectory 是因为它会自动清理。现在都加了注释标记为承担正确性功能的,并明确标注了环境变量名字,因为任何添加 --work-dir 参数的人都会移除其中之一,而没有办法知道。
这条规则是我从整个交流中得到的最有用的东西。
我的测试断言了一个代理。Pyc 数量是一个代理。"观察到的行为实际改变了"才是属性。代理在我的配置下成立,在支持的环境变量下失败。属性断言成本相同,但没有那个失败模式。
所以我重写了测试,让一个保持字节大小的变异体通过真实代码路径,然后断言观察到的结果不是"存活"。我加了一个元测试来复现过期分支,以证明这个检查有牙齿。一个从未有人见过失败的回归测试和金丝雀最初无法检测到这个问题是同一类别的东西。
在那里的时候我关闭了一件我推迟过的事:手工标注智能体无法杀死的九个变异体。七个可证明是等价的,其中六个是 TYPE_CHECKING 块内的类型提示变异,运行时从不求值。两个是真正的漏杀,追溯到智能体测试了错误的调用模式和错误的函数。所以声明从"53 个中 44 个,带着无限等价性附带条件"移动到了"46 个可杀中 44 个,95.7%,每个变异体的论证已发布"。
两轮。两位读者。四个检查改进。数字一动没动。
没有人质疑一个发现。所有的审视都落在了工具上,而两次工具都在我通过阅读无法察觉的方式上错了。
这就是发布工具而不是发布标题的论据。结果是一个人们可以接受或拒绝的主张。工具是人们可以攻击的东西,而攻击才是告诉你它是否有效的东西。我从两条评论线程中得到的东西比我自己的四十小时审阅还多。
断言属性,而不是代理。成本相同。少一个失败模式。
构建一个检查,在那里好的数字才是警报。在其他地方,惊讶才是触发调试的条件,而一个讨喜的 bug 永远不会让你惊讶。