mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-13 23:30:58 +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>
184 lines
6.7 KiB
Python
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
|