Files
hermes-webui/tests/test_issue4650_yaml_config_cache.py
T
nesquena-hermesandnesquena-hermes 1f4e70a5d4 stage #4652 (nesquena-hermes): stop /api/reasoning storm + memoize YAML config parse (fixes #4650) (#4659)
Two-layer perf fix for the /api/reasoning storm dropping streaming to ~2 tok/s:
- ui.js: syncReasoningChip() serves the cached chip on routine topbar syncs, only
  re-fetching when the model/provider key (_lastReasoningFetchKey) changes — restores
  pre-a9ce2889 behavior while keeping its model-switch-refresh intent.
- config.py: memoize _load_yaml_config_file keyed on (path, st_mtime_ns, st_size) under
  a lock; cache the RAW parsed dict and re-run cheap _expand_env_vars per call (cache stays
  correct vs os.environ and read-modify-save callers get a fresh structure; on-disk edits
  picked up via mtime/size). Code byte-faithful from PR head f868f10a1ad7. 9 own tests pass.

Co-authored-by: nesquena-hermes <agent@nesquena-hermes>
2026-06-21 21:45:13 -07:00

136 lines
4.9 KiB
Python

"""Backend half of the #4650 fix: _load_yaml_config_file memoizes the parsed
config keyed on (path, mtime_ns, size).
GET /api/reasoning -> get_reasoning_status() calls _load_yaml_config_file on
every request, and yaml.safe_load on an ~800-line config costs ~125ms. Under
the (pre-fix) request storm that became a YAML-reparse storm. This test pins
the cache contract:
* a second read of an unchanged file does NOT re-run yaml.safe_load,
* an on-disk edit (new mtime/size) DOES trigger a fresh parse,
* env-var expansion still runs per call (so ${VAR} stays live and callers
that mutate the returned dict never corrupt the cached copy).
"""
import importlib
import time
import pytest
config = importlib.import_module("api.config")
@pytest.fixture
def clean_cache():
config._yaml_file_cache.clear()
yield
config._yaml_file_cache.clear()
def _write(p, text):
p.write_text(text, encoding="utf-8")
def test_second_read_uses_cache_no_reparse(tmp_path, monkeypatch, clean_cache):
cfg = tmp_path / "config.yaml"
_write(cfg, "agent:\n reasoning_effort: high\n")
calls = {"n": 0}
import yaml as _yaml
real_load = _yaml.safe_load
def counting_load(text):
calls["n"] += 1
return real_load(text)
monkeypatch.setattr(_yaml, "safe_load", counting_load)
first = config._load_yaml_config_file(cfg)
second = config._load_yaml_config_file(cfg)
assert first == {"agent": {"reasoning_effort": "high"}}
assert second == first
assert calls["n"] == 1, "second read of an unchanged file must hit the cache, not reparse"
def test_mtime_change_invalidates_cache(tmp_path, monkeypatch, clean_cache):
cfg = tmp_path / "config.yaml"
_write(cfg, "agent:\n reasoning_effort: high\n")
calls = {"n": 0}
import yaml as _yaml
real_load = _yaml.safe_load
def counting_load(text):
calls["n"] += 1
return real_load(text)
monkeypatch.setattr(_yaml, "safe_load", counting_load)
assert config._load_yaml_config_file(cfg)["agent"]["reasoning_effort"] == "high"
# Rewrite with different content + bump mtime so (mtime_ns, size) changes.
time.sleep(0.01)
_write(cfg, "agent:\n reasoning_effort: low\n")
import os
st = cfg.stat()
os.utime(cfg, ns=(st.st_atime_ns, st.st_mtime_ns + 1_000_000))
reloaded = config._load_yaml_config_file(cfg)
assert reloaded["agent"]["reasoning_effort"] == "low", "an on-disk edit must be picked up"
assert calls["n"] == 2, "changed file must trigger exactly one fresh parse"
def test_env_expansion_runs_per_call_even_from_cache(tmp_path, monkeypatch, clean_cache):
cfg = tmp_path / "config.yaml"
_write(cfg, "agent:\n api_key: ${MY_TEST_KEY}\n")
monkeypatch.setenv("MY_TEST_KEY", "first")
first = config._load_yaml_config_file(cfg)
assert first["agent"]["api_key"] == "first"
# Cache hit (file unchanged) but env changed -> expansion must reflect it.
monkeypatch.setenv("MY_TEST_KEY", "second")
second = config._load_yaml_config_file(cfg)
assert second["agent"]["api_key"] == "second", "env expansion must run per call, not be frozen in the cache"
def test_caller_mutation_does_not_corrupt_cache(tmp_path, clean_cache):
cfg = tmp_path / "config.yaml"
_write(cfg, "display:\n show_reasoning: true\n")
first = config._load_yaml_config_file(cfg)
# Mirror the read-modify-save callers (set_reasoning_display etc.).
first["display"]["show_reasoning"] = False
first["injected"] = "mutation"
second = config._load_yaml_config_file(cfg)
assert second["display"]["show_reasoning"] is True, "caller mutation must not leak into the cache"
assert "injected" not in second
def test_missing_file_returns_empty(tmp_path, clean_cache):
assert config._load_yaml_config_file(tmp_path / "nope.yaml") == {}
def test_save_evicts_cache_so_next_read_is_fresh(tmp_path, clean_cache):
"""#4650 review: a WebUI save that preserves (mtime_ns, size) could otherwise
serve a stale cached parse. _save_yaml_config_file must evict the cache entry
for the path it writes, so the next read reflects the saved content."""
cfg = tmp_path / "config.yaml"
_write(cfg, "agent:\n reasoning_effort: high\n")
first = config._load_yaml_config_file(cfg)
assert first["agent"]["reasoning_effort"] == "high"
assert str(cfg) in config._yaml_file_cache, "first read should populate the cache"
# Save new content through the WebUI writer.
config._save_yaml_config_file(cfg, {"agent": {"reasoning_effort": "low"}})
assert str(cfg) not in config._yaml_file_cache, (
"_save_yaml_config_file must evict the memoized parse for the written path"
)
# Next read must reflect the saved value even if mtime/size happened to collide.
fresh = config._load_yaml_config_file(cfg)
assert fresh["agent"]["reasoning_effort"] == "low", (
"read after save must return the freshly-written config, not a stale cache hit"
)