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

167 lines
14 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.
# 强制更新对话框“退出软件”安全实现分析
## 结论
强制更新对话框可以提供“退出软件”,但不能把按钮直接连接到 `dialog.close()``reject()``QApplication.quit()`。当前更新下载由全局 `QThreadPool` 中的 `QRunnable` 执行,退出应用不会自动取消或等待该任务;安全的最小方案应是两阶段退出:
1. GUI 线程记录“退出已请求”,禁止再提交安装,并用线程安全的取消事件通知下载任务;
2. 更新任务通过既有 `finished` 信号确认已结束后,再由 `ApplicationController` 调用 `application.quit()`
3. `aboutToQuit` 中的 `ApplicationController.shutdown()` 只做最终、幂等的资源清理,不能承担异步等待任务结束的职责。
这条顺序保证:用户选择“退出软件”后不会又启动更新助手;`.part` 文件能走现有异常清理;Qt 事件循环在工作线程仍可能发信号时不会提前消失。
## 当前实现与证据
### 1. 强制对话框目前没有退出路径
- `AppUpdateDialog` 对强制更新移除关闭按钮并设为应用级模态(`app/src/doctor_workstation/ui/dialogs/app_update.py:112-121`)。
- 按钮区只有“稍后提醒”“取消下载”“立即更新”;“稍后提醒”在强制更新时隐藏(`:179-197`)。
- `set_busy()` 只在“忙且非强制”时显示取消下载,所以强制更新下载过程中没有任何停止入口(`:208-213`)。
- 强制更新或任意下载忙状态都会忽略窗口关闭事件,强制更新还会忽略 Escape(`:265-275`)。
因此新增能力应是独立的 `exit_requested` 语义,而不是复用 `download_cancelled`。后者当前在 session 中明确拒绝强制更新(`:411-415`),且它的既有语义只是“取消后留在应用内”。
### 2. session 没有“请求取消 -> 已经停稳”的闭环
- `_TaskSignals` 已声明 `finished``_Task.run()` 也一定会在 `finally` 发出它(`app_update.py:67-90`),但 `AppUpdateSession` 没有连接该信号。
- session 只保留一个跨线程共享的 `_cancel: bool` 和最近一次 `_signals`,没有活动 worker/token、退出状态或完成回调(`:285-292`)。
- 检查任务和安装准备任务均直接提交到 `QThreadPool.globalInstance()``:316-330``:467-472`);局部 `worker` 没被 session 用来跟踪生命周期。
- 安装准备任务直接把进度/状态连到 dialog,把结果连到 `_finish_install()``:467-471`)。关闭事件循环前没有撤销或门控这些回调。
- 新 offer 到达时,session 会对旧 dialog 调用 `close()` 后立刻 `deleteLater()``:397-405`)。如果旧 dialog 正在强制更新/下载,它的 `closeEvent()` 会拒绝关闭,但 `deleteLater()` 仍会排队;与此同时旧 worker 仍持有连接和捕获该 dialog 的 lambda。这也是需要用“活动操作 token”阻止重入/替换的理由。
### 3. 当前取消只能在收到下载分块以后生效
- `download_package()` 把 HTTP read timeout 设为 `None``app/src/doctor_workstation/services/app_update.py:311`)。服务器建立连接后若不再发送数据,worker 可以无限阻塞在读取中,GUI 写入 `_cancel=True` 也不能唤醒 socket。
- 取消回调只在 `iter_bytes()` 产出一个 chunk 后检查(`:334-337`)。取消被观察到时会抛出 `AppUpdateError`,现有异常分支会删除 `.part` 文件(`:349-351`),这一清理机制可以继续复用。
- 下载返回以后没有再次检查取消状态;job 会继续校验安装器,或调用不可取消的 `safe_extract_zip()`UI `app_update.py:439-465`service `app_update.py:247-260`)。
- 下载全部完成后,文件在 `os.replace()` 前也没有最后一次取消检查(service `app_update.py:358-367`)。即使退出请求恰好到达末尾,job 仍可能返回 `_PreparedUpdate`
- 工作目录在下一次同版本尝试开始时会整体删除重建(service `app_update.py:722-727`),所以取消发生在下载完成或解压阶段时,保留完整 zip/部分解压目录不会污染下一次尝试;关键仍是不能继续提交安装。
### 4. 直接 `quit()` 存在安装竞态
当前 `_finish_install()` 收到任何合法 `_PreparedUpdate` 就先启动外部更新助手,再用 300 ms 定时器调用 `application.quit()`UI `app_update.py:483-503`)。更新助手按设计等待当前 PID 消失后才覆盖/安装并重启:archive 路径见 service `app_update.py:517-525`Inno Setup 路径见 `:594-625`
若下载中“退出软件”直接调用 `quit()`,存在以下时序:
1. worker 已完成最后一个 chunk,并已把 `result` 排进 GUI 事件队列;
2. 用户的退出点击与该 queued result 先后到达 GUI 线程;
3. 若 result 仍被处理,当前 `_finish_install()` 没有“退出已请求”门禁,会启动更新助手;
4. 应用随后退出,于是用户选择的“只退出”实际变成“退出并安装”。
反向时序也不安全:如果 `quit()` 先结束事件循环,worker 仍可能在独立 `httpx.Client` 中写 `.part`、解压或发射 Qt 信号。`QApplication.quit()` 是退出事件循环的请求,不是 `QRunnable` 的 cancel/join。进程最终可能等待 Qt 线程池析构、遗留中间文件,或丢弃已经排队的结果;不能把这些析构时机当成生命周期协议。
### 5. `ApplicationController.shutdown()` 目前不管理 updater
- `aboutToQuit` 在控制器构造时连接到 `shutdown()``app/src/doctor_workstation/app.py:402-427`)。
- `shutdown()` 只置 `_shutting_down`、失效 session restore、关闭视频和远端 API client;没有调用 `self.app_updater.shutdown()``:1081-1097`)。
- Qt 配置了 `setQuitOnLastWindowClosed(True)``:1147-1162`),因此单纯关闭/拒绝 dialog 也不是统一的退出协议:父 login/shell 仍存在时未必退出,最后窗口意外关闭时又会绕过 updater 的准备阶段。
- `ApiClient.close()` 会无超时地等待所有活跃短请求归还连接(`app/src/doctor_workstation/services/api_client.py:414-450`,尤其 `:427-432`)。更新检查使用的正是共享 remote clientUI `app_update.py:310-330`),所以若退出恰逢检查请求,`aboutToQuit -> shutdown -> client.close()` 可能在 GUI 线程等待请求超时/重试结束。强制对话框的原始检查通常已经返回,但 session 仍应在最终 shutdown 时先递增 generation,使迟到的检查结果绝不能再创建窗口。
`aboutToQuit` 已经处于事件循环退出阶段,不适合再启动“取消后等 finished signal”的异步流程;finished queued signal 可能已没有下一轮事件可处理。因此必须在点击“退出软件”时先完成 quiesce,再真正调用 `quit()`
## 建议的最小实现
### A. 对话框只发意图,不自行退出
`AppUpdateDialog` 增加独立信号 `exit_requested = Signal()` 和按钮:
- 文案为“退出软件”,仅 `offer.force` 时显示;非强制更新继续使用“稍后提醒/取消下载”。
- 强制更新即使 `_busy=True` 也保持该按钮可用,因为这正是下载中唯一的离开路径。
- 点击后只 emit;session 接管状态转换。对话框增加 `set_exiting()`,禁用所有按钮、显示“正在停止更新并退出…”,防止双击。
- `closeEvent()` 和 Escape 的现有强制拦截继续保留。不要让窗口标题栏关闭绕开协调器。
- 一旦外部安装助手已经成功启动,进入不可逆的 `APPLY_COMMITTED` 状态,禁用“退出软件”;此后退出必然表示“退出并安装”。
### B. 用 `threading.Event` 和活动操作身份建立闭环
`AppUpdateSession` 最少需要以下 GUI 线程状态:
```python
self._cancel_event = Event()
self._active_install_signals: _TaskSignals | None = None
self._exit_requested = False
self._quit_when_idle: Callable[[], None] | None = None
self._apply_committed = False
```
开始安装准备时 `clear()` event,保存本次 `signals`,并把 `signals.finished` 连到带 `signals` 身份参数的 `_on_install_finished()`。进度、状态、result、error 也不要再直接连接 dialog 方法;统一经过 session handler,并同时验证:
- `signals is self._active_install_signals`
- dialog 仍是 `self.dialog`
- 未处于 `_exit_requested`finished handler 除外)。
这会同时解决迟到回调、旧 dialog 被替换、以及上一次任务影响下一次 `_cancel` 状态的问题。活动安装存在时,`check()`/`_present()` 应拒绝再替换 dialog,避免两个 job 同时删除和使用同一版本 workspace。
退出请求的最小状态机是:
```text
IDLE/PREPARING --点击退出--> EXIT_PENDING
EXIT_PENDING --cancel_event.set()--> 等待当前 install signals.finished
无活动任务或 finished 到达 --> ApplicationController.request_quit()
aboutToQuit --> ApplicationController.shutdown() 最终幂等清理
```
`_finish_install()` 的第一条业务门禁必须是“如果退出已请求、event 已 set、或 signals 已不是当前操作,则直接返回,不调用 `apply_downloaded_update()`”。这是防止“退出反而安装”的关键断言。
### C. 让 job 在阶段边界观察取消,并给网络读取有限上界
现有 `download_package(cancelled=...)` 接口无需改变,改传 `self._cancel_event.is_set`。job 至少在以下边界调用统一的 `_raise_if_cancelled()`
1. 创建 workspace 前;
2. `download_package()` 返回后;
3. 安装器校验/zip 解压前;
4. 校验/解压后、构造 `_PreparedUpdate` 前。
同时把 `download_package()``read=None` 改成有限的“单次读空闲超时”,建议沿用配置的 `request_timeout` 或默认 30 秒。这个 timeout 不是总下载时长:只要持续收到 chunk,大文件仍可继续;服务器停止发数据后,退出等待则有确定上界。
如果希望解压中点击退出也能很快响应,可把 `safe_extract_zip()` 从一次性 `extractall()` 改为逐 member 提取并在每个 member 前检查同一个 cancel callback。若坚持最小改动,也可以让退出等待当前 `extractall()` 完成,但必须保持 event loop 和 dialog 存活,并在解压后门禁掉安装,不能先 `quit()`
### D. 由控制器统一发起真正退出
`ApplicationController` 增加与 `_shutting_down` 分离的 `_quit_requested`,以及幂等 `request_quit()`
1. 首次调用时设置 `_quit_requested`
2. 调用 `app_updater.prepare_to_quit(self.application.quit)`
3. updater 无活动安装时立即以 `QTimer.singleShot(0, callback)` 完成;有任务时保存 callback,待该任务 `finished` 后完成;
4. 重复调用不做任何事。
不要提前设置 `_shutting_down`,否则真正触发 `aboutToQuit` 时现有 `shutdown()` 会在 `:1084-1086` 直接返回,跳过资源释放。
`ApplicationController.shutdown()` 中应在 `_cancel_session_restore()` 之后、关闭视频和 remote client 之前调用幂等的 `self.app_updater.shutdown()`。该方法应:递增 `_generation`、设置 cancel event、清除退出 callback、使所有迟到 callback 失效;它是兜底,不再等待 worker。正常的强制对话框退出路径到这里时,安装准备 worker 已经 finished。
成功更新也应复用同一完成门:`_finish_install()` 成功启动 helper 后只记录 `_apply_committed=True` 和“任务结束后退出”;由本次 `signals.finished` 再调用控制器的 `request_quit()`。这样可以删除当前依赖经验值的 300 ms 定时退出(UI `app_update.py:501-503`),并明确保证 worker 已离开 `run()`
### E. 不建议的实现
- 不要在退出按钮中调用 `os._exit()``terminate()` 或强杀线程;这会绕过 controller 的视频/API 清理,并可能截断 `.part`/日志写入。
- 不要在 `aboutToQuit` 中调用 `QThreadPool.globalInstance().waitForDone()`;它会等待整个应用的全局线程池,而不只是更新任务,当前无限 read timeout 还可能让 GUI 永久卡住。
- 不要用循环 `processEvents()` 等待 worker;这会允许更新按钮、窗口关闭和 queued result 重入。
- 不要只设置现有 `_cancel=True` 后立即 `quit()`;设置取消只是请求,`finished` 才是可退出的确认。
## 建议补测
`app/tests/test_app_update_ui.py` 现有强制对话框测试(`:68-83`)基础上补:
1. 强制更新显示“退出软件”,不显示“稍后提醒”,关闭按钮/Escape 仍不能绕过;非强制更新不显示该退出按钮。
2. 空闲时点击退出只调用一次 controller `request_quit()`
3. 下载中点击退出会 set event、保持应用运行且不立即调用 `application.quit()`;手工 emit 当前 signals 的 `finished` 后才调用一次。
4. 退出请求后再投递 `progress/status/result/error` 均不更新旧 dialog;特别断言 `_finish_install()` 不调用 `apply_downloaded_update()`
5. 模拟“result 已排队但退出点击先处理”的边界,断言不会启动 helper;模拟 result 已先完成 helper 提交,则退出按钮已禁用且最终走“安装后退出”。
6. 新 offer 在活动安装期间不会 `deleteLater()` 当前 dialog,也不会启动第二个 workspace job。
7. `ApplicationController.shutdown()` 调用 updater shutdown 早于 `remote_repository.client.close()`,并保持二次调用幂等。
`app/tests/test_app_update.py` 的下载测试(现有 `:235-293`)基础上补:
8. 流式响应在若干 chunk 后设置 `Event`,断言抛取消错误、目标文件和 `.part` 都不存在。
9. 下载最后一个 chunk 后、`os.replace()`/job 返回前取消,断言 session 的阶段门禁不会产出可安装结果。
10. 读空闲超时为有限值,并被转换为 `AppUpdateError`;避免退出永久等待。
## 实施顺序
最小、低风险的提交顺序是:先加入 session 的 operation token、`Event`、finished 门和 controller `request_quit()`;再加对话框按钮;最后把 read timeout 改为有限值并补阶段取消检查。只有当“退出后 result 绝不会进入 `apply_downloaded_update()`”和“quit 只发生在 finished 以后”两条测试通过,才应开放强制更新下载中的退出按钮。
## 审阅说明
- 本次依据工作树当前内容只读分析;生产代码与现有测试均未修改。
- 根目录 `AGENTS.md` 已读取;仓库当前不存在其中提到的 `.trellis/` 目录,因此没有额外的 workflow/spec 文件可读。
- 工作树原本已有多项未提交修改;本次只新增本研究文档,没有覆盖或回退任何现有改动。