mirror of
https://github.com/Imbad0202/academic-research-skills.git
synced 2026-09-14 13:51:17 +08:00
f1a57bbcab
* 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>
115 lines
3.9 KiB
Python
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)
|
|
|