Files
zyt/app/research/update_install_handoff_diagnosis.md
2026-08-28 18:24:37 +08:00

116 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Windows 更新安装交接故障诊断
## 结论
“下载完成后显示即将关闭,但程序不退出/不安装”不是下载失败。当前现场同时存在三个问题,其中第 1 项能够确定性复现用户看到的主症状,第 2 项是本次现场已经发生但被代码吞掉的 helper 启动失败,第 3 项是必须立即纠正的发布配置错误。
1. **确定性根因:强制更新对话框拒绝了 `QApplication.quit()` 触发的关闭事件。** 下载 job 完成后,session 启动 helper、设置 `_apply_committed=True`,随后在 `finished` 回调中调用应用级 `request_quit()`;但对话框此时仍是 `offer.force=True``_busy=True`,其 `closeEvent()` 无条件 `ignore()`。Qt 明确允许窗口通过 Close event 阻止 `quit()`,所以事件循环不退出,`aboutToQuit`/`ApplicationController.shutdown()` 也不会发生。helper 又先等待当前 PID 消失,于是交接形成闭环等待。
2. **独立的已确认问题:PowerShell helper 进程被创建后,在执行脚本首条日志之前就退出了,而父进程把“CreateProcess 成功”误判成“helper 已接管”。** 现场有精确的 PowerShell 启动事件,但没有 `update_helper.log`、没有 `inno_setup.log`、没有存活 helper/installer 进程。代码丢弃 `Popen` 句柄、不检查早退、也没有 ready handshake,因此 UI 仍停在“即将关闭”。现有证据不足以还原该次子进程的退出码;这正是当前可观测性缺口。
3. **发布配置错误:服务端宣称最新/最低版本为 `1.3.0`,实际下发的安装器却是 `1.1.0`。** 本机当前运行 `1.2.0`,所以即使退出与 helper 均修复,也会尝试降级安装,而不是升级到 `1.3.0`。修复客户端前应先停止这条强制更新配置。
## 代码交接链路与根因证据
### 1. UI 已经请求退出,但强制对话框否决退出
当前交接顺序是:
1. `_start_install()` 设置 busy、启动 QThreadPool 下载 job`app/src/doctor_workstation/ui/dialogs/app_update.py:481-555`)。
2. job 下载并校验 Inno Setup EXE,返回 `_PreparedUpdate``:511-545`)。
3. `_finish_install()` 先显示“即将关闭程序并自动安装”,再调用 `dialog.set_apply_committed()`;该方法把 `_busy` 保持为 true`:616-628``:275-283`)。
4. `apply_downloaded_update()` 进入 `apply_inno_setup_update()`,写脚本并 `Popen` PowerShell helper`app/src/doctor_workstation/services/app_update.py:429-472`)。
5. QRunnable 随后发送 `finished``_on_install_finished()` 清理 active signals 并调用 `_complete_quit()`UI `:634-641`)。
6. `_complete_quit()` 调用 controller `request_quit()`controller 用 `QTimer.singleShot(0, self.application.quit)` 请求正常退出(UI `:472-479``app/src/doctor_workstation/app.py:1081-1086`)。
7. 但是更新对话框的 `closeEvent()``offer.force` **或** `_busy` 为真时执行 `event.ignore()`UI `:296-300`)。此时两个条件都为真。
使用当前 PySide6 做了不改文件的最小事件循环复现:显示一个 modal dialog,其 `closeEvent()` 执行 `ignore()`50 ms 后调用 `QApplication.quit()`,并设置 2 秒进程级 watchdog。输出为:
```text
calling quit
closeEvent ignored
CODE=9
```
也就是 `quit()` 确实到达了窗口,但被 Close event 否决,事件循环直到 watchdog 才终止。Qt 官方文档同样说明 `QCoreApplication.quit()` 可能被仍未关闭的窗口或被忽略的 Quit/Close event 阻止:<https://doc.qt.io/qt-6/qcoreapplication.html#quit>。
这也解释了为何 controller 的 `aboutToQuit -> shutdown()` 接线本身没有帮助:`aboutToQuit` 只有在退出请求未被阻止时才会发出(`app.py:425-427``:1088-1105`)。
### 2. Inno helper 的等待设计放大了 UI 退出缺陷
生成的 `install_update.ps1` 首先写 `waiting for pid ...`,然后无限轮询 `Get-Process -Id $TargetPid`;只有目标 PID 消失后才启动安装器(service `:566-627`)。父进程传入的是 `os.getpid()``:637-672`)。因此,只要 Qt 主进程被对话框留下,安装器就不可能启动。
安装器参数本身与 Inno 静默更新意图一致:`/VERYSILENT``/SUPPRESSMSGBOXES``/NORESTART``/CLOSEAPPLICATIONS``/NOFORCECLOSEAPPLICATIONS``/NORESTARTAPPLICATIONS``/LOG=...`;返回 `0`/`3010` 后才重启已安装的 `DoctorWorkstation.exe`,其他返回码重启旧程序(`:599-627`)。`powershell.exe -File <script> <script args>` 的排列也符合 Windows PowerShell 5.1 的 `-File` 契约:<https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_powershell_exe?view=powershell-5.1>。
不过当前等待还有两个健壮性问题:
- 循环无超时、只按整数 PID 查询;极端情况下 PID 被复用会继续等待无关进程。更安全的是先取得特定 `Process` 对象/句柄,再等待该对象退出,而不是每 400 ms 重新按 ID 查找。
- helper 的成功标准只是 `subprocess.Popen(...)` 没有同步抛出 `OSError``Popen` 返回后句柄立即丢失,没有 child PID 日志、早退检测、stderr 捕获或 ready handshake`:637-672`)。
当前还同时设置 `DETACHED_PROCESS``CREATE_NEW_PROCESS_GROUP``CREATE_NO_WINDOW``:645-670`)。Microsoft 文档指出 `CREATE_NO_WINDOW``DETACHED_PROCESS` 同用时会被忽略,因此这组 flag 至少是冗余且不能证明 helper 已进入脚本:<https://learn.microsoft.com/en-us/windows/win32/procthread/process-creation-flags>。本次现场只能确认 PowerShell 在脚本体前早退,不能仅凭事件日志断言具体是 flag、stdio、执行策略还是主机初始化中的哪一个原因;修复应以“可确认接管”为契约,而不是猜一个退出原因。
### 3. `apply_downloaded_update()` 的路由本身正确
`package_type=inno_setup` 会进入 `apply_inno_setup_update()`;后者要求 Windows、可解析的 frozen install root、存在的已安装 EXE,以及扩展名为 `.exe` 且 DOS header 为 `MZ` 的安装器(service `:390-401``:429-483`)。现场已经生成 `install_update.ps1`,证明路由、install root、EXE/PE 基本校验均已通过;故障发生在 helper 启动及应用退出交接之后。
## 本机现场证据(2026-08-28Asia/Shanghai
- 运行进程:PID `36304``C:\Program Files\ZYT\DoctorWorkstation\DoctorWorkstation.exe`,启动于 `18:08:13`;检查时仍 `Responding=True`、主窗口可见,文件 `ProductVersion=1.2.0`
- 应用日志:`C:\Users\pc\AppData\Local\Zhenyangtang\ZhenyangDoctor\Logs\doctor-workstation.log`
- `18:08:13`:应用启动。
- `18:08:15`:以 `current_version=1.2.0&platform=windows&arch=x64` 检查更新成功。
- `18:08:32`:下载 `DoctorWorkstation-Setup-Windows-x64-1.1.0.exe` 返回 HTTP 200。
- 此后没有安装/helper/退出阶段日志,也没有 Python 异常。
- 工作区:`C:\Users\pc\AppData\Local\Zhenyangtang\ZhenyangDoctor\updates\1_3_0`
- 安装器大小 `162,873,123` 字节,SHA-256 `0CDD38DB6EEF5E7B7380FFAE3FBC407B602EB916C68AE4C8F29496B621F50789``ProductVersion=1.1.0`,未签名。
- `install_update.ps1``18:08:37` 生成,PowerShell AST 解析无语法错误。
- `update_helper.log` 不存在;`inno_setup.log` 不存在。
- Windows PowerShell Operational 日志:`18:08:37.828` 有 Event `40961`“Powershell 控制台正在启动”,没有配对的 ready `40962`;检查时也没有命令行指向 `install_update.ps1` 的 PowerShell 或 Inno Setup 进程。这证明子进程被创建过,但没有进入脚本首条 `Write-Log`
- crash log 只有各次进程启动标记,本次没有崩溃堆栈。
- 实时只读请求更新接口得到:`latest_version=1.3.0``min_version=1.3.0``force=true`,但 URL、filename、size、sha256 全都对应上述 `1.1.0` 安装器。本机实测文件 hash 与接口 SHA 一致,说明下载正确,错误在发布元数据/产物绑定。
## 最小安全修复
### P0:先修发布配置
在正确的 `1.3.0` 安装器上传并核对 `ProductVersion`、filename、size、SHA-256 前,立即关闭这条强制更新或将其设为不可安装。不要让 `latest_version=1.3.0` 继续绑定 `1.1.0` 安装器。客户端后续应增加“offer 版本与包版本”的发布门禁;仅校验 HTTPS、SHA 和 `MZ` 不能防止签名正确的旧包被错误发布。
### P0:让应用级退出能够越过“用户不可关闭”的对话框门禁
不要把“禁止用户关闭强制更新弹窗”和“禁止应用已提交后的受控退出”共用同一个 `closeEvent` 条件。建议增加明确的 `_allow_application_exit` 状态:
- 用户点击标题栏关闭/Escape 时仍然拒绝;
- helper 已确认接管,或用户点击专用“退出软件”且更新 worker 已安全结束时,session 先设置 allow 状态并关闭/隐藏该 dialog,再调用 controller `request_quit()`
- `closeEvent()` 仅在 `not _allow_application_exit and (offer.force or _busy)` 时 ignore。
直接在主线程调用 `QCoreApplication.exit(0)`也能绕过 Close event,但会绕过其他窗口的正常 close 协议;相比之下,显式放行本更新对话框后继续走既有 `QApplication.quit -> aboutToQuit -> shutdown` 更小、更安全。
### P0:把 helper“已接管”变成可验证状态
`_spawn_inno_setup_applier()` 应返回并保留 `Popen`/child PID,且 helper 在做任何 PID 等待前原子写入 ready 标记(或首条结构化 bootstrap log)。父进程应异步等待一个很短且有界的 ready 窗口:
- ready 到达且 child 仍存活后,才设置 `_apply_committed=True`、放行 dialog close、请求主程序退出;
- child 在 ready 前退出时,读取退出码/bootstrap stderr,留在当前 UI 显示错误,不退出主程序;
- 使用 `-NonInteractive`,明确重定向 stdin/stdout/stderr 到日志或 `DEVNULL`;规范化 creation flags,不同时依赖会被忽略的 `DETACHED_PROCESS + CREATE_NO_WINDOW`
- helper 顶层捕获脚本初始化、首条日志、PID wait、安装器启动等所有异常,并写明阶段和退出码。
这样即使本次 PowerShell 早退的底层原因在另一台机器上不同,也不会再出现“UI 已锁死并宣称即将关闭,但其实无人接管”的假成功。
## 建议测试
现有 `app/tests/test_app_update.py``app/tests/test_app_update_ui.py``28 passed`,但没有覆盖真实交接:service 测试只截获 `_spawn_inno_setup_applier` 并检查脚本文本;UI 测试只断言 mock `host.request_quit` 被调用,没有运行 `QApplication` 事件循环,也没有验证强制 dialog 是否会否决 quit。现有 installer smoke 直接执行安装器,也绕过了应用退出与 helper。
建议至少增加:
1. **Qt 子进程回归(必须)**:显示 `force=True` 且 busy/apply committed 的真实 `AppUpdateDialog`,触发 session 完成退出,以 watchdog 保底;断言事件循环正常返回 `0``aboutToQuit` 发生,而不是被 `closeEvent` 卡住。
2. **显式退出路径**:强制更新下载中点击“退出软件”,worker `finished` 后同样能退出;result 已排队但退出先处理时不得启动 helper。
3. **helper ready 成功**:使用临时 noop PowerShell fixture 和真实生产 creation flags,断言 child PID、ready、等待目标进程退出、后续阶段按顺序发生,并覆盖路径含空格。
4. **helper 早退**:脚本缺失/解析失败/首条日志失败时,断言父进程取得非零退出码、不设置 committed、不退出、UI 可重试且有明确日志路径。
5. **PID 身份**:目标进程退出后即使整数 PID 被模拟复用,也不会等待或误伤新进程;等待有诊断超时但绝不在无法确认旧进程退出时启动安装。
6. **隔离端到端 Inno 更新**:从一个测试 frozen app 发起 handoff,确认旧 PID 消失、helper log 产生、安装器 log 产生、目标版本真正安装、只重启一次。不要只测试安装器单独运行。
7. **发布契约**:服务端 `latest_version`、package filename/manifest `ProductVersion`、SHA/size 必须属于同一版本;构造 `latest=1.3.0 + package=1.1.0` 时发布或客户端安装必须失败。
## 审阅边界
- 已读取根 `AGENTS.md`;仓库不存在 `.trellis/`,因此没有额外 workflow/spec 可读取。
- 工作树原本已有大量未提交改动,包括本报告涉及的生产文件和测试;本次未修改、覆盖或回退它们。
- 除新增本文档外,没有修改生产代码或测试。