Skip to content

fix: 修复了线程静默退出时进度组件永久停留的问题 - #84

Merged
fyc09 merged 4 commits into
yan-xiaoo:mainfrom
TiaoFeng:fix/process-widget-silent-death
Oct 12, 2026
Merged

fyc09 merged 4 commits into
yan-xiaoo:mainfrom
TiaoFeng:fix/process-widget-silent-death

Conversation

@TiaoFeng

@TiaoFeng TiaoFeng commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

变更说明

背景

ProcessWidget(页面内进度组件)和 ProgressInfoBar(右下角进度弹条)用同一套模式:checkProcess 定时轮询 thread.isRunning(),但只在用户点过取消(stopped=True)时补发 canceled。

  • 子线程因未捕获异常、或某条 return 路径漏发 hasFinished/canceled 而静默退出时,两个组件都不会发出任何结束信号:进度条/弹条永久停在界面上,按钮状态也不回退。
  • ProgressInfoBar.connectToThread(disconnect_last=True) 的重绑定路径此前从未被实际执行过,其中两个信号名(progressPause、processFinish)是错误的,且没有断开 QThread.finished。一旦有组件重绑定线程,旧线程结束会误伤新绑定,目前程序可以正常运行只是因为没有触及这个路径。

原因

checkProcess 原先的结束判定是:

if not self.thread_.isRunning():
    if self.stopped:          # 只有用户点过取消才上报
        self.onStopped()
    self.timer.stop()

静默退出的线程在这一分支被直接忽略。

修复

两个组件统一引入 _saw_end 跟踪本轮是否收到过 hasFinished/canceled,并把结束判定改挂到 QThread.finished(新增 onThreadExited):

  • ProcessWidget:线程结束仍未收到结束信号 → warning + onStopped(),走统一清理(隐藏、按钮复位、发出 canceled);onThreadExited 先停表再判定(该组件成功路径的 onFinished 不停表)。
  • ProgressInfoBar:onProcessFinish/onStopped 已停表并关闭,onThreadExited 只做判定、不再触碰 timer;增加 sender() 防御旧线程的迟到信号;connectToThread 断开旧线程时补上 finished,并修正 progressPause -> progressPaused、processFinish -> hasFinished。
  • 两边 onFinished/onProcessFinish/onStopped 置位 _saw_end,onThreadStart/connectToThread 重置;checkProcess 不再做结束判定,not isRunning 分支只停表并 return,避免停表后继续执行倒退动画/强杀逻辑。

为何要使用 QThread.finished

worker 线程内的顺序是固定的:run() 里发出的 hasFinished/canceled 先入队,run() 返回后 Qt 才发出 finished。主线程按 FIFO 派发,因此 onThreadExited读取 _saw_end 时本轮的结束信号一定已经处理过。

曾尝试过 checkProcess 定时器里按 isRunning() 轮询判定,但定时器回调与排队信号是不同的事件源,派发顺序由 Qt 实现决定。用 1ms 定时器放大窗口测试 200 轮,旧写法出现了 1 次“正常完成被误报 canceled”

改动文件

  • app/threads/ProcessWidget.py:
    • _saw_end 判定 + onThreadExited 挂钩 QThread.finished;
    • checkProcess 移除竞态判定并补 return。
  • app/components/ProgressInfoBar.py:
    • 同样的判定方式,适配自身“结束即 close()/deleteLater()”的生命周期;
    • 修复重绑定断开列表的两处错误信号名并补 finished 断连。
  • test/app/test_process_widget.py(新增,5 用例):新增测试模块(TEST_DOMAIN=qt-ui、TEST_REGRESSION=True)。
  • test/app/test_progress_info_bar.py(新增,5 用例):新增测试模块(TEST_DOMAIN=qt-ui、TEST_REGRESSION=True)。

