Files
hermes-webui/tests/test_issue5220_config_reload_stampede.py
T

159 lines
5.7 KiB
Python

"""Regression coverage for #5220 config reload stampede collapse."""
import sys
import threading
import time
import types
from concurrent.futures import ThreadPoolExecutor
def test_stale_readers_collapse_to_single_reload_when_config_is_hot_reload(tmp_path, monkeypatch):
import api.config as config
config_path = tmp_path / "config.yaml"
config_path.write_text("marker: stale\n", encoding="utf-8")
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
with config._yaml_file_cache_lock:
config._yaml_file_cache.clear()
config.reload_config()
old_mtime = config._cfg_mtime
config_path.write_text("marker: refreshed\n", encoding="utf-8")
# Keep cache state stale for every thread entering the read path.
config._cfg_mtime = old_mtime - 1.0
calls = {"n": 0}
calls_lock = threading.Lock()
real_refresh = config._refresh_config_cache
def _counted_refresh(path=None):
with calls_lock:
calls["n"] += 1
call_no = calls["n"]
if call_no == 1:
time.sleep(0.1)
return real_refresh(path)
def _unexpected_reload():
raise AssertionError(
"stale model readers should route through reload_config_if_stale() instead of forced reload_config()"
)
monkeypatch.setattr(config, "_refresh_config_cache", _counted_refresh)
monkeypatch.setattr(config, "reload_config", _unexpected_reload)
def _get():
cfg = config.get_config()
return cfg.get("marker")
with ThreadPoolExecutor(max_workers=6) as executor:
values = [value for value in executor.map(lambda _idx: _get(), range(6))]
assert all(value == "refreshed" for value in values), (
f"stale readers should observe refreshed config, got {values!r}"
)
assert calls["n"] == 1, (
f"expected one refresh for concurrent stale readers, got {calls['n']} "
"(base would re-enter refresh work per stale reader)"
)
def test_explicit_reload_config_forces_disk_refresh(tmp_path, monkeypatch):
import api.config as config
config_path = tmp_path / "config.yaml"
config_path.write_text("marker: cold\n", encoding="utf-8")
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
with config._yaml_file_cache_lock:
config._yaml_file_cache.clear()
config.reload_config()
assert config.get_config().get("marker") == "cold"
calls = {"n": 0}
calls_lock = threading.Lock()
real_load = config._load_yaml_config_file_raw
def _counted_load(path):
with calls_lock:
calls["n"] += 1
return real_load(path)
monkeypatch.setattr(config, "_load_yaml_config_file_raw", _counted_load)
config_path.write_text("marker: hot\n", encoding="utf-8")
config.reload_config()
assert config.get_config().get("marker") == "hot"
assert calls["n"] == 1, (
"explicit reload_config() should refresh from disk even when caller requests it"
)
def test_stale_model_readers_collapse_to_single_reload_when_models_are_requested(tmp_path, monkeypatch):
import api.config as config
config_path = tmp_path / "config.yaml"
config_path.write_text(
"model:\n provider: openai\n default: cold-model\n",
encoding="utf-8",
)
monkeypatch.setattr(config, "_get_config_path", lambda: config_path)
with config._yaml_file_cache_lock:
config._yaml_file_cache.clear()
config.invalidate_models_cache()
fake_pkg = types.ModuleType("hermes_cli")
fake_pkg.__path__ = []
fake_models = types.ModuleType("hermes_cli.models")
fake_models._PROVIDER_ALIASES = {}
fake_models.list_available_providers = lambda: []
fake_auth = types.ModuleType("hermes_cli.auth")
fake_auth.get_auth_status = lambda provider_id: {"logged_in": False, "key_source": ""}
monkeypatch.setitem(sys.modules, "hermes_cli", fake_pkg)
monkeypatch.setitem(sys.modules, "hermes_cli.models", fake_models)
monkeypatch.setitem(sys.modules, "hermes_cli.auth", fake_auth)
monkeypatch.setattr(config, "_load_models_cache_from_disk", lambda: None)
monkeypatch.setattr(config, "_load_stale_models_cache_from_disk", lambda: None)
monkeypatch.setattr(config, "_save_models_cache_to_disk", lambda _data: None)
monkeypatch.setattr(config, "_models_cache_source_fingerprint", lambda: {"config": "test"})
config.reload_config()
old_mtime = config._cfg_mtime
config_path.write_text(
"model:\n provider: openai\n default: refreshed-model\n",
encoding="utf-8",
)
config._cfg_mtime = old_mtime - 1.0
config.invalidate_models_cache()
calls = {"n": 0}
calls_lock = threading.Lock()
real_refresh = config._refresh_config_cache
def _counted_refresh(path=None):
with calls_lock:
calls["n"] += 1
call_no = calls["n"]
if call_no == 1:
time.sleep(0.1)
return real_refresh(path)
def _unexpected_reload():
raise AssertionError(
"stale model readers should route through reload_config_if_stale() instead of forced reload_config()"
)
monkeypatch.setattr(config, "_refresh_config_cache", _counted_refresh)
monkeypatch.setattr(config, "reload_config", _unexpected_reload)
with ThreadPoolExecutor(max_workers=6) as executor:
results = list(executor.map(lambda _idx: config.get_available_models(), range(6)))
assert all(result.get("default_model") == "refreshed-model" for result in results), (
f"stale model readers should observe refreshed config, got {results!r}"
)
assert calls["n"] == 1, (
f"expected one config refresh for concurrent model reads, got {calls['n']} "
"(base would re-enter refresh work per stale reader)"
)