Skip to content

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

Description

@HansBug

结论摘要

本仓库存在一类缺陷:资源在负责释放它的处理器建立之前就已经被获取,中间那段窗口是无主的。资源不限于文件描述符——普查后确认还包括临时文件、临时目录和子进程。该缺陷类在 CPython 上游有权威记录(PEP 419bpo-29988 / cpython#74174),上游从未彻底消除、最终选择文档化(bpo-42340),标准库自身也中招过(cpython#106238logging._acquireLock())。

三句话结论:

  • 最严重的一处不在最初报告范围内:pyfcstm/_selfcheck/registry.py_run_subprocess_bounded 整个函数没有任何 finally,实测 28 个注入点中 14 处留下孤儿子进程。孤儿进程与描述符不同,不随父进程退出被操作系统回收。
  • 三轴普查在当前 main 上确认 9 处真实缺陷,分布在 6 个函数中;另有 16 处获取点判定为已合规或设计如此。最初报告点名的是 3 处站点、共 4 处获取点,仅覆盖其中一小部分。
  • 唯一有稳定用户可见症状的是 pyfcstm/entry/bmc.pywrite_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#84403PR #25729):SETUP_FINALLYPOP_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 与上下文管理器都不受 KeyboardInterruptgenerator.throw() 引发的 GeneratorExit 保护。「获取之后、登记清理之前」这一表述来源于此,而非 bpo-29988
bpo-29988 / cpython#74174 标题为 with statements are not ensuring that __exit__ is called if __enter__ succeeds。由 trio 作者 Nathaniel Smith 提出。需要注意其原始描述针对的是退出侧的窗口(POP_BLOCKWITH_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.setitimerSIGALRM,二者在 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 #18334eval_breaker 检查放在调用返回之后,而当获取动作本身是一次真实的 C 调用时,那个检查点恰好落在无主窗口内部。因此 3.3 节在 3.11+ 得到的 0 是合成用例的假象。

改用真实原语重测需要一个与描述符编号无关的判据。用 set(os.listdir("/proc/self/fd")) 做前后差集是错的os.listdir 自身的目录描述符会占据最小空闲编号,恰好就是泄漏描述符会拿到的编号,从而把每一次泄漏都掩盖掉。本节改用「统计 /proc/self/fdreadlink 指向 /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_FASTtry 入口之间的间隙: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.pywrite_bmc_output 11 个注入点,6 处残留 .result.json.*.tmp(行 749–754),另有 2 处裸描述符残留
config/_build_identity.pywrite_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.pyrun_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 产物写出器),但其范围从来不含 GUIpyfcstm/diagram/api.py 那处已由 PR #389 修掉,最初报告开头即写明是「另开 issue 而不扩大那个 PR」。真正的 GUI 缺陷另有其事,例如已于 2026-08-03 关闭的 issue #409issue #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」。轴三匹配「某个 trybody 内先出现获取调用,其后又出现另一个 try」。

结果:轴一命中 62 处(with 托管 37、裸获取 24、其他 1),轴二命中 5 处,轴三命中 3 处。该 62 处含内置 openos.makedirsthreading.Lock 构造等并不需要显式释放的调用,故 62 / 37 两个数字只用于说明扫描覆盖面;真正进入人工判定的是 24 处裸获取加 1 处「在 with 块内但非托管」,共 25 处,恰为第 5.2 节 9 处与第 5.3 节 16 处之和。随后对每处裸获取自动判定「是否被带 finallytry 覆盖,且该 finally 内是否有释放调用」,再人工判定「是否设计如此」。

另记两条普查状态。:补扫 .acquire() / threading.Lock / RLock 全仓零命中pyfcstm/ 不存在锁获取点,第七节第 6 条提到锁属前瞻性约束,不产生存量债务。套接字registry.py:2253:2259 两处为 socket.create_connection,均由 with 托管,已计入上文 37 处托管中。两条均记录为「已查」,而非未查。

最初报告的原语清单只有 7 个,缺少 subprocess.Popentempfile.mkdtemptempfile.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:744write_bmc_output,定义在 716) 临时文件 + 描述符 函数内无任何 finally ✅ 11 注入点 / 6 处残留 一 + 二
3 pyfcstm/_selfcheck/process.py:448run_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:492write_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 内两个 TemporaryFilewith 托管,但 subprocess.Popen 是裸获取且整个函数没有任何 finallykill 只出现在若干 except 与超时分支里。孤儿子进程不随父进程退出被回收,性质比描述符泄漏严重。第 3 至 6 处同属 run_check_process 一个函数:其 finally(关键字在行 701)无条件清理了会话目录与两个缓冲流(stdout_spool / stderr_spool),但对子进程只走 Windows 路径——行 702 的 if job is not Nonejob 仅在行 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:501write_report 临时文件一侧已合规temporary = None 在 497,finally 中条件 os.unlink)。残余问题与第 5.2 节第 7 处同形:描述符在 501 获取、504 才由 os.fdopen 接管,中间 1 个语句行不受任何处理器覆盖。因该窗口不残留磁盘产物、失败路径已由 finally 兜住,此处不单列为缺陷,但改造时应一并采用 6.2 节的所有权移交形状
pyfcstm/config/_build_identity.py:503 被带 finallytry 覆盖且 finally 内有 os.close
pyfcstm/diagram/api.py:960 / :961_open_standalone_window 被带 finallytry 覆盖,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 / :471open_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 / :606pyfcstm/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 #389pyfcstm/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: packagingarea: bmcarea: 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 #389api.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:492bmc.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 不导入 registryregistry.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.Popentempfile.mkdtemptempfile.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: passCLAUDE.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_boundedrun_check_process 两个函数,涵盖全部两处子进程站点与最宽的窗口)? 后者覆盖全部不可回收资源与最宽的窗口,前者一次做完。需注意代价方向:第 7、8、9 处窗口宽度为 0–1 个语句行、修法有现成形状,是最便宜的一批;第 1、3–6 处需下沉为上下文管理器,是全部工作量的主体。因此「先做前两项」推迟的是便宜的部分,并不减少主要工作量。

  2. run_check_process 是整体重构还是逐点加哨兵? 需先纠正一个事实:四处资源共用行 701 的同一个 finally,且 _close_capture_streamprocess.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_processprocess 没有哨兵,只在行 509 绑定。因此一旦把 try 加宽到行 446,行 690 的 except BaseException 在 446–508 之间任何异常发生时都会崩溃,且两种改法各有一种形态:不加哨兵时 _terminate(process, ...) 在实参求值处抛 UnboundLocalError(尚未进入 _terminate);按第七节第 1 条补上 process = None 后,则在 _terminate 行 267 抛 AttributeError。两者都会掩盖原本干净的 KeyboardInterrupt,因此必须在加宽 try 之前先给 _terminateNone 守卫。

    建议整体重构:把三段获取/释放下沉为 _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_workerfinally,否则本次重构自身即违反第八节关于「清理失败仍可观察」的要求;其二,若三个助手写成 @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

「无主窗口宽度」的计算方式:对未被覆盖的获取点,找出函数内该行之后最近的带 finalbodytry,统计两者之间的语句行数。

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