# 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;本次按根指令执行并在此记录。工作树原本已有大量未提交 修改,本次没有改动其中任何生产源码或既有测试。