Repository navigation
资源在其清理处理器建立之前被获取,中断会泄漏描述符、临时文件、临时目录与子进程 #412
Description
结论摘要
本仓库存在一类缺陷:资源在负责释放它的处理器建立之前就已经被获取,中间那段窗口是无主的。资源不限于文件描述符——普查后确认还包括临时文件、临时目录和子进程。该缺陷类在 CPython 上游有权威记录(PEP 419、bpo-29988 / cpython#74174),上游从未彻底消除、最终选择文档化(bpo-42340),标准库自身也中招过(cpython#106238 的 logging._acquireLock())。
三句话结论:
- 最严重的一处不在最初报告范围内:
pyfcstm/_selfcheck/registry.py的_run_subprocess_bounded整个函数没有任何finally,实测 28 个注入点中 14 处留下孤儿子进程。孤儿进程与描述符不同,不随父进程退出被操作系统回收。 - 三轴普查在当前
main上确认 9 处真实缺陷,分布在 6 个函数中;另有 16 处获取点判定为已合规或设计如此。最初报告点名的是 3 处站点、共 4 处获取点,仅覆盖其中一小部分。 - 唯一有稳定用户可见症状的是
pyfcstm/entry/bmc.py的write_bmc_output(中断后输出目录残留.result.json.*.tmp),其成因与字节码细节无关,是普通的except范围过窄。 - 本 issue 的验收包含一项硬性条件:第七节的维护纪律须写入
CLAUDE.md的「Exception Handling Policy」小节——第 1 至 6 条与第 8 条作为规范性条目逐条写入,第 7、9 条作为方法学简述加链接收录(见第八节第 9 项),缺少它则本 issue 不得关闭。
一、缺陷类:三条轴
这一类缺陷有三条独立的轴,触发条件、版本范围和可达手段都不同,必须分开讨论,混谈会得出错误的优先级。
| 轴 | 无主窗口在哪 | 版本范围 | 真实信号可达性 | 逐行注入可达 |
|---|---|---|---|---|
| 轴一 获取先于清理登记 | 从资源被获取,到释放它的 try 被进入(含 try: 行本身) |
全版本 3.7–3.14 | 合成用例:3.7–3.10 可观测、3.11+ 近似为 0。真实裸 os.open:全版本均为数十个百分点,3.11+ 不低于 3.7–3.10;真实 tempfile.mkstemp:未能可靠测量(见 3.4 节) |
是 |
轴二 清理只写在 except、无 finally |
从获取到函数结束的全部非匹配异常路径 | 全版本,与字节码无关 | 是,且与中断时刻无关——任何不匹配 except 类型的异常都会跳过清理 |
是 |
轴三 持有资源时进入嵌套 try |
内层 try: 那一行 |
仅 3.11+ | 否,全版本 0 次 | 是,仅 3.11+ |
轴一是朴素的:资源已经存在,而清理还没登记。轴二与字节码细节完全无关,是普通的 except 类型范围过窄——KeyboardInterrupt 不属于 OSError,于是根本不进清理分支。轴三反直觉:外层 try/finally 明明已经生效,却因为在持有资源期间进入一个内层 try 而重新打开了窗口。
最初报告只描述了轴一与轴三,没有轴二。 而轴二恰好覆盖了本仓库两处最严重的缺陷。
二、上游证据
2.1 机制本身有官方文档
CPython 自 3.11 起把异常处理改为「零开销」实现(bpo-40222 / cpython#84403,PR #25729):SETUP_FINALLY 与 POP_BLOCK 降级为伪指令,保护关系不再由运行时块栈维护,而是编译进 co_exceptiontable,只在异常真正发生时才查表。
InternalDocs/exception_handling.md 明确说明了空隙的来源:"Instructions which are not covered by any exception handler within the same code object's bytecode, do not appear in the exception table at all." 即不被任何处理器覆盖的指令根本不进表,表里出现「空隙」是设计如此。
而 try: 那一行恰好留下了一条不进表的指令。PR #25729 的正文中实现者 Mark Shannon 本人写道:"This is not quite zero-cost at the moment, as it leaves a NOPs for each try, and possibly a few other."(原文如此),原因是把这个 NOP 也去掉需要改动行号表。由于 NOP 不可能抛出异常,编译器合法地把它排除在异常表之外——这是一个被上游解释为「设计如此」的空隙,不是缺陷。
2.2 缺陷类在上游有权威记录,且从未彻底消除
点开查看上游证据逐条汇总(7 条)
| 来源 | 内容 |
|---|---|
| PEP 419(状态:Deferred) | 最早系统描述该类问题:程序可能在 open() 调用之后、SETUP_WITH 字节码执行之前被中断;finally 与上下文管理器都不受 KeyboardInterrupt 及 generator.throw() 引发的 GeneratorExit 保护。「获取之后、登记清理之前」这一表述来源于此,而非 bpo-29988 |
| bpo-29988 / cpython#74174 | 标题为 with statements are not ensuring that __exit__ is called if __enter__ succeeds。由 trio 作者 Nathaniel Smith 提出。需要注意其原始描述针对的是退出侧的窗口(POP_BLOCK 到 WITH_CLEANUP_START 之间),并指出进入侧已由 SETUP_WITH 保证原子——该原子性说明只针对同步 with,同一贴中指出 async with 的进入侧并不原子。该 issue 后来已按修复关闭 |
| PR #18334 | Mark Shannon 的缓解措施:只在调用返回之后和后向边上检查 eval_breaker。commit message 写明目的是保证 with / async with 在被中断时仍会调用 __exit__ / __aexit__。合入时间 2021-03-24,落在 Python 3.10(3.10.0a7),不是 3.11 |
| bpo-42340 | 上游最终处置是文档化而非消除:记录「在某些情况下 KeyboardInterrupt 可能使代码进入不一致状态」,并给出规避范例 |
| cpython#106238 | 标准库中的真实案例:logging._acquireLock() 中招,SIGINT 导致锁被遗弃,在高频 fork 场景下使进程挂死 |
| mypy#13104 | 类型检查器也踩过同一语义:mypy 假设执行不会被异步中断,于是在 finally 子句里误报「不可达代码」 |
| Control-C handling in Python and Trio | 对该类问题的系统性讨论,其中给出「只能在解释器内部修复」的判断 |
一条对本 issue 定性至关重要的上游说明:f_trace_opcodes 是专门为 bpo-29988 的测试用例而加的,用途是「在任意字节码偏移之后注入异常」。这句话出自 Nick Coghlan 在 PR #18334 上 2021-01-27 的评论。也就是说,逐行/逐指令注入这一手段,其设计意图就是故意越过真实信号的投递限制,因此用它测出的窗口不能直接等同于真实 Ctrl-C 的风险。
三、本地实测
以下全部在 pyenv 的 8 个真实解释器上运行:3.7.1 / 3.8.1 / 3.9.1 / 3.10.1 / 3.11.1 / 3.12.1 / 3.13.1 / 3.14.1,覆盖本仓库声明的完整兼容包线(python_requires=">=3.7")。
测量范围限制: 全部测量在 Linux 上运行,第 3.3 节依赖 signal.setitimer 与 SIGALRM,二者在 Windows 上不存在。本仓库 CI 矩阵为 ubuntu-22.04 / windows-2022 / macos-14 × 3.7–3.14;Windows 的 SIGINT 由 C 运行时的辅助线程投递,其检查点分布与本文测量的 Unix 路径不同,本 issue 未覆盖。自由线程构建(3.13t / 3.14t)与 PyPy 同样未覆盖,视为超出范围。
3.1 异常表空隙确实存在(轴三的机制)
被测代码:
def case_B():
try:
x = 1
try: # <- 内层 try
pass
except OSError:
raise
finally:
x = 2该行编译结果与异常表覆盖情况:
Python 3.10.1
内层 try: 那一行 -> SETUP_FINALLY to 12 (真实指令,运行时压入处理器)
co_exceptiontable: 无
Python 3.11.1
内层 try: 那一行 -> NOP 偏移量 8
异常表: [4,8)->52 [10,12)->52 [12,36)->36 [36,42)->52 [52,60)->60
偏移量 8 被异常表覆盖: 否 —— 无处理器
Python 3.14.1
内层 try: 那一行 -> NOP 偏移量 8
异常表: [4,8)->52 [20,46)->46 [46,52)->52 [52,60)->60
偏移量 8 被异常表覆盖: 否 —— 无处理器
3.11 上 [4,8) 左闭右开、到 8 即止,下一条目从 10 开始,偏移量 8 落在空隙里;3.14 上更明显,[4,8) 之后直接跳到 [20,46)。所以在那一行抛出的异常查不到任何处理器——内层 except 不接,外层 finally 也不接。
作为对照,把内层 try 换成 with contextlib.suppress(OSError): 后,同一个偏移量 8 上的首指令是 LOAD_GLOBAL(真实指令),受异常表保护(3.11.1 指向 128,3.14.1 指向 140)。这就是「需要处理器时用 with 而不用嵌套 try」这条处方的依据。
3.2 逐行注入:3.11 是轴三的分界线
在指定行抛 KeyboardInterrupt,观察清理是否执行:
情形 A:顶层 try/finally,注入在 try: 行
情形 B:外层 try/finally 已生效,注入在内层 try: 行
Python 3.7.1 | A 清理执行=False | B 清理执行=True
Python 3.8.1 | A 清理执行=False | B 清理执行=True
Python 3.9.1 | A 清理执行=False | B 清理执行=True
Python 3.10.1 | A 清理执行=False | B 清理执行=True
Python 3.11.1 | A 清理执行=False | B 清理执行=False
Python 3.12.1 | A 清理执行=False | B 清理执行=False
Python 3.13.1 | A 清理执行=False | B 清理执行=False
Python 3.14.1 | A 清理执行=False | B 清理执行=False
情形 A 各版本一致,符合预期(try: 那一行还没进入保护块)。情形 B 在 3.11 出现行为变化,与 3.1 节的异常表空隙对应。
3.3 真实异步信号:合成用例的结果与注入法相反
改用真实异步信号(SIGALRM,与 SIGINT 共用同一套投递机制)代替注入,每个版本 5000 轮,并把每一次泄漏关联到信号到达时的源码行。被测形状中 acquire() 为 HELD.append("r"),即一次纯 Python 操作、不含调用边界——这一点在 3.4 节至关重要。
| 版本 | 轴一 总泄漏 | 其中落在获取后到 try 之间 |
其中落在 finally 内部 |
轴三 总泄漏 | 其中落在内层 try: 行 |
|---|---|---|---|---|---|
| 3.7.1 | 2299 / 5000 | 531 | 1201 | 272 / 4999 | 0 |
| 3.8.1 | 1831 / 4998 | 570 | 767 | 650 / 5000 | 0 |
| 3.9.1 | 1626 / 4998 | 437 | 628 | 727 / 5000 | 0 |
| 3.10.1 | 1651 / 4999 | 534 | 0 | 0 / 5000 | 0 |
| 3.11.1 | 0 / 5000 | 0 | 0 | 0 / 4999 | 0 |
| 3.12.1 | 0 / 4999 | 0 | 0 | 0 / 5000 | 0 |
| 3.13.1 | 0 / 4999 | 0 | 0 | 0 / 4999 | 0 |
| 3.14.1 | 0 / 4998 | 0 | 0 | 0 / 4999 | 0 |
关于这张表必须说明三点。第一,中间两列不穷尽总泄漏:还有一部分泄漏落在 acquire() 函数体内部(3.10.1 上约占其 1651 次泄漏的三分之二),表中未单列。第二,这些是单次运行的计数,存在统计波动:重复运行时 3.7.1 的「轴一 总泄漏」在约 1500–2600 之间、「finally 内部」在约 400–1200 之间浮动,因此表中数字应读作量级而非精确值。第三,3.11+ 的 0 应读作「近似为 0」:在更大样本(每版本 4 万轮)下 3.11.1 / 3.13.1 / 3.14.1 各观察到 1–2 次泄漏,均落在 acquire() 内部;而「获取后到 try 之间」这一列与轴三那一列在 3.11+ 上确实是严格的 0。
3.7–3.9 上轴三的泄漏全部落在 finally 内部以及 except 里的 raise,没有一次落在内层 try: 行。
3.4 合成用例的比率不能外推到真实获取原语
3.3 节的 acquire() 不含任何调用边界,这一点抹掉了被测机制的关键特征:PR #18334 把 eval_breaker 检查放在调用返回之后,而当获取动作本身是一次真实的 C 调用时,那个检查点恰好落在无主窗口内部。因此 3.3 节在 3.11+ 得到的 0 是合成用例的假象。
改用真实原语重测需要一个与描述符编号无关的判据。用 set(os.listdir("/proc/self/fd")) 做前后差集是错的:os.listdir 自身的目录描述符会占据最小空闲编号,恰好就是泄漏描述符会拿到的编号,从而把每一次泄漏都掩盖掉。本节改用「统计 /proc/self/fd 中 readlink 指向 /dev/null 的描述符个数」,并加入阳性对照——故意泄漏 7 个,判据须报出 7,关闭后须回到 0。8 个版本上阳性对照均有效。
一个必须记录的方法学陷阱: 两种形状若在同一进程内顺序执行且不逐轮回收泄漏的描述符,则先运行者累积的描述符会改变后运行者所处的描述符表状态,后运行的那个形状总会报出更低的比率。实测把顺序对调即可让结论反号:3.7.1 上原序得到 32.5% / 11.6%,对调后得到 28.9% / 9.3%;3.11.1 上 40.3%/10.7% 对调为 41.9%/7.8%;3.14.1 上 63.3%/35.6% 对调为 68.7%/35.7%。因此任何同进程 A/B 比较都必须交错执行、逐轮回收,并同时设置阴性与阳性对照。
由此得到两条结论。第一,真实裸 os.open 上该窗口在全部 8 个版本上都以数十个百分点被命中,3.11+ 并非「近似为 0」。 版本走向不可发布:不同测量设计给出的走向相反,因此只声明「各版本均为高比例」,不声明哪个版本更安全。第二,哨兵形状(fd = None; try: fd = acquire())没有可测的运行时收益。 改用条件概率 P(泄漏 | 中断落在获取行) 复测:3.10.1 / 3.11.1 / 3.14.1 上未修改形状与哨兵形状同为 100%;记录 frame.f_lasti 可见泄漏只发生在单一字节码偏移上——执行获取动作的那条 CALL,而该指令在两种形状中都落在无主窗口内。哨兵唯一能覆盖的是 STORE_FAST 到 try 入口之间的间隙:3.10+ 上那里根本不是检查点,3.7–3.9 上它仅一条指令宽,实测从未被命中。
tempfile.mkstemp 形状未能可靠测量,本 issue 不给出该形状的数字。 尝试用同一套方法测量时阳性对照报出 0%,说明 2 微秒定时器在第一次 mkstemp 调用完成之前就已触发,该设计无效。此处只记录机制差异:mkstemp 的大部分时间花在纯 Python 的名字生成循环里,该循环的调用返回与后向边远比裸 os.open 密集,因此上游检查点会吸收相当比例的信号——但吸收比例未经测定。
3.5 各缺陷站点的逐行注入测量
| 站点 | 结果 |
|---|---|
_selfcheck/registry.py 的 _run_subprocess_bounded |
28 个注入点,14 处留下孤儿子进程(50%)。泄漏行:289、301–308、318、320–322、330 |
entry/bmc.py 的 write_bmc_output |
11 个注入点,6 处残留 .result.json.*.tmp(行 749–754),另有 2 处裸描述符残留 |
config/_build_identity.py 的 write_build_identity_file |
28 个注入点,泄漏 5 处(行 495 / 496 / 497 / 504 / 507)。泄漏集合在 3.7.1–3.14.1 全部 8 个版本上一致;注入点计数在 3.7.1 上为 27。其中行 495 / 496 除描述符外还会残留 .build_info.*.tmp 文件 |
_selfcheck/worker.py 的 _write_frame |
10 个注入点,泄漏 2 处:行 181 与行 185。行 181 是 try:,属轴一——descriptor 在行 180 被获取、181 才进入 try,全部 8 个版本均泄漏。行 185 是 os.close(descriptor) 自身,属各版本共有的清理过程内部中断 |
_selfcheck/process.py 的 run_check_process |
结构判定已完成(finally 的覆盖关系与四处窗口宽度均已核对);端到端注入未实测,构造成本较高(需要完整的自检检查项对象与工作进程环境) |
_bootstrap.py 的 _emergency_write |
未实测。到达它需要先让 _write_fd_all(2, ...) 失败 |
四、需要纠正的三处既有表述
4.1 「3.11+ 才失败」来自注入法,不是现场报告
最初报告与 PR #389 记录的现象是「在 3.11 / 3.12 / 3.13 / 3.14 上失败,在 3.7 和 3.10 上通过」。核对该 PR 交付的守卫测试 test_no_line_of_the_write_leaks_on_an_interrupt 后可以确认:该结论来自 sys.settrace 逐行注入。 这个版本分布正是注入法的特征,与真实中断的分布不同。
两种手段暴露的是不同的东西。注入法暴露的是异常表的空隙,能命中 3.11+ 的 NOP 空隙。真实信号暴露的是 eval_breaker 检查点落在无主窗口内。因此「升级到 3.11 让代码更不安全」这一推论必须把注入可达性与真实可达性分开回答:注入一侧确实只有 3.11+ 可达;真实信号一侧不能笼统地说可达性继续下降——PR #18334 把检查点收窄到「调用返回之后 + 后向边」,对 with 是保护,但对「先获取、再 try」的形状恰恰相反,因为调用返回处就是那个无主窗口。本 issue 因此只给出分层结论,不给出「哪个版本更安全」的整体判断。
4.2 最初报告的清单不是普查结果,且遗漏了整整一条轴
最初报告明确写道其三处站点是「修 pyfcstm/diagram/api.py 时顺手注意到的」("found while closing the same defect class in pyfcstm/diagram/api.py during PR #389"),并非普查。第五节的三轴普查结果表明:最初报告覆盖 3 处站点、共 4 处获取点(其中只有 write_build_identity_file 被指名为函数,另两处仅给出 file:line),而实际有 9 处缺陷分布在 6 个函数中;遗漏的部分包括本仓库最严重的两处,以及整条轴二。
同时需要澄清一个容易混淆的点:本 issue 的发现场景是 GUI(diagram 产物写出器),但其范围从来不含 GUI。pyfcstm/diagram/api.py 那处已由 PR #389 修掉,最初报告开头即写明是「另开 issue 而不扩大那个 PR」。真正的 GUI 缺陷另有其事,例如已于 2026-08-03 关闭的 issue #409 与 issue #421,与本 issue 无关。
4.3 本 issue 已有评论的两处结论已被取代
本 issue 已有的评论记录的是更早的 3.11 视角分析,其中两点已被本正文取代,评论本身保留作为过程记录:其一,评论中「在 3.11/3.12/3.13/3.14 上泄漏、在 3.7/3.10 上通过」的版本分布来自注入法;其二,评论结尾建议的「同一个检查可用于上述三处」已被第 5.4 节否定——该判据依赖 ast.Yield,而涉及的函数均非生成器。
五、本仓库现状摸排
5.1 普查方法
对 pyfcstm/ 做三轴 AST 普查(豁免 ANTLR 生成目录)。轴一匹配 26 个获取原语,包括 os.open / os.dup / os.dup2 / os.pipe / os.openpty / os.fdopen / tempfile.mkstemp / tempfile.mkdtemp / tempfile.TemporaryFile / tempfile.NamedTemporaryFile / socket.socket / subprocess.Popen / zipfile.ZipFile 等,并区分「with 托管」与「裸获取」。轴二匹配「清理调用(unlink / close / rmtree / terminate / kill 等)出现在 except 体内,而所属 try 没有 finalbody」。轴三匹配「某个 try 的 body 内先出现获取调用,其后又出现另一个 try」。
结果:轴一命中 62 处(with 托管 37、裸获取 24、其他 1),轴二命中 5 处,轴三命中 3 处。该 62 处含内置 open、os.makedirs、threading.Lock 构造等并不需要显式释放的调用,故 62 / 37 两个数字只用于说明扫描覆盖面;真正进入人工判定的是 24 处裸获取加 1 处「在 with 块内但非托管」,共 25 处,恰为第 5.2 节 9 处与第 5.3 节 16 处之和。随后对每处裸获取自动判定「是否被带 finally 的 try 覆盖,且该 finally 内是否有释放调用」,再人工判定「是否设计如此」。
另记两条普查状态。锁:补扫 .acquire() / threading.Lock / RLock 全仓零命中,pyfcstm/ 不存在锁获取点,第七节第 6 条提到锁属前瞻性约束,不产生存量债务。套接字:registry.py:2253 与 :2259 两处为 socket.create_connection,均由 with 托管,已计入上文 37 处托管中。两条均记录为「已查」,而非未查。
最初报告的原语清单只有 7 个,缺少 subprocess.Popen、tempfile.mkdtemp 与 tempfile.TemporaryFile——第 1 处与第 3 至 6 处这五处缺陷(即两个子进程站点与 run_check_process 的全部资源)恰好落在这三个原语上,其中就包括本仓库最严重的一处。
5.2 确认为缺陷的 9 处
「无主窗口宽度」为获取行与覆盖它的 try 之间的语句行数;0 表示 try: 紧跟在获取行之后(此时窗口仍存在,即 try: 行本身)。
| # | 站点 | 资源 | 无主窗口 | 实测 | 轴 | 在最初报告内 |
|---|---|---|---|---|---|---|
| 1 | pyfcstm/_selfcheck/registry.py:282(_run_subprocess_bounded,定义在 278) |
子进程 | 函数内无任何 finally |
✅ 28 注入点 / 14 处孤儿进程 | 一(属分类中「在 with 块内但非托管」的那 1 处)+ 二 |
❌ |
| 2 | pyfcstm/entry/bmc.py:744(write_bmc_output,定义在 716) |
临时文件 + 描述符 | 函数内无任何 finally |
✅ 11 注入点 / 6 处残留 | 一 + 二 | ❌ |
| 3 | pyfcstm/_selfcheck/process.py:448(run_check_process,定义在 414) |
临时目录 | 41 个语句行 | ✗ 结构判定 | 一 | ❌ |
| 4 | pyfcstm/_selfcheck/process.py:464(同上) |
TemporaryFile |
27 个语句行 | ✗ 结构判定 | 一 | ❌ |
| 5 | pyfcstm/_selfcheck/process.py:465(同上) |
TemporaryFile |
26 个语句行 | ✗ 结构判定 | 一 | ❌ |
| 6 | pyfcstm/_selfcheck/process.py:509(同上) |
子进程 | 5 个语句行 | ✗ 结构判定 | 一 | ❌ |
| 7 | pyfcstm/config/_build_identity.py:492(write_build_identity_file) |
临时文件 + 描述符 | 1 个语句行 | ✅ 28 注入点 / 5 处泄漏 | 一 + 三 | ✅ |
| 8 | pyfcstm/_selfcheck/worker.py:180(_write_frame,定义在 173) |
描述符 | 0 | ✅ 10 注入点 / 1 处真实泄漏 | 一 + 三 | ✅ |
| 9 | pyfcstm/_bootstrap.py:301(_emergency_write,定义在 287) |
临时文件 + 描述符 | 0 | ✗ 未实测 | 一 + 二 + 三 | ✅ |
三处需要特别说明。第 1 处是本仓库最严重的一处:_run_subprocess_bounded 内两个 TemporaryFile 由 with 托管,但 subprocess.Popen 是裸获取且整个函数没有任何 finally;kill 只出现在若干 except 与超时分支里。孤儿子进程不随父进程退出被回收,性质比描述符泄漏严重。第 3 至 6 处同属 run_check_process 一个函数:其 finally(关键字在行 701)无条件清理了会话目录与两个缓冲流(stdout_spool / stderr_spool),但对子进程只走 Windows 路径——行 702 的 if job is not None 中 job 仅在行 527 的 if os.name == "nt" 分支被赋值,_finish_job 操作的是 Windows Job 对象;在 POSIX 上子进程由行 690 的 except BaseException 在行 693 调用 _terminate 清理。无论哪条路径,四处资源分别在 448 / 464 / 465 / 509 获取,全部在行 524 的 try 之前——窗口宽度 41 / 27 / 26 / 5 个语句行,是本仓库最宽的一批。第 9 处的临时文件在成功时是函数的产物(_emergency_write 返回该应急日志路径),因此清理只能覆盖失败路径。
5.3 判定为非缺陷的 16 处
| 站点 | 判定理由 |
|---|---|
pyfcstm/_bootstrap.py:274 / :276(_silence_broken_stdout) |
已是哨兵加 finally 形态(replacement = None 在 270,条件释放在 279 起的 finally)。os.dup2 另需说明:它就地覆盖描述符 1,不产生新的可释放资源,该重定向按设计必须在函数返回后继续有效 |
pyfcstm/_selfcheck/report.py:501(write_report) |
临时文件一侧已合规(temporary = None 在 497,finally 中条件 os.unlink)。残余问题与第 5.2 节第 7 处同形:描述符在 501 获取、504 才由 os.fdopen 接管,中间 1 个语句行不受任何处理器覆盖。因该窗口不残留磁盘产物、失败路径已由 finally 兜住,此处不单列为缺陷,但改造时应一并采用 6.2 节的所有权移交形状 |
pyfcstm/config/_build_identity.py:503 |
被带 finally 的 try 覆盖且 finally 内有 os.close |
pyfcstm/diagram/api.py:960 / :961(_open_standalone_window) |
被带 finally 的 try 覆盖,finally 内有释放调用 |
pyfcstm/diagram/api.py:1506 / :1532(_staging_file) |
已由 PR #389 修复,并带 AST 守卫测试 |
pyfcstm/diagram/api.py:1092(_private_viewer_directory) |
该目录存入 _PRIVATE_DIRECTORIES / _FALLBACK_DIRECTORIES 并由 atexit 钩子回收,按设计必须在函数返回后继续存在(该目录在行 1092 创建、1093–1094 才登记,中间存在一个窄窗口;命中后 atexit 钩子看不到该目录。危害有限,不改变本行判定) |
pyfcstm/entry/visualize.py:453 / :462 / :471(open_diagram_with_default_app) |
启动即忘:Popen(..., stdout=DEVNULL, stderr=DEVNULL) 后直接 return True,用途是把文件交给用户的默认应用;在 finally 里终止它反而会关掉用户的查看器 |
pyfcstm/diagram/api.py:1270(_unusable_viewer_directory) |
os.mkdir(str(path), 0o700) 创建的是查看器目录本身,按设计必须留下;FileExistsError 即首次之后的常态 |
pyfcstm/render/render.py:116 / :606、pyfcstm/template/__init__.py:214 |
os.makedirs 创建的是输出目录,按设计必须留下,不是需要释放的资源 |
本节 16 处与第 5.2 节的 9 处之间的账目关系:轴一命中的 24 处裸获取中,8 处为缺陷、16 处为非缺陷;第 5.2 节的第 1 处缺陷(registry.py:282)属于轴一分类中「在 with 块内但非托管」的那 1 处,不计入 24。
5.4 现有守卫测试无法直接推广
PR #389 为 pyfcstm/diagram/api.py 交付了 AST 守卫测试 test_no_handler_is_entered_while_the_staging_file_is_held(42 行),其判据是:找到 _staging_file 中的 ast.Yield 作为交接点,断言「创建点在 yield 之前」且「两者之间没有嵌套 try」。
该判据依赖生成器或上下文管理器形状。第 5.2 节的 9 处缺陷分布在 6 个函数中,没有一个是生成器(isgeneratorfunction 均为 False),不存在「yield 交接点」这一概念。因此把现有门禁参数化推广是不可行的,需要另写判据。
六、处理意见
本 issue 作为整体整改处理,不拆分。原因是该缺陷类跨越 area: packaging、area: bmc、area: cli 与自检子系统,逐处拆分会让同一形状在不同 PR 里得到不同修法,而维护纪律(第七节)也需要一次性确立。
排序依据是资源可回收性与窗口宽度,不是发现顺序:
_run_subprocess_bounded(第 1 处) —— 唯一泄漏不可回收资源、且函数内无任何finally的一处,实测 50% 命中。孤儿进程不随父进程退出被回收。两处子进程站点的严重性论证在两个平台上都成立。_run_subprocess_bounded是裸Popen、无任何容器;run_check_process虽在 Windows 上会把子进程并入 Job 对象,但这不改变第 6 处的窗口性质:其一,attach_process(_win32.py:138)只做CreateJobObjectW/OpenProcess/AssignProcessToJobObject,从未调用SetInformationJobObject,全仓不存在JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,因此句柄关闭并不连带回收,回收只发生在_finish_job显式调用TerminateJobObject(_win32.py:112–126)时;其二,attach_process在行 527 才执行,位于行 524 的try之内,而第 6 处的窗口是 509→524,在这 5 个语句行里 Windows 上job同样是None。run_check_process的四处(第 3–6 处) —— 窗口 41 / 27 / 26 / 5 个语句行,是本仓库最宽的一批,其中 509 也是子进程。该函数应整体重构,而非逐点打补丁。write_bmc_output(第 2 处) —— 唯一有稳定用户可见症状的一处,修法有现成范本(PR #389 在api.py中做过一遍)。- 第 7、8、9 处 —— 窗口 0–1 个语句行,最初报告点名的那批,实际最轻。
6.1 轴一的处方及其实测边界
# 有窗口:资源在 try 之前就已存在
fd = os.open(path, flags)
try:
...
finally:
os.close(fd)
# 处方:try 在什么都还没有时就进入,finally 判断是否有东西需要释放
fd = None
try:
fd = os.open(path, flags)
...
finally:
if fd is not None:
os.close(fd)该处方不降低异步中断的命中率。 按条件概率 P(泄漏 | 中断落在获取行) 衡量,3.10–3.14 上两种形状同为 100%;泄漏只发生在执行获取动作的那条 CALL 指令上,该指令在两种形状中都在窗口内(见 3.4 节)。
那么这条处方的收益在哪里? 在同步异常上:当获取语句与 try 之间还夹着别的语句时,这些语句抛出的任何异常都会确定性地泄漏资源——与信号、版本、平台都无关。本仓库的 _build_identity.py 正是这个形状(mkstemp 在 492–494,temporary_path = Path(temporary_name) 在 495,try 在 496)。实测让行 495 的 Path() 抛出异常,描述符与临时文件 100% 泄漏:
Path 调用次数=2
描述符: 3 -> 4 泄漏=True
目录残留: ['.build_info.yc2r8lqg.tmp']
窗口越宽,这一类同步异常的暴露面越大——run_check_process 的 41 个语句行意味着 41 行代码中任何一行抛出异常都会泄漏那个临时目录。采纳与否应按这一条收益来权衡,不应按「降低异步中断命中率」来权衡。
6.2 所有权移交必须显式处理
当描述符会被移交给 os.fdopen 等接管方时,哨兵必须在移交成功后立刻清空,否则 finally 会二次关闭一个可能已被复用的描述符:
fd = None
try:
fd, name = tempfile.mkstemp(...)
stream = os.fdopen(fd, "wb")
fd = None # 所有权已移交给 stream
with stream:
...
finally:
if fd is not None:
os.close(fd)本仓库需要这一形状的站点为 _build_identity.py:492、bmc.py:744,以及 report.py:501(见 5.3 节的同形说明)。
6.3 轴二的处方
清理动作应放在 finally,不应放在 except。任何按类型枚举的 except 都不会捕获 KeyboardInterrupt,而中断恰恰是原子写出路径上最常见的非正常结束方式。
write_bmc_output 有一处必须先解决的取舍:现有实现在 os.unlink 失败时会 raise OSError("...additionally failed to remove temporary file...") from err,这正是 CLAUDE.md 异常处理政策第 4 条要求的「不得静默吞掉」。单纯把清理搬进 finally 会产生两难——在 finally 中抛出会掩盖正在传播的异常(含 KeyboardInterrupt),删掉该分支则违反第 4 条。建议的形状是 finally 只负责删除并把失败记录到可观察通道(例如 warnings 或诊断输出),把「清理失败也要可见」与「不掩盖原异常」两个语义分开。该取舍须在本 issue 的实现 PR 中以显式方案落地并在 PR 正文说明,不得留给实现者临场决定;第八节第 7 项(清理失败仍可观察)对其结果做验收。
关于上游先例的引用范围:cpython#106238 处理的是同一类问题,但上游采用的是 except BaseException: release; raise,属本仓库异常处理政策第 1 条禁止的宽泛捕获。此处只借用其问题定性,不借用其实现形状。
6.4 持有资源时新增清理:用上下文管理器,不用裸 try/finally
当一处资源要在已经持有其他资源的位置获取时,直接加 try/finally 会违反第七节第 2 条——那个 try: 行本身自 3.11 起不受任何处理器覆盖,外层的 with 也保不住它。此时应把「获取 + 释放」整体下沉为上下文管理器,由 with 承接(BEFORE_WITH 是真实指令,见 3.1 节)。
第 5.2 节的第 1 处即为此形状:两个 TemporaryFile 已由 with 托管(registry.py:280 / :281),Popen 在其内部裸获取。处方:
@contextlib.contextmanager
def _bounded_popen(command, **popen_kwargs):
process = subprocess.Popen(command, **popen_kwargs)
try:
yield process
finally:
if process.poll() is None:
process.kill()
try:
process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
# SIGKILL 的投递是异步的,宽限期内子进程可能仍未被回收。
# 在 finally 中抛出会替换掉正在传播的异常(含 KeyboardInterrupt),
# 因此只记录,不抛出。
_write_cleanup_diagnostic("bounded process kill", ...)两点必须注意。其一,不会重复调用 kill():现有三处分支(registry.py 的 296–298、310–312、321–323)都是先 kill() 再 wait() 然后抛出,此时 poll() 已非 None,上面的守卫会跳过;正常路径也只在 poll() 返回非 None 后才跳出循环。其二,wait(timeout=1.0) 必须包在 except subprocess.TimeoutExpired 内——它在 finally 中抛出会掩盖正在传播的异常,这正是本 issue 要避免的失败模式。两项工程细节:registry.py 目前未导入 contextlib,采用该形状时需一并添加;_write_cleanup_diagnostic 可安全地从 process.py:346 引入——process.py 不导入 registry、registry.py 也不导入 process,不存在循环导入。
6.5 轴三的处方
轴三在真实信号下全版本命中 0 次,仅由逐行注入可达。需要说明的是,「调试器、覆盖率工具亦可达该路径」这一推断本 issue 未做实验验证;已证实的可达主体只有本仓库自己的注入测试。处方:需要在持有资源时使用处理器,改用 with,或把那段逻辑拆成独立函数让处理器落在被调函数内部。
_build_identity.py:503 是第七节第 1、2 条相冲突的实例:按第 1 条需要为目录描述符新开 try,而此时已持有临时文件,第 2 条禁止在持有资源时进入嵌套 try。建议把该目录 fsync 整体抽为独立函数(例如 _fsync_directory(path)),让处理器落在被调函数内部。
七、维护纪律(本 issue 解决时须写入 CLAUDE.md)
以下内容须在本 issue 的实现 PR 中写入 CLAUDE.md 的「Exception Handling Policy」小节(现有 5 条编号规则),作为其延伸:第 1 至 6 条与第 8 条逐条写入为规范性条目,第 7、9 条为普查与测量方法学,只在小节末尾以一段简述加指向本 issue 的链接收录。写入本身是验收条件之一,见第八节第 9 项。
- 资源应在其清理处理器建立之后才被获取。 不要写「先获取、再用
try包起来」;要写「先进入try,在其中获取,由finally判断是否有东西需要释放」。需同时知悉 6.1 节的实测结论:该形状不降低异步中断的命中率,其确定收益是消除「获取语句与try之间的语句抛出同步异常」这一类 100% 泄漏。 - 持有资源时不要进入嵌套的
try。 需要处理器时使用with,或把该段逻辑拆成独立函数。原因是自 CPython 3.11 起try:只编译出一个不进异常表的NOP,在持有资源时进入内层try会重新打开一个连外层finally都不覆盖的窗口。 - 清理动作应放在
finally,不应放在except。 本条与既有的「禁止宽泛捕获」政策不冲突:宽泛捕获仍被禁止,此处要求的是把清理动作放进finally,而不是放宽except的类型范围。例外有两类,机制不同不可混用:其一,资源即函数的返回产物——_emergency_write返回该应急日志路径,清理只应覆盖失败路径,判据是「是否已交付调用方」;其二,所有权移交给进程级持有者——_private_viewer_directory把目录登记进_PRIVATE_DIRECTORIES/_FALLBACK_DIRECTORIES并由atexit钩子回收,判据是「是否已成功登记到那个持有者」。把任意对象塞进模块级全局不构成本例外。 - 资源所有权移交后必须清空哨兵。 见 6.2 节的形状。否则
finally会二次关闭一个可能已被复用的描述符,破坏范围超出本函数。 - 第 1 条与第 2 条在「已持有资源时再获取第二个资源」的场景下互斥,此时以第 2 条为准:把第二个资源的获取与释放整体下沉为独立函数,或改用
with。 - 「资源」不限于文件描述符。 本条纪律适用于一切需要显式释放的对象,至少包括:文件描述符、临时文件、临时目录、子进程、套接字、锁。子进程尤其重要——孤儿进程不随父进程退出被操作系统回收,其泄漏后果比描述符严重。判断标准是「该对象是否需要一个显式动作才能释放」,不是「它是不是一个整数 fd」。
- 普查此类缺陷时,原语清单的完整性决定结论的完整性。 本 issue 最初的清单只有 7 个原语且缺少
subprocess.Popen、tempfile.mkdtemp与tempfile.TemporaryFile,因而漏掉了本仓库最严重的两处缺陷。新增此类普查时应同时覆盖三条轴(获取先于登记、清理只在except、持有时进入嵌套try),并明确记录所用原语清单,使后续读者能判断覆盖范围。 - 新增需显式释放的资源获取点时,需同时给出对应的注入测试或结构性门禁。 该缺陷类的特征是「本地用某个版本开发看不见」,仅靠代码评审不足以拦截:PR #389 中同一处缺陷经过四次提交才修对:前两次只是把无主的那一行换了位置,第三次才发现是另一个成因——自 3.11 起
try:那一行本身就不受异常表覆盖。 - 报告此类缺陷时须说明所用手段与版本范围。 逐行注入是一种故意越过信号投递限制的穷举手段(依据见 2.2 节末),用它测出的窗口不能直接等同于真实中断的风险。若要给出真实可达性的定量结论,需按 3.4 节的要求设计判据明确的测量:判据必须与资源编号无关、必须交错执行并逐轮回收、必须同时设置阴性与阳性对照。
其中第 1 至 6 条可由 AST 检查机械核对,第 7 至 9 条是流程约定,需要评审清单支撑,本身不构成可自动执行的门禁。
八、验收标准
以下以第九节决策①取「全部 9 处」为前提;若取部分范围,第 1、3、5 项按实际范围收缩(第 3 项针对第 2 处、第 5 项针对第 7、8 两处,若这些站点被推迟则相应条目一并推迟),其余各项不变。
- 第 5.2 节确认的 9 处全部改造完毕,且每处标注所用形状(哨兵 /
with/ 函数下沉 / 所有权移交清空 / 清理移入finally)。 _run_subprocess_bounded在中断后不留下孤儿子进程;新增用例以sys.settrace逐行注入驱动,断言注入后无新增子进程。write_bmc_output在中断后不残留.<name>.*.tmp;新增用例须以sys.settrace逐行注入驱动(与 3.5 节同一手段),不得依赖真实信号——真实信号用例在本类站点上既不稳定,其命中率又高度依赖信号到达时刻的分布(见 3.4 节),可能空跑通过。run_check_process的四处资源(临时目录、两个TemporaryFile、子进程)在中断后全部释放,且该函数的无主窗口宽度降到 0。两项指标须分别举证:窗口宽度以结构核对(或门禁)证明;「中断后全部释放」优先以sys.settrace逐行注入用例证明,若端到端注入环境确实无法构造(见 3.5 节),须在实现 PR 中写明未测原因,并至少对下沉出的三个上下文管理器各自单测其finally路径,不得以「已重构」自述通过。- 第 7、8 两处须各自附带
sys.settrace逐行注入用例:第 7 处断言注入后无残留.build_info.*.tmp且无泄漏描述符(改造前为 28 注入点 / 5 处泄漏),第 8 处断言注入后无泄漏描述符(改造前为 10 注入点 / 1 处真实泄漏);第 7 处的用例还须覆盖:503的目录fsync已下沉为独立函数。第 9 处_emergency_write若仍无法构造注入环境,须在实现 PR 中写明未测原因,不得以「已改造」自述通过。 - 改造后的 6 个函数内不得存在「在已持有资源的位置进入的嵌套
try」(第七节第 2 条)。run_check_process现有的三处(process.py的 463、508、526)须全部消除;其余函数若因改造新引入此形状,同样不予通过。本项不以是否新建结构性门禁为前提,可由实现 PR 以人工核对加 PR 正文列举满足。 - 清理失败仍可观察:不得引入无记录的
except OSError: pass(CLAUDE.md异常政策第 4 条),也不得在finally中抛出而掩盖原异常。 - 第 5.3 节判定为非缺陷的 16 处保持不变,唯一例外是
report.py:501:该处已在 5.3 节记明与第 7 处同形的所有权移交残余,实现 PR 应一并按 6.2 节形状处理,无需另行说明理由。除它之外,若实现 PR 改动了其余 15 处中的任何一处,须在 PR 正文说明理由。 - 第七节的维护纪律已写入
CLAUDE.md的「Exception Handling Policy」小节,以英文撰写(该小节既有内容为英文,且仓库约定政策文本使用英文),接在既有 5 条之后编号,并显式记录第七节第 1 条与第 2 条的冲突消解顺序。三点必须落实:其一,第 1 至 6 条与第 8 条为规范性编码约束,逐条写入;第 7、9 条为普查与测量方法学,在同一小节末尾以一段简述加指向本 issue 的链接收录,不逐条展开——把设计注入实验的方法学写成规范条目会成为无人查阅的死文本。其二,新增条目须显式限定适用范围为pyfcstm/:该小节现有范围还包含editors/jsfcstm/src/与editors/vscode/src/,而本 issue 只普查了 Python 侧;轴二在 TypeScript 上确有对应形态(清理只写在catch,非匹配类型的throw同样跳过),扩展到editors/须另立 issue 并先做同等普查。其三,与既有第 1、4 条同时成立。本项为硬性验收条件,缺少它则本 issue 不得关闭。 - 若新建结构性门禁:判据须在 9 处改造后全部通过、在改造前的任一处上失败,且在第 5.3 节的 16 处不产生误报;门禁须覆盖三条轴;若实现为 AST 静态判据则版本无关、无需跨版本运行,若实现为运行时注入判据则须在 8 个受支持版本上给出相同结论。
- 明确超出范围的项在实现 PR 中被显式声明,不被顺带改动:中断落在
finally内部的情形(公认不可避免)、generator.throw()引发的GeneratorExit、自由线程构建、PyPy、Windows 上的 SIGINT 投递路径。
九、需要决策的问题
-
修复范围取第 5.2 节的 9 处全部,还是先做第六节排序的前两项(第 1 处与第 3 至 6 处,即
_run_subprocess_bounded与run_check_process两个函数,涵盖全部两处子进程站点与最宽的窗口)? 后者覆盖全部不可回收资源与最宽的窗口,前者一次做完。需注意代价方向:第 7、8、9 处窗口宽度为 0–1 个语句行、修法有现成形状,是最便宜的一批;第 1、3–6 处需下沉为上下文管理器,是全部工作量的主体。因此「先做前两项」推迟的是便宜的部分,并不减少主要工作量。 -
run_check_process是整体重构还是逐点加哨兵? 需先纠正一个事实:四处资源共用行 701 的同一个finally,且_close_capture_stream(process.py:163–166)与_cleanup_session(331 / 337)均已容忍None,因此逐点方案得到的是一个从行 446 起加宽的try,不是四个嵌套try。真正的障碍在别处——该函数已有三个内层try(463 的缓冲流、508 的Popen、526 的attach_process),每一个都是在已持有资源的位置进入的,属第七节第 2 条禁止的形状,且与是否加哨兵无关。两条路线都必须先修一处会掩盖原异常的崩溃:
_terminate(定义在 258)在行 267 直接取process.pid,只有if posix_group:守卫、没有None守卫;而run_check_process中process没有哨兵,只在行 509 绑定。因此一旦把try加宽到行 446,行 690 的except BaseException在 446–508 之间任何异常发生时都会崩溃,且两种改法各有一种形态:不加哨兵时_terminate(process, ...)在实参求值处抛UnboundLocalError(尚未进入_terminate);按第七节第 1 条补上process = None后,则在_terminate行 267 抛AttributeError。两者都会掩盖原本干净的KeyboardInterrupt,因此必须在加宽try之前先给_terminate补None守卫。建议整体重构:把三段获取/释放下沉为
_selfcheck_session()、_capture_spools()、_spawned_worker(),由contextlib.ExitStack.enter_context组合(ExitStack自 3.3 起可用,满足 3.7 下界)。代价有二:其一,行 690–700 的except BaseException会变成死代码必须删除,其仅存的副作用_write_cleanup_diagnostic("interrupted cleanup", ...)(行 695)须迁入_spawned_worker的finally,否则本次重构自身即违反第八节关于「清理失败仍可观察」的要求;其二,若三个助手写成@contextlib.contextmanager生成器,则本次整改采用的正是第八节末项声明超出范围的GeneratorExit形状——该残余与finally内部中断同属已排除项,但应在实现 PR 中写明,或改用类式__enter__/__exit__。规模约为迁出 120 行、删除 40 行,不改动run_check_process的签名与CheckResult。 -
是否新建结构性门禁? 如新建,需按 5.4 节的更正另写判据,不能复用 PR #389 中依赖
ast.Yield的实现;同时须满足第八节关于三轴覆盖与误报的要求。另有两点须在表决前明确。其一,门禁的落点:PR #389 的守卫测试落在 pytest 内,而仓库另有make test_boundary_check这一 pytest 之外的结构性守卫,两者择一并写入验收。其二,5.4 节「没有一个是生成器」只对改造前的代码成立:若第 1、3–6 处按 6.4 节与决策 2 的建议改为上下文管理器,代码就会出现yield交接点,那个依赖ast.Yield的判据即部分可复用,本决策的成本随之显著下降。
十、复现脚本
点开查看子进程泄漏的复现脚本(第 5.2 节第 1 处)
"""复现 _run_subprocess_bounded 的孤儿子进程泄漏。逐行注入 KeyboardInterrupt,
统计注入后是否留下新的子进程。"""
import sys, os, dis, subprocess, time
from pyfcstm._selfcheck import registry
f = registry._run_subprocess_bounded
code = f.__code__
lines = sorted({l for _, l in dis.findlinestarts(code) if l})
def children():
out = subprocess.run(["pgrep", "-P", str(os.getpid())],
capture_output=True, text=True).stdout
return {p for p in out.split() if p}
orphaned = []
tried = 0
for ln in lines:
def tr(fr, ev, a, w=ln):
if fr.f_code is code and ev == "line" and fr.f_lineno == w:
raise KeyboardInterrupt
return tr
before = children()
sys.settrace(tr)
fired = False
try:
f(["/bin/sleep", "5"], timeout=2.0)
except KeyboardInterrupt:
fired = True
except BaseException:
pass
finally:
sys.settrace(None)
if not fired:
continue
tried += 1
time.sleep(0.05)
left = children() - before
if left:
orphaned.append(ln)
for pid in left:
try:
os.kill(int(pid), 9)
except OSError:
pass
print("注入点 %d,其中留下孤儿子进程 %d 处" % (tried, len(orphaned)))
print("泄漏行号:", orphaned)实测输出:注入点 28,其中留下孤儿子进程 14 处。
点开查看三轴普查脚本的判据说明
三轴普查均为 ast 静态分析,判据如下。轴一:遍历 pyfcstm/ 下所有函数,匹配 26 个获取原语的调用;排除出现在 with 语句 items 中的调用(视为已托管);对每处剩余调用,查找函数内是否存在带 finalbody 且其 body 行范围覆盖该调用行的 try,并检查该 finalbody 内是否含释放调用(close / unlink / rmtree / terminate / kill 等)。轴二:遍历所有 try,跳过带 finalbody 者,检查其 handlers 体内是否含释放调用。轴三:遍历所有 try,取其 body 内最早的获取调用行,检查该行之后 body 内是否还存在另一个 try。
「无主窗口宽度」的计算方式:对未被覆盖的获取点,找出函数内该行之后最近的带 finalbody 的 try,统计两者之间的语句行数。
Activity
HansBug commentedon Jul 29, 2026
仍然成立的部分:3.11 起的最小复现
本 issue 规则中关于 3.11 的那一半有一个与本仓库无关的最小复现。其行为比「内层
try尚未建立」这一表述更强:在内层try:那一行收到中断,会连外层的try/finally一起逃逸,两个处理器都不执行。通过
sys.settrace对每个语句行各注入一次KeyboardInterrupt,检查HELD是否被清空:各版本都会报告的「其自身的
HELD.clear()行」属于另一类情形:注入到清理过程内部会跳过该清理的剩余部分,这在任何版本上都成立,也是任何 Python 程序都无法幸免的情形,因此不计入结论。真正的差异只有一条:内层try:那一行,自 3.11 起才出现。因此实践规则比「避免嵌套处理器」更窄:在持有资源期间进入的嵌套
try,自 3.11 起存在一行使外层清理不执行。with语句不具有这一行为——BEFORE_WITH是一条真实指令,其行事件落在外层保护区间之内。复核结果:上述结论已被独立验证
本 issue 正文第 3.1、3.2 节对以上主张做了独立复核,结论一致并有所扩展:
with_inner确实在内层try:行额外逃逸,3.10.1 上没有。分界线精确落在 3.11。SETUP_FINALLY(真实指令),在 3.11+ 编译为NOP,且该NOP的偏移量落在co_exceptiontable覆盖区间的空隙里,不被任何条目覆盖。with版本在同一偏移量上是LOAD_GLOBAL,受覆盖——这与原评论关于BEFORE_WITH的说明相符。no_inner4/5、with_inner5/8——即原评论的 5/8 完全复现,仅no_inner的 4/7 与重跑的 4/5 不同,原因是两套探针对「哪些行计入注入」的枚举方式不同;逃逸行的集合一致。已被取代的部分(两处)
一、PR #389 的版本分布来自逐行注入,不是真实 Ctrl-C 的观测
原评论记述:该形状在 PR #389 中「于 3.11、3.12、3.13、3.14 上、在 Linux / macOS / Windows 三个平台泄漏了一个写了一半的约 29 MB 文档,而在 3.7 与 3.10 上通过」。这段记述本身准确(该 PR 的提交信息即如此记载),但其来源是
sys.settrace逐行注入测试,不是现场报告,也不是真实中断的观测结果。正文第 3.3 节改用真实异步信号重测,结果与注入法相反:内层
try:那一行在全部 8 个版本上真实信号命中 0 次;而真实信号能命中的是「获取之后、清理登记之前」这个另一类窗口,且据合成用例测量只在 3.7–3.10 上可观测(正文第 3.5 节说明该比率不可外推到真实获取原语)。上游对此有解释:f_trace_opcodes本就是为 bpo-29988 的测试用例而加的,用途是「在任意字节码偏移之后注入异常」,即故意越过真实信号的投递限制(Nick Coghlan 于 2021-01-27 在 PR #18334 的评论)。因此「升级到 3.11 让代码更不安全」不成立。正文第 4.1 节给出了完整论证。
二、「同一个检查可用于上述三处」已被否定
原评论结尾建议:PR #389 现在带有一个断言「资源创建与移交它的
yield之间不进入任何处理器」的测试,同样的检查可以用来约束本 issue 列出的三处站点。这一点不成立。正文第 5.2 节复核后确认:该守卫测试的判据依赖
ast.Yield来定位「交接点」,而本 issue 涉及的write_build_identity_file、_emergency_write、_write_frame均非生成器(isgeneratorfunction均为False),不存在yield交接点这一概念。因此该门禁无法参数化推广,需另写判据(大致形式为「资源获取调用与释放它的try:之间不得存在任何语句」)。这是对工作量估算的更正——原先按「参数化现有门禁」估算偏低。本评论的现行定位
本评论保留作为过程记录。现行结论以 issue 正文为准,尤其是:风险分层见正文第一节与第 3.3 节;真实获取原语上的中断命中率是未决问题,见第 3.5 节;本仓库 8 处获取点的逐处复核结论(其中 5 处需改动、3 处为非缺陷)见第五节;处理意见与优先级见第六节;维护纪律见第七节。
[-]Raw descriptors are acquired before the try that releases them, so a Ctrl-C leaks them[/-][+]裸资源在其清理处理器建立之前被获取,中断时会泄漏描述符与临时文件[/+]HansBug commentedon Aug 3, 2026
相关但未处理,保持开放
伞 PR #383 已合入
main(merge commit980ce49e11ff)。本 issue 点名的八处位置——pyfcstm/_bootstrap.py:274/:276/:301、pyfcstm/config/_build_identity.py:492/:503、pyfcstm/entry/bmc.py:654、pyfcstm/_selfcheck/report.py:501、pyfcstm/_selfcheck/worker.py:180——一处都没有被这条线触及(git diff --stat 1174eec3~1 origin/main对这些文件为空)。本 issue 描述的缺陷全部原样存在。之所以在这里留言,是因为伞 PR 在自己的代码里遇到并处理了同一缺陷类的另一批实例,其做法与结论对本 issue 或许有参考价值。
Diagram.save()与to_html()之类的写出路径原本也是先建临时文件再建立清理职责,中间那段窗口无主。现在改为写出前就把清理挂上,并且捕获BaseException而非只捕获OSError——因为 Ctrl-C 抛的是KeyboardInterrupt,只清理OSError的话,用户在写一个约 30 MB 的自包含查看器途中按下 Ctrl-C,会留下一个同样大小的临时兄弟文件。这一点由test/diagram/test_api.py的test_atomic_writes_remove_their_temporary_when_interrupted守着,做法是在写出过程的每一行注入中断并要求临时文件不残留。这个例子支持本 issue 的一个判断:这类缺陷靠代码审阅很难穷举,因为「窗口」在源码上通常只是相邻两行;能可靠发现它的是逐行注入中断这种测试形态,而不是读代码。如果本 issue 后续要系统性清理那八处,建议一并考虑同样的验证方式。
HansBug commentedon Aug 5, 2026
分支协调说明已过时:BMC 解释伞已合入
main身份:主会话。本 issue 正文的「分支协调」一段写着
pyfcstm/entry/bmc.py在dev/bmc-infeasibility-explanation-umbrella内有 +122/−11 的改动,需要避让。该伞已于 2026-08-05 以 merge commitdf09ce5a合入main(伞 PR #387),分支已不再是活动分支,那段避让约束不再适用 —— 以main上的当前entry/bmc.py为准即可。本 issue 的缺陷本身未受影响,也未被该伞修复,保持开着。
[-]裸资源在其清理处理器建立之前被获取,中断时会泄漏描述符与临时文件[/-][+]资源在其清理处理器建立之前被获取,中断会泄漏描述符、临时文件、临时目录与子进程[/+]fix(tooling): make the ownership gate detect the defect it was writte…
HansBug commentedon Aug 5, 2026
本 issue 已由 PR #441 实现并合入
main(合并提交dd5053e9)。以下是逐项交付结论、证据、以及需要明确接受的残余风险。有一项验收(第 10 项)未满足,已如实标注,不按满足计。一、交付范围
第 5.2 节点名的 9 处全部改造,外加 5.3 节点名的
report.py:501。另有两处由多智能体 review 发现、原普查未覆盖的站点也一并修掉,理由见第四节。registry.py_run_subprocess_boundedwith(新增_bounded_popen)+ 函数下沉(_kill_and_reap)bmc.pywrite_bmc_outputfinallyprocess.pyrun_check_processwith(新增_session_transport)TemporaryFilewith(新增_output_spools)with(新增_worker_tree/_WorkerTree)_build_identity.pywrite_build_identity_file_fsync_directory)worker.py_write_frame_append_frame_to_file)_bootstrap.py_emergency_writefinallyreport.pywrite_reportworker.py_execute_worker_callbackos.write/sys.stdout/sys.stderr)_classify_worker_call)+ 快照移到所有者try之前(_capture_output_state)run_check_process的四处资源统一由ExitStack持有后,原来的try/except BaseException/finally脚手架整体退休;LIFO 卸载顺序与原finally一致(先终止子进程,再删它的 cwd)。二、逐行注入 A/B
计数格式为「注入点 / 函数体窗口泄漏」。「函数体窗口」与「注入落在
finally体内部」分开统计,后者任何 Python 程序都无法幸免,不计入结论。_run_subprocess_boundedwrite_bmc_outputwrite_build_identity_file_write_frame_append_frame_to_file8 / 0)write_report_emergency_writereturn,该文件是返回产物,finally有意不删)run_check_process端到端改造后的三个上下文管理器与下沉出的 helper 也各自单独注入过(
_session_transport9 点、_output_spools10 点、_worker_tree10 点,均 0 窗口)。这一点很关键:脚手架的行搬进上下文管理器后,只注入run_check_process已不足以证明安全,而正是补上 CM 注入才在实现过程中抓到一个真实缺陷(见第四节第 2 条)。跨版本:全部 13 个被改造函数在 3.10.1 / 3.11.1 / 3.12.1 / 3.14.1 上逐行注入,函数体窗口合计 0;生产代码在 3.7.1 / 3.8.1 / 3.9.1 上导入并冒烟通过。
三、对本 issue 自身内容的两处更正
一、第一节「3.12 起多漏一行」的说法不成立于一般情形,是形状相关的。 实测三种内层
try体,内层try:那一行自 3.11 起在所有形状上都漏(本 issue 的核心论断成立),但「多漏一行」只出现在编译成裸NOP的行上:try体passtry:try:+ 多一行try:try:,无额外try/finallytry:try:+ 多一行CLAUDE.md里已按此写明触发条件,不再作无条件断言。二、第 6.1 节推荐的「哨兵 +
with保护」需要一个本 issue 未验证的前提。 本 issue 只测过「外层try+ 内层try」,而ExitStack方案的实际形状是「外层with+ 内层try」。实现时先补测了这一点——3.10 / 3.11 / 3.12 / 3.14 上外层with均不漏——否则整个方案可能是假的。但同时发现一个反例边界:嵌套try位于@contextlib.contextmanager生成器体内时,自 3.11 起照漏不误,与普通嵌套try无异。也就是说「外层with保护你」成立,「写那个支撑with的生成器时同样安全」不成立。CLAUDE.md第 7 条已区分这两个帧。四、实现过程中发现的、原普查未覆盖的缺陷
1.
worker.py的_execute_worker_callback。 它在try之前安装进程级输出限流器、随后又开内层try。中断会让该 worker 子进程余生的os.write/sys.stdout/sys.stderr一直被替换着。实测 3.14 比 3.10 多一个逃逸点。它不在本 issue 的 9 处里,因为第五节的原语清单没有「进程级全局状态」这一类——这正是第七节第 7 条「普查此类缺陷时,原语清单的完整性决定结论的完整性」所预言的情形。已修,四版本实测归一。2. 三个新建的上下文管理器最初全部写成了本 issue 禁止的嵌套
try形状。 实测 3.11 / 3.12 / 3.14 上_session_transport的_cleanup_session根本不执行,整个 session 目录泄漏;3.10 上正常。也就是说:本地 3.10 全绿(48000+ 测试),而真正会触发缺陷的版本上照漏不误。_output_spools看似没事只是被引用计数回收掩盖,_worker_tree则会留下活的 worker。这一条只有在注入上下文管理器本身、且在 3.11+ 上才会暴露。3.
write_build_identity_file相对改造前出现过回归。 磁盘满时flush失败会让finally里的stream.close()再抛一次,中断它自己finally的剩余部分,临时文件因此残留;改造前用with,该错误反而能逃到外层finally完成清理。用/dev/full复现确认后已修。4. 共享注入 harness 曾污染整个测试套件:
sys.settrace(None)会连 coverage.py 的 tracer 一起卸掉(实测某模块 85% → 20%);_reap()用waitpid(-1)会收走别的测试的子进程,Popen吞掉ChildProcessError后把真实非零退出码报成 0。均已修。第一条的教训本仓库其实已写在test/diagram/test_api.py,抽取共享 harness 时把做法抄走了、把写在旁边的原因丢了。五、验收对照
report.py:501,形状见第一节表test_run_subprocess_bounded_leaves_no_orphan_at_any_injection_point,sys.settrace驱动,28/15 → 29/0test_write_bmc_output_leaves_no_descriptor_or_temporary_file,注入驱动、不依赖真实信号,11/6 → 21/0ExitStack在获取前持有;释放:端到端注入用例已入仓且默认运行(74 点、约 30 秒),88/33 → 74/0,另加三个 CM 各自的finally单测与逐行注入:503的目录fsync下沉由test_directory_flush_is_delegated_rather_than_opened_inline以替换观察委派行为覆盖(非扫描源码文本)。第 9 处不适用「未测」条款:注入环境可构造(禁掉sys.stderr.buffer与 fd 2),已落成用例make resource_ownership_check,AST 静态、版本无关、函数清单自动发现,接在 CI 的lint作业(无if/needs,不受提交信息旁路令牌影响)。同一判据在改造前修订上报出 6 处except OSError: pass均为单一职责释放 helper 且各注明原因;尚有汇报通道时照常汇报。两处finally有意让os.close失败上抛(_append_frame_to_file由调用方转成可汇报诊断、_fsync_directory无在途异常),已在 PR 正文写明而非笼统声称「均不抛出」report.py:501外,5.3 节的_build_identity.py:503也被改动——那是同一 issue 第 5 项与 6.5 节明文要求的。其余 14 处零改动CLAUDE.md「Exception Handling Policy」小节,英文,接既有 5 条编号为 6–12(7 条)。第七节 1–6 与 8 逐条写入;7、9 条为方法学,在小节末尾一段简述加本 issue 链接。适用范围显式限定pyfcstm/。第 10 条记录第 1、2 条冲突消解顺序(以第 2 条为准)。与既有第 1、4 条的关系写在第 8 条,含 rule 4 静默吞噬禁令的放宽边界及其条件GeneratorExit排除项对其适用第 10 项为何未满足
建了结构性门禁,四项条件中满足三项:改造后全部通过 ✅;在改造前修订上失败(6 处)✅;对 5.3 节 16 处无误报 ✅;AST 静态、版本无关 ✅。「须覆盖三条轴」不满足——它只覆盖轴 3 的一种形状。 轴 1、轴 2 仍只有逐行注入用例覆盖,没有机械门禁。
需要一个决定:接受一轴门禁,还是修订该项。我倾向前者,理由见下面残余风险第 1、2 条——三轴普适门禁的假阳性代价可能高于收益,而这次的经验表明判据必须被实测校准才有意义。
六、残余风险(合并即意味着接受这些)
门禁有三类看不见的形状,而三类在本仓库都真实出现过:① 完全不需要嵌套
try的逃逸(with X: pass,pass编译成裸NOP——这才是三个 CM 泄漏的真实机制);② 经 helper 间接获取(而把获取下沉为 helper 恰恰是第七节第 2 条的补救手段,所以改造后的代码比改造前更难被这个门禁复查);③ 进程级全局状态。这三类只有逐行注入能覆盖。已写入CLAUDE.md第 12 条。门禁报出的集合与第 8 节第 6 项点名的不是同一组。 该项要求消除
process.py的 463 / 508 / 526;扫描器看到的是 526 / 555 / 566,且结构上看不到 463 与 508——那两行处并无外层处理器持有资源。463 与 508 的消除依据是ExitStack重构加逐行注入,不是门禁。另需预先说明:门禁会报_build_identity.py:504,那是 5.2 节缺陷 fix(model): fix PlantUML code generation and add safe identifier utility #7(轴 3),不是对 5.3 节:503条目的误报。平台不对称:18 个新测试中 13 个依赖
/proc/self/fd,在 Windows 与 macOS 上会 skip。也就是说非 Linux 平台上,那条版本无关的 AST 门禁是唯一防线,而它是两件仪器中较窄的一件。真实 Ctrl-C 残留是大幅减少,而非被证明消除。 获取原语调用自身那一个字节码上的窗口无法在 Python 层关闭——用真实
SIGALRM对三种交接写法交错测量,命中率分别为 33.4% / 33.2% / 46.8%,即各形状之间并无差异。/tmp/pyfcstm-selfcheck-*仍可能在中断后残留。逐行注入证明的是「没有行级可达窗口」。GeneratorExit:三个新 CM 均为@contextlib.contextmanager生成器,未采用 class 式__enter__/__exit__,故该排除项对其适用。本 issue 关闭后成为承载性文档:
CLAUDE.md第 12 条之后的方法学段落把第七节第 7、9 条(普查完整性、测量设计)以链接形式委托给本 issue。这份 issue 正文必须保持完整可读,否则该部分政策会失效。跨版本与真实信号的数字来自实现环境,仓库内无脚本可复现。 其所依赖的机制(外层
try+ 内层try自 3.11 漏、外层with不漏)已被多个独立 reviewer 复现,但具体百分比没有入仓的复现物。七、附带收获与顺带修复
ruff.toml落地了 3.7 语法闸门。setup.py的python_requires=">=3.7"此前没有任何东西在兑现:无配置时ruff check对match、:=、带括号多项with一律All checks passed。这个缺口是在改造中撞上的(ruff format会把超长多项with改写成 3.7/3.8 上语法错误的带括号形式)。落地代价为零:全仓invalid-syntax为 0,ruff check计数不变。边界也写明了:只管语法,不管库 API。main后与新加入的强制前置命令表对齐,门禁成为第四条。CONTRIBUTING.md三处「ruff format .」改为只格式化改动文件——实测那条命令会重排约 180 个文件。八、验证
Code test作业(3 平台 × 8 版本)、Doctest gate、全部Template full套件、以及lint作业中Check resource ownership步骤实际执行成功main上复验:make resource_ownership_check自检 + 全包扫描通过;14 个资源所有权测试通过make test_boundary_check通过;make rst_auto零 diff;ruff check与合并前基线一致make doctest(合并main后成为强制前置命令)本地 7 failed / 1090 passed,与干净main完全相同的 7 项(本地缺 MiniRacer 与未安装包导致的 pygments entry point),CI 上该门禁 pass九、过程说明
实现经过 5 轮、10 位 reviewer 的多智能体 code review,每轮都找出真问题,其中两次是「修一个缺陷时引入另一个」。这些都记录在 PR #441 的正文与提交历史里。这个过程本身印证了第七节第 8 条的判断:这类缺陷靠代码评审拦不住,因为改动往往看起来更完善、实则更弱。