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._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.

View File

@ -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

View File

@ -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))

View File

@ -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