数据截至 (上游 commit efde31963f6f)
第 3 章 · 多端同步协议:快照握手与同步输出
本章讲「多端接力」在线协议层面的实现:客户端和服务端之间跑哪几种消息、新设备连上时如何无重影地拿到当前画面、以及 TUI 全屏重绘时两端如何配合不闪。
3.1 两条 WebSocket:字节的归字节,状态的归状态
一个客户端同时挂两条连接,职责严格分开:
| 通道 | 路由 | 传什么 | 处理器 |
|---|---|---|---|
| 终端通道 | /ws?paneId=… | 单个 pane 的输出字节、输入、resize、快照握手 | ws_handler(src/ws/terminal.rs:26) |
| 同步通道 | /ws/sync | tab 列表/布局/工作区/通知等全局状态 | sync_handler(src/ws/sync.rs:28) |
为什么这么分: 字节流是高频、按 pane 隔离的;布局/通知是低频、全局一份的。分流后,「在手机上切了 tab」这类状态变化一条 /ws/sync 就广播到所有设备,不用碰任何字节通道。
同步通道的服务端→客户端消息是 SyncMsg 枚举(src/session/types.rs:28-192),主要变体:
| 变体 | 含义 |
|---|---|
TabList / TabCreated / TabClosed / TabActivated / TabRenamed | tab 目录与增删改 |
LayoutUpdated | 某个 tab 的布局树变了(分屏、拖拽) |
CommandFinished | 某 pane 一条命令跑完(OSC 133,第 1 章) |
Bell / Notify / StateDelta / Snapshot / MarkReadResult | 通知/注意力系统 |
WorkspaceCreated 等 7 个 | 工作区增删改与激活 |
SyncHello | 连接建立时下发 client_id(供回声抑制) |
MissionControlToggled / SelectionChanged / McSnapshot | Mission Control 总览状态 |
Event / Suggestions / MonitorData / MonitorHistory 等 | 插件事件、历史建议、系统监控 |
客户端→服务端的上行是 SyncClientMsg(src/ws/types.rs:33-85):ActivateTab / CreateTab / CloseTab / UpdateLayout / Input 等。服务端处理后,用 broadcast_sync_others 广播给除发送者外的所有客户端(发送者已本地应用过),例如激活 tab 的处理在 src/ws/sync.rs:248-265。
新客户端连上 /ws/sync 时,服务端按固定顺序发一批「追赶」消息:SyncHello → TabList → Suggestions → MonitorHistory → WorkspaceList → McSnapshot(src/ws/sync.rs:104-165)。注意客户端是先注册进广播列表、再收 TabList 的——这样两者之间发生的任何变更都不会漏(src/ws/sync.rs:102-104 注释)。
3.2 重连握手:四步拿到无重影的画面
它要解决的小问题: 新设备(或刷新的页面)连上已有会话,需要当前画面。但「立刻发快照」有个坑:快照是按服务端记录的旧行列数编码的,客户端拿到后自己的布局可能还没收敛(fit),快照的绝对行寻址会钳到错误尺寸——刷新错位的根因(src/ws/types.rs:20-26 注释)。
思路:让客户端先报尺寸,服务端在临界区里「resize + 取快照」一起做。 完整握手:
客户端 服务端
│── WS connect(/ws?paneId) ────────►│
│◄────────── Reconnected{cols,rows} │ ① 仅告知旧尺寸,不发内容
│ (reset xterm, 收敛布局, fit 一次) │
│── SnapshotRequest{cols,rows} ────►│ ② 报上自己的最终尺寸
│ │ ③ atomic_resize_and_snapshot:
│ │ PTY+屏幕 resize 到该尺寸,
│ │ 同一临界区取滚动历史+快照
│◄─ ReplayBegin{cols,rows} │ ④
│◄─ [scrollback chunks] │
│◄─ [snapshot] │
│◄─ ReplayEnd │
│ (整批一次性写入 xterm) │
│◄─ 之后的实时 Output 照常 ──────────│
四步对应的真实代码:
- Reconnected:重连路径上,服务端先把会话状态标 Connected 并复核代际(
src/ws/terminal.rs:110-121),然后add_client并只发一个带旧尺寸的Reconnected(src/ws/terminal.rs:131-150)。 - SnapshotRequest:客户端布局稳定后上报尺寸,服务端路由到
atomic_resize_and_snapshot_for_client(src/ws/terminal.rs:252-259)。 - 原子 resize + 快照(
src/session/mod.rs:455-503):先resize_async改 PTY 和 VirtualScreen 尺寸,再同时拿着 clients 锁(挡住并发广播)和 screen 锁(挡住 PTY reader 喂字节)取滚动历史块(每块 200 行)和snapshot_for_replay(第 1 章)。 - ReplayBegin → chunks → ReplayEnd:按序直接塞进目标客户端的 mpsc 通道(
src/session/mod.rs:487-492)。
教学示例(# 示意,非源码):
# 客户端
def on_reconnected(old_size):
xterm.reset() # 清掉本地旧画面
fit_once() # 布局收敛,量出真实行列
send({"type": "snapshot_request", "cols": c, "rows": r})
def on_replay_end():
xterm.write(transaction_buffer.join()) # 一帧写完,无中间态