跳到主要内容

数据截至 (上游 commit efde31963f6f)

第 2 章 · 会话生命周期:从创建、收割到重启恢复

本章讲一个终端会话在 dinotty 里的「一生」:怎么出生(Session 结构)、客户端断开时为什么不死、什么时候会被回收(reap)、布局怎么落盘(session.json)、服务端重启后又怎么活过来(restore)。


2.1 Session:一个会话的全家福

Session(src/session/mod.rs:103-155)把「一个活着的终端」需要的所有东西捆在一起:

字段干什么
backend传输后端:Local(本地 PTY)/ Ssh / Exited,枚举见 src/session/backend.rs:22-33
screen服务端 VirtualScreen(第 1 章)
clients挂在这个会话上的客户端端点列表
status + is_connectedConnected / Detached 状态,外加一个给收割器用的原子镜像
input_tx / output_tx输入、原始输出两条通道
syncDEC 2026 同步输出缓冲(第 3 章)
ssh_paramsSSH 会话的连接参数与句柄

Connected / Detached 的语义: 客户端全断开后,会话标记为 Detached,但进程和屏幕都活着——这就是「断网不丢」(src/ws/terminal.rs:278-281)。is_connected 是一个 AtomicBool 镜像,收割器在锁外就能读它(src/session/mod.rs:114-115),设置时刻意让镜像先于状态可见、后于状态清除(set_status,src/session/mod.rs:161-173),防止收割竞态。


2.2 SessionManager:注册表与一把大锁

SessionManager(src/session/manager.rs:86-114)是全局注册表,核心字段:

  • sessions: DashMap<String, Arc<Session>>——pane_id 到会话。
  • tab_layouts / tab_order / active_pane_id——tab 布局树与激活态。
  • sync_clients——挂在 /ws/sync 上的多端同步客户端(第 3 章)。
  • lifecycle: Mutex<()>——守卫成员关系和所有复合突变的一把锁;注释里写死了锁序纪律:它绝不与任何 per-session 锁同持(src/session/manager.rs:94-101)。

为什么到处见 Arc::ptr_eq: 同一个 pane_id 可能先后对应两代会话(旧会话刚死、新会话同名重建)。管理器操作几乎都要确认「我手里这个 Arc 还是注册表里那一代」,例如 is_current_session(src/session/manager.rs:467-470)。这把「代际」检查贯穿创建、重连、收割、关闭所有路径。


2.3 收割:30 秒一拍,1 分钟宽限

它要解决的小问题: 会话可能变成「孤儿」——客户端全断了、tab 布局里也没引用它(比如布局编辑竞态)。孤儿会话挂着 PTY 白占资源,得有垃圾回收。

机制: start_cleanup_task 每 30 秒扫一轮(SESSION_REAP_TICK,src/session/manager.rs:20),逻辑分两步:

  1. scan_unowned_sessions 找出「不在任何 tab 布局的终端叶子里」的会话,首次发现时记入 unowned_since(reconcile_unowned_since,src/session/manager.rs:53-78)。
  2. 只有同时满足三个条件才收割:无主时长 ≥ 1 分钟宽限(SESSION_UNOWNED_GRACE,src/session/manager.rs:21)、当前未连接(!session.is_connected())、收割瞬间复核仍无引用(try_reap_session,src/session/manager.rs:1030-1062)。
每 30s 一拍:
所有会话 ──► 被 tab 引用? ──是──► 跳过(并清除无主计时)
│否

记入 unowned_since(首次)


无主 ≥ 60s 且未连接? ──是──► close_session(reason=Reaped)

宽限的意义: 客户端断开 → 布局更新到达之间存在正常的时间窗;没有这 1 分钟,刷新页面都可能把会话误杀。


2.4 关闭:单一收口 close_session

所有关闭路径——用户关 tab、PTY 自然退出、收割器、服务关停——最终都汇入 close_session(src/session/manager.rs:817-828)。它在 lifecycle 锁内完成「成员移除」这唯一一次认领,抢到的调用方才执行后续清理(execute_close_plan,src/session/manager.rs:872-917):

  1. 按原因杀后端并确认进程真的死了(kill_child_and_confirm,SIGTERM 后 50ms 再 SIGKILL,250ms 内轮询 try_wait,src/session/mod.rs:197-242)。
  2. 确认死掉才从 PID ledger 删条目;不确定就保留,留给下次启动时的恢复逻辑判断(src/session/manager.rs:888-896)。
  3. 通知客户端 SessionExit、广播 TabClosed/LayoutUpdated、发事件总线。

CloseReason 区分四种死因:Explicit(用户关闭)、NaturalExit(进程自己退出)、Reaped(收割)、Shutdown,见 src/session/types.rs:7-13


2.5 落盘:session.json 的原子写

