Connection lifecycle
Authentication at the upgrade, the hello handshake, heartbeat and close, and why reconnects are lossy.
CLOSED ──dial──▶ AUTHENTICATING ──upgrade ok──▶ HELLO ──hello/hello_ack──▶ READY
▲ │ HTTP 401 │ unsupported_version → CLOSED (4400)
│ ▼ │
└────────────── CLOSED ◀── CLOSING ◀── zyris.closing / ws close / heartbeat timeout
reconnect with resume_token within grace ⇒ presence continuityAuthentication
The dialer sends Authorization: Bearer <credential> on the websocket upgrade request. A failure
refuses the upgrade with HTTP 401 before either side has spoken the protocol — there is no
websocket to close yet.
What a valid credential looks like is the deployment's choice; the wire only requires that it ride in
that header. On Attacca it is a zc_ credential — see Credentials.
The socket is authenticated once, at the upgrade, and nothing re-checks it. A deployment that revokes
credentials needs an in-band way to act on it. The recommended shape, and Attacca's: the periodic
heartbeat also reports whether the node was revoked, and the peer holding the socket closes it with
websocket code 4401 when it was.
Handshake
The first frame in each direction is the handshake. It is always msgpack, whatever the negotiation settles on.
// dialer → acceptor
{ "t": "hello",
"protocol": { "major": 1, "minors_supported": [0] },
"serialization": ["msgpack", "json"],
"agent": "zyris/0.2.0 (myrepo; service)",
"kind": "service", // optional
"node_name": "myrepo", // optional
"features": ["cancel", "attachments", "video-webrtc", "video-mjpeg"],
"resume": { "conn_id": "…", "resume_token": "…" } } // optional
// acceptor → dialer
{ "t": "hello_ack",
"protocol": { "major": 1, "minor": 0 },
"serialization": "msgpack",
"conn_id": "…", "resume_token": "…",
"node_id": "…",
"node": { "system": "laptop", "program": "zyris-code", "name": "myrepo-2" }, // optional
"heartbeat": { "interval_s": 20, "timeout_s": 45 },
"limits": { "max_control_frame": 1048576, "max_chunk": 262144,
"max_inflight_reqs": 64, "initial_stream_credit": 262144 },
"resumed": false,
"features": ["cancel", "attachments"] }- Version. A major-version mismatch is answered with
err unsupported_version, then websocket close4400. - Features. A feature is usable only when it appears in both lists. A peer MUST NOT send
frames gated behind a feature the other side did not advertise. An acceptor that predates the
featuresfield advertises nothing, which is the safe reading. node_nameis the name this connection asks to be known by. It is optional on the wire, but a deployment that names nodes may require it: Attacca answers a non-clihello without one witherr invalid_paramsand closes. Akind: "cli"dialer is a consumer that registers no node, and itsnode_nameis ignored.nodeis where the acceptor placed the connection: three slugs, written as the pathsystem/program/name. The name is the requested one if it is free among live nodes at thatsystem/programpath, and the lowest free-2,-3… otherwise — so read it back rather than assuming it. It is absent for aclidialer.
The Rust runtime never sends hello.resume. Instead, a node_name that matches a node of the
same credential released within the grace, or gone stale, takes that node back by name, keeping
its node_id and node — so a crash does not turn desktop into desktop-2. Any other connect is a
new node with a new node_id.
Heartbeat and close
Websocket ping/pong is the liveness primitive. The acceptor pings every interval_s; either side
closes after timeout_s of silence.
A graceful close:
- Send
note zyris.closing { reason }. - Stop issuing new requests.
- Finish or cancel in-flight work.
- Close the websocket with code
1000.
A server shutting down for a rolling restart sends reason: "server_restart", so nodes reconnect
immediately instead of backing off.
Reconnect is deliberately lossy
On disconnect, every in-flight request on both sides fails locally with connection_lost
(retriable), and every open stream is dead.
Reconnecting with a valid resume_token within the grace window (30 seconds) restores identity
continuity only: the node is never marked offline, and its capability announcement is assumed
unchanged (resumed: true). Otherwise the node MUST announce again.
Resuming interrupted work is an application concern, by design: turn streams resume by cursor, file transfers by byte offset, PTYs by reopening and redrawing. Streaming tools MUST document how they resume.
Presence
Enrollment and identity are out of band — they happen over HTTP before the websocket exists — and are described in Concepts and Credentials.
On the wire, a node exists while its connection does. When it drops, a resume grace runs (30 seconds on Attacca); after it, the node is gone and its name is free. A periodic sweep removes nodes whose last heartbeat is older than twice the heartbeat interval, as a backstop against a server replica dying with a socket open.