Files
EzVibe/docs/investigation/complete-debug-journal.md
e2hang 8e3b87051d prod: remove test_reminder, add system notifications, write complete debug journal
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>
2026-05-23 14:50:52 +08:00

12 KiB
Raw Permalink Blame History

EzVibe Segfault 完整排查日志

日期: 2026-05-23 作者: Claude Code + e2hang 状态: 已完全修复


目录

  1. 问题概述
  2. 阶段 1GPU 命令缓冲区崩溃
  3. 阶段 2Live2D 连续渲染循环
  4. 阶段 3QWebChannel 调试探针
  5. 阶段 4隔离崩溃触发点
  6. 阶段 5定位根本原因
  7. 根因:一行代码的 bug
  8. 修复方案
  9. UI 改进
  10. 最终状态
  11. 经验教训

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 秒崩溃一次

两个并发问题

  1. Segfault:应用运行约 2 分钟后崩溃
  2. 聊天气泡消失:气泡在崩溃前突然消失

2. 阶段 1GPU 命令缓冲区崩溃

假设

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. 阶段 2Live2D 连续渲染循环

假设

Live2D viewer.html 每 100ms (10fps) 持续渲染 WebGL 帧,触发 Chromium GPU 线程 QObject 跨线程 reparent与 Qt 主线程布局操作竞争。

尝试的修复

  • 将连续 setInterval(100ms) 改为 burst 模式(初始 10 帧后停止)
  • 添加 gl.finish() 在首帧渲染后同步 GPU
  • 纹理从 4096×4096 缩放至 1024×1024GPU 内存 128MB → 8MB
  • 移除 mipmap 生成(减少 33% 纹理内存)
  • 添加 gl.getError() 检查

结果

  • setParent 警告从 23 个减少到 12 个
  • 首帧渲染成功
  • 但 segfault 仍然在约 30-40 秒时发生

学到的

WebGL 渲染本身是稳定的。崩溃需要时间累积,暗示与定时器/调度器相关。


4. 阶段 3QWebChannel 调试探针

假设

我们需要精确定位在哪一步崩溃。

方法

  • 添加 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 timer30s或 scheduler10s触发 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 分钟+ 无崩溃
  • 但气泡渲染有问题:消息出现在"弹窗"中

尝试 3QTextBrowser

将 QScrollArea + QLabel 完全替换为 QTextBrowsersetHtml() 渲染)。

结果:立即崩溃,错误信息:

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_messagessetHtml() 不是在主线程执行的。


7. 根因:一行代码的 bug

有 bug 的代码

ui/pet_window.pyadd_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 警告 + 延迟 segfault
  • insertWidget() 跨线程 → 布局级联失效 + QBoxLayout::setGeometry crash
  • setHtml() 跨线程 → QTextDocument Cannot 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 改进

聊天气泡

  • 从 QTextBrowserCSS 受限)回到 QLabel 控件池(完整 Qt stylesheet 支持)
  • IM 风格设计:
    • 自己的消息:蓝色(#4A90D9、白色文字、右对齐、右下角小圆角尾巴
    • 对方的消息:白色半透明、深色文字、左对齐、左下角小圆角尾巴
    • 紧密排列:同侧连续消息共享圆角(首条顶部圆、末条底部圆、中间小圆角)
    • 布局间距 0px

系统通知

  • show_reminder 同时发送:
    1. 聊天面板气泡
    2. 系统托盘通知(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. 经验教训

  1. QApplication.instance().thread()QThread.currentThread()

    • 前者返回 QApplication 所在的线程(始终是主线程)
    • 后者返回当前执行代码的线程
    • 线程安全判断必须用 currentThread()
  2. GPU/WebGL 崩溃可能是症状而非根因

    • 当 Qt 控件树因跨线程操作而损坏时,任何涉及控件树的操作都可能崩溃
    • WebEngine compositor 崩溃是因为它访问了损坏的控件树,不是因为它本身有问题
  3. 调试探针比猜测更有效

    • QWebChannel checkpoints 精准定位了崩溃时机
    • QTextBrowser 的显式报错揭示了线程问题
    • 每个探针都缩小了搜索范围
  4. 一次只改一个变量

    • 禁用 brain timer → 无崩溃 → 确认触发点
    • 恢复 brain timer → 崩溃 → 确认相关性
    • 改用 QTextBrowser → 新的错误信息 → 揭示根因
  5. 控件池 > 删除+重建

    • 对于动态列表 UI维护控件池并更新文本/可见性比不断删除和创建控件更安全
    • 避免了 deleteLater() 的异步删除和 insertWidget() 的布局级联
  6. QTextBrowser 的 CSS 支持有限

    • 不支持 border-radiusdisplay: inline-block 等关键属性
    • 对于需要丰富样式的 UIQLabel + Qt stylesheet 是更好的选择