15 KiB
训练前端 Windows 兼容性与「暂停」审计(结论 + 修复方案)
审计对象:
src/mcm26b/training_ui/、src/mcm26b/process_control.py、scripts/train_hrl_high.py。 触发问题:「无法暂停」「频繁出现 os 无 killpg 等」。 结论一句话:这两个现象同源——训练前端的停止/暂停路径是纯 POSIX 设计; Windows 上os.killpg根本不存在,异常又没被兜住,所以点了停止等于没点。
0. 结论摘要
| # | 现象 | 根因 | 严重度 |
|---|---|---|---|
| 1 | 点「停止训练」返回 500 / 无效果,训练照跑,于是「无法暂停」 | TrainingJob.stop() 直接调用 os.killpg(os.getpgid(...));这两个符号只存在于 POSIX,Windows 上抛 AttributeError,而旧 except 只接 ProcessLookupError/PermissionError |
阻断 |
| 2 | 控制台/日志反复出现 module 'os' has no attribute 'killpg' |
同上:错误最终落到 _UIHandler.do_POST 的兜底 except Exception,返回 500 并带出 AttributeError 文本;每点一次复现一次 |
阻断 |
| 3 | 即使把 killpg 换成 terminate(),停止后模型/元数据仍然丢失(Windows 上) |
Windows 的 os.kill(pid, SIGTERM) 语义是 TerminateProcess 硬杀,训练脚本里"收到信号后在 step 边界保存"的处理器不会执行 |
高 |
| 4 | 界面根本没有「暂停」能力(POSIX 也没有) | 原设计只有 start/stop;且 SIGSTOP/SIGCONT 在 Windows 上不存在,原生暂停在 Windows 上无法实现,必须改成协作式 |
高 |
| 5 | 子进程进程组隔离在 Windows 上失效 | Popen(start_new_session=True) 在 Windows 被静默忽略(CPython 的 Windows _execute_child 形参名就是 unused_start_new_session) |
中 |
| 6 | subproc worker 在 Windows 下可能抢在主进程收尾前退出 | worker 只忽略 SIGTERM/SIGINT,没处理 Windows 的 SIGBREAK(CTRL_BREAK_EVENT 的落点) |
中 |
修复原则:把"暂停/继续/停止"从信号语义改成控制文件语义。 控制文件在 POSIX 与 Windows 上都只是普通文件 IO,行为完全一致;信号与强杀退居 兜底。这样 Windows 上暂停、继续、优雅停止、落盘保证全部成立。
1. 失败链复现(旧代码)
用户点「停止训练」
└─ POST /api/train/stop
└─ TrainingUI.stop() → TrainingJob.stop()
└─ os.killpg(os.getpgid(process.pid), signal.SIGTERM)
├─ POSIX :成功,进程组收到 SIGTERM,脚本优雅收尾
└─ Windows:AttributeError: module 'os' has no attribute 'killpg'
↑ 只 except (ProcessLookupError, PermissionError),接不住
└─ _UIHandler.do_POST 兜底 except Exception → HTTP 500
└─ 训练子进程完全没收到任何指令,继续跑 → 「无法暂停 / 停止没反应」
关键代码(修复前,src/mcm26b/training_ui/server.py):
def stop(self) -> bool:
with self._lock:
process = self.process
if process is None or process.poll() is not None:
return False
try:
os.killpg(os.getpgid(process.pid), signal.SIGTERM)
except (ProcessLookupError, PermissionError):
process.terminate()
self.status = "stopping"
return True
2. 根因逐条(含证据)
2.1 os.killpg / os.getpgid 是 POSIX-only
CPython 只在 posix 构建里注册这两个函数;Windows 的 os 模块没有它们。
访问会得到 AttributeError(不是 OSError 子类),所以旧 except 接不住。
2.2 start_new_session=True 在 Windows 被静默忽略
subprocess.Popen 接受该参数,但 Windows 的 _execute_child 形参名是
unused_start_new_session,也就是根本不读。要隔离进程组,Windows 必须传
creationflags=CREATE_NEW_PROCESS_GROUP。
2.3 Windows 的 SIGTERM 是硬杀
os.kill(pid, sig) 在 Windows 上只有 CTRL_C_EVENT / CTRL_BREAK_EVENT 是
"软"信号;任何其它值(含 SIGTERM)都会走 TerminateProcess。因此
scripts/train_hrl_high.py 里的:
for _signum in (signal.SIGTERM, signal.SIGINT): # 旧代码
signal.signal(_signum, _on_stop_signal)
在 Windows 上形同虚设——处理器注册了,但没人会用它。这也解释了为什么"停止后 模型没保存"。
2.4 Windows 没有 SIGSTOP,暂停无法用信号实现
暂停(挂起/继续)在 POSIX 上可以用 SIGSTOP/SIGCONT,但 Windows 既无这两个
信号,也无等价的"无侵入挂起"用户态 API(真正的挂起要 SuspendThread/调试 API)。
所以任何跨平台暂停都必须是协作式的:由训练进程自己在 step 边界检查标志并
让出执行。本项目原先没有这条通道,界面也没有暂停按钮。
2.5 SIGBREAK 未处理
Windows 的 CTRL_BREAK_EVENT(发给进程组的软信号)在 Python 里落到
signal.SIGBREAK。旧的 handler 安装与 worker 屏蔽列表都只含
SIGTERM/SIGINT,Windows 下等于裸奔。
2.6 异常类型没兜住
except (ProcessLookupError, PermissionError) 漏掉 AttributeError、以及
更一般的 OSError;_hard_kill 里 signal.SIGKILL 在 Windows 上也不存在
(getattr(signal, "SIGKILL", None) is None),同样会 AttributeError。
3. 修复方案(已实施)
3.1 新增 src/mcm26b/process_control.py(零第三方依赖)
| 能力 | 实现 | 作用 |
|---|---|---|
| 子进程隔离 | spawn_kwargs():POSIX start_new_session=True;Windows creationflags=CREATE_NEW_PROCESS_GROUP |
前端 Ctrl+C 不误伤训练;Windows 也能组杀进程树 |
| 控制协议读写 | write_control() / read_control():JSON + 同目录临时文件 + os.replace 原子替换;损坏/缺失一律视为"无指令" |
两端统一的暂停/停止通道 |
| 强杀兜底 | terminate_process_tree(process, grace_s):先等宽限期;超时后 POSIX 走 killpg(SIGKILL)、Windows 走 taskkill /F /T /PID;所有 POSIX 符号用 getattr 探测 |
彻底移除 os.killpg 无条件调用;返回"是否动用了强杀" |
IS_WINDOWS 是运行期常量而非导入期分支,便于测试直接 monkeypatch 模拟 Windows。
3.2 控制文件协议
路径由前端生成并随命令下发:--control-file <abs>(默认落在
output/hrl/ui/control/<job_id>.control.json,output/ 已 gitignore)。
{"paused": false, "stop": false, "seq": 1712345678901234567, "ts": 1712345678.9}
- 前端只写:
pause()→paused=true;resume()→paused=false;stop()→stop=true(同时清零 paused,保证阻塞中的暂停循环能被唤醒)。 - 训练脚本只读:
_ControlPoll每 32 个 step 抽样读一次盘(避免每 step 一次 文件 IO);暂停时在_on_step内阻塞轮询(poll_s=0.2),进程存活、显存仍占用, 但不消耗算力。 - 墙钟上限
--max-wall-s扣除累计暂停时长,避免"挂起一小时回来直接判超时"。
3.3 训练脚本 scripts/train_hrl_high.py
- 新增
--control-file;_ControlPoll(纯标准库、可单测)+ 薄薄的_ControlChannel(BaseCallback)接到 SB3 回调链最前面。 - 收尾信号集合扩为
_stop_signals():POSIXSIGTERM/SIGINT;Windows 追加SIGBREAK。worker 屏蔽同一集合,保证主进程vec_env.close()之前 worker 不先退。 - 落盘保证不变:停止后仍写最终模型 + 元数据,
stopped_by_signal=true,stop_signal记control_file或信号名;异常仍落*_emergency.zip。
3.4 训练前端 server.py / static/
- 新增
POST /api/train/pause、POST /api/train/resume;stop语义改为 "写控制文件 → 宽限期内自行退出;超时强杀",强杀在独立看门狗线程里做, HTTP 请求不会被 15s 等待卡住。 - 状态机新增
paused;_sync_control_events()用训练脚本回显的paused/resumed/stop_requested事件校正状态。 - 快照新增
forced_kill,前端横幅区分"优雅停止"与"超时强杀"。 - 前端新增「暂停训练 / 继续训练」按钮、暂停横幅;停止确认文案更新。
3.5 停止/暂停时序
pause: UI 写 {paused:true} ──► 脚本 _on_step 读到 ──► 回显 paused,阻塞轮询
resume: UI 写 {paused:false} ─► 暂停循环退出 ────────► 回显 resumed,继续训练
stop: UI 写 {stop:true} ────► 脚本 step 边界收尾落盘 ──► 退出码 0
│
└─(宽限 15s 未退)─► terminate_process_tree 强杀;forced_kill=true
3.6 急停(强杀)也必须可恢复
宽限期一到就强杀,训练脚本没有任何 Python 收尾机会——不写最终模型、不写
元数据、不写 training_failure.json。如果磁盘上只有 --checkpoint-every 的稀疏
历史 checkpoint,急停就可能丢掉上万步,或者在"保存途中被杀"时留下一个打不开的
半截 zip。为此加了四条保证:
| 保证 | 实现 | 效果 |
|---|---|---|
| 起步基线 | recovery_every > 0 时,learn() 之前先写一份 <stem>_latest.zip |
训练刚起就急停,也有可加载、可 --resume 的产物 |
| 滚动恢复点 | 每 --recovery-every(默认 2000)环境步覆盖写同一份 <stem>_latest.zip |
急停最多丢 recovery_every 步,而不是 checkpoint-every 步 |
| 原子落盘 | 模型 zip / 旁挂 json / resume.json 一律「同目录 .tmp- + os.replace」 |
保存途中被强杀,目标路径仍是上一份完整快照;不会出现半截文件 |
| 权威恢复指针 | 每次落盘刷新 <run_dir>/resume.json(model / timesteps / 增量语义 / 生效 PPO 超参);前端 /api/train/state 暴露为 recovery |
无论怎么停,都有一份明确的"从哪继续";界面一键「用最近检查点续训」 |
前端在强杀后还会补写 <run_dir>/forced_stop.json(job_id、pid、reason、
recovery.model),因为此时训练脚本已经无法记录任何事情。
4. 平台兼容性矩阵(修复后)
| 能力 | POSIX | Windows |
|---|---|---|
| 启动子进程并隔离 | start_new_session=True(setsid) |
CREATE_NEW_PROCESS_GROUP |
| 暂停 / 继续 | 控制文件(协作式) | 控制文件(协作式) |
| 优雅停止 + 落盘 | 控制文件;SIGTERM 亦可 |
控制文件;CTRL_BREAK_EVENT→SIGBREAK 亦可 |
| 超时强杀进程树 | killpg(SIGKILL) |
taskkill /F /T /PID |
os.killpg 依赖 |
有 | 无(不再调用) |
| 暂停期间是否算墙钟 | 否(扣除) | 否(扣除) |
5. 验证
- 新增/更新回归测试:
tests/test_training_ui.py:控制文件协议往返与损坏容错、spawn_kwargs平台分支、Windows 强杀走taskkill、无killpg环境下强杀不抛异常 (直接复现旧 bug)、暂停/继续/停止全链路、控制文件被忽略时强杀并写forced_stop.json恢复指引、discover_recovery的resume.json/扫描/悬空指针三分支、HTTPpause/resume端到端。tests/test_hrl_checkpoint.py:控制文件状态机单测;原子保存失败不破坏上一份 快照;JSON 原子替换;历史清理不误删滚动恢复点;起步基线可加载; 强杀急停后resume.json+ 滚动恢复点仍可--resume;控制文件停止后 模型/元数据落盘(stop_signal=control_file)与暂停→继续;POSIX 信号路径 单列并加平台跳过。以上集成用例都用真实训练脚本跑 subproc。
- 手工验证:
--recovery-every 32短程实跑后强杀,resume.json指向q3_ckpt_latest.zip;以它为--resume目标再跑 32 步,元数据actual_timesteps从 64 变 96、timesteps_semantics=additional。 - 全量测试:
pytest tests referee/tests全绿。 - 全仓库
pylint src scripts project tests referee main.py达到 10.00/10 (口径见 §7)。
6. 残余风险与后续建议
- 暂停粒度 =
poll_every个 env step(默认 32,约 0.1–3s 量级),不是即时; 若要更快可调小_ControlChannel(poll_every=...),代价是更频繁的文件 stat。 - 宽限期固定 15s(
server.STOP_GRACE_S)。大模型保存可能更久;但因为落盘是 原子的,即使宽限期到点被强杀,也只是"新的最终模型没写成",上一份滚动恢复点 与历史 checkpoint 完好,不会出现半截文件。需要更长可调大该常量。 - 急停的损失上界 =
--recovery-every(默认 2000 环境步)。要更小就调小它; 调到 0 表示放弃滚动恢复点(此时只剩--checkpoint-every的稀疏历史 checkpoint 与起步基线)。 - Windows 强杀依赖
taskkill(系统自带)。若被安全策略禁用,会退回Popen.kill()(只杀主进程,spawn 的 worker 可能残留)。 - 更严格的 Windows 方案是 Job Object(子进程随 job 句柄一起销毁),但需要
ctypes/pywin32,本仓库基础依赖刻意保持轻量,未引入。 - 前端进程重启后,内存中的任务对象没了,「用最近检查点续训」按钮不会自动
出现;但恢复信息都落在磁盘上(
<run_dir>/resume.json、forced_stop.json、checkpoints/<stem>_latest.zip),在表单里填同一个模型输出路径并勾选--resume即可继续。 - 界面
--device mps选项在 Windows 上无意义;auto会回退 cuda/cpu,属既有 行为,未改。 _ControlPoll的暂停循环会占用一个 Python 线程的调度(每 0.2s 一次读盘), 长暂停期间 CPU 占用可忽略。
7. pylint 口径说明
配置写在 pyproject.toml 的 [tool.pylint.*],目标是
pylint src scripts project tests referee main.py = 10.00/10。
改代码修掉的真实问题(不是 disable):未使用的 import/变量/参数、重复字典键
(reward_version)、value != value 判 NaN、__dataclass_fields__ 私有访问、
raise 非异常类型、pytest.importorskip 之外的赋值不确定、过长行、无谓括号、
len(x) 判空、循环闭包变量、subprocess.run 未显式 check、os.path 循环导入等。
有意 disable 的类别(每条在 pyproject 里写了理由):项目刻意的懒加载
(import-outside-toplevel)、防御式宽异常(broad-exception-caught)、测试访问
内部状态(protected-access)、pytest fixture 同名(redefined-outer-name)、
无头环境不实现 gym.Env.render(abstract-method)、以及静态分析的已知误报
(possibly-used-before-assignment)。
注意:good-names 不覆盖 redefined-builtin,stdlib 回调签名里的
log_message(self, format, ...) 只能在对应 def 上行内 disable。