mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-12 23:10:59 +01:00
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
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
246 lines
9.4 KiB
Python
246 lines
9.4 KiB
Python
# Copyright (c) 2026 Krzysztof Rudnicki
|
|
"""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._parts = collaborators
|
|
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._parts.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._parts.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._parts.detector.take_pending():
|
|
_logger.debug("output-change signal received; re-asserting the lock")
|
|
self.tick()
|
|
# Same fail-open reasoning as _verify.
|
|
except Exception: # pylint: disable=broad-exception-caught
|
|
_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: # pylint: disable=broad-exception-caught
|
|
_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._parts.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._parts.surfaces.update_backdrop()
|
|
delta = self._parts.surfaces.apply(scan)
|
|
corrected = self._parts.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._parts.config.resolved_grab() != "global":
|
|
return False
|
|
held = self.holds_grab()
|
|
reasserted = False
|
|
if not held:
|
|
with contextlib.suppress(tk.TclError):
|
|
self._root.grab_set_global()
|
|
_logger.warning("global grab had been lost; re-acquired it")
|
|
reasserted = held = True
|
|
self._parts.on_grab_held(held=held)
|
|
return reasserted
|
|
|
|
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._parts.surfaces.preferred_focus_index()
|
|
self._parts.hooks.on_focus_ready(self._parts.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._parts.config.resolved_disable_vt():
|
|
return False
|
|
if self._ticks % _VT_REASSERT_EVERY:
|
|
return False
|
|
disable_vt_switching()
|
|
return True
|