mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-11 15:54:11 +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>
98 lines
3.3 KiB
Python
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
|