utils/gatelock/gatelock/_recovery.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

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