Runs one accepted WebSocket upgrade to completion: handshake, hub
registration, the Open → Message* → Close user-handler lifecycle,
and cleanup.
Called by App::handle_request's WebSocket dispatch branch. Unlike
HTTP handle_request (which runs once per request), this function is
long-lived — it returns only when the peer loop exits (client close,
read timeout, oversized message) or when the enclosing task group is
cancelled (server shutdown).
Lifecycle phases:
- Handshake. @async_websocket.from_http_server validates the upgrade
headers and writes the 101 Switching Protocols response.
InvalidHandshake is swallowed silently — no Open/Close events fire,
the underlying conn closes via defer ws.close(). Other errors
propagate (surfaces to the keep-alive loop, which closes the conn).
- Registration. Allocate a connection_id, create a bounded
outbound queue (Blocking(outgoing_queue_capacity)), register with
the hub. The overflow_policy is recorded in the hub so
ws_publish can drop oldest/latest on a full queue.
- Writer task. write_native_ws_outgoing is spawned as a sibling
inside the task group: it drains outgoing into the socket. It
exits when outgoing.close() fires (normal exit) or when the task
group is cancelled.
- Peer loop. Fire Open once, then loop: recv_native_ws_message
reads a frame header (respecting read_timeout_ms);
read_native_ws_message_contents aggregates continuation frames up
to max_message_bytes (oversized → 1009 close sent internally,
returns None → we break). Each complete message fires Message.
Either helper returning None ends the session.
- Shutdown. outgoing.close() signals the writer task to exit.
handler(Close) fires (see invariants below).
Key invariants:
- Close fires exactly once whenever Open did. Normal exit reaches
the final handler(Close(...)) at the bottom; error/cancellation goes
through the catch block, which calls Close and re-raises. If the
handshake fails (InvalidHandshake), neither Open nor Close fires.
- Cancellation sends a polite GoingAway close frame before the
socket dies. send_native_ws_shutdown_close uses
protect_from_cancel so the frame actually reaches the wire even
though we're already in a cancelled task. Without this, clients would
see an abrupt TCP reset on server shutdown instead of a 1001 close.
- The two defers guarantee cleanup on every path, including user
handler exceptions during Close. defer ws.close() drops the socket;
defer unregister_native_ws_connection removes the hub entry,
channel memberships, and any subscriptions the user added during
Close. Running unregister only on the happy path would leak those
on any exception inside the Close handler.