utils/gatelock/gatelock/_window.py
Krzysztof kuhy Rudnicki 35f6f95956
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (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
repo-contract / repo contract (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
music_theory tests / test (push) Has been cancelled
gatelock 0.11.0: Claim.grab_held + held_grabs(), so a reader can tell a locker's X grab is actually held
A live claim was not "input is locked": it is published before the window
grabs, and leetcode-guard's study mode releases the grab while the claim
lives on. leisure-watch (contract C2) must pause shadow billing only while a
grab is really held.

- Claim.grab_held (always serialised; missing key from an old writer reads
  as grab != "none"). Arbitration still uses the declared grab only.
- Arbiter.set_grab_held(held=) rewrites the claim and holder copies in place
  through the owner's flocked fd (O_APPEND cleared, pwrite at 0, padded so
  the file never shrinks) -- same inode, flock liveness untouched. Never raises.
- Set true only after grab_set/grab_set_global succeeded; RecoveryLoop
  reports each tick's real answer (a stolen, un-retaken grab reads false).
  LockWindow.report_grab_held(held=) is the hook for deliberate releases.
- gatelock.held_grabs(claims_dir=None): live claims with grab_held and a real
  grab. Root callers pass /run/user/1000/gatelock/claims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
2026-10-19 11:40:46 +02:00

251 lines
9.2 KiB
Python

# Copyright (c) 2026 Krzysztof Rudnicki
"""Lock window orchestration: surfaces, input grab, VT-disable, safe lifecycle.
Generalizes the window mechanics that wake_alarm, screen-locker, and
diet_guard each implemented separately. The three differ along independent
axes (whether the window is WM-unmanaged, what kind of input grab is taken,
whether VT switching is disabled, how a failed global grab is retried) --
:class:`LockConfig` exposes each axis explicitly, with ``mode`` as a
convenience preset, so one class can reproduce all three projects' existing
behavior exactly.
Since v0.2.0 this module only *orchestrates*. Enumeration lives in
:mod:`gatelock._outputs`, windows in :mod:`gatelock._surfaces`, change
detection in :mod:`gatelock._detect`, re-assertion in
:mod:`gatelock._recovery`, and cross-app priority in
:mod:`gatelock._arbiter`.
Arming order (**arm first, render second**) is explained in
:mod:`gatelock._arming`.
"""
from __future__ import annotations
import atexit
import contextlib
import logging
import signal
import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import _arming, _preempt
from gatelock._detect import OutputChangeDetector
from gatelock._outputs import OutputEnumerator
from gatelock._recovery import RecoveryCollaborators, RecoveryLoop
from gatelock._surfaces import SurfaceSet
from gatelock._vt import restore_vt_switching
if TYPE_CHECKING:
from types import FrameType
from gatelock._arbiter import Arbiter
from gatelock._config import LockConfig
from gatelock._hooks import LockWindowHooks
_logger = logging.getLogger(__name__)
# Periodic no-op so a grabbed, event-starved loop keeps handing control back
# to Python, letting SIGTERM/SIGINT be serviced promptly.
_KEEPALIVE_MS = 250
class LockWindow:
"""Per-output lock surfaces, input grab, and exit-path lifecycle."""
def __init__(
self,
root: tk.Tk,
config: LockConfig,
hooks: LockWindowHooks,
*,
arbiter: Arbiter | None = None,
) -> None:
"""Initialize the lock window wrapper.
Args:
root: The Tk root to configure and own the lifecycle of. It becomes
the backdrop and the grab holder; per-output surfaces are its
children.
config: Declarative lock behavior for this instance.
hooks: App-supplied callbacks for surfaces, focus, errors, teardown.
arbiter: The arbiter whose claim this lock is arming under. Used to
name the real holder when a grab is blocked, and released on
close so the next app can arm.
"""
self.root = root
self._config = config
self._hooks = hooks
self._arbiter = arbiter
self._vt_disabled = False
self._closed = False
self._focus_notified = False
self._preempted_pids: set[int] = set()
surfaces = SurfaceSet(root, config, hooks)
enumerator = OutputEnumerator(root)
detector = OutputChangeDetector(root)
recovery = RecoveryLoop(
root,
RecoveryCollaborators(
config=config,
surfaces=surfaces,
enumerator=enumerator,
detector=detector,
hooks=hooks,
on_grab_held=self.report_grab_held,
),
)
# The arming bundle is also where the window reaches its
# collaborators afterwards; it is the one owner of those four.
self._arming = _arming.ArmingCollaborators(
config=config,
surfaces=surfaces,
enumerator=enumerator,
detector=detector,
recovery=recovery,
notify_focus_ready=self._notify_focus_ready,
log_grab_blocked=self._log_grab_blocked,
notify_grab_held=lambda: self.report_grab_held(held=True),
)
@property
def surfaces(self) -> SurfaceSet:
"""The live surface set, for apps that need to fan work out over it."""
return self._arming.surfaces
@property
def recovery(self) -> RecoveryLoop:
"""The re-assertion loop, for an app that suspends the lock.
Releasing the grab while this runs is pointless: it re-takes the grab
within a tick. An app that hands the screen to something else
(leetcode-guard's study mode) stops this and :attr:`detector` first,
and starts both again when it re-arms.
"""
return self._arming.recovery
@property
def detector(self) -> OutputChangeDetector:
"""The output-change watcher; see :attr:`recovery` for when to stop it."""
return self._arming.detector
def report_grab_held(self, *, held: bool) -> None:
"""Publish whether the X grab is ACTUALLY held (``Claim.grab_held``).
Called on every successful grab and recovery tick; an app releasing the
grab on purpose (leetcode study mode) calls it False/True around that.
"""
if self._arbiter is not None:
self._arbiter.set_grab_held(held=held)
# -- window mechanics -----------------------------------------------------
def setup(self) -> None:
"""Configure the backdrop and build a surface on every live output.
Delegates to :func:`gatelock._arming.setup`.
"""
self._vt_disabled = _arming.setup(self.root, self._arming)
def grab_input(self) -> None:
"""Take the configured grab, then start watching for output changes.
Delegates to :func:`gatelock._arming.grab_input`.
"""
_arming.grab_input(self.root, self._arming)
def _acquire_global_grab(self, *, attempt: int) -> None:
"""Acquire the global grab, retrying per ``grab_retry_ms``.
Delegates to :func:`gatelock._arming.acquire_global_grab`.
"""
_arming.acquire_global_grab(self.root, self._arming, attempt=attempt)
def _log_grab_blocked(self, attempt: int) -> None:
"""Log who holds the grab, and stand a weaker holder down.
Delegates to :mod:`gatelock._preempt`; see there for the policy and
why a stronger holder is left alone.
"""
_preempt.log_grab_blocked(
attempt,
arbiter=self._arbiter,
config=self._config,
preempted_pids=self._preempted_pids,
)
def _notify_focus_ready(self) -> None:
"""Tell the app it can focus its first input widget now.
Guarded rather than de-duplicated at the call sites: both timings are
load-bearing. The 100ms ``after`` covers a grab that succeeds
immediately, and the call from :meth:`_acquire_global_grab` covers one
that only succeeds after seconds of retrying.
"""
if self._focus_notified:
return
self._focus_notified = True
with contextlib.suppress(tk.TclError):
index = self._arming.surfaces.preferred_focus_index()
self._hooks.on_focus_ready(self._arming.surfaces.focus_surface(index))
# -- lifecycle --------------------------------------------------------
def _install_signal_handlers(self) -> None:
"""Ensure VT switching is restored on crash or kill, not just close."""
atexit.register(self._restore_vt)
for sig in (signal.SIGTERM, signal.SIGINT):
with contextlib.suppress(ValueError):
signal.signal(sig, self._on_signal)
def _on_signal(self, _signum: int, _frame: FrameType | None) -> None:
"""Raise so SIGTERM/SIGINT unwind through run()'s try/finally."""
raise SystemExit(0)
def _keepalive(self) -> None:
"""Re-arm a periodic no-op so pending signals get serviced promptly."""
with contextlib.suppress(tk.TclError):
self.root.after(_KEEPALIVE_MS, self._keepalive)
def _restore_vt(self) -> None:
"""Restore VT switching; idempotent, safe to call on any exit path."""
if not self._vt_disabled:
return
restore_vt_switching()
self._vt_disabled = False
def close(self) -> None:
"""Run app teardown, release the screen, destroy every window.
Idempotent -- safe to call directly (normal dismiss) and again from
:meth:`run`'s ``finally`` (crash/signal exit) without double-running
app teardown.
Releasing the arbiter here is what makes a *clean* handoff work: the
morning routine runs the alarm and then the workout locker as
sequential subprocesses, and a claim left behind would make the second
one stand down against an app that had already exited.
"""
if self._closed:
return
self._closed = True
self._arming.recovery.stop()
self._arming.detector.stop()
self._hooks.on_close()
self._restore_vt()
if self._arbiter is not None:
self._arbiter.release()
self._arming.enumerator.close()
with contextlib.suppress(tk.TclError):
self._arming.surfaces.destroy_all()
with contextlib.suppress(tk.TclError):
self.root.destroy()
def run(self) -> None:
"""Run the Tk mainloop, guaranteeing cleanup on every exit path."""
self._install_signal_handlers()
self._keepalive()
try:
self.root.mainloop()
finally:
self.close()