验证

  • 每个 bug 修复都新增或更新了可复现旧问题的回归测试。
  • 与已知 Issue 相关的程序错误已有明确的回归测试,并出现在 Actions 日志中。
  • 已运行相关本地测试,并在下方列出无法运行的测试或环境。
  • 合并前 PR Tests / CI Gate 已通过。
  • 平台相关行为已由现有矩阵覆盖,或已在下方解释额外验证范围。

测试证据

本机环境:Linux x86_64,Python 3.13(uv run --frozen)

QT_QPA_PLATFORM=offscreen QT_OPENGL=software \
  uv run --frozen python -m unittest test.app.test_process_widget test.app.test_progress_info_bar -v

# Ran 10 tests in 0.076s
# OK
uv run --frozen python -m test.ci.check_test_contract

# OK: 29 product test modules are covered by 5 domains.
uv run --frozen python -m test.ci.run_test_shard --domain qt-ui

# Ran 131 tests in 2.723s
# OK
uv run --frozen python -m test.ci.run_test_regressions

# Ran 147 tests in 2.669s
# OK
uv run --frozen local_test.py

# ...
# PASS: 6 local test commands succeeded.

## 问题
`ProcessWidget.checkProcess` 原先只在 `self.stopped` 为真时补发
`canceled`。因此若子线程因未捕获异常或漏发结束信号而退出,且用户没有点过取消,组件会一直停留在“进行中”且不再发出任何结束信号,进度条/进程对话框永久卡住。

## 修复
使用 `_saw_end` 记录本轮是否已收到
`hasFinished/canceled`,并把结束判定挂到 `QThread.finished`(新增
`onThreadExited`):
- 线程结束但从未收到成功/取消信号时,记录 warning
并按取消走统一清理,组件正常隐藏、按钮复位;
- `QThread.finished` 一定在 `run()` 内的结束信号之后入队,主线程 FIFO
派发,判定不会与还在排队的信号竞速,正常完成不会被误报为取消;
- `checkProcess` 不再做结束判定,`not isRunning`
分支只停表并`return`,避免停表后继续执行倒退动画/强杀逻辑;
- 重复调用只上报一次,用户取消与超时强杀路径行为不变。

## 测试
新增
`test/app/test_process_widget.py`(TEST_DOMAIN=qt-ui、TEST_REGRESSION)
## 问题
- `checkProcess` 原先只在 `stopped` 为真时补发
`canceled`,线程因未捕获异常或漏发结束信号而退出时,进度弹条会一直停在界面上。

## 修复
- 改为用 `_saw_end` 跟踪本轮结束信号,并挂
`QThread.finished`(`onThreadExited`)统一判定;
- `checkProcess` 不再做结束判定。

## 其他修复
- `connectToThread` 断开旧线程时的两处错误信号名(`progressPause` ->
`progressPaused`、`processFinish` -> `hasFinished`)

## 测试
新增
`test/app/test_progress_info_bar.py`(TEST_DOMAIN=qt-ui、TEST_REGRESSION)
@TiaoFeng TiaoFeng changed the title fix(threads): 修复线程静默退出时 ProcessWidget 卡住的问题 fix: 修复了线程静默退出时进度组件永久停留的问题 Sep 25, 2026

@fyc09 fyc09 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

静默退出后的清理方式总体合理,但重绑定线程时对旧信号的保护还不完整。请补充对已排队旧线程信号的绑定校验,并增加“旧线程先发出信号、主线程派发前重绑定”的回归测试后再合并。这是基于代码和 Qt 排队信号机制的边界风险判断,尚未在真实 Qt 环境实机复现。

Comment thread app/components/ProgressInfoBar.py
针对 PR 审查意见(P2):connectToThread() 的 disconnect() 不保证撤销已排队的 queued signal,旧线程先发出的 hasFinished / canceled / started 等信号在重绑定后仍可能进入状态槽,从而关闭新任务的进度条或重置其状态。

