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

12 KiB
Raw Blame History

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 下载 jobapp/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 helperapp/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-479app/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。输出为:

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(...) 没有同步抛出 OSErrorPopen 返回后句柄立即丢失,没有 child PID 日志、早退检测、stderr 捕获或 ready handshake:637-672)。

当前还同时设置 DETACHED_PROCESSCREATE_NEW_PROCESS_GROUPCREATE_NO_WINDOW:645-670)。Microsoft 文档指出 CREATE_NO_WINDOWDETACHED_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 36304C:\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 0CDD38DB6EEF5E7B7380FFAE3FBC407B602EB916C68AE4C8F29496B621F50789ProductVersion=1.1.0,未签名。
    • install_update.ps118: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.0min_version=1.3.0force=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.pyapp/tests/test_app_update_ui.py28 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 保底;断言事件循环正常返回 0aboutToQuit 发生,而不是被 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 可读取。
  • 工作树原本已有大量未提交改动,包括本报告涉及的生产文件和测试;本次未修改、覆盖或回退它们。
  • 除新增本文档外,没有修改生产代码或测试。