Private
Public Access
Messages could silently stop arriving until a room switch happened to paper over it with a REST refetch. Root cause: a connection that dies without a clean close (laptop sleep, an idle connection recycled by a proxy/NAT) looks identical to a healthy-but-quiet one -- readyState stays OPEN, onclose never fires, so the already-correct reconnect and rejoin logic never runs. The backend now pings every 30s and the frontend force-closes the socket if nothing (any frame, not just a pong) arrives within 65s, routing straight through the existing reconnect/rejoin path instead of needing separate recovery logic. Also found and fixed a more severe adjacent bug while tracing this: ConnectionManager.broadcast()/send_to_user() had no per-socket error handling, so one dead or racing socket could raise inside the app's single, never-restarted Redis pubsub listener task and silently kill live delivery for every room and every user on that process until restart. Now caught and logged per-socket instead of propagating. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
68 lines
2.8 KiB
Python
68 lines
2.8 KiB
Python
import logging
|
|
import uuid
|
|
from collections import defaultdict
|
|
|
|
from fastapi import WebSocket
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class ConnectionManager:
|
|
"""Local, single-process WebSocket socket registry.
|
|
|
|
Purely about delivering to sockets connected to *this* process --
|
|
cross-instance fan-out lives in Broadcaster, and cross-instance "who's
|
|
connected" for push lives in Presence, both backed by Redis
|
|
(ARCHITECTURE.md phase 5).
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self._rooms: dict[uuid.UUID, set[WebSocket]] = defaultdict(set)
|
|
self._users: dict[uuid.UUID, set[WebSocket]] = defaultdict(set)
|
|
|
|
def join(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
|
|
self._rooms[room_id].add(websocket)
|
|
|
|
def leave(self, room_id: uuid.UUID, websocket: WebSocket) -> None:
|
|
self._rooms[room_id].discard(websocket)
|
|
if not self._rooms[room_id]:
|
|
del self._rooms[room_id]
|
|
|
|
def leave_all(self, websocket: WebSocket) -> None:
|
|
for room_id in list(self._rooms.keys()):
|
|
self.leave(room_id, websocket)
|
|
|
|
def register_user(self, user_id: uuid.UUID, websocket: WebSocket) -> None:
|
|
"""Ties a socket to the authenticated user who owns it, independent
|
|
of which (if any) room it has joined -- lets a user be reached the
|
|
instant they're added to a room, before they've ever joined that
|
|
room's channel."""
|
|
self._users[user_id].add(websocket)
|
|
|
|
def unregister_user(self, user_id: uuid.UUID, websocket: WebSocket) -> None:
|
|
self._users[user_id].discard(websocket)
|
|
if not self._users[user_id]:
|
|
del self._users[user_id]
|
|
|
|
async def broadcast(self, room_id: uuid.UUID, payload: dict) -> None:
|
|
for websocket in list(self._rooms.get(room_id, ())):
|
|
# #76: one dead/racing socket (e.g. its own disconnect cleanup
|
|
# hasn't finished removing it from _rooms yet) must not take the
|
|
# rest of this broadcast down with it -- this runs inside
|
|
# Broadcaster.listen()'s single, never-restarted per-process
|
|
# pubsub loop, so an unhandled exception here previously meant
|
|
# one bad socket silently killed *live delivery for every room
|
|
# on this instance*, not just the one socket, until the process
|
|
# was restarted.
|
|
try:
|
|
await websocket.send_json(payload)
|
|
except Exception:
|
|
logger.warning("Dropping dead socket during broadcast to room %s", room_id, exc_info=True)
|
|
|
|
async def send_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
|
|
for websocket in list(self._users.get(user_id, ())):
|
|
try:
|
|
await websocket.send_json(payload)
|
|
except Exception:
|
|
logger.warning("Dropping dead socket sending to user %s", user_id, exc_info=True)
|