Skip to content

Part II · 源码篇 / Ch 10

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

小A与老Z围在一块展示完整微缩场景的黑板旁,老Z正把空白便签依次贴到板上,象征快照之后按序应用增量更新。

本章导读: 源码路线第 ⑨ 站(远程层),同时看 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) 发出去,编码失败也只记错误,不阻断关闭。”

text
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,直接失败。

ts
// 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,“处理顺序是这样的。”

text
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。并发的附着请求不会把同一个会话打开两次。”

ts
// 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 编码,更新是相对快照的状态编码,不是独立的完整状态。”

text
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_errorclient/server确认版本与服务器身份版本错、超时、首帧错,连接关闭
request/responseclient/server用关联 ID 完成 Promise断线时执行结果未知
cancelclient中止在途请求不撤销已发生的副作用
attachmentserver告知当前附着目标或 null旧 attachmentId 的请求被拒
service_updateserver在已装好的快照上增量更新解码失败即断开;不能单独重建状态

断线的真实边界 ​

“回到我开头的问题。”小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 变成连接级错误。读这一层源码,与其问“它保证什么”,不如问“它在哪里划界、划界之后谁来负责重建”。

源码走查 ​

  1. 在 packages/protocol/src/protocol.ts 找到 ClientMessageSchema 和 ServerMessageSchema,列出 RpcTarget 的两种形状。
  2. 跟读 server.ts 的 accept、dispatchMessage、finishHandshake,写出从 awaitingHello 到 ready 的每个失败出口。
  3. 阅读 handleRequest,标出 wrong_server、invalid_request、cancelled 各自在哪里产生;再找到订阅分支里的 pendingUpdates。
  4. 阅读 session-router.ts 的 attachClientNow、requireAttachment 和 disconnect;说明为什么一个旧 attachmentId 的请求会被拒。
  5. 阅读 client.ts 的 subscribeService,确认更新在 hydrated 和 start() 之前分别存在哪里。
  6. 断开 transport 后检查 client.attachment 和在途请求的结果;不要把观察到的 UI 行为当成自动重连证据。