| """Idle-session reaper for Gradio demo apps. |
| |
| Gradio's ``demo.unload()`` callback is JavaScript-based and won't fire on |
| browser crash, network drop, or mobile tab kill. This module runs a |
| background thread that closes sessions after a configurable period of |
| inactivity, preventing orphaned sessions from inflating metrics. |
| """ |
|
|
| import threading |
| import time |
| from collections.abc import Callable |
|
|
|
|
| class SessionReaper: |
| """Track per-session activity and reap idle sessions. |
| |
| Args: |
| timeout_s: Seconds of inactivity before a session is reaped. |
| on_expired: Called (outside any lock) with the session hash of each |
| reaped session. The callback is responsible for any cleanup |
| (decrementing counters, logging ``session_end``, etc.). |
| check_interval_s: How often the reaper thread wakes up to scan. |
| """ |
|
|
| def __init__( |
| self, |
| timeout_s: float, |
| on_expired: Callable[[str], None], |
| check_interval_s: float = 30.0, |
| ) -> None: |
| self._timeout_s = timeout_s |
| self._on_expired = on_expired |
| self._check_interval_s = check_interval_s |
| self._sessions: dict[str, float] = {} |
| self._lock = threading.Lock() |
| self._shutting_down = False |
| self._thread = threading.Thread(target=self._run, daemon=True) |
| self._thread.start() |
|
|
| def add(self, session_hash: str) -> None: |
| """Register a new session and record its initial activity.""" |
| with self._lock: |
| self._sessions[session_hash] = time.monotonic() |
|
|
| def remove(self, session_hash: str) -> bool: |
| """Unregister a session. Returns ``True`` if it was tracked.""" |
| with self._lock: |
| return self._sessions.pop(session_hash, None) is not None |
|
|
| def touch(self, session_hash: str) -> bool: |
| """Update last-activity timestamp. Returns ``False`` if unknown.""" |
| with self._lock: |
| if session_hash not in self._sessions: |
| return False |
| self._sessions[session_hash] = time.monotonic() |
| return True |
|
|
| def is_active(self, session_hash: str) -> bool: |
| with self._lock: |
| return session_hash in self._sessions |
|
|
| @property |
| def active_count(self) -> int: |
| with self._lock: |
| return len(self._sessions) |
|
|
| def shutdown(self) -> None: |
| """Signal the reaper thread to stop.""" |
| self._shutting_down = True |
|
|
| def _run(self) -> None: |
| while not self._shutting_down: |
| time.sleep(self._check_interval_s) |
| now = time.monotonic() |
| with self._lock: |
| stale = [ |
| sh |
| for sh, ts in self._sessions.items() |
| if now - ts >= self._timeout_s |
| ] |
| for sh in stale: |
| del self._sessions[sh] |
| |
| for sh in stale: |
| self._on_expired(sh) |
|
|