Root cause: add_message() used QApplication.instance().thread() to check thread affinity, but this always returns the main thread (same as container.thread()), so the check was always False. Qt widget operations (deleteLater, insertWidget, setHtml) were executed from the scheduler background thread, corrupting internal state. Fix: use QThread.currentThread() to compare against the actual executing thread. Also: replace QScrollArea+QLabel with QTextBrowser for message display, eliminating widget management entirely. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5.6 KiB
EzVibe Segfault — 完整根因分析与最终修复
日期: 2026-05-23 状态: ✅ 已修复
一、崩溃现象
多种崩溃表现,随时间演变:
| 阶段 | 表现 | 时机 |
|---|---|---|
| 初始 | GPU state invalid after WaitForGetOffsetInRange + segfault |
Live2D 纹理加载后立即崩溃 |
| Phase 2 | QObject::setParent 警告 (x23) + segfault |
first_frame_ok 后 ~20-30 秒 |
| Phase 3 | QObject::setParent 警告 (x12) + startTimer 警告 + segfault |
first_frame_ok 后 ~20-30 秒 |
| Phase 4 | Cannot create children for parent in different thread + segfault |
消息到达时立即崩溃 |
每次崩溃表现不同,但都发生在 scheduler/brain timer 触发 show_reminder → _refresh_messages 时。
二、诊断过程
阶段 1:GPU 命令缓冲区损坏
- 添加
--no-sandbox --in-process-gpuChromium flags - 结果:GPU 错误消失,但 segfault 仍在(延迟出现)
阶段 2:连续 WebGL 渲染循环
- 将
setInterval(100ms)连续渲染改为 burst 模式 - 结果:
setParent警告从 23 减少到 12,首帧渲染成功
阶段 3:QWebChannel 调试探针
- 引入
qwebchannel.js+[JS→PY]checkpoint 消息 - 定位:Live2D 初始化完全成功(WebGL, Shader, Model, Textures, Buffers, First Frame 全部通过)
- 崩溃发生在
first_frame_ok后 20-30 秒
阶段 4:隔离测试
- 禁用 brain timer + test_reminder → 稳定运行 2 分钟+
- 确认:崩溃触发点为
show_reminder→_refresh_messages
阶段 5:QTextBrowser 揭示真相
- 将 QScrollArea+QLabel 替换为 QTextBrowser
- 崩溃变为:
Cannot create children for a parent that is in a different thread - 关键线索:QTextDocument 在主线程,但
setHtml()从其他线程调用
三、根因
一行代码的 Bug
ui/pet_window.py 第 284 行(修复前):
# ❌ bug: QApplication.instance().thread() 和 container.thread() 都是主线程
if self._QtWidgets.QApplication.instance().thread() != self._container.thread():
QApplication.instance().thread() 返回主线程。self._container.thread() 也返回主线程。两者是同一个 QThread 对象,比较结果永远为 False。
导致:从 scheduler 后台线程调用 _do_add_message → _refresh_messages → 在非主线程执行 Qt Widget 操作。
崩溃机制
scheduler 线程 (ezvibe-scheduler)
→ show_reminder(message)
→ add_message(text)
→ 线程检查: main_thread != main_thread → False ✗
→ _do_add_message(text) ← 在 scheduler 线程执行!
→ _refresh_messages()
→ deleteLater() / insertWidget() / setHtml()
→ 跨线程修改 Qt Widget 树
→ 内部状态损坏
→ SIGSEGV
为什么症状多样化
deleteLater()跨线程 →QObject::setParent警告 + 延迟 segfaultinsertWidget()跨线程 → 布局级联失效 +QBoxLayout::setGeometrycrashsetHtml()跨线程 → QTextDocumentCannot create children+ 立即 crash- 同一时刻的 Qt 内部状态不同 → 每次崩溃栈不同
为什么 Live2D/WebEngine 似乎是个因素
- 禁用 Live2D 时,QWebEngineView 不存在 → 控件树更简单 → 跨线程损坏不致命
- 启用 Live2D 时,QWebEngineView 的 Chromium compositor 持有 GPU 资源 → 控件树损坏触发 compositor 访问无效指针 → segfault
- Live2D 放大了症状,但不是根因
四、修复
一行修复
# ✅ 正确:比较当前执行线程与 container 所在线程
if self._QtCore.QThread.currentThread() != self._container.thread():
QThread.currentThread() 返回当前正在执行代码的线程,而非 QApplication 所在的线程。
辅助优化
| 修改 | 文件 | 作用 |
|---|---|---|
| QScrollArea+QLabel → QTextBrowser | pet_window.py |
用 setHtml() 替代控件管理,消除 deleteLater/insertWidget |
| 纹理缩放 1024×1024, 无 mipmap | viewer.html |
GPU 内存 128MB→8MB,消除 GPU 内存压力 |
| QWebChannel 集成 + 调试探针 | viewer.html, live2d_view.py |
JS→PY 消息追踪 |
| Chromium flags | main.py |
--no-sandbox --in-process-gpu --disable-gpu-rasterization --ignore-gpu-blocklist |
| 托盘图标 | pet_window.py |
32×32 蓝色 pixmap,修复 "No Icon set" |
五、变更文件
| 文件 | 关键修改 |
|---|---|
ui/pet_window.py |
QThread.currentThread() 修复;QTextBrowser 替代 QScrollArea |
assets/live2d/march7/viewer.html |
QWebChannel;纹理缩放;burst 渲染;gl.finish |
ui/live2d_view.py |
onJsMessage 调试;playMotion/setExpression 按需渲染 |
main.py |
Chromium flags |
agent/scheduler.py |
test_reminder 恢复启用 |
六、经验教训
-
QApplication.instance().thread()≠QThread.currentThread()。前者返回 QApplication 所在的线程(始终是主线程),后者返回当前执行代码的线程。对于线程安全判断,必须使用后者。 -
QTextBrowser 比 QScrollArea + QLabel 更适合消息列表。QTextBrowser 内部管理内容渲染,不需要创建/删除 QWidget 子控件,避免了 layout cascade 问题。
-
GPU/WebGL 崩溃可能是症状而非根因。当 Qt 控件树因跨线程操作而损坏时,任何涉及控件树遍历的操作(包括 WebEngine compositor)都可能触发崩溃。
-
调试探针比猜测更有效。QWebChannel checkpoints 精准定位了崩溃发生在 first_frame_ok 之后,而 QTextBrowser 的显式报错最终揭示了线程问题。