mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 20:30:59 +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>
113 lines
3.8 KiB
Python
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
|