gatelock 0.4.1: re-focus a surface that comes back after a blip
Some checks failed
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (3.10) (push) Has been cancelled
gatelock tests / test (3.11) (push) Has been cancelled
gatelock tests / test (3.12) (push) Has been cancelled

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGAEbavLSCAotBqhVfFhPc
This commit is contained in:
Krzysztof Rudnicki 2026-08-12 22:13:46 +02:00
parent 5f64a8959b
commit 92f55cee5a
4 changed files with 134 additions and 21 deletions

View File

@ -47,7 +47,7 @@ if TYPE_CHECKING:
from gatelock._detect import OutputChangeDetector from gatelock._detect import OutputChangeDetector
from gatelock._outputs import OutputEnumerator from gatelock._outputs import OutputEnumerator
from gatelock._surfaces import SurfaceDelta, SurfaceSet from gatelock._surfaces import SurfaceDelta, SurfaceSet
from gatelock._window import LockConfig from gatelock._window import LockConfig, LockWindowHooks
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@ -69,23 +69,32 @@ class RecoveryReport:
blind: tuple[str, ...] = () 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: class RecoveryLoop:
"""Re-asserts the lock's coverage on a timer and on change events.""" """Re-asserts the lock's coverage on a timer and on change events."""
def __init__( def __init__(self, root: tk.Misc, collaborators: RecoveryCollaborators) -> None:
self,
root: tk.Misc,
config: LockConfig,
surfaces: SurfaceSet,
enumerator: OutputEnumerator,
detector: OutputChangeDetector,
) -> None:
"""Wire the loop to the pieces it re-asserts over.""" """Wire the loop to the pieces it re-asserts over."""
self._root = root self._root = root
self._config = config self._config = collaborators.config
self._surfaces = surfaces self._surfaces = collaborators.surfaces
self._enumerator = enumerator self._enumerator = collaborators.enumerator
self._detector = detector self._detector = collaborators.detector
self._hooks = collaborators.hooks
self._running = False self._running = False
self._ticks = 0 self._ticks = 0
self._drain_job: str | None = None self._drain_job: str | None = None
@ -176,6 +185,8 @@ class RecoveryLoop:
corrected = self._surfaces.enforce() corrected = self._surfaces.enforce()
grab_reasserted = self._reassert_grab() grab_reasserted = self._reassert_grab()
vt_reasserted = self._reassert_vt() vt_reasserted = self._reassert_vt()
if delta.created:
self._reassert_focus()
if delta.unverified: if delta.unverified:
_logger.error( _logger.error(
@ -265,6 +276,24 @@ class RecoveryLoop:
self._last_grab_warning = None self._last_grab_warning = None
return held 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: def _warn_once(self, reason: str) -> None:
"""Warn about a lost grab, but only when the reason changes. """Warn about a lost grab, but only when the reason changes.

View File

@ -36,7 +36,7 @@ from gatelock import _density
from gatelock._arbiter import RANK_SCREEN_LOCKER from gatelock._arbiter import RANK_SCREEN_LOCKER
from gatelock._detect import OutputChangeDetector from gatelock._detect import OutputChangeDetector
from gatelock._outputs import OutputEnumerator 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._surfaces import SurfaceSet, needs_backdrop_root
from gatelock._vt import disable_vt_switching, restore_vt_switching from gatelock._vt import disable_vt_switching, restore_vt_switching
@ -314,7 +314,14 @@ class LockWindow:
self._enumerator = OutputEnumerator(root) self._enumerator = OutputEnumerator(root)
self._detector = OutputChangeDetector(root) self._detector = OutputChangeDetector(root)
self._recovery = RecoveryLoop( 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 @property

View File

@ -16,7 +16,7 @@ from unittest.mock import MagicMock, patch
import pytest import pytest
from gatelock._outputs import Output, OutputRect, OutputScan 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._surfaces import SurfaceSet
from gatelock._window import LockConfig from gatelock._window import LockConfig
@ -48,11 +48,21 @@ def symbols(module: str) -> set[str]:
def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]: def loop(mock_root: MagicMock) -> tuple[RecoveryLoop, MagicMock, SurfaceSet]:
"""A recovery loop over mocked surroundings.""" """A recovery loop over mocked surroundings."""
config = LockConfig(mode="hard") config = LockConfig(mode="hard")
surfaces = SurfaceSet(mock_root, config, MagicMock()) hooks = MagicMock()
surfaces = SurfaceSet(mock_root, config, hooks)
enumerator = MagicMock() enumerator = MagicMock()
detector = MagicMock() detector = MagicMock()
return ( return (
RecoveryLoop(mock_root, config, surfaces, enumerator, detector), RecoveryLoop(
mock_root,
RecoveryCollaborators(
config=config,
surfaces=surfaces,
enumerator=enumerator,
detector=detector,
hooks=hooks,
),
),
enumerator, enumerator,
surfaces, surfaces,
) )
@ -267,10 +277,68 @@ class TestGrabReassertion:
surfaces = SurfaceSet(mock_root, config, MagicMock()) surfaces = SurfaceSet(mock_root, config, MagicMock())
enumerator = MagicMock() enumerator = MagicMock()
enumerator.scan.return_value = BOTH 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 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: class TestVtReassertion:
"""Periodically re-disabling VT switching, never re-enabling it.""" """Periodically re-disabling VT switching, never re-enabling it."""
@ -291,7 +359,16 @@ class TestVtReassertion:
surfaces = SurfaceSet(mock_root, config, MagicMock()) surfaces = SurfaceSet(mock_root, config, MagicMock())
enumerator = MagicMock() enumerator = MagicMock()
enumerator.scan.return_value = BOTH 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)) assert all(not recovery.tick().vt_reasserted for _ in range(31))

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "gatelock" name = "gatelock"
version = "0.4.0" version = "0.4.1"
description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps" description = "Shared per-output lock-window + HMAC log-integrity backend for blocking-overlay apps"
requires-python = ">=3.10" requires-python = ">=3.10"
dependencies = [] # pure stdlib — tkinter is bundled with Python dependencies = [] # pure stdlib — tkinter is bundled with Python