81 lines
3.4 KiB
Python
81 lines
3.4 KiB
Python
"""Regression test for a Windows-only PermissionError under lock contention.
|
|
|
|
``_file_lock()`` (backing ``_auth_store_lock()``) ensures its lock file has
|
|
at least 1 byte of content before opening it for ``msvcrt.locking()`` (which
|
|
requires a non-empty file). That "ensure content" write was unguarded: under
|
|
real concurrency, one thread/process's ``msvcrt.locking()`` byte-range lock
|
|
can already be held on the file at the exact moment another thread runs the
|
|
same ensure-content check, and the write collides and raises
|
|
``PermissionError`` -- uncaught, since it happens before the retry loop the
|
|
rest of the function uses for exactly this kind of contention.
|
|
|
|
This was found by a stress test for the Anthropic OAuth credential-pool fix
|
|
(``tests/agent/test_anthropic_oauth_stress.py``), which reliably reproduces
|
|
it (16/20 concurrent refreshes hit PermissionError pre-fix, deterministically
|
|
across repeated runs) because the surrounding CredentialPool work widens the
|
|
race window enough for real OS-level thread interleaving to land on it. That
|
|
test is the authoritative regression guard for the exact failure mode. This
|
|
file is deliberately a minimal, isolated stress test against
|
|
``_auth_store_lock()`` directly -- best-effort coverage that does not always
|
|
land on the same narrow window in isolation, but exercises the SAME shared
|
|
primitive Codex/xAI/Nous already depend on for their single-use-refresh-token
|
|
protection, so this bug was never Anthropic-specific.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
|
|
import pytest
|
|
|
|
from hermes_cli.auth import _auth_store_lock
|
|
|
|
CONCURRENCY = 40
|
|
|
|
|
|
@pytest.fixture
|
|
def hermes_home(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
return tmp_path
|
|
|
|
|
|
@pytest.mark.windows_only
|
|
def test_many_concurrent_lock_acquisitions_do_not_raise_permission_error(hermes_home):
|
|
"""CONCURRENCY threads race to acquire/release the same auth-store lock.
|
|
|
|
None of them should ever see an uncaught PermissionError/OSError from
|
|
the lock-file "ensure content" pre-check -- only the intended
|
|
TimeoutError (never expected here, since each critical section is
|
|
instant) should be a possible failure mode.
|
|
"""
|
|
errors: dict[int, BaseException] = {}
|
|
entered = 0
|
|
entered_lock = threading.Lock()
|
|
# Synchronize all threads to hit the lock-file "ensure content" pre-check
|
|
# at (as close as the OS scheduler allows to) the exact same instant --
|
|
# the race only exists on the very first acquisition against a fresh
|
|
# lock file, so a tight barrier maximizes the odds of reproducing it
|
|
# instead of relying on incidental thread-start jitter.
|
|
barrier = threading.Barrier(CONCURRENCY)
|
|
|
|
def _run(idx: int) -> None:
|
|
nonlocal entered
|
|
try:
|
|
barrier.wait(timeout=10)
|
|
with _auth_store_lock(timeout_seconds=10.0):
|
|
with entered_lock:
|
|
entered += 1
|
|
except BaseException as exc: # pragma: no cover - failure diagnostics
|
|
errors[idx] = exc
|
|
|
|
threads = [threading.Thread(target=_run, args=(i,)) for i in range(CONCURRENCY)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join(timeout=30)
|
|
|
|
still_alive = [t for t in threads if t.is_alive()]
|
|
assert not still_alive, f"{len(still_alive)}/{CONCURRENCY} threads never finished"
|
|
assert not errors, f"unexpected exceptions acquiring the lock concurrently: {errors!r}"
|
|
assert entered == CONCURRENCY
|