图解 Pi AgentPi Agent, Illustratedv1.1.0
包Packages· server
演示Slides
Run modes已完成Donepackages/server · v1.1.0

server@earendil-works/pi-server

实验性的 pi 服务端。experimental server package for pi.

1.1.0
版本version
16
源文件src files
1,958
源码行src LOC
6
测试文件test files
41
测试用例test cases
3
导出路径export paths
依赖Depends onchordprotocol被依赖Used by—

这个包是什么What this package ispi-server 是远程 pi 会话的服务端一端:它接受任何 ServerListener 交来的字节连接,完成握手,核对每个请求的路由,再把调用原样交给应用——服务器级调用交给 serverServices,Session 调用交给应用打开的 RoutedSessionHandle 为这次附着发的租约。它不解析服务契约、不保存 Session;它负责的是路由、附着的生命周期、取消、订阅更新的顺序,以及关闭时的清理。pi-server is the server end of remote pi sessions: it accepts byte connections from any ServerListener, completes the handshake, checks every request's route and hands the call to the application unchanged; server-wide calls go to serverServices, Session calls to the lease that the app's RoutedSessionHandle issued for this attachment. It parses no service contract and stores no Session; it owns routing, the attachment lifecycle, cancellation, the order of subscription updates, and cleanup on shutdown.

1,958 行 TypeScript,三个导入路径:根入口 "."、Unix socket 传输 "./unix" 和测试工具 "./testing"。依赖 chord 与 pi-protocol;用到 node:crypto(attachmentId)。唯一的使用者是 coding-agent 的 experimental/ 服务器:每个 Session 跑在自己的 worker 进程里。1,958 lines of TypeScript, three import paths: the root ".", the Unix-socket transport "./unix" and the testing kit "./testing". Depends on chord and pi-protocol; uses node:crypto (attachment ids). Its only user is coding-agent's experimental/ server, where every Session runs in its own worker process.

01Run modes

Server、SessionRouter 与可替换的监听器Server, SessionRouter and pluggable listeners#

一句话结论TakeawayServer(server.ts)管连接、握手和请求分派;内部的 SessionRouter(session-router.ts)管托管的 Session、每个连接的附着和释放;ServerListener 只负责把已建立的字节连接交给 server.accept()。根入口用 export * 公开 errors、listener、server、types 四个模块;SessionRouter 与 ByteConnection 不公开。Server (server.ts) owns connections, the handshake and request dispatch; the internal SessionRouter (session-router.ts) owns hosted Sessions, each connection's attachment and its release; a ServerListener only hands established byte connections to server.accept(). The root uses export * to publish four modules: errors, listener, server and types; SessionRouter and ByteConnection stay private.

pi-server 的结构:Server、SessionRouter、监听器与测试工具pi-server: Server, SessionRouter, listeners and the testing kit
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
pi-server: 1,958 lines — handshake, route-based dispatch, attachments to the app's Sessionspackages/server/srctransports/unix → "./unix"testing/ → "./testing"依赖与使用者Dependencies and users4index.ts根入口 ".":export * 自 errors、listener、server、typesroot ".": export * from errors, listener, server, types576server.tsServer:连接、握手、请求分派Server: connections, handshake, dispatchtoProtocolError · failProtocol312session-router.tsSessionRouter:Session 与附着(内部)SessionRouter: Sessions, attachments (internal)runForClient · releaseAttachment68types.tsServerHost 契约ServerHost contract57errors.ts5 个错误子类5 error subclasses8listener.tsServerListener37connection.tsByteConnection(内部)ByteConnection (internal)476createUnixServergetUnixSocketPath · createUnixListener绑定、发布、身份校验bind, publish, identity checks420createTestServerTestServerHost · TestHarnessProtocolTestClient · Deferred@earendil-works/chord ^1.1.0服务控制解码、状态编码器、Context 与取消service-control decoding, state encoder, Context and abort@earendil-works/pi-protocol ^1.1.0ClientMessageDecoder · encodeServerMessage · isServerIdserver.tscreateUnixServer:~/.pi/server/<id>.sockcreateUnixServer: ~/.pi/server/<id>.sockservices/server.tsRoutedServerServiceHost:服务器级服务RoutedServerServiceHost: server-wide servicessession-worker-manager.tsRoutedSessionHandle:每个 Session 一个 workerRoutedSessionHandle: one worker per Sessionradius-relay.ts中继来的连接交给 server.accept()relayed connections go to server.accept()依赖Depends on使用者:coding-agent 的 experimental/(4 个文件导入它)Used by: coding-agent experimental/ (4 importing files)目录可用 PI_SERVER_DIR 覆盖。PI_SERVER_DIR overrides the directory.Server 本身不懂任何服务契约,也不保存 Session:它只核对路由,把调用交给应用通过 ServerHost 提供的能力。The Server knows no service contract and stores no Session: it checks routes and hands calls to capabilities the app supplies through ServerHost.标签 = 行数tag = lines核心coreSession 路由Session routingUnix / 使用方Unix / users测试与依赖testing & deps

要点Key points

  • 监听器负责传输层的认证;实验性的 Unix 监听器不做认证,只依靠文件权限。Listeners own transport-level authentication; the experimental Unix listener does none and relies on file permissions.
  • coding-agent 的 radius-relay.ts 只用到 Pick<Server, "accept">:中继来的连接与本地 socket 进入同一个 Server。coding-agent's radius-relay.ts only needs Pick<Server, "accept">: relayed connections enter the same Server as local sockets.
  • Server.closed 在关闭后 resolve,监听器或 Session 清理失败时 reject。Server.closed resolves after shutdown and rejects when listener or Session cleanup fails.

