201 lines
10 KiB
Markdown
201 lines
10 KiB
Markdown
# 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` 不会因局部变量释放而提前消失。** 安装期间它同时被 `_Task`、
|
||
`AppUpdateSession._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_GROUP`、`CREATE_NO_WINDOW` 以及两者组合均能执行脚本。
|
||
6. **本机真实 18:08 更新尝试与复现完全一致。** 安装包和 `install_update.ps1` 都在
|
||
18:08:37 生成,证明 Qt `result` 已投递且 `_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 的 Windows
|
||
`Popen` 和三个 creation flags。
|
||
|
||
必须注意一个 Qt 细节:worker 发出 `result` 后不会等待 GUI 槽执行,紧接着就发出
|
||
`finished`;两者作为同一 sender 的跨线程事件按连接顺序排入 GUI 队列。本次 100 次实测均为:
|
||
|
||
```text
|
||
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. 现有测试
|
||
|
||
```powershell
|
||
$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 探针
|
||
|
||
```powershell
|
||
$env:QT_QPA_PLATFORM='offscreen'
|
||
uv run --project app python app/research/update_commit_repro.py 100
|
||
```
|
||
|
||
关键结果:
|
||
|
||
```text
|
||
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. 真实运行残留
|
||
|
||
本机目录:
|
||
|
||
```text
|
||
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_received`、`helper_spawn_requested`、
|
||
`helper_ready`、`worker_finished`、`request_quit`、`about_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 和
|
||
finished;finished 后断开连接并 `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
|
||
`aboutToQuit`、`app_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 后才调用 quit;helper 未 ready、提前退出或无法写
|
||
日志时,应用保留并展示可重试错误。
|
||
4. **打包 smoke**:从实际 PyInstaller onedir/installer 环境执行上述测试,不能只在源码虚拟环境
|
||
mock `subprocess.Popen`。
|
||
|
||
## 仓库说明
|
||
|
||
根 `AGENTS.md` 声明项目由 Trellis 管理,但当前工作区没有 `.trellis/` 目录,因而无法读取
|
||
`.trellis/workflow.md` 或 layer spec;本次按根指令执行并在此记录。工作树原本已有大量未提交
|
||
修改,本次没有改动其中任何生产源码或既有测试。
|