utils/gatelock/gatelock/_detect.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

184 lines
6.7 KiB
Python

"""Notice output changes fast, by four means at once.
The lock must react the moment a monitor wakes up, and must not miss a monitor
going dark. No single mechanism covers both cheaply, so all of them run:
1. **RandR events** (``python-xlib``) -- instant and authoritative, catching
every layout change including ones that leave the screen size unchanged.
2. **Tk ``<Configure>`` on the root** -- zero forks, fires when the X screen's
bounding box changes. Exactly the signal a monitor coming back produces.
3. **``xrandr --query`` polling** -- the backstop, driven by
:mod:`gatelock._recovery`, for when ``python-xlib`` is absent.
4. **Tk's own screen dimensions** -- the last-resort floor in
:mod:`gatelock._outputs`.
Only the first two live here; they are the *push* signals, coalesced into one
"something changed" flag that the recovery loop drains.
**Threading rule, enforced by a test:** Tk is not thread-safe, and a Tk call
from the RandR thread is a hard crash or silent memory corruption. The thread
may only call ``queue.put``. Every Tk touch happens on the drain tick, on the
Tk thread.
"""
from __future__ import annotations
import contextlib
import logging
import queue
import select
import threading
import tkinter as tk
from typing import Any
_logger = logging.getLogger(__name__)
_SELECT_TIMEOUT_S = 0.5
"""How long the RandR thread blocks before re-checking the stop flag."""
class _RandrEventSource:
"""Watches RandR notifications on a private X connection.
Private on purpose: Xlib connections are not safe to share across threads,
and :mod:`gatelock._outputs` is using its own on the Tk thread.
"""
def __init__(self, sink: queue.Queue[str]) -> None:
"""Prepare a source that posts change notices into ``sink``."""
self._sink = sink
self._display: Any = None
self._thread: threading.Thread | None = None
self._stop = threading.Event()
def start(self) -> bool:
"""Begin watching. False if python-xlib is unavailable or refuses."""
if not self._connect():
return False
self._thread = threading.Thread(
target=self._run, name="gatelock-randr", daemon=True
)
self._thread.start()
return True
def _connect(self) -> bool:
"""Open the connection and subscribe to layout changes."""
try:
from Xlib import display as xdisplay
from Xlib.ext import randr
except ImportError:
_logger.info(
"python-xlib is not installed; falling back to Tk <Configure> "
"plus xrandr polling for output-change detection"
)
return False
try:
self._display = xdisplay.Display()
root = self._display.screen().root
root.xrandr_select_input(
randr.RRScreenChangeNotifyMask
| randr.RRCrtcChangeNotifyMask
| randr.RROutputChangeNotifyMask
)
except (OSError, ValueError, AttributeError, TypeError) as exc:
_logger.warning("could not subscribe to RandR events: %s", exc)
self._close()
return False
return True
def _run(self) -> None:
"""Drain X events until stopped. Touches nothing but the queue."""
try:
self._loop()
except (OSError, ValueError) as exc: # pragma: no cover - thread teardown
_logger.debug("RandR watch thread ended: %s", exc)
finally:
self._close()
def _loop(self) -> None:
"""Block on the X socket, posting a notice per batch of events."""
fileno = self._display.fileno()
while not self._stop.is_set():
readable, _, _ = select.select([fileno], [], [], _SELECT_TIMEOUT_S)
if not readable:
continue
pending = self._display.pending_events()
for _ in range(pending):
self._display.next_event()
if pending:
# One notice per batch: the drain tick re-scans regardless of
# how many events arrived, so counting them buys nothing.
self._sink.put("randr")
def stop(self) -> None:
"""Ask the thread to finish and wait briefly for it."""
self._stop.set()
if self._thread is not None:
self._thread.join(timeout=_SELECT_TIMEOUT_S * 4)
self._thread = None
def _close(self) -> None:
"""Drop the private X connection."""
if self._display is None:
return
with contextlib.suppress(OSError, ValueError, AttributeError):
self._display.close()
self._display = None
class OutputChangeDetector:
"""Coalesces every push signal into one drainable "something changed" flag."""
def __init__(self, root: tk.Misc) -> None:
"""Prepare a detector bound to ``root``; subscribes nothing yet."""
self._root = root
self._queue: queue.Queue[str] = queue.Queue()
self._randr = _RandrEventSource(self._queue)
self._configure_pending = False
self._binding: str | None = None
self._randr_active = False
@property
def randr_active(self) -> bool:
"""Whether RandR events are live (so polling can be less eager)."""
return self._randr_active
def start(self) -> None:
"""Subscribe to Tk ``<Configure>`` and, if possible, RandR events."""
self._binding = self._root.bind("<Configure>", self._on_configure, add="+")
self._randr_active = self._randr.start()
_logger.info(
"output-change detection active (randr_events=%s, tk_configure=True)",
self._randr_active,
)
def _on_configure(self, _event: tk.Event[tk.Misc]) -> None:
"""Record that the X screen's geometry changed.
No debounce needed: this only raises a flag, and the drain tick
coalesces. A Configure storm sets one boolean many times, and the
surface diff downstream turns an unchanged layout into a no-op.
"""
self._configure_pending = True
def take_pending(self) -> bool:
"""Consume and report whether any change signal has arrived."""
pending = self._configure_pending
self._configure_pending = False
while True:
try:
self._queue.get_nowait()
except queue.Empty:
break
pending = True
return pending
def stop(self) -> None:
"""Unsubscribe from everything."""
self._randr.stop()
self._randr_active = False
if self._binding is not None:
with contextlib.suppress(tk.TclError):
self._root.unbind("<Configure>", self._binding)
self._binding = None