工作原理How it works

  1. const server = new Server(host, { listeners, serverId }),然后 await server.start()。const server = new Server(host, { listeners, serverId }), then await server.start().
  2. 本机:createUnixServer(host, { serverId, path: getUnixSocketPath(serverId, dir) })。Locally: createUnixServer(host, { serverId, path: getUnixSocketPath(serverId, dir) }).
  3. onConnectionCountChanged(count) 观察连接数,onError(error) 收集诊断。Observe connections with onConnectionCountChanged(count), collect diagnostics with onError(error).

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/server/src/index.tsL3export *根入口the root entry
packages/server/src/server.tsL46Server连接与分派connections and dispatch
packages/server/src/session-router.tsL34SessionRouterSession 与附着Sessions and attachments
packages/server/src/listener.tsL4ServerListener监听器契约the listener contract
packages/server/src/transports/unix/preset.tsL7createUnixServer()Unix 预设the Unix preset
02Run modes

公共 APIPublic API#

一句话结论Takeaway3 个导入路径。根入口 index.ts 用 export * 公开 4 个模块里的 8 个值和 10 个类型:Server、六个错误类、INTERNAL_SERVER_ERROR_MESSAGE,其余是宿主与监听器的契约;SessionRouter 和 ByteConnection 不公开。Unix 传输在 ./unix,测试工具在 ./testing。3 import paths. The root index.ts uses export * to publish 8 values and 10 types from 4 modules: Server, six error classes and INTERNAL_SERVER_ERROR_MESSAGE, the rest host and listener contracts; SessionRouter and ByteConnection stay private. The Unix transport lives in ./unix, the testing kit in ./testing.

导入路径Import path目标Target说明Notes
@earendil-works/pi-server./dist/index.jsServer、ServerHost 契约、监听器契约与错误类Server, the ServerHost contract, the listener contract and error classes
@earendil-works/pi-server/testing./dist/testing/index.js测试服务器、内存宿主与帧级协议客户端a test server, an in-memory host and a frame-level protocol client
@earendil-works/pi-server/unix./dist/transports/unix/index.jsNode 专用:Unix socket 监听器与预设Node only: the Unix-socket listener and preset

package.json · exportsL8

常用导出Key exports

模块Module常用导出Key exports用途Use
packages/server/src/server.tsServer连接、握手与请求分派connections, handshake and dispatch
packages/server/src/types.tsServerHost SessionMetadata应用要实现的宿主the host an app implements
packages/server/src/types.tsRoutedSessionHandle RoutedSessionAttachmentSession 句柄与附着租约Session handle and attachment lease
packages/server/src/types.tsRoutedServerServiceHost RoutedServerPresentation服务器级服务与附着能力server-wide services and attach capabilities
packages/server/src/listener.tsServerListener接入传输plug in a transport
packages/server/src/errors.tsServerError SessionNotFoundError SessionAmbiguousError可越过边界的错误boundary-safe errors

子路径 ./unixSubpath ./unix

模块Module常用导出Key exports用途Use
packages/server/src/transports/unix/preset.tscreateUnixServerServer + 一个 Unix 监听器Server + one Unix listener
packages/server/src/transports/unix/listener.tscreateUnixListener单独的 Unix 监听器a Unix listener on its own
packages/server/src/transports/unix/address.tsgetUnixSocketPath<dir>/<serverId>.sock<dir>/<serverId>.sock
packages/server/src/transports/unix/types.tsUnixListenerOptions UnixServerOptions选项options

子路径 ./testingSubpath ./testing

模块Module常用导出Key exports用途Use
packages/server/src/testing/server.tscreateTestServer未启动的测试服务器an unstarted test server
packages/server/src/testing/host.tsTestServerHost TestHarness createTestServerServices Deferred内存宿主与时序控制in-memory host and timing control
packages/server/src/testing/client.tsProtocolTestClient connectUnixTestClient帧级协议客户端frame-level protocol client

根入口的全部导出(按声明所在模块,逐行链接)Every root export, by declaring module (line-linked)

由构建脚本展开 index.ts 的 export * 与 export { … } from 并扫描声明生成;每个符号链接到 v1.1.0 中的定义行。Generated at build time by expanding the export * and export { … } from statements in index.ts and scanning the declarations; each symbol links to its definition at v1.1.0.

03Run modes

ServerHost:应用提供的能力ServerHost: capabilities the app provides#

一句话结论Takeaway应用只实现三样东西:serverServices(握手时为每个连接建一个服务器级端点)、resolveSession(id)(把 id 解析成元数据,或抛出有界的路由错误)和 openSession(metadata)(返回一个 RoutedSessionHandle)。句柄的 attachClient() 为每次附着发一个租约,租约的 invokeService() 接收不透明的服务调用。The app implements just three things: serverServices (one server-wide endpoint per connection, built at the handshake), resolveSession(id) (resolve an id to metadata, or throw a bounded routing error) and openSession(metadata) (return a RoutedSessionHandle). The handle's attachClient() issues one lease per attachment, and the lease's invokeService() takes opaque service calls.

