From a4b72a21c8766bfc5128be82db3e412a53d94e25 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 21 Aug 2026 13:31:23 +0200 Subject: [PATCH] Make lock preemption opt-in: it could evict the armed workout lock preempt_weaker_holder defaulted to True, so *any* app building a plain hard LockConfig would SIGTERM a lower-ranked incumbent. wake_alarm (RANK_WAKE_ALARM=300) builds exactly that and outranks screen_locker (200), so on any morning wake-alarm-trigger.timer fired while the workout locker was armed, it stood the enforcement lock down: machine unlocked, no workout logged, next re-arm up to 30 minutes away. Default to False and let screen_locker opt in explicitly, which is the direction that actually motivated the feature (diet_guard held the grab for hours on 2026-08-21). Nothing can now evict an enforcement lock without asking for that power by name. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk --- gatelock/gatelock/_window.py | 7 +++- .../gatelock/tests/test_window_surfaces.py | 37 +++++++++++++++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/gatelock/gatelock/_window.py b/gatelock/gatelock/_window.py index 7b26c17..2a145db 100644 --- a/gatelock/gatelock/_window.py +++ b/gatelock/gatelock/_window.py @@ -93,6 +93,11 @@ class LockConfig: 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. + Defaults to False, and must stay opt-in: for an *enforcement* + lock (screen_locker), being preempted means the machine unlocks + with the obligation unmet. Only an app whose own dismissal is + harmless -- or which genuinely outranks every enforcer -- should + turn this on, and only after considering who it can now evict. app_name: This app's name, used in arbitration logs so a blocked app can say who is actually holding the screen. rank: Arbitration priority; higher wins. See the ``RANK_*`` constants @@ -137,7 +142,7 @@ class LockConfig: disable_vt: bool | None = None grab_retry_ms: int | None = None grab_log_every: int = 25 - preempt_weaker_holder: bool = True + preempt_weaker_holder: bool = False app_name: str = "gatelock" rank: int = RANK_SCREEN_LOCKER recovery_tick_ms: int = 1000 diff --git a/gatelock/gatelock/tests/test_window_surfaces.py b/gatelock/gatelock/tests/test_window_surfaces.py index 7e13239..79c3609 100644 --- a/gatelock/gatelock/tests/test_window_surfaces.py +++ b/gatelock/gatelock/tests/test_window_surfaces.py @@ -6,7 +6,12 @@ import queue import signal from unittest.mock import MagicMock, patch -from gatelock._arbiter import RANK_DIET_GUARD, RANK_SCREEN_LOCKER, Claim +from gatelock._arbiter import ( + RANK_DIET_GUARD, + RANK_SCREEN_LOCKER, + RANK_WAKE_ALARM, + Claim, +) from gatelock._detect import _RandrEventSource from gatelock._outputs import Output, OutputRect from gatelock._window import LockConfig @@ -139,7 +144,7 @@ class TestPreemptWeakerHolder: 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, _hooks = make_window(mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)) window._arbiter = arbiter with patch("gatelock._window.os.kill") as kill: window._log_grab_blocked(25) @@ -149,7 +154,7 @@ class TestPreemptWeakerHolder: 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, _hooks = make_window(mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)) window._arbiter = arbiter with patch("gatelock._window.os.kill") as kill: window._log_grab_blocked(25) @@ -159,7 +164,7 @@ class TestPreemptWeakerHolder: 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, _hooks = make_window(mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)) window._arbiter = arbiter with patch("gatelock._window.os.kill") as kill: window._log_grab_blocked(25) @@ -178,8 +183,26 @@ class TestPreemptWeakerHolder: window._log_grab_blocked(25) kill.assert_not_called() - def test_no_arbiter_means_no_preemption(self, mock_root: MagicMock) -> None: + def test_off_by_default(self, mock_root: MagicMock) -> None: + """Preemption is opt-in, so no app can evict anyone by accident. + + wake_alarm outranks screen_locker and builds a plain hard LockConfig. + While the default was True, that combination SIGTERMed the *armed + workout lock*, unlocking enforcement with no workout logged. + """ + arbiter = self._arbiter_with( + our_rank=RANK_WAKE_ALARM, + 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_no_arbiter_means_no_preemption(self, mock_root: MagicMock) -> None: + window, _hooks = make_window(mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)) with patch("gatelock._window.os.kill") as kill: window._maybe_preempt( Claim( @@ -200,7 +223,7 @@ class TestPreemptWeakerHolder: 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, _hooks = make_window(mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)) window._arbiter = arbiter with ( patch("gatelock._window.os.kill", side_effect=ProcessLookupError), @@ -215,7 +238,7 @@ class TestPreemptWeakerHolder: 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, _hooks = make_window(mock_root, config=LockConfig(mode="hard", preempt_weaker_holder=True)) window._arbiter = arbiter with ( patch("gatelock._window.os.kill", side_effect=OSError("nope")),