From 92f55cee5a83e26831675fe2c67a564d6f895139 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Wed, 12 Aug 2026 22:13:46 +0200 Subject: [PATCH] gatelock 0.4.1: re-focus a surface that comes back after a blip LockWindow.grab_input() only ever focuses the entry once, on first grab acquisition (_notify_focus_ready is one-shot). A surface torn down and rebuilt later -- a monitor going dark and recovering, which flaky DDC/CI power control does routinely -- gets a brand new widget that nothing ever focuses. The grab stays held, so no other window can take focus either: the entry renders correctly but is permanently keyboard-dead until the process restarts. RecoveryLoop.tick() now re-runs the same focus_surface(preferred_focus_ index()) call whenever a tick's surface delta reports a newly created output, mirroring what LockWindow does on first acquisition. Bundled the loop's five collaborators into a RecoveryCollaborators dataclass rather than growing its constructor past ruff's five-argument limit. Diagnosed against a week of wake-alarm's real journal history: the dismiss-code box rendered normally every alarm morning but accepted no keystrokes, tracking exactly with HDMI-0's recorded dark/recover blips near 07:00 and 07:55 each day. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NGAEbavLSCAotBqhVfFhPc --- gatelock/gatelock/_recovery.py | 55 +++++++++++---- gatelock/gatelock/_window.py | 11 ++- gatelock/gatelock/tests/test_recovery.py | 87 ++++++++++++++++++++++-- gatelock/pyproject.toml | 2 +- 4 files changed, 134 insertions(+), 21 deletions(-) diff --git a/gatelock/gatelock/_recovery.py b/gatelock/gatelock/_recovery.py index 95bc89d..b3c56ab 100644 --- a/gatelock/gatelock/_recovery.py +++ b/gatelock/gatelock/_recovery.py @@ -47,7 +47,7 @@ if TYPE_CHECKING: from gatelock._detect import OutputChangeDetector from gatelock._outputs import OutputEnumerator from gatelock._surfaces import SurfaceDelta, SurfaceSet - from gatelock._window import LockConfig + from gatelock._window import LockConfig, LockWindowHooks _logger = logging.getLogger(__name__) @@ -69,23 +69,32 @@ class RecoveryReport: blind: tuple[str, ...] = () +@dataclass(frozen=True) +class RecoveryCollaborators: + """The pieces one tick re-asserts over. + + Bundled to keep the loop's constructor at one object instead of a + growing, unbounded arg list. + """ + + config: LockConfig + surfaces: SurfaceSet + enumerator: OutputEnumerator + detector: OutputChangeDetector + hooks: LockWindowHooks + + class RecoveryLoop: """Re-asserts the lock's coverage on a timer and on change events.""" - def __init__( - self, - root: tk.Misc, - config: LockConfig, - surfaces: SurfaceSet, - enumerator: OutputEnumerator, - detector: OutputChangeDetector, - ) -> None: + def __init__(self, root: tk.Misc, collaborators: RecoveryCollaborators) -> None: """Wire the loop to the pieces it re-asserts over.""" self._root = root - self._config = config - self._surfaces = surfaces - self._enumerator = enumerator - self._detector = detector + 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 @@ -176,6 +185,8 @@ class RecoveryLoop: 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( @@ -265,6 +276,24 @@ class RecoveryLoop: self._last_grab_warning = None return held + 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 _warn_once(self, reason: str) -> None: """Warn about a lost grab, but only when the reason changes. diff --git a/gatelock/gatelock/_window.py b/gatelock/gatelock/_window.py index 2e0a92b..cd314b4 100644 --- a/gatelock/gatelock/_window.py +++ b/gatelock/gatelock/_window.py @@ -36,7 +36,7 @@ from gatelock import _density from gatelock._arbiter import RANK_SCREEN_LOCKER from gatelock._detect import OutputChangeDetector from gatelock._outputs import OutputEnumerator -from gatelock._recovery import RecoveryLoop +from gatelock._recovery import RecoveryCollaborators, RecoveryLoop from gatelock._surfaces import SurfaceSet, needs_backdrop_root from gatelock._vt import disable_vt_switching, restore_vt_switching @@ -314,7 +314,14 @@ class LockWindow: self._enumerator = OutputEnumerator(root) self._detector = OutputChangeDetector(root) self._recovery = RecoveryLoop( - root, config, self._surfaces, self._enumerator, self._detector + root, + RecoveryCollaborators( + config=config, + surfaces=self._surfaces, + enumerator=self._enumerator, + detector=self._detector, + hooks=hooks, + ), ) @property diff --git a/gatelock/gatelock/tests/test_recovery.py b/gatelock/gatelock/tests/test_recovery.py index 32e6b87..3043759 100644 --- a/gatelock/gatelock/tests/test_recovery.py +++ b/gatelock/gatelock/tests/test_recovery.py @@ -16,7 +16,7 @@ from unittest.mock import MagicMock, patch import pytest from gatelock._outputs import Output, OutputRect, OutputScan -from gatelock._recovery import RecoveryLoop +from gatelock._recovery import RecoveryCollaborators, RecoveryLoop from gatelock._surfaces import SurfaceSet from gatelock._window import LockConfig @@ -48,11 +48,21 @@ def symbols(module: str) -> set[str]: def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]: """A recovery loop over mocked surroundings.""" config = LockConfig(mode="hard") - surfaces = SurfaceSet(mock_root, config, MagicMock()) + hooks = MagicMock() + surfaces = SurfaceSet(mock_root, config, hooks) enumerator = MagicMock() detector = MagicMock() return ( - RecoveryLoop(mock_root, config, surfaces, enumerator, detector), + RecoveryLoop( + mock_root, + RecoveryCollaborators( + config=config, + surfaces=surfaces, + enumerator=enumerator, + detector=detector, + hooks=hooks, + ), + ), enumerator, surfaces, ) @@ -267,10 +277,68 @@ class TestGrabReassertion: surfaces = SurfaceSet(mock_root, config, MagicMock()) enumerator = MagicMock() enumerator.scan.return_value = BOTH - recovery = RecoveryLoop(mock_root, config, surfaces, enumerator, MagicMock()) + recovery = RecoveryLoop( + mock_root, + RecoveryCollaborators( + config=config, + surfaces=surfaces, + enumerator=enumerator, + detector=MagicMock(), + hooks=MagicMock(), + ), + ) assert recovery.tick().grab_reasserted is False +class TestFocusReassertion: + """Re-focusing a surface that was (re)built after the first tick. + + Regression coverage for the 2026-08 bug: a monitor blip tears down and + rebuilds a surface's widgets mid-lock, but ``LockWindow`` only ever + focuses the entry once, on first grab acquisition. Without this, the new + widget renders correctly but is never the Tk focus target, and the held + global grab means nothing else can take focus either -- a silent, + permanent input dead end that looks identical to a working lock. + """ + + def test_refocuses_when_a_surface_is_recreated( + self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet] + ) -> None: + """A surface coming back live re-triggers on_focus_ready.""" + recovery, enumerator, surfaces = loop + hooks = surfaces._builder + enumerator.scan.return_value = BOTH + recovery.tick() + hooks.on_focus_ready.reset_mock() + enumerator.scan.return_value = DEAD_PRIMARY + recovery.tick() + enumerator.scan.return_value = BOTH + recovery.tick() + hooks.on_focus_ready.assert_called_once() + + def test_no_refocus_when_nothing_was_created( + self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet] + ) -> None: + """A tick with no new surfaces must not fight the user mid-typing.""" + recovery, enumerator, surfaces = loop + hooks = surfaces._builder + enumerator.scan.return_value = BOTH + recovery.tick() + hooks.on_focus_ready.reset_mock() + recovery.tick() + hooks.on_focus_ready.assert_not_called() + + def test_tclerror_during_refocus_is_swallowed( + self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock + ) -> None: + """A focus call on a half-torn-down widget must not crash the tick.""" + recovery, enumerator, surfaces = loop + hooks = surfaces._builder + hooks.on_focus_ready.side_effect = tk.TclError("gone") + enumerator.scan.return_value = BOTH + recovery.tick() + + class TestVtReassertion: """Periodically re-disabling VT switching, never re-enabling it.""" @@ -291,7 +359,16 @@ class TestVtReassertion: surfaces = SurfaceSet(mock_root, config, MagicMock()) enumerator = MagicMock() enumerator.scan.return_value = BOTH - recovery = RecoveryLoop(mock_root, config, surfaces, enumerator, MagicMock()) + recovery = RecoveryLoop( + mock_root, + RecoveryCollaborators( + config=config, + surfaces=surfaces, + enumerator=enumerator, + detector=MagicMock(), + hooks=MagicMock(), + ), + ) assert all(not recovery.tick().vt_reasserted for _ in range(31)) diff --git a/gatelock/pyproject.toml b/gatelock/pyproject.toml index cc6f0b4..e672df8 100644 --- a/gatelock/pyproject.toml +++ b/gatelock/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "gatelock" -version = "0.4.0" +version = "0.4.1" description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps" requires-python = ">=3.10" dependencies = [] # pure stdlib — tkinter is bundled with Python