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>
352 lines
12 KiB
Markdown
352 lines
12 KiB
Markdown
# 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 是更好的选择
|