Skip to content

第27章 黑板与便签——pi-server+pi-client ​

本章导读: 源码路线第 ⑨ 站(远程层)。同时看产品层的 pi-server 和核心层的 pi-client——读 server/sessions.ts、server/snapshots.ts 和 client/connection.ts。这一站要直面断线后那个最折磨人的问题:那条命令到底执行了没有?重连拿到的状态能信多少?

字节已经能变成协议消息了。但小a把 client 和 server 连起来的时候,又冒出第二个问题:“我断线重连之后,怎么知道那条命令到底执行了没有?重连后拿到的状态,我能信多少?”

“先别急着信。”老z说,“连接建立后要回答三件事:客户端如何确认版本、何时相信 snapshot、断线后又能保证什么。我们跟读 client/server 协议层——但不承诺源码未实现的自动重连。”

本章源码证据均来自 pi-mono 提交 583f153d(packages/protocol/src/schemas.ts、packages/server/src/、packages/client/src/,包版本 0.83.0)。

握手先于命令 ​

小a:“那我连上就发 prompt?”

“不行。连接不能直接发送 prompt。”老z说,“packages/server/src/connection.ts 与 server.ts 将连接分为握手与 ready 两个阶段。客户端先编码 hello;server 解码与验证,检查版本和连接阶段,成功后把连接转为 ready,并交付 handshake 和当前 snapshot。之后请求才进入 session manager——这个顺序把‘不支持版本’‘尚未握手’‘已关闭连接’与业务错误分开。”

“客户端侧 packages/client/src/connection.ts 和 client.ts 将 transport、解码器与请求 Promise 组合。正常路径是 client hello、server handshake/snapshot、请求带 ID、响应匹配 ID;事件则不依赖某个请求响应。”

小a追问:“握手阶段的边界具体卡在哪?我看 ConnectionStage 有好几个值。”

“五个阶段,每个都有明确的拒绝规则。”老z打开 server.ts 的 dispatchMessage,“ConnectionStage = "awaitingHello" | "handshaking" | "ready" | "closing" | "closed"。连接刚建立是 awaitingHello——第一条消息如果不是 hello,直接 failProtocol(invalid_request, "The first client message must be hello")。收到 hello 后切到 handshaking,finishHandshake 串行做三件事:authenticate(hello) 验证 token、isSupportedProtocolVersion(hello.version) 验证版本、snapshots.get() 取当前快照。三步全过才发 server hello(带 snapshot),并把 stage 切到 ready、清掉 handshake 超时。”

“还有一个细节。”老z补充,“handshaking 期间如果又来一条消息,dispatchMessage 不会丢弃它——而是挂到 handshake.then(...) 上,等握手成功且 stage 已是 ready 才交给 handleRequest。但如果握手失败(版本或 auth),那条排队的消息永远不会被处理,连接也会被 failProtocol 关掉。”

小a:“那握手阶段本身有没有超时?总不能永远挂在 handshaking 吧?”

“有,accept 里就埋了。”老z回到 server.ts 的 accept,“连接建立的同时就 setTimeout 一个 handshake timeout,默认 5 秒(DEFAULT_HANDSHAKE_TIMEOUT_MS),触发时 failProtocol(state, { code: "invalid_request", message: "Handshake timeout" })。这个定时器在握手成功后 clearTimeout——所以‘收到 hello 但版本错’和‘一直不发 hello’是两种不同的死法:前者立刻报 version,后者拖到超时才报 invalid_request。另外 failProtocol 会把 stage 置成 closing,并尝试把一条 hello_error 作为最后一段数据 close(finalFrame) 发出去——发送失败也走 reportError,不阻断关闭。握手各步的失败点可以用一张状态图串起来。”

text
accept ──> awaitingHello
             │ 非 hello 首帧 ──failProtocol(invalid_request)
             │ 超时未 hello ──failProtocol(invalid_request)
             v
收到 hello ──> handshaking ──authenticate──> 失败 ──failProtocol(auth)
                     │ ──isSupportedProtocolVersion──> 失败 ──failProtocol(version)
                     │ ──snapshots.get()──> 异常 ──toProtocolError ──failProtocol
                     v
            发送 server hello(带 snapshot)
             │ 期间 connection 关闭 ──返回,不置 ready
             v
            ready(清超时;若 revision 已变,补发一条 server_snapshot)
             │ 之后非 hello 消息 ──handleRequest
             │ 再来 hello ──failProtocol("hello may only be sent as the first message")

要点:版本不匹配不执行命令

版本不匹配或无效请求会进入 protocol error,而不是执行会话命令。

snapshot 是权威状态,progress 是增量 ​