ServerHost:应用交给 Server 的能力ServerHost: the capabilities the app hands to the Server
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
ServerHost: every capability the app hands to the Server«app»ServerHost<TMetadata>+serverServices: RoutedServerServiceHost+resolveSession(sessionId, ctx): TMetadata+openSession(metadata, ctx): RoutedSessionHandle«app»RoutedServerServiceHost+attachClient(presentation, ctx)«per conn»RoutedServerServiceAttachment+invokeService(call, publish, ctx)+release(ctx)«per Session»RoutedSessionHandle+attachClient(ctx): RoutedSessionAttachment+terminated?: Promise<Error | undefined>+close(ctx): Promise<void>«per attach»RoutedSessionAttachment+invokeService(call, publish, ctx)+release(ctx)«from Server»RoutedServerPresentation+attachSession(sessionId, ctx)+detachSession(ctx)+prepareSessionRemoval(sessionId, ctx)serverServices握手时at handshakeopenSession()每次 attachper attach作为参数passed in握手时:At handshake: serverServices.attachClient(presentation) 为每个连接建一个服务器级端点;连接断开时 release()。serverServices.attachClient(presentation) builds one server-wide endpoint per connection; release() when it drops.attach 时:On attach: resolveSession → openSession(同一 Session 只开一次)→ handle.attachClient() 为这次附着取得一个租约。resolveSession → openSession (once per Session) → handle.attachClient() takes a lease for this attachment.terminated:terminated: 意外退出时以错误 resolve,正常关闭时以 undefined resolve;Server 随即让该 Session 的所有附着失效。resolves with an error on unexpected exit, undefined after a normal close; the Server then invalidates every attachment.publish:publish: invokeService 的第二个参数;订阅的更新经它回到 Server,再编码成 service_update。invokeService's second argument; subscription updates come back through it and become service_update.coding-agent:serverServices 来自 services/server.ts;openSession 交给 SessionWorkerManager,每个 Session 跑在自己的 worker 进程里。coding-agent: serverServices comes from services/server.ts; openSession goes to SessionWorkerManager, each Session running in its own worker process.服务器级server-wideSession 级Session levelServer 提供给应用Server → app返回returns参数argument

要点Key points

  • 服务器级端点拿到一个 RoutedServerPresentation:attachSession、detachSession、prepareSessionRemoval——应用的管理服务就是用它们 attach 的。The server-wide endpoint receives a RoutedServerPresentation: attachSession, detachSession, prepareSessionRemoval; the app's management service attaches through these.
  • terminated 是可选的 Promise:意外退出时以错误 resolve,Server 随即让该 Session 的附着全部失效。terminated is an optional promise: it resolves with an error on unexpected exit, and the Server then invalidates every attachment to that Session.
  • SessionMetadata 只要求 id;应用可以扩展它(coding-agent 带上了 path)。SessionMetadata only requires an id; apps may extend it (coding-agent adds a path).

工作原理How it works

  1. const host: ServerHost<StoredSession> = { serverServices, resolveSession, openSession }。const host: ServerHost<StoredSession> = { serverServices, resolveSession, openSession }.
  2. 找不到时 throw new SessionNotFoundError(Unknown session: ${id})。When missing, throw new SessionNotFoundError(Unknown session: ${id}).
  3. invokeService(call, publish, ctx):订阅的更新经 publish(subscriptionId, update) 回到 Server。invokeService(call, publish, ctx): subscription updates return through publish(subscriptionId, update).

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/server/src/types.tsL63ServerHost应用能力app capabilities
packages/server/src/types.tsL55RoutedSessionHandle每个 Sessionper Session
packages/server/src/types.tsL22RoutedSessionAttachment每次附着的租约per-attachment lease
packages/server/src/types.tsL33RoutedServerPresentation交给应用的附着能力attach capabilities for the app
packages/coding-agent/src/experimental/session-worker-manager.tsL194#routedHandle()coding-agent:worker 句柄coding-agent: the worker handle
04Run modes

连接的五个阶段Five connection stages#

一句话结论Takeaway每条连接经历 awaitingHello → handshaking → ready → closing → closed。第一条消息必须是 hello,而且整个握手要在 5 s 内完成;finishHandshake() 检查版本、为连接建服务器级端点、发回 hello { version, serverId },然后才进入 ready。协议错误走 failProtocol():把 hello_error 作为最后一帧发出,再关闭。Every connection goes awaitingHello → handshaking → ready → closing → closed. The first message must be hello and the whole handshake must finish within 5 s; finishHandshake() checks the version, builds the connection's server-wide endpoint and sends back hello { version, serverId } before entering ready. Protocol errors go through failProtocol(): hello_error is sent as the final frame, then the connection closes.

一条服务器连接的五个阶段与握手Five stages of a server connection, and the handshake
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Five stages of a server connection: the handshake must finish within 5 sfinishHandshake(state, hello)server.tsfailProtocol(state, error)server.tsdisconnect(state):只做一次disconnect(state): runs onceawaitingHellohandshakingreadyclosingclosed超时 5 s · 首条不是 hello · 坏帧timeout 5 s · first is not hello · bad frame传输 onClose / onErrortransport onClose / onError收到 hellogot hellohello 已发出hello sent版本检查Version check不支持 → hello_error { code: "version" }unsupported → hello_error { code: "version" }serverServices.attachClient(presentation)presentation = { attachSession, detachSession, prepareSessionRemoval }再检查一次Check again期间关闭或断开 → release() 刚得到的端点,就此结束closed or dropped meanwhile → release() the new endpoint and stopsend hello { version, serverId }发送成功 → stage = ready,清掉握手定时器sent → stage = ready, clear the handshake timer握手期间到达的请求挂在 state.handshake 之后,ready 时才处理,不会丢。Requests arriving during the handshake wait on state.handshake and run once ready: none are lost.stage = closing;把 hello_error { error } 编码成最后一帧,stage = closing; encode hello_error { error } as the final frame,connection.close(finalFrame) 发完它再关闭,然后 disconnect。connection.close(finalFrame) sends it, then closes; then disconnect.握手之后的协议错误(例如第二个 hello)也走这条路。Protocol errors after the handshake (a second hello) take this path too.ProtocolValidationError → invalid_request。ProtocolValidationError → invalid_request.stage = closed,清掉握手定时器stage = closed, clear the handshake timerabort 所有进行中的请求:"Client disconnected"abort every active request: "Client disconnected"清空订阅编码器;连接数变化 → onConnectionCountChangedclear subscription encoders; onConnectionCountChangedsessions.disconnect() 与 serverServices.release() 并行sessions.disconnect() and serverServices.release() in parallelServer.close():先关所有监听器,再把每个连接置为 closing 并关闭,最后 SessionRouter.close() 关闭所有托管的 Session。Server.close(): close every listener, set each connection to closing and close it, then SessionRouter.close() closes every hosted Session.12341234前进advance协议失败protocol failure可以处理请求serving requests清理cleanup

