PacketRelayDocs

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).

Warning

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.

Note

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.

Important

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.