File size: 15,417 Bytes
7acbd80 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 | """Regression tests for bounded growth of the Honcho local session cache.
Covers the fix for unbounded RSS growth in long-running gateways: prior to
this, ``HonchoSession.messages`` grew forever (never trimmed after a sync),
and ``HonchoSessionManager``'s ``_cache``/``_sessions_cache``/``_context_cache``
had no eviction path short of an explicit ``/new`` reset.
"""
import threading
import time
from datetime import datetime, timedelta
from types import SimpleNamespace
import pytest
from plugins.memory.honcho.session import (
HonchoSession,
HonchoSessionManager,
_PEERS_CACHE_MAX_SIZE,
_SESSION_CACHE_MAX_SIZE,
_SESSION_IDLE_TTL_SECONDS,
_SESSION_MESSAGE_RETENTION,
)
def _session(key="k", honcho_session_id=None):
return HonchoSession(
key=key,
user_peer_id="user",
assistant_peer_id="assistant",
honcho_session_id=honcho_session_id or f"hs-{key}",
)
def _manager():
cfg = SimpleNamespace(
write_frequency="turn",
dialectic_reasoning_level="low",
dialectic_dynamic=True,
dialectic_max_chars=600,
observation_mode="directional",
user_observe_me=True,
user_observe_others=True,
ai_observe_me=True,
ai_observe_others=True,
message_max_chars=25000,
dialectic_max_input_chars=10000,
)
return HonchoSessionManager(honcho=SimpleNamespace(), config=cfg)
def test_trim_synced_messages_caps_total_length():
session = _session()
for i in range(250):
session.add_message("user", f"msg{i}", _synced=True)
for i in range(250, 255):
session.add_message("user", f"msg{i}", _synced=False)
assert len(session.messages) == 255
HonchoSessionManager._trim_synced_messages(session)
assert len(session.messages) == _SESSION_MESSAGE_RETENTION
# oldest synced messages are the ones dropped; the unsynced tail survives intact
assert not any(m.get("_synced") is False for m in session.messages[:-5])
assert all(m.get("_synced") is False for m in session.messages[-5:])
def test_trim_synced_messages_never_drops_an_unsynced_message():
session = _session()
for i in range(300):
session.add_message("user", f"m{i}", _synced=(i != 10))
HonchoSessionManager._trim_synced_messages(session)
contents = [m["content"] for m in session.messages]
assert "m10" in contents
idx = contents.index("m10")
assert session.messages[idx].get("_synced") is False
# trimming stops at the first unsynced message from the front — it does
# not skip past it to keep reducing, so everything from there on survives
assert contents[idx:] == [f"m{i}" for i in range(10, 300)]
def test_trim_synced_messages_is_a_noop_under_the_cap():
session = _session()
for i in range(10):
session.add_message("user", f"m{i}", _synced=True)
HonchoSessionManager._trim_synced_messages(session)
assert len(session.messages) == 10
def test_sweep_idle_sessions_evicts_stale_entries_across_all_caches():
mgr = _manager()
stale = _session(key="stale", honcho_session_id="hs-stale")
stale.updated_at = datetime.now() - timedelta(seconds=_SESSION_IDLE_TTL_SECONDS + 1)
fresh = _session(key="fresh", honcho_session_id="hs-fresh")
mgr._cache = {"stale": stale, "fresh": fresh}
mgr._sessions_cache = {"hs-stale": object(), "hs-fresh": object()}
mgr._context_cache = {"stale": {"x": 1}, "fresh": {"x": 1}}
evicted = mgr._sweep_idle_sessions_locked()
assert evicted == 1
assert set(mgr._cache) == {"fresh"}
assert set(mgr._sessions_cache) == {"hs-fresh"}
assert set(mgr._context_cache) == {"fresh"}
def test_sweep_idle_sessions_keeps_fresh_entries():
mgr = _manager()
fresh = _session(key="fresh")
mgr._cache = {"fresh": fresh}
mgr._sessions_cache = {fresh.honcho_session_id: object()}
mgr._context_cache = {"fresh": {}}
evicted = mgr._sweep_idle_sessions_locked()
assert evicted == 0
assert "fresh" in mgr._cache
def test_maybe_sweep_idle_sessions_is_rate_limited():
mgr = _manager()
stale = _session(key="stale")
stale.updated_at = datetime.now() - timedelta(seconds=_SESSION_IDLE_TTL_SECONDS + 1)
mgr._cache = {"stale": stale}
mgr._last_idle_sweep_ts = time.time() # just swept — this call should no-op
mgr._maybe_sweep_idle_sessions()
assert "stale" in mgr._cache
mgr._last_idle_sweep_ts = 0.0 # force the interval to have elapsed
mgr._maybe_sweep_idle_sessions()
assert "stale" not in mgr._cache
def test_get_or_create_triggers_sweep_without_blocking_on_lock_reentrancy():
"""`_cache_lock` is an RLock specifically so a sweep triggered from inside
`get_or_create` (which also takes the lock) can't deadlock the manager's
own thread. Guard against that regressing silently.
"""
mgr = _manager()
stale = _session(key="stale")
stale.updated_at = datetime.now() - timedelta(seconds=_SESSION_IDLE_TTL_SECONDS + 1)
mgr._cache = {"stale": stale}
mgr._last_idle_sweep_ts = 0.0
done = threading.Event()
def call_it():
mgr._maybe_sweep_idle_sessions()
done.set()
t = threading.Thread(target=call_it)
t.start()
t.join(timeout=5)
assert done.is_set(), "sweep did not complete — possible deadlock"
assert "stale" not in mgr._cache
# ---------------------------------------------------------------------------
# hard caps, unsynced buffers, peers, and read activity (follows #71463)
# ---------------------------------------------------------------------------
def _fill_sessions(mgr, count, unsynced_keys=()):
for i in range(count):
key = f"k{i}"
session = _session(key=key)
if key in unsynced_keys:
session.add_message("user", "pending", _synced=False)
mgr._cache[key] = session
mgr._sessions_cache[session.honcho_session_id] = object()
mgr._session_observation[session.honcho_session_id] = {"ai_observe_others": False}
mgr._context_cache[key] = {"representation": "r"}
def test_size_cap_evicts_least_recently_used_sessions_with_their_entries():
mgr = _manager()
_fill_sessions(mgr, _SESSION_CACHE_MAX_SIZE + 2)
with mgr._cache_lock:
mgr._enforce_cache_caps_locked()
assert len(mgr._cache) == _SESSION_CACHE_MAX_SIZE
assert "k0" not in mgr._cache and "k1" not in mgr._cache
assert "k2" in mgr._cache
for gone in ("k0", "k1"):
assert f"hs-{gone}" not in mgr._sessions_cache
assert f"hs-{gone}" not in mgr._session_observation
assert gone not in mgr._context_cache
assert "hs-k2" in mgr._sessions_cache and "hs-k2" in mgr._session_observation
def test_size_cap_never_evicts_a_session_with_unsynced_messages():
mgr = _manager()
_fill_sessions(mgr, _SESSION_CACHE_MAX_SIZE + 1, unsynced_keys={"k0"})
with mgr._cache_lock:
mgr._enforce_cache_caps_locked()
assert "k0" in mgr._cache # the only copy until its flush lands
assert "k1" not in mgr._cache
assert len(mgr._cache) == _SESSION_CACHE_MAX_SIZE
def test_idle_sweep_keeps_sessions_with_unsynced_messages():
mgr = _manager()
stale = _session(key="stale")
stale.add_message("user", "pending", _synced=False)
stale.updated_at = datetime.now() - timedelta(seconds=_SESSION_IDLE_TTL_SECONDS + 1)
mgr._cache = {"stale": stale}
with mgr._cache_lock:
evicted = mgr._sweep_idle_sessions_locked()
assert evicted == 0
assert "stale" in mgr._cache
def test_peers_cap_evicts_unreferenced_peers_oldest_first():
mgr = _manager()
live = _session(key="live")
mgr._cache = {"live": live}
mgr._peers_cache[live.user_peer_id] = object()
mgr._peers_cache[live.assistant_peer_id] = object()
for i in range(_PEERS_CACHE_MAX_SIZE):
mgr._peers_cache[f"guest{i}"] = object()
with mgr._cache_lock:
mgr._enforce_cache_caps_locked()
assert len(mgr._peers_cache) == _PEERS_CACHE_MAX_SIZE
assert live.user_peer_id in mgr._peers_cache and live.assistant_peer_id in mgr._peers_cache
assert "guest0" not in mgr._peers_cache and "guest1" not in mgr._peers_cache
assert f"guest{_PEERS_CACHE_MAX_SIZE - 1}" in mgr._peers_cache
def test_recall_read_counts_as_activity_for_the_idle_sweep():
mgr = _manager()
session = _session(key="read-only")
session.updated_at = datetime.now() - timedelta(seconds=_SESSION_IDLE_TTL_SECONDS + 1)
mgr._cache = {"read-only": session}
assert mgr._cached_session("read-only") is session
with mgr._cache_lock:
evicted = mgr._sweep_idle_sessions_locked()
assert evicted == 0
assert "read-only" in mgr._cache
def test_sdk_object_hit_moves_the_key_to_the_recent_end():
mgr = _manager()
mgr._peers_cache = {"a": object(), "b": object()}
mgr._cached_sdk_object(mgr._peers_cache, "a", lambda: None)
assert list(mgr._peers_cache) == ["b", "a"]
def test_get_or_create_stores_observation_flags_with_the_entry_and_eviction_drops_them():
mgr = _manager()
mgr._config.ai_peer = "hermes"
mgr._config.peer_name = "operator" # unnamed peers now fail closed instead of minting a fallback
flags = {"user_observe_me": True, "user_observe_others": True, "ai_observe_me": True, "ai_observe_others": False}
mgr._get_or_create_peer = lambda peer_id: object()
mgr._get_or_create_honcho_session = lambda sid, user, assistant: (object(), [], dict(flags))
session = mgr.get_or_create("cli:one")
assert mgr._session_observation[session.honcho_session_id] == flags
assert mgr._ai_observes_others(session) is False
with mgr._cache_lock:
mgr._evict_session_locked("cli:one", session)
assert session.honcho_session_id not in mgr._session_observation
def test_cap_enforcement_drops_observation_flags_a_post_eviction_flush_stored():
"""A flush that rebuilds an evicted session's SDK session stores its flags again; the next cap pass
must prune that orphan like every other per-session entry, or the dict grows one entry per evicted-then-
flushed session."""
mgr = _manager()
live = _session(key="live")
mgr._cache = {"live": live}
mgr._session_observation = {live.honcho_session_id: {"ai_observe_others": True}, "hs-gone": {"ai_observe_others": False}}
with mgr._cache_lock:
mgr._enforce_cache_caps_locked()
assert set(mgr._session_observation) == {live.honcho_session_id}
def test_flush_does_not_resurrect_an_evicted_session():
mgr = _manager()
session = _session(key="gone")
session.add_message("user", "late", _synced=False)
peer = SimpleNamespace(message=lambda content: content)
mgr._get_or_create_peer = lambda peer_id: peer
mgr._sessions_cache[session.honcho_session_id] = SimpleNamespace(add_messages=lambda messages: None)
assert mgr._flush_session(session) is True
assert "gone" not in mgr._cache
assert all(m["_synced"] for m in session.messages)
def _sdk_session():
return SimpleNamespace(add_messages=lambda messages: None)
def test_flush_that_recreates_the_sdk_session_stores_its_observation_flags():
"""After an eviction the flush path rebuilds the SDK session. The flags it configured must be kept."""
mgr = _manager()
session = _session(key="back")
session.add_message("user", "hello", _synced=False)
flags = {"user_observe_me": False, "user_observe_others": True, "ai_observe_me": True, "ai_observe_others": False}
mgr._get_or_create_peer = lambda peer_id: SimpleNamespace(message=lambda content: content)
mgr._get_or_create_honcho_session = lambda sid, user, assistant: (_sdk_session(), [], flags)
assert mgr._flush_session(session) is True
assert mgr._session_observation[session.honcho_session_id] == flags
def test_cached_sdk_session_returns_the_flags_stored_for_it():
mgr = _manager()
flags = {"user_observe_me": True, "user_observe_others": False, "ai_observe_me": True, "ai_observe_others": True}
sdk = _sdk_session()
mgr._sessions_cache["hs-x"] = sdk
mgr._session_observation["hs-x"] = flags
assert mgr._get_or_create_honcho_session("hs-x", None, None) == (sdk, [], flags)
@pytest.mark.parametrize("synced, kept", [(False, True), (True, False)])
def test_deferred_save_keeps_an_evicted_session_only_while_it_holds_unsynced_messages(synced, kept):
"""write_frequency "session" defers to flush_all(), which only sees cached sessions."""
mgr = _manager()
mgr._write_frequency = "session"
session = _session(key="evicted")
session.add_message("user", "pending", _synced=synced)
mgr.save(session)
assert (mgr._cache.get("evicted") is session) is kept
def test_deferred_save_flushes_inline_when_a_newer_object_owns_the_key():
mgr = _manager()
mgr._write_frequency = "session"
newer = _session(key="k")
mgr._cache["k"] = newer
stale = _session(key="k")
stale.add_message("user", "late", _synced=False)
flushed = []
mgr._flush_session = lambda s: flushed.append(s) or True
mgr.save(stale)
assert flushed == [stale]
assert mgr._cache["k"] is newer
def _failing_then_recording_uploads(mgr, session):
"""The SDK session for ``session`` refuses the first batch; ``restore()`` swaps in one that records."""
uploads = []
def refuse(messages):
raise ConnectionError("upload refused")
mgr._get_or_create_peer = lambda peer_id: SimpleNamespace(message=lambda content: content)
mgr._sessions_cache[session.honcho_session_id] = SimpleNamespace(add_messages=refuse)
def restore():
mgr._sessions_cache[session.honcho_session_id] = SimpleNamespace(add_messages=lambda ms: uploads.extend(ms))
return uploads, restore
def test_failed_turn_save_after_an_eviction_is_retried_by_flush_all():
"""A clean session can be evicted while its caller still holds it. The caller's next save flushes inline in
turn mode, and a failed upload used to leave that object nowhere flush_all() could find it."""
mgr = _manager()
session = _session(key="k")
mgr._cache["k"] = session
with mgr._cache_lock:
mgr._evict_session_locked("k", session)
session.add_message("user", "late")
uploads, restore = _failing_then_recording_uploads(mgr, session)
mgr.save(session)
assert mgr._cache["k"] is session
restore()
mgr.flush_all()
assert uploads == ["late"]
assert session.messages[0]["_synced"] is True
def test_failed_collision_flush_waits_for_flush_all_without_displacing_the_newer_object():
mgr = _manager()
mgr._write_frequency = "session"
newer = _session(key="k")
mgr._cache["k"] = newer
stale = _session(key="k")
stale.add_message("user", "late")
uploads, restore = _failing_then_recording_uploads(mgr, stale)
mgr.save(stale)
assert mgr._cache["k"] is newer
assert mgr._retry_sessions == [stale]
restore()
mgr.flush_all()
assert uploads == ["late"]
assert mgr._retry_sessions == []
assert mgr._cache["k"] is newer
def test_a_retained_session_is_listed_once_across_repeated_failures():
mgr = _manager()
mgr._cache["k"] = _session(key="k")
stale = _session(key="k")
stale.add_message("user", "late")
_failing_then_recording_uploads(mgr, stale)
mgr.save(stale)
mgr.save(stale)
assert mgr._retry_sessions == [stale]
|