Files
hermes-webui/tests/test_issue3787_probe_pool.py
T

423 lines
15 KiB
Python

"""Tests for Issue #3787: Probe-worker pool tail-latency.
Focus on:
- Per-home worker pool size N=2
- Non-blocking acquire returns an idle worker when available
- Returns None immediately when all workers are locked (no blocking wait)
- Scoped invalidation only flushes the active home's workers
- Full invalidation (no provider_id) flushes all workers
- Cleanup iterates nested worker lists correctly
- Lock held across selector handoff prevents TOCTOU races
"""
import threading
import time
import unittest
from pathlib import Path
from unittest import mock
from api.providers import (
_ACCOUNT_USAGE_WORKERS_PER_HOME,
_account_usage_worker_pool,
_account_usage_worker_pool_lock,
_cleanup_account_usage_probe_workers,
_close_account_usage_probe_workers,
_get_account_usage_probe_worker,
invalidate_account_usage_status_cache,
)
class TestProbeWorkerPoolPerHome(unittest.TestCase):
"""Test per-home worker pool with N=2 configuration."""
def setUp(self):
"""Clear the pool before each test."""
with _account_usage_worker_pool_lock:
_account_usage_worker_pool.clear()
def tearDown(self):
"""Clean up after each test."""
with _account_usage_worker_pool_lock:
_account_usage_worker_pool.clear()
def test_pool_creates_two_workers_per_home_key(self):
"""Pool should create exactly N=2 workers for each home key."""
home = Path.home() / ".hermes"
worker = _get_account_usage_probe_worker(home)
self.assertIsNotNone(worker)
worker._lock.release()
with _account_usage_worker_pool_lock:
key = str(Path(home))
workers_list = _account_usage_worker_pool.get(key)
self.assertIsNotNone(workers_list)
self.assertEqual(len(workers_list), _ACCOUNT_USAGE_WORKERS_PER_HOME)
self.assertEqual(_ACCOUNT_USAGE_WORKERS_PER_HOME, 2)
def test_nonblocking_acquire_returns_idle_worker(self):
"""Non-blocking acquire should return an idle worker when one is free."""
home = Path("/tmp/test_nonblocking_idle")
# Populate the pool
worker_initial = _get_account_usage_probe_worker(home)
self.assertIsNotNone(worker_initial)
worker_initial._lock.release()
with _account_usage_worker_pool_lock:
key = str(Path(home))
workers = _account_usage_worker_pool[key]
self.assertEqual(len(workers), 2)
# Hold workers[0] locked from a background thread to simulate actual use
lock_holder = threading.Event()
release_signal = threading.Event()
def hold_lock():
workers[0]._lock.acquire()
lock_holder.set()
release_signal.wait(timeout=2.0)
workers[0]._lock.release()
thread = threading.Thread(target=hold_lock, daemon=True)
thread.start()
lock_holder.wait(timeout=2.0)
try:
worker = _get_account_usage_probe_worker(home)
self.assertIsNotNone(worker)
self.assertIs(worker, workers[1])
worker._lock.release()
finally:
release_signal.set()
thread.join(timeout=1.0)
def test_returns_none_when_all_workers_locked(self):
"""Should return None immediately when all workers are locked from other threads."""
home = Path("/tmp/test_immediate_none")
# Populate the pool
worker_initial = _get_account_usage_probe_worker(home)
self.assertIsNotNone(worker_initial)
worker_initial._lock.release()
with _account_usage_worker_pool_lock:
key = str(Path(home))
workers = _account_usage_worker_pool[key]
# Hold both workers locked from a background thread
lock_holder = threading.Event()
release_signal = threading.Event()
def hold_locks():
workers[0]._lock.acquire()
workers[1]._lock.acquire()
lock_holder.set()
release_signal.wait(timeout=2.0)
workers[1]._lock.release()
workers[0]._lock.release()
thread = threading.Thread(target=hold_locks, daemon=True)
thread.start()
lock_holder.wait(timeout=2.0)
try:
start = time.time()
result = _get_account_usage_probe_worker(home)
elapsed = time.time() - start
self.assertIsNone(result)
# Should return immediately, not block
self.assertLess(elapsed, 0.1)
finally:
release_signal.set()
thread.join(timeout=1.0)
def test_scoped_invalidation_only_flushes_active_home(self):
"""Scoped invalidation with provider_id should only flush active home."""
home1 = Path.home() / ".hermes"
home2 = Path("/tmp/other_home")
worker1 = _get_account_usage_probe_worker(home1)
worker2 = _get_account_usage_probe_worker(home2)
self.assertIsNotNone(worker1)
self.assertIsNotNone(worker2)
worker1._lock.release()
worker2._lock.release()
with _account_usage_worker_pool_lock:
initial_count = len(_account_usage_worker_pool)
self.assertEqual(initial_count, 2)
with mock.patch("api.providers._get_hermes_home", return_value=home1):
invalidate_account_usage_status_cache(provider_id="anthropic")
time.sleep(0.2)
with _account_usage_worker_pool_lock:
remaining_keys = list(_account_usage_worker_pool.keys())
self.assertNotIn(str(Path(home1)), remaining_keys)
self.assertIn(str(Path(home2)), remaining_keys)
def test_full_invalidation_flushes_all_workers(self):
"""Full invalidation (no provider_id) should flush all workers."""
home1 = Path.home() / ".hermes"
home2 = Path("/tmp/other_home")
worker1 = _get_account_usage_probe_worker(home1)
worker2 = _get_account_usage_probe_worker(home2)
self.assertIsNotNone(worker1)
self.assertIsNotNone(worker2)
worker1._lock.release()
worker2._lock.release()
with _account_usage_worker_pool_lock:
self.assertEqual(len(_account_usage_worker_pool), 2)
invalidate_account_usage_status_cache(provider_id=None)
time.sleep(0.2)
with _account_usage_worker_pool_lock:
self.assertEqual(len(_account_usage_worker_pool), 0)
def test_cleanup_iterates_nested_worker_lists(self):
"""Cleanup should properly iterate over nested worker lists."""
home = Path.home() / ".hermes"
worker = _get_account_usage_probe_worker(home)
self.assertIsNotNone(worker)
worker._lock.release()
with _account_usage_worker_pool_lock:
key = str(Path(home))
workers_list = _account_usage_worker_pool.get(key)
self.assertEqual(len(workers_list), 2)
now = time.monotonic() + 100000
_cleanup_account_usage_probe_workers(now=now, idle_seconds=1.0)
with _account_usage_worker_pool_lock:
if _account_usage_worker_pool.get(str(Path(home))):
remaining = _account_usage_worker_pool.get(str(Path(home)), [])
self.assertEqual(len(remaining), 0)
else:
self.assertNotIn(str(Path(home)), _account_usage_worker_pool)
def test_partial_cleanup_replenishes_pool(self):
"""When cleanup removes one stale worker but the other is busy, pool replenishes to N=2."""
home = Path("/tmp/test_replenish")
worker = _get_account_usage_probe_worker(home)
self.assertIsNotNone(worker)
worker._lock.release()
with _account_usage_worker_pool_lock:
key = str(Path(home))
workers = _account_usage_worker_pool[key]
self.assertEqual(len(workers), 2)
# Hold workers[1] locked from a background thread (simulating active use)
lock_holder = threading.Event()
release_signal = threading.Event()
def hold_lock():
workers[1]._lock.acquire()
lock_holder.set()
release_signal.wait(timeout=5.0)
workers[1]._lock.release()
thread = threading.Thread(target=hold_lock, daemon=True)
thread.start()
lock_holder.wait(timeout=2.0)
try:
# Run cleanup far in the future; workers[0] is idle and stale,
# workers[1] is locked so it survives
now = time.monotonic() + 100000
_cleanup_account_usage_probe_workers(now=now, idle_seconds=1.0)
with _account_usage_worker_pool_lock:
remaining = _account_usage_worker_pool.get(key, [])
# Pool should be replenished back to 2
self.assertEqual(len(remaining), _ACCOUNT_USAGE_WORKERS_PER_HOME)
# The original busy worker should still be present
self.assertIn(workers[1], remaining)
finally:
release_signal.set()
thread.join(timeout=1.0)
def test_synchronous_close_flattens_nested_lists(self):
"""Synchronous close should flatten nested lists correctly."""
home = Path.home() / ".hermes"
worker = _get_account_usage_probe_worker(home)
self.assertIsNotNone(worker)
worker._lock.release()
with _account_usage_worker_pool_lock:
key = str(Path(home))
workers_list = _account_usage_worker_pool.get(key)
self.assertEqual(len(workers_list), 2)
_close_account_usage_probe_workers()
with _account_usage_worker_pool_lock:
self.assertEqual(len(_account_usage_worker_pool), 0)
def test_concurrent_selector_no_double_claim(self):
"""Two threads holding returned workers simultaneously never share the same instance."""
home = Path("/tmp/test_concurrent")
# Pre-populate pool
initial = _get_account_usage_probe_worker(home)
self.assertIsNotNone(initial)
initial._lock.release()
with _account_usage_worker_pool_lock:
workers = _account_usage_worker_pool[str(Path(home))]
# Lock workers[0] from a background thread so only workers[1] is free
lock_holder = threading.Event()
release_signal = threading.Event()
def hold_first():
workers[0]._lock.acquire()
lock_holder.set()
release_signal.wait(timeout=5.0)
workers[0]._lock.release()
holder_thread = threading.Thread(target=hold_first, daemon=True)
holder_thread.start()
lock_holder.wait(timeout=2.0)
# Two threads race; each holds its worker until both have finished grabbing
results = [None, None]
barrier = threading.Barrier(2, timeout=2.0)
both_done = threading.Barrier(2, timeout=2.0)
def grab(idx):
barrier.wait()
w = _get_account_usage_probe_worker(home)
results[idx] = w
try:
both_done.wait()
except threading.BrokenBarrierError:
pass
if w is not None:
w._lock.release()
t0 = threading.Thread(target=grab, args=(0,), daemon=True)
t1 = threading.Thread(target=grab, args=(1,), daemon=True)
t0.start()
t1.start()
t0.join(timeout=3.0)
t1.join(timeout=3.0)
try:
got = [r for r in results if r is not None]
# At most one thread should have gotten a worker (workers[1]);
# the other gets None because the lock is already held
self.assertEqual(len(got), 1, "Expected exactly one winner, got %d" % len(got))
self.assertIs(got[0], workers[1])
finally:
release_signal.set()
holder_thread.join(timeout=1.0)
def test_invalidation_cannot_pop_worker_between_lookup_and_claim(self):
"""Getter keeps the pool lock through worker claim, so invalidation cannot pop first."""
home = Path("/tmp/test_claim_under_pool_lock")
initial = _get_account_usage_probe_worker(home)
self.assertIsNotNone(initial)
initial._lock.release()
with _account_usage_worker_pool_lock:
workers = _account_usage_worker_pool[str(Path(home))]
workers[1]._lock.acquire()
self.addCleanup(workers[1]._lock.release)
acquire_entered = threading.Event()
allow_acquire = threading.Event()
claimed = threading.Event()
release_claim = threading.Event()
invalidation_done = threading.Event()
result: dict[str, object] = {}
target = workers[0]
original_lock = target._lock
self_outer = self
class GateLock:
def __init__(self, inner):
self._inner = inner
def acquire(self, blocking=True, timeout=-1):
if blocking is False:
self_outer.assertTrue(
_account_usage_worker_pool_lock.locked(),
"worker claim must happen while the pool lock is still held",
)
acquire_entered.set()
allow_acquire.wait(timeout=2.0)
if timeout == -1:
return self._inner.acquire(blocking)
return self._inner.acquire(blocking, timeout)
def release(self):
return self._inner.release()
def __enter__(self):
self._inner.acquire()
return self
def __exit__(self, exc_type, exc, tb):
self._inner.release()
return False
target._lock = GateLock(original_lock)
def run_getter():
result["worker"] = _get_account_usage_probe_worker(home)
claimed.set()
release_claim.wait(timeout=2.0)
if result["worker"] is not None:
result["worker"]._lock.release()
def run_invalidation():
with mock.patch("api.providers._get_hermes_home", return_value=home):
invalidate_account_usage_status_cache(provider_id="anthropic")
invalidation_done.set()
fetch_thread = threading.Thread(target=run_getter, daemon=True)
invalidation_thread = threading.Thread(target=run_invalidation, daemon=True)
fetch_thread.start()
self.assertTrue(acquire_entered.wait(timeout=2.0))
invalidation_thread.start()
time.sleep(0.05)
self.assertFalse(invalidation_done.is_set())
allow_acquire.set()
self.assertTrue(claimed.wait(timeout=2.0))
self.assertIs(result["worker"], target)
release_claim.set()
fetch_thread.join(timeout=2.0)
invalidation_thread.join(timeout=2.0)
self.assertTrue(invalidation_done.is_set())
with _account_usage_worker_pool_lock:
self.assertNotIn(str(Path(home)), _account_usage_worker_pool)
class TestWorkerPoolConfiguration(unittest.TestCase):
"""Test that constants are properly configured."""
def test_workers_per_home_is_two(self):
"""Should have exactly 2 workers per home."""
self.assertEqual(_ACCOUNT_USAGE_WORKERS_PER_HOME, 2)
if __name__ == "__main__":
unittest.main()