fix: add WebSocket heartbeat ping for iPad resilience
All checks were successful
Tests / lint (pull_request) Successful in 3s
Tests / test (pull_request) Successful in 1m3s

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:
kimi
2026-03-19 02:00:35 -04:00
parent 86224d042d
commit 95419c7c7b
2 changed files with 60 additions and 1 deletions

View File

@@ -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))

View File

@@ -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