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

10 KiB
Raw Permalink Blame History

AppUpdateSession 更新提交时序复现报告

日期:2026-08-28
环境:Windows、Python 3.12.13、PySide6 6.11.1、uv 0.11.8
范围:只读检查生产源码和现有测试;新增的唯一测试资产是 app/research/update_commit_repro.py,未修改生产源码和既有测试。

结论

  1. 当前工作树中的 result -> finished -> request_quit 时序可以稳定复现为正确。 100 次真实 QThreadPool 跨线程循环没有一次乱序或丢失:下载/prepare 在 worker 线程,_finish_install()_on_install_finished()request_quit() 都在 GUI 主线程。
  2. _TaskSignals 不会因局部变量释放而提前消失。 安装期间它同时被 _TaskAppUpdateSession._signals_active_install_signals 强引用;finished 到达后才清空 session 引用。探针在主动 gc.collect() 后仍观察到 wrapper 存活,风险方向是残留/泄漏, 不是过早 GC 导致信号丢失。
  3. ApplicationController.request_quit() 本身有效。 在真实 Qt 事件循环里,调用顺序是 request_quit_enter -> request_quit_return -> aboutToQuit,没有触发 1 秒 watchdog;本次 测量从进入事件循环到退出约 0.1 ms。
  4. apply_downloaded_update()Popen 调用不阻塞 GUI。 生产参数下 _spawn_inno_setup_applier() 约 2.7--4.7 ms 返回。
  5. 真正可复现的安装失败位于 Windows helper 启动。 当前代码组合 DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW 启动 Windows PowerShell。调用会快速返回,PowerShell 进程退出码甚至是 0,但脚本没有执行第一条写日志 命令。标志矩阵表明:本机上任何包含 DETACHED_PROCESS 的组合都失败;去掉它后, CREATE_NEW_PROCESS_GROUPCREATE_NO_WINDOW 以及两者组合均能执行脚本。
  6. 本机真实 18:08 更新尝试与复现完全一致。 安装包和 install_update.ps1 都在 18:08:37 生成,证明 Qt result 已投递且 _finish_install() 已进入 apply_downloaded_update();但同目录没有 update_helper.loginno_setup.log,说明 helper 没有运行到脚本第 20 行的第一条 Write-Log

因此,“下载完成后没有进入安装”的首要根因不是 finished 信号丢失,也不是 request_quit() 失效,而是 DETACHED_PROCESS 令 PowerShell helper 静默不执行。 当前源码仍会在 finished 后请求退出,所以如果现场描述为“窗口也一直不关闭”,这部分在当前 工作树中未能复现;现有日志更符合“应用已走到提交/退出路径,但安装器从未启动,因此没有安装 和重启”的用户观感。成功路径没有阶段日志,无法仅凭旧日志证明窗口具体关闭时刻。

源码时序

相关位置:

  • app/src/doctor_workstation/ui/dialogs/app_update.py:68-91_Task.run() 在同一个 try/else/finally 中先 result.emit(result),再 finished.emit()
  • app/src/doctor_workstation/ui/dialogs/app_update.py:503-555:安装任务创建 _TaskSignals,保存到 _signals_active_install_signals,然后连接 progress/status/result/error/finished
  • app/src/doctor_workstation/ui/dialogs/app_update.py:601-641result 槽先设置 _apply_committed=True 并同步调用 apply_downloaded_update()finished 槽随后清理活动 signals,并在 _apply_committed_exit_requested 时调用 _complete_quit()
  • app/src/doctor_workstation/app.py:1081-1086controller 通过 QTimer.singleShot(0, application.quit) 请求正常退出。
  • app/src/doctor_workstation/app.py:427,1088-1105aboutToQuit 同步进入幂等 shutdown(),更新 session 先被 invalidated,再清理视频和 remote client。
  • app/src/doctor_workstation/services/app_update.py:637-672Inno helper 的 Windows Popen 和三个 creation flags。

必须注意一个 Qt 细节:worker 发出 result 后不会等待 GUI 槽执行,紧接着就发出 finished;两者作为同一 sender 的跨线程事件按连接顺序排入 GUI 队列。本次 100 次实测均为:

worker: download/prepare return
  -> GUI: result slot enter
  -> GUI: apply_downloaded_update enter/return
  -> GUI: result slot return
  -> GUI: finished slot enter
  -> GUI: request_quit

这也意味着:如果 apply_downloaded_update() 真正阻塞,排在它后面的 finished 和 quit 会一起 延迟。但本机真实 Popen 返回只需数毫秒,未观察到阻塞。

复现结果

1. 现有测试

$env:QT_QPA_PLATFORM='offscreen'
uv run --project app pytest app/tests/test_app_update_ui.py app/tests/test_app_update.py -q

结果:28 passed

现有 test_session_waits_for_update_worker_before_quitting 是直接调用私有槽的同步单元测试,能够 检查状态门禁,但没有经过 QThreadPool/Qt queued delivery;这正是独立探针需要补足的部分。

2. 独立跨线程与 helper 探针

$env:QT_QPA_PLATFORM='offscreen'
uv run --project app python app/research/update_commit_repro.py 100