要点Key points

  • 握手期间到达的请求挂在 state.handshake 之后,ready 时才处理,不会丢。Requests that arrive during the handshake wait on state.handshake and run once ready; none are lost.
  • disconnect() 只执行一次:abort 所有进行中的请求、清空订阅编码器,并行释放 Session 附着与服务器级端点。disconnect() runs once: it aborts every active request, clears subscription encoders, and releases the Session attachment and server-wide endpoint in parallel.
  • 关闭中的 Server 对新连接直接 close,不建任何状态。A closing Server closes new connections at once without creating any state.

工作原理How it works

  1. handshakeTimeoutMs 与 maxFrameLength 都是 1 … 上限之间的整数,构造时检查。handshakeTimeoutMs and maxFrameLength are integers within bounds, checked by the constructor.
  2. await server.close():先关监听器,再关连接,最后关所有托管的 Session。await server.close(): listeners first, then connections, then every hosted Session.
  3. 自定义监听器:start(accept) 里对每个连接调用 accept(connection),把返回的 handler 接到传输事件上。A custom listener: in start(accept), call accept(connection) per connection and wire the returned handler to transport events.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/server/src/server.tsL136accept()新连接与握手定时器new connection, handshake timer
packages/server/src/server.tsL223dispatchMessage()按阶段分派dispatch by stage
packages/server/src/server.tsL262finishHandshake()完成握手finish the handshake
packages/server/src/server.tsL474failProtocol()hello_error 作最后一帧hello_error as final frame
packages/server/src/server.tsL417disconnect()断开清理disconnect cleanup
packages/server/src/connection.tsL21ConnectionStage五个阶段five stages
05Run modes

一个请求的处理Handling one request#

一句话结论TakeawayhandleRequest() 先拒绝重复的请求 id 和不合法的调用,再登记一个 AbortController;然后核对 serverId,按 target 是否带 sessionId 路由到 SessionRouter 或服务器级端点。订阅必须返回快照:Server 为它装上 ServiceStateEncoder,先发回应,再冲刷期间排队的更新。handleRequest() first rejects duplicate request ids and invalid calls, then registers an AbortController; it checks the serverId and routes to the SessionRouter or the server-wide endpoint depending on whether the target has a sessionId. A subscription must return a snapshot: the Server installs a ServiceStateEncoder for it, sends the response first, then flushes the updates queued meanwhile.

handleRequest:核对、路由、订阅与错误映射handleRequest: checks, routing, subscriptions and error mapping
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
handleRequest: check, route to a Session or server-wide services, and send subscription updates after the responsehandleRequest(state, envelope)server.ts失败时On failuretoProtocolError(error)server.tshandleCancel(state, envelope)server.tsinvalid_requestinvalid_requestwrong_serverinvalid_requestactiveRequests.has(id)parseServiceCall(envelope.call)Chord 校验调用的形状Chord checks the call's shapeactiveRequests.set(id, { controller, target })context = withAbortSignal(controller.signal)target.serverId !== this.serverId订阅 id 已有编码器subscription id already has an encoder路由Route"sessionId" in target ? sessions.executeServiceCall : serverServices.invokeService订阅:必须返回快照Subscribe: a snapshot is requiredencodeSnapshot,安装 ServiceStateEncoderencodeSnapshot, install a ServiceStateEncoder发送回应,再冲刷 pendingUpdatesSend the response, then flush pendingUpdates之后的更新直接编码成 service_updatelater updates go straight out as service_updatefinally:从 activeRequests 删掉自己(只删自己那一项)。finally: remove its own entry from activeRequests.还没回应:Not yet responded:response { ok: false, error };已 abort → cancelled,否则 toProtocolErrorresponse { ok: false, error }; aborted → cancelled, else toProtocolError已经回应(冲刷更新时出错):Already responded (failed while flushing):onError,然后关闭这条连接——订阅流不能半途断掉onError, then close the connection: a stream must not break halfway安装了编码器却没回应成功 → 移除编码器。Encoder installed but no response → the encoder is removed.退订成功 → 删掉对应的编码器,之后的更新被丢弃。A successful unsubscribe deletes the encoder; later updates are dropped.ServerError · RemoteServiceError原样:code + messageas is: code + messageProtocolValidationErrorinvalid_request + message其它任何错误anything elseinternal_error "Internal server error" + onErrorserverId 不是本服务器 → 忽略;id 与 target 都相同 →serverId of another server → ignored; same id and target →controller.abort(AbortError);处理器看到 abort 后回应 cancelled。controller.abort(AbortError); the handler then answers cancelled.取消从不单独回应:它只影响原请求的回应。A cancel never gets its own response; it only shapes the original one.同一连接上的请求并发处理;只有 attach / detach 与 Session 调用的“准入”按连接串行(见 SessionRouter)。Requests on one connection run concurrently; only attach / detach and the admission of Session calls are serialised per connection (see SessionRouter).12345678请求路径request path错误回应error responses错误映射error mapping取消cancellation

要点Key points

  • 错误回应用 toProtocolError() 映射:ServerError / RemoteServiceError 原样,ProtocolValidationError → invalid_request,其它 → internal_error。Error responses are mapped by toProtocolError(): ServerError / RemoteServiceError as is, ProtocolValidationError → invalid_request, anything else → internal_error.
  • 已 abort 的请求一律回应 cancelled;cancel 本身从不单独回应。An aborted request always answers cancelled; a cancel never gets its own response.
  • 回应已发出后才出错(例如冲刷更新时),Server 关闭这条连接:订阅流不能半途断掉。If something fails after the response was sent (flushing updates, say), the Server closes the connection: a stream must not break halfway.

