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_idboth 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_helloandmobile_hellocarry nothing butsession_id(src/protocol.rs:81-85), so anyone who learns the id can take either role. See Security model.
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#
Connect the desktop client to the relay over WebSocket, at any non-reserved path. Send nothing until the socket is open.
Send
desktop_helloas 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_sessioncreates a bridge under that id if it is free and reuses it if a bridge already exists —src/session.rs:112-128.Read
session_ready. This is the canonical fixture the wire tests pin,testdata/session_ready.json:1, asserted atsrc/protocol.rs:172-191:{"type":"session_ready","session_id":"demo-session"}Connect the mobile client and send
mobile_hellowith 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"}Read the pairing frames. The mobile receives
session_readyand thenpeer_connected; the desktop receivespeer_connectedat the same moment. Both sends are issued from the joiner's connection task —src/connection.rs:283-289.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_ready — src/connection.rs:261-266 |
| Mobile hello accepted | The mobile | session_ready — src/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_disconnected — src/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.
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::Desktop — src/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_disconnected —
src/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.
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#
The pairing was accepted. Both clients received
session_ready, and both receivedpeer_connected. If only one side sawpeer_connected, the other side was not registered at the moment the joiner arrived.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.
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.Teardown reaches the survivor. Close the mobile socket. The desktop must receive
peer_disconnected.The server agrees. Run with
RUST_LOG=packet_relay=infoand look for oneClient registeredline per role and aClient disconnectedline per close —src/connection.rs:246-252,src/connection.rs:409-414. Bridge roles log the fullsession_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.
Related#
- Broadcast sessions — the one-to-many protocol, and where its event ordering differs from this one.
- Message reference — every hello and every response, field by field.
- Limits & capacity — the budgets, caps and reaper cadence referenced above.
- Security model — what the relay authenticates, and why a bridge id is a bearer credential.
- Troubleshooting — the failures that are silent by design.
- Your first session — the shortest end-to-end run of the steps above.