Changes: - Remove test_reminder behavior (debug artifact) - System tray notifications via QSystemTrayIcon.showMessage() - IM-style chat bubbles: QLabel pool, tight grouping, tail corners - Thread safety fix: QThread.currentThread() in add_message() - docs: complete debug journal (11 sections, from first segfault to final fix) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
12 KiB
EzVibe Segfault 完整排查日志
日期: 2026-05-23 作者: Claude Code + e2hang 状态: ✅ 已完全修复
目录
- 问题概述
- 阶段 1:GPU 命令缓冲区崩溃
- 阶段 2:Live2D 连续渲染循环
- 阶段 3:QWebChannel 调试探针
- 阶段 4:隔离崩溃触发点
- 阶段 5:定位根本原因
- 根因:一行代码的 bug
- 修复方案
- UI 改进
- 最终状态
- 经验教训
1. 问题概述
初始现象
QObject::setParent: Cannot set parent, new parent is in a different thread (x12)
[ERROR: command_buffer_proxy_impl.cc:327] GPU state invalid after WaitForGetOffsetInRange.
Segmentation fault (core dumped)
- 环境:Fedora Linux, X11 (xcb), PySide6 6.11.1
- Live2D March 7th 模型(307 drawables, 2× 4096×4096 纹理)
- 100% 稳定复现,约每 120 秒崩溃一次
两个并发问题
- Segfault:应用运行约 2 分钟后崩溃
- 聊天气泡消失:气泡在崩溃前突然消失
2. 阶段 1:GPU 命令缓冲区崩溃
假设
Chromium GPU 进程的命令缓冲区(command buffer)在 Linux sandbox 下损坏,导致 WaitForGetOffsetInRange 检测到无效状态。
尝试的修复
在 main.py 中添加 Chromium flags:
os.environ["QTWEBENGINE_CHROMIUM_FLAGS"] = "--no-sandbox --in-process-gpu"
os.environ["QTWEBENGINE_DISABLE_SANDBOX"] = "1"
结果
- GPU 错误
command_buffer_proxy_impl.cc:327消失了 - 但 segfault 仍然存在(现在的崩溃路径不同)
--in-process-gpu将 GPU 操作移入浏览器进程,消除了跨进程 IPC 问题
学到的
禁用 sandbox + in-process-gpu 修复了一个崩溃路径,但还有另一个崩溃路径。
3. 阶段 2:Live2D 连续渲染循环
假设
Live2D viewer.html 每 100ms (10fps) 持续渲染 WebGL 帧,触发 Chromium GPU 线程 QObject 跨线程 reparent,与 Qt 主线程布局操作竞争。
尝试的修复
- 将连续
setInterval(100ms)改为 burst 模式(初始 10 帧后停止) - 添加
gl.finish()在首帧渲染后同步 GPU - 纹理从 4096×4096 缩放至 1024×1024(GPU 内存 128MB → 8MB)
- 移除 mipmap 生成(减少 33% 纹理内存)
- 添加
gl.getError()检查
结果
setParent警告从 23 个减少到 12 个- 首帧渲染成功
- 但 segfault 仍然在约 30-40 秒时发生
学到的
WebGL 渲染本身是稳定的。崩溃需要时间累积,暗示与定时器/调度器相关。
4. 阶段 3:QWebChannel 调试探针
假设
我们需要精确定位在哪一步崩溃。
方法
- 添加
qwebchannel.js到 assets - 在 viewer.html 中添加 QWebChannel 初始化
- 在 Live2D 初始化的每个步骤插入
pybridge.onJsMessage()探针 - Python 侧打印
[JS→PY]消息
检查点序列
[JS→PY] init_start ← JS 开始初始化
[JS→PY] webgl_ok:WebKit ← WebGL 上下文创建成功
[JS→PY] shader_ok ← Shader 编译链接成功
[JS→PY] test_rect_ok ← 测试三角形渲染成功
[JS→PY] decoding_moc ← 开始解码 .moc3
[JS→PY] model_ok:d=307 ← 模型加载成功(307 drawables)
[JS→PY] loading_textures:n=2 ← 开始加载纹理
[JS→PY] texture_ok:0 ← 纹理 0 加载成功
[JS→PY] texture_ok:1 ← 纹理 1 加载成功
[JS→PY] textures_done:n=2 ← 所有纹理加载完成
[JS→PY] building_buffers ← 开始构建 WebGL 缓冲区
[JS→PY] buffers_ok:n=307 ← 307 个缓冲区构建成功
[JS→PY] scheduling_render ← 调度首帧渲染
[JS→PY] ready ← Live2D 就绪
[JS→PY] first_frame_ok ← 首帧渲染成功
... ~20-30 秒正常运行 ...
Segmentation fault
关键发现
Live2D 全部初始化成功,崩溃发生在 first_frame_ok 后 20-30 秒。
这个时间精准对应 brain timer 的 30 秒间隔。
5. 阶段 4:隔离崩溃触发点
假设
Brain timer(30s)或 scheduler(10s)触发 show_reminder → _refresh_messages 导致崩溃。
测试
| 测试条件 | 结果 |
|---|---|
| brain timer 禁用 + test_reminder 禁用 | 2 分钟+ 无崩溃 |
| brain timer 恢复 + test_reminder 恢复 | 约 30s 崩溃 |
结论
崩溃触发点是 _refresh_messages(被 brain timer 或 scheduler 触发时)。
6. 阶段 5:定位根本原因
尝试 1:布局级联保护
假设:deleteLater() + insertWidget() 修改 QLayout 触发级联失效,影响 QWebEngineView。
修复:在 _refresh_messages 期间使用 setUpdatesEnabled(False) 阻断 QWebEngineView。
结果:仍然崩溃。说明不是布局级联的问题。
尝试 2:控件池复用
假设:避免任何布局修改(不调用 deleteLater/insertWidget),仅更新已有 QLabel 的文本和可见性。
修复:创建 QLabel 控件池,只在池不足时追加新控件,后续仅更新文本。
结果:
- 用户测试:2 分钟+ 无崩溃
- 但气泡渲染有问题:消息出现在"弹窗"中
尝试 3:QTextBrowser
将 QScrollArea + QLabel 完全替换为 QTextBrowser(用 setHtml() 渲染)。
结果:立即崩溃,错误信息:
QObject: Cannot create children for a parent that is in a different thread.
(Parent is QTextDocument..., parent's thread is QThread(main),
current thread is QThread(different))
🔴 关键线索
QTextDocument 在主线程,但 setHtml() 从其他线程调用!
这说明 _do_add_message → _refresh_messages → setHtml() 不是在主线程执行的。
7. 根因:一行代码的 bug
有 bug 的代码
ui/pet_window.py 的 add_message 方法:
def add_message(self, text, is_self=False):
# ❌ BUG: QApplication.instance().thread() 始终返回主线程
# self._container.thread() 也返回主线程
# 两个主线程比较永远相等 → 永远为 False → 永不进入 QueuedConnection
if self._QtWidgets.QApplication.instance().thread() != self._container.thread():
# 这个分支永远不会执行!
QMetaObject.invokeMethod(..., QueuedConnection, ...)
return
# 总是直接调用 → 从 scheduler 线程执行 Qt 操作!
self._do_add_message(text, is_self)
为什么
| 代码 | 返回值 | 说明 |
|---|---|---|
QApplication.instance().thread() |
主线程 | QApplication 所在的线程 |
self._container.thread() |
主线程 | Container 控件所在的线程 |
QThread.currentThread() |
当前线程 | 正在执行代码的线程 |
QApplication.instance().thread() 返回 QApplication 所在的线程(始终是主线程),不是当前执行代码的线程。正确的 API 是 QThread.currentThread()。
崩溃机制
scheduler 线程 (ezvibe-scheduler)
→ self._window.show_reminder(message)
→ self._chat_bubble.add_message(message)
→ QApplication.thread() == container.thread() → False (都=主线程)
→ 跳过 QueuedConnection
→ self._do_add_message(text, False) ← 在 scheduler 线程执行!
→ self._refresh_messages()
→ deleteLater() / insertWidget() / setHtml()
→ 跨线程修改 Qt widget 树
→ Qt 内部状态损坏
→ 随机 SIGSEGV
为什么症状多样化
deleteLater()跨线程 →setParent警告 + 延迟 segfaultinsertWidget()跨线程 → 布局级联失效 +QBoxLayout::setGeometrycrashsetHtml()跨线程 → QTextDocumentCannot create children+ 立即 crash- 每次崩溃栈不同,因为 Qt 内部损坏的时机和位置不同
为什么 Live2D/WebEngine 放大了症状
- 没有 Live2D 时:Qt 控件树简单,跨线程损坏不致命
- 有 Live2D 时:QWebEngineView 的 Chromium compositor 持有 GPU 资源 → 控件树损坏触发 compositor 访问无效指针 → segfault
- Live2D 不是根因,但放大了症状
8. 修复方案
核心修复(一行)
# ✅ 正确:比较当前执行线程
if self._QtCore.QThread.currentThread() != self._container.thread():
辅助改进
| 修改 | 文件 | 作用 |
|---|---|---|
| QLabel 控件池 | pet_window.py |
不删除控件,仅更新文本/可见性,避免布局修改 |
| 纹理缩放 1024×1024 | viewer.html |
GPU 内存 128MB→8MB |
| 移除 mipmap | viewer.html |
减少 GPU 操作 |
| Burst 渲染 | viewer.html |
首帧后停止连续渲染,按需渲染 |
| QWebChannel 探针 | viewer.html, live2d_view.py |
调试基础设施 |
| Chromium flags | main.py |
--no-sandbox --in-process-gpu |
| 托盘图标 | pet_window.py |
修复 "No Icon set" 警告 |
9. UI 改进
聊天气泡
- 从 QTextBrowser(CSS 受限)回到 QLabel 控件池(完整 Qt stylesheet 支持)
- IM 风格设计:
- 自己的消息:蓝色(#4A90D9)、白色文字、右对齐、右下角小圆角尾巴
- 对方的消息:白色半透明、深色文字、左对齐、左下角小圆角尾巴
- 紧密排列:同侧连续消息共享圆角(首条顶部圆、末条底部圆、中间小圆角)
- 布局间距 0px
系统通知
show_reminder同时发送:- 聊天面板气泡
- 系统托盘通知(
QSystemTrayIcon.showMessage())
10. 最终状态
修改文件
| 文件 | 修改 |
|---|---|
ui/pet_window.py |
线程检查修复;QLabel 控件池;IM 气泡样式;系统通知 |
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 |
验证结果
| 测试场景 | 时长 | 结果 |
|---|---|---|
| 完整功能(Live2D + 聊天 + scheduler + brain timer) | 2 min+ | 无崩溃 |
| 系统通知 | 每次提醒 | 正常工作 |
| 聊天气泡 | 每条消息 | 正常显示,IM 风格 |
| 用户输入 + AI 回复 | 每次交互 | 正常工作 |
11. 经验教训
-
QApplication.instance().thread()≠QThread.currentThread()- 前者返回 QApplication 所在的线程(始终是主线程)
- 后者返回当前执行代码的线程
- 线程安全判断必须用
currentThread()
-
GPU/WebGL 崩溃可能是症状而非根因
- 当 Qt 控件树因跨线程操作而损坏时,任何涉及控件树的操作都可能崩溃
- WebEngine compositor 崩溃是因为它访问了损坏的控件树,不是因为它本身有问题
-
调试探针比猜测更有效
- QWebChannel checkpoints 精准定位了崩溃时机
- QTextBrowser 的显式报错揭示了线程问题
- 每个探针都缩小了搜索范围
-
一次只改一个变量
- 禁用 brain timer → 无崩溃 → 确认触发点
- 恢复 brain timer → 崩溃 → 确认相关性
- 改用 QTextBrowser → 新的错误信息 → 揭示根因
-
控件池 > 删除+重建
- 对于动态列表 UI,维护控件池并更新文本/可见性比不断删除和创建控件更安全
- 避免了
deleteLater()的异步删除和insertWidget()的布局级联
-
QTextBrowser 的 CSS 支持有限
- 不支持
border-radius、display: inline-block等关键属性 - 对于需要丰富样式的 UI,QLabel + Qt stylesheet 是更好的选择
- 不支持