跳到主要内容

数据截至 (上游 commit efde31963f6f)

第 3 章 · 多端同步协议:快照握手与同步输出

本章讲「多端接力」在线协议层面的实现:客户端和服务端之间跑哪几种消息、新设备连上时如何无重影地拿到当前画面、以及 TUI 全屏重绘时两端如何配合不闪。


3.1 两条 WebSocket:字节的归字节,状态的归状态

一个客户端同时挂两条连接,职责严格分开:

通道路由传什么处理器
终端通道/ws?paneId=…单个 pane 的输出字节、输入、resize、快照握手ws_handler(src/ws/terminal.rs:26)
同步通道/ws/synctab 列表/布局/工作区/通知等全局状态sync_handler(src/ws/sync.rs:28)

为什么这么分: 字节流是高频、按 pane 隔离的;布局/通知是低频、全局一份的。分流后,「在手机上切了 tab」这类状态变化一条 /ws/sync 就广播到所有设备,不用碰任何字节通道。

同步通道的服务端→客户端消息是 SyncMsg 枚举(src/session/types.rs:28-192),主要变体:

变体含义
TabList / TabCreated / TabClosed / TabActivated / TabRenamedtab 目录与增删改
LayoutUpdated某个 tab 的布局树变了(分屏、拖拽)
CommandFinished某 pane 一条命令跑完(OSC 133,第 1 章)
Bell / Notify / StateDelta / Snapshot / MarkReadResult通知/注意力系统
WorkspaceCreated 等 7 个工作区增删改与激活
SyncHello连接建立时下发 client_id(供回声抑制)
MissionControlToggled / SelectionChanged / McSnapshotMission 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 照常 ──────────│

四步对应的真实代码:

  1. Reconnected:重连路径上,服务端先把会话状态标 Connected 并复核代际(src/ws/terminal.rs:110-121),然后 add_client 并只发一个带旧尺寸的 Reconnected(src/ws/terminal.rs:131-150)。
  2. SnapshotRequest:客户端布局稳定后上报尺寸,服务端路由到 atomic_resize_and_snapshot_for_client(src/ws/terminal.rs:252-259)。
  3. 原子 resize + 快照(src/session/mod.rs:455-503):先 resize_async 改 PTY 和 VirtualScreen 尺寸,再同时拿着 clients 锁(挡住并发广播)和 screen 锁(挡住 PTY reader 喂字节)取滚动历史块(每块 200 行)和 snapshot_for_replay(第 1 章)。
  4. 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()) # 一帧写完,无中间态

3.3 snapshot_pending:握手期间的防重绘闸门

问题: 握手第 ③④ 步之间,PTY 可能还在持续输出。这些字节的「效果」已经包含在即将发出的快照里;如果同时又把它们当实时 Output 发给这个客户端,同一段内容就画了两遍。

服务端一侧: 每个客户端端点带一个 snapshot_pending: AtomicBool(ClientEndpoint,src/session/mod.rs:88-98)。add_client 一创建就置 true(src/session/mod.rs:633),send_chunk_to_clients 对 pending 的客户端跳过实时 Output(src/session/mod.rs:650-655);直到 ReplayEnd 入队后才清掉(src/session/mod.rs:498-500)。由于 mpsc 是 FIFO,之后到达的实时 Output 自然排在 ReplayEnd 后面,线序严格为:快照 → 快照之后的输出。

客户端一侧: 前端也有一个 _snapshotPending(frontend/src/composables/useTerminal.ts:178):收到 Reconnected 时置位(frontend/src/composables/useTerminal.ts:910),并抑制本地 resize 上报——服务端的原子操作包揽了这个窗口期的 PTY 尺寸,客户端再报 resize 会引入竞态(_sendResize,frontend/src/composables/useTerminal.ts:1370),直到 ReplayEnd 落地才解除(frontend/src/composables/useTerminal.ts:1157)。


3.4 客户端事务缓冲:ReplayBegin/End 之间攒成一批

ReplayBegin/ReplayEnd 不只是语义标记。前端在两者之间把收到的 Output 全攒进一个事务缓冲,ReplayEnd 时合并成一次 xterm.write(_handleReplayBegin / _handleReplayEnd / _flushTransaction,frontend/src/composables/useTerminal.ts:1122-1194)。

这样 fit 阶梯、ResizeObserver 这类异步事件就没有机会在「快照写了一半」时插进来打断——错位正是这么来的(注释自述,frontend/src/composables/useTerminal.ts:1096-1101)。缓冲用深度计数支持嵌套,超过 8MB 会提前冲刷兜底(MAX_TRANSACTION_BYTES,frontend/src/composables/useTerminal.ts:167)。


