Make lock preemption opt-in: it could evict the armed workout lock
Some checks are pending
gatelock pre-commit / pre-commit (push) Waiting to run
gatelock tests / test (3.10) (push) Waiting to run
gatelock tests / test (3.11) (push) Waiting to run
gatelock tests / test (3.12) (push) Waiting to run
palette drift / palette (push) Waiting to run

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018M5gKyYzxWG6QkCaj1wWfk
This commit is contained in:
Krzysztof Rudnicki 2026-08-21 13:31:23 +02:00
parent 7e7c34a073
commit a4b72a21c8
2 changed files with 36 additions and 8 deletions

View File

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

View File

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