第 14 章:协作与 RPC——把编辑器状态变成可恢复的分布式系统
协作不是“把 WebSocket 接到 Buffer 上”。Zed 把连接状态、类型化消息、项目权限、实体订阅、 Buffer CRDT 和断线重同步分层,任何一层都可以独立失败和恢复。
1. 从单机对象图到分布式对象图
本地模式中,Workspace 直接持有 Project,Project 再持有 BufferStore、WorktreeStore、LspStore 等 Entity。进入共享项目后,对端并不会复制整个对象图,而是为需要共享的实体建立协议身份与订阅关系。
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.rs 的 Client 持有 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,只保证:
- frame 能转换为合法 envelope;
- 心跳及时响应;
- 流结束或解码失败能传递给 Peer;
- 写入串行化,避免多个 task 交错破坏 frame。
协议版本在 crates/rpc/src/rpc.rs 的 PROTOCOL_VERSION 中集中声明;本快照值为 68。版本不兼容 在连接层失败,避免业务 handler 收到“看似可解码但语义已变化”的消息。
4. Peer:类型化 RPC 的核心
crates/rpc/src/peer.rs 的 Peer 在 MessageStream 之上提供三类语义:
| 类型 | 用途 | 例子 |
|---|---|---|
| notification | 单向事件,不等待返回 | UpdateBuffer、project event |
| request/response | request id 关联一次结果 | OpenBuffer、JoinProject |
| stream | 一个请求持续产生多项结果 | 搜索或长任务结果 |
Peer 内部维护递增 request id、pending response map、stream sender 和 handler set。reader task 解包后 按消息类型分发;writer task 是统一出口。请求完成、连接关闭或 task 取消时,pending waiter 都必须被 唤醒,不能永久悬挂。
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-1792、crates/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 集合决定。
这解决了三个问题:
- 两端同时输入不需要锁文件;
- operation 重发可按身份去重;
- Anchor 能随合并后的文本继续定位。
original_sender_id 会跨 server/host 转发保留,使客户端能区分自己的回声和真正的远端编辑。
8. 为什么还需要 SynchronizeBuffers
增量 operation 流仍可能因断线、订阅时序或实体尚未建立而不完整。重连后,BufferStore 使用 SynchronizeBuffers 比较每个 Buffer 的已知状态,再补发缺失 operation 或完整初始化信息。
连接恢复
→ 重新确认 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,最后恢复普通请求。
一个健壮的功能必须回答:
- request 发送前断线怎么办;
- server 已执行但 response 丢失怎么办;
- 重连后能否安全重试;
- 本地 Entity id 是否仍映射到同一远端实体;
- UI 如何区分暂时失败和永久权限失败。
12. 一次协同输入的完整链路
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. 可迁移经验
- 连接状态用显式状态机建模;
- transport、RPC、domain message、UI 分层;
- request 必须有取消、断线和重复执行语义;
- snapshot 与增量之间设置可证明的顺序栅栏;
- 实时通道之外保留重同步协议;
- 文本用可合并 operation,外部副作用保留权威端;
- 权限在 server/host 执行边界验证,而不只在 UI 验证。