结论摘要
本仓库存在一类缺陷:资源在负责释放它的处理器建立之前就已经被获取,中间那段窗口是无主的。资源不限于文件描述符——普查后确认还包括临时文件、临时目录和子进程。该缺陷类在 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,统计两者之间的语句行数。
结论摘要
本仓库存在一类缺陷:资源在负责释放它的处理器建立之前就已经被获取,中间那段窗口是无主的。资源不限于文件描述符——普查后确认还包括临时文件、临时目录和子进程。该缺陷类在 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范围过窄。CLAUDE.md的「Exception Handling Policy」小节——第 1 至 6 条与第 8 条作为规范性条目逐条写入,第 7、9 条作为方法学简述加链接收录(见第八节第 9 项),缺少它则本 issue 不得关闭。一、缺陷类:三条轴
这一类缺陷有三条独立的轴,触发条件、版本范围和可达手段都不同,必须分开讨论,混谈会得出错误的优先级。
try被进入(含try:行本身)os.open:全版本均为数十个百分点,3.11+ 不低于 3.7–3.10;真实tempfile.mkstemp:未能可靠测量(见 3.4 节)except、无finallyexcept类型的异常都会跳过清理trytry:那一行轴一是朴素的:资源已经存在,而清理还没登记。轴二与字节码细节完全无关,是普通的
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 aNOPs for eachtry, and possibly a few other."(原文如此),原因是把这个NOP也去掉需要改动行号表。由于NOP不可能抛出异常,编译器合法地把它排除在异常表之外——这是一个被上游解释为「设计如此」的空隙,不是缺陷。2.2 缺陷类在上游有权威记录,且从未彻底消除
点开查看上游证据逐条汇总(7 条)
open()调用之后、SETUP_WITH字节码执行之前被中断;finally与上下文管理器都不受KeyboardInterrupt及generator.throw()引发的GeneratorExit保护。「获取之后、登记清理之前」这一表述来源于此,而非 bpo-29988with 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 后来已按修复关闭eval_breaker。commit message 写明目的是保证with/async with在被中断时仍会调用__exit__/__aexit__。合入时间 2021-03-24,落在 Python 3.10(3.10.0a7),不是 3.11KeyboardInterrupt可能使代码进入不一致状态」,并给出规避范例logging._acquireLock()中招,SIGINT 导致锁被遗弃,在高频fork场景下使进程挂死finally子句里误报「不可达代码」一条对本 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 异常表空隙确实存在(轴三的机制)
被测代码:
该行编译结果与异常表覆盖情况:
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:那一行还没进入保护块)。情形 B 在 3.11 出现行为变化,与 3.1 节的异常表空隙对应。3.3 真实异步信号:合成用例的结果与注入法相反
改用真实异步信号(
SIGALRM,与SIGINT共用同一套投递机制)代替注入,每个版本 5000 轮,并把每一次泄漏关联到信号到达时的源码行。被测形状中acquire()为HELD.append("r"),即一次纯 Python 操作、不含调用边界——这一点在 3.4 节至关重要。try之间finally内部try:行关于这张表必须说明三点。第一,中间两列不穷尽总泄漏:还有一部分泄漏落在
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_boundedentry/bmc.py的write_bmc_output.result.json.*.tmp(行 749–754),另有 2 处裸描述符残留config/_build_identity.py的write_build_identity_file.build_info.*.tmp文件_selfcheck/worker.py的_write_frametry:,属轴一——descriptor在行 180 被获取、181 才进入try,全部 8 个版本均泄漏。行 185 是os.close(descriptor)自身,属各版本共有的清理过程内部中断_selfcheck/process.py的run_check_processfinally的覆盖关系与四处窗口宽度均已核对);端到端注入未实测,构造成本较高(需要完整的自检检查项对象与工作进程环境)_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 inpyfcstm/diagram/api.pyduring 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:行本身)。pyfcstm/_selfcheck/registry.py:282(_run_subprocess_bounded,定义在 278)finallywith块内但非托管」的那 1 处)+ 二pyfcstm/entry/bmc.py:744(write_bmc_output,定义在 716)finallypyfcstm/_selfcheck/process.py:448(run_check_process,定义在 414)pyfcstm/_selfcheck/process.py:464(同上)TemporaryFilepyfcstm/_selfcheck/process.py:465(同上)TemporaryFilepyfcstm/_selfcheck/process.py:509(同上)pyfcstm/config/_build_identity.py:492(write_build_identity_file)pyfcstm/_selfcheck/worker.py:180(_write_frame,定义在 173)pyfcstm/_bootstrap.py:301(_emergency_write,定义在 287)三处需要特别说明。第 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:503finally的try覆盖且finally内有os.closepyfcstm/diagram/api.py:960/:961(_open_standalone_window)finally的try覆盖,finally内有释放调用pyfcstm/diagram/api.py:1506/:1532(_staging_file)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:214os.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中做过一遍)。6.1 轴一的处方及其实测边界
该处方不降低异步中断的命中率。 按条件概率 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% 泄漏:窗口越宽,这一类同步异常的暴露面越大——
run_check_process的 41 个语句行意味着 41 行代码中任何一行抛出异常都会泄漏那个临时目录。采纳与否应按这一条收益来权衡,不应按「降低异步中断命中率」来权衡。6.2 所有权移交必须显式处理
当描述符会被移交给
os.fdopen等接管方时,哨兵必须在移交成功后立刻清空,否则finally会二次关闭一个可能已被复用的描述符:本仓库需要这一形状的站点为
_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在其内部裸获取。处方:两点必须注意。其一,不会重复调用
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钩子回收,判据是「是否已成功登记到那个持有者」。把任意对象塞进模块级全局不构成本例外。finally会二次关闭一个可能已被复用的描述符,破坏范围超出本函数。with。subprocess.Popen、tempfile.mkdtemp与tempfile.TemporaryFile,因而漏掉了本仓库最严重的两处缺陷。新增此类普查时应同时覆盖三条轴(获取先于登记、清理只在except、持有时进入嵌套try),并明确记录所用原语清单,使后续读者能判断覆盖范围。try:那一行本身就不受异常表覆盖。其中第 1 至 6 条可由 AST 检查机械核对,第 7 至 9 条是流程约定,需要评审清单支撑,本身不构成可自动执行的门禁。
八、验收标准
以下以第九节决策①取「全部 9 处」为前提;若取部分范围,第 1、3、5 项按实际范围收缩(第 3 项针对第 2 处、第 5 项针对第 7、8 两处,若这些站点被推迟则相应条目一并推迟),其余各项不变。
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路径,不得以「已重构」自述通过。sys.settrace逐行注入用例:第 7 处断言注入后无残留.build_info.*.tmp且无泄漏描述符(改造前为 28 注入点 / 5 处泄漏),第 8 处断言注入后无泄漏描述符(改造前为 10 注入点 / 1 处真实泄漏);第 7 处的用例还须覆盖:503的目录fsync已下沉为独立函数。第 9 处_emergency_write若仍无法构造注入环境,须在实现 PR 中写明未测原因,不得以「已改造」自述通过。try」(第七节第 2 条)。run_check_process现有的三处(process.py的 463、508、526)须全部消除;其余函数若因改造新引入此形状,同样不予通过。本项不以是否新建结构性门禁为前提,可由实现 PR 以人工核对加 PR 正文列举满足。except OSError: pass(CLAUDE.md异常政策第 4 条),也不得在finally中抛出而掩盖原异常。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 不得关闭。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 处)
实测输出:
注入点 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,统计两者之间的语句行数。