它要解决的小问题: 想让「重启服务后布局还在」,就得持久化;写文件写到一半进程被杀,不能留下半个 JSON。

机制: SessionSnapshotStore(src/session/snapshot.rs:69-158)把当前 tab 布局写成 <config_dir>/session.json。原子性靠经典手法:先写同目录临时文件,再 persist(POSIX 上是 rename)整体换名(atomic_write_json,src/session/snapshot.rs:144-158)。

读的一侧同样防御:文件不存在返回空快照;JSON 损坏则删掉坏文件、返回空——恢复是 best-effort,绝不阻塞启动(load,src/session/snapshot.rs:92-112)。

快照里有什么: SessionSnapshot 记录版本号、保存时间、全局激活 pane、tab 顺序和每个 tab 的布局树(src/session/snapshot.rs:26-64)。写之前还会把每个终端叶子的当前工作目录填进布局(enrich_leaf_cwd,src/session/snapshot.rs:202-239)——这样恢复出来的 shell 落在你原来的目录里。

什么时候写: 两处触发。

  • 防抖写:任何布局变更经 schedule_snapshot 发个信号,后台任务等 1 秒「安静期」后统一落盘(start_snapshot_task,src/session/manager.rs:205-239)。
  • 关停冲写:优雅停机时不等防抖,直接同步写一次,保住最新状态(flush_snapshot_sync,src/session/manager.rs:243-251;调用点在 src/main.rs:600-602)。

2.6 恢复:两阶段提交

它要解决的小问题: 恢复 = 按快照重建 PTY + 布局。但如果 PTY 只拉起来一半就把布局提交了,tab_list 会看到「引用了不存在会话」的叶子并把它当腐坏数据清掉。

思路:先全部拉活,再提交布局。 restore_single_tab(src/session/restore.rs:51-89)分两步:

  1. Phase 1:遍历布局树,收集所有终端叶子(collect_terminal_leaves_with_cwd,src/session/restore.rs:109-137),逐个 spawn_pane任何一个失败,把本 tab 已拉起的会话全部回滚,整个 tab 放弃恢复(src/session/restore.rs:67-77)。
  2. Phase 2:全部成功才 insert_tab 提交布局(src/session/restore.rs:81-87)。
restore_single_tab(tab)

├─ Phase 1: for each terminal leaf ──► spawn PTY
│ │ 任一失败
│ ▼
│ kill_and_remove(已拉起的) ──► 放弃这个 tab

└─ Phase 2: insert_tab(layout) ← 只有全部成功才走到

恢复的边界(诚实清单):

  • 只恢复本地 tab:带 connection_id 的 SSH tab 被过滤掉,用户手动重连(src/session/restore.rs:28-29)。
  • 最多 20 个 tab:MAX_RESTORE_TABS(src/session/restore.rs:21)。
  • 恢复的是新 shell,不是旧进程:快照里只有布局和 cwd,原来 shell 里跑的 Claude Code 进程本身回不来——「会话不死」管的是断网,不管服务端重启。
  • 开关可控:由设置项 restore_session_on_startup 决定,启动时若开启才加载快照(src/main.rs:234-243)。
  • 恢复发生在收割任务启动之前,避免收割器把恢复中的会话当孤儿(src/main.rs:231-233 注释)。

2.7 本章小结

  • Session 是「后端 + 屏幕 + 客户端」的捆绑;客户端走光只是 Detached,进程与屏幕存活。
  • 收割器 30 秒一拍:无主 + 超时 60s + 未连接,三者齐备才回收,且回收瞬间还要复核代际与引用。
  • 关闭走单一收口 close_session,进程死没死决定 PID ledger 删不删。
  • 落盘用「临时文件 + rename」原子写 session.json,1 秒防抖 + 关停冲写两个触发点。
  • 恢复是两阶段提交:叶子全部拉活才提交布局;SSH tab 跳过、上限 20 个、旧进程不回来。

2.8 代码地图

主题文件路径符号名
会话结构src/session/mod.rsSessionSessionStatusset_status
后端枚举src/session/backend.rsSessionBackendSshCmd
注册表src/session/manager.rsSessionManagerlifecycle
收割src/session/manager.rsstart_cleanup_tasktry_reap_sessionreconcile_unowned_since
关闭收口src/session/manager.rsclose_sessionexecute_close_plan
进程终止确认src/session/mod.rskill_child_and_confirmnatural_exit_confirmed
快照结构/原子写src/session/snapshot.rsSessionSnapshotSessionSnapshotStoreatomic_write_json
cwd 回填src/session/snapshot.rsenrich_leaf_cwd
两阶段恢复src/session/restore.rsrestore_sessionrestore_single_tabspawn_pane
启动时序src/main.rsrestore(234-243 行)、flush_snapshot_sync 调用(600-602 行)