hermes-agent/tests/test_honcho_client_concurrency.py
Teknium 47d5177a7d
fix(plugins): thread-safe lazy-singleton helpers; fix honcho TOCTOU (#24759) (#42150)
* fix(plugins): add thread-safe lazy-singleton helpers, fix honcho TOCTOU (#24759)

get_honcho_client() and fal's _load_fal_client() used unlocked
check-then-init: racing threads both ran the expensive build and the
loser's client (open connection) leaked.

Rather than one-off locks, add plugins/plugin_utils.py with two
reusable primitives every plugin author can drop in:
- lazy_singleton: decorator for zero-arg accessors
- SingletonSlot: manual slot for config-keyed accessors (first wins)

Both use double-checked locking; factory runs at most once; failed
builds aren't cached. honcho is the reference consumer; fal's sibling
TOCTOU gets a matching double-checked lock. Plugin dev guide documents
the pattern so future plugins don't reintroduce the race.

Closes #24759

* test(honcho): update reset test for SingletonSlot internals

test_reset_clears_singleton poked the removed _honcho_client module
global directly. Assert through the slot's public peek() surface
instead, matching the #24759 refactor.
2026-06-08 09:35:22 -07:00

109 lines
3 KiB
Python

"""Concurrency test for get_honcho_client() — the TOCTOU race fix (#24759).
Proves the Honcho client is constructed exactly once even when many threads
race the first call, by stubbing the SDK constructor and counting invocations.
"""
import sys
import threading
import types
import pytest
from plugins.memory.honcho import client as honcho_client
from plugins.memory.honcho.client import (
HonchoClientConfig,
get_honcho_client,
reset_honcho_client,
)
@pytest.fixture(autouse=True)
def _reset_singleton():
reset_honcho_client()
yield
reset_honcho_client()
def _install_fake_honcho_sdk(monkeypatch, build_count, build_lock):
"""Make `from honcho import Honcho` resolve to a counting fake."""
class _FakeHoncho:
def __init__(self, **kwargs):
with build_lock:
build_count["n"] += 1
import time
time.sleep(0.01) # widen the race window
self.kwargs = kwargs
fake_mod = types.ModuleType("honcho")
fake_mod.Honcho = _FakeHoncho
monkeypatch.setitem(sys.modules, "honcho", fake_mod)
# Skip the lazy-install path entirely.
monkeypatch.setattr(
honcho_client, "_resolve_optional_float", lambda *a, **k: None, raising=False
)
def test_get_honcho_client_builds_once_under_concurrent_first_call(monkeypatch):
build_count = {"n": 0}
build_lock = threading.Lock()
_install_fake_honcho_sdk(monkeypatch, build_count, build_lock)
config = HonchoClientConfig(
api_key="test-key",
workspace_id="ws",
environment="production",
)
barrier = threading.Barrier(20)
results = []
results_lock = threading.Lock()
def worker():
barrier.wait()
c = get_honcho_client(config)
with results_lock:
results.append(c)
threads = [threading.Thread(target=worker) for _ in range(20)]
for t in threads:
t.start()
for t in threads:
t.join()
assert build_count["n"] == 1, "Honcho client must be constructed exactly once"
assert len(results) == 20
assert all(r is results[0] for r in results), "all threads share one client"
def test_reset_allows_rebuild(monkeypatch):
build_count = {"n": 0}
build_lock = threading.Lock()
_install_fake_honcho_sdk(monkeypatch, build_count, build_lock)
config = HonchoClientConfig(
api_key="test-key", workspace_id="ws", environment="production"
)
c1 = get_honcho_client(config)
assert build_count["n"] == 1
# Cached: no rebuild.
assert get_honcho_client(config) is c1
assert build_count["n"] == 1
reset_honcho_client()
c2 = get_honcho_client(config)
assert build_count["n"] == 2
assert c2 is not c1
def test_missing_credentials_still_raises_before_build(monkeypatch):
build_count = {"n": 0}
build_lock = threading.Lock()
_install_fake_honcho_sdk(monkeypatch, build_count, build_lock)
bad = HonchoClientConfig(api_key="", base_url="", workspace_id="ws")
with pytest.raises(ValueError):
get_honcho_client(bad)
assert build_count["n"] == 0