utils/gatelock/gatelock/tests/test_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

98 lines
3.3 KiB
Python

"""Tests for the shared safety guards."""
from __future__ import annotations
import sys
import tkinter as tk
from unittest.mock import MagicMock, patch
import pytest
from gatelock._guards import (
_probe_x_server,
assert_not_under_pytest,
wait_for_x_server,
)
class TestAssertNotUnderPytest:
"""Refuse to build a real grabbing window inside a test run."""
def test_raises_under_pytest_with_real_tkinter(self) -> None:
"""The dangerous combination is rejected."""
with pytest.raises(RuntimeError, match="refusing to build"):
assert_not_under_pytest("the workout lock")
def test_allows_when_tkinter_is_mocked(self) -> None:
"""A properly isolated test cannot open a window, so it is allowed."""
fake_tk = MagicMock()
fake_tk.__name__ = "MagicMock"
with patch("gatelock._guards.tk", fake_tk):
assert_not_under_pytest("the workout lock")
def test_allows_outside_pytest(self) -> None:
"""In production there is no pytest, so nothing is blocked."""
modules = {k: v for k, v in sys.modules.items() if k != "pytest"}
with patch.dict("sys.modules", modules, clear=True):
assert_not_under_pytest("the workout lock")
class TestWaitForXServer:
"""Gate on an X server existing -- never on how many outputs are live."""
def test_returns_true_immediately(self) -> None:
"""A ready display returns at once."""
assert wait_for_x_server(probe=lambda: True) is True
def test_retries_then_succeeds(self) -> None:
"""A slow display is waited for."""
results = iter([False, False, True])
sleeps: list[float] = []
assert (
wait_for_x_server(
probe=lambda: next(results),
sleep=sleeps.append,
monotonic=lambda: 0.0,
)
is True
)
assert sleeps == [1.0, 1.0]
def test_times_out(self) -> None:
"""A display that never arrives gives up and says so."""
clock = iter([0.0, 100.0])
assert (
wait_for_x_server(
probe=lambda: False,
sleep=lambda _s: None,
monotonic=lambda: next(clock),
timeout_s=1.0,
)
is False
)
def test_zero_live_outputs_does_not_block_arming(self) -> None:
"""THE 2b rule: a connected X server with dark monitors still arms.
This function must answer "is there an X server", nothing more. If it
ever consulted output liveness, a dark monitor would once again mean
"do not lock" instead of "lock without showing".
"""
assert wait_for_x_server(probe=lambda: True) is True
class TestProbeXServer:
"""The default probe."""
def test_success_destroys_the_probe_window(self) -> None:
"""A working display yields True and leaves nothing behind."""
fake_root = MagicMock()
with patch("gatelock._guards.tk.Tk", return_value=fake_root):
assert _probe_x_server() is True
fake_root.destroy.assert_called_once_with()
def test_failure_returns_false(self) -> None:
"""No display yields False rather than raising."""
with patch("gatelock._guards.tk.Tk", side_effect=tk.TclError("no display")):
assert _probe_x_server() is False