工作原理How it works

  1. 同一连接上的请求并发处理;需要顺序时由应用在服务里自己排队。Requests on one connection run concurrently; apps that need ordering queue inside their services.
  2. 在服务里尊重 context 的 abort 信号,取消才会尽快生效。Honour the context abort signal in services so cancellation takes effect quickly.
  3. 退订成功后编码器被删掉,之后对该订阅的 publish 静默丢弃。After a successful unsubscribe the encoder is deleted, and later publish calls for it are silently dropped.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/server/src/server.tsL306handleRequest()核对、路由、回应check, route, respond
packages/server/src/server.tsL298handleCancel()按 id 与 target 取消cancel by id and target
packages/server/src/server.tsL438sendServiceUpdate()编码更新encode an update
packages/server/src/server.tsL512toProtocolError()错误映射error mapping
packages/server/src/server.tsL547sameTarget()target 比较target comparison
06Run modes

SessionRouter:attach 与准入SessionRouter: attach and admission#

一句话结论Takeawayattach 按连接串行(runForClient):已附着同一 Session 就直接返回;否则 acquire()——已托管、正在打开或新打开(resolveSession + openSession)——释放当前附着、为新附着生成 randomUUID() 并向句柄取租约,最后带外推送 attachment 路由。Session 调用也在同一队列里“准入”:路由必须与本连接的当前附着完全一致。Attach is serialised per connection (runForClient): if already attached to that Session it returns at once; otherwise acquire()s it (hosted, opening, or freshly opened via resolveSession + openSession), releases the current attachment, generates a randomUUID() for the new one, takes a lease from the handle, and finally pushes the attachment route out of band. Session calls are admitted through the same queue: the route must exactly match this connection's current attachment.

SessionRouter:按连接串行的 attach 与调用准入SessionRouter: per-connection attach and call admission
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
SessionRouter: attach serialised per connection, every Session call checked against its attachmentacquire → open(sessionId)session-router.tsexecuteServiceCall(call, target, client, …)session-router.tsattachClient(client, sessionId)正在关闭 → ServerDrainingErrorclosing → ServerDrainingErrorrunForClient(client, op)按连接排队:前一个操作结束后才开始queued per connection: starts after the previous op已附着同一个 Session?Already attached to this Session?是 → 直接返回(幂等)yes → return at once (idempotent)acquire(sessionId)已托管 → 正在打开 → open()hosted → opening → open()释放当前附着Release the current attachmentpublish = false:新路由马上会推送publish = false: the new route followshandle.attachClient(ctx)新附着 id = randomUUID(),拿到租约new attachment id = randomUUID(), take the leasepublishAttachment(client, target)attachment { serverId, sessionId, attachmentId }session_not_attachedserver_draininghostedSessionsopeningSessionsattachmentsByClientclientOperations拿到租约后再查一次:Session 已失效、连接已断或服务器在关闭 → 释放租约并 ServerDrainingError。After the lease, check again: Session invalidated, client gone or server closing → release it and ServerDrainingError.host.resolveSession(sessionId)得到元数据;找不到 / 有歧义由应用抛 SessionNotFound / Ambiguousmetadata; the app throws SessionNotFound / Ambiguoushost.openSession(metadata)得到 RoutedSessionHandle;期间开始关闭 → handle.close() 并拒绝a RoutedSessionHandle; closing meanwhile → handle.close() and rejecthostedSessions.set(metadata.id, …)并监听 handle.terminated → invalidate()and watch handle.terminated → invalidate()openingSessions 让并发的 attach 共享同一次打开;打开失败时所有人一起失败,之后可以重试。openingSessions lets concurrent attaches share one open; a failure fails them all, and a later retry may succeed.runForClient → requireAttachmentsessionId 与 attachmentId 都要等于本连接的当前附着sessionId and attachmentId must equal this connection's attachmentlease.invokeService(call, publish, ctx)只在队列里“准入”;结果在队列外等待,所以调用可以并发only admission is queued; the result is awaited outside, so calls run concurrentlytrackOperation(attachment, result)释放附着前会等这些调用结算release waits for these calls to settle不匹配、过期或服务器级 target → session_not_attached;关闭中或连接已断 → server_draining。mismatched, stale or server-level target → session_not_attached; closing or client gone → server_draining.内部状态Internal state123123排队queueingSession 与租约Session and lease路由route拒绝rejections

要点Key points

  • 只有准入排队;调用结果在队列外等待,所以同一 Session 上的调用可以并发。Only admission is queued; results are awaited outside the queue, so calls on one Session run concurrently.
  • openingSessions 让并发的 attach 共享同一次打开;失败时一起失败,之后可以重试。openingSessions lets concurrent attaches share one open; on failure they fail together, and a later retry may succeed.
  • 打开期间服务器开始关闭:立即 handle.close() 并以 server_draining 拒绝。If the server starts closing during an open, handle.close() runs at once and the attach rejects with server_draining.

工作原理How it works

  1. 应用的管理服务调用 presentation.attachSession(sessionId, ctx);结果里不带任何路由 id。The app's management service calls presentation.attachSession(sessionId, ctx); its result carries no route ids.
  2. 一个 Session 可以有多个连接的附着;每个附着各自有租约。One Session may have attachments from several connections, each with its own lease.
  3. 要彻底删除 Session,先调用 prepareSessionRemoval(sessionId),再删应用自己的元数据。To delete a Session, call prepareSessionRemoval(sessionId) first, then delete the app's own metadata.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/server/src/session-router.tsL146runForClient()按连接串行per-connection queue
packages/server/src/session-router.tsL160attachClientNow()attach 的步骤the attach steps
packages/server/src/session-router.tsL262acquire()共享打开shared opening
packages/server/src/session-router.tsL276open()resolve + openresolve + open
packages/server/src/session-router.tsL224requireAttachment()路由核对route check
07Run modes

