Files
Edward Cheng-I Wu f1a57bbcab fix: shared file-lock helper with msvcrt backend for the remaining fcntl sites (#845) (#847)
* fix: shared file-lock helper with msvcrt backend for the six fcntl sites (#845)

scripts/file_lock.py owns the backend choice (fcntl.flock on POSIX,
msvcrt.locking on byte 0 on Windows) and routes adjudication_activity,
inquiry_branch_ledger, review_criteria_binding, and ars_mark_read through
acquire()/release(). POSIX lock sequences are unchanged. Per-site Windows
decisions: adjudication reads degrade to exclusive with a 5 s bounded wait;
the review-criteria manifest lock is capped at 30 s on Windows only; the
inquiry ledger alpha keeps refusing non-POSIX hosts. Two finally blocks that
released an unacquired lock now release only what they acquired. SETUP docs
state the best-effort Windows posture; no Windows CI job is added.

Refs #845, #843, #844.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131cZMWBPPeEFiqgEPFZ3X2

* fix(file_lock): interrupted attempts honour the deadline; pin adjudication wait policy (#845)

Cross-model review round 1 (gpt-6-astra, xhigh): a persistent
InterruptedError could retry past the bound; the Windows-shape test did
not exercise adjudication's reader-waits / writer-does-not-wait policy;
the adjudication contention message now names LockTimeout instead of
BlockingIOError, recorded in the CHANGELOG rather than masked.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131cZMWBPPeEFiqgEPFZ3X2

* refactor(file_lock): held() context manager, single BACKEND source, one fake msvcrt (#845)

/simplify pass (four cleanup reviewers): the release-only-if-acquired
invariant moves into file_lock.held() and review_criteria_binding /
inquiry_branch_ledger use it; runtime branches key off BACKEND and
SHARED_LOCKS_SUPPORTED is dropped; EINTR joins the retryable errno set and
the unreachable EDEADLK entry goes; backend calls are deduplicated; all four
consumers try the sibling import first so one module instance is shared;
the Windows fake lives once in tests/fake_msvcrt.py; test scaffolding is
folded into a lock_pair fixture and a parametrized wait test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131cZMWBPPeEFiqgEPFZ3X2

* fix(file_lock): keep lock acquisition and the guarded body in separate try blocks (#845)

Cross-model review round 3 (gpt-6-astra, xhigh): wrapping the body in the
same handler that translates LockTimeout meant a contended inner lock inside
the body was reported as the outer manifest/passport lock failing. Both
consumers now acquire in their own try block and release only after a
successful acquire; held() is dropped from the helper. The subprocess test
pins that a LockTimeout raised inside the binding body surfaces as itself.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131cZMWBPPeEFiqgEPFZ3X2

* test(file_lock): let the body LockTimeout leave _locked() so the attribution check bites (#845)

Cross-model review round 4: the inner LockTimeout was caught inside the
binding body, so the erroneous outer translation would still have passed.
Verified by mutation: restoring the outer translation fails this test.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131cZMWBPPeEFiqgEPFZ3X2

* ci(673): whitelist scripts/test_file_lock.py as a non-consumer importer of the activity runtime (#845)

The shared file-lock test imports adjudication_activity in a subprocess to
exercise its lock backend under a fake msvcrt; it never reads or writes an
activity store. The exact-owner whitelist is the lint's route for that.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0131cZMWBPPeEFiqgEPFZ3X2

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 14:47:26 +08:00

115 lines
3.9 KiB
Python

#!/usr/bin/env python3
"""Shared advisory file locking for ARS scripts (#845).
One backend per host, chosen at import time and published as ``BACKEND``:
* ``"fcntl"`` (POSIX): ``flock``. Shared and exclusive modes, blocking and
non-blocking, exactly as before #845.
* ``"msvcrt"`` (Windows): ``locking`` on byte 0 of the lock file. This
backend is best-effort and has no CI coverage; it differs from ``flock`` in
two documented ways that callers decide about rather than paper over:
- there is no shared mode, so ``exclusive=False`` takes an exclusive lock;
- there is no indefinite blocking wait, so ``timeout=None`` polls for at
most ``WINDOWS_BLOCKING_WAIT_SECONDS`` and then raises ``LockTimeout``.
The helper never reads or writes the lock file. ``msvcrt.locking`` may lock
a byte beyond end-of-file, so an empty lock file is valid on both backends;
callers that require the lock file to stay empty keep that invariant.
Contention on either backend surfaces as ``LockTimeout`` (a
``BlockingIOError`` carrying ``EAGAIN``), including ``timeout=0``, so a
caller can treat "someone else holds it" uniformly. Every other ``OSError``
propagates unchanged.
"""
from __future__ import annotations
import errno
import os
import time
try:
import fcntl
BACKEND = "fcntl"
except ModuleNotFoundError: # pragma: no cover - exercised on Windows
import msvcrt # type: ignore[import-not-found]
BACKEND = "msvcrt"
WINDOWS_BLOCKING_WAIT_SECONDS = 30.0
POLL_SECONDS = 0.05
# EAGAIN / EWOULDBLOCK: flock non-blocking contention. EACCES: msvcrt
# contention. EINTR: a signal interrupted the attempt; retried like
# contention so the deadline still bounds it.
_RETRYABLE_ERRNOS = frozenset(
{errno.EAGAIN, errno.EWOULDBLOCK, errno.EACCES, errno.EINTR}
)
class LockTimeout(BlockingIOError):
"""The lock was still held by someone else when the wait ran out."""
def __init__(self, waited: float) -> None:
super().__init__(errno.EAGAIN, f"lock still held after {waited:g}s")
def _flock(fd: int, *, exclusive: bool, blocking: bool) -> None:
operation = fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH
if not blocking:
operation |= fcntl.LOCK_NB
fcntl.flock(fd, operation)
def _msvcrt_byte0(fd: int, mode: int) -> None:
os.lseek(fd, 0, os.SEEK_SET)
msvcrt.locking(fd, mode, 1)
def _try_once(fd: int, *, exclusive: bool) -> None:
"""One non-blocking attempt; raises OSError with a retryable errno if held."""
if BACKEND == "fcntl":
_flock(fd, exclusive=exclusive, blocking=False)
else:
_msvcrt_byte0(fd, msvcrt.LK_NBLCK)
def acquire(fd: int, *, exclusive: bool = True, timeout: float | None) -> None:
"""Acquire an advisory lock on ``fd``.
``timeout=None`` blocks until the lock is free (bounded on Windows, see
module docstring); ``timeout=0`` makes a single attempt; ``timeout>0``
polls until the deadline. Raises ``LockTimeout`` when the lock is still
held at the end of the wait.
"""
if timeout is not None and timeout < 0:
raise ValueError("lock timeout must be non-negative")
if timeout is None and BACKEND == "fcntl":
_flock(fd, exclusive=exclusive, blocking=True)
return
wait = WINDOWS_BLOCKING_WAIT_SECONDS if timeout is None else float(timeout)
deadline = time.monotonic() + wait
while True:
try:
_try_once(fd, exclusive=exclusive)
return
except OSError as exc:
if exc.errno not in _RETRYABLE_ERRNOS:
raise
remaining = deadline - time.monotonic()
if remaining <= 0:
raise LockTimeout(wait) from exc
time.sleep(min(POLL_SECONDS, remaining))
def release(fd: int) -> None:
"""Release a lock taken with :func:`acquire`."""
if BACKEND == "fcntl":
fcntl.flock(fd, fcntl.LOCK_UN)
else:
_msvcrt_byte0(fd, msvcrt.LK_UNLCK)