关键结果:

session.iterations = 100
session.failures = []

worker thread != GUI thread
result_slot_enter.thread_id == finished_slot_enter.thread_id
request_quit.thread_id == GUI main thread

_spawn_inno_setup_applier return = 约 3 ms
production flags script log created = false
production PowerShell return code = 0

controller quit events =
  request_quit_enter, request_quit_return, aboutToQuit

Windows 创建标志矩阵:

creation flags 脚本是否执行
0
CREATE_NEW_PROCESS_GROUP
CREATE_NO_WINDOW
`CREATE_NEW_PROCESS_GROUP CREATE_NO_WINDOW`
DETACHED_PROCESS
`DETACHED_PROCESS CREATE_NEW_PROCESS_GROUP`
`DETACHED_PROCESS CREATE_NO_WINDOW`
当前生产三标志组合

close_fds 改为 false,或给 stdin/stdout/stderr 全部接 DEVNULL,均不能挽救包含 DETACHED_PROCESS 的组合。

3. 真实运行残留

本机目录:

C:\Users\pc\AppData\Local\ZhenYangTang\ZhenyangDoctor\updates\1_3_0\

18:08:37 已有:

  • DoctorWorkstation-Setup-Windows-x64-1.1.0.exe
  • install_update.ps1

不存在:

  • update_helper.log
  • inno_setup.log

install_update.ps1 只会在 _finish_install() -> apply_downloaded_update() -> apply_inno_setup_update() 中生成,所以这组残留直接排除了“result 未投递”和 “_TaskSignals 被提前回收”。脚本第一项业务动作就是写 waiting for pid ...;没有 helper log 则失败发生在脚本业务逻辑之前。

另有一个独立的发布数据风险:workspace 名为 1_3_0,下载文件名却是 1.1.0,而请求中的 当前版本是 1.2.0。即使 helper 启动成功,也可能尝试降级安装。服务端 offer 的 latest_version、package filename、安装器 FileVersion/产品版本需要在发布端和客户端都做一致性 校验。这不是本次 helper 不启动的直接原因,但上线前必须处理。

建议修复方向

  1. Windows helper 不使用 DETACHED_PROCESS;先验证保留 CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW 时,父进程结束后 helper 仍能存活并运行。
  2. 不把 Popen() 成功等同于 helper 已启动。让 helper 在等待目标 PID 前先原子写一个 ready/waiting 标记,应用收到握手后才设置最终 commit 并退出;握手超时则留在应用内显示 明确错误。
  3. 给成功路径补结构化日志:prepare_result_receivedhelper_spawn_requestedhelper_readyworker_finishedrequest_quitabout_to_quit。当前只有异常日志,现场无法 区分“100% 后仍在 fsync/校验”“helper 启动失败”和“quit 清理较慢”。
  4. 对 package 声明版本和安装器版本做一致性校验,拒绝低于当前版本或不同于 latest_version 的安装器。

建议回归测试

Qt/session 测试

  1. 真实 queued delivery 成功路径:用 QThreadPool 启动 _Task,以 event loop 等待,断言 result slot -> apply return -> finished slot -> request_quit 严格顺序,并断言所有 UI/controller 槽都在 GUI 线程。
  2. signals 生命周期:启动任务后删除局部 worker/signals 引用并强制 GC,仍应收到 result 和 finishedfinished 后断开连接并 deleteLater(),最终 weakref 应释放,避免长期检查导致残留。
  3. apply 门控:用 Event 暂停 fake apply,断言暂停期间不会调用 quit;释放后 finished 只触发 一次 quit。
  4. apply 失败apply_downloaded_update()AppUpdateError 时不 quit、 _apply_committed 恢复 false;另外补非 AppUpdateError 异常,避免意外异常留下 commit=true 后 仍被 finished 退出。
  5. controller 集成:在真实 QApplication.exec() 中调用 controller request_quit()spy aboutToQuitapp_updater.shutdown() 和远程 client close,断言各一次且总时长有上界。

Windows helper 测试

  1. sentinel 启动测试(当前代码应失败):生成只写 sentinel 的 PowerShell 文件,使用生产 _spawn_inno_setup_applier() 启动,2 秒内必须看到 sentinel;不能只断言 Popen 被调用。
  2. 父进程退出测试:子 Python 进程启动 helper 后立即退出;helper 应先记录 waiting/ready 再观察父 PID 消失并写第二个 sentinel,证明去掉 DETACHED_PROCESS 后不会被父退出连带杀死。
  3. 完整握手测试:应用只有在 helper ready 后才调用 quithelper 未 ready、提前退出或无法写 日志时,应用保留并展示可重试错误。
  4. 打包 smoke:从实际 PyInstaller onedir/installer 环境执行上述测试,不能只在源码虚拟环境 mock subprocess.Popen

仓库说明

AGENTS.md 声明项目由 Trellis 管理,但当前工作区没有 .trellis/ 目录,因而无法读取 .trellis/workflow.md 或 layer spec;本次按根指令执行并在此记录。工作树原本已有大量未提交 修改,本次没有改动其中任何生产源码或既有测试。