utils/gatelock/gatelock/_guards.py
Krzysztof kuhy Rudnicki 949272778c
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
gatelock 0.8.2: wait_for_x_server waits for as long as it takes
The 60 s deadline was the 2026-09-13 leetcode-guard miss with a smaller
number: a Persistent=true catch-up starts one second into a boot, before X
exists, and a bounded wait that expires exits the unit while systemd has
already stamped the slot as fired. No X server means the user cannot use
the machine either, so an unbounded wait is not a bypass. The wait now
re-states itself at WARNING every five minutes so a long wait never reads
as a healthy, silent unit. `timeout_s` stays as an opt-in for callers that
want a bound.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BxkZDHDmoVSxUT4w72xSiE
2026-09-13 10:19:52 +02:00

143 lines
5.4 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""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.
The clock does not gate arming either (2026-09-13). The wait used to give up
after 60 s, and a boot-time ``Persistent=true`` catch-up starts one second
after the user manager, long before X exists: on a slow boot the deadline
passed, the unit exited, and nothing locked until the next timer slot -- which
systemd had already marked as fired. No X server means the user cannot use the
machine either, so waiting forever costs nothing and cannot be a bypass. The
wait now polls until the server answers and re-states itself at WARNING every
five minutes, so ``systemctl status`` never shows a healthy, silent unit.
"""
from __future__ import annotations
import logging
import os
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_INTERVAL_S = 1.0
# How often a still-missing X server is re-stated at WARNING. Same reasoning
# as ``_queue.QUEUE_HEARTBEAT_SECONDS``: a long silent wait is
# indistinguishable from a hung unit.
X_WAIT_HEARTBEAT_S = 300.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 | None = None,
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. ``None`` (the default) waits for
as long as it takes; see the module docstring for why a bound is
the wrong tool here.
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 once an X server answered, False only if ``timeout_s`` was set
and passed.
"""
attempt = probe if probe is not None else _probe_x_server
started = monotonic()
next_heartbeat = X_WAIT_HEARTBEAT_S
while True:
if attempt():
elapsed = monotonic() - started
if elapsed >= interval_s:
_logger.warning("X server answered after %.0fs; arming now", elapsed)
return True
elapsed = monotonic() - started
if timeout_s is not None and elapsed >= timeout_s:
_logger.error(
"no X server answered within %.0fs; cannot build a lock window",
timeout_s,
)
return False
if elapsed >= next_heartbeat:
_logger.warning(
"no X server on %s after %.0fs -- still waiting, nothing is locked",
os.environ.get("DISPLAY", "<unset>"),
elapsed,
)
while next_heartbeat <= elapsed:
next_heartbeat += X_WAIT_HEARTBEAT_S
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