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

11 KiB
Raw Permalink Blame History

EzVibe Segfault 根因调查报告

调查时间

2026-05-22

崩溃现象

  • 崩溃周期:约 120 秒2分钟稳定复现
  • 退出码:Segmentation fault (core dumped)
  • GDB 堆栈(两次运行不同):

Run 1QBoxLayout::setGeometryQLayoutPrivate::doResizeQLayout::activate

#0  QBoxLayout::setGeometry(QRect const&)
#1  QLayoutPrivate::doResize()
#2  QLayout::activate()

Run 2QWidget::accessibleName()setWindowTitle_syssetVisible

#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_DeleteAfterClosedeleteLater),访问其内部状态会导致未定义行为。


崩溃路径分析

路径 AQBoxLayout::setGeometry(更常见)

ChatBubbleWidget._refresh_messages
  → _msg_vbox.addWidget(bubble)   // bubble 是新 QLabel
  → QLayout::activate()
  → QLayoutPrivate::doResize()
  → QBoxLayout::setGeometry()     // 崩溃点
    → QWidget::accessibleName()  // 内部访问已删除的 widget
      → segfault

可能的根因: _refresh_messages 创建的新 bubble widgetQLabel)在某个地方被提前销毁,导致 setGeometry 访问其内部时崩溃。

可能的原因链:

  1. _show_notification 创建 QLabel(popup) 作为其 parent
  2. 5 秒后 QTimer.singleShot(5000, popup.deleteLater) 触发
  3. popup 被销毁时,其子 widget如果有被级联删除
  4. 如果 bubble widget 和 popup 之间有某种隐式关联,可能导致 bubble widget 被意外删除

路径 BQWidget::setVisiblecreate

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_notificationsingleShot 唯一的不同之处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 的延迟删除机制。

测试代码

# 当前(有问题):
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)