utils/gatelock/gatelock/_claimfile.py
Krzysztof kuhy Rudnicki 35f6f95956
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
gatelock 0.11.0: Claim.grab_held + held_grabs(), so a reader can tell a locker's X grab is actually held
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
2026-10-19 11:40:46 +02:00

90 lines
3.3 KiB
Python

# 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")