Part II Chapter 10: The Blackboard and the Sticky Notes — pi-server + pi-client

Chapter guide: Stop ⑨ on the source route (remote layer). We look at
pi-serverandpi-clienttogether, readingserver/server.ts,server/session-router.ts,client/connection.ts, andclient/client.ts. What this stop has to face head-on is the nastiest problem after a disconnect: did that command actually execute, and how much of the state you get back after reconnecting can you trust?
Bytes can become protocol messages by now. But when Xiao A wired the client to the server, a second problem surfaced: "After I disconnect and reconnect, how do I know whether that command actually executed? How much of the state I get back after reconnecting can I trust?"
"Don't rush to trust it," Lao Z said. "Once a connection is up, three questions have to be answered: how the client confirms the version and the server's identity, when you may believe a snapshot, and what is still guaranteed after a disconnect. We read the client/server layer together. Automatic reconnect is not implemented in the source, and this chapter makes no promise about it."
All source evidence in this chapter comes from the upstream pi tag v1.0.0 (commit a13d35a7, packages/server/src/ and packages/client/src/, package version 1.0.0). As the previous chapter said, the protocol in v1.0.0 keeps only the generic RPC envelope; the business content is service calls defined by @earendil-works/chord, and the server only routes a call to the service the host provides. How a service itself implements sessions, models, and tools is outside the scope of this chapter.
The handshake comes before the request
Xiao A: "So I just send a request as soon as I connect?"
"No," Lao Z said. "packages/server/src/connection.ts defines ConnectionStage = "awaitingHello" | "handshaking" | "ready" | "closing" | "closed". A connection starts in awaitingHello. If the first message is not hello, dispatchMessage goes straight to failProtocol(invalid_request, "The first client message must be hello")."
"After receiving hello it switches to handshaking, and finishHandshake does three things in order: first isSupportedProtocolVersion(hello.version) — an unsupported version fails with version; then it calls the host's serverServices.attachClient(...), which hangs server-level services on this connection and also hands it the three capabilities attachSession, detachSession, and prepareSessionRemoval; finally it sends the server hello, whose content is only version and serverId. Only after that send succeeds does it switch the stage to ready and clear the handshake timeout."
"There is no snapshot in the server hello?" Xiao A asked.
"Not in v1.0.0," Lao Z said. "You get all of the state through service subscriptions — that part comes later. One more detail: if another message arrives while the stage is still handshaking, dispatchMessage does not discard it. It chains that message onto handshake.then(...) and only handles it once the handshake succeeded and the stage is already ready. If the handshake fails, the queued message is never processed and the connection is closed as well."
Xiao A: "Surely the handshake cannot stay pending forever?"
"The timeout is armed right inside accept." Lao Z went back to server.ts. "As the connection is accepted, a setTimeout starts a handshake timeout, 5 seconds by default (DEFAULT_HANDSHAKE_TIMEOUT_MS); when it fires, the connection fails with invalid_request and 'Handshake timeout'. So 'hello arrived but the version was wrong' and 'hello never arrived' are two different deaths: the first reports version immediately, the second drags on until the timeout and then reports invalid_request. failProtocol sets the stage to closing and tries to send one hello_error as the last piece of data alongside close(finalFrame); if encoding it fails, the error is only recorded and never blocks the close."
accept ──> awaitingHello
│ first frame is not hello ──failProtocol(invalid_request)
│ no hello before timeout ──failProtocol(invalid_request)
v
hello arrives ──> handshaking ──version unsupported──> failProtocol(version)
│ ──serverServices.attachClient──> throw ──toProtocolError ──failProtocol
v
send server hello(version, serverId)
│ connection closed meanwhile ──return, stage not set to ready
v
ready (timeout cleared)
│ request ──handleRequest
│ cancel ──handleCancel
│ another hello ──failProtocol("hello may only be sent as the first message")"What about token authentication?" Xiao A asked. "Older material says hello carries a token."
"ClientHello in v1.0.0 has only type and version," Lao Z said. "The comment in listener.ts says the listener hands a connection to the server only after completing whatever authentication the transport requires. Authentication moved out of the protocol and into the transport layer; how the Unix socket listener does it is not covered in this chapter."
On the client side, Connection in packages/client/src/connection.ts maintains a three-state lifecycle, disconnected / connecting / connected, plus an auto-incrementing id. connect() takes ++#sequence to get a new id, and #handleData, onClose, and onError each first check whether that id still matches the current attempt, so a late event from an old connection cannot pollute the new one. Once the transport is established, the client sends hello first; if server data arrives before the hello has been sent, it fails outright.
// packages/client/src/connection.ts, the handshake branch of #handleMessage (v1.0.0, excerpt)
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;
}"Note the third check," Lao Z said. "The client must be given the expected serverId at construction time, and it disconnects when the serverId in the server hello does not match. A physical address may point at a different server; what this step confirms is the logical identity."
Key point: a version mismatch never executes a request
A version mismatch, a first frame that is not hello, and a handshake timeout all end the connection with
hello_error. No service call is entered.
How one request gets routed
Xiao A: "After ready, how does a request find its session?"
"Look at RequestEnvelope: id, target, call." Lao Z opened handleRequest. "Here is the processing order."
handleRequest(envelope)
→ id already in activeRequests ──> response ok:false invalid_request
→ parseServiceCall(call) fails ──> response ok:false invalid_request
→ register an AbortController in activeRequests
→ target.serverId is not this host ──> WrongServerError (wrong_server)
→ target carries sessionId ──> SessionRouter.executeServiceCall
target carries no sessionId ──> this connection's serverServices.invokeService
→ success: response ok:true (may carry result)
→ failure: response ok:false; if already cancelled, code is cancelled
→ finally: remove from activeRequests"A cancel envelope locates the in-flight request by id and target and calls its controller.abort(); if the target does not match, it is ignored. Error codes are normalized through toProtocolError: a ServerError and chord's RemoteServiceError carry their code out unchanged; a protocol validation error becomes invalid_request; any other exception reports only internal_error with the fixed text 'Internal server error', and the details go only into the local onError, never to the peer."
The client's Client.#request is the mirror image: request ids are request-${sequence}, and unfinished requests live in #pendingRequests; when the passed-in AbortSignal fires, the local Promise rejects immediately and a cancel envelope is sent. When a response arrives, it is looked up by id; if the lookup fails, #connection.fail(...) runs. A response with an unknown id is treated as a protocol error and invalidates the connection, rather than being silently ignored.
Attachment: one connection points at at most one session
Xiao A: "So where do the sessionId and attachmentId inside target come from?"
"From attaching." Lao Z opened session-router.ts. "The host's server-level service call presentation.attachSession(sessionId) ends up in SessionRouter.attachClientNow. It first uses acquire to get the hosted session: reuse it if it is already hosted, reuse the same Promise if it is currently opening, and otherwise call the host's resolveSession and openSession. Concurrent attach requests will not open the same session twice."
// packages/server/src/session-router.ts, attachClientNow (v1.0.0, excerpt)
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) acquires the lease …
this.attachmentsByClient.set(client, attachment);
await this.options.publishAttachment(client, { serverId, sessionId, attachmentId: attachment.id }, context);"Three things are settled here. First, attachmentsByClient maps one connection to one attachment, and attaching to a new session releases the old one first. Second, each attach generates a new random attachmentId. Third, once the attach completes the server proactively pushes an attachment envelope, and the client updates client.attachment from it."
"After that, every session request has to pass requireAttachment: the sessionId and attachmentId in the target must match this connection's current attachment exactly, or it throws SessionNotAttachedError," Lao Z said. "So sending a request with a stale attachmentId is rejected even when the session is still the same one."
The snapshot is the blackboard, the updates are sticky notes
Xiao A: "Once I am connected, what exactly is the thing that repaints on screen?"
"You have to separate two kinds of thing," Lao Z said. "In v1.0.0 you get state through service subscriptions. The client calls subscribeService: it first sends a subscribe call, and the result in that response is the complete snapshot; from then on, server-side changes are pushed as deltas in service_update envelopes."
Metaphor: the blackboard and the sticky notes
A snapshot is the blackboard: authoritative, complete, handed to you as a whole at subscribe time. An update is a sticky note: timely, scattered, describing only what it changed on the blackboard. A sticky note must be stuck onto the very blackboard you received; get the order wrong and nothing lines up.
"The crux is ordering." Lao Z opened the subscription branch inside handleRequest. "The service may publish updates before it returns the snapshot. The server parks the updates that arrive while the subscribe response is still pending in pendingUpdates, sends the response out, and then replays them in order. Both the snapshot and the updates are encoded by chord's ServiceStateEncoder: an update is state encoded relative to the snapshot, not a standalone complete state."
client subscribeService ──request(subscribe)──────────> server
the service returns the snapshot; updates arriving meanwhile go into pendingUpdates
client <──response(ok, result=encoded snapshot)──────── server
client <──service_update × n (pending replayed first)── server"The client end has two gates as well," Lao Z continued. "A service_update that arrives before the snapshot is decoded (hydrated is false) is stored as raw bytes first; after the snapshot decodes, those bytes are decoded in order. A decoded update still waits until the caller calls start() before delivery begins — the comment on ServiceSubscription.start says: start ordered delivery after the caller has installed the snapshot. If any single update fails to decode, the connection is dropped as a protocol error."
| Message | Who sends it | Normal semantics | Failure boundary |
|---|---|---|---|
| hello / hello_error | client / server | confirm version and server identity | wrong version, timeout, wrong first frame — connection closes |
| request / response | client / server | complete a Promise via the correlation id | the execution result is unknown when the line drops |
| cancel | client | abort an in-flight request | does not undo side effects that already happened |
| attachment | server | announce the current attachment target or null | requests with a stale attachmentId are rejected |
| service_update | server | incremental update on the snapshot already installed | decode failure disconnects; it cannot rebuild state on its own |
The real boundary of a disconnect
"Back to the question I opened with," Xiao A said. "After a disconnect, does the client reconnect on its own?"
"It does not," Lao Z said. "Client.reconnect() is just another call to connect(), and the caller decides when to make it. On a disconnect, #handleConnectionStateChange does three things: it nulls the attachment, rejects every in-flight request's Promise with a disconnect error, and clears the listeners of all service subscriptions. So after reconnecting you re-handshake, re-attach, and re-subscribe; the attach yields a new attachmentId, and the subscription yields a new snapshot."
"And on the server side? If one connection drops, does its session close with it?"
"Look at it in two layers." Lao Z went back to disconnect in server.ts. "The connection layer: set the stage to closed, call abort(new Error("Client disconnected")) on every in-flight request, clear the subscription encoders for this connection, and release the server-level services. The session layer: SessionRouter.disconnect releases this connection's attachment, and before releasing it runs Promise.allSettled(attachment.operations) to wait for the service calls already running on that attachment."
"The hosted session itself then stays in hostedSessions," Lao Z said. "The router does not close a session just because the last attachment left. A session closes in exactly three situations: the host deletes it via prepareSessionRemoval, the server shuts down, or the session handle's terminated completes. Other clients can still attach to it — that is the precondition for multiple clients sharing a session."
Xiao A: "If a reconnect gives me a snapshot, can I say the command must have executed exactly once?"
"You cannot," Lao Z said. "The disconnect may land right after the server executed the command and before the response arrived. The client only knows its Promise was rejected with a disconnect error; it does not distinguish 'the server never executed it' from 'it executed and the response was lost'. The abort on the server side is only a cancellation signal to the service; side effects that already happened are not rolled back. Retrying a request with side effects needs at least one of an idempotency key, an audit trail, or human confirmation."
"There is another spot people often overlook: when the transport closes normally, the server's transportClosed and the client's #handleClose both call decoder.end()," Lao Z added. "If the local decoder still holds a half frame, end() throws 'Truncated frame at end of stream'. The server records that in its error callback; the client turns it into the disconnect reason. An EOF is not clean by nature."
Benefits, costs, and alternatives
The benefits you can derive from the source are these: the protocol is stateless, the server only does handshake and routing, all business logic is handed to chord services, and a host can swap in a different session implementation without changing the protocol; attachments carry a random attachmentId, so a stale request cannot accidentally land on a new attachment. The cost is that much of the responsibility for reliability is pushed onto the caller: after a disconnect you reconnect, re-attach, and re-subscribe yourself, and the commit status of an in-flight request has to be judged by an idempotency key at the business layer.
A common alternative is to build automatic reconnect and request replay into the client library. It is less work for whoever uses it, but it silently re-sends requests whose commit status is unknown, which is not safe for commands with side effects. The v1.0.0 source has no built-in automatic reconnect; reconnect() must be called explicitly by the caller. From this you can infer that the implementation buys its refusal to guess commit status at the price of more boilerplate.
Summary
The reliability of a remote session rests on explicit protocol stages. The handshake comes before the request: the client sends hello, the server validates the version, hangs the server-level services on the connection, and answers with hello; the client also checks serverId. A version mismatch, a first frame that is not hello, and a handshake timeout all close the connection with hello_error, so nothing runs by accident. Requests complete their Promises by correlation id, and target decides whether one goes to a server-level service or to a session; a session request must carry the attachmentId of the current attachment. State comes from subscriptions: the response delivers the complete snapshot, service_update only applies deltas on that snapshot, and both ends buffer to guarantee snapshot first, updates second.
What this chapter most requires you to restrain is the imagination around "recovery from a disconnect". Read against the source, the path that actually works is: after a disconnect the client clears the attachment, rejects the in-flight requests, and drops the subscriptions; the caller then invokes reconnect() and re-handshakes, re-attaches, re-subscribes. Automatic reconnect, automatic replay of unacknowledged requests, lossless recovery — none of them has an implementation to point at in the source. If the disconnect lands after the server executed and before the response arrived, the client cannot tell from its local Promise whether the request committed, and a retry has to come prepared with a business-level idempotency key, an audit trail, or human confirmation.
The source is full of "boundaries" rather than "guarantees". On the server side, the five ConnectionStage values block illegal message sequences, and the handshake timeout plus failProtocol steer broken connections toward closure; the router pins session requests onto the current attachment with one-connection-one-attachment, a random attachmentId, and requireAttachment. On the client side, the lifecycle id isolates late events from an old connection, and the request table turns an unknown response into a connection-level error. Reading this layer, instead of asking "what does it guarantee", ask "where does it draw the line, and who owns the rebuild after that line".
Source Walkthrough
- In
packages/protocol/src/protocol.ts, findClientMessageSchemaandServerMessageSchema, and list the two shapes ofRpcTarget. - Read
accept,dispatchMessage, andfinishHandshakeinserver.ts, and write out every failure exit on the way fromawaitingHellotoready. - Read
handleRequestand mark wherewrong_server,invalid_request, andcancelledare each produced; then findpendingUpdatesin the subscription branch. - Read
attachClientNow,requireAttachment, anddisconnectinsession-router.ts; explain why a request with a staleattachmentIdis rejected. - Read
subscribeServiceinclient.tsand confirm where updates are stored beforehydratedand beforestart(), respectively. - After dropping the transport, inspect
client.attachmentand the outcome of in-flight requests; do not treat observed UI behavior as evidence of automatic reconnect.
