From 983ecc675b07034a4955ef0dde7b416b6be609f3 Mon Sep 17 00:00:00 2001 From: Jono Date: Fri, 9 Oct 2026 11:51:30 -0700 Subject: [PATCH] fix(focus): keep the focus watcher alive through query errors A daemon started at login before the GNOME extension owned org.deckd.Focus got ServiceUnknown on its first poll; the exception escaped watch_active_app, ended the focus task, and pinned every client to the Home deck until restart. started_ok stayed true, so /diag hid it and systemd never restarted the process. - watch_active_app logs the first failure of a streak, retries each poll, and logs recovery - /diag focus block gains healthy (backend started, task alive, last poll ok) and query_error (current failure) --- daemon/deckd/diagnostics.py | 20 +++++++-- daemon/deckd/platform.py | 25 ++++++++++- docs/GUIDE.md | 1 + docs/REFERENCE.md | 2 +- tests/test_diagnostics.py | 85 +++++++++++++++++++++++++++++++++++++ tests/test_platform.py | 28 ++++++++++++ 6 files changed, 155 insertions(+), 6 deletions(-) diff --git a/daemon/deckd/diagnostics.py b/daemon/deckd/diagnostics.py index 3e367e0..8262bed 100644 --- a/daemon/deckd/diagnostics.py +++ b/daemon/deckd/diagnostics.py @@ -419,14 +419,28 @@ def task_state(t: asyncio.Task[None] | None) -> str: return "running" focus_backend = getattr(server, "focus_backend", None) + focus_started_ok = getattr(server, "_focus_started_ok", None) + focus_query_error = getattr(focus_backend, "_focus_query_error", None) + # ``healthy`` is the one field to check for "is context switching + # working right now": the backend started, the watcher task is still + # alive, and its latest poll succeeded. ``started_ok`` alone stays + # true after the task dies. None when focus is disabled. + focus_healthy: bool | None = None + if focus_backend is not None: + focus_healthy = ( + focus_started_ok is not False + and focus_task is not None + and not focus_task.done() + and focus_query_error is None + ) focus_block: dict[str, Any] = { "backend": type(focus_backend).__name__ if focus_backend is not None else None, "platform": safe_str(getattr(server, "_focus_platform", None)), "last_app": _app_info_to_dict(getattr(server, "_last_focus", None)), - "started_ok": getattr(server, "_focus_started_ok", None), + "started_ok": focus_started_ok, + "healthy": focus_healthy, + "query_error": focus_query_error, } - if focus_backend is None: - focus_block["backend"] = None key_sink = getattr(server, "key_sink", None) sink_name = type(key_sink).__name__ if key_sink is not None else None diff --git a/daemon/deckd/platform.py b/daemon/deckd/platform.py index 75ddd41..9dd186a 100644 --- a/daemon/deckd/platform.py +++ b/daemon/deckd/platform.py @@ -363,6 +363,10 @@ def read(self) -> SensorReading | None: class PlatformBackend: + # Latest error of an ongoing focus-query failure streak, None when the + # last poll succeeded. Maintained by :meth:`watch_active_app`. + _focus_query_error: str | None = None + def capabilities(self) -> frozenset[str]: """Backend capability flags consumed by the server (issue #121). @@ -415,9 +419,26 @@ async def get_active_app(self) -> AppInfo: raise NotImplementedError async def watch_active_app(self, *, interval_s: float = 0.1) -> AsyncIterator[AppInfo]: + # Query errors (extension not yet owning the bus name at login, + # gnome-shell restart, a gdbus timeout) must not escape: an + # exception here ends the generator and with it the server's + # focus task, silently pinning every client to the default deck. + # Log the first failure of a streak, sleep through it, and resume. + # The streak's latest error is kept on ``_focus_query_error`` so + # ``/diag`` can report a watcher that is alive but not working. last: AppInfo | None = None while True: - current = await self.get_active_app() + try: + current = await self.get_active_app() + except Exception as exc: + if self._focus_query_error is None: + log.warning("focus query failed (will keep retrying): %s", exc) + self._focus_query_error = str(exc) or type(exc).__name__ + await asyncio.sleep(interval_s) + continue + if self._focus_query_error is not None: + log.info("focus query recovered") + self._focus_query_error = None if current != last: last = current yield current @@ -538,7 +559,7 @@ async def watch_windows( Errors from the gdbus shell-out (extension not installed, session bus unreachable) are logged once and the loop sleeps - through them — mirrors the focus watcher's behaviour so a + through them — same as :meth:`watch_active_app`, so a daemon started before the extension is enabled survives and the windows list catches up the moment the bus replies. """ diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 36bc278..fa7757e 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -497,6 +497,7 @@ app_id='org.gnome.Console' wm_class='org.gnome.Console' pid=1234 title='Terminal app_id=None wm_class='firefox' pid=188566 title='YouTube — Mozilla Firefox' ``` +**Deck stuck on Home?** Check `deckctl diag` → `focus.healthy`. It is `true` only when the backend started, the watcher task is alive, and the latest focus poll succeeded; `focus.query_error` carries the current failure (e.g. `ServiceUnknown … not activatable` while the extension isn't loaded yet). The watcher retries every poll, so a daemon started at login before the extension is ready catches up on its own once the bus name appears. #### X11 sessions (any desktop environment) diff --git a/docs/REFERENCE.md b/docs/REFERENCE.md index e0377c6..60a0789 100644 --- a/docs/REFERENCE.md +++ b/docs/REFERENCE.md @@ -116,7 +116,7 @@ All are read-only and unauthenticated unless noted. | Endpoint | Description | |----------|-------------| | `GET /health` | Host identity (`hostname`, `os`, `desktop`), sessions, current app, bind surface (`bind`, `addresses`, `url`). Used by `deckctl status` and the client Settings panel. | -| `GET /diag` | Full snapshot: focus watcher status, input sink, deck store, sessions, tasks, MPRIS state (if active). Machine-readable for AI-assisted debugging. | +| `GET /diag` | Full snapshot: focus watcher status (`focus.healthy` = context switching is working right now; `focus.query_error` = current poll failure), input sink, deck store, sessions, tasks, MPRIS state (if active). Machine-readable for AI-assisted debugging. | | `GET /metrics` | Prometheus text-format scrape target. Counters: `deckd_actions_total{primitive,outcome}`, `deckd_dbus_calls*`, `deckd_deck_reloads*`, `deckd_ws_sessions_active`, `deckd_mpris_*`, etc. | | `GET /decks` | Enumeration of every loaded deck with safe widget summaries (id, kind, label, grid, `has_action`; no raw shell/dbus/url/text command bodies). | | `GET /actions/recent?limit=N` | Bounded ring buffer of recent action attempts (id, outcome, timestamp; no command text). Default 64. | diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 7b62d3c..349d170 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -452,6 +452,91 @@ class _FakeServer: assert "password" not in snap["auth"] +def _focus_diag( + *, backend: object | None, task_alive: bool | None, started_ok: bool | None +) -> dict: + """Build a ``/diag`` focus block against a minimal fake server. + + ``task_alive`` None means the watcher task was never created.""" + + class _FakeServer: + host = "127.0.0.1" + port = 8765 + decks_dir = "/decks" + overlay_dir = None + password = None + decks = type("S", (), {"decks": []})() + _current_app_id = "default" + _current_deck = None + _current_error = None + _sessions: set = set() + _subscribed_sources: set = set() + sensors = None + scroll = None + mpris = None + _decks_task = None + _sensor_task = None + _media_task = None + _focus_platform = None + _last_focus = None + key_sink = None + + server = _FakeServer() + server.focus_backend = backend + server._focus_started_ok = started_ok + + async def run() -> dict: + task = None + if task_alive is not None: + task = asyncio.create_task(asyncio.sleep(3600 if task_alive else 0)) + if not task_alive: + await task + server._focus_task = task + try: + return await build_diag_snapshot(server=server, started_at=0.0) + finally: + if task is not None: + task.cancel() + + return asyncio.run(run())["focus"] + + +class _Backend: + _focus_query_error: str | None = None + + +def test_diag_focus_unhealthy_when_watcher_task_died() -> None: + """Regression: a dead focus task used to leave ``started_ok: true`` + as the only focus signal, hiding that context switching had stopped.""" + focus = _focus_diag(backend=_Backend(), task_alive=False, started_ok=True) + assert focus["healthy"] is False + + +def test_diag_focus_unhealthy_while_queries_fail() -> None: + backend = _Backend() + backend._focus_query_error = "ServiceUnknown: The name is not activatable" + focus = _focus_diag(backend=backend, task_alive=True, started_ok=True) + assert focus["healthy"] is False + assert focus["query_error"] == "ServiceUnknown: The name is not activatable" + + +def test_diag_focus_unhealthy_when_backend_failed_to_start() -> None: + focus = _focus_diag(backend=_Backend(), task_alive=False, started_ok=False) + assert focus["healthy"] is False + + +def test_diag_focus_healthy_when_watcher_running_cleanly() -> None: + focus = _focus_diag(backend=_Backend(), task_alive=True, started_ok=True) + assert focus["healthy"] is True + assert focus["query_error"] is None + + +def test_diag_focus_health_is_null_when_focus_disabled() -> None: + """``--no-focus``: there is nothing to be unhealthy.""" + focus = _focus_diag(backend=None, task_alive=None, started_ok=None) + assert focus["healthy"] is None + + # --------------------------------------------------------------------------- # JSON logging formatter # --------------------------------------------------------------------------- diff --git a/tests/test_platform.py b/tests/test_platform.py index edd4dcc..3a329e2 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -507,3 +507,31 @@ def test_window_info_from_payload_non_int_workspace_is_none() -> None: parser does for missing keys.""" info = plat._window_info_from_payload({"workspace": "primary"}) assert info.workspace is None + + +@pytest.mark.asyncio +async def test_watch_active_app_survives_transient_backend_errors() -> None: + """Regression: a daemon started before the GNOME extension owns + ``org.deckd.Focus`` got ``ServiceUnknown`` on its first poll, the + exception escaped the generator, and the focus watcher task died + silently — every client stuck on the Home deck until a restart. + The base poll loop must sleep through query errors and pick up the + focused app once the backend recovers.""" + + class FlakyBackend(plat.PlatformBackend): + def __init__(self) -> None: + self.calls = 0 + + async def get_active_app(self) -> AppInfo: + self.calls += 1 + if self.calls <= 3: + raise FocusBackendUnavailable("The name is not activatable") + return AppInfo(app_id=None, wm_class="firefox", title="x", pid=None) + + backend = FlakyBackend() + gen = backend.watch_active_app(interval_s=0) + first = await gen.__anext__() + await gen.aclose() + assert first.wm_class == "firefox" + assert backend.calls == 4 + assert backend._focus_query_error is None # cleared on recovery