附着的结束How attachments end#

一句话结论Takeawaydetach、切换 Session、连接断开、句柄 terminated、prepareSessionRemoval() 和 Server.close() 最终都走 releaseAttachment():同一附着只释放一次,先等已准入的调用结算,再 lease.release(),最后摘掉路由——只有 detach 与失效会推送 attachment: null。删除和关闭服务器还会 handle.close()。Detach, switching Sessions, a dropped connection, a terminated handle, prepareSessionRemoval() and Server.close() all end in releaseAttachment(): each attachment is released once, admitted calls settle first, then lease.release(), then the route is cleared; only detach and invalidation push attachment: null. Removal and shutdown also call handle.close().

附着结束的六种方式与唯一的释放路径Six ways an attachment ends, and the one release path
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Six ways an attachment ends, all through one release path触发TriggersreleaseAttachment(attachment)之后:Session 本身Afterwards: the Session itself关闭服务器时On shutdownSessionRouter.close()detachSession()推送 attachment: nullpushes attachment: nullattach 另一个 Sessionattach another Session不推送 null,随后推送新路由no null; the new route follows连接断开connection lost不推送(已经没有连接)no push (no connection left)handle.terminatedinvalidate:所有附着,推送 nullinvalidate: every attachment, push nullprepareSessionRemoval()所有附着 + handle.close()every attachment + handle.close()Server.close()全部附着 + 全部 handle.close()all attachments + every handle.close()releasing ??= …只释放一次;并发调用共享这个 Promisereleased once; concurrent callers share the promiseallSettled(attachment.operations)先等已准入的调用结算wait for admitted calls to settlelease.release(ctx)租约仍在获取中 → 先等 acquiringstill acquiring → await it firstclearAttachment(…, publish)从 Session 与连接上摘掉;publish 时推送 nulldetach from Session and client; null if publishrelease 失败也一定 clearAttachment(finally)。clearAttachment runs even if release fails (finally).断开 / detach / 切换drop / detach / switchSession 仍被托管;是否退役由应用决定stays hosted; the app decides on retirementterminated从 hostedSessions 删掉;下次 attach 重新打开removed from hostedSessions; reopened on next attach删除 / 关闭服务器removal / shutdownhandle.close():释放 worker 与写入权handle.close(): frees the worker and writer等进行中的连接操作和打开wait for client ops and opens释放每个 Session 的每个附着release every attachment关闭每个 handleclose every handle有失败 → AggregateErrorfailures → AggregateErrorServer.closed 因此 reject。so Server.closed rejects.README:连接断开时,本地的回应立即拒绝,但附着要等已准入的服务调用结算后才释放。README: losing a connection rejects its local responses, but the attachment is released only after admitted service calls settle.1234连接触发connection triggersSession 失效Session failure释放与关闭release and close

要点Key points

  • 连接断开只释放附着,Session 仍被托管;什么时候让 worker 退役由应用决定。A dropped connection only releases the attachment; the Session stays hosted, and the app decides when its worker retires.
  • 句柄终止后 Session 从 hostedSessions 删除;下次 attach 会重新打开它。After a handle terminates, the Session leaves hostedSessions; the next attach reopens it.
  • 释放失败也一定清掉路由(finally),否则连接会永远占着它。The route is cleared even if release fails (finally); otherwise the connection would hold it forever.

工作原理How it works

  1. 在租约的 release(ctx) 里放掉 Session 级资源;它可能在调用仍在完成时被等待。Free Session-level resources in the lease's release(ctx); it may be awaited while calls are still completing.
  2. 用 terminated 报告 worker 意外退出,让客户端尽快看到 attachment: null。Report unexpected worker exits through terminated so clients see attachment: null quickly.
  3. 关闭失败时 server.close() 与 server.closed 以 AggregateError 拒绝。On cleanup failure server.close() and server.closed reject with an AggregateError.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/server/src/session-router.tsL234releaseAttachment()唯一的释放路径the one release path
packages/server/src/session-router.tsL254clearAttachment()摘掉路由clear the route
packages/server/src/session-router.tsL302invalidate()句柄终止handle termination
packages/server/src/session-router.tsL72removeSession()删除前释放release before removal
packages/server/src/session-router.tsL108closeInternal()关闭全部close everything
08Run modes

Unix 监听器The Unix listener#

一句话结论TakeawayUnixListener.start() 建 0700 目录,清理陈旧 socket(还活着就拒绝启动),先在 bind-<sha256 前 8 位> 上监听,记下 socket 的 dev/ino,再用 link() 原子地发布到公共路径、chmod 0600、删掉 bind 路径。关闭时只删除身份仍然匹配的 socket,所以接替它的新服务器不会被误删。UnixListener.start() creates a 0700 directory, cleans up stale sockets (refusing to start if one is alive), listens first on bind-<first 8 hex of sha256>, records the socket's dev/ino, then publishes it atomically to the public path with link(), chmod 0600s it and unlinks the bind path. On close it removes the socket only while its identity still matches, so a server that replaced it is never removed by mistake.

