Skip to content

[Bug] Session resurrects after deletion — race condition in CloseTab #4384

@Lumoa-dev

Description

@Lumoa-dev

描述 (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)

需要满足以下时序条件:

  1. 有一个会话 A,在标签页中打开并使用过
  2. 关闭会话 A 的标签页(调用 CloseTab
  3. 从历史面板删除会话 A(右键 → 删除 → 确认,调用 DeleteSession
  4. 创建一个新会话 B(调用 NewSession / EnsureBlankTab
  5. 有一定概率,会话 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-907CloseTab 方法

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() 被延迟到了锁外面执行,创建了一个竞态窗口:

  1. 标签页已从 a.tabs 移除,所以 openSessionPaths 认为会话已关闭
  2. DeleteSession 可以顺利把文件移入回收站
  3. 之后的 Snapshot() 又把完整会话内容写回原路径
  4. 历史的文件夹重新出现,相当于复活

为什么 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.goCloseTab 方法(第 860-907 行)
  • desktop/app.goDeleteSession 方法(第 1232-1256 行)
  • desktop/app.goopenSessionPaths 方法(第 1282-1300 行)
  • internal/control/controller.goSnapshot 方法(第 1904-1910 行)

补充

该 Bug 在 v2 (Go rewrite) 中引入。有 Issue #2126 描述了类似的 UI 问题(删除后界面未跳转),但与这个数据完整性问题不同。

Metadata

Metadata

Assignees

No one assigned

    Labels

    data-lossData loss (sessions, config, history)desktopWails desktop app (desktop/**)

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions