utils/gatelock/gatelock/tests
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
..
__init__.py Initial gatelock: shared lock-window + HMAC log-integrity backend 2026-06-19 17:39:44 +02:00
conftest.py gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration 2026-07-25 14:30:03 +02:00
test_arbiter.py gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration 2026-07-25 14:30:03 +02:00
test_detect.py gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration 2026-07-25 14:30:03 +02:00
test_guards.py gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration 2026-07-25 14:30:03 +02:00
test_log_integrity.py Initial gatelock: shared lock-window + HMAC log-integrity backend 2026-06-19 17:39:44 +02:00
test_outputs.py gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration 2026-07-25 14:30:03 +02:00
test_recovery.py gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration 2026-07-25 14:30:03 +02:00
test_root.py Initial gatelock: shared lock-window + HMAC log-integrity backend 2026-06-19 17:39:44 +02:00
test_surfaces.py gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration 2026-07-25 14:30:03 +02:00
test_vt.py Initial gatelock: shared lock-window + HMAC log-integrity backend 2026-06-19 17:39:44 +02:00
test_window_lifecycle.py Initial gatelock: shared lock-window + HMAC log-integrity backend 2026-06-19 17:39:44 +02:00
test_window_surfaces.py gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration 2026-07-25 14:30:03 +02:00
test_window.py gatelock v0.2.0: per-output locking, monotonic recovery, N-app arbitration 2026-07-25 14:30:03 +02:00