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

14 KiB
Raw Blame History

强制更新对话框“退出软件”安全实现分析

结论

强制更新对话框可以提供“退出软件”,但不能把按钮直接连接到 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 设为 Noneapp/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-465service 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-525Inno 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 线程状态:

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_requestedfinished handler 除外)。

这会同时解决迟到回调、旧 dialog 被替换、以及上一次任务影响下一次 _cancel 状态的问题。活动安装存在时,check()/_present() 应拒绝再替换 dialog,避免两个 job 同时删除和使用同一版本 workspace。

退出请求的最小状态机是:

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)基础上补:

  1. 流式响应在若干 chunk 后设置 Event,断言抛取消错误、目标文件和 .part 都不存在。
  2. 下载最后一个 chunk 后、os.replace()/job 返回前取消,断言 session 的阶段门禁不会产出可安装结果。
  3. 读空闲超时为有限值,并被转换为 AppUpdateError;避免退出永久等待。

实施顺序

最小、低风险的提交顺序是:先加入 session 的 operation token、Event、finished 门和 controller request_quit();再加对话框按钮;最后把 read timeout 改为有限值并补阶段取消检查。只有当“退出后 result 绝不会进入 apply_downloaded_update()”和“quit 只发生在 finished 以后”两条测试通过,才应开放强制更新下载中的退出按钮。

审阅说明

  • 本次依据工作树当前内容只读分析;生产代码与现有测试均未修改。
  • 根目录 AGENTS.md 已读取;仓库当前不存在其中提到的 .trellis/ 目录,因此没有额外的 workflow/spec 文件可读。
  • 工作树原本已有多项未提交修改;本次只新增本研究文档,没有覆盖或回退任何现有改动。