Files
hermes-agent/tests/honcho_plugin/test_shutdown.py
kshitijk4poor b2ee58d24a fix(honcho): prune orphaned observation flags; share the local-platform set; drop test-shape defenses
A flush that rebuilds an evicted session's SDK session stores its observation
flags again, and the cap pass pruned every per-session dict but that one, so the
dict grew one entry per evicted-then-flushed session. The cap pass now prunes it
with the rest.

The peer-failure notice classified platforms with its own {"cli","tui","desktop"}
set, so an ACP session read the gateway wording ("do not suggest peerName"); it
now uses agent.coding_context.INTERACTIVE_CODING_PLATFORMS, which includes acp.

Three getattr/try-except guards existed only for test doubles (a bare __new__
provider, a SimpleNamespace config); the real types always carry the attribute.
Removed, and the two tests build real objects. An unset timeout resolves to the
client's 30s default, so the join-budget test expects 30, not the 5s floor.

Timing tests keep the >= 2s wall-clock bound the testing rules ask for.
2026-09-13 19:05:39 +05:30

223 lines
8.8 KiB
Python

"""Provider shutdown joins every plugin thread within one budget (#37632, #33485, #60616)."""
import logging
import threading
import time
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from plugins.memory.honcho import HonchoMemoryProvider
from plugins.memory.honcho import session as session_module
from plugins.memory.honcho.client import (
HonchoClientConfig,
close_honcho_clients,
join_plugin_threads,
spawn_context_thread,
)
from plugins.memory.honcho.client_cache import _client_slots, _client_slots_lock
from plugins.memory.honcho.session import HonchoSession, HonchoSessionManager
from plugins.plugin_utils import SingletonSlot
@pytest.fixture
def async_manager(monkeypatch):
fake = MagicMock()
monkeypatch.setattr(session_module, "get_honcho_client", lambda *a, **k: fake)
cfg = HonchoClientConfig(write_frequency="async", api_key="test-key", enabled=True)
mgr = HonchoSessionManager(honcho=fake, config=cfg)
mgr.fake_client = fake
yield mgr
mgr.shutdown()
class _Owner:
pass
class TestThreadRegistry:
def test_join_covers_only_the_given_owners(self):
mine, theirs = _Owner(), _Owner()
release = threading.Event()
own = spawn_context_thread(lambda: release.wait(timeout=2), name="own", owner=mine)
other = spawn_context_thread(lambda: release.wait(timeout=2), name="other", owner=theirs)
own.start()
other.start()
try:
started = time.monotonic()
assert join_plugin_threads((theirs, None), timeout=0.05) == ["other"]
release.set()
assert join_plugin_threads((mine,), timeout=2) == []
assert time.monotonic() - started < 2.0
finally:
release.set()
own.join(timeout=1)
other.join(timeout=1)
class TestProviderShutdown:
def _provider(self, manager, cfg=None):
provider = HonchoMemoryProvider()
provider._manager = manager
provider._config = cfg or manager._config
provider._session_key = "test-session"
provider._session_initialized = True
return provider
def _shutdown_blocks_until(self, provider, release):
"""shutdown() off-thread must still be running until ``release`` is set, then finish."""
done = threading.Event()
threading.Thread(target=lambda: (provider.shutdown(), done.set()), daemon=True).start()
try:
assert not done.wait(timeout=0.05)
release.set()
assert done.wait(timeout=2)
finally:
release.set()
def test_shutdown_waits_for_an_in_flight_context_prefetch(self, async_manager):
provider = self._provider(async_manager)
started, release = threading.Event(), threading.Event()
def slow_prefetch(session_key, user_message=None):
started.set()
release.wait(timeout=2)
return {"representation": "ready"}
async_manager.get_prefetch_context = slow_prefetch
async_manager.prefetch_context("test-session", "query")
assert started.wait(timeout=1)
self._shutdown_blocks_until(provider, release)
assert not any(t.name == "honcho-context-prefetch" and t.is_alive() for t in threading.enumerate())
def test_shutdown_joins_a_stalled_init_thread_within_the_budget(self, monkeypatch, caplog):
monkeypatch.setattr(HonchoMemoryProvider, "_SHUTDOWN_JOIN_FLOOR", 0.2)
cfg = HonchoClientConfig(api_key="test-key", enabled=True, timeout=0.1)
release = threading.Event()
entered = threading.Event()
class StalledManager:
def __init__(self, *args, **kwargs):
pass
def get_or_create(self, session_key):
entered.set()
release.wait(timeout=5)
return SimpleNamespace(messages=[])
def migrate_memory_files(self, *a, **k):
pass
monkeypatch.setattr("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", lambda: cfg)
monkeypatch.setattr("plugins.memory.honcho.client.get_honcho_client", lambda cfg: object())
monkeypatch.setattr("plugins.memory.honcho.session.HonchoSessionManager", StalledManager)
provider = HonchoMemoryProvider()
provider.initialize("session-1", platform="cli")
try:
assert entered.wait(timeout=1)
started = time.monotonic()
with caplog.at_level(logging.WARNING, logger="plugins.memory.honcho"):
provider.shutdown()
assert 0.15 <= time.monotonic() - started < 2.0
assert "honcho-session-init" in caplog.text
assert "timed out after 0.2s" in caplog.text
finally:
release.set()
provider._init_thread.join(timeout=2)
def test_shutdown_joins_the_prewarm_dialectic_thread(self, async_manager):
provider = self._provider(async_manager)
release = threading.Event()
async_manager.dialectic_query = lambda *a, **k: release.wait(timeout=2) and "answer"
provider._spawn_dialectic("who?", thread_name="honcho-prewarm-dialectic", fired_at=0, log_label="prewarm")
self._shutdown_blocks_until(provider, release)
assert not provider._prefetch_thread.is_alive()
def test_recall_sync_worker_is_registered_under_the_provider(self, async_manager):
from plugins.memory.honcho.recall_sync import prefetch_sync
provider = self._provider(async_manager, cfg=HonchoClientConfig(api_key="test-key", enabled=True, timeout=0.05))
entered, release = threading.Event(), threading.Event()
def blocked(session_key, query, **kwargs):
entered.set()
release.wait(timeout=2)
return {}
async_manager.get_prefetch_context = blocked
provider.on_turn_start(1, "what did we decide about the schema?")
try:
assert prefetch_sync(provider, "what did we decide about the schema?") == ""
assert entered.wait(timeout=1)
assert join_plugin_threads((provider,), timeout=0.01) == ["honcho-recall-sync"]
release.set()
assert join_plugin_threads((provider,), timeout=2) == []
finally:
release.set()
provider._recall_sync_thread.join(timeout=2)
def test_shutdown_skips_a_flush_whose_lock_an_upload_holds_past_the_budget(self, async_manager, monkeypatch, caplog):
provider = self._provider(async_manager)
monkeypatch.setattr(provider, "_shutdown_join_budget", lambda: 0.2)
session = HonchoSession("test-session", "user", "assistant", "sid")
session.add_message("user", "pending")
async_manager._cache["test-session"] = session
entered, release = threading.Event(), threading.Event()
def upload(messages):
entered.set()
release.wait(timeout=5)
async_manager._sessions_cache["sid"] = SimpleNamespace(add_messages=upload)
async_manager.save(session)
try:
assert entered.wait(timeout=1)
started = time.monotonic()
with caplog.at_level(logging.WARNING, logger="plugins.memory.honcho"):
provider.shutdown()
assert time.monotonic() - started < 2.0
assert "1 message(s) in 1 session(s) still unsynced" in caplog.text
assert session.messages[0].get("_synced") is None
finally:
release.set()
def test_shutdown_does_not_close_the_shared_client(self, async_manager):
provider = self._provider(async_manager)
provider.shutdown()
async_manager.fake_client._http.close.assert_not_called()
@pytest.mark.parametrize("timeout, budget", [(60.0, 60.0), (2.0, 5.0), (None, 30.0)])
def test_shutdown_join_budget_is_the_floor_or_the_longer_timeout(timeout, budget):
"""Unset timeout resolves to the client's 30s default, so the join waits as long as a blocked call can."""
provider = HonchoMemoryProvider()
provider._config = HonchoClientConfig(api_key="k", timeout=timeout)
assert provider._shutdown_join_budget() == budget
class TestManagerAfterShutdown:
def test_prefetch_is_skipped_after_shutdown(self, async_manager):
calls = []
async_manager.get_prefetch_context = lambda *a, **k: calls.append(a) or {}
async_manager.shutdown()
async_manager.prefetch_context("test-session", "query")
time.sleep(0.05)
assert calls == []
def test_close_honcho_clients_closes_every_pool_and_drops_the_slots():
client = MagicMock()
slot = SingletonSlot()
slot.get(lambda: client)
key = ("test", "close-me")
with _client_slots_lock:
_client_slots[key] = slot
try:
close_honcho_clients()
finally:
with _client_slots_lock:
_client_slots.pop(key, None)
client._http.close.assert_called_once_with()
assert key not in _client_slots