10 KiB
AppUpdateSession 更新提交时序复现报告
日期:2026-08-28
环境:Windows、Python 3.12.13、PySide6 6.11.1、uv 0.11.8
范围:只读检查生产源码和现有测试;新增的唯一测试资产是
app/research/update_commit_repro.py,未修改生产源码和既有测试。
结论
- 当前工作树中的
result -> finished -> request_quit时序可以稳定复现为正确。 100 次真实QThreadPool跨线程循环没有一次乱序或丢失:下载/prepare 在 worker 线程,_finish_install()、_on_install_finished()和request_quit()都在 GUI 主线程。 _TaskSignals不会因局部变量释放而提前消失。 安装期间它同时被_Task、AppUpdateSession._signals和_active_install_signals强引用;finished到达后才清空 session 引用。探针在主动gc.collect()后仍观察到 wrapper 存活,风险方向是残留/泄漏, 不是过早 GC 导致信号丢失。ApplicationController.request_quit()本身有效。 在真实 Qt 事件循环里,调用顺序是request_quit_enter -> request_quit_return -> aboutToQuit,没有触发 1 秒 watchdog;本次 测量从进入事件循环到退出约 0.1 ms。apply_downloaded_update()的Popen调用不阻塞 GUI。 生产参数下_spawn_inno_setup_applier()约 2.7--4.7 ms 返回。- 真正可复现的安装失败位于 Windows helper 启动。 当前代码组合
DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW启动 Windows PowerShell。调用会快速返回,PowerShell 进程退出码甚至是 0,但脚本没有执行第一条写日志 命令。标志矩阵表明:本机上任何包含DETACHED_PROCESS的组合都失败;去掉它后,CREATE_NEW_PROCESS_GROUP、CREATE_NO_WINDOW以及两者组合均能执行脚本。 - 本机真实 18:08 更新尝试与复现完全一致。 安装包和
install_update.ps1都在 18:08:37 生成,证明 Qtresult已投递且_finish_install()已进入apply_downloaded_update();但同目录没有update_helper.log和inno_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-641:result槽先设置_apply_committed=True并同步调用apply_downloaded_update();finished槽随后清理活动 signals,并在_apply_committed或_exit_requested时调用_complete_quit()。app/src/doctor_workstation/app.py:1081-1086:controller 通过QTimer.singleShot(0, application.quit)请求正常退出。app/src/doctor_workstation/app.py:427,1088-1105:aboutToQuit同步进入幂等shutdown(),更新 session 先被 invalidated,再清理视频和 remote client。app/src/doctor_workstation/services/app_update.py:637-672:Inno helper 的 WindowsPopen和三个 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.exeinstall_update.ps1
不存在:
update_helper.loginno_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 不启动的直接原因,但上线前必须处理。
建议修复方向
- Windows helper 不使用
DETACHED_PROCESS;先验证保留CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW时,父进程结束后 helper 仍能存活并运行。 - 不把
Popen()成功等同于 helper 已启动。让 helper 在等待目标 PID 前先原子写一个ready/waiting标记,应用收到握手后才设置最终 commit 并退出;握手超时则留在应用内显示 明确错误。 - 给成功路径补结构化日志:
prepare_result_received、helper_spawn_requested、helper_ready、worker_finished、request_quit、about_to_quit。当前只有异常日志,现场无法 区分“100% 后仍在 fsync/校验”“helper 启动失败”和“quit 清理较慢”。 - 对 package 声明版本和安装器版本做一致性校验,拒绝低于当前版本或不同于
latest_version的安装器。
建议回归测试
Qt/session 测试
- 真实 queued delivery 成功路径:用
QThreadPool启动_Task,以 event loop 等待,断言result slot -> apply return -> finished slot -> request_quit严格顺序,并断言所有 UI/controller 槽都在 GUI 线程。 - signals 生命周期:启动任务后删除局部 worker/signals 引用并强制 GC,仍应收到 result 和
finished;finished 后断开连接并
deleteLater(),最终 weakref 应释放,避免长期检查导致残留。 - apply 门控:用
Event暂停 fake apply,断言暂停期间不会调用 quit;释放后 finished 只触发 一次 quit。 - apply 失败:
apply_downloaded_update()抛AppUpdateError时不 quit、_apply_committed恢复 false;另外补非AppUpdateError异常,避免意外异常留下 commit=true 后 仍被 finished 退出。 - controller 集成:在真实
QApplication.exec()中调用 controllerrequest_quit(),spyaboutToQuit、app_updater.shutdown()和远程 client close,断言各一次且总时长有上界。
Windows helper 测试
- sentinel 启动测试(当前代码应失败):生成只写 sentinel 的 PowerShell 文件,使用生产
_spawn_inno_setup_applier()启动,2 秒内必须看到 sentinel;不能只断言Popen被调用。 - 父进程退出测试:子 Python 进程启动 helper 后立即退出;helper 应先记录 waiting/ready,
再观察父 PID 消失并写第二个 sentinel,证明去掉
DETACHED_PROCESS后不会被父退出连带杀死。 - 完整握手测试:应用只有在 helper ready 后才调用 quit;helper 未 ready、提前退出或无法写 日志时,应用保留并展示可重试错误。
- 打包 smoke:从实际 PyInstaller onedir/installer 环境执行上述测试,不能只在源码虚拟环境
mock
subprocess.Popen。
仓库说明
根 AGENTS.md 声明项目由 Trellis 管理,但当前工作区没有 .trellis/ 目录,因而无法读取
.trellis/workflow.md 或 layer spec;本次按根指令执行并在此记录。工作树原本已有大量未提交
修改,本次没有改动其中任何生产源码或既有测试。