14 KiB
强制更新对话框“退出软件”安全实现分析
结论
强制更新对话框可以提供“退出软件”,但不能把按钮直接连接到 dialog.close()、reject() 或 QApplication.quit()。当前更新下载由全局 QThreadPool 中的 QRunnable 执行,退出应用不会自动取消或等待该任务;安全的最小方案应是两阶段退出:
- GUI 线程记录“退出已请求”,禁止再提交安装,并用线程安全的取消事件通知下载任务;
- 更新任务通过既有
finished信号确认已结束后,再由ApplicationController调用application.quit(); 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()(UIapp_update.py:439-465;serviceapp_update.py:247-260)。 - 下载全部完成后,文件在
os.replace()前也没有最后一次取消检查(serviceapp_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(),存在以下时序:
- worker 已完成最后一个 chunk,并已把
result排进 GUI 事件队列; - 用户的退出点击与该 queued result 先后到达 GUI 线程;
- 若 result 仍被处理,当前
_finish_install()没有“退出已请求”门禁,会启动更新助手; - 应用随后退出,于是用户选择的“只退出”实际变成“退出并安装”。
反向时序也不安全:如果 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(UIapp_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_requested(finished 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():
- 创建 workspace 前;
download_package()返回后;- 安装器校验/zip 解压前;
- 校验/解压后、构造
_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():
- 首次调用时设置
_quit_requested; - 调用
app_updater.prepare_to_quit(self.application.quit); - updater 无活动安装时立即以
QTimer.singleShot(0, callback)完成;有任务时保存 callback,待该任务finished后完成; - 重复调用不做任何事。
不要提前设置 _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)基础上补:
- 强制更新显示“退出软件”,不显示“稍后提醒”,关闭按钮/Escape 仍不能绕过;非强制更新不显示该退出按钮。
- 空闲时点击退出只调用一次 controller
request_quit()。 - 下载中点击退出会 set event、保持应用运行且不立即调用
application.quit();手工 emit 当前 signals 的finished后才调用一次。 - 退出请求后再投递
progress/status/result/error均不更新旧 dialog;特别断言_finish_install()不调用apply_downloaded_update()。 - 模拟“result 已排队但退出点击先处理”的边界,断言不会启动 helper;模拟 result 已先完成 helper 提交,则退出按钮已禁用且最终走“安装后退出”。
- 新 offer 在活动安装期间不会
deleteLater()当前 dialog,也不会启动第二个 workspace job。 ApplicationController.shutdown()调用 updater shutdown 早于remote_repository.client.close(),并保持二次调用幂等。
在 app/tests/test_app_update.py 的下载测试(现有 :235-293)基础上补:
- 流式响应在若干 chunk 后设置
Event,断言抛取消错误、目标文件和.part都不存在。 - 下载最后一个 chunk 后、
os.replace()/job 返回前取消,断言 session 的阶段门禁不会产出可安装结果。 - 读空闲超时为有限值,并被转换为
AppUpdateError;避免退出永久等待。
实施顺序
最小、低风险的提交顺序是:先加入 session 的 operation token、Event、finished 门和 controller request_quit();再加对话框按钮;最后把 read timeout 改为有限值并补阶段取消检查。只有当“退出后 result 绝不会进入 apply_downloaded_update()”和“quit 只发生在 finished 以后”两条测试通过,才应开放强制更新下载中的退出按钮。
审阅说明
- 本次依据工作树当前内容只读分析;生产代码与现有测试均未修改。
- 根目录
AGENTS.md已读取;仓库当前不存在其中提到的.trellis/目录,因此没有额外的 workflow/spec 文件可读。 - 工作树原本已有多项未提交修改;本次只新增本研究文档,没有覆盖或回退任何现有改动。