fix: add WebSocket heartbeat ping for iPad resilience
Adds a 15-second heartbeat ping to the Workshop WebSocket relay so dead connections (e.g. Safari tab suspension) are detected promptly instead of only on the next broadcast. Completes the Phase 3 Bridge MVP acceptance criteria for connection health monitoring. Fixes #362 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -45,6 +45,8 @@ _conversation: deque[dict] = deque(maxlen=_MAX_EXCHANGES)
|
||||
|
||||
_WORKSHOP_SESSION_ID = "workshop"
|
||||
|
||||
_HEARTBEAT_INTERVAL = 15 # seconds — ping to detect dead iPad/Safari connections
|
||||
|
||||
|
||||
def _read_presence_file() -> dict | None:
|
||||
"""Read presence.json if it exists and is fresh enough."""
|
||||
@@ -121,13 +123,28 @@ async def get_world_state() -> JSONResponse:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _heartbeat(websocket: WebSocket) -> None:
|
||||
"""Send periodic pings to detect dead connections (iPad resilience).
|
||||
|
||||
Safari suspends background tabs, killing the TCP socket silently.
|
||||
A 15-second ping ensures we notice within one interval.
|
||||
"""
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL)
|
||||
await websocket.send_text(json.dumps({"type": "ping"}))
|
||||
except Exception:
|
||||
pass # connection gone — receive loop will clean up
|
||||
|
||||
|
||||
@router.websocket("/ws")
|
||||
async def world_ws(websocket: WebSocket) -> None:
|
||||
"""Accept a Workshop client and keep it alive for state broadcasts.
|
||||
|
||||
Sends a full ``world_state`` snapshot immediately on connect so the
|
||||
client never starts from a blank slate. Incoming frames are parsed
|
||||
as JSON — ``visitor_message`` triggers a bark response.
|
||||
as JSON — ``visitor_message`` triggers a bark response. A background
|
||||
heartbeat ping runs every 15 s to detect dead connections early.
|
||||
"""
|
||||
await websocket.accept()
|
||||
_ws_clients.append(websocket)
|
||||
@@ -139,6 +156,8 @@ async def world_ws(websocket: WebSocket) -> None:
|
||||
await websocket.send_text(json.dumps({"type": "world_state", **snapshot}))
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to send WS snapshot: %s", exc)
|
||||
|
||||
ping_task = asyncio.create_task(_heartbeat(websocket))
|
||||
try:
|
||||
while True:
|
||||
raw = await websocket.receive_text()
|
||||
@@ -146,6 +165,7 @@ async def world_ws(websocket: WebSocket) -> None:
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
ping_task.cancel()
|
||||
if websocket in _ws_clients:
|
||||
_ws_clients.remove(websocket)
|
||||
logger.info("World WS disconnected — %d clients", len(_ws_clients))
|
||||
|
||||
@@ -14,6 +14,7 @@ from dashboard.routes.world import (
|
||||
_conversation,
|
||||
_generate_bark,
|
||||
_handle_client_message,
|
||||
_heartbeat,
|
||||
_read_presence_file,
|
||||
broadcast_world_state,
|
||||
)
|
||||
@@ -368,3 +369,41 @@ async def test_conversation_buffer_caps_at_max():
|
||||
finally:
|
||||
_ws_clients.clear()
|
||||
_conversation.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Heartbeat ping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_sends_ping():
|
||||
"""Heartbeat sends a ping JSON frame after the interval elapses."""
|
||||
ws = AsyncMock()
|
||||
|
||||
with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||||
# Let the first sleep complete, then raise to exit the loop
|
||||
call_count = 0
|
||||
|
||||
async def sleep_side_effect(_interval):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count > 1:
|
||||
raise ConnectionError("stop")
|
||||
|
||||
mock_sleep.side_effect = sleep_side_effect
|
||||
await _heartbeat(ws)
|
||||
|
||||
ws.send_text.assert_called_once()
|
||||
msg = json.loads(ws.send_text.call_args[0][0])
|
||||
assert msg["type"] == "ping"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_heartbeat_exits_on_dead_connection():
|
||||
"""Heartbeat exits cleanly when the WebSocket is dead."""
|
||||
ws = AsyncMock()
|
||||
ws.send_text.side_effect = ConnectionError("gone")
|
||||
|
||||
with patch("dashboard.routes.world.asyncio.sleep", new_callable=AsyncMock):
|
||||
await _heartbeat(ws) # should not raise
|
||||
|
||||
Reference in New Issue
Block a user