gatelock: preempt a weaker holder blocking a stronger claim's grab

wait_for_turn only helps a weaker locker arriving after a stronger
one; it does nothing when the weaker one is already the incumbent.
On 2026-08-21 diet_guard (rank 100) held the grab for ~3 minutes
against a newly-arriving screen_locker (rank 200), which just
retried and logged instead of ever making progress.

Fix: when a grab is blocked by a holder that outranks-us-not, SIGTERM
it once. The holder's own signal handler already converts SIGTERM
into its normal close() teardown (VT restore, arbiter release,
app-specific on_close) -- this reuses that path rather than adding a
new one, and never signals upward (never disturbs an incumbent that
outranks us), so it cannot become a way to bypass a stronger lock.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
This commit is contained in:
Krzysztof Rudnicki 2026-08-21 12:57:41 +02:00
parent 06aae48e24
commit 24abb22de0
3 changed files with 184 additions and 4 deletions

View File

@ -28,6 +28,7 @@ import atexit
import contextlib import contextlib
from dataclasses import dataclass from dataclasses import dataclass
import logging import logging
import os
import signal import signal
import tkinter as tk import tkinter as tk
from typing import TYPE_CHECKING, Literal, Protocol from typing import TYPE_CHECKING, Literal, Protocol
@ -43,7 +44,7 @@ from gatelock._vt import disable_vt_switching, restore_vt_switching
if TYPE_CHECKING: if TYPE_CHECKING:
from types import FrameType from types import FrameType
from gatelock._arbiter import Arbiter from gatelock._arbiter import Arbiter, Claim
from gatelock._surfaces import SurfaceInfo from gatelock._surfaces import SurfaceInfo
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@ -86,6 +87,12 @@ class LockConfig:
fails. 0 means "try once, then fall back to a local grab". Left fails. 0 means "try once, then fall back to a local grab". Left
unset (None), a "global" grab retries forever every 200ms. unset (None), a "global" grab retries forever every 200ms.
grab_log_every: Log a warning every N failed retry-forever attempts. grab_log_every: Log a warning every N failed retry-forever attempts.
preempt_weaker_holder: SIGTERM a lower-ranked incumbent that is
blocking our grab, instead of retrying against it forever. Only
the *direction* the arbiter's own ranking already sanctions: we
never signal a holder that outranks us. SIGTERM (not SIGKILL) so
the holder's own signal handler runs its normal close() path --
same teardown as a clean dismiss, just triggered externally.
app_name: This app's name, used in arbitration logs so a blocked app app_name: This app's name, used in arbitration logs so a blocked app
can say who is actually holding the screen. can say who is actually holding the screen.
rank: Arbitration priority; higher wins. See the ``RANK_*`` constants rank: Arbitration priority; higher wins. See the ``RANK_*`` constants
@ -130,6 +137,7 @@ class LockConfig:
disable_vt: bool | None = None disable_vt: bool | None = None
grab_retry_ms: int | None = None grab_retry_ms: int | None = None
grab_log_every: int = 25 grab_log_every: int = 25
preempt_weaker_holder: bool = True
app_name: str = "gatelock" app_name: str = "gatelock"
rank: int = RANK_SCREEN_LOCKER rank: int = RANK_SCREEN_LOCKER
recovery_tick_ms: int = 1000 recovery_tick_ms: int = 1000
@ -310,6 +318,7 @@ class LockWindow:
self._vt_disabled = False self._vt_disabled = False
self._closed = False self._closed = False
self._focus_notified = False self._focus_notified = False
self._preempted_pids: set[int] = set()
self._surfaces = SurfaceSet(root, config, hooks) self._surfaces = SurfaceSet(root, config, hooks)
self._enumerator = OutputEnumerator(root) self._enumerator = OutputEnumerator(root)
self._detector = OutputChangeDetector(root) self._detector = OutputChangeDetector(root)
@ -419,6 +428,13 @@ class LockWindow:
v0.1.1 always blamed "a fullscreen game". On 2026-07-25 the holder was v0.1.1 always blamed "a fullscreen game". On 2026-07-25 the holder was
the other locker, and that guess sent the diagnosis in the wrong the other locker, and that guess sent the diagnosis in the wrong
direction for the length of the outage. direction for the length of the outage.
A holder that outranks us is left alone entirely -- retrying is the
correct behavior there, matching :func:`gatelock.wait_for_turn`'s own
rule for an app that has not armed yet. A *weaker* holder is preempted
below, once per pid, rather than left to block us for as long as it
takes to finish on its own (diet_guard held the grab for ~3 minutes
against a higher-ranked screen_locker on 2026-08-21).
""" """
holder = self._arbiter.describe_holder() if self._arbiter else None holder = self._arbiter.describe_holder() if self._arbiter else None
if holder is None: if holder is None:
@ -437,6 +453,41 @@ class LockWindow:
holder.pid, holder.pid,
holder.started, holder.started,
) )
self._maybe_preempt(holder)
def _maybe_preempt(self, holder: Claim) -> None:
"""SIGTERM a weaker holder once, so it stands down instead of blocking.
SIGTERM (not SIGKILL): the holder's own signal handler
(``LockWindow._install_signal_handlers``) raises ``SystemExit(0)``,
which unwinds through its ``run()``'s ``finally`` into its own
``close()`` -- the identical teardown a clean dismiss gets (VT
restored, arbiter claim released, app-specific ``on_close`` hook
run), just triggered externally instead of by the user.
"""
if not self._config.preempt_weaker_holder or self._arbiter is None:
return
if holder.rank >= self._arbiter.claim.rank:
return
if holder.pid in self._preempted_pids:
return
self._preempted_pids.add(holder.pid)
_logger.warning(
"preempting weaker holder %r (rank %d, pid %d) so rank %d can arm",
holder.app,
holder.rank,
holder.pid,
self._arbiter.claim.rank,
)
try:
os.kill(holder.pid, signal.SIGTERM)
except ProcessLookupError:
_logger.info(
"holder pid %d was already gone by the time we signalled it",
holder.pid,
)
except OSError:
_logger.exception("could not signal holder pid %d", holder.pid)
def _notify_focus_ready(self) -> None: def _notify_focus_ready(self) -> None:
"""Tell the app it can focus its first input widget now. """Tell the app it can focus its first input widget now.

