feat: add heartbeat-driven bell clearing for remote sessions in poll cycle

- Add module-level _federation_client reference for use in background poll task
- Assign _federation_client in lifespan startup; clear on shutdown
- Add step 12 to _run_poll_cycle: iterate devices viewing a remote session
  in fullscreen with recent interaction (<60s), fire POST bell/clear to
  the active remote instance with Bearer auth (fire-and-forget, errors logged)
- Add test_poll_cycle_fires_federation_bell_clear_for_remote_session test

Closes task-3 of federation-state-propagation-plan
This commit is contained in:
Brian Krabach
2026-04-08 18:50:17 -07:00
parent 5eb727fe86
commit f5ae0df9f5
2 changed files with 156 additions and 3 deletions
+45 -3
View File
@@ -81,6 +81,7 @@ _log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
_poll_task: asyncio.Task | None = None
_federation_client: httpx.AsyncClient | None = None
# ---------------------------------------------------------------------------
@@ -131,6 +132,46 @@ async def _run_poll_cycle() -> None:
# 9. Apply bell clear rule (acknowledge bells when device is watching fullscreen)
apply_bell_clear_rule(state)
# 12. Fire bell/clear to the active remote for any device viewing a remote
# session in fullscreen with recent interaction. Fire-and-forget: errors
# are logged and do not abort the rest of the poll cycle.
if _federation_client is not None:
active_remote_id = state.get("active_remote_id")
if active_remote_id is not None:
settings = load_settings()
remote_instances = settings.get("remote_instances", [])
if (
isinstance(active_remote_id, int)
and 0 <= active_remote_id < len(remote_instances)
):
remote = remote_instances[active_remote_id]
remote_url: str = remote.get("url", "").rstrip("/")
remote_key: str = remote.get("key", "")
now = time.time()
for device in state.get("devices", {}).values():
viewing_session = device.get("viewing_session")
view_mode = device.get("view_mode")
last_interaction_at = device.get("last_interaction_at", 0)
if (
viewing_session
and view_mode == "fullscreen"
and (now - last_interaction_at) < 60
):
bell_clear_url = (
f"{remote_url}/api/sessions/{viewing_session}/bell/clear"
)
try:
await _federation_client.post(
bell_clear_url,
headers={"Authorization": f"Bearer {remote_key}"} if remote_key else {},
)
except Exception:
_log.warning(
"federation bell clear failed for %s: %s",
viewing_session,
bell_clear_url,
)
# 10. Prune devices that haven't sent a heartbeat recently
prune_devices(state)
@@ -161,6 +202,7 @@ async def _poll_loop() -> None:
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI):
global _poll_task
global _federation_client
# Startup: kill any orphaned ttyd from a previous muxplex run, then
# start the background poll loop.
@@ -187,6 +229,7 @@ async def lifespan(app: FastAPI):
# Bearer token auth handles authorization. Users who need cert verification
# should use mkcert (CA-trusted) or Tailscale (LE-trusted) certs.
)
_federation_client = app.state.federation_client
yield
@@ -194,6 +237,7 @@ async def lifespan(app: FastAPI):
client = getattr(app.state, "federation_client", None)
if client is not None:
await client.aclose()
_federation_client = None
except Exception:
_log.exception("federation_client aclose error")
finally:
@@ -871,9 +915,7 @@ async def federation_terminal_ws_proxy(websocket: WebSocket, remote_id: int) ->
async with websockets.connect(
ws_url,
subprotocols=[Subprotocol("tty")],
additional_headers={"Authorization": f"Bearer {remote_key}"}
if remote_key
else {},
additional_headers={"Authorization": f"Bearer {remote_key}"} if remote_key else {},
ssl=ssl_context,
) as remote_ws: