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

201 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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;本次按根指令执行并在此记录。工作树原本已有大量未提交
修改,本次没有改动其中任何生产源码或既有测试。