gatelock 0.11.0: Claim.grab_held + held_grabs(), so a reader can tell a locker's X grab is actually held
Some checks failed
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
gatelock pre-commit / pre-commit (push) Has been cancelled
gatelock tests / test (push) Has been cancelled
music_theory tests / test (push) Has been cancelled

A live claim was not "input is locked": it is published before the window
grabs, and leetcode-guard's study mode releases the grab while the claim
lives on. leisure-watch (contract C2) must pause shadow billing only while a
grab is really held.

- Claim.grab_held (always serialised; missing key from an old writer reads
  as grab != "none"). Arbitration still uses the declared grab only.
- Arbiter.set_grab_held(held=) rewrites the claim and holder copies in place
  through the owner's flocked fd (O_APPEND cleared, pwrite at 0, padded so
  the file never shrinks) -- same inode, flock liveness untouched. Never raises.
- Set true only after grab_set/grab_set_global succeeded; RecoveryLoop
  reports each tick's real answer (a stolen, un-retaken grab reads false).
  LockWindow.report_grab_held(held=) is the hook for deliberate releases.
- gatelock.held_grabs(claims_dir=None): live claims with grab_held and a real
  grab. Root callers pass /run/user/1000/gatelock/claims.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
This commit is contained in:
Krzysztof Rudnicki 2026-10-19 11:40:16 +02:00
parent 2d97dc4b0e
commit 35f6f95956
12 changed files with 534 additions and 21 deletions

View File

@ -80,6 +80,17 @@ evening night-lockdown already happened?" from night-lockdown's
re-arm for the rest of such a day; diet-guard does not. Missing or garbage re-arm for the rest of such a day; diet-guard does not. Missing or garbage
means "no lockdown" (garbage warns), so the gate locks as before. means "no lockdown" (garbage warns), so the gate locks as before.
`gatelock.held_grabs(claims_dir=None) -> tuple[Claim, ...]` answers "is a
locker's X grab ACTUALLY held right now?": live claims (flock-proved) whose
`grab_held` is true and whose declared grab is not `none`. `grab_held` turns
true only after `grab_set`/`grab_set_global` succeeded, follows each recovery
tick, and is cleared/restored around a deliberate release via
`LockWindow.report_grab_held(held=...)` (or `Arbiter.set_grab_held`) --
leetcode-guard's study mode. It is rewritten in place under the owner's flock.
A claim from a pre-0.11 writer has no key and reads as `grab != "none"`. Root
has no `XDG_RUNTIME_DIR`, so a root reader passes the user's dir
(`/run/user/1000/gatelock/claims`).
## Development ## Development
```bash ```bash

View File

