mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[F064][F065][F066] websocket: non-blocking fan-out, finally-disconnect, idle timeout
F064: the bridge forwarder awaited every conn.send_text in a gather with no per-connection queue and no send timeout — one slow WS client back-pressured ALL event delivery to ALL clients (head-of-line blocking on the listen loop). Each connect_* now registers a _ClientConnection (bounded asyncio.Queue(256) + sender task); broadcasts enqueue via put_nowait (drop + structlog warn on QueueFull) and return immediately. The sender drains the queue with each send wrapped in wait_for(SEND_TIMEOUT=10s). Unregistered legacy sockets (set directly into a subscription set, bypassing connect_*) get a timeout-bounded fallback send task held in _pending_sends (ruff RUF006). disconnect cancels + drops the sender. F065: route handlers caught only WebSocketDisconnect with no finally — a non-clean exit (anyio closed-resource, CancelledError, transport error) propagated without manager.disconnect, leaking the dead socket into every subscription set forever. Added finally: manager.disconnect(websocket) to all 5 handlers (disconnect is idempotent). F066: no server-side heartbeat/idle timeout — a half-open socket from a dead container blocked receive_text forever and was never reaped. receive_text now wraps in wait_for(IDLE_TIMEOUT_SECONDS=90s); on TimeoutError, log + fall through to the F065 finally. Named module constants (no config.py precedent for WS tuning; callers/tests patch them). TDD: 22 new tests across 3 files (handler cleanup, idle timeout, send queue), non-flaky across repeats; 1 existing test adapted with a yield for the new async fan-out (assertion unchanged). ruff/mypy clean, 421 unit/api tests pass. No type:ignore/noqa.
This commit is contained in:
@@ -8,6 +8,7 @@ against mock sockets (no real app/lifespan/Redis).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -33,9 +34,14 @@ async def test_broadcast_system_sends_to_every_connection() -> None:
|
||||
ws1, ws2 = MagicMock(), MagicMock()
|
||||
ws1.send_text = AsyncMock()
|
||||
ws2.send_text = AsyncMock()
|
||||
# These sockets are placed directly into the subscription set (bypassing
|
||||
# connect_system), so they take the F064 legacy fallback path: broadcast
|
||||
# schedules a timeout-bounded send task per socket instead of awaiting
|
||||
# send_text inline. Yield once so those tasks run before asserting.
|
||||
mgr.system_connections = {ws1, ws2}
|
||||
|
||||
await mgr.broadcast_system({"type": "RATE_LIMIT_HIT", "provider": "anthropic"})
|
||||
await asyncio.sleep(0)
|
||||
|
||||
ws1.send_text.assert_awaited_once()
|
||||
ws2.send_text.assert_awaited_once()
|
||||
|
||||
Reference in New Issue
Block a user