数据截至 (上游 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_connected | Connected / Detached 状态,外加一个给收割器用的原子镜像 |
input_tx / output_tx | 输入、原始输出两条通道 |
sync | DEC 2026 同步输出缓冲(第 3 章) |
ssh_params 等 | SSH 会话的连接参数与句柄 |
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),逻辑分两步:
scan_unowned_sessions找出「不在任何 tab 布局的终端叶子里」的会话,首次发现时记入unowned_since(reconcile_unowned_since,src/session/manager.rs:53-78)。- 只有同时满足三个条件才收割:无主时长 ≥ 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):
- 按原因杀后端并确认进程真的死了(
kill_child_and_confirm,SIGTERM 后 50ms 再 SIGKILL,250ms 内轮询try_wait,src/session/mod.rs:197-242)。 - 确认死掉才从 PID ledger 删条目;不确定就保留,留给下次启动时的恢复逻辑判断(
src/session/manager.rs:888-896)。 - 通知客户端 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)分两步:
- Phase 1:遍历布局树,收集所有终端叶子(
collect_terminal_leaves_with_cwd,src/session/restore.rs:109-137),逐个spawn_pane。任何一个失败,把本 tab 已拉起的会话全部回滚,整个 tab 放弃恢复(src/session/restore.rs:67-77)。 - 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.rs | Session、SessionStatus、set_status |
| 后端枚举 | src/session/backend.rs | SessionBackend、SshCmd |
| 注册表 | src/session/manager.rs | SessionManager、lifecycle |
| 收割 | src/session/manager.rs | start_cleanup_task、try_reap_session、reconcile_unowned_since |
| 关闭收口 | src/session/manager.rs | close_session、execute_close_plan |
| 进程终止确认 | src/session/mod.rs | kill_child_and_confirm、natural_exit_confirmed |
| 快照结构/原子写 | src/session/snapshot.rs | SessionSnapshot、SessionSnapshotStore、atomic_write_json |
| cwd 回填 | src/session/snapshot.rs | enrich_leaf_cwd |
| 两阶段恢复 | src/session/restore.rs | restore_session、restore_single_tab、spawn_pane |
| 启动时序 | src/main.rs | restore(234-243 行)、flush_snapshot_sync 调用(600-602 行) |