mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 13:30:59 +01:00
On 2026-07-25 the PC appeared frozen and needed an SSH rescue. Three defects
stacked:
1. DP-0 came up `connected` with a valid EDID and no mode/CRTC -- physically
dark, while X still reserved its 3840px slice of the root window.
2. diet-guard-gate and workout-locker each mapped an override-redirect window
spanning the WHOLE root (6400x1440+0+0), centring their controls on the
primary output -- the dark one. The escape UI was invisible.
3. They deadlocked on the X grab, and the loser logged "another app -- e.g. a
fullscreen game -- holds it". No game was running; the holder was the other
locker. That guess sent the diagnosis the wrong way for the whole outage.
The library had no concept of an individual output: setup() sized the window
from winfo_screenwidth(), the union bounding box of every output.
What changes:
* _outputs.py An output is LIVE only if connected AND has a mode/CRTC.
RandR (crtc==0 or mode==0) and an xrandr --query parser derive
that predicate independently; verified to agree exactly on the
real DP-0/HDMI-0 layout. Fails closed three distinct ways.
* _surfaces.py One window per live output at its own geometry, and the only
module allowed to create/move/destroy windows. Explicit
geometry replaces attributes(fullscreen=True), which an
override-redirect window is invisible to anyway.
* _detect.py RandR events + Tk <Configure> + xrandr polling, coalesced. The
RandR thread runs on a private X connection and may only call
queue.put -- Tk is not thread-safe.
* _recovery.py Re-asserts coverage, placement, grab and VT on a timer. Proven
monotonic: it may only ADD coverage or RE-ACQUIRE the grab. An
ast test asserts the file contains none of grab_release /
restore_vt_switching / close / destroy / quit / withdraw, while
disable_vt_switching and grab_set_global ARE present. Yanking
every cable therefore does not unlock anything.
* _arbiter.py N-app priority: wake_alarm > screen_locker > diet_guard.
Liveness is proved by flock-on-death -- no heartbeat, no PID
scan, no clock. A two-axis strength check stops rank ever
weakening the lock: an app stands down only for an incumbent
at least as strong on BOTH grab kind and VT disabling.
* _guards.py wait_for_x_server replaces wait_for_display and gates ONLY on
an X server existing. Output count must never gate arming:
zero live outputs means lock without showing, not decline to
lock. That inversion is what left the machine unlocked.
* _window.py Arms first, renders second. Names the real grab holder instead
of blaming a fullscreen game. Releases the arbiter on close so
the alarm -> workout handoff cannot strand the next app.
Two bugs found by running it, not reading it:
* acquire_holder() opened the holder file "w", which truncates at open() --
before the lock attempt. A losing contender erased the incumbent's claim, so
the app that most needed to know who held the screen found an empty file.
* tk.Toplevel over a MagicMock sends real tkinter into infinite mock recursion;
the suite hung instead of failing, and an unpatched setup() would have run
setxkbmap against the developer's own machine. conftest now has an autouse
hermetic fixture; the suite went from >120s to 0.7s.
235 tests, 100% branch coverage, ruff (select=ALL) and mypy --strict clean.
No suppressions added: Protocols replace Any for python-xlib, public attribute
access replaces ._data, and type-only Protocols live under TYPE_CHECKING.
Consumers still pin gatelock-v0.1.1 and are unmigrated; nothing ships until a
tag and a reinstall into system python.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
398 lines
14 KiB
Python
398 lines
14 KiB
Python
"""Decide which of N locker apps may hold the screen.
|
|
|
|
On 2026-07-25 two lockers armed at the same boot. One won the X grab; the
|
|
other spun forever in a retry loop, logging that "a fullscreen game" held the
|
|
grab. No game was running -- the holder was the other locker. Nothing
|
|
coordinated them because nothing could: each app only knew about itself.
|
|
|
|
This module is that coordination. Every app publishes a *claim* before it
|
|
builds any window, and the highest-ranked live claim wins. Ranks, highest
|
|
first:
|
|
|
|
* ``RANK_WAKE_ALARM`` -- you have to wake up before you can do anything else
|
|
* ``RANK_SCREEN_LOCKER`` -- then you work out
|
|
* ``RANK_DIET_GUARD`` -- then you eat
|
|
|
|
Two properties do the real work.
|
|
|
|
**Liveness is proved by the kernel, not by a heartbeat.** Each app holds an
|
|
``flock`` on its own claim file for its entire life. The kernel drops that lock
|
|
on *any* death, including ``SIGKILL``, OOM and a crashed X server. So a reader
|
|
that successfully locks somebody else's claim has thereby proved the owner is
|
|
dead. No PID scanning, no timeouts, no clock skew.
|
|
|
|
**Rank can never weaken the lock.** Deferring to a higher-ranked app would be a
|
|
bypass if that app locked less firmly -- "start the alarm to disarm the workout
|
|
lock". So an app stands down only for an incumbent that is at least as strong
|
|
on *both* axes (grab kind and VT disabling). Otherwise it says so, loudly, and
|
|
arms anyway. See :meth:`Arbiter.evaluate`.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
import errno
|
|
import fcntl
|
|
import json
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
import tempfile
|
|
from typing import IO, Final, Literal
|
|
import uuid
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
RANK_WAKE_ALARM: Final = 300
|
|
RANK_SCREEN_LOCKER: Final = 200
|
|
RANK_DIET_GUARD: Final = 100
|
|
|
|
_RUNTIME_DIR_ENV: Final = "GATELOCK_RUNTIME_DIR"
|
|
_CLAIMS_DIRNAME: Final = "claims"
|
|
_HOLDER_FILENAME: Final = "holder.lock"
|
|
|
|
# Ordering for the strength check. A stand-down is only safe towards an
|
|
# incumbent whose grab is at least this strong.
|
|
_GRAB_STRENGTH: Final[dict[str, int]] = {"none": 0, "local": 1, "global": 2}
|
|
|
|
VerdictReason = Literal["clear", "outranked", "weaker-incumbent-armed-anyway"]
|
|
|
|
|
|
def _utc_now_iso() -> str:
|
|
"""Return an ISO-8601 UTC timestamp, sortable as a plain string."""
|
|
return datetime.now(tz=timezone.utc).isoformat()
|
|
|
|
|
|
def grab_strength(grab: str) -> int:
|
|
"""Return the comparable strength of a grab kind.
|
|
|
|
Unknown values sort as the weakest possible, so a future grab kind can
|
|
never be silently treated as strong enough to stand down for.
|
|
"""
|
|
return _GRAB_STRENGTH.get(grab, 0)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Claim:
|
|
"""One app's declared intent to hold the screen."""
|
|
|
|
app: str
|
|
rank: int
|
|
pid: int
|
|
started: str
|
|
grab: str
|
|
disable_vt: bool
|
|
instance_id: str
|
|
"""Identifies the *arbiter*, not the process.
|
|
|
|
A pid is not unique enough: one process may legitimately host two
|
|
arbiters, and the whole test suite does exactly that to exercise real
|
|
``flock`` contention without spawning subprocesses.
|
|
"""
|
|
|
|
def to_json(self) -> str:
|
|
"""Serialise this claim for its on-disk file."""
|
|
return json.dumps(
|
|
{
|
|
"app": self.app,
|
|
"rank": self.rank,
|
|
"pid": self.pid,
|
|
"started": self.started,
|
|
"grab": self.grab,
|
|
"disable_vt": self.disable_vt,
|
|
"instance_id": self.instance_id,
|
|
}
|
|
)
|
|
|
|
@classmethod
|
|
def from_json(cls, text: str) -> Claim | None:
|
|
"""Parse a claim file's contents, or None if it is unusable.
|
|
|
|
A malformed claim is treated as absent rather than fatal: a half-written
|
|
file must never be able to stop a locker from arming.
|
|
"""
|
|
try:
|
|
raw = json.loads(text)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if not isinstance(raw, dict):
|
|
return None
|
|
try:
|
|
return cls(
|
|
app=str(raw["app"]),
|
|
rank=int(raw["rank"]),
|
|
pid=int(raw["pid"]),
|
|
started=str(raw["started"]),
|
|
grab=str(raw["grab"]),
|
|
disable_vt=bool(raw["disable_vt"]),
|
|
instance_id=str(raw["instance_id"]),
|
|
)
|
|
except (KeyError, TypeError, ValueError):
|
|
return None
|
|
|
|
def at_least_as_strong_as(self, other: Claim) -> bool:
|
|
"""Whether this claim locks at least as firmly as ``other``."""
|
|
return (
|
|
grab_strength(self.grab) >= grab_strength(other.grab)
|
|
and self.disable_vt >= other.disable_vt
|
|
)
|
|
|
|
def sort_key(self) -> tuple[int, str, int]:
|
|
"""Rank descending, then earliest start, then lowest pid."""
|
|
return (-self.rank, self.started, self.pid)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ArbiterVerdict:
|
|
"""Whether this app may arm, and why."""
|
|
|
|
may_arm: bool
|
|
blocked_by: Claim | None
|
|
reason: VerdictReason
|
|
|
|
|
|
def default_runtime_dir() -> Path:
|
|
"""Return the directory holding claims and the holder lock.
|
|
|
|
Prefers ``$XDG_RUNTIME_DIR`` (tmpfs, so it self-clears on reboot) and falls
|
|
back to a per-uid temp directory when that is unset or unwritable.
|
|
"""
|
|
override = os.environ.get(_RUNTIME_DIR_ENV)
|
|
if override:
|
|
return Path(override)
|
|
xdg = os.environ.get("XDG_RUNTIME_DIR")
|
|
if xdg:
|
|
return Path(xdg) / "gatelock"
|
|
return Path(tempfile.gettempdir()) / f"gatelock-{os.getuid()}"
|
|
|
|
|
|
def _try_lock(handle: IO[str]) -> bool:
|
|
"""Try to take an exclusive non-blocking flock. True if acquired."""
|
|
try:
|
|
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
except OSError as exc:
|
|
if exc.errno in {errno.EACCES, errno.EAGAIN}:
|
|
return False
|
|
raise
|
|
return True
|
|
|
|
|
|
def _same_file(handle: IO[str], path: Path) -> bool:
|
|
"""Whether ``path`` still names the file behind ``handle``.
|
|
|
|
Guards the reap below: between locking a dead app's claim and unlinking it,
|
|
a fresh process may have recreated the same path. Comparing inodes stops us
|
|
deleting the newcomer's claim.
|
|
"""
|
|
try:
|
|
return os.fstat(handle.fileno()).st_ino == path.stat().st_ino
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
class Arbiter:
|
|
"""Publishes this app's claim and reports whether it may arm."""
|
|
|
|
def __init__(
|
|
self,
|
|
app: str,
|
|
rank: int,
|
|
*,
|
|
grab: str,
|
|
disable_vt: bool,
|
|
runtime_dir: Path | None = None,
|
|
) -> None:
|
|
"""Prepare an arbiter for one app.
|
|
|
|
Args:
|
|
app: Human-readable app name, used in logs.
|
|
rank: Priority; higher wins. Use the ``RANK_*`` constants.
|
|
grab: The grab kind this app will take (``none``/``local``/``global``).
|
|
disable_vt: Whether this app will disable VT switching.
|
|
runtime_dir: Override for the state directory. Tests pass a tmp_path.
|
|
"""
|
|
self._root = runtime_dir if runtime_dir is not None else default_runtime_dir()
|
|
self._claims_dir = self._root / _CLAIMS_DIRNAME
|
|
self._claim = Claim(
|
|
app=app,
|
|
rank=rank,
|
|
pid=os.getpid(),
|
|
started=_utc_now_iso(),
|
|
grab=grab,
|
|
disable_vt=disable_vt,
|
|
instance_id=uuid.uuid4().hex,
|
|
)
|
|
name = f"{rank:04d}-{self._claim.pid}-{self._claim.instance_id}.json"
|
|
self._claim_path = self._claims_dir / name
|
|
self._claim_handle: IO[str] | None = None
|
|
self._holder_handle: IO[str] | None = None
|
|
|
|
@property
|
|
def claim(self) -> Claim:
|
|
"""This app's own claim."""
|
|
return self._claim
|
|
|
|
@property
|
|
def holds_screen(self) -> bool:
|
|
"""Whether this arbiter currently owns the holder lock."""
|
|
return self._holder_handle is not None
|
|
|
|
def publish(self) -> None:
|
|
"""Write and lock this app's claim, so others can see it is alive."""
|
|
self._claims_dir.mkdir(parents=True, exist_ok=True)
|
|
handle = self._claim_path.open("a+", encoding="utf-8")
|
|
if not _try_lock(handle):
|
|
# Only reachable if this pid's claim file is already locked, which
|
|
# would mean two Arbiters in one process. Close and carry on
|
|
# unpublished rather than dying.
|
|
handle.close()
|
|
_logger.warning(
|
|
"could not lock own claim %s; continuing unpublished",
|
|
self._claim_path,
|
|
)
|
|
return
|
|
handle.seek(0)
|
|
handle.truncate()
|
|
handle.write(self._claim.to_json())
|
|
handle.flush()
|
|
self._claim_handle = handle
|
|
|
|
def live_claims(self) -> tuple[Claim, ...]:
|
|
"""Return every claim whose owning process is still alive.
|
|
|
|
Dead owners' claims are reaped as a side effect -- proving a claim is
|
|
stale and deleting it are the same operation.
|
|
"""
|
|
if not self._claims_dir.is_dir():
|
|
return ()
|
|
claims: list[Claim] = []
|
|
for path in sorted(self._claims_dir.glob("*.json")):
|
|
claim = self._read_if_live(path)
|
|
if claim is not None:
|
|
claims.append(claim)
|
|
return tuple(sorted(claims, key=Claim.sort_key))
|
|
|
|
def _read_if_live(self, path: Path) -> Claim | None:
|
|
"""Return the claim at ``path`` if its owner is alive; else reap it."""
|
|
try:
|
|
handle = path.open("r+", encoding="utf-8")
|
|
except OSError:
|
|
return None
|
|
with handle:
|
|
if _try_lock(handle):
|
|
# We got the lock, so nobody holds it, so the owner is gone.
|
|
self._reap(handle, path)
|
|
return None
|
|
try:
|
|
return Claim.from_json(handle.read())
|
|
except OSError:
|
|
return None
|
|
|
|
def _reap(self, handle: IO[str], path: Path) -> None:
|
|
"""Delete a claim whose owner has died, unless it was recreated."""
|
|
if not _same_file(handle, path):
|
|
_logger.debug("claim %s was recreated while reaping; leaving it", path)
|
|
return
|
|
try:
|
|
path.unlink()
|
|
except OSError as exc:
|
|
_logger.debug("could not reap stale claim %s: %s", path, exc)
|
|
|
|
def evaluate(self) -> ArbiterVerdict:
|
|
"""Decide whether this app may arm.
|
|
|
|
Standing down is only permitted for a *stronger or equal* incumbent.
|
|
Deferring to a weaker one would turn rank into a bypass, so that case
|
|
arms anyway and says so at ERROR.
|
|
"""
|
|
mine = self._claim.instance_id
|
|
others = [c for c in self.live_claims() if c.instance_id != mine]
|
|
stronger_rank = [c for c in others if c.rank > self._claim.rank]
|
|
if not stronger_rank:
|
|
return ArbiterVerdict(may_arm=True, blocked_by=None, reason="clear")
|
|
|
|
winner = min(stronger_rank, key=Claim.sort_key)
|
|
if winner.at_least_as_strong_as(self._claim):
|
|
return ArbiterVerdict(may_arm=False, blocked_by=winner, reason="outranked")
|
|
_logger.error(
|
|
"%s (rank %d) outranks %s (rank %d) but locks more weakly "
|
|
"(grab=%s vt=%s vs grab=%s vt=%s); arming anyway rather than "
|
|
"letting rank weaken the lock",
|
|
winner.app,
|
|
winner.rank,
|
|
self._claim.app,
|
|
self._claim.rank,
|
|
winner.grab,
|
|
winner.disable_vt,
|
|
self._claim.grab,
|
|
self._claim.disable_vt,
|
|
)
|
|
return ArbiterVerdict(
|
|
may_arm=True,
|
|
blocked_by=winner,
|
|
reason="weaker-incumbent-armed-anyway",
|
|
)
|
|
|
|
def acquire_holder(self) -> bool:
|
|
"""Take the holder lock, marking this app as owning the screen."""
|
|
if self._holder_handle is not None:
|
|
return True
|
|
self._root.mkdir(parents=True, exist_ok=True)
|
|
# Opened "a+", never "w": "w" truncates at open() time, which happens
|
|
# BEFORE the lock attempt. A losing contender would therefore erase the
|
|
# incumbent's claim on its way out, and the app that most needs to know
|
|
# who holds the screen would find an empty file.
|
|
handle = (self._root / _HOLDER_FILENAME).open("a+", encoding="utf-8")
|
|
if not _try_lock(handle):
|
|
handle.close()
|
|
return False
|
|
handle.seek(0)
|
|
handle.truncate()
|
|
handle.write(self._claim.to_json())
|
|
handle.flush()
|
|
self._holder_handle = handle
|
|
return True
|
|
|
|
def describe_holder(self) -> Claim | None:
|
|
"""Return the claim of whichever app holds the screen, if any.
|
|
|
|
Used to replace the old "a fullscreen game holds it" guess with the
|
|
truth, which on 2026-07-25 would have named diet_guard immediately.
|
|
"""
|
|
path = self._root / _HOLDER_FILENAME
|
|
if not path.is_file():
|
|
return None
|
|
if self._holder_handle is not None:
|
|
return self._claim
|
|
try:
|
|
with path.open("r+", encoding="utf-8") as handle:
|
|
if _try_lock(handle):
|
|
# Lock was free, so no live app holds the screen.
|
|
return None
|
|
return Claim.from_json(handle.read())
|
|
except OSError:
|
|
return None
|
|
|
|
def release(self) -> None:
|
|
"""Drop the holder lock and this app's claim.
|
|
|
|
Must run on every exit path. ``flock`` covers a killed process for
|
|
free, but a *cleanly* exiting app has to release explicitly -- the
|
|
morning routine runs the alarm and then the workout locker as
|
|
sequential subprocesses, and a lingering claim would make the workout
|
|
locker stand down against an app that had already finished.
|
|
"""
|
|
for handle in (self._holder_handle, self._claim_handle):
|
|
if handle is not None:
|
|
try:
|
|
handle.close()
|
|
except OSError as exc: # pragma: no cover - close rarely fails
|
|
_logger.debug("ignoring error closing arbiter handle: %s", exc)
|
|
self._holder_handle = None
|
|
self._claim_handle = None
|
|
try:
|
|
self._claim_path.unlink(missing_ok=True)
|
|
except OSError as exc:
|
|
_logger.debug("could not remove own claim: %s", exc)
|