Unix 监听器:私有绑定、硬链接发布、按身份清理The Unix listener: private bind, hard-link publish, identity-checked cleanup
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
The Unix listener: bind privately, publish atomically with a hard link, remove only its own socketUnixListener.start(accept)transports/unix/listener.ts例子:README 的目录Example: the README's directoryremoveStaleSocket / isSocketLiveclose():只删自己的 socketclose(): remove only its own socketUnixByteConnectionmkdir(dir, { recursive, mode: 0o700 })私有目录a private directoryremoveStaleSocket(path) · (bindPath)不是 socket → 拒绝;还活着 → "already running"not a socket → refuse; alive → "already running"listen(bindPath)bind-<sha256(path) 前 8 位>,与公共路径同目录bind-<first 8 hex of sha256(path)>, same directorylstat(bindPath) → { dev, ino }记住自己 socket 的身份remember the identity of its own socketlink(bindPath, path)硬链接发布:原子,且目标已存在时失败publish by hard link: atomic, fails if the target existschmod(path, 0o600)只有属主能连接(Windows 上跳过)owner-only connections (skipped on Windows)unlink(bindPath)只留下公共路径only the public path remains任何一步失败:关闭 net.Server,删掉未发布的 bind 路径,再按身份清理。Any step fails: close the net.Server, remove the unpublished bind path, clean up by identity./run/user/1000/pi/bind-0842f0390f8fad5b-d9cb-469f-a165-70867728950e.sock// ⑤ link 之后两者是同一个 inode,⑦ 删掉 bind// ⑤ after link both are one inode; ⑦ drops bind// getUnixSocketPath(serverId, dir)bind-0842f039 = sha256("/run/user/1000/pi/<id>.sock") 的前 8 位(构建时计算)bind-0842f039 = first 8 hex of sha256 of the public path (computed at build time)连得上connects活着 → 拒绝启动alive → refuse to start1000 ms 没反应no answer in 1000 ms也当作活着(宁可不删)treated as alive too (never delete)ECONNREFUSED · ENOENT · …陈旧:改名 stale-xxxxxx,核对 dev/ino 后才 unlinkstale: rename to stale-xxxxxx, unlink after a dev/ino check改名期间路径被换掉 → 换回去并报错。If the path changed meanwhile, put it back and fail.lstat(path) 的 dev/ino 等于记下的身份 → 改名 cleanup-xxxxxx → 再核对 → unlink;lstat(path) dev/ino equals the remembered identity → rename to cleanup-xxxxxx → recheck → unlink;否则保留(可能是接替它的新服务器的 socket)。otherwise leave it (it may belong to the server that replaced this one).maxPendingBytes 默认 4 × 帧上限,且必须 ≥ 帧上限 + 4;超过 → 断开慢的对端。maxPendingBytes: 4 × the frame limit by default, ≥ limit + 4; beyond → drop the slow peer.close(final):写完队列,end(final);5000 ms 后强制 destroy。close(final): drain the queue, end(final); force destroy after 5000 ms.README:对端认证是应用的策略,实验性的 Unix 传输不做认证——依靠 0700 目录与 0600 socket 的文件权限。README: peer authentication is application policy and not implemented by the experimental Unix transport; it relies on 0700 / 0600 file permissions.1234567

要点Key points

  • 活性探测:连得上或 1000 ms 没反应都算“还活着”——宁可启动失败,也不删别人的 socket。Liveness probe: connecting, or no answer within 1000 ms, both count as alive; better to fail start-up than delete another server's socket.
  • 删除前总是先改名(stale- / cleanup-)再核对 dev/ino:避免与并发的替换竞争。Removal always renames first (stale- / cleanup-) and then checks dev/ino, to avoid racing a concurrent replacement.
  • UnixByteConnection 把排队字节限制在 maxPendingBytes(默认 4 × 帧上限,至少帧上限 + 4),超过就断开慢的对端。UnixByteConnection caps queued bytes at maxPendingBytes (4 × the frame limit by default, at least limit + 4) and drops a slow peer beyond it.

工作原理How it works

  1. getUnixSocketPath(serverId, dir) 得到 <dir>/<serverId>.sock;README 建议用一个短的私有运行时目录。getUnixSocketPath(serverId, dir) gives <dir>/<serverId>.sock; the README recommends a short private runtime directory.
  2. createUnixListener({ path, mode, maxPendingBytes, gracefulCloseTimeoutMs }) 可以和其它监听器一起交给 new Server()。createUnixListener({ path, mode, maxPendingBytes, gracefulCloseTimeoutMs }) can go to new Server() alongside other listeners.
  3. coding-agent:目录默认 ~/.pi/server,可用 PI_SERVER_DIR 覆盖。coding-agent: the directory defaults to ~/.pi/server, overridable with PI_SERVER_DIR.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/server/src/transports/unix/listener.tsL47start()绑定与发布bind and publish
packages/server/src/transports/unix/listener.tsL299removeStaleSocket()陈旧 socketstale sockets
packages/server/src/transports/unix/listener.tsL147cleanupOwnedSocket()按身份清理identity-checked cleanup
packages/server/src/transports/unix/listener.tsL191UnixByteConnection写队列与优雅关闭write queue, graceful close
packages/server/src/transports/unix/address.tsL4getUnixSocketPath()路径约定path convention
09Run modes

错误码与测试工具Error codes and the testing kit#

一句话结论Takeaway能越过协议边界的错误都是 ServerError:五个子类覆盖 wrong_server、session_not_found、session_ambiguous、session_not_attached 和 server_draining,应用也可以用 Chord 的 RemoteServiceErrorCode。其它异常一律变成 internal_error "Internal server error",细节只交给 onError。./testing 提供测试服务器、内存宿主与帧级协议客户端。Errors that may cross the protocol boundary are ServerErrors: five subclasses cover wrong_server, session_not_found, session_ambiguous, session_not_attached and server_draining, and apps may also use Chord's RemoteServiceErrorCodes. Any other exception becomes internal_error "Internal server error", with details only in onError. ./testing provides a test server, an in-memory host and a frame-level protocol client.

