159 lines
5.7 KiB
Python
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)"
|
|
)
|