hermes-agent/tests/hermes_cli/test_env_load_cache.py
Teknium 3f13d78088
perf(tools): cache get_nous_auth_status() and load_env() to fix slow hermes tools menus (#25341)
`hermes tools` -> "All Platforms" took ~14s to render the checklist
because building the toolset labels called `get_nous_auth_status()` ~31x
transitively (`_toolset_has_keys` -> `_visible_providers` ->
`get_nous_subscription_features` -> `managed_nous_tools_enabled`).
Each call did a synchronous OAuth refresh POST to
portal.nousresearch.com (~350ms even on the failure path), so one menu
paint burned >13s of HTTP and 31 single-use Nous refresh tokens.

Secondary hot spot: every `get_env_value()` re-read and re-sanitised
the entire .env file. 116 reads with O(lines x known-keys) scanning
added ~300ms of CPU per render.

Fix is two process-level caches, both mtime-keyed so login/logout/edit
invalidate naturally:

* `hermes_cli/auth.py`: memoise `get_nous_auth_status()` for 15s keyed
  on auth.json mtime. Splits `_compute_nous_auth_status()` as the
  uncached impl. Adds `invalidate_nous_auth_status_cache()`.
* `hermes_cli/config.py`: memoise `load_env()` keyed on .env
  (path, mtime, size). Adds `invalidate_env_cache()`, wired into
  `save_env_value`, `remove_env_value`, and the sanitize-on-load
  writer so writers don't return stale dicts on same-second writes.

Before/after on Teknium's box (real HERMES_HOME, no Nous login):

* "All Platforms" cold path: ~13,874ms -> ~691ms label-build
* Warm re-open within the same process: ~122ms -> ~17ms

Side benefit: stops burning a Nous refresh token on every menu paint,
which was risking the portal's reuse-detection revocation logic.
2026-05-13 18:40:14 -07:00

193 lines
6.6 KiB
Python

"""Tests for the load_env() process-level cache.
The cache exists to keep `hermes tools` → "All Platforms" fast: every
`get_env_value()` lookup used to re-read and re-sanitise the entire
.env file, racking up hundreds of ms across one menu render. The
cache is keyed on (path, mtime, size); writers (save_env_value /
remove_env_value / sanitise_env_file) call invalidate_env_cache().
"""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from unittest.mock import patch
def _write_env(path: Path, contents: str) -> None:
path.write_text(contents, encoding="utf-8")
def test_load_env_caches_on_repeat_calls():
"""Repeated load_env() calls on the same file return the cached dict."""
from hermes_cli.config import invalidate_env_cache, load_env
invalidate_env_cache()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".env", delete=False, encoding="utf-8"
) as f:
f.write("OPENAI_API_KEY=sk-first\n")
env_path = Path(f.name)
try:
with patch("hermes_cli.config.get_env_path", return_value=env_path):
first = load_env()
# Even if a writer outside our cache mutates the file, an
# mtime/size match means the cache still wins. We simulate that
# by writing identical bytes back — sanity check that the cache
# is keyed structurally, not on a counter.
second = load_env()
assert first == second
assert first.get("OPENAI_API_KEY") == "sk-first"
finally:
env_path.unlink(missing_ok=True)
invalidate_env_cache()
def test_load_env_invalidates_on_mtime_bump():
"""Editing the file (mtime changes) invalidates the cache."""
from hermes_cli.config import invalidate_env_cache, load_env
invalidate_env_cache()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".env", delete=False, encoding="utf-8"
) as f:
f.write("OPENAI_API_KEY=sk-old\n")
env_path = Path(f.name)
try:
with patch("hermes_cli.config.get_env_path", return_value=env_path):
first = load_env()
assert first.get("OPENAI_API_KEY") == "sk-old"
# Rewrite file with new contents and bump mtime to make sure
# the FS records the change even on coarse-mtime filesystems.
_write_env(env_path, "OPENAI_API_KEY=sk-new\n")
future = env_path.stat().st_mtime + 5.0
os.utime(env_path, (future, future))
second = load_env()
assert second.get("OPENAI_API_KEY") == "sk-new", (
"load_env() returned stale value after file change"
)
finally:
env_path.unlink(missing_ok=True)
invalidate_env_cache()
def test_invalidate_env_cache_forces_reread():
"""invalidate_env_cache() forces the next load_env() to hit the disk.
This is the belt-and-braces knob for writers (save_env_value, etc.)
on filesystems where mtime resolution might miss a same-second write.
"""
from hermes_cli.config import invalidate_env_cache, load_env
invalidate_env_cache()
with tempfile.NamedTemporaryFile(
mode="w", suffix=".env", delete=False, encoding="utf-8"
) as f:
f.write("OPENAI_API_KEY=sk-old\n")
env_path = Path(f.name)
try:
with patch("hermes_cli.config.get_env_path", return_value=env_path):
assert load_env().get("OPENAI_API_KEY") == "sk-old"
# Rewrite WITHOUT bumping mtime — simulates same-second write.
mtime_before = env_path.stat().st_mtime
_write_env(env_path, "OPENAI_API_KEY=sk-new\n")
os.utime(env_path, (mtime_before, mtime_before))
# Without invalidation, cache hit might return stale.
invalidate_env_cache()
assert load_env().get("OPENAI_API_KEY") == "sk-new"
finally:
env_path.unlink(missing_ok=True)
invalidate_env_cache()
def test_save_env_value_invalidates_cache(tmp_path, monkeypatch):
"""save_env_value() invalidates the cache so subsequent reads see the update."""
from hermes_cli import config as config_mod
from hermes_cli.config import invalidate_env_cache, load_env, save_env_value
invalidate_env_cache()
env_path = tmp_path / ".env"
env_path.write_text("EXISTING_KEY=old\n", encoding="utf-8")
monkeypatch.setattr(config_mod, "get_env_path", lambda: env_path)
monkeypatch.setattr(config_mod, "ensure_hermes_home", lambda: None)
monkeypatch.setattr(config_mod, "_secure_file", lambda _p: None)
monkeypatch.setattr(config_mod, "is_managed", lambda: False)
try:
# Prime the cache.
first = load_env()
assert first.get("EXISTING_KEY") == "old"
save_env_value("NEW_KEY", "shiny")
# Same-second writes on coarse-mtime filesystems would normally
# let stale cache survive; invalidate_env_cache() inside the
# writer makes the next read see the new key.
result = load_env()
assert result.get("NEW_KEY") == "shiny"
assert result.get("EXISTING_KEY") == "old"
finally:
monkeypatch.delenv("NEW_KEY", raising=False)
invalidate_env_cache()
def test_remove_env_value_invalidates_cache(tmp_path, monkeypatch):
"""remove_env_value() invalidates the cache so the removed key disappears."""
from hermes_cli import config as config_mod
from hermes_cli.config import (
invalidate_env_cache,
load_env,
remove_env_value,
save_env_value,
)
invalidate_env_cache()
env_path = tmp_path / ".env"
monkeypatch.setattr(config_mod, "get_env_path", lambda: env_path)
monkeypatch.setattr(config_mod, "ensure_hermes_home", lambda: None)
monkeypatch.setattr(config_mod, "_secure_file", lambda _p: None)
monkeypatch.setattr(config_mod, "is_managed", lambda: False)
save_env_value("DOOMED_KEY", "value")
assert load_env().get("DOOMED_KEY") == "value"
try:
removed = remove_env_value("DOOMED_KEY")
assert removed is True
assert "DOOMED_KEY" not in load_env()
finally:
monkeypatch.delenv("DOOMED_KEY", raising=False)
invalidate_env_cache()
def test_load_env_handles_missing_file():
"""A nonexistent .env returns {} and caches the empty result."""
from hermes_cli.config import invalidate_env_cache, load_env
invalidate_env_cache()
nonexistent = Path(tempfile.gettempdir()) / "hermes-test-no-such-env-xyz123.env"
nonexistent.unlink(missing_ok=True)
try:
with patch("hermes_cli.config.get_env_path", return_value=nonexistent):
assert load_env() == {}
assert load_env() == {} # cached
finally:
invalidate_env_cache()