小a:“那连上之后,屏幕上刷新的东西到底是什么?”

“要分清两种东西。”老z打开 packages/protocol/src/schemas.ts:“注释明确 TranscriptProgress 是 normalized incremental activity,而 snapshots remain authoritative(快照保持权威)。packages/server/src/snapshots.ts 为 ServerSnapshot 递增 revision 并广播;packages/server/src/sessions.ts 对 runtime progress 发 session_progress,对其他状态变化广播 session_snapshot。”

text
command → result(snapshot)
runtime progress → session_progress
状态变化 → session_snapshot(revision)

比喻:黑板与便签

snapshot 是黑板——权威、完整、随时可看;progress 是便签——及时、零散、只表示“正在发生”。便签贴得再多,最后都要对着黑板校正。

“客户端应把 progress 当作界面及时性信号,并以较新 snapshot 校正完整状态。revision 可帮助检测顺序问题,但源码片段并不证明跨网络分区的强一致或 exactly-once 语义。”

小a:“那 session 是怎么变成 live 的?create 和 attach 都往同一个池子里放吗?”

“同一个池子,但有先后之别。”老z回到 sessions.ts 的 acquire,“create 调 backend.createSession(options),attach 调 backend.openSession(sessionId),两者最终都走同一个 acquire(id, acquireRuntime)。acquire 是个 for 循环:session 已存在且非 terminal 就直接返回;正在 opening 就复用那个 Promise(并发请求不会开两个 runtime);create 之后还要 attach——所以 create 和 attach 是两个命令,不是一次握手带上的。create 里还有一道防线:runtime.snapshot() 返回的 snapshot.id 必须等于 server 分配的 id,否则报 Backend returned session X for server-assigned session Y——backend 被要求使用 server 分配的 ID,杜绝 runtime 自造 ID 绕过会话命名约定。”

小a追问:“那 progress 和 snapshot 具体由谁触发?我看 handleRuntimeEvent 里有个分支。”

“看 sessions.ts 的 handleRuntimeEvent。”老z指回源码,“runtime 抛出来的事件分三类:error 走 terminate(关闭所有附着连接并 dispose);progress 封装成 session_progress envelope 广播给 live.connections 里所有连接;其他所有状态变化(transcript 更新、phase 变化等)都走 broadcastSnapshot,封装成 session_snapshot。注意 session_progress 直接从 runtime 的 event.progress 取值,不经过 snapshot 校正——它是‘正在发生’的原始增量;而 broadcastSnapshot 会先调 normalizedSnapshot 重新从 runtime 拉完整状态,再广播。所以 progress 可能比 snapshot 早到、也可能丢(连接刚断),但 snapshot 一来就会覆盖之前所有增量。”

小a:“那 revision 是每个 session 一个,还是全局的?”

“是全局广播队列的序号,不是 per-session 的。”老z回到 snapshots.ts,“ServerSnapshotPublisher 只有一个 revision 字段,performBroadcast 里 ++this.revision 后塞进每条 server_snapshot。但 session_snapshot(per-session 的)走的是 sessions.ts 的 broadcastSnapshot,它不带 revision——revision 只出现在 server 级快照里。所以 revision 校正的是‘服务器整体状态视图’,不是单个 session 的事务序号。”

断线的真实边界 ​

小a:“那重连呢?断线后客户端会自动重连吗?”

“先看 server 侧的边界。”老z打开 sessions.ts:“它维护连接附着集合,连接关闭会触发 disconnect、可能释放空闲 runtime;requireAttached 拒绝未附着的会话命令。”

ts
if (!connection.sessionIds.has(sessionId)) {
	throw new PiServerError("invalid_request", `Connection is not attached to session ${sessionId}`);
}

“这里能确认连接生命周期与重新 attach 的协议边界。能确认的是:可以重新连接、重新握手、重新 attach。不能确认的是:client 自动重连、自动重放未确认请求、无丢失恢复——本快照没有把这些行为作为本章所跟读路径的一部分。requireAttached 恰好说明:重建 socket 并不等于恢复附着关系。”

小a:“那重连后收到 snapshot,就能说命令一定只执行一次吗?”

“不能。”老z说,“若断线发生在服务器执行命令之后、响应到达之前,客户端需要业务级 request ID、幂等设计或人工确认来决定是否重试。重试有副作用的 request 需要幂等键、审计或人工确认。”

小a追问:“断线时 server 这边到底清理了什么?session 会被杀掉吗?”