@ -14,6 +14,7 @@ from gatelock._arbiter import (
RANK_WAKE_ALARM, RANK_WAKE_ALARM,
Arbiter, Arbiter,
) )
from gatelock._claimfile import held_grabs
from gatelock._claims import ArbiterVerdict, Claim, default_runtime_dir, grab_strength from gatelock._claims import ArbiterVerdict, Claim, default_runtime_dir, grab_strength
from gatelock._config import GrabPolicy, LockConfig, SpaceStep, TypeRole from gatelock._config import GrabPolicy, LockConfig, SpaceStep, TypeRole
from gatelock._detect import OutputChangeDetector from gatelock._detect import OutputChangeDetector
@ -124,6 +125,7 @@ __all__ = [
"escape_text_tab_trap", "escape_text_tab_trap",
"grab_strength", "grab_strength",
"heading", "heading",
"held_grabs",
"make_button", "make_button",
"measure_fit", "measure_fit",
"mirror_text_widgets", "mirror_text_widgets",

View File

@ -31,12 +31,14 @@ arms anyway. See :meth:`Arbiter.evaluate`.
from __future__ import annotations from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime from datetime import UTC, datetime
import logging import logging
import os import os
from typing import IO, TYPE_CHECKING, Final from typing import IO, TYPE_CHECKING, Final
import uuid import uuid
from gatelock._claimfile import CLAIMS_DIRNAME, rewrite_in_place
from gatelock._claims import ( from gatelock._claims import (
ArbiterVerdict, ArbiterVerdict,
Claim, Claim,
@ -55,12 +57,8 @@ RANK_WAKE_ALARM: Final = 300
RANK_SCREEN_LOCKER: Final = 200 RANK_SCREEN_LOCKER: Final = 200
RANK_DIET_GUARD: Final = 100 RANK_DIET_GUARD: Final = 100
_CLAIMS_DIRNAME: Final = "claims"
_HOLDER_FILENAME: Final = "holder.lock" _HOLDER_FILENAME: Final = "holder.lock"
# Ordering for the strength check. A stand-down is only safe towards an
# incumbent whose grab is at least this strong.
def _utc_now_iso() -> str: def _utc_now_iso() -> str:
"""Return an ISO-8601 UTC timestamp, sortable as a plain string.""" """Return an ISO-8601 UTC timestamp, sortable as a plain string."""
@ -89,7 +87,7 @@ class Arbiter:
runtime_dir: Override for the state directory. Tests pass a tmp_path. runtime_dir: Override for the state directory. Tests pass a tmp_path.
""" """
self._root = runtime_dir if runtime_dir is not None else default_runtime_dir() self._root = runtime_dir if runtime_dir is not None else default_runtime_dir()
self._claims_dir = self._root / _CLAIMS_DIRNAME self._claims_dir = self._root / CLAIMS_DIRNAME
self._claim = Claim( self._claim = Claim(
app=app, app=app,
rank=rank, rank=rank,
@ -134,6 +132,19 @@ class Arbiter:
handle.flush() handle.flush()
self._claim_handle = handle self._claim_handle = handle
def set_grab_held(self, *, held: bool) -> None:
"""Record whether this app's X grab is ACTUALLY held right now.
Rewrites the claim (and the holder copy, if held) in place under the
existing flock -- see :mod:`gatelock._claimfile`. Idempotent, and
never raises: it runs inside grab and study-mode transitions.
"""
if self._claim.grab_held is held:
return
self._claim = replace(self._claim, grab_held=held)
for handle in (self._claim_handle, self._holder_handle):
rewrite_in_place(handle, self._claim.to_json())
def live_claims(self) -> tuple[Claim, ...]: def live_claims(self) -> tuple[Claim, ...]:
"""Return every claim whose owning process is still alive. """Return every claim whose owning process is still alive.

View File

@ -51,6 +51,10 @@ _TOPMOST_ON = True
_DEFAULT_GRAB_RETRY_MS = 200 _DEFAULT_GRAB_RETRY_MS = 200
def _no_grab_listener() -> None:
"""Default for :attr:`ArmingCollaborators.notify_grab_held`."""
@dataclass(frozen=True) @dataclass(frozen=True)
class ArmingCollaborators: class ArmingCollaborators:
"""Everything the arming sequence needs from its owning window. """Everything the arming sequence needs from its owning window.
@ -64,6 +68,8 @@ class ArmingCollaborators:
notify_focus_ready: Called once the lock is mapped and grabbed. notify_focus_ready: Called once the lock is mapped and grabbed.
log_grab_blocked: Called with the attempt count when a global grab is log_grab_blocked: Called with the attempt count when a global grab is
still blocked, to name the holder and stand a weaker one down. still blocked, to name the holder and stand a weaker one down.
notify_grab_held: Called once a grab call has actually succeeded, so
the claim's ``grab_held`` turns true only then.
""" """
config: LockConfig config: LockConfig
@ -73,6 +79,7 @@ class ArmingCollaborators:
recovery: RecoveryLoop recovery: RecoveryLoop
notify_focus_ready: Callable[[], None] notify_focus_ready: Callable[[], None]
log_grab_blocked: Callable[[int], None] log_grab_blocked: Callable[[int], None]
notify_grab_held: Callable[[], None] = _no_grab_listener
def setup(root: tk.Tk, collab: ArmingCollaborators) -> bool: def setup(root: tk.Tk, collab: ArmingCollaborators) -> bool:
@ -142,6 +149,7 @@ def grab_input(root: tk.Tk, collab: ArmingCollaborators) -> None:
elif grab == "local": elif grab == "local":
with contextlib.suppress(tk.TclError): with contextlib.suppress(tk.TclError):
root.grab_set() root.grab_set()
collab.notify_grab_held()
collab.detector.start() collab.detector.start()
collab.recovery.start() collab.recovery.start()
root.after(100, collab.notify_focus_ready) root.after(100, collab.notify_focus_ready)
@ -165,6 +173,7 @@ def acquire_global_grab(
_logger.warning("Global grab failed, falling back to local grab") _logger.warning("Global grab failed, falling back to local grab")
with contextlib.suppress(tk.TclError): with contextlib.suppress(tk.TclError):
root.grab_set() root.grab_set()
collab.notify_grab_held()
return return
effective_retry_ms = retry_ms or _DEFAULT_GRAB_RETRY_MS effective_retry_ms = retry_ms or _DEFAULT_GRAB_RETRY_MS
if not attempt % collab.config.grab.log_every: if not attempt % collab.config.grab.log_every:
@ -174,6 +183,7 @@ def acquire_global_grab(
lambda: acquire_global_grab(root, collab, attempt=attempt + 1), lambda: acquire_global_grab(root, collab, attempt=attempt + 1),
) )
return return
collab.notify_grab_held()
with contextlib.suppress(tk.TclError): with contextlib.suppress(tk.TclError):
root.focus_force() root.focus_force()
collab.notify_focus_ready() collab.notify_focus_ready()

View File

@ -0,0 +1,89 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Whether a locker's X grab is ACTUALLY held, on disk, for any reader.
A claim is published *before* its window grabs, and leetcode-guard's study
mode releases the grab while the claim lives on -- so "a live claim exists" is
not "input is locked". :attr:`gatelock.Claim.grab_held` is the second answer,
and this module is how it is written and read.
**Rewritten in place, never replaced.** Liveness is the ``flock`` the owner
holds on its open claim file (see :mod:`gatelock._arbiter`). A temp file plus
``os.replace`` would swap in a fresh inode nobody has locked, and the next
reader would take its lock and reap it as dead. So the flip overwrites the
same inode through the owner's own descriptor: ``O_APPEND`` (the handle was
opened ``a+``) is cleared so ``pwrite`` lands at offset 0, and the new text is
space-padded to the current size so the file never shrinks -- a reader sees
the old document or the new one, never an empty or truncated one. JSON allows
trailing whitespace, so the padding parses.
**Never raises.** The flip runs inside grab and study-mode transitions; an
exception there would roll a lock back. A failed write is logged and the
in-memory claim still changes.
"""
from __future__ import annotations
import fcntl
import logging
import os
from typing import IO, TYPE_CHECKING, Final
from gatelock._claims import default_runtime_dir, live_claims
if TYPE_CHECKING:
from pathlib import Path
from gatelock._claims import Claim
_logger = logging.getLogger(__name__)
CLAIMS_DIRNAME: Final = "claims"
def rewrite_in_place(handle: IO[str] | None, text: str) -> bool:
"""Overwrite the flock-held file behind `handle` with `text`, same inode.
Args:
handle: The owner's open (and locked) claim or holder handle; None
when the arbiter never published or has released.
text: The new JSON document.
Returns:
Whether the file was rewritten.
"""
if handle is None:
return False
try:
handle.flush()
fd = handle.fileno()
flags = fcntl.fcntl(fd, fcntl.F_GETFL)
fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_APPEND)
data = text.encode("utf-8")
os.pwrite(fd, data.ljust(os.fstat(fd).st_size, b" "), 0)
except (OSError, ValueError) as exc:
_logger.warning("could not rewrite claim file in place: %s", exc)
return False
return True
def held_grabs(claims_dir: Path | None = None) -> tuple[Claim, ...]:
"""Every live claim whose X grab is actually held right now.
Readable by root: the enforcer runs as root, where ``XDG_RUNTIME_DIR`` is
unset and the default below resolves to root's own (empty) dir -- so root
callers must pass the user's dir, ``/run/user/1000/gatelock/claims``.
Dead owners' claims are reaped as a side effect, as in
:func:`gatelock._claims.live_claims`.
Args:
claims_dir: Directory holding claim files; defaults to the one
:class:`gatelock.Arbiter` publishes into for this user.
Returns:
Live claims with ``grab_held`` and a declared grab other than "none",
in arbitration order.
"""
directory = (
claims_dir if claims_dir is not None else default_runtime_dir() / CLAIMS_DIRNAME
)
return tuple(c for c in live_claims(directory) if c.grab_held and c.grab != "none")

View File

@ -57,6 +57,9 @@ class Claim:
arbiters, and the whole test suite does exactly that to exercise real arbiters, and the whole test suite does exactly that to exercise real
``flock`` contention without spawning subprocesses. ``flock`` contention without spawning subprocesses.
""" """
grab_held: bool = False
"""Whether the X grab is ACTUALLY held now (false before it lands and while
released, e.g. leetcode-guard study mode). See :mod:`gatelock._claimfile`."""
def to_json(self) -> str: def to_json(self) -> str:
"""Serialise this claim for its on-disk file.""" """Serialise this claim for its on-disk file."""
@ -69,6 +72,7 @@ class Claim:
"grab": self.grab, "grab": self.grab,
"disable_vt": self.disable_vt, "disable_vt": self.disable_vt,
"instance_id": self.instance_id, "instance_id": self.instance_id,
"grab_held": self.grab_held,
} }
) )
@ -86,14 +90,17 @@ class Claim:
if not isinstance(raw, dict): if not isinstance(raw, dict):
return None return None
try: try:
grab = str(raw["grab"])
return cls( return cls(
app=str(raw["app"]), app=str(raw["app"]),
rank=int(raw["rank"]), rank=int(raw["rank"]),
pid=int(raw["pid"]), pid=int(raw["pid"]),
started=str(raw["started"]), started=str(raw["started"]),
grab=str(raw["grab"]), grab=grab,
disable_vt=bool(raw["disable_vt"]), disable_vt=bool(raw["disable_vt"]),
instance_id=str(raw["instance_id"]), instance_id=str(raw["instance_id"]),
# A pre-0.11 writer never wrote the key: trust its declared grab.
grab_held=bool(raw.get("grab_held", grab != "none")),
) )
except KeyError, TypeError, ValueError: except KeyError, TypeError, ValueError:
return None return None

View File

@ -193,13 +193,15 @@ class RecoveryLoop:
""" """
if self._parts.config.resolved_grab() != "global": if self._parts.config.resolved_grab() != "global":
return False return False
if self.holds_grab(): held = self.holds_grab()
return False reasserted = False
with contextlib.suppress(tk.TclError): if not held:
self._root.grab_set_global() with contextlib.suppress(tk.TclError):
_logger.warning("global grab had been lost; re-acquired it") self._root.grab_set_global()
return True _logger.warning("global grab had been lost; re-acquired it")
return False reasserted = held = True
self._parts.on_grab_held(held=held)
return reasserted
def holds_grab(self) -> bool: def holds_grab(self) -> bool:
"""Whether the grab is held by a window this application owns. """Whether the grab is held by a window this application owns.

View File

@ -9,7 +9,7 @@ against a report without pulling in the machinery that builds one.
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from dataclasses import dataclass
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Protocol
if TYPE_CHECKING: if TYPE_CHECKING:
from gatelock._config import LockConfig from gatelock._config import LockConfig
@ -19,6 +19,18 @@ if TYPE_CHECKING:
from gatelock._surfaces import SurfaceDelta, SurfaceSet from gatelock._surfaces import SurfaceDelta, SurfaceSet
class GrabHeldListener(Protocol):
"""Told whether the grab is really ours (``Claim.grab_held``)."""
def __call__(self, *, held: bool) -> None:
"""Record the grab state."""
def ignore_grab_held(*, held: bool) -> None:
"""Default :class:`GrabHeldListener`: no arbiter to tell."""
del held
@dataclass(frozen=True) @dataclass(frozen=True)
class RecoveryReport: class RecoveryReport:
"""What one tick observed and corrected.""" """What one tick observed and corrected."""
@ -46,3 +58,5 @@ class RecoveryCollaborators:
enumerator: OutputEnumerator enumerator: OutputEnumerator
detector: OutputChangeDetector detector: OutputChangeDetector
hooks: LockWindowHooks hooks: LockWindowHooks
on_grab_held: GrabHeldListener = ignore_grab_held
"""Told each tick whether the grab is really ours (``Claim.grab_held``)."""

View File

@ -15,12 +15,8 @@ detection in :mod:`gatelock._detect`, re-assertion in
:mod:`gatelock._recovery`, and cross-app priority in :mod:`gatelock._recovery`, and cross-app priority in
:mod:`gatelock._arbiter`. :mod:`gatelock._arbiter`.
Arming order matters and is deliberate: **arm first, render second.** The grab Arming order (**arm first, render second**) is explained in
and the VT-disable happen regardless of how many outputs are live, and the :mod:`gatelock._arming`.
surfaces are then built for whatever the user can actually see -- possibly
nothing. Zero live outputs means "lock without showing", never "decline to
lock". The inverse of that rule is what left the machine unlocked on
2026-07-25.
""" """
from __future__ import annotations from __future__ import annotations
@ -95,6 +91,7 @@ class LockWindow:
enumerator=enumerator, enumerator=enumerator,
detector=detector, detector=detector,
hooks=hooks, hooks=hooks,
on_grab_held=self.report_grab_held,
), ),
) )
# The arming bundle is also where the window reaches its # The arming bundle is also where the window reaches its
@ -107,6 +104,7 @@ class LockWindow:
recovery=recovery, recovery=recovery,
notify_focus_ready=self._notify_focus_ready, notify_focus_ready=self._notify_focus_ready,
log_grab_blocked=self._log_grab_blocked, log_grab_blocked=self._log_grab_blocked,
notify_grab_held=lambda: self.report_grab_held(held=True),
) )
@property @property
@ -130,6 +128,15 @@ class LockWindow:
"""The output-change watcher; see :attr:`recovery` for when to stop it.""" """The output-change watcher; see :attr:`recovery` for when to stop it."""
return self._arming.detector return self._arming.detector
def report_grab_held(self, *, held: bool) -> None:
"""Publish whether the X grab is ACTUALLY held (``Claim.grab_held``).
Called on every successful grab and recovery tick; an app releasing the
grab on purpose (leetcode study mode) calls it False/True around that.
"""
if self._arbiter is not None:
self._arbiter.set_grab_held(held=held)
# -- window mechanics ----------------------------------------------------- # -- window mechanics -----------------------------------------------------
def setup(self) -> None: def setup(self) -> None:

View File

@ -0,0 +1,214 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for ``Claim.grab_held``: whether a locker's X grab is ACTUALLY held.
A claim is published before the window grabs, and leetcode-guard's study mode
releases the grab while its claim lives on -- so "a claim exists" is not "the
input is locked". ``grab_held`` is the second answer, rewritten in place in
the flock-held claim file, and :func:`gatelock.held_grabs` is how a reader
(root included) asks for it.
"""
from __future__ import annotations
import fcntl
import json
import os
import subprocess
import sys
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from gatelock import Arbiter, Claim, held_grabs
from gatelock._arbiter import RANK_SCREEN_LOCKER
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_BASE = {
"app": "leetcode_guard",
"rank": 150,
"pid": 1,
"started": "2026-10-19T08:00:00+00:00",
"disable_vt": True,
"instance_id": "abc",
}
def _arbiter(runtime: Path, grab: str = "global") -> Arbiter:
"""A published arbiter under `runtime`."""
arbiter = Arbiter(
"leetcode_guard",
RANK_SCREEN_LOCKER,
grab=grab,
disable_vt=True,
runtime_dir=runtime,
)
arbiter.publish()
return arbiter
@pytest.fixture
def arb(tmp_path: Path) -> Iterator[Arbiter]:
"""A published global-grab arbiter, released afterwards."""
arbiter = _arbiter(tmp_path / "rt")
yield arbiter
arbiter.release()
def _claim_file(arbiter: Arbiter) -> Path:
(path,) = arbiter._claims_dir.glob("*.json")
return path
class TestSerialisation:
"""``grab_held`` is always written, and inferred for old writers."""
def test_defaults_false_and_is_always_serialised(self) -> None:
"""A fresh claim has not grabbed anything yet."""
claim = Claim(**_BASE, grab="global")
assert claim.grab_held is False
assert json.loads(claim.to_json())["grab_held"] is False
@pytest.mark.parametrize(
"case", [("global", True), ("local", True), ("none", False)]
)
def test_old_writer_without_the_key_infers_from_grab(
self, case: tuple[str, bool]
) -> None:
"""JSON from a pre-0.11 writer reads as held whenever it declared a grab."""
grab, held = case
claim = Claim.from_json(json.dumps({**_BASE, "grab": grab}))
assert claim is not None
assert claim.grab_held is held
def test_explicit_key_wins_over_inference(self) -> None:
"""A new writer saying False is believed even with a global grab."""
text = json.dumps({**_BASE, "grab": "global", "grab_held": False})
claim = Claim.from_json(text)
assert claim is not None
assert claim.grab_held is False
def test_round_trip(self) -> None:
"""to_json/from_json preserve the flag."""
claim = Claim(**_BASE, grab="global", grab_held=True)
assert Claim.from_json(claim.to_json()) == claim
class TestSetGrabHeld:
"""``Arbiter.set_grab_held`` rewrites the live claim in place."""
def test_published_claim_starts_not_held(self, arb: Arbiter) -> None:
"""Published before the grab, so it must not claim one yet."""
(claim,) = arb.live_claims()
assert claim.grab_held is False
def test_flip_is_visible_to_readers_and_back(self, arb: Arbiter) -> None:
"""True after the grab, False again on release, read off disk."""
arb.set_grab_held(held=True)
assert arb.claim.grab_held is True
assert [c.grab_held for c in arb.live_claims()] == [True]
arb.set_grab_held(held=False)
assert [c.grab_held for c in arb.live_claims()] == [False]
def test_rewrite_keeps_inode_and_flock(self, arb: Arbiter) -> None:
"""Same file, still locked: liveness stays the kernel's flock."""
path = _claim_file(arb)
inode = path.stat().st_ino
for held in (True, False, True):
arb.set_grab_held(held=held)
assert path.stat().st_ino == inode
with path.open("r", encoding="utf-8") as handle:
with pytest.raises(BlockingIOError):
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
assert Claim.from_json(handle.read()) == arb.claim
def test_another_process_sees_it_held(self, arb: Arbiter) -> None:
"""A separate process (the root enforcer, in production) reads it."""
arb.set_grab_held(held=True)
code = (
"import sys; from pathlib import Path; from gatelock import held_grabs; "
"print(len(held_grabs(Path(sys.argv[1]))))"
)
# Popen, not run: the suite-wide hermetic fixture patches subprocess.run.
with subprocess.Popen(
[sys.executable, "-c", code, str(arb._claims_dir)],
stdout=subprocess.PIPE,
text=True,
env={**os.environ, "PYTHONPATH": os.pathsep.join(sys.path)},
) as proc:
stdout, _ = proc.communicate(timeout=20)
assert stdout.strip() == "1"
assert proc.returncode == 0
def test_unchanged_value_does_not_touch_disk(self, arb: Arbiter) -> None:
"""Recovery reports every tick; only a transition is written."""
with patch("gatelock._arbiter.rewrite_in_place") as rewrite:
arb.set_grab_held(held=False)
assert rewrite.call_count == 0
arb.set_grab_held(held=True)
written = rewrite.call_count
arb.set_grab_held(held=True)
assert written > 0
assert rewrite.call_count == written
def test_holder_file_follows_too(self, arb: Arbiter) -> None:
"""The holder.lock copy of the claim is kept in step."""
assert arb.acquire_holder()
arb.set_grab_held(held=True)
holder = arb._root / "holder.lock"
claim = Claim.from_json(holder.read_text(encoding="utf-8"))
assert claim is not None
assert claim.grab_held is True
def test_unpublished_and_released_never_raise(self, tmp_path: Path) -> None:
"""No handle (or a closed one) is an in-memory update, not a crash."""
arbiter = Arbiter("x", 1, grab="global", disable_vt=False, runtime_dir=tmp_path)
arbiter.set_grab_held(held=True)
assert arbiter.claim.grab_held is True
arbiter.publish()
arbiter.release()
arbiter.set_grab_held(held=False)
assert arbiter.claim.grab_held is False
def test_a_closed_handle_is_logged_not_raised(
self, arb: Arbiter, caplog: pytest.LogCaptureFixture
) -> None:
"""A handle closed under us (ValueError) degrades to a warning."""
assert arb._claim_handle is not None
arb._claim_handle.close()
arb.set_grab_held(held=True)
assert "could not rewrite" in caplog.text
class TestHeldGrabs:
"""``held_grabs`` lists live claims whose grab is held right now."""
def test_filters_unheld_and_grabless(self, tmp_path: Path) -> None:
"""Only a held, real grab counts."""
runtime = tmp_path / "rt"
held, unheld, grabless = (
_arbiter(runtime, g) for g in ("global", "local", "none")
)
held.set_grab_held(held=True)
grabless.set_grab_held(held=True)
try:
assert [c.instance_id for c in held_grabs(runtime / "claims")] == [
held.claim.instance_id
]
finally:
for arbiter in (held, unheld, grabless):
arbiter.release()
def test_default_dir_is_the_runtime_claims_dir(self) -> None:
"""No argument reads the same dir Arbiter publishes into (hermetic env)."""
arbiter = Arbiter("x", 1, grab="global", disable_vt=False)
arbiter.publish()
arbiter.set_grab_held(held=True)
try:
assert held_grabs() == (arbiter.claim,)
finally:
arbiter.release()
assert held_grabs() == ()

View File

@ -0,0 +1,146 @@
# Copyright (c) 2026 Krzysztof Rudnicki
"""Tests for WHEN ``grab_held`` flips: only on a grab that actually succeeded.
The arming sequence reports a held grab after ``grab_set``/``grab_set_global``
returns; the recovery loop reports each tick's real answer (so a grab a game
stole reads False until it is re-taken); ``LockWindow.report_grab_held`` is the
public hook an app that suspends its lock (leetcode-guard) uses.
"""
from __future__ import annotations
from dataclasses import replace
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, call
from gatelock._config import GrabPolicy, LockConfig
from gatelock._window import LockWindow
from gatelock.tests.conftest import BOTH
if TYPE_CHECKING:
from gatelock._recovery import RecoveryLoop
from gatelock._surfaces import SurfaceSet
def _window(root: MagicMock, config: LockConfig) -> tuple[LockWindow, MagicMock]:
arbiter = MagicMock()
return LockWindow(root, config, MagicMock(), arbiter=arbiter), arbiter
class TestArming:
"""The first grab reports held only once it succeeded."""
def test_global_success_reports_held(self, mock_root: MagicMock) -> None:
"""grab_set_global returned: held."""
window, arbiter = _window(mock_root, LockConfig(mode="hard"))
window._acquire_global_grab(attempt=1)
arbiter.set_grab_held.assert_called_once_with(held=True)
def test_global_failure_with_retry_reports_nothing(
self, mock_root: MagicMock
) -> None:
"""Still retrying (a game holds it): not held, nothing written."""
mock_root.grab_set_global.side_effect = tk.TclError("held")
window, arbiter = _window(mock_root, LockConfig(mode="hard"))
window._acquire_global_grab(attempt=1)
arbiter.set_grab_held.assert_not_called()
def test_local_fallback_reports_held_only_on_success(
self, mock_root: MagicMock
) -> None:
"""retry_ms=0 falls back to a local grab; held iff that worked."""
mock_root.grab_set_global.side_effect = tk.TclError("held")
config = LockConfig(mode="hard", grab=GrabPolicy(retry_ms=0))
window, arbiter = _window(mock_root, config)
window._acquire_global_grab(attempt=1)
arbiter.set_grab_held.assert_called_once_with(held=True)
mock_root.grab_set.side_effect = tk.TclError("also gone")
window, arbiter = _window(mock_root, config)
window._acquire_global_grab(attempt=1)
arbiter.set_grab_held.assert_not_called()
def test_configured_local_grab(self, mock_root: MagicMock) -> None:
"""A local-grab lock reports held after grab_set, not before."""
config = LockConfig(mode="soft", grab=GrabPolicy(kind="local"))
window, arbiter = _window(mock_root, config)
window.grab_input()
arbiter.set_grab_held.assert_called_once_with(held=True)
mock_root.grab_set.side_effect = tk.TclError("no")
window, arbiter = _window(mock_root, config)
window.grab_input()
arbiter.set_grab_held.assert_not_called()
def _spy(recovery: RecoveryLoop) -> MagicMock:
"""Swap the loop's grab-state callback for a mock."""
spy = MagicMock()
recovery._parts = replace(recovery._parts, on_grab_held=spy)
return spy
class TestRecovery:
"""Each tick reports whether the grab is really ours."""
def test_reports_true_while_held(
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
) -> None:
"""Held grab -> True (deduplicated downstream)."""
recovery, enumerator, _ = loop
spy = _spy(recovery)
enumerator.scan.return_value = BOTH
mock_root.grab_current.return_value = mock_root
recovery.tick()
spy.assert_called_once_with(held=True)
def test_stolen_and_not_retaken_reports_false(
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
) -> None:
"""Lost and the re-grab failed: False, the truth."""
recovery, enumerator, _ = loop
spy = _spy(recovery)
enumerator.scan.return_value = BOTH
mock_root.grab_current.return_value = None
mock_root.grab_set_global.side_effect = tk.TclError("game")
assert recovery.tick().grab_reasserted is False
spy.assert_called_once_with(held=False)
def test_retaken_reports_true(
self, loop: tuple[RecoveryLoop, MagicMock, SurfaceSet], mock_root: MagicMock
) -> None:
"""Lost then re-taken in the same tick: True."""
recovery, enumerator, _ = loop
spy = _spy(recovery)
enumerator.scan.return_value = BOTH
mock_root.grab_current.return_value = None
assert recovery.tick().grab_reasserted is True
spy.assert_called_once_with(held=True)
class TestWindowHook:
"""``LockWindow.report_grab_held`` forwards to the arbiter, if any."""
def test_forwards_both_directions(self, mock_root: MagicMock) -> None:
"""Study mode's release and resume land on the claim."""
window, arbiter = _window(mock_root, LockConfig(mode="hard"))
window.report_grab_held(held=False)
window.report_grab_held(held=True)
assert arbiter.set_grab_held.call_args_list == [
call(held=False),
call(held=True),
]
def test_recovery_reports_reach_the_arbiter(self, mock_root: MagicMock) -> None:
"""The loop the window builds is wired to the same hook."""
window, arbiter = _window(mock_root, LockConfig(mode="hard"))
mock_root.grab_current.return_value = None
mock_root.grab_set_global.side_effect = tk.TclError("game")
window.recovery._reassert_grab()
arbiter.set_grab_held.assert_called_once_with(held=False)
def test_no_arbiter_is_a_noop(self, mock_root: MagicMock) -> None:
"""Arbitration disabled: nothing to write, nothing raised."""
window = LockWindow(mock_root, LockConfig(mode="hard"), MagicMock())
window.report_grab_held(held=True)

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "gatelock" name = "gatelock"
version = "0.10.0" version = "0.11.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.14" requires-python = ">=3.14"
dependencies = [] # pure stdlib — tkinter is bundled with Python dependencies = [] # pure stdlib — tkinter is bundled with Python