描述 (Description)
删除会话后,有一定概率该会话会"借尸还魂"重新出现在历史列表中。
After deleting a session from the history panel, the session sometimes reappears ("resurrects") in the history list, as if it was never deleted.
触发条件 (When it happens)
需要满足以下时序条件:
- 有一个会话 A,在标签页中打开并使用过
- 关闭会话 A 的标签页(调用
CloseTab)
- 从历史面板删除会话 A(右键 → 删除 → 确认,调用
DeleteSession)
- 创建一个新会话 B(调用
NewSession / EnsureBlankTab)
- 有一定概率,会话 A 重新出现在历史列表中
This race requires the tab close and the delete operation to happen in quick succession — the window is small but real, especially on Windows where os.Rename and file operations can be slower.
根因分析 (Root Cause)
问题代码位置
desktop/tabs.go:860-907 — CloseTab 方法
func (a *App) CloseTab(tabID string) error {
a.mu.Lock()
delete(a.tabs, tabID) // ① 从 a.tabs 移除标签页
...
a.saveTabsLocked()
a.mu.Unlock() // ② 释放锁!
// Tear down outside the lock.
if tab.Ctrl != nil {
tab.Ctrl.Cancel()
_ = tab.Ctrl.Snapshot() // ③ 保存会话到磁盘(锁外!)
tab.Ctrl.Close()
}
return nil
}
精确时间线 (Race Timeline)
| 时间 |
CloseTab 线程 |
DeleteSession 线程 |
| t0 |
加锁 a.mu.Lock() |
— |
| t1 |
delete(a.tabs, tabID) → Tab A 从 a.tabs 中移除 |
— |
| t2 |
a.mu.Unlock() 释放锁 |
— |
| t3 |
— |
加锁进入 DeleteSession(path) |
| t4 |
— |
openSessionPaths(dir)[sessionPath] 检查 → 找不到 Tab A(t1 已移除)→ ✅ 允许删除 |
| t5 |
— |
trashSessionArtifactsBeforeMove(...) 把 .jsonl 移到 .trash/ |
| t6 |
— |
runDestroyHandles(destroys) 清理完成 |
| t7 |
tab.Ctrl.Snapshot() 写入磁盘 |
— |
| t8 |
文件在原始路径被重新创建! 会话 A 复活 🎉 |
— |
| t9 |
用户刷新历史列表 → 看到会话 A 又出现了 😱 |
— |
关键问题
tab.Ctrl.Snapshot() 被延迟到了锁外面执行,创建了一个竞态窗口:
- 标签页已从
a.tabs 移除,所以 openSessionPaths 认为会话已关闭
DeleteSession 可以顺利把文件移入回收站
- 之后的
Snapshot() 又把完整会话内容写回原路径
- 历史的文件夹重新出现,相当于复活
为什么 Snapshot 在锁外面?
因为 Snapshot() 可能涉及磁盘 I/O,代码注释说是为了避免阻塞 UI。但这个优化创建了竞态条件——应该先 Snapshot 再删标签页,或者对已删除的路径做 fencing。
修复建议 (Proposed Fix)
方案 A(推荐):将 tab.Ctrl.Snapshot() 移到 delete(a.tabs, tabID) 之前,在锁内完成保存后再移除标签页:
// 先保存再删除
if tab.Ctrl != nil {
tab.Ctrl.Cancel()
_ = tab.Ctrl.Snapshot() // ← 移到锁内
}
a.mu.Lock()
delete(a.tabs, tabID)
...
a.saveTabsLocked()
a.mu.Unlock()
// 锁外只做 Close(不涉及写文件)
if tab.Ctrl != nil {
tab.Ctrl.Close()
}
方案 B:在 DeleteSession 中增加防重入检查,比如对已删除路径写一个墓碑标记文件,阻止后续的 Snapshot() 写入。
环境 (Environment)
- Reasonix v2 (Go rewrite, main-v2 branch)
- 桌面端 (Wails desktop app)
- Windows / macOS / Linux 均可复现(竞态条件,Windows 上概率更高)
相关代码文件
desktop/tabs.go — CloseTab 方法(第 860-907 行)
desktop/app.go — DeleteSession 方法(第 1232-1256 行)
desktop/app.go — openSessionPaths 方法(第 1282-1300 行)
internal/control/controller.go — Snapshot 方法(第 1904-1910 行)
补充
该 Bug 在 v2 (Go rewrite) 中引入。有 Issue #2126 描述了类似的 UI 问题(删除后界面未跳转),但与这个数据完整性问题不同。
描述 (Description)
删除会话后,有一定概率该会话会"借尸还魂"重新出现在历史列表中。
After deleting a session from the history panel, the session sometimes reappears ("resurrects") in the history list, as if it was never deleted.
触发条件 (When it happens)
需要满足以下时序条件:
CloseTab)DeleteSession)NewSession/EnsureBlankTab)This race requires the tab close and the delete operation to happen in quick succession — the window is small but real, especially on Windows where
os.Renameand file operations can be slower.根因分析 (Root Cause)
问题代码位置
desktop/tabs.go:860-907—CloseTab方法精确时间线 (Race Timeline)
CloseTab线程DeleteSession线程a.mu.Lock()delete(a.tabs, tabID)→ Tab A 从a.tabs中移除a.mu.Unlock()释放锁DeleteSession(path)openSessionPaths(dir)[sessionPath]检查 → 找不到 Tab A(t1 已移除)→ ✅ 允许删除trashSessionArtifactsBeforeMove(...)把.jsonl移到.trash/runDestroyHandles(destroys)清理完成tab.Ctrl.Snapshot()写入磁盘关键问题
tab.Ctrl.Snapshot()被延迟到了锁外面执行,创建了一个竞态窗口:a.tabs移除,所以openSessionPaths认为会话已关闭DeleteSession可以顺利把文件移入回收站Snapshot()又把完整会话内容写回原路径为什么
Snapshot在锁外面?因为
Snapshot()可能涉及磁盘 I/O,代码注释说是为了避免阻塞 UI。但这个优化创建了竞态条件——应该先Snapshot再删标签页,或者对已删除的路径做 fencing。修复建议 (Proposed Fix)
方案 A(推荐):将
tab.Ctrl.Snapshot()移到delete(a.tabs, tabID)之前,在锁内完成保存后再移除标签页:方案 B:在
DeleteSession中增加防重入检查,比如对已删除路径写一个墓碑标记文件,阻止后续的Snapshot()写入。环境 (Environment)
相关代码文件
desktop/tabs.go—CloseTab方法(第 860-907 行)desktop/app.go—DeleteSession方法(第 1232-1256 行)desktop/app.go—openSessionPaths方法(第 1282-1300 行)internal/control/controller.go—Snapshot方法(第 1904-1910 行)补充
该 Bug 在 v2 (Go rewrite) 中引入。有 Issue #2126 描述了类似的 UI 问题(删除后界面未跳转),但与这个数据完整性问题不同。