“不一定,分两步。”老z回到 sessions.ts 的 disconnect,“连接关闭触发 disconnect(connection):先把 connection.sessionIds.clear(),再把该 connection 从每个 live.connections 集合里 delete 掉,然后对每个受影响的 session 调 maybeDispose。maybeDispose 会判断 session 是否还有其他附着连接、是否空闲超时——如果还有别的 client 连着,session 不会被 dispose。这是多客户端共享同一 session 的前提:一个 client 断了,session 对其他 client 仍可见。只有当 live.connections.size === 0 且满足空闲条件,runtime 才会被释放。”

“所以重建 socket 后重新 attach,面对的是一个可能仍然存活的 session。”老z继续,“attach 方法检查 connection.disconnected || connection.stage !== "ready" || connection.connection.closed——三者任一为真就拒绝 attach 并 maybeDispose。这印证了‘重建 socket 不等于恢复附着’:新连接是新 connection,必须重新走 hello → ready → attach,旧 connection 的 sessionIds 集合已经在 disconnect 时清空了,不会自动迁移过来。”

“那 client 侧呢?断线之后 client 内部发生了什么?”小a追问。

“client 的 Connection 也维护一套生命周期,和 server 的 stage 对应但更简单。”老z打开 packages/client/src/connection.ts,“ConnectionLifecycle 只有 disconnected/connecting/connected 三个状态,外加一个自增的 id。connect() 时 ++#sequence 得到新 id,整个生命周期都带上这个 id——#handleData、#handleClose、#handleError 都先 #isCurrent(id) 判断,旧连接的迟到事件不会污染新连接。#openTransport 里 transport factory 异步返回后还要再检查一次 id:如果期间已经重连,就 transport.close() 丢弃这个过期 transport。hello 必须由 client 先发:#handleData 在 connecting 且 transport 还没建立时收到服务端数据,直接 failAndClose("Received server data before the client hello was sent")。服务端 hello 到达后才切到 connected 并 resolve 握手 Promise。”

“再往上一层,PiClient 把请求和会话租约捆在一起。”老z继续,“#pendingRequests 是个 Map,key 是 request-${序号} 的 ID。收到 response 时 #takePendingRequest(message.id) 匹配,匹配不到就 #connection.fail(...)——未知 ID 的 response 会被当成协议错误,把连接废掉,而不是静默忽略。#handleConnectionStateChange 里一旦变 disconnected,就 #rejectPendingRequests 把所有未完成的请求 Promise 拒绝掉——所以断线时,所有在途请求立刻从‘等待响应’变成‘已知失败’,只是失败原因不区分‘服务端是否已执行’。会话租约也一起失效:#invalidateAllSessionLeases 把 lease 计数清空并 bump generation,本地持有的 SessionHandle 进入 invalidated,后续 request 抛 PiSessionDetachedError。”

状态/消息谁发出正常语义失败边界
hello/handshakeclient/server确认版本与 readyversion/auth 错误,不能发命令
request/responseclient/server用关联 ID 完成 Promise断线时执行结果未知
session_progressserver增量刷新不是完整恢复状态
session_snapshotserver用 revision 校正状态旧 revision 不应覆盖新状态
disconnecttransport清理连接相关状态不等于自动重连

从 hello 到 response ​

“packages/protocol/src/schemas.ts 里版本是常量,packages/server/src/snapshots.ts 的 get 把它放进快照。”老z依次打开两处:

ts
export const PROTOCOL_VERSION = 2 as const;
ts
return {
	serverId: this.options.serverId,
	protocolVersion: PROTOCOL_VERSION,
	revision: this.revision,
	sessions: await this.options.listSessions(connection),
	models: models ?? (await this.options.backend.listModels()),
};

“client 的 connection 层将 transport、decoder 与未完成请求的 Promise 组合。正常路径是 hello、handshake/snapshot、带 ID 的 request、按 ID 匹配的 response;event 不依赖某个 request。断线发生在执行后响应前时,client 无法从本地 Promise 知道请求是否提交。”

小a:“server 存 token 的时候,是直接拿明文比对的吗?”

“不是,这里是值得学的一处细节。”老z指回 server.ts 的构造函数和 authenticate,“构造时 tokenDigest(options.token) 把 token 的 sha256 摘要存进 expectedTokenDigest,握手时 timingSafeEqual(tokenDigest(hello.token), this.expectedTokenDigest)。两个点:一是服务端不保存 token 明文,内存里只有摘要,泄露路径少一条;二是用 timingSafeEqual 做常数时间比较,避免普通 === 的早期返回被侧信道利用。token 本身是字符串且 minLength: 1,但真正的强度取决于调用方怎么生成它——协议只保证比较方式,不保证 token 熵。验证失败的连接走 failProtocol(auth),和版本错误一样在握手阶段终结。”

