CRUMB a card from devarno-cloud

WebSocket Session Lifecycle

chronicle intermediate 5 min read

ELI5

Each WebSocket connection is a person in a soundproof booth. The relay only un-mutes their headset once they’ve shown ID (authenticated) AND signed into a specific room (document_joined). Until both flags flip true, broadcasts arrive at the booth but are not played.

Technical Deep Dive

SessionState

SessionState (relay/src/handlers/websocket.rs) holds per-connection mutable state behind Arc<Mutex<...>>:

FieldSet when
connection_idAt upgrade
document_idFrom URL path /ws/documents/:id
user_id, user_name, session_idAfter auth_result success
colorFrom WsParams.color (or hashed default after auth)
authenticatedFlipped by handshake
document_joinedFlipped by JoinDocument

is_authenticated() requires both authenticated == true AND user_id.is_some().

Channels

On upgrade the connection subscribes to two tokio::sync::broadcast channels obtained from AppState:

  • document_channel(document_id) — buffer 1024, used for operations + sync.
  • presence_channel(document_id) — buffer 256, used for cursor + join/leave.

A separate mpsc::channel::<Vec<u8>>(32) (response_tx) carries direct replies (auth_result, joined_document, snapshots). The send task multiplexes all three with tokio::select!.

Lifecycle

stateDiagram-v2
[*] --> Upgraded: ws.on_upgrade
Upgraded --> Pending: register Connection (Connecting)
Pending --> Authenticated: authenticate ok
Pending --> [*]: authenticate fail / drop
Authenticated --> Joined: join_document
Joined --> Authenticated: leave_document
Authenticated --> [*]: socket close
Joined --> [*]: socket close
note right of Joined
broadcasts (doc_rx, presence_rx)
delivered only here
end note

Send-Path Filter

sequenceDiagram
autonumber
participant Doc as broadcast(document_channel)
participant Pres as broadcast(presence_channel)
participant Resp as mpsc(response_tx)
participant Send as send_task
participant Client
par Direct response
Resp->>Send: bytes
Send->>Client: Text(bytes)
and Document broadcast
Doc->>Send: bytes
Send->>Send: lock SessionState
alt is_authenticated && document_joined
Send->>Client: Binary(bytes)
else
Send->>Send: drop
end
and Presence broadcast
Pres->>Send: bytes
Send->>Send: lock SessionState
alt is_authenticated && document_joined
Send->>Client: Binary(bytes)
end
end

Cleanup

When the socket closes (either side), ConnectionManager::unregister_connection removes the connection and decrements active stats; the presence service should also leave the document but that is performed at the message layer.

Key Terms

  • send_task → spawned tokio::select! loop that owns the WebSocket sender half; the only place authoritative auth/joined gating happens.
  • doc_rx / presence_rx → broadcast subscriber receivers; their backpressure semantics are “lag = drop oldest”, which is acceptable because clients can sync_request to recover.
  • response_tx → why direct responses (e.g. auth_result) bypass the broadcast filter — they are addressed to one connection and sent before document_joined is true.

Q&A

Q: Why split direct responses onto an mpsc channel instead of broadcasting them? A: Replies like auth_result must reach the client before any gating flag is set; routing them through the broadcast filter would deadlock the handshake.

Q: What is the message format on the broadcast channels — text or binary? A: The send task sends them as Message::Binary. Direct responses go out as Message::Text (UTF-8 JSON).

Q: Is the Connection registered before or after authentication? A: Before — registered in Connecting state immediately after upgrade so peak_concurrent_connections is accurate.

Examples

A subtle bug class: a client that authenticates but forgets join_document will see ping/pong work and auth_result arrive, but every operation broadcast silently disappears — the gate is doing exactly its job.

neighbors on the map