Skip to content

资源在其清理处理器建立之前被获取,中断会泄漏描述符、临时文件、临时目录与子进程 #412

Closed

Description

@HansBug

结论摘要

本仓库存在一类缺陷:资源在负责释放它的处理器建立之前就已经被获取,中间那段窗口是无主的。资源不限于文件描述符——普查后确认还包括临时文件、临时目录和子进程。该缺陷类在 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 里得到不同修法,而维护纪律(第七节)也需要一次性确立。

排序依据是资源可回收性与窗口宽度,不是发现顺序:

  1. _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。
  2. run_check_process 的四处(第 3–6 处) —— 窗口 41 / 27 / 26 / 5 个语句行,是本仓库最宽的一批,其中 509 也是子进程。该函数应整体重构,而非逐点打补丁。
  3. write_bmc_output(第 2 处) —— 唯一有稳定用户可见症状的一处,修法有现成范本(PR #389 在 api.py 中做过一遍)。
  4. 第 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 项。

  1. 资源应在其清理处理器建立之后才被获取。 不要写「先获取、再用 try 包起来」;要写「先进入 try,在其中获取,由 finally 判断是否有东西需要释放」。需同时知悉 6.1 节的实测结论:该形状不降低异步中断的命中率,其确定收益是消除「获取语句与 try 之间的语句抛出同步异常」这一类 100% 泄漏。
  2. 持有资源时不要进入嵌套的 try。 需要处理器时使用 with,或把该段逻辑拆成独立函数。原因是自 CPython 3.11 起 try: 只编译出一个不进异常表的 NOP,在持有资源时进入内层 try 会重新打开一个连外层 finally 都不覆盖的窗口。
  3. 清理动作应放在 finally,不应放在 except。 本条与既有的「禁止宽泛捕获」政策不冲突:宽泛捕获仍被禁止,此处要求的是把清理动作放进 finally,而不是放宽 except 的类型范围。例外有两类,机制不同不可混用:其一,资源即函数的返回产物——_emergency_write 返回该应急日志路径,清理只应覆盖失败路径,判据是「是否已交付调用方」;其二,所有权移交给进程级持有者——_private_viewer_directory 把目录登记进 _PRIVATE_DIRECTORIES / _FALLBACK_DIRECTORIES 并由 atexit 钩子回收,判据是「是否已成功登记到那个持有者」。把任意对象塞进模块级全局不构成本例外。
  4. 资源所有权移交后必须清空哨兵。 见 6.2 节的形状。否则 finally 会二次关闭一个可能已被复用的描述符,破坏范围超出本函数。
  5. 第 1 条与第 2 条在「已持有资源时再获取第二个资源」的场景下互斥,此时以第 2 条为准:把第二个资源的获取与释放整体下沉为独立函数,或改用 with。
  6. 「资源」不限于文件描述符。 本条纪律适用于一切需要显式释放的对象,至少包括:文件描述符、临时文件、临时目录、子进程、套接字、锁。子进程尤其重要——孤儿进程不随父进程退出被操作系统回收,其泄漏后果比描述符严重。判断标准是「该对象是否需要一个显式动作才能释放」,不是「它是不是一个整数 fd」。
  7. 普查此类缺陷时,原语清单的完整性决定结论的完整性。 本 issue 最初的清单只有 7 个原语且缺少 subprocess.Popen、tempfile.mkdtemp 与 tempfile.TemporaryFile,因而漏掉了本仓库最严重的两处缺陷。新增此类普查时应同时覆盖三条轴(获取先于登记、清理只在 except、持有时进入嵌套 try),并明确记录所用原语清单,使后续读者能判断覆盖范围。
  8. 新增需显式释放的资源获取点时,需同时给出对应的注入测试或结构性门禁。 该缺陷类的特征是「本地用某个版本开发看不见」,仅靠代码评审不足以拦截:PR #389 中同一处缺陷经过四次提交才修对:前两次只是把无主的那一行换了位置,第三次才发现是另一个成因——自 3.11 起 try: 那一行本身就不受异常表覆盖。
  9. 报告此类缺陷时须说明所用手段与版本范围。 逐行注入是一种故意越过信号投递限制的穷举手段(依据见 2.2 节末),用它测出的窗口不能直接等同于真实中断的风险。若要给出真实可达性的定量结论,需按 3.4 节的要求设计判据明确的测量:判据必须与资源编号无关、必须交错执行并逐轮回收、必须同时设置阴性与阳性对照。

其中第 1 至 6 条可由 AST 检查机械核对,第 7 至 9 条是流程约定,需要评审清单支撑,本身不构成可自动执行的门禁。

八、验收标准

以下以第九节决策①取「全部 9 处」为前提;若取部分范围,第 1、3、5 项按实际范围收缩(第 3 项针对第 2 处、第 5 项针对第 7、8 两处,若这些站点被推迟则相应条目一并推迟),其余各项不变。

  1. 第 5.2 节确认的 9 处全部改造完毕,且每处标注所用形状(哨兵 / with / 函数下沉 / 所有权移交清空 / 清理移入 finally)。
  2. _run_subprocess_bounded 在中断后不留下孤儿子进程;新增用例以 sys.settrace 逐行注入驱动,断言注入后无新增子进程。
  3. write_bmc_output 在中断后不残留 .<name>.*.tmp;新增用例须以 sys.settrace 逐行注入驱动(与 3.5 节同一手段),不得依赖真实信号——真实信号用例在本类站点上既不稳定,其命中率又高度依赖信号到达时刻的分布(见 3.4 节),可能空跑通过。
  4. run_check_process 的四处资源(临时目录、两个 TemporaryFile、子进程)在中断后全部释放,且该函数的无主窗口宽度降到 0。两项指标须分别举证:窗口宽度以结构核对(或门禁)证明;「中断后全部释放」优先以 sys.settrace 逐行注入用例证明,若端到端注入环境确实无法构造(见 3.5 节),须在实现 PR 中写明未测原因,并至少对下沉出的三个上下文管理器各自单测其 finally 路径,不得以「已重构」自述通过。
  5. 第 7、8 两处须各自附带 sys.settrace 逐行注入用例:第 7 处断言注入后无残留 .build_info.*.tmp 且无泄漏描述符(改造前为 28 注入点 / 5 处泄漏),第 8 处断言注入后无泄漏描述符(改造前为 10 注入点 / 1 处真实泄漏);第 7 处的用例还须覆盖 :503 的目录 fsync 已下沉为独立函数。第 9 处 _emergency_write 若仍无法构造注入环境,须在实现 PR 中写明未测原因,不得以「已改造」自述通过。
  6. 改造后的 6 个函数内不得存在「在已持有资源的位置进入的嵌套 try」(第七节第 2 条)。run_check_process 现有的三处(process.py 的 463、508、526)须全部消除;其余函数若因改造新引入此形状,同样不予通过。本项不以是否新建结构性门禁为前提,可由实现 PR 以人工核对加 PR 正文列举满足。
  7. 清理失败仍可观察:不得引入无记录的 except OSError: pass(CLAUDE.md 异常政策第 4 条),也不得在 finally 中抛出而掩盖原异常。
  8. 第 5.3 节判定为非缺陷的 16 处保持不变,唯一例外是 report.py:501:该处已在 5.3 节记明与第 7 处同形的所有权移交残余,实现 PR 应一并按 6.2 节形状处理,无需另行说明理由。除它之外,若实现 PR 改动了其余 15 处中的任何一处,须在 PR 正文说明理由。
  9. 第七节的维护纪律已写入 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 不得关闭。
  10. 若新建结构性门禁:判据须在 9 处改造后全部通过、在改造前的任一处上失败,且在第 5.3 节的 16 处不产生误报;门禁须覆盖三条轴;若实现为 AST 静态判据则版本无关、无需跨版本运行,若实现为运行时注入判据则须在 8 个受支持版本上给出相同结论。
  11. 明确超出范围的项在实现 PR 中被显式声明,不被顺带改动:中断落在 finally 内部的情形(公认不可避免)、generator.throw() 引发的 GeneratorExit、自由线程构建、PyPy、Windows 上的 SIGINT 投递路径。

九、需要决策的问题

  1. 修复范围取第 5.2 节的 9 处全部,还是先做第六节排序的前两项(第 1 处与第 3 至 6 处,即 _run_subprocess_bounded 与 run_check_process 两个函数,涵盖全部两处子进程站点与最宽的窗口)? 后者覆盖全部不可回收资源与最宽的窗口,前者一次做完。需注意代价方向:第 7、8、9 处窗口宽度为 0–1 个语句行、修法有现成形状,是最便宜的一批;第 1、3–6 处需下沉为上下文管理器,是全部工作量的主体。因此「先做前两项」推迟的是便宜的部分,并不减少主要工作量。

  2. 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。

  3. 是否新建结构性门禁? 如新建,需按 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

  1. added
    kind: bugConfirmed incorrect behavior or regression.
    status: proposedProposal or discussion requiring a decision before implementation.
    area: packagingPackages, bootstrap/self-check, frozen artifacts, resources, and publishing.
    on Jul 29, 2026
  2. HansBug commented on Jul 29, 2026

    @HansBug
    OwnerAuthor

    说明: 本评论原为英文,记录的是本 issue 早期的 3.11 视角分析。现改写为中文,并按 issue 正文的复核结果标注哪些结论仍然成立、哪些已被取代。原始英文内容的技术主张未被删改,仅重新组织与补注。

    仍然成立的部分:3.11 起的最小复现

    本 issue 规则中关于 3.11 的那一半有一个与本仓库无关的最小复现。其行为比「内层 try 尚未建立」这一表述更强:在内层 try: 那一行收到中断,会连外层的 try/finally 一起逃逸,两个处理器都不执行。

    import contextlib, dis, sys
    
    HELD = []
    
    @contextlib.contextmanager
    def with_inner():
        try:
            HELD.append("resource")
            try:                      # <- 内层 try
                yield
            except OSError:
                raise
        finally:
            HELD.clear()              # <- 不会执行的清理
    
    @contextlib.contextmanager
    def no_inner():
        try:
            HELD.append("resource")
            yield
        finally:
            HELD.clear()

    通过 sys.settrace 对每个语句行各注入一次 KeyboardInterrupt,检查 HELD 是否被清空:

    Python 3.10.1
      no_inner    finally 未执行的行: [其自身的 HELD.clear() 行]
      with_inner  finally 未执行的行: [其自身的 HELD.clear() 行]
    Python 3.11.1
      no_inner    finally 未执行的行: [其自身的 HELD.clear() 行]
      with_inner  finally 未执行的行: [内层 try: 行, 其自身的 HELD.clear() 行]
    Python 3.14.1
      no_inner    finally 未执行的行: [其自身的 HELD.clear() 行]
      with_inner  finally 未执行的行: [内层 try: 行, 其自身的 HELD.clear() 行]
    

    各版本都会报告的「其自身的 HELD.clear() 行」属于另一类情形:注入到清理过程内部会跳过该清理的剩余部分,这在任何版本上都成立,也是任何 Python 程序都无法幸免的情形,因此不计入结论。真正的差异只有一条:内层 try: 那一行,自 3.11 起才出现。

    因此实践规则比「避免嵌套处理器」更窄:在持有资源期间进入的嵌套 try,自 3.11 起存在一行使外层清理不执行。 with 语句不具有这一行为——BEFORE_WITH 是一条真实指令,其行事件落在外层保护区间之内。

    复核结果:上述结论已被独立验证

    本 issue 正文第 3.1、3.2 节对以上主张做了独立复核,结论一致并有所扩展:

    • 上述脚本原样重跑,3.11.1 与 3.14.1 上 with_inner 确实在内层 try: 行额外逃逸,3.10.1 上没有。分界线精确落在 3.11。
    • 版本范围从原评论的 3 个扩展到全部 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):3.7–3.10 不逃逸,3.11–3.14 逃逸。
    • 字节码层面给出了原因:该行在 3.10 编译为 SETUP_FINALLY(真实指令),在 3.11+ 编译为 NOP,且该 NOP 的偏移量落在 co_exceptiontable 覆盖区间的空隙里,不被任何条目覆盖。with 版本在同一偏移量上是 LOAD_GLOBAL,受覆盖——这与原评论关于 BEFORE_WITH 的说明相符。
    • 注入点计数与原评论部分不同:重跑在 3.11+ 上得到 no_inner 4/5、with_inner 5/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 处为非缺陷)见第五节;处理意见与优先级见第六节;维护纪律见第七节。

  3. changed the title [-]Raw descriptors are acquired before the try that releases them, so a Ctrl-C leaks them[/-] [+]裸资源在其清理处理器建立之前被获取,中断时会泄漏描述符与临时文件[/+] on Jul 31, 2026
  4. HansBug commented on Aug 3, 2026

    @HansBug
    OwnerAuthor

    相关但未处理,保持开放

    伞 PR #383 已合入 main(merge commit 980ce49e11ff)。本 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 后续要系统性清理那八处,建议一并考虑同样的验证方式。

  5. HansBug commented on Aug 5, 2026

    @HansBug
    OwnerAuthor

    分支协调说明已过时:BMC 解释伞已合入 main

    身份:主会话。本 issue 正文的「分支协调」一段写着 pyfcstm/entry/bmc.py 在 dev/bmc-infeasibility-explanation-umbrella 内有 +122/−11 的改动,需要避让。该伞已于 2026-08-05 以 merge commit df09ce5a 合入 main(伞 PR #387),分支已不再是活动分支,那段避让约束不再适用 —— 以 main 上的当前 entry/bmc.py 为准即可。

    本 issue 的缺陷本身未受影响,也未被该伞修复,保持开着。

  6. changed the title [-]裸资源在其清理处理器建立之前被获取,中断时会泄漏描述符与临时文件[/-] [+]资源在其清理处理器建立之前被获取,中断会泄漏描述符、临时文件、临时目录与子进程[/+] on Aug 5, 2026
  7. added
    status: in progressImplementation or an associated active PR is underway.
    and removed
    status: proposedProposal or discussion requiring a decision before implementation.
    on Aug 5, 2026
  8. HansBug commented on Aug 5, 2026

    @HansBug
    OwnerAuthor

    本 issue 已由 PR #441 实现并合入 main(合并提交 dd5053e9)。以下是逐项交付结论、证据、以及需要明确接受的残余风险。有一项验收(第 10 项)未满足,已如实标注,不按满足计。

    一、交付范围

    第 5.2 节点名的 9 处全部改造,外加 5.3 节点名的 report.py:501。另有两处由多智能体 review 发现、原普查未覆盖的站点也一并修掉,理由见第四节。

    # 站点 资源 采用形状
    1 registry.py _run_subprocess_bounded 子进程 with(新增 _bounded_popen)+ 函数下沉(_kill_and_reap)
    2 bmc.py write_bmc_output 临时文件 + fd 哨兵 + 所有权移交清空 + 清理移入 finally
    3 process.py run_check_process 临时目录 with(新增 _session_transport)
    4、5 同上 两个 TemporaryFile with(新增 _output_spools)
    6 同上 子进程 with(新增 _worker_tree / _WorkerTree)
    7 _build_identity.py write_build_identity_file 临时文件 + fd 哨兵 + 移交清空 + 函数下沉(_fsync_directory)
    8 worker.py _write_frame fd 函数下沉(_append_frame_to_file)
    9 _bootstrap.py _emergency_write 临时文件 + fd 哨兵 + 清理合并为单一 finally
    — report.py write_report fd 哨兵 + 所有权移交清空
    追加 worker.py _execute_worker_callback 进程级全局(os.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_bounded 28 / 15(含 2 处活子进程、多处僵尸) 29 / 0
    write_bmc_output 11 / 6 21 / 0
    write_build_identity_file 28 / 4 29 / 0
    _write_frame 10 / 1 5 / 0(_append_frame_to_file 8 / 0)
    write_report 15 / 1 21 / 0
    _emergency_write 16 / 4 20 / 1(幸存点是移交 return,该文件是返回产物,finally 有意不删)
    run_check_process 端到端 88 / 33(31 处残留 session 目录,其中 2 处另留活的 worker) 74 / 0

    改造后的三个上下文管理器与下沉出的 helper 也各自单独注入过(_session_transport 9 点、_output_spools 10 点、_worker_tree 10 点,均 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 体 3.10 3.11 3.12 / 3.14
    pass 不漏 漏 try: 漏 try: + 多一行
    一条真实语句 不漏 漏 try: 漏 try:,无额外
    内层 try/finally 不漏 漏 try: 漏 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 时把做法抄走了、把写在旁边的原因丢了。

    五、验收对照

    项 结论 证据
    1 ✅ 9 处 + report.py:501,形状见第一节表
    2 ✅ test_run_subprocess_bounded_leaves_no_orphan_at_any_injection_point,sys.settrace 驱动,28/15 → 29/0
    3 ✅ test_write_bmc_output_leaves_no_descriptor_or_temporary_file,注入驱动、不依赖真实信号,11/6 → 21/0
    4 ✅ 窗口宽度:四处资源全部由 ExitStack 在获取前持有;释放:端到端注入用例已入仓且默认运行(74 点、约 30 秒),88/33 → 74/0,另加三个 CM 各自的 finally 单测与逐行注入
    5 ✅ 第 7 处 28/4 → 29/0,第 8 处 10/1 → 5/0;:503 的目录 fsync 下沉由 test_directory_flush_is_delegated_rather_than_opened_inline 以替换观察委派行为覆盖(非扫描源码文本)。第 9 处不适用「未测」条款:注入环境可构造(禁掉 sys.stderr.buffer 与 fd 2),已落成用例
    6 ✅ 6 处 → 0 处。判据为 make resource_ownership_check,AST 静态、版本无关、函数清单自动发现,接在 CI 的 lint 作业(无 if/needs,不受提交信息旁路令牌影响)。同一判据在改造前修订上报出 6 处
    7 ✅ 新增的 except OSError: pass 均为单一职责释放 helper 且各注明原因;尚有汇报通道时照常汇报。两处 finally 有意让 os.close 失败上抛(_append_frame_to_file 由调用方转成可汇报诊断、_fsync_directory 无在途异常),已在 PR 正文写明而非笼统声称「均不抛出」
    8 ✅ 除 report.py:501 外,5.3 节的 _build_identity.py:503 也被改动——那是同一 issue 第 5 项与 6.5 节明文要求的。其余 14 处零改动
    9(硬性) ✅ 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 静默吞噬禁令的放宽边界及其条件
    10 ❌ 未满足 见下
    11 ✅ 五类排除项已声明;另补充决策 ② 要求的披露:三个新 CM 是生成器式,故 GeneratorExit 排除项对其适用

    第 10 项为何未满足

    建了结构性门禁,四项条件中满足三项:改造后全部通过 ✅;在改造前修订上失败(6 处)✅;对 5.3 节 16 处无误报 ✅;AST 静态、版本无关 ✅。「须覆盖三条轴」不满足——它只覆盖轴 3 的一种形状。 轴 1、轴 2 仍只有逐行注入用例覆盖,没有机械门禁。

    需要一个决定:接受一轴门禁,还是修订该项。我倾向前者,理由见下面残余风险第 1、2 条——三轴普适门禁的假阳性代价可能高于收益,而这次的经验表明判据必须被实测校准才有意义。

    六、残余风险(合并即意味着接受这些)

    1. 门禁有三类看不见的形状,而三类在本仓库都真实出现过:① 完全不需要嵌套 try 的逃逸(with X: pass,pass 编译成裸 NOP——这才是三个 CM 泄漏的真实机制);② 经 helper 间接获取(而把获取下沉为 helper 恰恰是第七节第 2 条的补救手段,所以改造后的代码比改造前更难被这个门禁复查);③ 进程级全局状态。这三类只有逐行注入能覆盖。已写入 CLAUDE.md 第 12 条。

    2. 门禁报出的集合与第 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 条目的误报。

    3. 平台不对称:18 个新测试中 13 个依赖 /proc/self/fd,在 Windows 与 macOS 上会 skip。也就是说非 Linux 平台上,那条版本无关的 AST 门禁是唯一防线,而它是两件仪器中较窄的一件。

    4. 真实 Ctrl-C 残留是大幅减少,而非被证明消除。 获取原语调用自身那一个字节码上的窗口无法在 Python 层关闭——用真实 SIGALRM 对三种交接写法交错测量,命中率分别为 33.4% / 33.2% / 46.8%,即各形状之间并无差异。/tmp/pyfcstm-selfcheck-* 仍可能在中断后残留。逐行注入证明的是「没有行级可达窗口」。

    5. GeneratorExit:三个新 CM 均为 @contextlib.contextmanager 生成器,未采用 class 式 __enter__/__exit__,故该排除项对其适用。

    6. 本 issue 关闭后成为承载性文档:CLAUDE.md 第 12 条之后的方法学段落把第七节第 7、9 条(普查完整性、测量设计)以链接形式委托给本 issue。这份 issue 正文必须保持完整可读,否则该部分政策会失效。

    7. 跨版本与真实信号的数字来自实现环境,仓库内无脚本可复现。 其所依赖的机制(外层 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 个文件。

    八、验证

    • CI 全绿:61 pass / 2 legitimately skipped / 0 fail,含 23 个 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
    • 门禁自身做过变异测试:9 种「让扫描器失效」的手法抓到 8 种;未抓到的一种是直接修改阈值常量本身,任何以常量为基准的下界都无法自检这一点

    九、过程说明

    实现经过 5 轮、10 位 reviewer 的多智能体 code review,每轮都找出真问题,其中两次是「修一个缺陷时引入另一个」。这些都记录在 PR #441 的正文与提交历史里。这个过程本身印证了第七节第 8 条的判断:这类缺陷靠代码评审拦不住,因为改动往往看起来更完善、实则更弱。

  9. added
    status: completedWork was delivered and the issue is closed as completed.
    and removed
    status: in progressImplementation or an associated active PR is underway.
    on Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: packagingPackages, bootstrap/self-check, frozen artifacts, resources, and publishing.kind: bugConfirmed incorrect behavior or regression.status: completedWork was delivered and the issue is closed as completed.

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions