Skip to content

第 14 章:协作与 RPC——把编辑器状态变成可恢复的分布式系统

协作不是“把 WebSocket 接到 Buffer 上”。Zed 把连接状态、类型化消息、项目权限、实体订阅、 Buffer CRDT 和断线重同步分层,任何一层都可以独立失败和恢复。

1. 从单机对象图到分布式对象图

本地模式中,Workspace 直接持有 Project,Project 再持有 BufferStore、WorktreeStore、LspStore 等 Entity。进入共享项目后,对端并不会复制整个对象图,而是为需要共享的实体建立协议身份与订阅关系。

mermaid
flowchart LR
  UI["Workspace / Editor"] --> P1["本地 Project"]
  P1 --> BS1["BufferStore"]
  BS1 --> B1["Buffer Entity"]
  P1 --> C["client::Client"]
  C --> PEER["rpc::Peer"]
  PEER --> WS["WebSocket / MessageStream"]
  WS --> S["collab server"]
  S --> HOST["项目 Host Peer"]
  HOST --> P2["Host Project"]

Server 负责房间、成员、权限、路由与持久会话;项目 host 仍是文件系统、LSP、终端等资源的权威端; 各客户端 Buffer 通过 operation 合并文本,而不是让 server 拼字符串。

2. Client 是连接状态机,不只是 socket

crates/client/src/client.rsClient 持有 Arc<Peer>、HTTP/cloud client、凭据、telemetry、 handler set 与当前状态。Status 明确区分:

  • SignedOut、Authenticating、Authenticated;
  • Connecting、Connected;
  • ConnectionLost、Reconnecting、Reauthenticated;
  • UpgradeRequired、ConnectionError、ReconnectionError。

这让 UI 能准确显示“正在认证”“网络丢失但正在重连”“协议版本过旧”,也让业务代码不必从一个 Option<WebSocket> 猜测真实状态。

重连采用指数退避:源码快照中的初始间隔为 500ms、上限 30s,连接超时为 20s。常量入口: crates/client/src/client.rs:87-89

3. MessageStream:传输层边界

crates/rpc/src/message_stream.rs 把 WebSocket binary frame 解码为 protobuf Envelope,并单独处理 Ping/Pong。它不理解 Project 或 Buffer,只保证:

  1. frame 能转换为合法 envelope;
  2. 心跳及时响应;
  3. 流结束或解码失败能传递给 Peer;
  4. 写入串行化,避免多个 task 交错破坏 frame。

协议版本在 crates/rpc/src/rpc.rsPROTOCOL_VERSION 中集中声明;本快照值为 68。版本不兼容 在连接层失败,避免业务 handler 收到“看似可解码但语义已变化”的消息。

4. Peer:类型化 RPC 的核心

crates/rpc/src/peer.rsPeer 在 MessageStream 之上提供三类语义:

类型用途例子
notification单向事件,不等待返回UpdateBuffer、project event
request/responserequest id 关联一次结果OpenBuffer、JoinProject
stream一个请求持续产生多项结果搜索或长任务结果

Peer 内部维护递增 request id、pending response map、stream sender 和 handler set。reader task 解包后 按消息类型分发;writer task 是统一出口。请求完成、连接关闭或 task 取消时,pending waiter 都必须被 唤醒,不能永久悬挂。

text
Client::request<M>()
  → Peer::request_envelope
  → 分配 request_id,登记 oneshot sender
  → writer task 发送 Envelope
  → reader task 收到 response_to=request_id
  → 移除 pending entry,唤醒调用方

源码入口:crates/client/src/client.rs:1731-1792crates/rpc/src/peer.rs:405-620

5. HandlerSet 如何消除“大 switch”

每种 protobuf message 通过 trait 关联 payload 类型、response 类型和名字。模块启动时把 handler 注册到 HandlerSet;Peer 收到 envelope 后按 type id 找到 handler。这样 Project、Channel、LSP 等模块可以在 自己的边界内声明处理器,而不是向一个中央 dispatcher 持续追加分支。

代价是 handler 生命周期必须清晰:Entity 被释放或 project 离开后,订阅应一起移除,否则旧消息可能 写入已经无主的状态。

6. 加入项目时同步的不是一个快照

JoinProject response 至少需要建立:

  • project id、role 与 capability;
  • 当前 collaborators 与 host;
  • worktree 元数据;
  • 已共享 Buffer 的实体 id、version/operation state;
  • 后续消息的顺序边界。

