12 KiB
Windows 更新安装交接故障诊断
结论
“下载完成后显示即将关闭,但程序不退出/不安装”不是下载失败。当前现场同时存在三个问题,其中第 1 项能够确定性复现用户看到的主症状,第 2 项是本次现场已经发生但被代码吞掉的 helper 启动失败,第 3 项是必须立即纠正的发布配置错误。
- 确定性根因:强制更新对话框拒绝了
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 消失,于是交接形成闭环等待。 - 独立的已确认问题:PowerShell helper 进程被创建后,在执行脚本首条日志之前就退出了,而父进程把“CreateProcess 成功”误判成“helper 已接管”。 现场有精确的 PowerShell 启动事件,但没有
update_helper.log、没有inno_setup.log、没有存活 helper/installer 进程。代码丢弃Popen句柄、不检查早退、也没有 ready handshake,因此 UI 仍停在“即将关闭”。现有证据不足以还原该次子进程的退出码;这正是当前可观测性缺口。 - 发布配置错误:服务端宣称最新/最低版本为
1.3.0,实际下发的安装器却是1.1.0。 本机当前运行1.2.0,所以即使退出与 helper 均修复,也会尝试降级安装,而不是升级到1.3.0。修复客户端前应先停止这条强制更新配置。
代码交接链路与根因证据
1. UI 已经请求退出,但强制对话框否决退出
当前交接顺序是:
_start_install()设置 busy、启动 QThreadPool 下载 job(app/src/doctor_workstation/ui/dialogs/app_update.py:481-555)。- job 下载并校验 Inno Setup EXE,返回
_PreparedUpdate(:511-545)。 _finish_install()先显示“即将关闭程序并自动安装”,再调用dialog.set_apply_committed();该方法把_busy保持为 true(:616-628、:275-283)。apply_downloaded_update()进入apply_inno_setup_update(),写脚本并PopenPowerShell helper(app/src/doctor_workstation/services/app_update.py:429-472)。- QRunnable 随后发送
finished,_on_install_finished()清理 active signals 并调用_complete_quit()(UI:634-641)。 _complete_quit()调用 controllerrequest_quit();controller 用QTimer.singleShot(0, self.application.quit)请求正常退出(UI:472-479;app/src/doctor_workstation/app.py:1081-1086)。- 但是更新对话框的
closeEvent()在offer.force或_busy为真时执行event.ignore()(UI:296-300)。此时两个条件都为真。
使用当前 PySide6 做了不改文件的最小事件循环复现:显示一个 modal dialog,其 closeEvent() 执行 ignore(),50 ms 后调用 QApplication.quit(),并设置 2 秒进程级 watchdog。输出为:
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-28,Asia/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-2560CDD38DB6EEF5E7B7380FFAE3FBC407B602EB916C68AE4C8F29496B621F50789,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有 Event40961“Powershell 控制台正在启动”,没有配对的 ready40962;检查时也没有命令行指向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。
建议至少增加:
- Qt 子进程回归(必须):显示
force=True且 busy/apply committed 的真实AppUpdateDialog,触发 session 完成退出,以 watchdog 保底;断言事件循环正常返回0、aboutToQuit发生,而不是被closeEvent卡住。 - 显式退出路径:强制更新下载中点击“退出软件”,worker
finished后同样能退出;result 已排队但退出先处理时不得启动 helper。 - helper ready 成功:使用临时 noop PowerShell fixture 和真实生产 creation flags,断言 child PID、ready、等待目标进程退出、后续阶段按顺序发生,并覆盖路径含空格。
- helper 早退:脚本缺失/解析失败/首条日志失败时,断言父进程取得非零退出码、不设置 committed、不退出、UI 可重试且有明确日志路径。
- PID 身份:目标进程退出后即使整数 PID 被模拟复用,也不会等待或误伤新进程;等待有诊断超时但绝不在无法确认旧进程退出时启动安装。
- 隔离端到端 Inno 更新:从一个测试 frozen app 发起 handoff,确认旧 PID 消失、helper log 产生、安装器 log 产生、目标版本真正安装、只重启一次。不要只测试安装器单独运行。
- 发布契约:服务端
latest_version、package filename/manifestProductVersion、SHA/size 必须属于同一版本;构造latest=1.3.0 + package=1.1.0时发布或客户端安装必须失败。
审阅边界
- 已读取根
AGENTS.md;仓库不存在.trellis/,因此没有额外 workflow/spec 可读取。 - 工作树原本已有大量未提交改动,包括本报告涉及的生产文件和测试;本次未修改、覆盖或回退它们。
- 除新增本文档外,没有修改生产代码或测试。