Files
EzVibe/docs/segfault_investigation.md
e2hang 798e5c2f7d checkpoint: segfault root cause analysis + Live2D stability fixes
- _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
2026-05-23 13:33:58 +08:00

246 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 widgetswater, 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 时:
- 单独启用 popupchat bubble 禁用):`app.exec()``app.quit()` 后正常返回,`processEvents()` 可以工作
- 单独启用 chat bubblepopup 禁用):`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)
```