Spaces:
Running on CPU Upgrade
feat(liveness): stale-producer sweep, SSE generation guard, hardware_id dedup, token cache TTL
Browse filesFixes the ghost-producer bug: a robot dying without closing its socket
(power cut, yanked Wi-Fi) stayed listed as connectable forever because
request.is_disconnected() never fires on half-open sockets behind the
HTTP/2 proxy, and no eviction path existed since the TTL removal.
- Refresh Peer.last_seen on every inbound /send message (includes the
daemon heartbeat) and sweep producers silent for a full lease (30s).
Legacy guard: producers without meta.hardware_id (daemons < v1.7.2,
no heartbeat/health loop) are exempt and keep today's behaviour.
- Advertise recommended_heartbeat_interval_seconds=10 in the SSE
welcome again (daemons >= v1.7.2 negotiate it; older ones keep their
faster 5s default, which is safe).
- One queue + generation counter per SSE connection: a stale half-open
generator superseded by a reconnect can no longer steal messages or
evict the live peer when it finally closes.
- Stable-id collision eviction now also matches meta.hardware_id
(shipped by daemons >= v1.7.2); install_id alone was dead code since
no shipped daemon emits it.
- Token cache entries expire after 1h so revoked HF tokens stop
working without a Space restart; stale identities are served during
whoami outages, only explicit rejection drops an entry. Sweeper also
prunes idle rate-limit buckets.
- DEV_TOKEN_SEED env escape hatch to pre-seed the token cache for
local testing (never set in deployed Spaces).
Co-authored-by: Cursor <cursoragent@cursor.com>
- README.md +16 -6
- app.py +295 -73
- test_signaling.py +317 -0
|
@@ -14,15 +14,25 @@ WebRTC signaling server for Reachy Mini robot.
|
|
| 14 |
|
| 15 |
## Features
|
| 16 |
|
| 17 |
-
- GStreamer-compatible WebRTC signaling protocol
|
| 18 |
-
- Producer/Consumer session management
|
| 19 |
-
-
|
| 20 |
- Real-time status monitoring
|
| 21 |
|
| 22 |
-
##
|
| 23 |
|
| 24 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
## Protocol
|
| 27 |
|
| 28 |
-
Implements the GStreamer webrtcsink/webrtcsrc signaling protocol
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
## Features
|
| 16 |
|
| 17 |
+
- GStreamer-compatible WebRTC signaling protocol over HTTP (SSE + POST)
|
| 18 |
+
- Producer/Consumer session management with per-user isolation
|
| 19 |
+
- Stale-producer sweep (half-open socket eviction, heartbeat-driven)
|
| 20 |
- Real-time status monitoring
|
| 21 |
|
| 22 |
+
## Endpoints
|
| 23 |
|
| 24 |
+
- `GET /events` - SSE stream (server to client messages)
|
| 25 |
+
- `POST /send` - client to server messages
|
| 26 |
+
- `GET /api/robot-status` - busy/free status of the caller's robots
|
| 27 |
+
- `GET /api/debug/peers` - owner-filtered peer dump for debugging
|
| 28 |
+
- `GET /health` - public counters
|
| 29 |
+
|
| 30 |
+
Authentication: `Authorization: Bearer <HF token>` on all authenticated
|
| 31 |
+
endpoints (`?token=` query form is deprecated).
|
| 32 |
|
| 33 |
## Protocol
|
| 34 |
|
| 35 |
+
Implements the GStreamer webrtcsink/webrtcsrc signaling protocol
|
| 36 |
+
semantics over SSE + HTTP POST (works through HTTP/2 proxies like
|
| 37 |
+
HuggingFace Spaces). See `reachy_mini/docs/SIGNALING.md` for the
|
| 38 |
+
canonical lifecycle contract.
|
|
@@ -10,15 +10,22 @@ This works reliably through HTTP/2 proxies like HuggingFace Spaces.
|
|
| 10 |
Design: central is a **stateless matchmaker**. It bootstraps WebRTC
|
| 11 |
sessions between mobile/desktop consumers and robot-daemon producers,
|
| 12 |
relays SDP/ICE, and maintains a producer registry keyed by a stable
|
| 13 |
-
``token -> peer_id`` mapping.
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
Lifecycle responsibilities (see ``reachy_mini/docs/SIGNALING.md`` for the
|
| 24 |
canonical contract):
|
|
@@ -41,9 +48,11 @@ import asyncio
|
|
| 41 |
import hashlib
|
| 42 |
import json
|
| 43 |
import logging
|
|
|
|
| 44 |
import time
|
| 45 |
import uuid
|
| 46 |
from collections import deque
|
|
|
|
| 47 |
from dataclasses import dataclass, field
|
| 48 |
from typing import Optional, AsyncGenerator
|
| 49 |
|
|
@@ -59,16 +68,40 @@ logger = logging.getLogger(__name__)
|
|
| 59 |
|
| 60 |
# --- Liveness ---------------------------------------------------------
|
| 61 |
#
|
| 62 |
-
#
|
| 63 |
-
# the daemon (which has authoritative visibility into the peer
|
| 64 |
-
# connection's data channel and ICE state). Central remains a stateless
|
| 65 |
-
# matchmaker, idempotent on the ``token -> peer_id`` mapping below; when
|
| 66 |
-
# the daemon decides a session is idle it tears down its PC and sends
|
| 67 |
-
# ``setPeerStatus`` to flip itself back to "available", or an explicit
|
| 68 |
-
# ``endSession`` to clear the session entry here.
|
| 69 |
#
|
| 70 |
-
#
|
| 71 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
def _session_state_changed_payload(
|
|
@@ -95,7 +128,21 @@ def _session_state_changed_payload(
|
|
| 95 |
}
|
| 96 |
|
| 97 |
|
| 98 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
|
| 100 |
# Add CORS middleware for browser clients.
|
| 101 |
#
|
|
@@ -112,8 +159,36 @@ app.add_middleware(
|
|
| 112 |
allow_headers=["*"],
|
| 113 |
)
|
| 114 |
|
| 115 |
-
# Cache for validated tokens
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
|
| 118 |
|
| 119 |
# --- Rate limiting --------------------------------------------------
|
|
@@ -136,16 +211,31 @@ token_cache: dict[str, str] = {}
|
|
| 136 |
#
|
| 137 |
# Sizing: 1200 req / 60 s = 20 req/s sustained per peer, aligned with
|
| 138 |
# typical WebRTC signaling servers (CloudGaming reports 200 msg / 10 s
|
| 139 |
-
# per connection). With heartbeat at 10 s
|
| 140 |
-
#
|
| 141 |
-
#
|
| 142 |
-
#
|
| 143 |
-
#
|
|
|
|
|
|
|
| 144 |
RATE_LIMIT_REQUESTS = 1200
|
| 145 |
RATE_LIMIT_WINDOW = 60.0
|
| 146 |
_rate_limit_buckets: dict[str, deque[float]] = {}
|
| 147 |
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
def _rate_limit_key(token: str) -> str:
|
| 150 |
"""Return a stable, non-reversible per-peer bucket key.
|
| 151 |
|
|
@@ -260,13 +350,20 @@ async def _resolve_hf_token(
|
|
| 260 |
|
| 261 |
|
| 262 |
async def validate_hf_token(token: str) -> Optional[str]:
|
| 263 |
-
"""Validate HuggingFace token and return username if valid.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
if not token:
|
| 265 |
return None
|
| 266 |
|
| 267 |
-
|
| 268 |
-
if
|
| 269 |
-
return
|
| 270 |
|
| 271 |
try:
|
| 272 |
async with httpx.AsyncClient() as client:
|
|
@@ -279,14 +376,25 @@ async def validate_hf_token(token: str) -> Optional[str]:
|
|
| 279 |
if response.status_code == 200:
|
| 280 |
data = response.json()
|
| 281 |
username = data.get("name", "unknown")
|
| 282 |
-
token_cache[token] =
|
|
|
|
|
|
|
|
|
|
| 283 |
logger.info(f"Token validated for user: {username}")
|
| 284 |
return username
|
| 285 |
else:
|
| 286 |
logger.warning(f"Token validation failed: {response.status_code}")
|
|
|
|
| 287 |
return None
|
| 288 |
except Exception as e:
|
| 289 |
logger.error(f"Error validating token: {e}")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 290 |
return None
|
| 291 |
|
| 292 |
|
|
@@ -294,12 +402,16 @@ async def validate_hf_token(token: str) -> Optional[str]:
|
|
| 294 |
class Peer:
|
| 295 |
"""Represents a connected peer (robot or client).
|
| 296 |
|
| 297 |
-
``last_seen`` is
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
"""
|
| 304 |
peer_id: str
|
| 305 |
username: str
|
|
@@ -310,6 +422,7 @@ class Peer:
|
|
| 310 |
session_id: Optional[str] = None
|
| 311 |
partner_id: Optional[str] = None
|
| 312 |
last_seen: float = field(default_factory=time.monotonic)
|
|
|
|
| 313 |
|
| 314 |
|
| 315 |
class SignalingServer:
|
|
@@ -369,7 +482,8 @@ class SignalingServer:
|
|
| 369 |
Three cases:
|
| 370 |
|
| 371 |
- ``roles=["producer"]``: register / refresh as producer. If the
|
| 372 |
-
payload's ``meta.install_id``
|
|
|
|
| 373 |
of the same user, evict that older producer first
|
| 374 |
(last-writer-wins, see ``docs/SIGNALING.md``). Broadcast a
|
| 375 |
``peerStatusChanged`` event so listeners learn about the new
|
|
@@ -397,7 +511,7 @@ class SignalingServer:
|
|
| 397 |
peer.meta = meta
|
| 398 |
|
| 399 |
if "producer" in roles:
|
| 400 |
-
await self.
|
| 401 |
peer.role = "producer"
|
| 402 |
self.producers[peer.peer_id] = peer
|
| 403 |
logger.info(f"Producer registered: {peer.peer_id} with meta: {meta}")
|
|
@@ -446,23 +560,36 @@ class SignalingServer:
|
|
| 446 |
}
|
| 447 |
return None
|
| 448 |
|
| 449 |
-
async def
|
| 450 |
-
"""Last-writer-wins on
|
| 451 |
|
| 452 |
-
A re-flashed daemon, a duplicated SD card,
|
| 453 |
-
process
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
|
|
|
| 457 |
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
alone here - cross-tenant collisions are out of scope for the
|
| 462 |
signaling server, the auth layer above us guarantees isolation.
|
| 463 |
"""
|
| 464 |
-
|
| 465 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
return
|
| 467 |
|
| 468 |
for old_id, old_peer in list(self.producers.items()):
|
|
@@ -470,11 +597,16 @@ class SignalingServer:
|
|
| 470 |
continue
|
| 471 |
if old_peer.username != new_peer.username:
|
| 472 |
continue
|
| 473 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
continue
|
| 475 |
logger.info(
|
| 476 |
-
"
|
| 477 |
-
|
|
|
|
| 478 |
old_id,
|
| 479 |
)
|
| 480 |
if old_peer.session_id is not None:
|
|
@@ -687,6 +819,11 @@ class SignalingServer:
|
|
| 687 |
|
| 688 |
async def handle_message(self, peer: Peer, message: dict) -> Optional[dict]:
|
| 689 |
"""Process incoming message and return response if any."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 690 |
msg_type = message.get("type", "")
|
| 691 |
logger.debug(f"Received from {peer.peer_id}: {msg_type}")
|
| 692 |
|
|
@@ -718,14 +855,73 @@ class SignalingServer:
|
|
| 718 |
logger.warning(f"Unknown message type: {msg_type}")
|
| 719 |
return None
|
| 720 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 721 |
async def disconnect_peer(self, peer_id: str):
|
| 722 |
"""Fully evict a peer from every server-side structure.
|
| 723 |
|
| 724 |
-
Called from
|
| 725 |
|
| 726 |
- SSE close path (``request.is_disconnected()`` becoming true:
|
| 727 |
the peer's HTTP channel closed cleanly).
|
| 728 |
-
- ``
|
|
|
|
|
|
|
| 729 |
|
| 730 |
Cleanup is exhaustive on purpose: ``peers``, ``producers``,
|
| 731 |
``token_to_peer`` and the active session are all cleared.
|
|
@@ -799,14 +995,27 @@ async def events(request: Request, token: str = Depends(_resolve_hf_token)):
|
|
| 799 |
|
| 800 |
peer = signaling.get_or_create_peer(token, username)
|
| 801 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 802 |
async def event_generator() -> AsyncGenerator[dict, None]:
|
| 803 |
-
# Send welcome message with username for client info.
|
| 804 |
-
#
|
| 805 |
-
#
|
| 806 |
-
#
|
| 807 |
-
# available. Older daemons that read these fields fall back to
|
| 808 |
-
# their internal defaults, which is fine because nothing on
|
| 809 |
-
# central depends on the daemon's heartbeat cadence anymore.
|
| 810 |
yield {
|
| 811 |
"event": "message",
|
| 812 |
"data": json.dumps(
|
|
@@ -814,6 +1023,7 @@ async def events(request: Request, token: str = Depends(_resolve_hf_token)):
|
|
| 814 |
"type": "welcome",
|
| 815 |
"peerId": peer.peer_id,
|
| 816 |
"username": username,
|
|
|
|
| 817 |
}
|
| 818 |
),
|
| 819 |
}
|
|
@@ -826,14 +1036,19 @@ async def events(request: Request, token: str = Depends(_resolve_hf_token)):
|
|
| 826 |
# Check if client disconnected. Best-effort:
|
| 827 |
# ``is_disconnected`` returns True on FIN/RST visible
|
| 828 |
# to starlette, but half-open sockets can stay False
|
| 829 |
-
#
|
| 830 |
-
#
|
| 831 |
-
# when it decides the session is dead.
|
| 832 |
if await request.is_disconnected():
|
| 833 |
break
|
| 834 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 835 |
try:
|
| 836 |
-
message = await asyncio.wait_for(
|
| 837 |
yield {"event": "message", "data": json.dumps(message)}
|
| 838 |
except asyncio.TimeoutError:
|
| 839 |
# Server-pushed keepalive. Its ONLY job is to keep
|
|
@@ -842,7 +1057,11 @@ async def events(request: Request, token: str = Depends(_resolve_hf_token)):
|
|
| 842 |
yield {"event": "ping", "data": ""}
|
| 843 |
|
| 844 |
finally:
|
| 845 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 846 |
|
| 847 |
return EventSourceResponse(event_generator())
|
| 848 |
|
|
@@ -906,9 +1125,10 @@ async def root():
|
|
| 906 |
</div>
|
| 907 |
<h2>Endpoints</h2>
|
| 908 |
<ul>
|
| 909 |
-
<li><code>GET /events
|
| 910 |
-
<li><code>POST /send
|
| 911 |
</ul>
|
|
|
|
| 912 |
<h2>Protocol</h2>
|
| 913 |
<p>This server implements the GStreamer WebRTC signaling protocol over HTTP/SSE.</p>
|
| 914 |
</body>
|
|
@@ -958,9 +1178,11 @@ async def robot_status(token: str = Depends(_resolve_hf_token)):
|
|
| 958 |
...) appear without another central change.
|
| 959 |
|
| 960 |
``last_seen_age_seconds`` is the wall-time gap since the producer's
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
|
|
|
|
|
|
| 964 |
"""
|
| 965 |
username = await validate_hf_token(token)
|
| 966 |
if not username:
|
|
|
|
| 10 |
Design: central is a **stateless matchmaker**. It bootstraps WebRTC
|
| 11 |
sessions between mobile/desktop consumers and robot-daemon producers,
|
| 12 |
relays SDP/ICE, and maintains a producer registry keyed by a stable
|
| 13 |
+
``token -> peer_id`` mapping. **Session** liveness is owned by the
|
| 14 |
+
daemon, which has direct visibility into the peer connection's data
|
| 15 |
+
channel and ICE state: when its watchdog decides a session is idle, it
|
| 16 |
+
closes its PC and announces availability via ``setPeerStatus`` (or
|
| 17 |
+
sends an explicit ``endSession``).
|
| 18 |
+
|
| 19 |
+
**Registration** liveness, however, is owned by central: a producer
|
| 20 |
+
whose process dies without closing its socket (power cut, yanked
|
| 21 |
+
Wi-Fi) leaves a half-open SSE channel that ``request.is_disconnected()``
|
| 22 |
+
never notices behind an HTTP/2 proxy, so the robot would stay listed
|
| 23 |
+
as connectable forever. The producer sweep (see the Liveness section
|
| 24 |
+
below) evicts producers with no inbound traffic for
|
| 25 |
+
``PRODUCER_LEASE_SECONDS``, keyed exclusively on inbound ``POST /send``
|
| 26 |
+
activity - never on SSE delivery, so back-pressure on a healthy
|
| 27 |
+
consumer can not tear down a healthy media session (the bug that got
|
| 28 |
+
the previous TTL sweeper removed).
|
| 29 |
|
| 30 |
Lifecycle responsibilities (see ``reachy_mini/docs/SIGNALING.md`` for the
|
| 31 |
canonical contract):
|
|
|
|
| 48 |
import hashlib
|
| 49 |
import json
|
| 50 |
import logging
|
| 51 |
+
import os
|
| 52 |
import time
|
| 53 |
import uuid
|
| 54 |
from collections import deque
|
| 55 |
+
from contextlib import asynccontextmanager
|
| 56 |
from dataclasses import dataclass, field
|
| 57 |
from typing import Optional, AsyncGenerator
|
| 58 |
|
|
|
|
| 68 |
|
| 69 |
# --- Liveness ---------------------------------------------------------
|
| 70 |
#
|
| 71 |
+
# Split ownership:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
#
|
| 73 |
+
# - **Sessions**: owned by the daemon (authoritative visibility into
|
| 74 |
+
# the PC's data channel / ICE state). Central never ends a session on
|
| 75 |
+
# idleness; it reacts to ``endSession`` / ``setPeerStatus``.
|
| 76 |
+
# - **Producer registrations**: owned by central. ``Peer.last_seen`` is
|
| 77 |
+
# refreshed on every inbound application-level message (POST /send),
|
| 78 |
+
# which includes the daemon's periodic ``setPeerStatus`` heartbeat.
|
| 79 |
+
# The sweep below evicts producers silent for more than
|
| 80 |
+
# ``PRODUCER_LEASE_SECONDS`` - the only way to catch half-open
|
| 81 |
+
# sockets that ``request.is_disconnected()`` never reports.
|
| 82 |
+
#
|
| 83 |
+
# Legacy guard: daemons older than v1.7.2 connect to central but have
|
| 84 |
+
# no heartbeat loop (and no producer health loop to self-heal after an
|
| 85 |
+
# eviction). They are exempted from the sweep, keyed on the absence of
|
| 86 |
+
# ``meta.hardware_id`` - a field introduced by the same v1.7.2 release
|
| 87 |
+
# as the heartbeat, so its presence proves the daemon heartbeats.
|
| 88 |
+
# Exempted producers keep today's behaviour verbatim (including the
|
| 89 |
+
# ghost-on-power-cut bug the sweep fixes for modern daemons).
|
| 90 |
+
#
|
| 91 |
+
# Sizing: lease = 30 s with the heartbeat advertised at 10 s via the
|
| 92 |
+
# SSE ``welcome`` frame gives 2 missed heartbeats of headroom (daemons
|
| 93 |
+
# that predate welcome negotiation fall back to their internal 5 s
|
| 94 |
+
# default, which only adds margin). A live robot evicted during a
|
| 95 |
+
# >30 s network blackout self-heals in <=90 s: its producer health
|
| 96 |
+
# loop (poll 30 s, 2 misses) notices the missing registration and
|
| 97 |
+
# force-reconnects.
|
| 98 |
+
PRODUCER_LEASE_SECONDS = float(
|
| 99 |
+
os.getenv("REACHY_CENTRAL_PRODUCER_LEASE_SECONDS", "30")
|
| 100 |
+
)
|
| 101 |
+
PRODUCER_SWEEP_INTERVAL_SECONDS = float(
|
| 102 |
+
os.getenv("REACHY_CENTRAL_PRODUCER_SWEEP_INTERVAL", "5")
|
| 103 |
+
)
|
| 104 |
+
RECOMMENDED_HEARTBEAT_INTERVAL_SECONDS = 10.0
|
| 105 |
|
| 106 |
|
| 107 |
def _session_state_changed_payload(
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
|
| 131 |
+
@asynccontextmanager
|
| 132 |
+
async def _lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
| 133 |
+
"""Run the stale-producer sweeper for the app's lifetime."""
|
| 134 |
+
sweeper_task = asyncio.create_task(signaling.run_producer_sweeper())
|
| 135 |
+
try:
|
| 136 |
+
yield
|
| 137 |
+
finally:
|
| 138 |
+
sweeper_task.cancel()
|
| 139 |
+
try:
|
| 140 |
+
await sweeper_task
|
| 141 |
+
except asyncio.CancelledError:
|
| 142 |
+
pass
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
app = FastAPI(title="Reachy Mini Central", lifespan=_lifespan)
|
| 146 |
|
| 147 |
# Add CORS middleware for browser clients.
|
| 148 |
#
|
|
|
|
| 159 |
allow_headers=["*"],
|
| 160 |
)
|
| 161 |
|
| 162 |
+
# Cache for validated tokens: token -> (username, expires_at monotonic).
|
| 163 |
+
#
|
| 164 |
+
# Entries expire after ``TOKEN_CACHE_TTL_SECONDS`` so a token revoked on
|
| 165 |
+
# HuggingFace stops working here within the TTL instead of surviving
|
| 166 |
+
# until the next Space restart. Expired entries are kept around (and
|
| 167 |
+
# lazily re-validated) so that a transient whoami outage degrades to
|
| 168 |
+
# serving the stale identity rather than 401-ing the whole fleet; only
|
| 169 |
+
# an explicit rejection from HF drops the entry. Entries stale for more
|
| 170 |
+
# than ``TOKEN_CACHE_STALE_GRACE_SECONDS`` are pruned by the sweeper.
|
| 171 |
+
TOKEN_CACHE_TTL_SECONDS = 3600.0
|
| 172 |
+
TOKEN_CACHE_STALE_GRACE_SECONDS = 86400.0
|
| 173 |
+
token_cache: dict[str, tuple[str, float]] = {}
|
| 174 |
+
|
| 175 |
+
# Local-testing escape hatch: pre-seed the token cache from the
|
| 176 |
+
# environment so a second client can authenticate without a real HF
|
| 177 |
+
# token (mirrors prod topology where robot and phone hold DISTINCT
|
| 178 |
+
# tokens of the same user). Format: "token:username[,token:username]".
|
| 179 |
+
# Never set in deployed Spaces. Seeded entries never expire.
|
| 180 |
+
for _seed in os.environ.get("DEV_TOKEN_SEED", "").split(","):
|
| 181 |
+
if ":" in _seed:
|
| 182 |
+
_tok, _user = _seed.split(":", 1)
|
| 183 |
+
token_cache[_tok.strip()] = (_user.strip(), float("inf"))
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _prune_token_cache() -> None:
|
| 187 |
+
"""Drop cache entries whose stale-serving grace has fully elapsed."""
|
| 188 |
+
now = time.monotonic()
|
| 189 |
+
for tok, (_, expires_at) in list(token_cache.items()):
|
| 190 |
+
if now > expires_at + TOKEN_CACHE_STALE_GRACE_SECONDS:
|
| 191 |
+
del token_cache[tok]
|
| 192 |
|
| 193 |
|
| 194 |
# --- Rate limiting --------------------------------------------------
|
|
|
|
| 211 |
#
|
| 212 |
# Sizing: 1200 req / 60 s = 20 req/s sustained per peer, aligned with
|
| 213 |
# typical WebRTC signaling servers (CloudGaming reports 200 msg / 10 s
|
| 214 |
+
# per connection). With heartbeat at 10 s as advertised in the welcome
|
| 215 |
+
# frame (12 req/min for pre-negotiation daemons on their 5 s default),
|
| 216 |
+
# a typical mobile session (offer + answer + ~10 ICE candidates ~ 15
|
| 217 |
+
# req over a few seconds), and aggressive reconnects, observed peak
|
| 218 |
+
# under load is ~50-100 req/min/peer. We keep a 12-24x headroom so
|
| 219 |
+
# adding features (status polls, presence, etc.) does not require
|
| 220 |
+
# retuning the limit.
|
| 221 |
RATE_LIMIT_REQUESTS = 1200
|
| 222 |
RATE_LIMIT_WINDOW = 60.0
|
| 223 |
_rate_limit_buckets: dict[str, deque[float]] = {}
|
| 224 |
|
| 225 |
|
| 226 |
+
def _prune_rate_limit_buckets() -> None:
|
| 227 |
+
"""Drop buckets whose newest entry aged out of the window.
|
| 228 |
+
|
| 229 |
+
``check_rate_limit`` only trims buckets it is actively serving, so
|
| 230 |
+
a departed peer's bucket would otherwise pin its timestamps in
|
| 231 |
+
memory forever. Called by the background sweeper.
|
| 232 |
+
"""
|
| 233 |
+
cutoff = time.monotonic() - RATE_LIMIT_WINDOW
|
| 234 |
+
for key, bucket in list(_rate_limit_buckets.items()):
|
| 235 |
+
if not bucket or bucket[-1] < cutoff:
|
| 236 |
+
del _rate_limit_buckets[key]
|
| 237 |
+
|
| 238 |
+
|
| 239 |
def _rate_limit_key(token: str) -> str:
|
| 240 |
"""Return a stable, non-reversible per-peer bucket key.
|
| 241 |
|
|
|
|
| 350 |
|
| 351 |
|
| 352 |
async def validate_hf_token(token: str) -> Optional[str]:
|
| 353 |
+
"""Validate HuggingFace token and return username if valid.
|
| 354 |
+
|
| 355 |
+
Fresh cache hits are served directly. Expired entries trigger a
|
| 356 |
+
re-validation against whoami; on transient failure (network,
|
| 357 |
+
HF outage) the stale identity is served rather than failing the
|
| 358 |
+
caller, and only an explicit non-200 from HF (revoked / invalid
|
| 359 |
+
token) drops the entry.
|
| 360 |
+
"""
|
| 361 |
if not token:
|
| 362 |
return None
|
| 363 |
|
| 364 |
+
cached = token_cache.get(token)
|
| 365 |
+
if cached is not None and time.monotonic() < cached[1]:
|
| 366 |
+
return cached[0]
|
| 367 |
|
| 368 |
try:
|
| 369 |
async with httpx.AsyncClient() as client:
|
|
|
|
| 376 |
if response.status_code == 200:
|
| 377 |
data = response.json()
|
| 378 |
username = data.get("name", "unknown")
|
| 379 |
+
token_cache[token] = (
|
| 380 |
+
username,
|
| 381 |
+
time.monotonic() + TOKEN_CACHE_TTL_SECONDS,
|
| 382 |
+
)
|
| 383 |
logger.info(f"Token validated for user: {username}")
|
| 384 |
return username
|
| 385 |
else:
|
| 386 |
logger.warning(f"Token validation failed: {response.status_code}")
|
| 387 |
+
token_cache.pop(token, None)
|
| 388 |
return None
|
| 389 |
except Exception as e:
|
| 390 |
logger.error(f"Error validating token: {e}")
|
| 391 |
+
if cached is not None:
|
| 392 |
+
logger.warning(
|
| 393 |
+
"Serving stale token cache entry for user %s during "
|
| 394 |
+
"validation outage",
|
| 395 |
+
cached[0],
|
| 396 |
+
)
|
| 397 |
+
return cached[0]
|
| 398 |
return None
|
| 399 |
|
| 400 |
|
|
|
|
| 402 |
class Peer:
|
| 403 |
"""Represents a connected peer (robot or client).
|
| 404 |
|
| 405 |
+
``last_seen`` is refreshed on every inbound application-level
|
| 406 |
+
message (``handle_message``, i.e. POST /send traffic - which
|
| 407 |
+
includes the daemon's periodic heartbeat) and drives the producer
|
| 408 |
+
sweep for heartbeat-capable daemons. It is also surfaced by
|
| 409 |
+
/api/robot-status and /api/debug/peers.
|
| 410 |
+
|
| 411 |
+
``sse_generation`` counts SSE connections bound to this peer. A
|
| 412 |
+
reconnect on the same token supersedes the previous generator:
|
| 413 |
+
the old one compares its captured generation against this counter
|
| 414 |
+
and exits without evicting the peer (see the /events endpoint).
|
| 415 |
"""
|
| 416 |
peer_id: str
|
| 417 |
username: str
|
|
|
|
| 422 |
session_id: Optional[str] = None
|
| 423 |
partner_id: Optional[str] = None
|
| 424 |
last_seen: float = field(default_factory=time.monotonic)
|
| 425 |
+
sse_generation: int = 0
|
| 426 |
|
| 427 |
|
| 428 |
class SignalingServer:
|
|
|
|
| 482 |
Three cases:
|
| 483 |
|
| 484 |
- ``roles=["producer"]``: register / refresh as producer. If the
|
| 485 |
+
payload's stable identity (``meta.install_id`` or
|
| 486 |
+
``meta.hardware_id``) collides with an existing producer
|
| 487 |
of the same user, evict that older producer first
|
| 488 |
(last-writer-wins, see ``docs/SIGNALING.md``). Broadcast a
|
| 489 |
``peerStatusChanged`` event so listeners learn about the new
|
|
|
|
| 511 |
peer.meta = meta
|
| 512 |
|
| 513 |
if "producer" in roles:
|
| 514 |
+
await self._evict_stable_id_collisions(peer, meta)
|
| 515 |
peer.role = "producer"
|
| 516 |
self.producers[peer.peer_id] = peer
|
| 517 |
logger.info(f"Producer registered: {peer.peer_id} with meta: {meta}")
|
|
|
|
| 560 |
}
|
| 561 |
return None
|
| 562 |
|
| 563 |
+
async def _evict_stable_id_collisions(self, new_peer: Peer, new_meta: dict) -> None:
|
| 564 |
+
"""Last-writer-wins on stable-identity collisions.
|
| 565 |
|
| 566 |
+
A re-flashed daemon, a duplicated SD card, a stale tray
|
| 567 |
+
process, or a robot re-provisioned with a fresh token can
|
| 568 |
+
register a producer that is the same physical robot as an
|
| 569 |
+
already-registered producer of the same user. Without eviction
|
| 570 |
+
we'd carry both forever and the mobile app would see the robot
|
| 571 |
+
twice. Policy: keep the newcomer, drop the older.
|
| 572 |
|
| 573 |
+
Identity keys, checked independently:
|
| 574 |
+
|
| 575 |
+
- ``install_id``: reserved per-install key (not emitted by any
|
| 576 |
+
shipped daemon yet, kept for forward compatibility).
|
| 577 |
+
- ``hardware_id``: SHA-256 prefix of the Pollen audio device's
|
| 578 |
+
USB serial, emitted by daemons >= v1.7.2 - stable per
|
| 579 |
+
physical robot across reinstalls and renames. This is the
|
| 580 |
+
key that actually fires in production today.
|
| 581 |
+
|
| 582 |
+
Owner-scoped: a different HF user holding the same id
|
| 583 |
+
(possible during a hardware swap between two accounts) is left
|
| 584 |
alone here - cross-tenant collisions are out of scope for the
|
| 585 |
signaling server, the auth layer above us guarantees isolation.
|
| 586 |
"""
|
| 587 |
+
new_ids = {
|
| 588 |
+
key: new_meta.get(key)
|
| 589 |
+
for key in ("install_id", "hardware_id")
|
| 590 |
+
if new_meta.get(key)
|
| 591 |
+
}
|
| 592 |
+
if not new_ids:
|
| 593 |
return
|
| 594 |
|
| 595 |
for old_id, old_peer in list(self.producers.items()):
|
|
|
|
| 597 |
continue
|
| 598 |
if old_peer.username != new_peer.username:
|
| 599 |
continue
|
| 600 |
+
matched_key = next(
|
| 601 |
+
(k for k, v in new_ids.items() if old_peer.meta.get(k) == v),
|
| 602 |
+
None,
|
| 603 |
+
)
|
| 604 |
+
if matched_key is None:
|
| 605 |
continue
|
| 606 |
logger.info(
|
| 607 |
+
"%s collision: %s already held by peer %s, evicting older",
|
| 608 |
+
matched_key,
|
| 609 |
+
new_ids[matched_key],
|
| 610 |
old_id,
|
| 611 |
)
|
| 612 |
if old_peer.session_id is not None:
|
|
|
|
| 819 |
|
| 820 |
async def handle_message(self, peer: Peer, message: dict) -> Optional[dict]:
|
| 821 |
"""Process incoming message and return response if any."""
|
| 822 |
+
# Inbound application-level traffic is the liveness signal the
|
| 823 |
+
# producer sweep keys on: only a peer whose client half is
|
| 824 |
+
# alive can POST /send (a half-open socket can't).
|
| 825 |
+
peer.last_seen = time.monotonic()
|
| 826 |
+
|
| 827 |
msg_type = message.get("type", "")
|
| 828 |
logger.debug(f"Received from {peer.peer_id}: {msg_type}")
|
| 829 |
|
|
|
|
| 855 |
logger.warning(f"Unknown message type: {msg_type}")
|
| 856 |
return None
|
| 857 |
|
| 858 |
+
async def sweep_stale_producers(self) -> list[str]:
|
| 859 |
+
"""Evict producers with no inbound traffic for a full lease.
|
| 860 |
+
|
| 861 |
+
Heartbeat-capable daemons (>= v1.7.2, detected via
|
| 862 |
+
``meta.hardware_id`` - shipped by the same release as the
|
| 863 |
+
heartbeat loop) refresh ``last_seen`` every few seconds
|
| 864 |
+
through their ``setPeerStatus`` re-emissions on POST /send. A
|
| 865 |
+
producer silent for more than ``PRODUCER_LEASE_SECONDS`` is
|
| 866 |
+
therefore a half-open socket (power cut, yanked Wi-Fi), not a
|
| 867 |
+
healthy robot: evict it fully so it stops showing up as
|
| 868 |
+
connectable in pickers.
|
| 869 |
+
|
| 870 |
+
Producers without ``hardware_id`` (legacy daemons that never
|
| 871 |
+
heartbeat, or daemons running without a robot attached) are
|
| 872 |
+
exempt - evicting them would be permanent since they have no
|
| 873 |
+
health loop to re-register.
|
| 874 |
+
|
| 875 |
+
Full ``disconnect_peer`` rather than a soft withdraw: a swept
|
| 876 |
+
peer is by definition unreachable, keeping its Peer object and
|
| 877 |
+
token mapping around would only leak memory and rebind a
|
| 878 |
+
returning daemon onto a dead message queue. If the robot was
|
| 879 |
+
in fact alive (>30 s network blackout), its producer health
|
| 880 |
+
loop notices the missing registration within two 30 s polls
|
| 881 |
+
and force-reconnects - the exact recovery path daemons already
|
| 882 |
+
exercise on every central redeploy.
|
| 883 |
+
|
| 884 |
+
Returns the list of evicted peer ids (handy for tests/logs).
|
| 885 |
+
"""
|
| 886 |
+
now = time.monotonic()
|
| 887 |
+
stale = [
|
| 888 |
+
pid
|
| 889 |
+
for pid, p in self.producers.items()
|
| 890 |
+
if p.meta.get("hardware_id")
|
| 891 |
+
and now - p.last_seen > PRODUCER_LEASE_SECONDS
|
| 892 |
+
]
|
| 893 |
+
for pid in stale:
|
| 894 |
+
peer = self.peers.get(pid)
|
| 895 |
+
logger.warning(
|
| 896 |
+
"Sweeping stale producer %s (name=%r, silent for %.0fs)",
|
| 897 |
+
pid,
|
| 898 |
+
peer.meta.get("name") if peer else None,
|
| 899 |
+
now - peer.last_seen if peer else -1,
|
| 900 |
+
)
|
| 901 |
+
await self.disconnect_peer(pid)
|
| 902 |
+
return stale
|
| 903 |
+
|
| 904 |
+
async def run_producer_sweeper(self) -> None:
|
| 905 |
+
"""Background task: periodic stale-producer sweep + cache pruning."""
|
| 906 |
+
while True:
|
| 907 |
+
await asyncio.sleep(PRODUCER_SWEEP_INTERVAL_SECONDS)
|
| 908 |
+
try:
|
| 909 |
+
await self.sweep_stale_producers()
|
| 910 |
+
_prune_rate_limit_buckets()
|
| 911 |
+
_prune_token_cache()
|
| 912 |
+
except Exception:
|
| 913 |
+
logger.exception("Producer sweeper iteration failed")
|
| 914 |
+
|
| 915 |
async def disconnect_peer(self, peer_id: str):
|
| 916 |
"""Fully evict a peer from every server-side structure.
|
| 917 |
|
| 918 |
+
Called from three places:
|
| 919 |
|
| 920 |
- SSE close path (``request.is_disconnected()`` becoming true:
|
| 921 |
the peer's HTTP channel closed cleanly).
|
| 922 |
+
- ``_evict_stable_id_collisions``, when a duplicate registers.
|
| 923 |
+
- ``sweep_stale_producers``, when a heartbeat-capable producer
|
| 924 |
+
went silent for a full lease (half-open socket).
|
| 925 |
|
| 926 |
Cleanup is exhaustive on purpose: ``peers``, ``producers``,
|
| 927 |
``token_to_peer`` and the active session are all cleared.
|
|
|
|
| 995 |
|
| 996 |
peer = signaling.get_or_create_peer(token, username)
|
| 997 |
|
| 998 |
+
# One queue and one generation per SSE connection. A reconnect on
|
| 999 |
+
# the same token (daemon restart while its previous socket is
|
| 1000 |
+
# half-open behind the proxy) supersedes the old generator: it
|
| 1001 |
+
# must neither steal messages from the shared queue nor evict the
|
| 1002 |
+
# peer when its dead socket finally closes. Replacing the queue is
|
| 1003 |
+
# safe because an SSE (re)connect restarts the conversation anyway
|
| 1004 |
+
# (welcome + list are re-sent below); anything queued before the
|
| 1005 |
+
# reconnect addressed the previous connection.
|
| 1006 |
+
peer.sse_generation += 1
|
| 1007 |
+
generation = peer.sse_generation
|
| 1008 |
+
peer.message_queue = asyncio.Queue()
|
| 1009 |
+
queue = peer.message_queue
|
| 1010 |
+
|
| 1011 |
+
def _is_current_connection() -> bool:
|
| 1012 |
+
return signaling.peers.get(peer.peer_id) is peer and peer.sse_generation == generation
|
| 1013 |
+
|
| 1014 |
async def event_generator() -> AsyncGenerator[dict, None]:
|
| 1015 |
+
# Send welcome message with username for client info. The
|
| 1016 |
+
# advertised heartbeat cadence drives daemons >= v1.7.2 (they
|
| 1017 |
+
# negotiate from this field); older daemons ignore it and keep
|
| 1018 |
+
# their internal default, which is faster and therefore safe.
|
|
|
|
|
|
|
|
|
|
| 1019 |
yield {
|
| 1020 |
"event": "message",
|
| 1021 |
"data": json.dumps(
|
|
|
|
| 1023 |
"type": "welcome",
|
| 1024 |
"peerId": peer.peer_id,
|
| 1025 |
"username": username,
|
| 1026 |
+
"recommended_heartbeat_interval_seconds": RECOMMENDED_HEARTBEAT_INTERVAL_SECONDS,
|
| 1027 |
}
|
| 1028 |
),
|
| 1029 |
}
|
|
|
|
| 1036 |
# Check if client disconnected. Best-effort:
|
| 1037 |
# ``is_disconnected`` returns True on FIN/RST visible
|
| 1038 |
# to starlette, but half-open sockets can stay False
|
| 1039 |
+
# forever behind HTTP/2 proxies - that case is covered
|
| 1040 |
+
# by the producer sweep instead.
|
|
|
|
| 1041 |
if await request.is_disconnected():
|
| 1042 |
break
|
| 1043 |
|
| 1044 |
+
# Superseded by a newer connection on the same token,
|
| 1045 |
+
# or evicted (sweep / stable-id collision): stop
|
| 1046 |
+
# serving, and let the finally below skip the eviction.
|
| 1047 |
+
if not _is_current_connection():
|
| 1048 |
+
break
|
| 1049 |
+
|
| 1050 |
try:
|
| 1051 |
+
message = await asyncio.wait_for(queue.get(), timeout=30.0)
|
| 1052 |
yield {"event": "message", "data": json.dumps(message)}
|
| 1053 |
except asyncio.TimeoutError:
|
| 1054 |
# Server-pushed keepalive. Its ONLY job is to keep
|
|
|
|
| 1057 |
yield {"event": "ping", "data": ""}
|
| 1058 |
|
| 1059 |
finally:
|
| 1060 |
+
# Only the peer's current connection may evict it: a stale
|
| 1061 |
+
# generator closing late must not tear down the live peer
|
| 1062 |
+
# that superseded it.
|
| 1063 |
+
if _is_current_connection():
|
| 1064 |
+
await signaling.disconnect_peer(peer.peer_id)
|
| 1065 |
|
| 1066 |
return EventSourceResponse(event_generator())
|
| 1067 |
|
|
|
|
| 1125 |
</div>
|
| 1126 |
<h2>Endpoints</h2>
|
| 1127 |
<ul>
|
| 1128 |
+
<li><code>GET /events</code> - SSE stream for receiving messages</li>
|
| 1129 |
+
<li><code>POST /send</code> - Send messages to server</li>
|
| 1130 |
</ul>
|
| 1131 |
+
<p>Authentication: <code>Authorization: Bearer <HF token></code></p>
|
| 1132 |
<h2>Protocol</h2>
|
| 1133 |
<p>This server implements the GStreamer WebRTC signaling protocol over HTTP/SSE.</p>
|
| 1134 |
</body>
|
|
|
|
| 1178 |
...) appear without another central change.
|
| 1179 |
|
| 1180 |
``last_seen_age_seconds`` is the wall-time gap since the producer's
|
| 1181 |
+
last inbound message (POST /send, which includes the daemon's
|
| 1182 |
+
periodic heartbeat). For heartbeat-capable daemons this is a real
|
| 1183 |
+
liveness signal: the sweep evicts producers whose age exceeds
|
| 1184 |
+
``PRODUCER_LEASE_SECONDS``. For legacy daemons (no ``hardware_id``
|
| 1185 |
+
in ``meta``) it only reflects their last signaling activity.
|
| 1186 |
"""
|
| 1187 |
username = await validate_hf_token(token)
|
| 1188 |
if not username:
|
|
@@ -30,13 +30,20 @@ from collections import deque
|
|
| 30 |
import pytest
|
| 31 |
|
| 32 |
from app import (
|
|
|
|
| 33 |
RATE_LIMIT_REQUESTS,
|
| 34 |
RATE_LIMIT_WINDOW,
|
|
|
|
|
|
|
| 35 |
Peer,
|
| 36 |
SignalingServer,
|
|
|
|
|
|
|
| 37 |
_rate_limit_buckets,
|
| 38 |
_rate_limit_key,
|
| 39 |
check_rate_limit,
|
|
|
|
|
|
|
| 40 |
)
|
| 41 |
|
| 42 |
|
|
@@ -655,3 +662,313 @@ def test_rate_limit_default_capacity_matches_industry_baseline():
|
|
| 655 |
"""
|
| 656 |
assert RATE_LIMIT_REQUESTS >= 600
|
| 657 |
assert RATE_LIMIT_WINDOW <= 60.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
import pytest
|
| 31 |
|
| 32 |
from app import (
|
| 33 |
+
PRODUCER_LEASE_SECONDS,
|
| 34 |
RATE_LIMIT_REQUESTS,
|
| 35 |
RATE_LIMIT_WINDOW,
|
| 36 |
+
TOKEN_CACHE_STALE_GRACE_SECONDS,
|
| 37 |
+
TOKEN_CACHE_TTL_SECONDS,
|
| 38 |
Peer,
|
| 39 |
SignalingServer,
|
| 40 |
+
_prune_rate_limit_buckets,
|
| 41 |
+
_prune_token_cache,
|
| 42 |
_rate_limit_buckets,
|
| 43 |
_rate_limit_key,
|
| 44 |
check_rate_limit,
|
| 45 |
+
token_cache,
|
| 46 |
+
validate_hf_token,
|
| 47 |
)
|
| 48 |
|
| 49 |
|
|
|
|
| 662 |
"""
|
| 663 |
assert RATE_LIMIT_REQUESTS >= 600
|
| 664 |
assert RATE_LIMIT_WINDOW <= 60.0
|
| 665 |
+
|
| 666 |
+
|
| 667 |
+
def test_prune_rate_limit_buckets_drops_aged_and_empty(
|
| 668 |
+
_clean_rate_limit_buckets,
|
| 669 |
+
):
|
| 670 |
+
"""The sweeper-side prune must reclaim buckets of departed peers
|
| 671 |
+
(all timestamps aged out) and empty leftovers, but never an active
|
| 672 |
+
bucket.
|
| 673 |
+
"""
|
| 674 |
+
now = time.monotonic()
|
| 675 |
+
_rate_limit_buckets[_rate_limit_key("tok-dead")] = deque(
|
| 676 |
+
[now - RATE_LIMIT_WINDOW - 5.0]
|
| 677 |
+
)
|
| 678 |
+
_rate_limit_buckets[_rate_limit_key("tok-empty")] = deque()
|
| 679 |
+
_rate_limit_buckets[_rate_limit_key("tok-live")] = deque([now])
|
| 680 |
+
|
| 681 |
+
_prune_rate_limit_buckets()
|
| 682 |
+
|
| 683 |
+
assert _rate_limit_key("tok-dead") not in _rate_limit_buckets
|
| 684 |
+
assert _rate_limit_key("tok-empty") not in _rate_limit_buckets
|
| 685 |
+
assert _rate_limit_key("tok-live") in _rate_limit_buckets
|
| 686 |
+
|
| 687 |
+
|
| 688 |
+
# ----------------------------------------------------------------------
|
| 689 |
+
# Liveness: last_seen refresh + stale-producer sweep
|
| 690 |
+
# ----------------------------------------------------------------------
|
| 691 |
+
|
| 692 |
+
|
| 693 |
+
HEARTBEATING_META = {"name": "r", "transport": "wifi", "hardware_id": "cafe1234"}
|
| 694 |
+
LEGACY_META = {"name": "old-r", "transport": "wifi"} # pre-v1.7.2: no hardware_id
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
@pytest.mark.asyncio
|
| 698 |
+
async def test_inbound_message_refreshes_last_seen():
|
| 699 |
+
"""Any POST /send traffic (here: a heartbeat setPeerStatus routed
|
| 700 |
+
through handle_message) must refresh last_seen - this is the signal
|
| 701 |
+
the sweep keys on, and what makes last_seen_age_seconds an honest
|
| 702 |
+
liveness metric for clients.
|
| 703 |
+
"""
|
| 704 |
+
server = _make_server()
|
| 705 |
+
p = _make_peer(server)
|
| 706 |
+
p.last_seen = time.monotonic() - 1000.0
|
| 707 |
+
|
| 708 |
+
await server.handle_message(
|
| 709 |
+
p, {"type": "setPeerStatus", "roles": ["producer"], "meta": HEARTBEATING_META}
|
| 710 |
+
)
|
| 711 |
+
|
| 712 |
+
assert time.monotonic() - p.last_seen < 1.0
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
@pytest.mark.asyncio
|
| 716 |
+
async def test_sweep_evicts_stale_heartbeating_producer():
|
| 717 |
+
"""A heartbeat-capable producer silent for a full lease is a
|
| 718 |
+
half-open socket: full eviction, and same-user listeners are told.
|
| 719 |
+
"""
|
| 720 |
+
server = _make_server()
|
| 721 |
+
ghost = server.get_or_create_peer(token="tok-ghost", username="alice")
|
| 722 |
+
listener = _make_peer(server, username="alice")
|
| 723 |
+
await server.handle_message(
|
| 724 |
+
ghost,
|
| 725 |
+
{"type": "setPeerStatus", "roles": ["producer"], "meta": HEARTBEATING_META},
|
| 726 |
+
)
|
| 727 |
+
while not listener.message_queue.empty():
|
| 728 |
+
listener.message_queue.get_nowait()
|
| 729 |
+
|
| 730 |
+
ghost.last_seen = time.monotonic() - PRODUCER_LEASE_SECONDS - 1.0
|
| 731 |
+
evicted = await server.sweep_stale_producers()
|
| 732 |
+
|
| 733 |
+
assert evicted == [ghost.peer_id]
|
| 734 |
+
assert ghost.peer_id not in server.producers
|
| 735 |
+
assert ghost.peer_id not in server.peers
|
| 736 |
+
assert "tok-ghost" not in server.token_to_peer
|
| 737 |
+
|
| 738 |
+
msg = listener.message_queue.get_nowait()
|
| 739 |
+
assert msg["type"] == "peerStatusChanged"
|
| 740 |
+
assert msg["roles"] == []
|
| 741 |
+
|
| 742 |
+
|
| 743 |
+
@pytest.mark.asyncio
|
| 744 |
+
async def test_sweep_spares_legacy_producer_without_hardware_id():
|
| 745 |
+
"""Daemons < v1.7.2 never heartbeat and cannot self-heal after an
|
| 746 |
+
eviction (no producer health loop). They must keep today's
|
| 747 |
+
behaviour verbatim: never swept, however stale.
|
| 748 |
+
"""
|
| 749 |
+
server = _make_server()
|
| 750 |
+
legacy = _make_peer(server)
|
| 751 |
+
await server.handle_message(
|
| 752 |
+
legacy, {"type": "setPeerStatus", "roles": ["producer"], "meta": LEGACY_META}
|
| 753 |
+
)
|
| 754 |
+
legacy.last_seen = time.monotonic() - 10 * PRODUCER_LEASE_SECONDS
|
| 755 |
+
|
| 756 |
+
evicted = await server.sweep_stale_producers()
|
| 757 |
+
|
| 758 |
+
assert evicted == []
|
| 759 |
+
assert legacy.peer_id in server.producers
|
| 760 |
+
|
| 761 |
+
|
| 762 |
+
@pytest.mark.asyncio
|
| 763 |
+
async def test_sweep_spares_fresh_producer():
|
| 764 |
+
server = _make_server()
|
| 765 |
+
fresh = _make_peer(server)
|
| 766 |
+
await server.handle_message(
|
| 767 |
+
fresh,
|
| 768 |
+
{"type": "setPeerStatus", "roles": ["producer"], "meta": HEARTBEATING_META},
|
| 769 |
+
)
|
| 770 |
+
|
| 771 |
+
evicted = await server.sweep_stale_producers()
|
| 772 |
+
|
| 773 |
+
assert evicted == []
|
| 774 |
+
assert fresh.peer_id in server.producers
|
| 775 |
+
|
| 776 |
+
|
| 777 |
+
@pytest.mark.asyncio
|
| 778 |
+
async def test_sweep_ends_ghost_session_and_notifies_consumer():
|
| 779 |
+
"""Sweeping a producer that died mid-session must free the session
|
| 780 |
+
slot and push endSession to the surviving consumer.
|
| 781 |
+
"""
|
| 782 |
+
server = _make_server()
|
| 783 |
+
producer = _make_peer(server, username="alice")
|
| 784 |
+
consumer = _make_peer(server, username="alice")
|
| 785 |
+
await server.handle_message(
|
| 786 |
+
producer,
|
| 787 |
+
{"type": "setPeerStatus", "roles": ["producer"], "meta": HEARTBEATING_META},
|
| 788 |
+
)
|
| 789 |
+
response = await server.handle_start_session(
|
| 790 |
+
consumer, {"peerId": producer.peer_id}
|
| 791 |
+
)
|
| 792 |
+
assert response["type"] == "sessionStarted"
|
| 793 |
+
while not consumer.message_queue.empty():
|
| 794 |
+
consumer.message_queue.get_nowait()
|
| 795 |
+
|
| 796 |
+
producer.last_seen = time.monotonic() - PRODUCER_LEASE_SECONDS - 1.0
|
| 797 |
+
await server.sweep_stale_producers()
|
| 798 |
+
|
| 799 |
+
assert not server.sessions
|
| 800 |
+
assert consumer.session_id is None
|
| 801 |
+
types = []
|
| 802 |
+
while not consumer.message_queue.empty():
|
| 803 |
+
types.append(consumer.message_queue.get_nowait()["type"])
|
| 804 |
+
assert "endSession" in types
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
# ----------------------------------------------------------------------
|
| 808 |
+
# Stable-id collisions: hardware_id joins install_id
|
| 809 |
+
# ----------------------------------------------------------------------
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
@pytest.mark.asyncio
|
| 813 |
+
async def test_hardware_id_collision_evicts_older_producer():
|
| 814 |
+
"""A robot re-provisioned with a fresh token registers under a new
|
| 815 |
+
peer while its old registration lingers (half-open ghost).
|
| 816 |
+
hardware_id - shipped by every daemon >= v1.7.2 - must trigger the
|
| 817 |
+
last-writer-wins eviction that install_id (not emitted by any
|
| 818 |
+
shipped daemon) was designed for.
|
| 819 |
+
"""
|
| 820 |
+
server = _make_server()
|
| 821 |
+
old = _make_peer(server, username="alice")
|
| 822 |
+
await server.handle_set_peer_status(
|
| 823 |
+
old, {"roles": ["producer"], "meta": {"name": "r", "hardware_id": "cafe1234"}}
|
| 824 |
+
)
|
| 825 |
+
|
| 826 |
+
new = _make_peer(server, username="alice")
|
| 827 |
+
await server.handle_set_peer_status(
|
| 828 |
+
new, {"roles": ["producer"], "meta": {"name": "r", "hardware_id": "cafe1234"}}
|
| 829 |
+
)
|
| 830 |
+
|
| 831 |
+
assert old.peer_id not in server.producers
|
| 832 |
+
assert old.peer_id not in server.peers
|
| 833 |
+
assert new.peer_id in server.producers
|
| 834 |
+
|
| 835 |
+
|
| 836 |
+
@pytest.mark.asyncio
|
| 837 |
+
async def test_hardware_id_collision_scoped_to_owner():
|
| 838 |
+
server = _make_server()
|
| 839 |
+
alices = _make_peer(server, username="alice")
|
| 840 |
+
await server.handle_set_peer_status(
|
| 841 |
+
alices, {"roles": ["producer"], "meta": {"name": "r", "hardware_id": "cafe1234"}}
|
| 842 |
+
)
|
| 843 |
+
|
| 844 |
+
bobs = _make_peer(server, username="bob")
|
| 845 |
+
await server.handle_set_peer_status(
|
| 846 |
+
bobs, {"roles": ["producer"], "meta": {"name": "r", "hardware_id": "cafe1234"}}
|
| 847 |
+
)
|
| 848 |
+
|
| 849 |
+
assert alices.peer_id in server.producers, "cross-tenant eviction is forbidden"
|
| 850 |
+
assert bobs.peer_id in server.producers
|
| 851 |
+
|
| 852 |
+
|
| 853 |
+
# ----------------------------------------------------------------------
|
| 854 |
+
# Token cache: TTL, serve-stale-on-error, explicit-rejection drop
|
| 855 |
+
# ----------------------------------------------------------------------
|
| 856 |
+
|
| 857 |
+
|
| 858 |
+
@pytest.fixture
|
| 859 |
+
def _clean_token_cache():
|
| 860 |
+
snapshot = dict(token_cache)
|
| 861 |
+
token_cache.clear()
|
| 862 |
+
try:
|
| 863 |
+
yield
|
| 864 |
+
finally:
|
| 865 |
+
token_cache.clear()
|
| 866 |
+
token_cache.update(snapshot)
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
class _FakeResponse:
|
| 870 |
+
def __init__(self, status_code: int, name: str = ""):
|
| 871 |
+
self.status_code = status_code
|
| 872 |
+
self._name = name
|
| 873 |
+
|
| 874 |
+
def json(self):
|
| 875 |
+
return {"name": self._name}
|
| 876 |
+
|
| 877 |
+
|
| 878 |
+
class _FakeAsyncClient:
|
| 879 |
+
"""Stands in for httpx.AsyncClient; behaviour set per test."""
|
| 880 |
+
|
| 881 |
+
response: _FakeResponse | None = None
|
| 882 |
+
error: Exception | None = None
|
| 883 |
+
calls: int = 0
|
| 884 |
+
|
| 885 |
+
async def __aenter__(self):
|
| 886 |
+
return self
|
| 887 |
+
|
| 888 |
+
async def __aexit__(self, *exc):
|
| 889 |
+
return False
|
| 890 |
+
|
| 891 |
+
async def get(self, *args, **kwargs):
|
| 892 |
+
type(self).calls += 1
|
| 893 |
+
if type(self).error is not None:
|
| 894 |
+
raise type(self).error
|
| 895 |
+
return type(self).response
|
| 896 |
+
|
| 897 |
+
|
| 898 |
+
@pytest.fixture
|
| 899 |
+
def _fake_whoami(monkeypatch):
|
| 900 |
+
import httpx
|
| 901 |
+
|
| 902 |
+
_FakeAsyncClient.response = None
|
| 903 |
+
_FakeAsyncClient.error = None
|
| 904 |
+
_FakeAsyncClient.calls = 0
|
| 905 |
+
monkeypatch.setattr(httpx, "AsyncClient", _FakeAsyncClient)
|
| 906 |
+
return _FakeAsyncClient
|
| 907 |
+
|
| 908 |
+
|
| 909 |
+
@pytest.mark.asyncio
|
| 910 |
+
async def test_fresh_cache_hit_skips_network(_clean_token_cache, _fake_whoami):
|
| 911 |
+
token_cache["tok"] = ("alice", time.monotonic() + TOKEN_CACHE_TTL_SECONDS)
|
| 912 |
+
assert await validate_hf_token("tok") == "alice"
|
| 913 |
+
assert _fake_whoami.calls == 0
|
| 914 |
+
|
| 915 |
+
|
| 916 |
+
@pytest.mark.asyncio
|
| 917 |
+
async def test_expired_entry_revalidates_and_refreshes_ttl(
|
| 918 |
+
_clean_token_cache, _fake_whoami
|
| 919 |
+
):
|
| 920 |
+
token_cache["tok"] = ("alice", time.monotonic() - 1.0)
|
| 921 |
+
_fake_whoami.response = _FakeResponse(200, "alice")
|
| 922 |
+
|
| 923 |
+
assert await validate_hf_token("tok") == "alice"
|
| 924 |
+
assert _fake_whoami.calls == 1
|
| 925 |
+
assert token_cache["tok"][1] > time.monotonic(), "TTL must be refreshed"
|
| 926 |
+
|
| 927 |
+
|
| 928 |
+
@pytest.mark.asyncio
|
| 929 |
+
async def test_revoked_token_is_dropped_on_explicit_rejection(
|
| 930 |
+
_clean_token_cache, _fake_whoami
|
| 931 |
+
):
|
| 932 |
+
"""The point of the TTL: a token revoked on HF must stop working
|
| 933 |
+
within one TTL, not survive until the next Space restart.
|
| 934 |
+
"""
|
| 935 |
+
token_cache["tok"] = ("alice", time.monotonic() - 1.0)
|
| 936 |
+
_fake_whoami.response = _FakeResponse(401)
|
| 937 |
+
|
| 938 |
+
assert await validate_hf_token("tok") is None
|
| 939 |
+
assert "tok" not in token_cache
|
| 940 |
+
|
| 941 |
+
|
| 942 |
+
@pytest.mark.asyncio
|
| 943 |
+
async def test_stale_entry_served_during_validation_outage(
|
| 944 |
+
_clean_token_cache, _fake_whoami
|
| 945 |
+
):
|
| 946 |
+
"""A whoami outage must degrade to stale identities, not 401 the
|
| 947 |
+
whole fleet.
|
| 948 |
+
"""
|
| 949 |
+
token_cache["tok"] = ("alice", time.monotonic() - 1.0)
|
| 950 |
+
_fake_whoami.error = ConnectionError("whoami down")
|
| 951 |
+
|
| 952 |
+
assert await validate_hf_token("tok") == "alice"
|
| 953 |
+
assert "tok" in token_cache, "stale entry must survive for the next retry"
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
@pytest.mark.asyncio
|
| 957 |
+
async def test_unknown_token_fails_closed_during_outage(
|
| 958 |
+
_clean_token_cache, _fake_whoami
|
| 959 |
+
):
|
| 960 |
+
_fake_whoami.error = ConnectionError("whoami down")
|
| 961 |
+
assert await validate_hf_token("never-seen") is None
|
| 962 |
+
|
| 963 |
+
|
| 964 |
+
def test_prune_token_cache_drops_only_beyond_grace(_clean_token_cache):
|
| 965 |
+
now = time.monotonic()
|
| 966 |
+
token_cache["tok-ancient"] = ("a", now - TOKEN_CACHE_STALE_GRACE_SECONDS - 1.0)
|
| 967 |
+
token_cache["tok-stale"] = ("b", now - 10.0) # expired but within grace
|
| 968 |
+
token_cache["tok-fresh"] = ("c", now + TOKEN_CACHE_TTL_SECONDS)
|
| 969 |
+
|
| 970 |
+
_prune_token_cache()
|
| 971 |
+
|
| 972 |
+
assert "tok-ancient" not in token_cache
|
| 973 |
+
assert "tok-stale" in token_cache
|
| 974 |
+
assert "tok-fresh" in token_cache
|