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

352 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# EzVibe Segfault 完整排查日志
**日期:** 2026-05-23
**作者:** Claude Code + e2hang
**状态:** ✅ 已完全修复
---
## 目录
1. [问题概述](#1-问题概述)
2. [阶段 1GPU 命令缓冲区崩溃](#2-阶段-1gpu-命令缓冲区崩溃)
3. [阶段 2Live2D 连续渲染循环](#3-阶段-2live2d-连续渲染循环)
4. [阶段 3QWebChannel 调试探针](#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. 阶段 1GPU 命令缓冲区崩溃
### 假设
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. 阶段 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 完全替换为 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 改进
### 聊天气泡
- 从 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-radius``display: inline-block` 等关键属性
- 对于需要丰富样式的 UIQLabel + Qt stylesheet 是更好的选择