This commit is contained in:
Your Name
2026-08-28 18:24:37 +08:00
parent 43ad07208f
commit ed48f8be31
383 changed files with 8673 additions and 2222 deletions
+200
View File
@@ -0,0 +1,200 @@
# 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 和
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
`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 后才调用 quithelper 未 ready、提前退出或无法写
日志时,应用保留并展示可重试错误。
4. **打包 smoke**:从实际 PyInstaller onedir/installer 环境执行上述测试,不能只在源码虚拟环境
mock `subprocess.Popen`
## 仓库说明
`AGENTS.md` 声明项目由 Trellis 管理,但当前工作区没有 `.trellis/` 目录,因而无法读取
`.trellis/workflow.md` 或 layer spec;本次按根指令执行并在此记录。工作树原本已有大量未提交
修改,本次没有改动其中任何生产源码或既有测试。