PacketRelayDocs

Bridge sessions#

A bridge is PacketRelay's one-to-one session: one desktop client, one mobile client, and every text frame either side sends handed to the other unchanged. The two roles are asymmetric on purpose — a desktop hello creates the session, a mobile hello may only join one that already exists, so a mobile client can never bring a bridge into being for a machine that is not listening. Bridge sessions are Implemented and are the protocol a plain WebSocket connection reaches by default.

This page opens a bridge step by step, lists every frame the relay emits and in what order, and documents the four edges that behave differently from how they read.

Before you start#

  • A relay reachable over WebSocket. See Run the relay.
  • Two WebSocket clients that can send and receive text frames. The first application message must be text; a binary first frame is refused and the connection closed — src/connection.rs:530-533.
  • A session_id both clients already agree on: non-empty, at most --max-id-bytes (default 64) bytes, and drawn from [A-Za-z0-9._-]src/connection.rs:41-50, src/connection.rs:472-478.
  • An understanding that possession of that id is the entire admission check. desktop_hello and mobile_hello carry nothing but session_id (src/protocol.rs:81-85), so anyone who learns the id can take either role. See Security model.
Important

The URL path does not select the protocol. Any target that is not /health, /healthz, /ready or /readyz upgrades to the same handler, and the session kind is chosen solely by the first application message — src/main.rs:485. Connecting to /bridge and connecting to / are the same thing.

Steps#

  1. Connect the desktop client to the relay over WebSocket, at any non-reserved path. Send nothing until the socket is open.

  2. Send desktop_hello as the first frame, within --hello-timeout (default 5 s). Miss the deadline and the connection is dropped with no error frame at all — src/connection.rs:491-546.

    {"type":"desktop_hello","session_id":"demo-session"}

    ensure_session creates a bridge under that id if it is free and reuses it if a bridge already exists — src/session.rs:112-128.

  3. Read session_ready. This is the canonical fixture the wire tests pin, testdata/session_ready.json:1, asserted at src/protocol.rs:172-191:

    {"type":"session_ready","session_id":"demo-session"}
  4. Connect the mobile client and send mobile_hello with the same id. Unlike the desktop, the mobile creates nothing — the relay checks that the session already exists and refuses with "session not found" if it does not — src/connection.rs:84-96.

    {"type":"mobile_hello","session_id":"demo-session"}
  5. Read the pairing frames. The mobile receives session_ready and then peer_connected; the desktop receives peer_connected at the same moment. Both sends are issued from the joiner's connection task — src/connection.rs:283-289.

  6. Send application frames. Anything either side writes as a text frame is forwarded to the current peer byte-for-byte.

What the relay sends, in order#

Moment Goes to Frame
Desktop hello accepted The desktop session_readysrc/connection.rs:261-266
Mobile hello accepted The mobile session_readysrc/connection.rs:261-266
Mobile joins a session whose desktop is present The mobile, then the desktop peer_connected to both — src/connection.rs:283-289
Either socket closes, errors, or breaches its budget The surviving peer peer_disconnectedsrc/connection.rs:422-428

peer_connected and peer_disconnected carry no fields at all — src/protocol.rs:119-123. They tell a client that its counterpart's state changed, not which counterpart, and not why.

The joiner's own session_ready always precedes its peer_connected, because the ready send at src/connection.rs:266 runs before the notify block at src/connection.rs:283. Broadcast does not share that ordering — see Broadcast sessions.

Forwarding is verbatim#

The relay parses the hello and nothing after it. Every subsequent text frame is handed to the peer's channel as the same string that arrived — src/connection.rs:357-366. There is no envelope, no sender attribution, and no schema check beyond the WebSocket frame ceiling. A bridge peer therefore cannot tell from the wire whether a frame came from its original counterpart or from a client that replaced it, which is why the desktop-reconnect edge below matters.

Payloads are not encrypted by the relay. End-to-end encryption is a client responsibility — src/main.rs:4-5.

The keepalive is dropped, never forwarded#

The exact envelope {"type":"ping"} is recognised and discarded rather than relayed — src/connection.rs:361. Recognition is strict: the frame must be under 64 bytes, valid JSON, an object with exactly one key, and that key must be "type" with the value "ping"src/connection.rs:480-488. The tests pin the boundary, and {"type":"ping","id":1} is deliberately not a keepalive — src/connection.rs:611-614. It is forwarded like any other frame.

Note

A keepalive is charged to the sender's message budget before role dispatch, even though it is then thrown away — src/connection.rs:346. A client pinging hard spends its --max-messages-per-window allowance on frames that reach nobody.

Sharp edges#

Each of these is current behaviour, not a plan to change it.

A desktop reconnect replaces the incumbent with no proof of identity#

register_bridge assigns session.desktop_tx = Some(tx) unconditionally for Role::Desktopsrc/session.rs:169-175. A second desktop_hello carrying the same session_id takes over the routing slot, and from that moment the mobile's frames go to the newcomer. Nothing is verified, because the hello carries nothing to verify.

The eviction is silent in both directions. The mobile is not told its desktop changed; it receives a second peer_connected with no intervening peer_disconnected, because the notify block runs for a reconnecting desktop exactly as it does for a first one. The displaced desktop is not told either — its socket stays open and its frames are still accepted and charged, they just arrive at a peer slot it no longer owns.

