# 强制更新对话框“退出软件”安全实现分析 ## 结论 强制更新对话框可以提供“退出软件”,但不能把按钮直接连接到 `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 client(UI `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 文件可读。 - 工作树原本已有多项未提交修改;本次只新增本研究文档,没有覆盖或回退任何现有改动。