源码篇第10章 黑板与便签——pi-server+pi-client

本章导读: 源码路线第 ⑨ 站(远程层),同时看
pi-server和pi-client,读server/server.ts、server/session-router.ts和client/connection.ts、client/client.ts。这一站要直面断线后最难缠的问题:那条命令到底执行了没有?重连拿到的状态能信多少?
字节已经能变成协议消息了。但小A把 client 和 server 连起来的时候,又冒出第二个问题:“我断线重连之后,怎么知道那条命令到底执行了没有?重连后拿到的状态,我能信多少?”
“先别急着信。”老Z说,“连接建立后要回答三件事:客户端如何确认版本和服务器身份、何时相信快照、断线后又能保证什么。我们跟读 client/server 这一层;源码未实现的自动重连,这一章不承诺。”
本章源码证据均来自上游 pi 标签 v1.0.0(提交 a13d35a7,packages/server/src/、packages/client/src/,包版本 1.0.0)。上一章说过,v1.0.0 的协议只剩通用 RPC 信封;业务内容是 @earendil-works/chord 定义的服务调用(service call),server 只负责把调用路由到宿主提供的服务。服务本身怎样实现会话、模型和工具,不在本章范围。
握手先于请求
小A:“那我连上就发请求?”
“不行。”老Z说,“packages/server/src/connection.ts 定义了 ConnectionStage = "awaitingHello" | "handshaking" | "ready" | "closing" | "closed"。连接刚建立是 awaitingHello,第一条消息如果不是 hello,dispatchMessage 直接 failProtocol(invalid_request, "The first client message must be hello")。”
“收到 hello 后切到 handshaking,finishHandshake 按顺序做三件事:先 isSupportedProtocolVersion(hello.version),不支持就以 version 失败;再调用宿主的 serverServices.attachClient(...),给这条连接挂上服务器级服务,同时交给它 attachSession、detachSession、prepareSessionRemoval 三个能力;最后发 server hello,内容只有 version 和 serverId。发送成功才把 stage 切到 ready、清掉握手超时。”
“server hello 里没有快照?”小A问。
“v1.0.0 没有。”老Z说,“状态全部通过服务订阅拿,后面讲。还有一个细节:handshaking 期间如果又来一条消息,dispatchMessage 不会丢弃它,而是挂到 handshake.then(...) 上,等握手成功且 stage 已是 ready 才处理。握手失败的话,那条排队的消息永远不会被处理,连接也会被关掉。”
小A:“握手总不能一直挂着吧?”
“accept 里就埋了超时。”老Z回到 server.ts,“连接建立的同时 setTimeout 一个握手超时,默认 5 秒(DEFAULT_HANDSHAKE_TIMEOUT_MS),触发时以 invalid_request 和 ‘Handshake timeout’ 失败。所以‘收到 hello 但版本错’和‘一直不发 hello’是两种不同的死法:前者立刻报 version,后者拖到超时才报 invalid_request。failProtocol 会把 stage 置成 closing,尝试把一条 hello_error 作为最后一段数据随 close(finalFrame) 发出去,编码失败也只记错误,不阻断关闭。”
accept ──> awaitingHello
│ 非 hello 首帧 ──failProtocol(invalid_request)
│ 超时未 hello ──failProtocol(invalid_request)
v
收到 hello ──> handshaking ──版本不支持──> failProtocol(version)
│ ──serverServices.attachClient──> 异常 ──toProtocolError ──failProtocol
v
发送 server hello(version, serverId)
│ 期间 connection 关闭 ──返回,不置 ready
v
ready(清超时)
│ request ──handleRequest
│ cancel ──handleCancel
│ 再来 hello ──failProtocol("hello may only be sent as the first message")“token 认证呢?”小A问,“旧资料说 hello 里带 token。”
“v1.0.0 的 ClientHello 只有 type 和 version。”老Z说,“listener.ts 的注释写的是:listener 在完成传输层需要的认证之后,才把连接交给 server。认证从协议里挪到了传输层;Unix socket listener 的具体做法本章不展开。”
客户端这边,packages/client/src/connection.ts 的 Connection 维护三态生命周期:disconnected/connecting/connected,外加一个自增的 id。connect() 时 ++#sequence 得到新 id,#handleData、onClose、onError 都先判断 id 是否还是当前这一次,旧连接的迟到事件不会污染新连接。transport 建好后由 client 先发 hello;收到服务端数据时还没发出 hello,直接失败。
// packages/client/src/connection.ts,#handleMessage 的握手分支(v1.0.0,节选)
if (message.type === "hello_error") {
this.#failAndClose(new ServerError(message.error));
return;
}
if (message.type !== "hello") {
this.#failAndClose(new ProtocolValidationError("Expected server hello as first message"));
return;
}
if (message.serverId !== this.#options.serverId) {
this.#failAndClose(new ProtocolValidationError(`Connected server ... does not match ...`));
return;
}“注意第三个检查。”老Z说,“client 构造时就要给出期望的 serverId,server hello 里的 serverId 对不上就断开。物理地址可能指向别的服务器,这一步确认的是逻辑身份。”
要点:版本不匹配不执行请求
版本不匹配、首帧不是 hello、握手超时,都以
hello_error结束连接,不会进入任何服务调用。
一次请求怎样被路由
小A:“ready 之后,请求怎么找到会话?”
“看 RequestEnvelope:id、target、call。”老Z打开 handleRequest,“处理顺序是这样的。”
handleRequest(envelope)
→ id 已在 activeRequests 中 ──> response ok:false invalid_request
→ parseServiceCall(call) 失败 ──> response ok:false invalid_request
→ 登记 AbortController 到 activeRequests
→ target.serverId 不是本机 ──> WrongServerError(wrong_server)
→ target 带 sessionId ──> SessionRouter.executeServiceCall
target 不带 sessionId ──> 本连接的 serverServices.invokeService
→ 成功:response ok:true(可带 result)
→ 失败:response ok:false;已被 cancel 则 code 为 cancelled
→ finally:从 activeRequests 移除“cancel 信封按 id 和 target 找到在途请求,调用它的 controller.abort();目标不一致就忽略。错误码经 toProtocolError 统一:ServerError 和 chord 的 RemoteServiceError 原样带出 code;协议校验错误归为 invalid_request;其他异常只报 internal_error 和固定文本 ‘Internal server error’,细节只进本地 onError,不发给对端。”
客户端的 Client.#request 是镜像:请求 ID 是 request-${序号},未完成的请求存在 #pendingRequests 里;传入的 AbortSignal 触发时,本地 Promise 立即 reject,同时发一个 cancel 信封。收到 response 时按 ID 取出;取不到就 #connection.fail(...),未知 ID 的 response 被当成协议错误,把连接废掉,而不是静默忽略。
附着:一条连接最多指向一个会话
小A:“那 target 里的 sessionId 和 attachmentId 从哪来?”
“从附着来。”老Z打开 session-router.ts,“宿主的服务器级服务调用 presentation.attachSession(sessionId),最后进到 SessionRouter.attachClientNow。它先用 acquire 拿到托管会话:已托管就复用,正在打开就复用同一个 Promise,否则调宿主的 resolveSession 和 openSession。并发的附着请求不会把同一个会话打开两次。”
// packages/server/src/session-router.ts,attachClientNow(v1.0.0,节选)
const current = this.attachmentsByClient.get(client);
if (current?.session.id === sessionId) return;
const hosted = await this.acquire(sessionId, context);
if (current) await this.releaseAttachment(current, context, false);
const attachment = { id: randomUUID(), client, session: hosted, operations: new Set() };
// ……hosted.handle.attachClient(context) 取得 lease……
this.attachmentsByClient.set(client, attachment);
await this.options.publishAttachment(client, { serverId, sessionId, attachmentId: attachment.id }, context);“三件事可以确认。第一,attachmentsByClient 是一条连接对一个附着,附着到新会话会先释放旧的。第二,每次附着生成新的随机 attachmentId。第三,附着完成后 server 主动推一条 attachment 信封,客户端据此更新 client.attachment。”
“之后每个会话请求都要过 requireAttachment:target 的 sessionId 和 attachmentId 必须和这条连接当前的附着完全一致,否则抛 SessionNotAttachedError。”老Z说,“所以拿着旧的 attachmentId 发请求,哪怕会话还是同一个,也会被拒。”
快照是黑板,更新是便签
小A:“连上之后,屏幕上刷新出来的到底是什么?”
“要分清两种东西。”老Z说,“v1.0.0 里状态靠服务订阅拿。客户端调 subscribeService:先发一个 subscribe 调用,response 的 result 就是完整快照;之后服务端的变化用 service_update 信封推送增量。”
比喻:黑板与便签
快照是黑板:权威、完整,订阅时整块交给你。更新是便签:及时、零散,只描述在黑板上改了什么。便签必须贴在拿到的那块黑板上,次序错了就对不上。
“关键在次序。”老Z打开 handleRequest 里的订阅分支,“服务在返回快照之前就可能发布更新。server 把订阅还没响应期间收到的更新先放进 pendingUpdates,等 response 发出去,再按顺序补发。快照和更新都经 chord 的 ServiceStateEncoder 编码,更新是相对快照的状态编码,不是独立的完整状态。”
client subscribeService ──request(subscribe)──> server
服务返回快照;期间的更新进 pendingUpdates
client <──response(ok, result=编码后的快照)──────── server
client <──service_update × n(先补发 pending)──── server“客户端那头也有两道闸。”老Z接着说,“快照还没解码(hydrated 为假)时到达的 service_update 先存原始字节;快照解码后再按顺序解码它们。解码好的更新还要等调用方调用 start() 才开始投递,ServiceSubscription.start 的注释写的是:调用方装好快照之后再开始有序投递。任何一条更新解码失败,都按协议错误断开连接。”
| 消息 | 谁发出 | 正常语义 | 失败边界 |
|---|---|---|---|
| hello/hello_error | client/server | 确认版本与服务器身份 | 版本错、超时、首帧错,连接关闭 |
| request/response | client/server | 用关联 ID 完成 Promise | 断线时执行结果未知 |
| cancel | client | 中止在途请求 | 不撤销已发生的副作用 |
| attachment | server | 告知当前附着目标或 null | 旧 attachmentId 的请求被拒 |
| service_update | server | 在已装好的快照上增量更新 | 解码失败即断开;不能单独重建状态 |
断线的真实边界
“回到我开头的问题。”小A说,“断线之后,客户端会自动重连吗?”
“不会。”老Z说,“Client.reconnect() 的实现就是再调一次 connect(),由调用方决定什么时候调。断线时 #handleConnectionStateChange 会做三件事:把附着置空,把所有在途请求的 Promise 以断线错误 reject,清空所有服务订阅的监听。所以重连之后,要重新握手、重新附着、重新订阅;附着会拿到新的 attachmentId,订阅会拿到新的快照。”
“server 那边呢?断一条连接,会话会跟着关吗?”
“分两层看。”老Z回到 server.ts 的 disconnect,“连接层:把 stage 置为 closed,对每个在途请求 abort(new Error("Client disconnected")),清空这条连接的订阅编码器,释放服务器级服务。会话层:SessionRouter.disconnect 释放这条连接的附着,释放前先 Promise.allSettled(attachment.operations),等这次附着上已经在跑的服务调用结束。”
“然后托管的会话本身留在 hostedSessions 里。”老Z说,“router 不会因为最后一个附着离开就关闭会话;会话只在三种情况下关闭:宿主调 prepareSessionRemoval 删除它、server 关闭、或者会话 handle 的 terminated 结束。其他客户端仍然可以附着到它,这是多客户端共享会话的前提。”
小A:“重连拿到快照,是不是就能说命令一定只执行了一次?”
“不能。”老Z说,“断线可能正好落在服务器执行命令之后、响应到达之前。client 只知道 Promise 被断线错误 reject 了,不区分‘服务端没执行’和‘执行了但响应丢了’。server 侧的 abort 只是给服务一个取消信号,已经发生的副作用不会回滚。一个有副作用的请求要重试,幂等键、审计或人工确认至少要有一样。”
“还有一处常被忽略:传输层正常关闭时,server 的 transportClosed 和 client 的 #handleClose 都会调 decoder.end()。”老Z补一句,“本地 decoder 还卡着半帧时,end() 会抛 ‘Truncated frame at end of stream’;server 把它记进错误回调,client 把它作为断线原因。EOF 并不天然干净。”
收益、代价与替代方案
从源码能推出的收益是:协议无状态、server 只做握手和路由,业务全部交给 chord 服务,宿主换一种会话实现不必改协议;附着带随机 attachmentId,旧请求不会误打到新附着上。代价是可靠性的很多责任被推给了调用方:断线后要自己重连、重新附着、重新订阅,在途请求的提交状态要靠业务层的幂等键来判断。
一个常见的替代方案是由客户端库内置自动重连和请求重放。它对使用者更省事,但会把“未知是否提交”的请求悄悄重发一次,对有副作用的命令并不安全。v1.0.0 的源码没有内置自动重连,reconnect() 要由调用方显式调用;由此可以推断,这套实现用更多样板代码换来了不猜测提交状态。
小结
远程会话的可靠性建立在明确的协议阶段上。握手先于请求:client 发 hello,server 校验版本、挂上服务器级服务,再回 hello;client 还要核对 serverId。版本不匹配、首帧不是 hello、握手超时,都以 hello_error 关闭连接,不会误执行。请求靠关联 ID 完成 Promise,target 决定它走服务器级服务还是某个会话;会话请求必须带上当前附着的 attachmentId。状态靠订阅:response 交付完整快照,service_update 只在这块快照上增量更新,两端都有缓冲保证先快照、后更新。
这一章最需要克制的是对“断线恢复”的想象。对照源码,走得通的路是:断线后 client 清空附着、拒绝在途请求、丢掉订阅;调用方再 reconnect(),重新握手、附着、订阅。自动重连、自动重放未确认请求、无丢失恢复,源码里找不到对应实现。断线要是落在服务端执行之后、响应到达之前,客户端无法从本地 Promise 知道请求到底提交了没有,重试前得备好业务级的幂等键、审计或人工确认。
源码里处处是“边界”而非“保证”。server 侧靠五个 ConnectionStage 卡住非法消息序列,握手超时和 failProtocol 把坏连接引向关闭;router 用一连接一附着、随机 attachmentId 和 requireAttachment 把会话请求钉在当前附着上。client 侧靠生命周期 id 隔离新旧连接的迟到事件,靠请求表把未知 response 变成连接级错误。读这一层源码,与其问“它保证什么”,不如问“它在哪里划界、划界之后谁来负责重建”。
源码走查
- 在
packages/protocol/src/protocol.ts找到ClientMessageSchema和ServerMessageSchema,列出RpcTarget的两种形状。 - 跟读
server.ts的accept、dispatchMessage、finishHandshake,写出从awaitingHello到ready的每个失败出口。 - 阅读
handleRequest,标出wrong_server、invalid_request、cancelled各自在哪里产生;再找到订阅分支里的pendingUpdates。 - 阅读
session-router.ts的attachClientNow、requireAttachment和disconnect;说明为什么一个旧attachmentId的请求会被拒。 - 阅读
client.ts的subscribeService,确认更新在hydrated和start()之前分别存在哪里。 - 断开 transport 后检查
client.attachment和在途请求的结果;不要把观察到的 UI 行为当成自动重连证据。
