tfrere HF Staff Cursor commited on
Commit
b016c2d
·
1 Parent(s): 5c4dc59

feat(liveness): stale-producer sweep, SSE generation guard, hardware_id dedup, token cache TTL

Browse files

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

Files changed (3) hide show
  1. README.md +16 -6
  2. app.py +295 -73
  3. test_signaling.py +317 -0
README.md CHANGED
@@ -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
- - Command relay between robot and client
20
  - Real-time status monitoring
21
 
22
- ## WebSocket Endpoint
23
 
24
- Connect to: `wss://<space-url>/ws`
 
 
 
 
 
 
 
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.
app.py CHANGED
@@ -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. It does **not** decide when a session is
14
- dead: liveness is owned by the daemon, which has direct visibility into
15
- the peer connection's data channel and ICE state. When the daemon's
16
- watchdog decides a session is idle, it closes its PC and announces
17
- availability via ``setPeerStatus`` (or sends an explicit ``endSession``);
18
- central reacts to those messages rather than driving evictions of its
19
- own. Removing the central-side TTL sweeper fixes a class of bugs where
20
- SSE back-pressure on a healthy consumer would tear down a healthy media
21
- session.
 
 
 
 
 
 
 
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
- # Central no longer evicts peers on a TTL. Session liveness is owned by
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
- # The ``Peer.last_seen`` field is retained for diagnostics
71
- # (/api/robot-status, /api/debug/peers) but does not drive any eviction.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
 
74
  def _session_state_changed_payload(
@@ -95,7 +128,21 @@ def _session_state_changed_payload(
95
  }
96
 
97
 
98
- app = FastAPI(title="Reachy Mini Central")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (token -> username)
116
- token_cache: dict[str, str] = {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 (6 req/min), a typical mobile
140
- # session (offer + answer + ~10 ICE candidates ~ 15 req over a few
141
- # seconds), and aggressive reconnects, observed peak under load is
142
- # ~50-100 req/min/peer. We keep a 12-24x headroom so adding features
143
- # (status polls, presence, etc.) does not require retuning the limit.
 
 
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
- # Check cache first
268
- if token in token_cache:
269
- return token_cache[token]
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] = username
 
 
 
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 **diagnostic-only**: it is set when the peer is
298
- created (and on reconnect via ``get_or_create_peer``) and surfaced
299
- by /api/robot-status and /api/debug/peers so operators can see
300
- how recently a peer was registered. It does NOT drive any eviction
301
- decision; session liveness is owned by the daemon (see module
302
- docstring).
 
 
 
 
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`` collides with an existing producer
 
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._evict_install_id_collisions(peer, meta)
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 _evict_install_id_collisions(self, new_peer: Peer, new_meta: dict) -> None:
450
- """Last-writer-wins on ``meta.install_id`` collisions.
451
 
452
- A re-flashed daemon, a duplicated SD card, or a stale tray
453
- process can register a producer whose ``install_id`` matches
454
- an already-registered producer of the same user. Without
455
- eviction we'd carry both forever and the mobile app would see
456
- the robot twice. Policy: keep the newcomer, drop the older.
 
457
 
458
- Owner-scoped: a different HF user holding the same
459
- ``install_id`` (collisions are unlikely with UUID4 but
460
- possible during a hardware swap between two accounts) is left
 
 
 
 
 
 
 
 
461
  alone here - cross-tenant collisions are out of scope for the
462
  signaling server, the auth layer above us guarantees isolation.
463
  """
464
- new_install_id = new_meta.get("install_id")
465
- if not new_install_id:
 
 
 
 
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
- if old_peer.meta.get("install_id") != new_install_id:
 
 
 
 
474
  continue
475
  logger.info(
476
- "install_id collision: %s already held by peer %s, evicting older",
477
- new_install_id,
 
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 two places:
725
 
726
  - SSE close path (``request.is_disconnected()`` becoming true:
727
  the peer's HTTP channel closed cleanly).
728
- - ``_evict_install_id_collisions``, when a duplicate registers.
 
 
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. Central
804
- # no longer advertises a lease / heartbeat interval: liveness
805
- # is owned by the daemon, which decides for itself when to
806
- # tear down an idle PC and ``setPeerStatus`` itself back to
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
- # for minutes behind HTTP/2 proxies. The daemon is the
830
- # authoritative liveness owner and will close its end
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(peer.message_queue.get(), timeout=30.0)
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
- await signaling.disconnect_peer(peer.peer_id)
 
 
 
 
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?token=...</code> - SSE stream for receiving messages</li>
910
- <li><code>POST /send?token=...</code> - Send messages to server</li>
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
- peer entry was last refreshed on central (registration / reconnect).
962
- Diagnostic only central no longer evicts on idleness; the daemon
963
- owns session liveness end-to-end.
 
 
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 &lt;HF token&gt;</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:
test_signaling.py CHANGED
@@ -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