98 lines
3.3 KiB
Python
98 lines
3.3 KiB
Python
"""A cached config read must not block behind a config WRITER.
|
|
|
|
``_load_config_impl`` served cache hits from inside ``_CONFIG_LOCK``, which
|
|
``save_config()`` holds across an atomic YAML write. A cache hit itself costs
|
|
~0.024ms-scale work, but measured on a clean tree the same cached read took
|
|
**10010ms** while another thread held the lock.
|
|
|
|
On a gateway that lands on the event loop: the per-message hook path
|
|
(``invoke_hook`` -> ``_resolve_hook_callback_timeout``) reads config, so one
|
|
background config write stalls every inbound message for its whole duration.
|
|
|
|
These tests drive the real functions against a temp HERMES_HOME -- no mocks of
|
|
the thing under test, no source reading.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
|
|
# Generous: the point is 10s-vs-instant, not a tight timing assertion.
|
|
MAX_BLOCKED_READ_SECS = 2.0
|
|
|
|
|
|
@pytest.fixture()
|
|
def config_home(tmp_path, monkeypatch):
|
|
home = tmp_path / "hermes-home"
|
|
home.mkdir()
|
|
lines = ["plugins:", " hook_callback_timeout: 30", "agent:", " max_turns: 500"]
|
|
for i in range(100):
|
|
lines += [f"section_{i}:", f" key_a: value_{i}"]
|
|
(home / "config.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
|
monkeypatch.setenv("HERMES_HOME", str(home))
|
|
|
|
from hermes_cli import config as cfgmod
|
|
|
|
cfgmod._LOAD_CONFIG_CACHE.clear()
|
|
cfgmod._RAW_CONFIG_CACHE.clear()
|
|
yield home
|
|
cfgmod._LOAD_CONFIG_CACHE.clear()
|
|
cfgmod._RAW_CONFIG_CACHE.clear()
|
|
|
|
|
|
def _time_cached_read_under_held_lock(reader):
|
|
"""Prime ``reader`` (lock-free), then time one more call while another thread holds
|
|
``_CONFIG_LOCK``. Returns ``(result, elapsed_seconds)``."""
|
|
from hermes_cli import config as cfgmod
|
|
|
|
reader() # prime the cache
|
|
|
|
holding = threading.Event()
|
|
release = threading.Event()
|
|
|
|
def _hold():
|
|
with cfgmod._CONFIG_LOCK:
|
|
holding.set()
|
|
release.wait(timeout=30)
|
|
|
|
thread = threading.Thread(target=_hold, daemon=True)
|
|
thread.start()
|
|
try:
|
|
assert holding.wait(timeout=10), "lock holder never started"
|
|
started = time.perf_counter()
|
|
result = reader()
|
|
elapsed = time.perf_counter() - started
|
|
finally:
|
|
release.set()
|
|
thread.join(timeout=10)
|
|
return result, elapsed
|
|
|
|
|
|
def test_cached_read_completes_while_another_thread_holds_the_config_lock(config_home):
|
|
from hermes_cli import config as cfgmod
|
|
|
|
cfg, elapsed = _time_cached_read_under_held_lock(cfgmod.load_config_readonly)
|
|
|
|
assert isinstance(cfg, dict) and cfg
|
|
assert elapsed < MAX_BLOCKED_READ_SECS, (
|
|
f"a cached load_config_readonly() blocked {elapsed:.1f}s behind a held "
|
|
"_CONFIG_LOCK; cache hits must not serialize against config writers"
|
|
)
|
|
|
|
|
|
def test_cached_raw_read_completes_while_another_thread_holds_the_config_lock(config_home):
|
|
"""Twin of the load_config test for the sibling ``read_raw_config`` fast path."""
|
|
from hermes_cli import config as cfgmod
|
|
|
|
raw, elapsed = _time_cached_read_under_held_lock(cfgmod.read_raw_config_readonly)
|
|
|
|
assert isinstance(raw, dict) and raw.get("plugins") == {"hook_callback_timeout": 30}
|
|
assert elapsed < MAX_BLOCKED_READ_SECS, (
|
|
f"a cached read_raw_config_readonly() blocked {elapsed:.1f}s behind a held "
|
|
"_CONFIG_LOCK; cache hits must not serialize against config writers"
|
|
)
|