4b06d9e9b1
CI slice 5/12 caught two ways the new cron budget broke `_stop_impl_body` for callers that are not real GatewayRunner instances: - `_FakeGateway` in test_shutdown_cache_cleanup.py borrows `_stop_impl` without subclassing, so it never picked up the class-level `_cron_drain_timeout` default and raised AttributeError. Read it through the getattr-guard convention the same function already uses for its liveness-guard machinery. - The same double overrides `_drain_active_agents(self, timeout)`, so passing the cron budget raised "takes 2 positional arguments but 3 were given". The double now mirrors the real optional parameter. It is the only override in the tree; test_startup_restart_race.py uses AsyncMock, which accepts any signature. Verified against a stashed clean tree: the 22 gateway test files that still fail locally fail identically with and without this branch (80 = 80, empty set difference both ways) — they are pre-existing Windows-only failures (setsid, POSIX modes) unrelated to this change.
179 lines
5.9 KiB
Python
179 lines
5.9 KiB
Python
"""Regression tests for gateway shutdown cleaning up cached agent memory providers (issue #11205).
|
|
|
|
When the gateway shuts down, ``stop()`` called ``_finalize_shutdown_agents()``
|
|
which only drained agents in ``_running_agents``. Idle agents sitting in
|
|
``_agent_cache`` (LRU cache) were never cleaned up, so their
|
|
``MemoryProvider.on_session_end()`` hooks never fired.
|
|
|
|
The fix adds an explicit sweep of ``_agent_cache`` after
|
|
``_finalize_shutdown_agents`` in the ``_stop_impl`` coroutine.
|
|
"""
|
|
|
|
import asyncio
|
|
import threading
|
|
from collections import OrderedDict
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
|
|
# Import the module (not the class) to reach stop() and helpers
|
|
import gateway.run as gw_mod
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class _FakeGateway:
|
|
"""Minimal stand-in with just enough state for ``stop()`` to run."""
|
|
|
|
def __init__(self):
|
|
self._running = True
|
|
self._draining = False
|
|
self._restart_requested = False
|
|
self._restart_detached = False
|
|
self._restart_via_service = False
|
|
self._stop_task = None
|
|
self._exit_cleanly = False
|
|
self._exit_with_failure = False
|
|
self._exit_reason = None
|
|
self._exit_code = None
|
|
self._restart_drain_timeout = 0.01
|
|
self._running_agents = {}
|
|
self._running_agents_ts = {}
|
|
self._agent_cache = OrderedDict()
|
|
self._agent_cache_lock = threading.Lock()
|
|
self.adapters = {}
|
|
self._background_tasks = set()
|
|
self._failed_platforms = []
|
|
self._shutdown_event = asyncio.Event()
|
|
self._pending_messages = {}
|
|
self._pending_approvals = {}
|
|
self._busy_ack_ts = {}
|
|
|
|
def _running_agent_count(self):
|
|
return len(self._running_agents)
|
|
|
|
def _active_cron_job_count(self):
|
|
# stop() reads this alongside _running_agent_count when logging the
|
|
# drain snapshot (#60432) -- this fake has no cron scheduler, so
|
|
# there's never in-flight cron work to report.
|
|
return 0
|
|
|
|
def _active_api_run_count(self):
|
|
# The shutdown log also reports adapter-owned API work (#63529).
|
|
# This fake has no API server adapter, so it is always idle.
|
|
return 0
|
|
|
|
def _update_runtime_status(self, *_a, **_kw):
|
|
pass
|
|
|
|
def _clear_plugin_message_injector(self):
|
|
pass
|
|
|
|
async def _run_in_executor_with_context(self, func, *args):
|
|
# stop() offloads agent-resource cleanup off the loop (#53175); run
|
|
# inline in tests so the bounded-cleanup path is exercised.
|
|
return func(*args)
|
|
|
|
async def _cleanup_agent_resources_off_loop(self, agent, *, context=""):
|
|
# Mirror the real bounded helper, inline (no executor/timeout) so the
|
|
# fake exercises the same call shape stop() now uses.
|
|
self._cleanup_agent_resources(agent)
|
|
|
|
async def _notify_active_sessions_of_shutdown(self):
|
|
pass
|
|
|
|
async def _cancel_secondary_profile_reconnect_tasks(self):
|
|
pass
|
|
|
|
async def _drain_active_agents(self, timeout, cron_timeout=None):
|
|
return {}, False
|
|
|
|
async def _finalize_shutdown_agents(self, agents):
|
|
for agent in agents.values():
|
|
self._cleanup_agent_resources(agent)
|
|
|
|
def _cleanup_agent_resources(self, agent):
|
|
if agent is None:
|
|
return
|
|
try:
|
|
if hasattr(agent, "shutdown_memory_provider"):
|
|
agent.shutdown_memory_provider()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
if hasattr(agent, "close"):
|
|
agent.close()
|
|
except Exception:
|
|
pass
|
|
|
|
def _evict_cached_agent(self, key):
|
|
pass
|
|
|
|
def _release_running_agent_state(self, session_key, **_kwargs):
|
|
agent = self._running_agents.pop(session_key, None)
|
|
self._running_agents_ts.pop(session_key, None)
|
|
self._cleanup_agent_resources(agent)
|
|
return agent is not None
|
|
|
|
|
|
def _make_mock_agent():
|
|
a = MagicMock()
|
|
a.shutdown_memory_provider = MagicMock()
|
|
a.close = MagicMock()
|
|
return a
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
class TestCachedAgentCleanupOnShutdown:
|
|
"""Verify that ``stop()`` calls ``_cleanup_agent_resources`` on idle
|
|
cached agents, triggering ``shutdown_memory_provider()`` (which calls
|
|
``on_session_end``)."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cached_agent_memory_provider_shut_down(self):
|
|
"""A cached agent's shutdown_memory_provider is called during gateway stop."""
|
|
gw = _FakeGateway()
|
|
agent = _make_mock_agent()
|
|
gw._agent_cache["session-1"] = (agent, "sig-123")
|
|
|
|
# Call the real stop() from GatewayRunner
|
|
await gw_mod.GatewayRunner.stop(gw)
|
|
|
|
agent.shutdown_memory_provider.assert_called_once()
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cache_cleared_after_shutdown(self):
|
|
"""The _agent_cache dict is cleared after stop."""
|
|
gw = _FakeGateway()
|
|
agent = _make_mock_agent()
|
|
gw._agent_cache["s1"] = (agent, "sig1")
|
|
|
|
await gw_mod.GatewayRunner.stop(gw)
|
|
|
|
assert len(gw._agent_cache) == 0
|
|
|
|
|
|
class TestRunningAgentsNotDoubleCleaned:
|
|
"""Verify behavior when agents appear in both _running_agents and _agent_cache."""
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_running_and_cached_agent_cleaned_at_least_once(self):
|
|
"""An agent in both _running_agents and _agent_cache gets
|
|
shutdown_memory_provider called at least once."""
|
|
gw = _FakeGateway()
|
|
shared = _make_mock_agent()
|
|
|
|
gw._running_agents["s1"] = shared
|
|
gw._agent_cache["s1"] = (shared, "sig1")
|
|
|
|
await gw_mod.GatewayRunner.stop(gw)
|
|
|
|
# Called at least once — either from _finalize_shutdown_agents
|
|
# or from the cache sweep (or both)
|
|
assert shared.shutdown_memory_provider.call_count >= 1
|