Review follow-up on the auth-store lock timeout copy (#124533): - The 'likely holder' half of the timeout message was an unconditional literal, asserted even when no holder exists. Holders now stamp their pid into the lock file on acquire (cleared on release), and the timeout reads it back through a liveness probe: a live foreign pid is named, a stale pid or an empty file stays silent. - Permanent lock failures (ENOSYS/EOPNOTSUPP on flock-unsupported filesystems, bad fd) used to burn the whole deadline and then blame a holder that does not exist. Non-contention errnos now propagate immediately; EACCES stays retried because msvcrt reports both real contention and ACL denials as EACCES. - The shared Nous store lock raises through the same _file_lock on the same init path (resolve_nous_access_token) but its message did not match the routed prefix, so init fell back to the /model + hermes setup advice this PR exists to remove. Both prefixes now route to the contention copy, and the Nous timeout names its lock file too.
106 lines
4.2 KiB
Python
106 lines
4.2 KiB
Python
"""The auth-store lock timeout must name the lock file, and a holder only when one is real.
|
|
|
|
``_auth_store_lock`` serializes auth.json transactions across processes, but a holder can keep
|
|
the lock across a slow OAuth refresh POST, so a waiter (e.g. a Desktop assistant start) times out
|
|
after ~15s while every credential is perfectly fine (#124533). The TimeoutError names the lock
|
|
file, and names the holder only when the lock file carries a live foreign pid — never as an
|
|
unconditional literal for a timeout with no holder. Permanent lock failures (flock-unsupported
|
|
filesystem, ENOSYS/EOPNOTSUPP) must propagate immediately instead of burning the deadline
|
|
telling the user to kill a process that does not exist.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import os
|
|
|
|
import pytest
|
|
|
|
|
|
def _busy_kernel_lock(*args, **kwargs):
|
|
raise BlockingIOError()
|
|
|
|
|
|
def test_lock_timeout_names_the_lock_file_and_a_live_holder(tmp_path, monkeypatch):
|
|
import hermes_cli.auth as auth
|
|
|
|
monkeypatch.setattr(auth, "_kernel_lock", _busy_kernel_lock)
|
|
holder_pid = os.getppid() # the test runner's parent: a live foreign process by construction
|
|
auth_path = tmp_path / "profiles" / "coder" / "auth.json"
|
|
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
auth_path.with_suffix(".lock").write_text(f"{holder_pid}\n", encoding="utf-8")
|
|
|
|
with pytest.raises(TimeoutError) as excinfo:
|
|
with auth._auth_store_lock(timeout_seconds=0.01, target_path=auth_path):
|
|
pass
|
|
|
|
message = str(excinfo.value)
|
|
assert "Timed out waiting for auth store lock" in message
|
|
assert str(auth_path.with_suffix(".lock")) in message # which file is contended
|
|
assert f"pid {holder_pid}" in message # and who is holding it — detected, not assumed
|
|
|
|
|
|
def test_lock_timeout_without_a_holder_stays_silent_about_one(tmp_path, monkeypatch):
|
|
import hermes_cli.auth as auth
|
|
|
|
monkeypatch.setattr(auth, "_kernel_lock", _busy_kernel_lock)
|
|
auth_path = tmp_path / "profiles" / "coder" / "auth.json"
|
|
|
|
with pytest.raises(TimeoutError) as excinfo:
|
|
with auth._auth_store_lock(timeout_seconds=0.01, target_path=auth_path):
|
|
pass
|
|
|
|
message = str(excinfo.value)
|
|
assert str(auth_path.with_suffix(".lock")) in message
|
|
assert "another hermes process" not in message # no holder detected: no one to blame
|
|
|
|
|
|
@pytest.mark.skipif(os.name != "posix", reason="holder liveness probe is POSIX-only (os.kill sig 0)")
|
|
def test_lock_timeout_ignores_a_stale_pid_from_a_dead_holder(tmp_path, monkeypatch):
|
|
import hermes_cli.auth as auth
|
|
|
|
monkeypatch.setattr(auth, "_kernel_lock", _busy_kernel_lock)
|
|
stale_pid = 2 ** 22 # far beyond any pid namespace: probe must report "no such process"
|
|
real_kill = os.kill
|
|
|
|
def _no_such_process(pid, sig):
|
|
if pid == stale_pid:
|
|
raise ProcessLookupError()
|
|
return real_kill(pid, sig)
|
|
|
|
monkeypatch.setattr(os, "kill", _no_such_process)
|
|
auth_path = tmp_path / "profiles" / "coder" / "auth.json"
|
|
auth_path.parent.mkdir(parents=True, exist_ok=True)
|
|
auth_path.with_suffix(".lock").write_text(f"{stale_pid}\n", encoding="utf-8")
|
|
|
|
with pytest.raises(TimeoutError) as excinfo:
|
|
with auth._auth_store_lock(timeout_seconds=0.01, target_path=auth_path):
|
|
pass
|
|
|
|
assert f"pid {stale_pid}" not in str(excinfo.value)
|
|
|
|
|
|
def test_permanent_lock_failure_propagates_instead_of_burning_the_deadline(tmp_path, monkeypatch):
|
|
import hermes_cli.auth as auth
|
|
|
|
def _unsupported(*args, **kwargs):
|
|
raise OSError(errno.ENOSYS, "flock not supported on this filesystem")
|
|
|
|
monkeypatch.setattr(auth, "_kernel_lock", _unsupported)
|
|
auth_path = tmp_path / "profiles" / "coder" / "auth.json"
|
|
|
|
with pytest.raises(OSError) as excinfo:
|
|
with auth._auth_store_lock(timeout_seconds=0.01, target_path=auth_path):
|
|
pass
|
|
|
|
assert excinfo.value.errno == errno.ENOSYS # the original error, not a lock-holder blame
|
|
|
|
|
|
def test_lock_timeout_message_still_matches_the_documented_prefix():
|
|
# Downstream diagnosis (tui_gateway.user_messages) matches on this prefix; keep it stable.
|
|
import hermes_cli.auth as auth
|
|
import inspect
|
|
|
|
source = inspect.getsource(auth._auth_store_lock)
|
|
assert "Timed out waiting for auth store lock" in source
|