Files
ds-chat/backend/app/ws/broadcaster.py
T
ksmithandClaude Sonnet 5 6f67f36578 Add WS heartbeat and harden broadcast against dead sockets (#76)
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>
2026-09-23 12:37:58 -06:00

72 lines
3.3 KiB
Python

import json
import logging
import uuid
from redis.asyncio import Redis
from app.ws.connection_manager import ConnectionManager
logger = logging.getLogger(__name__)
ROOM_CHANNEL_PREFIX = "room:"
USER_CHANNEL_PREFIX = "user:"
class Broadcaster:
"""Cross-instance message fan-out (ARCHITECTURE.md phase 5).
Publishes to a per-room or per-user Redis channel; every app instance --
including the one that published -- subscribes via a single pattern
subscription and forwards to its own locally connected WebSocket clients
via ConnectionManager. A single instance just talks to itself through
Redis, so there's no separate code path for the 1-instance vs N-instance
case.
Room channels carry anything scoped to a room's joined members (new
messages, edits, reactions). User channels carry anything scoped to one
person regardless of which rooms they've joined -- currently just
"you've been added to a room," which by definition arrives before the
recipient could ever have joined that room's own channel.
"""
def __init__(self, redis: Redis, manager: ConnectionManager) -> None:
self._redis = redis
self._manager = manager
async def publish(self, room_id: uuid.UUID, payload: dict) -> None:
await self._redis.publish(f"{ROOM_CHANNEL_PREFIX}{room_id}", json.dumps(payload))
async def publish_to_user(self, user_id: uuid.UUID, payload: dict) -> None:
await self._redis.publish(f"{USER_CHANNEL_PREFIX}{user_id}", json.dumps(payload))
async def listen(self) -> None:
pubsub = self._redis.pubsub()
await pubsub.psubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
try:
async for message in pubsub.listen():
if message["type"] != "pmessage":
continue
# #76: this is the one pubsub listener for the whole process
# (see main.py's lifespan) -- ConnectionManager.broadcast/
# send_to_user already guard against one dead socket taking
# the rest of a single fan-out down, but this belt-and-
# suspenders catch is for anything else unexpected (a
# malformed payload, e.g.) doing the same. An uncaught
# exception here previously meant *this whole async for*
# loop died silently -- no more live delivery to any room on
# this instance, for anyone, until the process restarted.
try:
channel = message["channel"]
payload = json.loads(message["data"])
if channel.startswith(ROOM_CHANNEL_PREFIX):
room_id = uuid.UUID(channel.removeprefix(ROOM_CHANNEL_PREFIX))
await self._manager.broadcast(room_id, payload)
elif channel.startswith(USER_CHANNEL_PREFIX):
user_id = uuid.UUID(channel.removeprefix(USER_CHANNEL_PREFIX))
await self._manager.send_to_user(user_id, payload)
except Exception:
logger.error("Error handling pubsub message on channel %s", message.get("channel"), exc_info=True)
finally:
await pubsub.punsubscribe(f"{ROOM_CHANNEL_PREFIX}*", f"{USER_CHANNEL_PREFIX}*")
await pubsub.aclose()