feat: add settings sync logic to poll cycle with 30-second interval

- Add SETTINGS_SYNC_INTERVAL = 15 constant (15 poll cycles × 2s ≈ 30s)
- Add _settings_sync_counter module-level counter
- Add _sync_settings_with_remotes() async function that:
  - GETs /api/settings/sync from each remote instance
  - Adopts remote settings if remote timestamp is newer
  - Pushes local settings via PUT if local timestamp is newer
  - Silently skips 404/405 (older muxplex without sync endpoints)
  - Catches all per-remote errors and logs as warnings
- Wire sync call into _run_poll_cycle() step 13 (runs outside state_lock)
- Add test_settings_sync_poll.py with 10 tests covering all sync behaviours
This commit is contained in:
Brian Krabach
2026-04-08 21:15:44 -07:00
parent a0ad4921cf
commit 2ebd3b9929
2 changed files with 333 additions and 0 deletions
+83
View File
@@ -79,6 +79,7 @@ from muxplex.ttyd import kill_orphan_ttyd, kill_ttyd, spawn_ttyd, TTYD_PORT
POLL_INTERVAL: float = float(os.environ.get("POLL_INTERVAL", "2.0"))
SERVER_PORT: int = int(os.environ.get("MUXPLEX_PORT", "8088"))
SETTINGS_SYNC_INTERVAL: int = 15 # sync every ~30 seconds (15 * 2s poll interval)
_log = logging.getLogger(__name__)
@@ -88,6 +89,74 @@ _log = logging.getLogger(__name__)
_poll_task: asyncio.Task | None = None
_federation_client: httpx.AsyncClient | None = None
_settings_sync_counter: int = 0
# ---------------------------------------------------------------------------
# Settings sync
# ---------------------------------------------------------------------------
async def _sync_settings_with_remotes(
settings: dict, http_client: httpx.AsyncClient
) -> None:
"""Sync settings with all reachable remote instances.
For each remote:
- GET /api/settings/sync to retrieve remote timestamp.
- If remote is newer: adopt remote settings via apply_synced_settings().
- If local is newer: push local settings via PUT /api/settings/sync.
- If equal: no action.
Errors are caught per-remote so one unreachable peer doesn't abort others.
404/405 responses from older muxplex instances that lack sync endpoints are
silently skipped.
"""
local_sync = get_syncable_settings()
local_ts = local_sync.get("settings_updated_at", 0.0)
for remote in settings.get("remote_instances", []):
url = remote.get("url", "").rstrip("/")
key = remote.get("key", "")
if not url:
continue
headers = {"Authorization": f"Bearer {key}"} if key else {}
try:
resp = await http_client.get(
f"{url}/api/settings/sync", headers=headers, timeout=5.0
)
if resp.status_code in (404, 405):
# Older muxplex instance without sync endpoint — skip silently.
continue
resp.raise_for_status()
remote_data = resp.json()
remote_ts = remote_data.get("settings_updated_at", 0.0)
if remote_ts > local_ts:
# Remote is newer — adopt.
apply_synced_settings(remote_data.get("settings", {}), remote_ts)
# Refresh local state so subsequent remotes see the updated ts.
local_sync = get_syncable_settings()
local_ts = local_sync.get("settings_updated_at", 0.0)
elif local_ts > remote_ts:
# Local is newer — push.
payload = {
"settings": {
k: local_sync[k]
for k in local_sync
if k != "settings_updated_at"
},
"settings_updated_at": local_ts,
}
await http_client.put(
f"{url}/api/settings/sync",
json=payload,
headers=headers,
timeout=5.0,
)
# If equal: no action.
except Exception as exc:
_log.warning("Settings sync with %s failed: %s", url, exc)
# ---------------------------------------------------------------------------
@@ -97,6 +166,7 @@ _federation_client: httpx.AsyncClient | None = None
async def _run_poll_cycle() -> None:
"""Perform one full poll cycle, all operations executed under state_lock."""
global _settings_sync_counter
async with state_lock:
# 1. Enumerate live tmux sessions
names = await enumerate_sessions()
@@ -184,6 +254,19 @@ async def _run_poll_cycle() -> None:
# 12. Atomically persist the updated state
save_state(state)
# 13. Periodically sync settings with remote instances (every SETTINGS_SYNC_INTERVAL
# poll cycles, ~30 seconds). Runs outside the state_lock to avoid blocking the
# poll cycle while waiting on remote HTTP calls.
_settings_sync_counter += 1
if _settings_sync_counter >= SETTINGS_SYNC_INTERVAL:
_settings_sync_counter = 0
if _federation_client is not None:
settings = load_settings()
try:
await _sync_settings_with_remotes(settings, _federation_client)
except Exception:
_log.exception("settings sync cycle error")
# ---------------------------------------------------------------------------
# Poll loop