fix: decouple browser broadcast from plugin WebSocket receive loop

🧝‍♂️ Fix 1: The Mailman Story

Imagine you're a mailman delivering letters. Before, you had to
knock on every door and wait for someone to answer before you could
go back to your truck and get more letters.

But your truck keeps getting MORE letters dumped in it! And if
you're stuck waiting at grandma's door... your truck overflows
and letters fall everywhere! 📬💥

The fix: Now you have a magic helper elf! You hand the letters to
the elf and say "you go deliver these!" while you run back to the
truck to grab more. The elf handles the doors, you handle the truck.
Nobody waits! 🧝‍♂️

🍕 Fix 2: The Pizza Party Story

Now let's talk about that elf. The elf had a problem too!

Imagine you have 5 friends at a pizza party and you're handing out
slices. Before, the elf would:

1. Give pizza to Tommy → wait for him to take a bite 🍕
2. Give pizza to Sally → wait for her to take a bite 🍕
3. Give pizza to Bobby → wait for him to take a bite 🍕

So boring! Everyone's just sitting there hungry!

The fix: Now the elf throws ALL the pizza slices at the same time!
🍕🍕🍕🍕🍕 Everyone gets their pizza at once and nobody has to
wait for Tommy to finish chewing!

Yay! 🎉

Technical details:
- Use asyncio.create_task() to fire-and-forget broadcasts
- Use asyncio.gather() to send to all browsers concurrently
- Plugin receive loop no longer blocks on slow browser clients

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
erik 2026-03-30 20:53:59 +00:00
parent 5c20d40f1f
commit c0da36c280

69
main.py
View file

@ -81,6 +81,7 @@ _cached_total_kills: dict = {"total": 0, "last_updated": None}
_cache_task: asyncio.Task | None = None
_rares_cache_task: asyncio.Task | None = None
_cleanup_task: asyncio.Task | None = None
_broadcast_tasks: set[asyncio.Task] = set()
# Player tracking for debug purposes
_player_history: list = [] # List of player sets from last 10 refreshes
@ -1196,6 +1197,14 @@ async def on_shutdown():
await _cleanup_task
except asyncio.CancelledError:
pass
# Cancel any in-flight broadcast tasks
if _broadcast_tasks:
logger.info(f"Cancelling {len(_broadcast_tasks)} in-flight broadcast tasks")
for task in _broadcast_tasks:
task.cancel()
await asyncio.gather(*_broadcast_tasks, return_exceptions=True)
_broadcast_tasks.clear()
logger.info("Disconnecting from database")
await database.disconnect()
@ -1992,37 +2001,45 @@ browser_conns: set[WebSocket] = set()
plugin_conns: Dict[str, WebSocket] = {}
async def _send_to_browser(ws: WebSocket, data: dict) -> WebSocket | None:
"""Send data to a single browser client. Returns the ws if it failed, None if ok."""
try:
await asyncio.wait_for(ws.send_json(data), timeout=1.0)
except (WebSocketDisconnect, RuntimeError, ConnectionAbortedError) as e:
logger.debug(f"Detected disconnected browser client: {e}")
return ws
except asyncio.TimeoutError:
logger.warning(
"Timed out broadcasting to browser client; removing stale connection"
)
return ws
except Exception as e:
logger.warning(f"Unexpected error broadcasting to browser client: {e}")
return ws
return None
async def _do_broadcast(data: dict):
"""Send data to all browser clients concurrently. Runs as a background task."""
clients = list(browser_conns)
if not clients:
return
results = await asyncio.gather(*(_send_to_browser(ws, data) for ws in clients))
for ws in results:
if ws is not None:
browser_conns.discard(ws)
async def _broadcast_to_browser_clients(snapshot: dict):
"""Broadcast a telemetry or chat message to all connected browser clients.
Converts any non-serializable types (e.g., datetime) before sending.
Handles connection errors gracefully and removes stale connections.
Fires off a background task so the plugin receive loop is never blocked
by slow browser connections.
"""
# Convert snapshot payload to JSON-friendly types
data = jsonable_encoder(snapshot)
# Use list() to avoid "set changed size during iteration" errors
disconnected_clients = []
for ws in list(browser_conns):
try:
await asyncio.wait_for(ws.send_json(data), timeout=1.0)
except (WebSocketDisconnect, RuntimeError, ConnectionAbortedError) as e:
# Collect disconnected clients for cleanup
disconnected_clients.append(ws)
logger.debug(f"Detected disconnected browser client: {e}")
except asyncio.TimeoutError:
disconnected_clients.append(ws)
logger.warning(
"Timed out broadcasting to browser client; removing stale connection"
)
except Exception as e:
# Handle any other unexpected errors
disconnected_clients.append(ws)
logger.warning(f"Unexpected error broadcasting to browser client: {e}")
# Clean up disconnected clients
for ws in disconnected_clients:
browser_conns.discard(ws)
task = asyncio.create_task(_do_broadcast(data))
_broadcast_tasks.add(task)
task.add_done_callback(_broadcast_tasks.discard)
async def _forward_to_inventory_service(inventory_msg: FullInventoryMessage):