fix: zero-session devices show 'No sessions' tile + grace period prevents flapping

Two federation UX fixes:

1. Zero-session devices: when remote returns empty sessions list, return
   {status: 'empty'} entry. Frontend renders 'No sessions' status tile
   instead of making the device invisible.

2. Flapping prevention: server-side cache of last-known-good federation
   results per remote. On transient failure, return cached sessions for
   up to 3 consecutive failures before marking unreachable. Eliminates
   the visible on/off/on/off pattern caused by occasional 5-second
   timeouts during 2-second poll cycles.

Tests added:
- test_fetch_remote_returns_empty_status_for_zero_sessions (Python)
- test_fetch_remote_uses_cache_on_transient_failure (Python)
- test_fetch_remote_marks_unreachable_after_grace_period (Python)
- renderGrid shows 'No sessions' status tile for status=empty devices (JS)

Also adds reset_federation_cache autouse fixture to prevent cross-test
contamination from the new module-level _federation_cache dict.
This commit is contained in:
Brian Krabach
2026-04-13 14:12:18 -07:00
parent 26a4d30d6f
commit f7519a8058
5 changed files with 305 additions and 3 deletions
+3 -1
View File
@@ -762,6 +762,7 @@ function renderGrid(sessions) {
(sessions || []).forEach(function(session) { (sessions || []).forEach(function(session) {
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth'); if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline'); else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty');
}); });
if (grid) grid.innerHTML = statusTilesHtml; if (grid) grid.innerHTML = statusTilesHtml;
// Only show empty-state when there are truly no tiles at all // Only show empty-state when there are truly no tiles at all
@@ -800,11 +801,12 @@ function renderGrid(sessions) {
html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join(''); html = ordered.map(function(session, index) { return buildTileHTML(session, index, mobile); }).join('');
} }
// Append status tiles for auth_failed and unreachable sessions // Append status tiles for auth_failed, unreachable, and empty sessions
var statusTilesHtml = ''; var statusTilesHtml = '';
(sessions || []).forEach(function(session) { (sessions || []).forEach(function(session) {
if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth'); if (session.status === 'auth_failed') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Auth required', 'auth');
else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline'); else if (session.status === 'unreachable') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'Offline', 'offline');
else if (session.status === 'empty') statusTilesHtml += buildStatusTileHTML(session.deviceName, 'No sessions', 'empty');
}); });
if (grid) grid.innerHTML = html + statusTilesHtml; if (grid) grid.innerHTML = html + statusTilesHtml;
+5
View File
@@ -1002,6 +1002,11 @@ body {
border-style: dashed; border-style: dashed;
} }
.source-tile--empty {
opacity: 0.6;
border-style: dashed;
}
.source-tile__name { .source-tile__name {
font-size: 15px; font-size: 15px;
font-weight: 600; font-weight: 600;
+32
View File
@@ -4758,3 +4758,35 @@ test('renderGrid status tiles use session.deviceName not session.name for offlin
globalThis.document.getElementById = origGetById; globalThis.document.getElementById = origGetById;
}); });
// --- renderGrid: status=empty shows "No sessions" tile ---
test('renderGrid shows "No sessions" status tile for status=empty devices', () => {
// A device that is online but has zero tmux sessions returns
// {status: 'empty', deviceName: '...'} from the federation endpoint.
// renderGrid must render a status tile with the text "No sessions" (not blank).
//
// Before implementation: fails because neither status loop handles status === 'empty',
// so the tile is never rendered and grid.innerHTML stays empty.
const grid = { innerHTML: '' };
const emptyState = { style: {}, classList: { add() {}, remove() {} } };
const origGetById = globalThis.document.getElementById;
globalThis.document.getElementById = (id) => {
if (id === 'session-grid') return grid;
if (id === 'empty-state') return emptyState;
return null;
};
app.renderGrid([{ status: 'empty', deviceName: 'quiet-box', remoteId: 3 }]);
assert.ok(
grid.innerHTML.includes('No sessions'),
`renderGrid must include "No sessions" text for status=empty device, got: ${grid.innerHTML}`
);
assert.ok(
grid.innerHTML.includes('quiet-box'),
`renderGrid must include the deviceName "quiet-box" in the status tile, got: ${grid.innerHTML}`
);
globalThis.document.getElementById = origGetById;
});
+37 -2
View File
@@ -1182,6 +1182,13 @@ async def auth_mode_endpoint():
return {"mode": _auth_mode, "user": username} return {"mode": _auth_mode, "user": username}
# Module-level cache: remote_id → {"sessions": [...], "fail_count": int}
# Populated by fetch_remote() on every successful poll; returned on transient failures
# so a single slow/dropped request doesn't immediately evict a device from the UI.
_federation_cache: dict[int, dict] = {}
_FEDERATION_GRACE_FAILURES = 3 # consecutive failures before marking unreachable
@app.get("/api/federation/sessions") @app.get("/api/federation/sessions")
async def federation_sessions(request: Request) -> list[dict]: async def federation_sessions(request: Request) -> list[dict]:
"""Fetch sessions from all instances (local + remotes) and merge. """Fetch sessions from all instances (local + remotes) and merge.
@@ -1220,7 +1227,12 @@ async def federation_sessions(request: Request) -> list[dict]:
http_client: httpx.AsyncClient = request.app.state.federation_client http_client: httpx.AsyncClient = request.app.state.federation_client
async def fetch_remote(i: int, remote: dict) -> list[dict]: async def fetch_remote(i: int, remote: dict) -> list[dict]:
"""Fetch /api/sessions from a remote instance, returning session dicts or a status entry.""" """Fetch /api/sessions from a remote instance, returning session dicts or a status entry.
On success: cache the result and return tagged sessions (or {status: 'empty'} if none).
On transient failure: return cached sessions for up to _FEDERATION_GRACE_FAILURES
consecutive failures before promoting to {status: 'unreachable'}.
"""
url: str = remote.get("url", "") url: str = remote.get("url", "")
key: str = remote.get("key", "") key: str = remote.get("key", "")
remote_name: str = remote.get("name", url) remote_name: str = remote.get("name", url)
@@ -1231,6 +1243,8 @@ async def federation_sessions(request: Request) -> list[dict]:
headers={"Authorization": f"Bearer {key}"} if key else {}, headers={"Authorization": f"Bearer {key}"} if key else {},
) )
if resp.status_code in (401, 403): if resp.status_code in (401, 403):
# Auth failure — clear cache so stale data is not served
_federation_cache.pop(remote_id, None)
return [ return [
{ {
"status": "auth_failed", "status": "auth_failed",
@@ -1241,7 +1255,7 @@ async def federation_sessions(request: Request) -> list[dict]:
resp.raise_for_status() resp.raise_for_status()
sessions = resp.json() sessions = resp.json()
# Tag each session with deviceName, remoteId, and unique sessionKey # Tag each session with deviceName, remoteId, and unique sessionKey
return [ tagged = [
{ {
**s, **s,
"deviceName": remote_name, "deviceName": remote_name,
@@ -1250,7 +1264,24 @@ async def federation_sessions(request: Request) -> list[dict]:
} }
for s in sessions for s in sessions
] ]
# Update cache on every successful poll (even empty)
_federation_cache[remote_id] = {"sessions": tagged, "fail_count": 0}
if not tagged:
# Device is online but has zero tmux sessions — show a status tile
# rather than making the device completely invisible.
return [
{
"status": "empty",
"remoteId": remote_id,
"deviceName": remote_name,
}
]
return tagged
except httpx.HTTPStatusError: except httpx.HTTPStatusError:
cached = _federation_cache.get(remote_id)
if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES:
cached["fail_count"] += 1
return cached["sessions"]
return [ return [
{ {
"status": "unreachable", "status": "unreachable",
@@ -1260,6 +1291,10 @@ async def federation_sessions(request: Request) -> list[dict]:
] ]
except Exception as exc: except Exception as exc:
_log.warning("Unexpected error fetching remote %s: %s", url, exc) _log.warning("Unexpected error fetching remote %s: %s", url, exc)
cached = _federation_cache.get(remote_id)
if cached and cached["fail_count"] < _FEDERATION_GRACE_FAILURES:
cached["fail_count"] += 1
return cached["sessions"]
return [ return [
{ {
"status": "unreachable", "status": "unreachable",
+228
View File
@@ -41,6 +41,22 @@ def patch_startup_and_state(tmp_path, monkeypatch):
monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop) monkeypatch.setattr("muxplex.main._poll_loop", noop_poll_loop)
@pytest.fixture(autouse=True)
def reset_federation_cache():
"""Clear _federation_cache before and after each test.
The module-level _federation_cache persists across tests in the same process,
causing cross-test contamination: a test that populates the cache for remoteId=0
causes a later test (also using remoteId=0) to get stale cached data instead of
the expected unreachable status.
"""
import muxplex.main as main_mod
main_mod._federation_cache.clear()
yield
main_mod._federation_cache.clear()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Client fixture — TestClient with lifespan enabled # Client fixture — TestClient with lifespan enabled
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -3036,3 +3052,215 @@ def test_put_settings_sync_ignores_nonsyncable_keys(client, tmp_path, monkeypatc
assert local["host"] == "127.0.0.1", ( assert local["host"] == "127.0.0.1", (
f"Non-syncable key 'host' must remain '127.0.0.1', got: {local['host']}" f"Non-syncable key 'host' must remain '127.0.0.1', got: {local['host']}"
) )
# ---------------------------------------------------------------------------
# fetch_remote: zero-session visibility and flapping grace period
# ---------------------------------------------------------------------------
def test_fetch_remote_returns_empty_status_for_zero_sessions(
client, monkeypatch, tmp_path
):
"""When remote /api/sessions returns [], federation returns {status: 'empty'} entry.
A device that is online but has zero tmux sessions must not vanish silently.
Instead, the endpoint must include a {status: 'empty', deviceName: ...} entry
so the frontend can render a 'No sessions' tile.
Before implementation: fails because the list comprehension returns [] for empty
session lists, and the empty list is flattened into nothing.
"""
import json
from unittest.mock import MagicMock
import httpx
import muxplex.settings as settings_mod
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
settings_path.write_text(
json.dumps(
{
"remote_instances": [
{
"url": "http://empty-host:8088",
"key": "secret",
"name": "empty-host",
}
]
}
)
)
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
mock_resp = MagicMock(spec=httpx.Response)
mock_resp.status_code = 200
mock_resp.json.return_value = []
mock_resp.raise_for_status.return_value = None
async def mock_get(*args, **kwargs):
return mock_resp
mock_fed_client = MagicMock()
mock_fed_client.get = mock_get
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
response = client.get("/api/federation/sessions")
assert response.status_code == 200
data = response.json()
assert len(data) == 1, (
f"Expected exactly one status entry for empty remote, got {len(data)} entries: {data}"
)
assert data[0].get("status") == "empty", (
f"Expected status='empty' for zero-session remote, got: {data[0].get('status')!r}"
)
assert data[0].get("deviceName") == "empty-host", (
f"Expected deviceName='empty-host', got: {data[0].get('deviceName')!r}"
)
def test_fetch_remote_uses_cache_on_transient_failure(client, monkeypatch, tmp_path):
"""When remote fails after a prior success, cached sessions are returned (grace period).
A single failed HTTP request must not immediately evict the device from the UI.
The server keeps the last-known-good result and returns it for up to
_FEDERATION_GRACE_FAILURES consecutive failures.
Before implementation: fails because fetch_remote has no cache — transient failure
immediately returns {status: 'unreachable'}.
"""
import json
from unittest.mock import MagicMock
import httpx
import muxplex.main as main_mod
import muxplex.settings as settings_mod
# Reset module-level cache so this test starts clean
monkeypatch.setattr(main_mod, "_federation_cache", {})
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
settings_path.write_text(
json.dumps(
{
"remote_instances": [
{"url": "http://remote:8088", "key": "k", "name": "cache-host"}
]
}
)
)
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
call_count = [0]
async def mock_get_stateful(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
mock_resp = MagicMock(spec=httpx.Response)
mock_resp.status_code = 200
mock_resp.json.return_value = [{"name": "sess1"}, {"name": "sess2"}]
mock_resp.raise_for_status.return_value = None
return mock_resp
raise httpx.TimeoutException("timeout", request=MagicMock())
mock_fed_client = MagicMock()
mock_fed_client.get = mock_get_stateful
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
# First call: succeeds and populates cache
r1 = client.get("/api/federation/sessions")
assert r1.status_code == 200
d1 = [s for s in r1.json() if s.get("deviceName") == "cache-host"]
assert len(d1) == 2, f"First call must return 2 sessions, got {d1}"
# Second call: remote times out — cache should return the 2 cached sessions
r2 = client.get("/api/federation/sessions")
assert r2.status_code == 200
d2 = [s for s in r2.json() if s.get("deviceName") == "cache-host"]
assert len(d2) == 2, f"Within grace period, must return 2 cached sessions, got {d2}"
assert not any(s.get("status") == "unreachable" for s in d2), (
"Within grace period, cached sessions must be returned, not 'unreachable'"
)
def test_fetch_remote_marks_unreachable_after_grace_period(
client, monkeypatch, tmp_path
):
"""After _FEDERATION_GRACE_FAILURES consecutive failures, device is marked unreachable.
The grace period prevents flapping, but must not hide a genuinely offline device
indefinitely. After 3 consecutive failures the next poll must return
{status: 'unreachable'}.
Before implementation: fails because there is no cache at all — unreachable is
returned immediately on first failure.
"""
import json
from unittest.mock import MagicMock
import httpx
import muxplex.main as main_mod
import muxplex.settings as settings_mod
# Reset module-level cache so this test starts clean
monkeypatch.setattr(main_mod, "_federation_cache", {})
settings_path = tmp_path / "settings.json"
monkeypatch.setattr(settings_mod, "SETTINGS_PATH", settings_path)
settings_path.write_text(
json.dumps(
{
"remote_instances": [
{"url": "http://remote:8088", "key": "k", "name": "grace-host"}
]
}
)
)
monkeypatch.setattr("muxplex.main.get_session_list", lambda: [])
monkeypatch.setattr("muxplex.main.get_snapshots", lambda: {})
call_count = [0]
async def mock_get_stateful(*args, **kwargs):
call_count[0] += 1
if call_count[0] == 1:
mock_resp = MagicMock(spec=httpx.Response)
mock_resp.status_code = 200
mock_resp.json.return_value = [{"name": "sess1"}]
mock_resp.raise_for_status.return_value = None
return mock_resp
raise httpx.TimeoutException("timeout", request=MagicMock())
mock_fed_client = MagicMock()
mock_fed_client.get = mock_get_stateful
monkeypatch.setattr(client.app.state, "federation_client", mock_fed_client)
# Call 1: success — populates cache
r = client.get("/api/federation/sessions")
d = r.json()
assert any(s.get("name") == "sess1" for s in d), "Call 1 must return sess1"
# Calls 2-4 (failures 1-3): within grace period — must return cached sessions
for i in range(3):
r = client.get("/api/federation/sessions")
d = r.json()
host_entries = [s for s in d if s.get("deviceName") == "grace-host"]
assert not any(s.get("status") == "unreachable" for s in host_entries), (
f"Call {i + 2}: fail_count={i + 1} is within grace period — "
f"must return cached sessions, not 'unreachable'. Got: {host_entries}"
)
# Call 5 (failure 4): exceeds grace period — must return unreachable
r = client.get("/api/federation/sessions")
d = r.json()
host_entries = [s for s in d if s.get("deviceName") == "grace-host"]
assert any(s.get("status") == "unreachable" for s in host_entries), (
f"After exceeding grace period, device must be marked 'unreachable'. Got: {host_entries}"
)