mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-14 18:11:00 +01:00
Some checks failed
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run
sync_settings_ui tests / test (push) Waiting to run
github_device_auth tests / test (push) Has been cancelled
_recovery.py 321 -> 242 + _recovery_types 47 + _grabwatch 84
_surfaces.py 380 -> 250 + _placement 136 + _surface_types 54 + _textmirror 70
Two guards had to be repaired first, because both would have gone quiet
rather than red:
1. palette-drift CI was ALREADY BROKEN by the earlier _window.py split.
palette_check.py reads LockConfig's colour defaults from a hardcoded
gatelock/_window.py, and LockConfig had moved to _config.py — so it parsed
0 tk tokens and reported "10 failing check(s)" where HEAD reports 0. Fixed
the path, and made parse_tk raise instead of returning {} when LockConfig
is not found: the empty dict is what let every tk comparison pass
vacuously while the drift guard was off for the whole stack.
2. test_recovery_invariants' symbols() read one hardcoded filename, so
splitting _recovery.py would have narrowed the "no weakening call"
invariant — the one that exists because of the 2026-07-25 unlock — to
whichever half kept the name, with the suite still green. It now globs the
whole _recovery*/_surfaces* family and fails if the glob matches nothing.
Verified it discriminates: a throwaway _recovery_probe.py calling
restore_vt_switching made the test fail, and removing it restored green.
GrabWatch is a small object rather than a free function because holds_grab
and its once-per-cause warning share state; _placement holds the geometry
questions and the re-assertion pass, which can only ever add visibility.
_surfaces keeps an explicit __all__: the autofixer pruned the mirror_text_widgets
re-export once and `import gatelock` broke, so the intent is now stated.
389 passed at 100% branch coverage; gatelock.__all__ is the same 58 names as
HEAD; palette_check back to 0 failing checks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CFSGeWxHPY5rHUGrPqhcmG
243 lines
9.2 KiB
Python
243 lines
9.2 KiB
Python
"""Keep the lock covering every live output, and never weaken it.
|
|
|
|
This loop is the watchdog the 2026-07-25 incident needed. A monitor that comes
|
|
back must show the lock immediately; a monitor that goes dark must not take the
|
|
lock's only escape UI with it.
|
|
|
|
Monotonicity
|
|
------------
|
|
|
|
Let ``C`` be the set of live outputs carrying a correctly-placed mapped
|
|
surface, ``G`` whether the global grab is held, and ``V`` whether VT switching
|
|
is disabled. Every tick satisfies:
|
|
|
|
No transition decreases ``G`` or ``V``, and no transition removes an
|
|
element of ``C`` whose output is still live.
|
|
|
|
The proof is by exhaustion over the operations a tick can perform: create a
|
|
surface, move one, remap one, raise one, destroy the surface of an output that
|
|
is *no longer live*, re-take the grab, re-disable VT switching. None of those
|
|
is a release. ``G`` and ``V`` are only ever set. ``C`` grows except when an
|
|
output stops being live -- and such an output is outside ``C``'s domain, so
|
|
``C`` restricted to live outputs never shrinks.
|
|
|
|
That property is *mechanical*, not aspirational. All structural change is
|
|
delegated to :mod:`gatelock._surfaces`, so this module contains none of
|
|
``grab_release``, ``restore_vt_switching``, ``close``, ``destroy``, ``quit`` or
|
|
``withdraw`` -- and a test parses this file to assert exactly that. Note the
|
|
asymmetry, which is the whole point: ``disable_vt_switching`` and
|
|
``grab_set_global`` *are* imported and called, because they strengthen. Only
|
|
their inverses are banned.
|
|
|
|
Consequence, deliberate: with zero live outputs the loop shows nothing and
|
|
keeps the grab. Pulling a cable is therefore not an escape.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
import tkinter as tk
|
|
|
|
from gatelock._grabwatch import GrabWatch
|
|
from gatelock._recovery_types import RecoveryCollaborators, RecoveryReport
|
|
from gatelock._vt import disable_vt_switching
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
_VT_REASSERT_EVERY = 30
|
|
"""Re-disable VT switching every N verify ticks, in case something reset it."""
|
|
|
|
|
|
class RecoveryLoop:
|
|
"""Re-asserts the lock's coverage on a timer and on change events."""
|
|
|
|
def __init__(self, root: tk.Misc, collaborators: RecoveryCollaborators) -> None:
|
|
"""Wire the loop to the pieces it re-asserts over."""
|
|
self._root = root
|
|
self._config = collaborators.config
|
|
self._surfaces = collaborators.surfaces
|
|
self._enumerator = collaborators.enumerator
|
|
self._detector = collaborators.detector
|
|
self._hooks = collaborators.hooks
|
|
self._running = False
|
|
self._ticks = 0
|
|
self._drain_job: str | None = None
|
|
self._verify_job: str | None = None
|
|
self._grab = GrabWatch(root)
|
|
|
|
@property
|
|
def ticks(self) -> int:
|
|
"""How many full verify passes have run."""
|
|
return self._ticks
|
|
|
|
def start(self) -> None:
|
|
"""Begin both cadences: the cheap drain and the full verify."""
|
|
self._running = True
|
|
self._schedule_drain()
|
|
self._schedule_verify()
|
|
|
|
def stop(self) -> None:
|
|
"""Stop rescheduling. Cancels timers; touches no window state."""
|
|
self._running = False
|
|
for job in (self._drain_job, self._verify_job):
|
|
if job is not None:
|
|
with contextlib.suppress(tk.TclError):
|
|
self._root.after_cancel(job)
|
|
self._drain_job = None
|
|
self._verify_job = None
|
|
|
|
def _schedule_drain(self) -> None:
|
|
"""Queue the next cheap drain tick."""
|
|
if not self._running:
|
|
return
|
|
self._drain_job = self._root.after(self._config.detect_drain_ms, self._drain)
|
|
|
|
def _schedule_verify(self) -> None:
|
|
"""Queue the next full verify tick."""
|
|
if not self._running:
|
|
return
|
|
self._verify_job = self._root.after(self._config.recovery_tick_ms, self._verify)
|
|
|
|
def _drain(self) -> None:
|
|
"""Run a full tick only if a push signal arrived. Cheap otherwise."""
|
|
try:
|
|
if self._detector.take_pending():
|
|
_logger.debug("output-change signal received; re-asserting the lock")
|
|
self.tick()
|
|
# Same fail-open reasoning as _verify.
|
|
except Exception:
|
|
_logger.exception("drain tick raised; the lock loop keeps running")
|
|
finally:
|
|
self._schedule_drain()
|
|
|
|
def _verify(self) -> None:
|
|
"""Run a full tick unconditionally.
|
|
|
|
The reschedule is in ``finally`` because it is the only thing keeping
|
|
the loop alive: a Tk ``after`` callback that raises just logs and
|
|
returns, so scheduling *after* ``tick()`` meant one unexpected
|
|
exception silently ended all re-assertion of coverage, grab and VT for
|
|
the rest of the lock. Fail-open is the one outcome this module exists
|
|
to prevent, so a broken tick must still leave the next one queued.
|
|
"""
|
|
try:
|
|
self.tick()
|
|
except Exception:
|
|
_logger.exception("verify tick raised; the lock loop keeps running")
|
|
finally:
|
|
self._schedule_verify()
|
|
|
|
def tick(self) -> RecoveryReport:
|
|
"""Re-assert coverage, placement, grab and VT. Never weakens any of them.
|
|
|
|
Returns:
|
|
What this pass saw and corrected.
|
|
"""
|
|
self._ticks += 1
|
|
scan = self._enumerator.scan()
|
|
|
|
if not scan.ok:
|
|
# No information is not a reason to change anything. Keep every
|
|
# surface, keep the grab, keep VT disabled, try again next tick.
|
|
_logger.warning(
|
|
"output enumeration failed; leaving the lock exactly as it is"
|
|
)
|
|
return RecoveryReport(scan_ok=False, source=scan.source)
|
|
|
|
self._surfaces.update_backdrop()
|
|
delta = self._surfaces.apply(scan)
|
|
corrected = self._surfaces.enforce()
|
|
grab_reasserted = self._reassert_grab()
|
|
vt_reasserted = self._reassert_vt()
|
|
if delta.created:
|
|
self._reassert_focus()
|
|
|
|
if delta.unverified:
|
|
_logger.error(
|
|
"these outputs are live but show no lock: %s -- the escape UI "
|
|
"may be somewhere the user cannot see",
|
|
", ".join(delta.unverified),
|
|
)
|
|
if delta.changed:
|
|
_logger.info(
|
|
"surfaces changed (created=%s moved=%s removed=%s) from %s",
|
|
delta.created,
|
|
delta.moved,
|
|
delta.removed,
|
|
scan.source,
|
|
)
|
|
if not scan.live:
|
|
_logger.warning(
|
|
"no live outputs; showing nothing and STAYING LOCKED "
|
|
"(grab and VT held) until one comes back"
|
|
)
|
|
|
|
return RecoveryReport(
|
|
scan_ok=True,
|
|
source=scan.source,
|
|
live_outputs=tuple(output.name for output in scan.live),
|
|
delta=delta,
|
|
corrected=corrected,
|
|
grab_reasserted=grab_reasserted,
|
|
vt_reasserted=vt_reasserted,
|
|
blind=delta.unverified,
|
|
)
|
|
|
|
def _reassert_grab(self) -> bool:
|
|
"""Re-take the global grab if we lost it. Never releases one.
|
|
|
|
Returns:
|
|
True if the grab had to be re-taken.
|
|
"""
|
|
if self._config.resolved_grab() != "global":
|
|
return False
|
|
if self.holds_grab():
|
|
return False
|
|
with contextlib.suppress(tk.TclError):
|
|
self._root.grab_set_global()
|
|
_logger.warning("global grab had been lost; re-acquired it")
|
|
return True
|
|
return False
|
|
|
|
def holds_grab(self) -> bool:
|
|
"""Whether the grab is held by a window this application owns.
|
|
|
|
Public so an embedder (or a verification harness) can ask the same
|
|
question the loop asks, rather than re-implementing it and drifting.
|
|
See :class:`gatelock._grabwatch.GrabWatch` for why the test is not a
|
|
simple identity check.
|
|
"""
|
|
return self._grab.holds_grab()
|
|
|
|
def _reassert_focus(self) -> None:
|
|
"""Re-focus the preferred surface after any output came back live.
|
|
|
|
``LockWindow.grab_input()`` only focuses the entry once, on first
|
|
acquisition (:meth:`LockWindow._notify_focus_ready` is one-shot). A
|
|
surface that is rebuilt later -- an output going dark and returning,
|
|
which a flaky monitor does routinely -- gets a brand new widget that
|
|
nothing ever focuses. The lock keeps its grab, so no other window can
|
|
take focus either: the entry renders correctly but is permanently
|
|
keyboard-dead until the process restarts. Re-running the same
|
|
focus_surface(preferred_focus_index()) call LockWindow makes on
|
|
startup, here, whenever a surface was just (re)created, is what keeps
|
|
that widget the actual Tk focus target.
|
|
"""
|
|
with contextlib.suppress(tk.TclError):
|
|
index = self._surfaces.preferred_focus_index()
|
|
self._hooks.on_focus_ready(self._surfaces.focus_surface(index))
|
|
|
|
def _reassert_vt(self) -> bool:
|
|
"""Periodically re-disable VT switching. Never re-enables it.
|
|
|
|
Returns:
|
|
True on ticks where the re-assert ran.
|
|
"""
|
|
if not self._config.resolved_disable_vt():
|
|
return False
|
|
if self._ticks % _VT_REASSERT_EVERY:
|
|
return False
|
|
disable_vt_switching()
|
|
return True
|