utils/gatelock/gatelock/_guards.py
Krzysztof kuhy Rudnicki a9b65812d7 gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration
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>
2026-07-25 14:30:03 +02:00

113 lines
3.8 KiB
Python

"""Safety guards shared by every locker app.
Both guards here were duplicated across the consuming repos (and one of them
was missing from ``wake_alarm`` entirely, which is exactly the kind of drift a
shared library exists to stop).
The second one, :func:`wait_for_x_server`, changed meaning when it moved here,
and the change is the point. Its ancestor gated *whether to arm at all*: on a
boot where the display was not ready it logged "will retry on the next timer
tick" and exited without locking. Combined with a monitor that came up
modeless, that left the machine unlocked until the next tick.
So the rule now is: **output count never gates arming.** Zero live outputs
means lock without showing, not decline to lock. This function may only answer
"can we talk to an X server at all", because without one there is no Tk and
genuinely nothing to do. It must never ask how many outputs are live.
"""
from __future__ import annotations
import logging
import sys
import time
import tkinter as tk
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Callable
_logger = logging.getLogger(__name__)
_DEFAULT_TIMEOUT_S = 60.0
_DEFAULT_INTERVAL_S = 1.0
def assert_not_under_pytest(what: str) -> None:
"""Refuse to build a real lock window inside a test run.
A lock window takes a global input grab and covers every screen. One built
by accident during a test run would black out the developer's machine, so
this fails loudly instead.
Call this from an app's entry point, never from inside gatelock: gatelock's
own tests mock the Tk *root* rather than the ``tkinter`` module, so the
check would fire throughout its own suite.
Args:
what: Name of the thing being built, for the error message.
Raises:
RuntimeError: If pytest is running with a real ``tkinter``.
"""
if "pytest" not in sys.modules:
return
if getattr(tk, "__name__", "") != "tkinter":
# tkinter is mocked, so no real window can appear. This is the normal
# path for a properly-isolated test.
return
message = (
f"refusing to build {what} under pytest with real tkinter -- "
"this would grab the input of the machine running the tests"
)
raise RuntimeError(message)
def wait_for_x_server(
*,
timeout_s: float = _DEFAULT_TIMEOUT_S,
interval_s: float = _DEFAULT_INTERVAL_S,
sleep: Callable[[float], None] = time.sleep,
monotonic: Callable[[], float] = time.monotonic,
probe: Callable[[], bool] | None = None,
) -> bool:
"""Wait until an X server will accept a connection.
Absorbs the cold-boot race where a unit starts before the display manager
has finished. It asks one question only -- is there an X server -- and
deliberately says nothing about whether any monitor is lit. A dark screen
is a reason to lock silently, never a reason to skip locking.
Args:
timeout_s: Give up after this long.
interval_s: Seconds between attempts.
sleep: Injected for tests.
monotonic: Injected for tests.
probe: Injected for tests; defaults to opening a throwaway Tk root.
Returns:
True if an X server answered, False on timeout.
"""
attempt = probe if probe is not None else _probe_x_server
deadline = monotonic() + timeout_s
while True:
if attempt():
return True
if monotonic() >= deadline:
_logger.error(
"no X server answered within %.0fs; cannot build a lock window",
timeout_s,
)
return False
sleep(interval_s)
def _probe_x_server() -> bool:
"""Whether a throwaway Tk root can be created and destroyed."""
try:
root = tk.Tk()
except tk.TclError:
return False
root.destroy()
return True