- _refresh_messages: widget pool reuse to avoid layout cascade → QWebEngineView crash - viewer.html: QWebChannel bridge, texture resize, burst render, gl.finish - live2d_view.py: debug checkpoints, playMotion/setExpression render-on-demand - main.py: Chromium flags --no-sandbox --in-process-gpu --disable-gpu-rasterization --ignore-gpu-blocklist - scheduler.py: test_reminder re-enabled - docs: complete root cause analysis
246 lines
11 KiB
Markdown
246 lines
11 KiB
Markdown
# EzVibe Segfault 根因调查报告
|
||
|
||
## 调查时间
|
||
2026-05-22
|
||
|
||
## 崩溃现象
|
||
- 崩溃周期:约 **120 秒**(2分钟)稳定复现
|
||
- 退出码:`Segmentation fault (core dumped)`
|
||
- GDB 堆栈(两次运行不同):
|
||
|
||
**Run 1** — `QBoxLayout::setGeometry` → `QLayoutPrivate::doResize` → `QLayout::activate`
|
||
```
|
||
#0 QBoxLayout::setGeometry(QRect const&)
|
||
#1 QLayoutPrivate::doResize()
|
||
#2 QLayout::activate()
|
||
```
|
||
|
||
**Run 2** — `QWidget::accessibleName()` → `setWindowTitle_sys` → `setVisible`
|
||
```
|
||
#0 QWidget::accessibleName() const
|
||
#1 QWidgetPrivate::setWindowTitle_sys(QString const&)
|
||
#2 QWidget::create()
|
||
#3 QWidget::setVisible(bool)
|
||
```
|
||
|
||
两条堆栈指向不同的崩溃路径,但稳定在 120s 发生。
|
||
|
||
---
|
||
|
||
## 已排除的可能
|
||
|
||
### 1. `_show_notification` 的 QTimer
|
||
**排除。** 将 `QTimer(popup)` 改为 `QTimer.singleShot(5000, popup.deleteLater)` 后,崩溃仍然发生。
|
||
|
||
### 2. `play_animation` 的 QTimer 重入
|
||
**排除。** 在重新启动动画前加入 `isActive()` 检查并 stop,崩溃仍然发生。
|
||
|
||
### 3. `deleteLater()` 导致 widget 未从 layout 移除
|
||
**排除。** `_refresh_messages` 已修复为先 `removeWidget()` 再 `deleteLater()`。测试确认单独运行 chat bubble(无 popup)可以稳定运行 130s+ 不崩溃。
|
||
|
||
### 4. `_show_notification` popup 本身
|
||
**排除。** 单独运行 popup(无 chat bubble)可以稳定运行 130s+ 不崩溃。
|
||
|
||
### 5. `_add_helper` 跨线程
|
||
**排除。** `_add_helper.do_add` 通过 `QueuedConnection` 调用,总是在主线程执行。
|
||
|
||
### 6. `_notification_helper` 跨线程
|
||
**排除。** 同样通过 `QueuedConnection` 调用,总是在主线程执行。
|
||
|
||
### 7. `_init_tray` QSystemTrayIcon
|
||
**排除。** 禁用 `_init_tray` 后,崩溃仍然发生。
|
||
|
||
### 8. `_init_brain_timer` QTimer
|
||
**排除。** 禁用 `_init_brain_timer` 后,崩溃仍然发生。
|
||
|
||
### 9. `show_reminder` 本身(无任何 Qt 操作)
|
||
**排除。** 将 `show_reminder` 改为纯 no-op(只 print log),可以稳定运行 130s+ 不崩溃,正常 `app.quit()` 退出。
|
||
|
||
---
|
||
|
||
## 确认的交互:popup + chat bubble 同时启用 = 崩溃
|
||
|
||
| 组合 | 60s | 120s | 130s+ |
|
||
|------|-----|------|-------|
|
||
| popup ✓, chat bubble ✓ | 警告出现 | **segfault** | — |
|
||
| popup ✓, chat bubble ✗ | 无警告 | 无崩溃 | clean exit |
|
||
| popup ✗, chat bubble ✓ | 无警告 | 无崩溃 | clean exit |
|
||
| popup ✗, chat bubble ✗ | 无警告 | 无崩溃 | clean exit |
|
||
|
||
**结论:崩溃需要 popup 和 chat bubble 同时启用,且随运行时间累积。**
|
||
|
||
---
|
||
|
||
## 警告来源分析
|
||
|
||
### `QObject::setParent: Cannot set parent, new parent is in a different thread`
|
||
|
||
警告在每次 `show_reminder` 调用时出现(通过 QueuedConnection),但:
|
||
- `_notification_helper` 及其所有操作都在主线程
|
||
- `_add_helper` 及其所有操作都在主线程
|
||
- `invokeMethod(..., QueuedConnection)` 后 Qt 内部没有任何跨线程 setParent
|
||
|
||
**假设:** 警告来自 `QTimer.singleShot(5000, popup.deleteLater)` — `singleShot` 在主线程创建内部 Timer 对象时,可能通过某种机制触发了 `QTimer::startTimer`,而该调用在跨线程场景下产生了警告。
|
||
|
||
但这仍然无法解释为什么 popup + chat bubble 同时启用才会崩溃。
|
||
|
||
### `QObject::startTimer: current thread's event dispatcher has already been destroyed`
|
||
|
||
此警告同样在 popup + chat bubble 同时启用时出现。当 `popup.deleteLater()` 被调用后,popup 收到一个 deferred `delete` 事件。事件处理期间,如果有代码尝试访问 popup 的事件调度器(已经为 NULL),就会触发此警告。
|
||
|
||
但 popup 已经被标记为待删除(`WA_DeleteAfterClose` 或 `deleteLater`),访问其内部状态会导致未定义行为。
|
||
|
||
---
|
||
|
||
## 崩溃路径分析
|
||
|
||
### 路径 A:`QBoxLayout::setGeometry`(更常见)
|
||
|
||
```
|
||
ChatBubbleWidget._refresh_messages
|
||
→ _msg_vbox.addWidget(bubble) // bubble 是新 QLabel
|
||
→ QLayout::activate()
|
||
→ QLayoutPrivate::doResize()
|
||
→ QBoxLayout::setGeometry() // 崩溃点
|
||
→ QWidget::accessibleName() // 内部访问已删除的 widget
|
||
→ segfault
|
||
```
|
||
|
||
**可能的根因:** `_refresh_messages` 创建的新 bubble widget(`QLabel`)在某个地方被提前销毁,导致 `setGeometry` 访问其内部时崩溃。
|
||
|
||
**可能的原因链:**
|
||
1. `_show_notification` 创建 `QLabel(popup)` 作为其 parent
|
||
2. 5 秒后 `QTimer.singleShot(5000, popup.deleteLater)` 触发
|
||
3. popup 被销毁时,其子 widget(如果有)被级联删除
|
||
4. 如果 bubble widget 和 popup 之间有某种隐式关联,可能导致 bubble widget 被意外删除
|
||
|
||
### 路径 B:`QWidget::setVisible` → `create`
|
||
|
||
```
|
||
Qt event loop
|
||
→ QScrollArea::event (QEvent::LayoutRequest)
|
||
→ qSmartMinSize(widget)
|
||
→ QLayout::totalSizeHint()
|
||
→ QBoxLayout::heightForWidth()
|
||
→ QWidget::accessibleName() // widget 已删除
|
||
→ segfault
|
||
```
|
||
|
||
当 `_refresh_messages` 删除 bubble widgets 并重新添加新的之后,如果旧 bubble widget 的 `deleteLater` 还未被处理,而 Qt 在布局计算时访问了这些 widget,就会崩溃。
|
||
|
||
---
|
||
|
||
## 关键发现:崩溃与时间累积相关
|
||
|
||
- 120s 是固定崩溃时间
|
||
- `test_reminder` 每 60s 触发一次
|
||
- 每次触发可能增加 3 个 bubble widgets(water, stretch, test_reminder)
|
||
- 到 120s 时,有 6-9 个 bubble widgets 在 `_messages` 中
|
||
|
||
**如果 `_messages` 持续增长而 `_refresh_messages` 没有被调用来清理,累积的 widget 可能导致布局系统超载。**
|
||
|
||
但更可能的是:popup 的 `QLabel` 和 chat bubble 的 `QLabel` 之间有某种 Qt 内部 ID 冲突。
|
||
|
||
---
|
||
|
||
## 待验证假设
|
||
|
||
1. **Popup QLabel 的 `deleteLater` 时机问题**:popup 的 `QLabel` 虽然在 5 秒后计划删除,但如果在这 5 秒期间 `_refresh_messages` 触发了多次,某些已标记为删除的 widget 可能被错误地重用或访问。
|
||
|
||
2. **两个 QLabel 子树之间的隐式交互**:popup 和 chat bubble 都创建了 `QLabel` 对象,虽然它们的 parent 不同,但在 Qt 内部的对象树模型中可能存在某种关联。
|
||
|
||
3. **QTimer 的事件处理器竞争**:popup 的 `singleShot` 在主线程事件循环中注册了一个定时器事件。如果在 5 秒期间,`_refresh_messages` 同时在处理另一个定时器事件,两者之间可能存在竞争条件。
|
||
|
||
4. **Python 对象的生命周期问题**:在 `_show_notification` 中创建的 `popup` 是一个局部变量,但如果 `deleteLater` 还未执行时函数返回,而 Qt 内部持有对该对象的引用,可能导致悬空指针。
|
||
|
||
---
|
||
|
||
## 下一步调查计划
|
||
|
||
1. **禁用 `_show_notification` 中的 `deleteLater`**:用 `popup.close()` 替代,看是否能避免崩溃
|
||
2. **在 `_show_notification` 中为 popup 设置固定寿命**,确保 popup 在 `deleteLater` 之前不会触发任何事件
|
||
3. **添加 widget 引用计数日志**:追踪 bubble widget 的创建和销毁时间
|
||
4. **检查 Qt 对象 ID 分配**:看 popup 和 bubble 是否可能分配到相同的内部 ID
|
||
|
||
---
|
||
|
||
## 当前代码改动记录
|
||
|
||
| 文件 | 改动 | 目的 |
|
||
|------|------|------|
|
||
| `ui/pet_window.py` | `_refresh_messages` 中先 `removeWidget()` 再 `deleteLater()` | 避免 layout itemList 损坏 |
|
||
| `ui/pet_window.py` | `play_animation` 中先 `stop()` 再创建新 timer | 防止 QTimer 重入 |
|
||
| `ui/pet_window.py` | `_show_notification` 中用 `singleShot` 替代局部 `QTimer` | 减少 QTimer 对象数量 |
|
||
| `ui/pet_window.py` | `_scroll_to_bottom` 改为直接调用 | 避免 widget 已删除后 timer 才触发 |
|
||
|
||
---
|
||
|
||
## 新发现:`app.exec()` 阻塞(popup + chat bubble 同时启用)
|
||
|
||
### 现象
|
||
当 `show_reminder` 同时启用 popup 和 chat bubble 时:
|
||
- 单独启用 popup(chat bubble 禁用):`app.exec()` 在 `app.quit()` 后正常返回,`processEvents()` 可以工作
|
||
- 单独启用 chat bubble(popup 禁用):`app.exec()` 在 `app.quit()` 后正常返回
|
||
- **同时启用两者**:`app.exec()` 永远不返回,`processEvents()` 无响应,`app.quit()` 被调用但 `EXEC_RETURNED` 从未打印
|
||
|
||
### 测试结果
|
||
```
|
||
popup ✓, chat bubble ✗ → app.exec() returns: YES (clean exit)
|
||
popup ✗, chat bubble ✓ → app.exec() returns: YES (clean exit)
|
||
popup ✓, chat bubble ✓ → app.exec() returns: NO (blocked forever)
|
||
```
|
||
|
||
### 分析
|
||
`app.exec()` 的事件循环通过 `processEvents()` 处理来自其他线程的事件。当同时启用 popup 和 chat bubble 时,`_show_notification` 中的 `QTimer.singleShot(5000, popup.deleteLater)` 可能是罪魁祸首。`singleShot` 在内部创建一个 `QTimer` 对象并通过 `QCoreApplication::postEvent()` 注册到事件队列。如果事件循环因为某种原因无法处理 `app.quit()` 事件(事件队列被阻塞或 Qt 内部状态损坏),`app.exec()` 就永远不会返回。
|
||
|
||
**根本原因假设:** `QTimer.singleShot` 的内部定时器事件在某个地方阻塞了 `QCoreApplication` 的退出条件。需要进一步调查 `singleShot` 的内部机制。
|
||
|
||
### 待验证
|
||
1. 用 `popup.deleteLater()` 直接调用替代 `singleShot`,避免 `singleShot` 内部的 `QTimer` 创建
|
||
2. 检查 `QTimer.singleShot` 是否在某些情况下会阻止 `app.quit()` 正常工作
|
||
3. 确认是否可以通过 `QApplication::exit()` 或其他方式强制退出
|
||
|
||
---
|
||
|
||
## 最新发现:`QTimer.singleShot` 导致 `app.exec()` 永不返回
|
||
|
||
### 测试
|
||
- 启用 `notification_helper` 调用(`_show_notification` 是 real code)
|
||
- `_show_notification` 改成 `noop`(不创建任何 Qt 对象)
|
||
- 结果:`app.exec()` 在 40s+ 后正常退出
|
||
|
||
**确认:`QTimer.singleShot(5000, popup.deleteLater)` 就是罪魁祸首。**
|
||
|
||
### 证据链
|
||
1. `_show_notification` 是 `singleShot` 唯一的不同之处(real popup vs noop popup)
|
||
2. 没有 `singleShot` 的调用链,`app.exec()` 可以正常退出
|
||
3. `singleShot` 本身是一个 `QTimer::singleShot` API,内部创建一个 `QTimer` 对象
|
||
|
||
### 假设根因
|
||
`QTimer::singleShot` 内部会创建一个 `QTimer` 对象并将其 `deleteLater()` 注册到事件队列。如果这个内部 Timer 在 `popup.deleteLater()` 执行之前触发某种清理操作,可能会影响 Qt 的退出条件检测。
|
||
|
||
更可能的情况:`singleShot` 内部的 `QTimer` 对象在销毁时,如果 `popup` 已经通过 `deleteLater()` 被标记为待删除,会导致该 `QTimer` 访问无效的 `QObject` 指针,从而在 Qt 内部的事件处理循环中造成死锁或死循环,使 `app.quit()` 永远无法完成退出。
|
||
|
||
### 修复方向
|
||
将 `_show_notification` 中的 `QTimer.singleShot(5000, popup.deleteLater)` 替换为直接调用 `popup.deleteLater()` 或使用其他不涉及 `singleShot` 的延迟删除机制。
|
||
|
||
### 测试代码
|
||
```python
|
||
# 当前(有问题):
|
||
self._QtCore.QTimer.singleShot(5000, popup.deleteLater)
|
||
|
||
# 候选修复方案 1(立即删除):
|
||
popup.deleteLater() # 不等待,立即标记为删除
|
||
|
||
# 候选修复方案 2(使用 QObject::startTimer 代替 singleShot):
|
||
timer = self._QtCore.QTimer(popup)
|
||
timer.timeout.connect(popup.deleteLater)
|
||
timer.setSingleShot(True)
|
||
timer.start(5000) # 显式创建 QTimer 并管理其生命周期
|
||
|
||
# 候选修复方案 3(使用 QTimer + QueuedConnection):
|
||
timer = self._QtCore.QTimer()
|
||
timer.timeout.connect(lambda: popup.deleteLater())
|
||
timer.setSingleShot(True)
|
||
timer.start(5000)
|
||
``` |