When that displaced connection finally closes, its cleanup is a no-op: every unregister path requires same_channel(tx), so a replaced connection cannot clear its successor's slot or emit a false peer_disconnectedsrc/session.rs:378-382, pinned by the test at src/session.rs:617-647. That invariant is what stops a stale disconnect from tearing down a live bridge. It is not an identity check, and it does not run until the old socket closes.

Warning

Treat the session_id as a bearer credential with takeover rights. Anyone who learns it can silently become the desktop of a live bridge and receive everything the mobile sends next.

A second mobile is rejected, not queued#

The mobile slot is guarded: if mobile_tx is already occupied, register_bridge returns "mobile already connected" and the new connection is closed — src/session.rs:176-181. The asymmetry with the desktop slot is the point. An incumbent mobile keeps its session, so a duplicate mobile cannot hijack a bridge the way a duplicate desktop can. The cost is the mirror image: a mobile whose socket has half-died holds the slot until the relay observes the close, and every reconnect attempt fails with this message until it does.

exists() checks the key, not the kind#

The mobile precondition calls store.exists(&session_id), which is a bare contains_key on the session map — src/session.rs:479-481. It does not look at what kind of session lives under that key. A mobile_hello aimed at an id already held by a broadcast or a room therefore passes this check, gets past "session not found", and fails one step later inside register_bridge with "session kind mismatch"src/session.rs:165.

The failure mode is a misleading error rather than a wrong admission: the id is occupied by a different protocol, and the client is told about session kinds when what it needs to know is that it has the wrong id. One session_id is one kind for as long as the session lives — src/session.rs:118-128.

The reaper deletes a bridge whose desktop is still connected#

Every --reaper-interval (default 30 s) the store drops any bridge where mobile_tx.is_none() and the session is older than --session-ttl (default 300 s) — src/session.rs:454-476, specifically src/session.rs:459-461. The desktop socket is not consulted. A desktop that connects and then waits more than five minutes for a mobile has its session removed out from under it while its WebSocket stays open and healthy.

Session age makes this sharper: age is measured from created_at and is never refreshed by activity — src/session.rs:137-147. A bridge that has been passing traffic for an hour is eligible the moment its mobile disconnects, because the clock has been running since creation rather than since the last frame. The desktop learns nothing about it — it holds a live socket against a session id that no longer resolves, and the next mobile_hello for that id fails with "session not found".

Design around it by treating a failed pairing as a signal to reconnect and re-send desktop_hello, rather than assuming a long-lived socket implies a long-lived session. The ambiguity is recorded as open work in the relay's own backlog; it has not been changed.

Verify#

  1. The pairing was accepted. Both clients received session_ready, and both received peer_connected. If only one side saw peer_connected, the other side was not registered at the moment the joiner arrived.

  2. A frame crosses, unchanged. Send a text frame from the mobile and confirm the desktop receives identical bytes, then send one the other way. Verbatim means identical — any transformation you observe is your client's.

  3. The keepalive does not cross. Send exactly {"type":"ping"} from the mobile; the desktop must receive nothing. Then send {"type":"ping","id":1}; the desktop must receive it unchanged. That pair separates a working keepalive filter from a dead forwarding path, which otherwise look the same.

  4. Teardown reaches the survivor. Close the mobile socket. The desktop must receive peer_disconnected.

  5. The server agrees. Run with RUST_LOG=packet_relay=info and look for one Client registered line per role and a Client disconnected line per close — src/connection.rs:246-252, src/connection.rs:409-414. Bridge roles log the full session_id; only room members get a truncated one — src/connection.rs:549-559.

If it does not work#

What you see What happened
{"type":"error","message":"session not found"} A mobile_hello arrived for an id no session holds — the desktop never connected, or the session was reaped. src/connection.rs:84-96
{"type":"error","message":"mobile already connected"} The mobile slot is occupied and the relay has not yet observed the incumbent's close. src/session.rs:176-181
{"type":"error","message":"session kind mismatch"} The id belongs to a broadcast or room session. src/session.rs:118-128, src/session.rs:165
{"type":"error","message":"invalid session ID"} The id was empty, over --max-id-bytes, or carried a character outside [A-Za-z0-9._-]. src/connection.rs:41-50
{"type":"error","message":"server at capacity"} The global session ceiling --max-sessions (default 256) is reached. src/session.rs:130-132
{"type":"error","message":"message rate exceeded"} or "message byte rate exceeded" The per-connection budget tripped; the relay sends the error and then closes the socket. src/rate_limit.rs:220-225
Frames sent, no error, nothing arrives Either the frame was exactly {"type":"ping"} and was dropped, or the peer slot has been taken over by a reconnecting desktop.
The socket closes with no frame of any kind The hello deadline expired, the first frame was binary, or the connection-rate limiter dropped the TCP stream before a byte was read. src/connection.rs:491-546, src/main.rs:365-369
HTTP 503, body route capacity reached The Legacy-route semaphore sized --max-connections is exhausted. src/main.rs:409-412

No refusal carries a WebSocket Close status code. The relay sends an error text frame where it has one to send and then drops the sink — src/connection.rs:34-65. A client that inspects only close codes sees every row above as an unexplained disconnect.