Core concepts#
PacketRelay has a small vocabulary — session, session id, session kind, role, protocol — and every one of those words is doing precise work. Most confusion about the relay comes from collapsing two of them together, usually by assuming that the URL a client dials decides what kind of session it joins. It does not. The first message does.
This page defines the vocabulary once, says which layer owns each thing, and sets out the rules that follow from those definitions. It describes no procedure; the guides do that.
A session is a routing table entry, nothing more#
A session is one entry in an in-memory map from session_id to a set of
outbound channels (session.rs:93). It has no owner, no metadata, no access
list and no history. It holds the channels of whoever is currently connected
under that id, plus a created_at timestamp, and that is the whole object.
Sessions are created implicitly by the first hello that needs one, and they are
destroyed the moment their last participant unregisters. Nothing about a
session is written down: it exists only while the process that made it is
running. The global map is capped — 256 sessions by default, enforced
atomically under the same write lock that performs the creation, so a burst of
concurrent first-hellos cannot overshoot the ceiling and instead receives
"server at capacity" (session.rs:130-132).
A background reaper sweeps stale sessions on a --reaper-interval cadence, and
its notion of staleness is age, not idleness. age is measured from
created_at and is never refreshed by traffic (session.rs:137-147), so a
long-lived session is reaped on schedule regardless of how busy it has been.
The bridge rule is the sharpest instance: a bridge is reaped once its mobile
slot is empty and its age exceeds the TTL, even if the desktop socket is
still open (session.rs:459-461). That ambiguity is recorded as open work in
the project backlog rather than resolved.
The session id is supplied by the client, never issued#
Every session id arrives in a hello. No code path in src/ mints one: the
relay validates ids and stores them, and the only randomness in the crate lives
in a test module. A client that wants a session picks the id itself.
Validation is identical for session_id and, in rooms, for member_id — the
id must be non-empty, at most --max-id-bytes (64 by default), and made
entirely of ASCII alphanumerics plus -, _ and .
(connection.rs:472-478). A failure returns
{"type":"error","message":"invalid session ID"}, or "invalid member ID" for
the member field, and then the handler returns. The conservative charset exists
to keep control characters and oversized ids out of the roster and the logs
before they can reach either.
For a room, the session id is a capability secret#
For a bridge or a broadcast, possession of the id is the entire admission boundary: anyone who knows it and picks a role is in. For a room, the id is necessary but not sufficient — a valid signature is checked as well — and it is still treated as a secret, because knowing it is what puts a caller at the door where that check happens.
That status is visible in the logging layer. log_session emits a room id as
its first six characters plus …(len=N); every other role logs the full id
(connection.rs:549-559). The redaction is deliberate and one-directional: a
log stream that is readable by more people than the room is, must not hand them
the room.
Session kinds are exclusive#
A session kind is one of three values — Bridge, Broadcast, Room
(session.rs:23-27) — and it is a property of the session, not of the
connection. A role determines the kind it needs through a total mapping
(session.rs:29-36), and ensure_session compares the requested kind against
any existing entry, returning "session kind mismatch" when they differ
(session.rs:118-128).
One session id therefore has exactly one kind for as long as it exists. There is no upgrade path and no coexistence: an id in use as a broadcast cannot also carry a bridge, even though the two populations never overlap.
| Kind | Created by | Roster | Removed when |
|---|---|---|---|
Bridge |
desktop_hello — a mobile_hello requires the session to exist already |
One desktop, one mobile | Both slots empty; or reaped when the mobile slot is empty and age exceeds the TTL |
Broadcast |
Either host_hello or spectator_hello — a spectator auto-creates |
One host, spectators up to --max-spectators (64) |
Host absent and spectators empty |
Room |
The first room_hello that passes verification |
Members up to --max-room-members (32) |
Roster empty — removal also drops every TOFU pin for that room |
The mobile case leaks the seam. exists() checks only for key presence and
never the kind (session.rs:479-481), so a mobile_hello against a broadcast
or room id passes the "session not found" gate and then fails one step later
at register_bridge with "session kind mismatch" (session.rs:165). The
outcome is correct; the error the client sees names the second failure, not the
first.
Five roles, assigned by the hello#
The role is chosen inside read_hello by which RelayHello variant
deserialises (connection.rs:502-524), and it determines everything the
connection may afterwards do.
| Role | Hello | Kind | What its text frames do | What it receives |
|---|---|---|---|---|
Desktop |
desktop_hello |
Bridge |
Forwarded verbatim to the mobile | session_ready, peer_connected, peer_disconnected, mobile frames verbatim |
Mobile |
mobile_hello |
Bridge |
Forwarded verbatim to the desktop | session_ready, peer_connected, peer_disconnected, desktop frames verbatim |
Host |
host_hello |
Broadcast |
Fanned out verbatim to every spectator | session_ready, viewer_count |
Spectator |
spectator_hello |
Broadcast |
Nothing — silently discarded | session_ready, host_connected, host_disconnected, viewer_count, host frames verbatim |
Member |
room_hello |
Room |
Wrapped as member_frame and sent to every other member |
room_ready, member_joined, member_left, member_count, other members' member_frames |
Two asymmetries in that table are load-bearing. There is no Role::Spectator
branch in the forwarding loop at all (connection.rs:389-394), so a spectator
that sends a frame gets no error, no close and no delivery — the frame is
charged to its message budget and dropped. And a member never receives its own
frame (session.rs:260-275), so a room client cannot use the relay as an echo
to confirm its own send.
Reconnection is role-shaped too. A desktop, host or member that reconnects
replaces the stored channel rather than creating a second slot
(session.rs:169-175, session.rs:202-206, session.rs:227-256), whereas a
second mobile on a live bridge is refused with "mobile already connected".
Every unregister path is ownership-aware — it removes a slot only when the
stored channel is the same channel — so a replaced connection's late cleanup
cannot evict its successor or emit a spurious peer_disconnected
(session.rs:299-331, session.rs:378-451).
Three protocols, one handler#
| Protocol | Shape | Frame handling | Authenticated |
|---|---|---|---|
| Bridge | One-to-one | Verbatim copy to the current peer | No — session_id only |
| Broadcast | One-to-many | Verbatim fan-out from the host only | No — session_id only |
| Room | Authenticated N:N | Wrapped as {"type":"member_frame","member_id":…,"payload":…} to every other member |
Yes — ECDSA P-256, ±30 s nonce window, TOFU pin |
All three run through the same connection handler and the same admission
sequence: read one hello against the --hello-timeout deadline, validate the
ids, register, send the ready envelope, then loop. The first frame must be
text; a binary first frame is refused and the connection closed
(connection.rs:530-533).
A connection that fails at the hello — timeout, WebSocket error,
close before hello, or unparseable JSON — is logged and dropped with no
error frame at all (connection.rs:491-546). Even where an error frame is
sent, no WebSocket Close frame carrying a status code is ever sent: the sink
is simply dropped. A client that expects a close code to explain a rejection
will wait forever.
Hello structs do not set deny_unknown_fields
(protocol.rs:76-80), so extra fields in a hello are accepted and silently
ignored. A client that misspells a field name sees a success, not a
complaint.
Route selection is by hello, not by path#
The listener reserves exactly four targets — /health, /healthz, /ready,
/readyz — and returns 404 for near misses such as /health?detail=1
(main.rs:474-486). Every other target, / and /ws/host and
/v1/product-route alike, falls into the single Legacy route and attempts a
WebSocket upgrade into the same handler (main.rs:485). A test pins
/v1/product-route to Legacy precisely so that a removed path cannot quietly
grow a special case again (main.rs:610-613).
The protocol is then selected entirely by the first application message
(connection.rs:502-524). Two consequences follow, and both bite in practice.
A client that connects to a "wrong" path still works, so a path typo produces
no diagnostic. And a reverse proxy that routes or authorises by path provides
no protocol isolation whatsoever — every path that reaches the relay reaches
the same handler with the same five hellos available.
Do not treat a URL path as a security boundary or as a protocol declaration in front of this relay. Path-based rules at the edge constrain nothing about which hello a connection may send once the upgrade completes.
Opaque means routed, not read#
The relay inspects the text of a frame in exactly three places, and none of
them is the payload. It parses the first frame as JSON to pick a role. It tests
every subsequent text frame against is_relay_keepalive_ping — under 64 bytes,
valid JSON, an object with exactly one key, that key "type" with the value
"ping" — and drops the frame if it matches (connection.rs:480-488). And for
rooms it embeds the raw text as a JSON string inside a member_frame
envelope.
Beyond that the payload is never parsed and never decrypted, because the relay
has no key with which to decrypt it. Encryption between endpoints is a client
responsibility (main.rs:4-5). The security consequence is worth stating
plainly: a room's member_frame carries a member_id the relay itself
authenticated, but any identity a client writes inside the payload is
self-asserted and the relay neither reads nor checks it
(protocol.rs:146-150).
Opacity has a cost on the other side too. A keepalive {"type":"ping"} is
dropped rather than forwarded, but it is charged to the connection's message
budget first, because the charge happens once per inbound text frame before any
role dispatch (connection.rs:346). Binary frames, by contrast, are discarded
without being charged at all (connection.rs:405).
Where each concept lives#
| Concept | Owned by |
|---|---|
| Routes, request-head parsing, WebSocket upgrade, frame ceilings | main.rs |
| Connection admission per IP and globally, per-connection message budget | rate_limit.rs |
Hello and response envelopes, the jarvis-room-hello-v1 canonicalisation |
protocol.rs |
| Role assignment, id validation, the forwarding loop, keepalive, close | connection.rs |
| Sessions, session kinds, rosters, fan-out, the reaper | session.rs |
| Member identity — signature verification, fingerprint binding, TOFU pins, nonce high-water marks | room_auth.rs |
Every one of those is process-local. None of it is written to disk or shared with another instance.
Related#
- How a connection works — the same concepts in the order a single connection meets them.
- Message reference — every hello and every response, field by field.
- Room authentication — the signing payload, the nonce
window and the pinning rules behind
Role::Member. - Limits & capacity — the caps, budgets and reaper cadence that bound every session.
- Security model — what possession of a session id actually grants, per kind.