import asyncio import json import os import signal from typing import Annotated, Any, cast from fastapi import APIRouter, Form from fastapi.responses import HTMLResponse from app.plugins.base import Plugin from app.render import render router = APIRouter(prefix="/api/plugins/sleep", tags=["plugins"]) WHO = "Dashboard (sleep-inhibit)" WHY = "dashboard: keep system awake" BLOCK_MODES = ("block", "block-weak") _holder: asyncio.subprocess.Process | None = None _toggle_lock = asyncio.Lock() async def _list() -> tuple[list[dict[str, Any]], str]: """List the currently active systemd inhibitor locks. Runs `systemd-inhibit --json=short --list` with a 5 s timeout (the child is killed on timeout). Every failure mode — missing binary, timeout, non-zero exit, bad JSON — is returned as a short error string rather than raised, so the UI can show a degraded state. Returns: (lock entries, "") on success, else ([], error description). """ try: proc = await asyncio.create_subprocess_exec( "systemd-inhibit", "--json=short", "--list", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) except OSError as e: return [], str(e)[:200] try: out, err = await asyncio.wait_for(proc.communicate(), 5) except TimeoutError: try: _ = proc.kill() except ProcessLookupError: pass return [], "systemd-inhibit timed out" if proc.returncode != 0: return [], (err.decode(errors="replace").strip() or f"systemd-inhibit failed (rc={proc.returncode})")[:200] try: data = json.loads(out.decode(errors="replace")) except ValueError: return [], "could not parse systemd-inhibit output" if not isinstance(data, list): return [], "unexpected systemd-inhibit output" items: list[dict[str, Any]] = [e for e in cast("list[Any]", data) if isinstance(e, dict)] return items, "" def _verdict(inhibitors: list[dict[str, Any]]) -> str: """Whether sleep is currently inhibited by anything. Only locks whose "what" includes "sleep" AND whose mode is block or block-weak actually prevent sleep (delay mode does not). Args: inhibitors: entries from _list(). Returns: "blocked" or "ok". """ for e in inhibitors: whats = str(e.get("what", "")).split(":") if "sleep" in whats and e.get("mode") in BLOCK_MODES: return "blocked" return "ok" def _rows(inhibitors: list[dict[str, Any]]) -> list[dict[str, str | bool]]: """Shape block-mode inhibitor entries into table rows for the UI. Delay-mode locks are skipped (they don't block sleep). The proc cell shows "user · pid" when the lock has a live pid. The own flag marks the lock held by this dashboard itself. Args: inhibitors: entries from _list(). Returns: One row per block-mode lock: who, proc, what, why, mode, own. """ rows: list[dict[str, str | bool]] = [] for e in inhibitors: mode = str(e.get("mode", "")) if mode not in BLOCK_MODES: continue user = str(e.get("user", "")) pid = e.get("pid") if isinstance(pid, int) and pid > 0: proc = f"{user} · {pid}" if user else str(pid) else: proc = user rows.append({ "who": str(e.get("who", "?")), "proc": proc, "what": str(e.get("what", "")), "why": str(e.get("why", "")), "mode": mode, "own": e.get("who") == WHO, }) return rows def _reap_dead_holder() -> None: """Forget the holder child if it has already exited on its own. The systemd-inhibit child can die (e.g. the user killed it) without going through _release(); checking returncode here keeps "holding" in sync with reality. """ global _holder if _holder is not None and _holder.returncode is not None: _holder = None def _context(inhibitors: list[dict[str, Any]], error: str, message: str = "") -> dict[str, Any]: """Build the template context shared by the state and skeleton fragments. Args: inhibitors: entries from _list(). error: error string to display (from _list or a caller), "". message: transient success message to display, "". Returns: Context with inhibitors rows, verdict, message, error, and holding (whether this dashboard holds a lock). """ _reap_dead_holder() return { "inhibitors": _rows(inhibitors), "verdict": _verdict(inhibitors), "message": message, "error": error, "holding": _holder is not None, } async def _state(message: str = "", error: str = "") -> str: """Render the compact state fragment (polling view). Args: message: transient success message, or "". error: error to display (overrides the _list error), or "". Returns: The rendered sleep_state.html fragment. """ inhibitors, err = await _list() if error: err = error return render("plugins/sleep_state.html", **_context(inhibitors, err, message)) async def _skeleton(message: str = "", error: str = "") -> str: """Render the full skeleton fragment (initial + post-toggle view). Args: message: transient success message, or "". error: error to display (overrides the _list error), or "". Returns: The rendered sleep_skeleton.html fragment. """ inhibitors, err = await _list() if error: err = error return render("plugins/sleep_skeleton.html", **_context(inhibitors, err, message)) async def _acquire() -> str: """Start the systemd-inhibit child that holds the dashboard's sleep lock. The child runs `systemd-inhibit --what=sleep --mode=block ... sleep infinity` in its own session, so the lock (identified by the WHO marker) survives independently of this coroutine and can be reaped by _open() on a restart. The whole child group is what _release() later kills via os.killpg. Returns: "" on success, or a short error string. """ global _holder try: _holder = await asyncio.create_subprocess_exec( "systemd-inhibit", "--what=sleep", "--mode=block", f"--who={WHO}", f"--why={WHY}", "sleep", "infinity", stdout=asyncio.subprocess.DEVNULL, stderr=asyncio.subprocess.DEVNULL, start_new_session=True, ) except OSError as e: return str(e)[:200] return "" async def _release() -> None: """Release the dashboard's sleep lock by killing the holder child. Clears the holder reference first (so re-entrant calls are safe), sends SIGTERM to the child's whole process group, waits up to 3 s, and escalates to SIGKILL if it is still alive. """ global _holder p, _holder = _holder, None if p is None: return try: os.killpg(p.pid, signal.SIGTERM) except (ProcessLookupError, PermissionError): pass try: _ = await asyncio.wait_for(p.wait(), 3) except TimeoutError: try: os.killpg(p.pid, signal.SIGKILL) except (ProcessLookupError, PermissionError): pass _ = await p.wait() @router.get("/state") async def state(): """Poll endpoint: return the compact state fragment.""" return HTMLResponse(await _state()) @router.post("/toggle") async def toggle(on: Annotated[str | None, Form()] = None): """Turn the dashboard's sleep lock on or off. Guarded by a module-level lock so rapid double-clicks cannot start two holders or race release against acquire. Toggling on acquires the lock (errors are shown in the fragment, not raised); toggling off releases it. Args: on: "on" to acquire, anything else to release. Returns: The skeleton fragment with a result message or error. """ async with _toggle_lock: if on and _holder is None: err = await _acquire() if err: return HTMLResponse(await _skeleton(error=err)) return HTMLResponse(await _skeleton(message="inhibiting sleep")) if not on and _holder is not None: await _release() return HTMLResponse(await _skeleton(message="sleep inhibition released")) return HTMLResponse(await _skeleton()) async def _open() -> None: """Reap stale sleep locks left by a previous dashboard instance. On startup, any block lock whose who marker is this dashboard's WHO string belongs to a dead instance (the holder child does not survive a restart), so it is SIGTERMed by pid. Locks held by other who markers are never touched. """ inhibitors, _err = await _list() for e in inhibitors: if e.get("who") != WHO: continue pid = e.get("pid") if not isinstance(pid, int) or pid <= 0: continue try: _ = os.kill(pid, signal.SIGTERM) except ProcessLookupError: pass async def _close() -> None: """Shutdown hook: release the lock if the UI left it on.""" await _release() plugin = Plugin( id="sleep", title="Sleep inhibitors", description="Active block-mode systemd inhibitor locks.", skeleton_fn=_skeleton, open_fn=_open, close_fn=_close, )