ServerError 的五个子类与 ./testing 工具The five ServerError subclasses and the ./testing kit
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Errors that may cross the protocol boundary, and the ./testing kitServerError 的子类(errors.ts)ServerError subclasses (errors.ts)./testing:测试工具./testing: the testing kitsrc/testingwrong_serversession_not_foundsession_ambiguoussession_not_attachedserver_drainingconformance · 20protocol · 6server · 6unix · 6listener · 2unix-connection · 1类Classcode在哪里产生Where it arisesWrongServerError"Request was addressed to another server"handleRequest:在访问任何仓库之前handleRequest: before any repository accessSessionNotFoundError"Session was not found"应用的 resolveSession 抛出thrown by the app's resolveSessionSessionAmbiguousError"Session ID matches more than one session"应用的 resolveSession 抛出(一个 id 对应多个)thrown by the app's resolveSession (one id, several)SessionNotAttachedError"Session is not attached to this client"requireAttachment:路由不匹配或已过期requireAttachment: mismatched or stale routeServerDrainingError"Server is draining"关闭中 attach / 调用 / 删除,或连接已断attach / call / removal while closing, or client gone应用也可以直接 throw new ServerError(code, message):code 还可以是 Chord 的 8 个 RemoteServiceErrorCode 之一。Apps may also throw new ServerError(code, message); code may also be one of Chord's 8 RemoteServiceErrorCodes.其它任何异常 → internal_error "Internal server error",细节只交给 onError。Anything else → internal_error "Internal server error"; details go only to onError.createTestServer(options)未启动的 Server,默认 serverId …0001an unstarted Server, default serverId …0001TestServerHost内存 ServerHostan in-memory ServerHostTestHarness · createTestServerServices假的 Session 与服务器级服务fake Sessions and server-wide servicesProtocolTestClient按帧收发、断言的协议客户端a frame-level client for assertionsconnectUnixTestClient(path)经真实 Unix socket 连接connect over a real Unix socketDeferred<T>手动控制时序control timing by hand传输实现可以拿它跑同一套一致性测试。Transport implementations can run the same conformance tests.测试(共 41 个 test)Tests (41 test cases)conformance.test.ts 覆盖多附着、过期路由、跨服务器请求、未知 / 有歧义的 Session、Harness 终止与重开、关闭时的清理失败。conformance.test.ts covers multiple attachments, stale routes, cross-server requests, unknown / ambiguous Sessions, Harness termination and reopening, and cleanup failures on shutdown.错误errors测试testing

要点Key points

  • wrong_server 在访问任何仓库之前检查:发错服务器的请求不会触发 resolveSession。wrong_server is checked before any repository access: a request for another server never triggers resolveSession.
  • 只有 ServerError 与 RemoteServiceError 的消息会被转发;其它异常的消息和堆栈不会离开服务器。Only ServerError and RemoteServiceError messages are forwarded; other exceptions' messages and stacks never leave the server.
  • conformance.test.ts 是最大的测试文件:多附着、过期路由、未知 / 有歧义的 Session、Harness 终止与关闭时的清理。conformance.test.ts is the largest test file: multiple attachments, stale routes, unknown / ambiguous Sessions, Harness termination and shutdown cleanup.

工作原理How it works

  1. 在 resolveSession 里 throw new SessionNotFoundError() 或 new SessionAmbiguousError()。In resolveSession, throw new SessionNotFoundError() or new SessionAmbiguousError().
  2. const { server, host } = createTestServer({ listeners }) 得到一个未启动的测试服务器。const { server, host } = createTestServer({ listeners }) gives an unstarted test server.
  3. const c = await connectUnixTestClient(path),用 ProtocolTestClient 按帧断言。const c = await connectUnixTestClient(path), then assert frame by frame with ProtocolTestClient.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/server/src/errors.tsL14ServerError可越界的错误boundary-safe errors
packages/server/src/errors.tsL24WrongServerErrorwrong_serverwrong_server
packages/server/src/errors.tsL11INTERNAL_SERVER_ERROR_MESSAGE内部错误的固定消息the fixed internal message
packages/server/src/testing/server.tsL16createTestServer()测试服务器the test server
packages/server/src/testing/client.tsL27ProtocolTestClient帧级客户端frame-level client
10Run modes

扩展点Extension points#

ServerHost应用接入点:serverServices、resolveSession、openSession。The app's entry point: serverServices, resolveSession, openSession.
RoutedSessionHandle · RoutedSessionAttachment把 Session 放在任何地方:同进程、worker 进程或远端。Host a Session anywhere: in process, in a worker process, or remotely.
ServerListener接入新的传输:start(accept) 交出已认证的字节连接。Add a transport: start(accept) hands over authenticated byte connections.
ServerError(code, message)给客户端一个可识别的错误码和消息。Give clients a recognisable error code and message.
./testing用 createTestServer 与 ProtocolTestClient 测试你的宿主或传输。Test your host or transport with createTestServer and ProtocolTestClient.
11Run modes

测试Tests#

一句话结论Takeaway六个测试文件:conformance.test.ts(20 个用例)用 ./testing 覆盖 Session 协议——多附着、过期路由、跨服务器请求、未知 / 有歧义的 Session、Harness 终止与关闭时的清理;protocol.test.ts 覆盖握手与分帧(分片、合并、截断);server.test.ts 覆盖选项校验、并发启动、握手超时与关闭失败;unix*.test.ts 与 listener.test.ts 覆盖 socket 绑定、陈旧清理、身份检查,以及排在待发数据之后的最终错误帧。Six test files: conformance.test.ts (20 cases) uses ./testing to cover the Session protocol: multiple attachments, stale routes, cross-server requests, unknown / ambiguous Sessions, Harness termination and shutdown cleanup; protocol.test.ts covers the handshake and framing (fragmented, coalesced, truncated); server.test.ts covers option checks, concurrent start, the handshake timeout and shutdown failures; unix*.test.ts and listener.test.ts cover socket binding, stale cleanup, identity checks, and a final error frame queued behind pending output.

用例数 = 源码中 it( / test( 的出现次数(静态统计,不运行测试)。Cases = occurrences of it( / test( in the source (static count; tests were not run).

12Run modes

交叉链接Cross-links#