View File

@ -3,9 +3,10 @@
from __future__ import annotations from __future__ import annotations
import queue import queue
import signal
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
from gatelock._arbiter import RANK_DIET_GUARD, Claim from gatelock._arbiter import RANK_DIET_GUARD, RANK_SCREEN_LOCKER, Claim
from gatelock._detect import _RandrEventSource from gatelock._detect import _RandrEventSource
from gatelock._outputs import Output, OutputRect from gatelock._outputs import Output, OutputRect
from gatelock._window import LockConfig from gatelock._window import LockConfig
@ -61,6 +62,15 @@ class TestGrabBlockedLogging:
def test_names_the_holding_gatelock_app(self, mock_root: MagicMock) -> None: def test_names_the_holding_gatelock_app(self, mock_root: MagicMock) -> None:
"""The 2026-07-25 diagnosis, available immediately.""" """The 2026-07-25 diagnosis, available immediately."""
arbiter = MagicMock() arbiter = MagicMock()
arbiter.claim = Claim(
app="screen_locker",
rank=RANK_SCREEN_LOCKER,
pid=1,
started="2026-07-25T12:49:40+00:00",
grab="global",
disable_vt=True,
instance_id="ours",
)
arbiter.describe_holder.return_value = Claim( arbiter.describe_holder.return_value = Claim(
app="diet_guard", app="diet_guard",
rank=RANK_DIET_GUARD, rank=RANK_DIET_GUARD,
@ -70,7 +80,9 @@ class TestGrabBlockedLogging:
disable_vt=True, disable_vt=True,
instance_id="t", instance_id="t",
) )
window, _hooks = make_window(mock_root, config=LockConfig(mode="hard")) window, _hooks = make_window(
mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=False)
)
window._arbiter = arbiter window._arbiter = arbiter
with patch("gatelock._window._logger") as logger: with patch("gatelock._window._logger") as logger:
window._log_grab_blocked(25) window._log_grab_blocked(25)
@ -96,6 +108,123 @@ class TestGrabBlockedLogging:
assert "fullscreen game" in str(logger.warning.call_args) assert "fullscreen game" in str(logger.warning.call_args)
class TestPreemptWeakerHolder:
"""A weaker incumbent must not block a stronger claim indefinitely."""
def _arbiter_with(
self, *, our_rank: int, holder_rank: int, holder_pid: int
) -> MagicMock:
arbiter = MagicMock()
arbiter.claim = Claim(
app="ours",
rank=our_rank,
pid=1,
started="2026-08-21T12:00:00+00:00",
grab="global",
disable_vt=True,
instance_id="ours",
)
arbiter.describe_holder.return_value = Claim(
app="holder",
rank=holder_rank,
pid=holder_pid,
started="2026-08-21T07:04:07+00:00",
grab="global",
disable_vt=True,
instance_id="theirs",
)
return arbiter
def test_signals_a_weaker_holder(self, mock_root: MagicMock) -> None:
arbiter = self._arbiter_with(
our_rank=RANK_SCREEN_LOCKER, holder_rank=RANK_DIET_GUARD, holder_pid=4075
)
window, _hooks = make_window(mock_root, config=LockConfig(mode="hard"))
window._arbiter = arbiter
with patch("gatelock._window.os.kill") as kill:
window._log_grab_blocked(25)
kill.assert_called_once_with(4075, signal.SIGTERM)
def test_never_signals_a_stronger_holder(self, mock_root: MagicMock) -> None:
arbiter = self._arbiter_with(
our_rank=RANK_DIET_GUARD, holder_rank=RANK_SCREEN_LOCKER, holder_pid=4137
)
window, _hooks = make_window(mock_root, config=LockConfig(mode="hard"))
window._arbiter = arbiter
with patch("gatelock._window.os.kill") as kill:
window._log_grab_blocked(25)
kill.assert_not_called()
def test_signals_the_same_holder_only_once(self, mock_root: MagicMock) -> None:
arbiter = self._arbiter_with(
our_rank=RANK_SCREEN_LOCKER, holder_rank=RANK_DIET_GUARD, holder_pid=4075
)
window, _hooks = make_window(mock_root, config=LockConfig(mode="hard"))
window._arbiter = arbiter
with patch("gatelock._window.os.kill") as kill:
window._log_grab_blocked(25)
window._log_grab_blocked(50)
kill.assert_called_once_with(4075, signal.SIGTERM)
def test_disabled_by_config(self, mock_root: MagicMock) -> None:
arbiter = self._arbiter_with(
our_rank=RANK_SCREEN_LOCKER, holder_rank=RANK_DIET_GUARD, holder_pid=4075
)
window, _hooks = make_window(
mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=False)
)
window._arbiter = arbiter
with patch("gatelock._window.os.kill") as kill:
window._log_grab_blocked(25)
kill.assert_not_called()
def test_no_arbiter_means_no_preemption(self, mock_root: MagicMock) -> None:
window, _hooks = make_window(mock_root, config=LockConfig(mode="hard"))
with patch("gatelock._window.os.kill") as kill:
window._maybe_preempt(
Claim(
app="holder",
rank=RANK_DIET_GUARD,
pid=4075,
started="2026-08-21T07:04:07+00:00",
grab="global",
disable_vt=True,
instance_id="theirs",
)
)
kill.assert_not_called()
def test_a_holder_that_is_already_gone_is_reported_at_info(
self, mock_root: MagicMock
) -> None:
arbiter = self._arbiter_with(
our_rank=RANK_SCREEN_LOCKER, holder_rank=RANK_DIET_GUARD, holder_pid=4075
)
window, _hooks = make_window(mock_root, config=LockConfig(mode="hard"))
window._arbiter = arbiter
with (
patch("gatelock._window.os.kill", side_effect=ProcessLookupError),
patch("gatelock._window._logger") as logger,
):
window._log_grab_blocked(25)
assert logger.info.called
def test_an_unsignalable_holder_is_reported_not_raised(
self, mock_root: MagicMock
) -> None:
arbiter = self._arbiter_with(
our_rank=RANK_SCREEN_LOCKER, holder_rank=RANK_DIET_GUARD, holder_pid=4075
)
window, _hooks = make_window(mock_root, config=LockConfig(mode="hard"))
window._arbiter = arbiter
with (
patch("gatelock._window.os.kill", side_effect=OSError("nope")),
patch("gatelock._window._logger") as logger,
):
window._log_grab_blocked(25)
assert logger.exception.called
class TestFocusNotifiedOnce: class TestFocusNotifiedOnce:
"""Both call sites are load-bearing, so the guard is on the callee.""" """Both call sites are load-bearing, so the guard is on the callee."""

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "gatelock" name = "gatelock"
version = "0.6.0" version = "0.7.0"
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