# 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) ```