响应携带的最后 message id 是一个同步栅栏:客户端先应用 join snapshot,再接收该边界之后的增量消息, 否则加入过程中发生的编辑可能落在快照与订阅之间。

7. Buffer 更新为何带 operation 而不是 text

共享 Buffer 发送 UpdateBuffer,payload 是可合并 operation、buffer id、版本/依赖信息与发送者信息。 接收端交给 Buffer CRDT 应用,最终文本由同一 operation 集合决定。

这解决了三个问题:

  1. 两端同时输入不需要锁文件;
  2. operation 重发可按身份去重;
  3. Anchor 能随合并后的文本继续定位。

original_sender_id 会跨 server/host 转发保留,使客户端能区分自己的回声和真正的远端编辑。

8. 为什么还需要 SynchronizeBuffers

增量 operation 流仍可能因断线、订阅时序或实体尚未建立而不完整。重连后,BufferStore 使用 SynchronizeBuffers 比较每个 Buffer 的已知状态,再补发缺失 operation 或完整初始化信息。

text
连接恢复
  → 重新确认 project membership
  → 枚举共享 buffer + 本地 version
  → SynchronizeBuffers
  → host 计算缺口
  → 补发 UpdateBuffer / file state
  → 恢复实时增量

处理入口:crates/project/src/buffer_store.rs:1182-1260。这是一条“状态校准”通道,不应与正常实时 消息混成一个隐含流程。

9. Server 是路由器与权限边界

crates/collab/src/rpc.rs 注册 share/join/leave project、buffer、LSP、terminal 等 RPC。每次转发前先 检查连接对应的用户、room/project membership 和 role,然后定位 host 或目标 peer。

重要边界是:

  • Server 决定谁能加入、谁可写、消息发给谁;
  • Host 执行需要本机资源的操作;
  • Buffer operation 在各副本应用并收敛;
  • Client UI 只展示其 capability 允许的动作。

只在按钮上隐藏“编辑”并不安全;权限必须在接收 RPC 的一侧再次检查。

10. Host authority 与 optimistic local edit

文本输入要求零网络等待,因此 Buffer edit 可先在本地应用,再广播 operation。但文件保存、LSP、Git、 terminal 等依赖 host 环境的动作由 host 权威执行并回传结果。

因此协作系统同时存在两种一致性:

  • 文本:多副本 CRDT 的最终收敛;
  • 外部资源:host 串行化/验证后的权威结果。

把所有动作都做成 optimistic 会制造文件系统与进程副作用冲突;把所有输入都等 host 会让编辑无法使用。

11. 断线期间什么可以继续

连接丢失后,本地 Buffer 仍可接受编辑并记录 operation;依赖 host 的操作则应失败、排队或显示不可用。 重连成功后先恢复身份和 project,再同步 Buffer,最后恢复普通请求。

一个健壮的功能必须回答:

  1. request 发送前断线怎么办;
  2. server 已执行但 response 丢失怎么办;
  3. 重连后能否安全重试;
  4. 本地 Entity id 是否仍映射到同一远端实体;
  5. UI 如何区分暂时失败和永久权限失败。

12. 一次协同输入的完整链路

mermaid
sequenceDiagram
  participant E as Editor A
  participant BA as Buffer A
  participant PA as Peer A
  participant S as Collab Server
  participant PB as Peer B
  participant BB as Buffer B
  participant EB as Editor B
  E->>BA: edit(range, text)
  BA->>BA: 生成并立即应用 Operation
  BA->>PA: UpdateBuffer(operation)
  PA->>S: protobuf Envelope
  S->>PB: 检查成员后转发
  PB->>BB: apply operation
  BB->>EB: BufferEvent / invalidate
  EB->>EB: 重新布局并绘制受影响区域

13. 可迁移经验

  1. 连接状态用显式状态机建模;
  2. transport、RPC、domain message、UI 分层;
  3. request 必须有取消、断线和重复执行语义;
  4. snapshot 与增量之间设置可证明的顺序栅栏;
  5. 实时通道之外保留重同步协议;
  6. 文本用可合并 operation,外部副作用保留权威端;
  7. 权限在 server/host 执行边界验证,而不只在 UI 验证。

独立源码学习笔记 · 文档采用 CC BY-SA 4.0