“还有一处常被忽略:正常断开时 transportClosed 会调 decoder.end()。”老z补一句,“如果连接在传输层关闭,但本地 decoder 还卡着半帧,end() 会抛 Truncated frame at end of stream,这个错误被 reportError 记下来——EOF 并不天然干净,传输层说‘关’不等于协议层说‘完整’。”

snapshot、progress 与 disconnect ​

小a:“那 revision 是不是就是事务序号?”

“不是。”老z打开 snapshots.ts 的 performBroadcast:

ts
const revision = ++this.revision;
const snapshot: ServerSnapshot = { ...current, revision };
const envelope: EventEnvelope = { type: "event", event: { type: "server_snapshot", snapshot } };
await this.options.sendMessage(connection, envelope);

“revision 是服务器广播队列的观察序列,不是跨网络分区的事务保证。schemas.ts 明确 progress 是增量活动、snapshot 才是权威状态;client 应以较新 snapshot 校正,不能把 delta 当作完整 transcript。”

小a:“那 performBroadcast 里为什么要先 ++revision 再循环发?一个连接失败了怎么办?”

“看广播的容错。”老z回到 performBroadcast,“它先把 readyConnections 过滤出来(stage === "ready" && !disconnected),++revision 后对每个连接 await sendMessage。如果某条 sendMessage 失败(对端已断),这个 await 会抛——但抛在 for 循环里,后面的连接就收不到这条 snapshot 了。不过 broadcast() 外层用 broadcastQueue.then(...).catch(reportError) 串行化,一次失败不会阻断下一次广播的 revision 自增。代价是:同一 revision 的 snapshot 可能只发到了部分连接,没收到的连接要等下一次广播或重连后的 handshake snapshot 来校正。这再次说明 revision 是‘尽力广播’的序号,不是‘全部送达’的事务号。”

“那 reconnect 之后,到底能保证什么?”

“源码可确认重新连接、重新握手和重新 attach 的边界;**没有证据支持自动重连、自动重放或 exactly-once。**坏 frame 在 decoder 层终止连接,版本错误在握手层返回;断线后未完成 request 可能处于未知提交状态。snapshot revision 校正显示状态,却不能回滚已执行写入——重新附着是新的协议操作,不是旧 socket 自动延续。”

小结 ​

远程会话的可靠性建立在三个明确的协议阶段上。握手先于命令:客户端发 hello,服务端校验版本和连接阶段,通过后才转为 ready 并交付当前 snapshot——版本不匹配或未握手就发命令,会直接进 protocol error,不会误执行。命令交互靠关联 ID 完成 Promise:每个请求带 ID,响应按 ID 匹配,事件则不依赖任何请求。观察靠两类消息分工:snapshot 是权威的完整状态,带自增的 revision;progress 是增量活动,只反映"正在发生"。客户端应该把 progress 当作界面及时性信号,以较新的 snapshot 校正完整状态,不能把 delta 累积成 transcript。

这一章最需要克制的是对"断线恢复"的想象。源码能确认的是:连接关闭会清理附着状态,重建 socket 后可以重新连接、重新握手、重新 attach。但源码没有证据支持自动重连、自动重放未确认请求或无丢失恢复。断线发生在服务端执行命令之后、响应到达之前时,客户端无法从本地 Promise 知道请求到底提交了没有——这时需要业务级的幂等键、审计或人工确认来决定是否重试,而不是盲目重放。revision 也只是广播队列的观察序号,不是跨网络分区的事务保证。

源码里其实处处都是"边界"而非"保证"。server 侧靠五个 ConnectionStage 卡住非法消息序列,握手超时和 failProtocol 把坏的连接引向关闭而不是半开;client 侧靠生命周期 id 隔离新旧连接的迟到事件,靠 pending request 表把未知 response 变成连接级错误。token 只存摘要、比较用常数时间,EOF 时 decoder 还要检查半帧——每一处都在减少"看似正常实则已坏"的状态。读协议层源码,真正要问的不是"它保证什么",而是"它在哪里划界、划界之后谁来负责重建"。把这些边界画出来,远程模式的故障才不再是黑盒。

源码走查 ​

  1. 在 schemas.ts 找到 hello、request、response 和 event 的联合类型。
  2. 跟读 server.ts/connection.ts 中从握手到 ready 的状态转换。
  3. 阅读 snapshots.ts 与 sessions.ts,比较 session_progress 和 session_snapshot 的发送条件。
  4. 断开 transport 后检查客户端错误与附着状态;不要把观察到的 UI 行为当成自动重连证据。