3.5 DEC mode 2026:同步输出的整帧投递

它要解决的小问题: Claude Code 这类 TUI 一帧重绘会产生几十上百个小输出块。若逐块发给客户端,xterm 每块都触发一次重绘——用户看到「画了一半」的中间帧。应用为此会发 DEC mode 2026 把一帧包起来(CSI ? 2026 hCSI ? 2026 l)。

服务端怎么配合(Session 层):

  1. vt_screen 解析出 2026 开/关事件(第 1 章 1.7),PTY reader 调 set_sync_mode(true/false)(src/pty.rs:484-492)。
  2. 开启时先向所有客户端发 SyncBegin;之后 broadcast 不再逐块发送,而是攒进 SyncState.buffer(src/session/mod.rs:665-679),上限 256KB(SYNC_BUFFER_LIMIT,src/session/mod.rs:39)。
  3. 关闭时把攒下的字节按 64KB 一块、沿 UTF-8 字符边界切开冲刷(flush_sync_buffer_locked,src/session/mod.rs:739-756),再发 SyncEnd。客户端同样用事务缓冲(3.4)把这一段攒成一次写入。

两道保险,都针对「应用开了 2026 却一直不发关闭」的卡死场景:

  • resize 打破同步:窗口尺寸变化时强制 set_sync_mode(false),先把旧尺寸的缓冲冲掉再 resize(ghostty 同款做法,apply_and_broadcast_resize,src/session/mod.rs:541-555)。
  • 静默看门狗:broadcast_task 每 100ms 检查一次,同步模式超过 500ms 没有新输出就强制冲刷——否则 PTY 一旦在一帧中间静默(比如 Claude Code 等 API 响应),客户端画面会冻住(src/pty.rs:81src/pty.rs:174-191)。

3.6 resize 的其余两件小事

  • 防抖:客户端拖窗口会产生一串 resize,resize_debounced 用 watch channel 只保留最新值,25ms 安静期后统一应用(src/session/mod.rs:536-539)。
  • 广播给别的端:apply_and_broadcast_resize 应用后,把 Resize 事件发给除发起者外的同会话客户端,各端各自 fit(src/session/mod.rs:568-574)。

3.7 一个会话多客户端时,输入归谁

多个设备可以同时看同一个 pane(输出广播给所有端),但输入通道只有一条:replace_input_channel 每次接入新连接就换发新 channel、丢弃旧的——最后一个连上的设备拿到「键盘」(src/session/mod.rs:619-625,重连路径调用点在 src/ws/terminal.rs:184)。

另外 /ws/sync 上还有一条兜底输入路:SyncClientMsg::Input 会写入当前激活 pane——给没有自己终端通道的硬件键盘客户端用;且 Mission Control 总览打开时输入被直接丢弃,防止方向键漏进 PTY(src/ws/sync.rs:380-402)。


3.8 本章小结

  • 两条 WS 分工:/ws 走 pane 字节,/ws/sync 走全局状态;SyncMsg 是状态广播的统一信封。
  • 重连四步握手:Reconnected → SnapshotRequest → 原子 resize+快照 → ReplayBegin/chunks/ReplayEnd;snapshot_pending 在两端各自防止重绘。
  • DEC 2026 同步输出:服务端攒帧 + 客户端事务缓冲,配 resize 打断与 500ms 看门狗两道保险。
  • 多客户端共享输出、输入归最后一连;布局类变更走 /ws/sync 广播给「其他所有端」。

3.9 代码地图

主题文件路径符号名
同步消息枚举src/session/types.rsSyncMsgTabInfo
上行消息src/ws/types.rsSyncClientMsgClientMsgServerMsg
同步通道src/ws/sync.rssync_handlerhandle_sync_socket
终端通道重连src/ws/terminal.rshandle_socket(105-282 行)
原子快照握手src/session/mod.rsatomic_resize_and_snapshot_for_client
防重绘闸门src/session/mod.rsClientEndpoint::snapshot_pendingsend_chunk_to_clients
2026 缓冲src/session/mod.rsset_sync_modeflush_sync_buffer_lockedSyncState
看门狗src/pty.rsbroadcast_task(72-199 行)
客户端事务缓冲frontend/src/composables/useTerminal.ts_handleReplayBegin_handleReplayEnd_flushTransaction_snapshotPending