- 新增 _bindThreadSignal():线程的 10 个信号统一经带绑定校验的代理接收
  器连接,派发时校验 _closed、sip.isdeleted(self) 与 thread is
  self.thread_,丢弃旧线程的迟到/已入队事件
- closeEvent 与 destroyed 显式断开接收器连接并清空,避免已关闭/已销毁
  对象被长生命周期线程连接持有;迟到信号由 _closed 与 sip.isdeleted 兜底
- 补充回归测试:覆盖「旧线程先发出信号、主线程派发前重绑定」场景,含
  disconnect_last=False 保留连接的真实排队路径,并以哨兵断言证明事件
  确实被派发;覆盖 hasFinished / canceled / started 及全部 UI 事件
@TiaoFeng
TiaoFeng requested a review from fyc09 October 3, 2026 07:12
qt-ui 分片里新增的 test_progress_info_bar 首次调用
sendPostedEvents(None, QEvent.DeferredDelete),会连带销毁先前用例残留、且仍在
运行的 QThread,触发 Qt qFatal -> SIGABRT(退出码 134)。

- tearDown 先停止并等待页面辖下全部 CampusFeatureThread 子线程,再显式发送
  DeferredDelete 真正销毁页面(processEvents 不会 flush 延迟删除)。
- 两个用例在 patch 范围外再次调用 showEvent,会发起真实后台网络请求(校历图片
  下载,timeout=30s),这类线程卡在 DNS/请求超时时 terminate() 结束不掉;改为在
  替换掉加载函数的前提下断言,既保留原意,也真正覆盖“无账号不请求”与“重新加载”
  两个分支。
@TiaoFeng

TiaoFeng commented Oct 4, 2026

Copy link
Copy Markdown
Contributor Author

CI 失败已修复

#57 的 CI 失败(exit 134)
不是这个 PR 创造的 bug,而是 PR 新增的测试第一次触发了一处早已存在的缺陷。

报错本身:exit 134 = SIGABRT。日志里最后一行测试输出是
QThread: Destroyed while thread is still running —— Qt 5.15 的
QThread::~QThread() 在销毁一个仍在运行的线程时会 qFatal() → abort()。
两个失败 job(linux-x64-py310、linux-x64-py313)停在同一个测试、同一则
消息,是同一个问题

根因:

  1. app/components/CampusPage.py:86 用 parent=self 创建 CampusFeatureThread,
    线程因此成为 page 的 Qt 子对象,会随 page 一起被销毁。
  2. test_campus_pages.py 的 tearDown 只做 deleteLater() + processEvents();
    实测 processEvents() 不会 flush DeferredDelete,所以带着运行中线程的 page
    会残留到后续用例。
  3. 整个 qt-ui 分片里唯一会 flush DeferredDelete 的是本 PR 新增的
    test/app/test_progress_info_bar.py(首个调用点在 424 行)。
  4. 这次 flush 真正销毁残留 page → Qt 连带销毁其中仍在运行的子线程 → abort。

CI runner 到 flush 时(分片起跑约 5s)仍在运行,导致触发错误。

本 PR 内已修复

  • test_campus_pages.py:tearDown 先停止并等待页面辖下全部 CampusFeatureThread
    子线程(findChildren(QThread) + widget.thread;can_run=False → wait() →
    逾时再 terminate() + wait()),最后用
    sendPostedEvents(None, QEvent.DeferredDelete) 真正销毁页面。
  • 两个在 patch 范围外调用 showEvent、会发起真实网络请求(校历图片下载,
    timeout=30s)的用例,改为在替换加载函数的前提下断言;顺带修复原先对已失效
    Mock 的无效断言。
  • 目前 CI 测试已通过

不在本 PR 范围

  • CampusPage 的线程生命周期(parent=self + 没有收尾路径)是既有缺陷,
    真实使用中(任务运行时关闭程序 / 销毁页面)同样可能 abort, 建议另开 Issue 修复

@fyc09
fyc09 merged commit dcf1d72 into yan-xiaoo:main Oct 12, 2026
33 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants