hermes-agent/tests/test_plugin_utils.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

159 lines
3.9 KiB
Python

"""Tests for plugins/plugin_utils.py — thread-safe lazy singleton helpers.
These exercise the actual concurrency guarantee with real threads (not mocks):
a barrier releases N threads simultaneously into the accessor, and we assert
the factory ran exactly once.
"""
import threading
import pytest
from plugins.plugin_utils import SingletonSlot, lazy_singleton
# --- lazy_singleton -------------------------------------------------------
def test_lazy_singleton_builds_once_and_returns_same_instance():
calls = []
@lazy_singleton
def get():
calls.append(1)
return object()
a = get()
b = get()
assert a is b
assert len(calls) == 1
def test_lazy_singleton_reset_rebuilds():
counter = {"n": 0}
@lazy_singleton
def get():
counter["n"] += 1
return counter["n"]
assert get() == 1
assert get() == 1
get.reset()
assert get() == 2
def test_lazy_singleton_factory_exception_not_cached():
state = {"fail": True}
@lazy_singleton
def get():
if state["fail"]:
raise RuntimeError("boom")
return "ok"
with pytest.raises(RuntimeError):
get()
# First call raised → nothing cached → retry succeeds once we stop failing.
state["fail"] = False
assert get() == "ok"
def test_lazy_singleton_concurrent_first_call_builds_once():
build_count = {"n": 0}
build_lock = threading.Lock()
barrier = threading.Barrier(16)
results = []
results_lock = threading.Lock()
@lazy_singleton
def get():
# Count builds under a lock so the assertion is exact even if the
# double-checked lock had a bug and let two through.
with build_lock:
build_count["n"] += 1
# Simulate an expensive build so threads genuinely overlap.
import time
time.sleep(0.01)
return object()
def worker():
barrier.wait() # release all threads at once
obj = get()
with results_lock:
results.append(obj)
threads = [threading.Thread(target=worker) for _ in range(16)]
for t in threads:
t.start()
for t in threads:
t.join()
assert build_count["n"] == 1, "factory must run exactly once under race"
assert len(results) == 16
assert all(r is results[0] for r in results), "all callers share one instance"
# --- SingletonSlot --------------------------------------------------------
def test_slot_caches_first_value():
slot: SingletonSlot = SingletonSlot()
assert slot.peek() is None
v1 = slot.get(lambda: "first")
assert slot.peek() == "first"
# Subsequent factory is ignored — first value wins.
v2 = slot.get(lambda: "second")
assert v1 == v2 == "first"
def test_slot_reset():
slot: SingletonSlot = SingletonSlot()
slot.get(lambda: "a")
slot.reset()
assert slot.peek() is None
assert slot.get(lambda: "b") == "b"
def test_slot_factory_exception_not_cached():
slot: SingletonSlot = SingletonSlot()
def boom():
raise ValueError("nope")
with pytest.raises(ValueError):
slot.get(boom)
assert slot.peek() is None
assert slot.get(lambda: "recovered") == "recovered"
def test_slot_concurrent_first_call_builds_once():
build_count = {"n": 0}
build_lock = threading.Lock()
barrier = threading.Barrier(16)
slot: SingletonSlot = SingletonSlot()
results = []
results_lock = threading.Lock()
def factory():
with build_lock:
build_count["n"] += 1
import time
time.sleep(0.01)
return object()
def worker():
barrier.wait()
obj = slot.get(factory)
with results_lock:
results.append(obj)
threads = [threading.Thread(target=worker) for _ in range(16)]
for t in threads:
t.start()
for t in threads:
t.join()
assert build_count["n"] == 1
assert len(results) == 16
assert all(r is results[0] for r in results)