Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions daemon/deckd/diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 23 additions & 2 deletions daemon/deckd/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
"""
Expand Down
1 change: 1 addition & 0 deletions docs/GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion docs/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
85 changes: 85 additions & 0 deletions tests/test_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
28 changes: 28 additions & 0 deletions tests/test_platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading