# EzVibe Segfault 完整排查日志 **日期:** 2026-05-23 **作者:** Claude Code + e2hang **状态:** ✅ 已完全修复 --- ## 目录 1. [问题概述](#1-问题概述) 2. [阶段 1:GPU 命令缓冲区崩溃](#2-阶段-1gpu-命令缓冲区崩溃) 3. [阶段 2:Live2D 连续渲染循环](#3-阶段-2live2d-连续渲染循环) 4. [阶段 3:QWebChannel 调试探针](#4-阶段-3qwebchannel-调试探针) 5. [阶段 4:隔离崩溃触发点](#5-阶段-4隔离崩溃触发点) 6. [阶段 5:定位根本原因](#6-阶段-5定位根本原因) 7. [根因:一行代码的 bug](#7-根因一行代码的-bug) 8. [修复方案](#8-修复方案) 9. [UI 改进](#9-ui-改进) 10. [最终状态](#10-最终状态) 11. [经验教训](#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. 阶段 1:GPU 命令缓冲区崩溃 ### 假设 Chromium GPU 进程的命令缓冲区(command buffer)在 Linux sandbox 下损坏,导致 `WaitForGetOffsetInRange` 检测到无效状态。 ### 尝试的修复 在 `main.py` 中添加 Chromium flags: ```python 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` 方法: ```python 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. 修复方案 ### 核心修复(一行) ```python # ✅ 正确:比较当前执行线程 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` 同时发送: 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-radius`、`display: inline-block` 等关键属性 - 对于需要丰富样式的 UI,QLabel + Qt stylesheet 是更好的选择