Files
hermes-agent/tests/gateway/test_shutdown_executor_quiesce.py
teknium1 92b4f1993b fix: a failed executor submission releases the API worker count (review follow-up)
The count was incremented on the submitting thread and released only in the
worker's finally; when loop.run_in_executor itself raised (default executor
already shut down during quiesce -> RuntimeError) no worker ever ran and
_API_WORKER_LIVE stayed elevated for the process lifetime, making the shutdown
close gate skip the SessionDB close forever. _submit_api_worker now owns both
sides of the submission.
2026-09-20 13:20:55 -07:00

383 lines
13 KiB
Python

"""Gateway shutdown quiesces its thread pool before closing state.db (#101093).
``_shutdown_executor()`` used to run *after* the SessionDB close block in
``_stop_impl``, and it never waited: ``cancel_futures`` only drops work that has
not started, and cancelling the awaiting task does not stop the worker thread
behind a ``run_in_executor`` future. So blocking DB work could still be running
when ``SessionDB.close()`` checkpointed the WAL and let SQLite unlink the
sidecar. The late write then reopens the handle (#94736) and mints a fresh WAL
generation behind that checkpoint, leaving teardown to checkpoint the same file
a second time from a connection the shutdown log never accounts for -- the
close-time page-write damage in #101093 and the split WAL generation in #101064.
The order is now: quiesce (bounded) -> close.
"""
import asyncio
import concurrent.futures
import threading
import time
from collections import OrderedDict
from contextlib import suppress
import pytest
import gateway.run as gw_mod
class _FakeSessionDB:
"""Records when the gateway closed it, on a shared event log."""
def __init__(self, events, name):
self._events = events
self._name = name
def close(self):
self._events.append(f"close:{self._name}")
class _FakeGateway:
"""Minimal stand-in with just enough state for ``stop()`` to run."""
def __init__(self, events):
self._events = events
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 = {}
self._executor_lock = threading.Lock()
self._executor_closing = False
self._executor = concurrent.futures.ThreadPoolExecutor(
max_workers=2, thread_name_prefix="quiesce-test"
)
self._session_db = _FakeSessionDB(events, "session_db")
self.session_store = None
# -- shutdown collaborators the real stop() reaches into ---------------
def _running_agent_count(self):
return len(self._running_agents)
def _active_cron_job_count(self):
return 0
# Real hook + counter: 0 while ``adapters`` is empty, the API-server count once a fake adapter is in.
_api_server_hook = gw_mod.GatewayShutdownMixin._api_server_hook
_mark_api_runs_shutdown_requested = gw_mod.GatewayShutdownMixin._mark_api_runs_shutdown_requested
_active_api_run_count = gw_mod.GatewayShutdownMixin._active_api_run_count
_active_api_worker_count = gw_mod.GatewayShutdownMixin._active_api_worker_count
_active_deferred_agent_worker_count = gw_mod.GatewayShutdownMixin._active_deferred_agent_worker_count
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):
return func(*args)
async def _cleanup_agent_resources_off_loop(self, agent, *, context=""):
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):
pass
def _cleanup_agent_resources(self, agent):
pass
def _evict_cached_agent(self, key):
pass
def _release_running_agent_state(self, session_key, **_kwargs):
self._running_agents.pop(session_key, None)
self._running_agents_ts.pop(session_key, None)
return False
def close_all_session_db_handles(self):
pass
@pytest.mark.asyncio
async def test_running_executor_work_finishes_before_session_db_close():
"""A future already running when stop() begins writes before the close."""
events = []
gw = _FakeGateway(events)
started = threading.Event()
def _blocking_db_write():
started.set()
# Longer than the rest of the shutdown tail (~0.4s), shorter than the
# 2s quiesce ceiling: without the wait the close lands first.
time.sleep(1.0)
events.append("worker_write")
future = gw._executor.submit(_blocking_db_write)
assert started.wait(2.0), "worker never started"
await gw_mod.GatewayRunner.stop(gw)
future.result(timeout=5)
assert "worker_write" in events, "worker never ran"
assert "close:session_db" in events, "SessionDB was never closed"
assert events.index("worker_write") < events.index("close:session_db"), (
f"state.db was closed while a worker was still writing: {events}"
)
@pytest.mark.asyncio
async def test_executor_refuses_new_work_before_session_db_close():
"""``_executor_closing`` is set before the close, so no fresh pool is minted."""
events = []
gw = _FakeGateway(events)
real_close = gw._session_db.close
def _close_and_probe():
# The flag must already be set by the time the DB is closed, or a
# coroutine reaching _get_executor() here would spin up a new pool and
# run more blocking DB work against the handle being torn down.
events.append(f"closing_flag:{gw._executor_closing}")
real_close()
gw._session_db.close = _close_and_probe
await gw_mod.GatewayRunner.stop(gw)
assert "closing_flag:True" in events, events
with pytest.raises(RuntimeError):
gw_mod.GatewayRunner._get_executor(gw)
@pytest.mark.asyncio
async def test_stuck_worker_skips_the_session_db_close():
"""A worker that outlives the quiesce budget must not be raced by close().
Reporting the live worker with a "may reopen state.db" warning is not
enough: the close()/checkpoint itself is the operation that raced the
late write and produced the wrong-page-number corruption in #101093,
so the close path has to be skipped whenever a worker survives the
budget, not merely logged around.
"""
events = []
gw = _FakeGateway(events)
release = threading.Event()
started = threading.Event()
def _stuck():
started.set()
release.wait(5.0)
events.append("worker_write")
future = gw._executor.submit(_stuck)
assert started.wait(2.0), "worker never started"
# Force the quiesce budget to 0 so the worker is deterministically still
# alive when `_shutdown_executor` returns, without sleeping through the
# real 2s ceiling.
original_timeout = gw_mod._EXECUTOR_QUIESCE_TIMEOUT
gw_mod._EXECUTOR_QUIESCE_TIMEOUT = 0.0
try:
await gw_mod.GatewayRunner.stop(gw)
finally:
gw_mod._EXECUTOR_QUIESCE_TIMEOUT = original_timeout
assert "close:session_db" not in events, (
f"SessionDB was closed/checkpointed while a worker was still alive: {events}"
)
release.set()
future.result(timeout=5)
assert "worker_write" in events, "worker never finished"
def _arm_cron(gw):
gw._active_cron_job_count = lambda: 1
def _arm_api(gw):
# Through the real hook: the adapter map is cleared one phase before the close gate, so the
# gate must use the count taken before the clear, not a live lookup.
from gateway.config import Platform
class _ApiAdapter:
def active_agent_work_count(self):
return 1
async def _teardown(adapter, platform, *, profile=None):
pass # the run keeps going on the default executor after the transport is torn down
gw.adapters[Platform.API_SERVER] = _ApiAdapter()
gw._bounded_adapter_teardown = _teardown
def _arm_deferred(gw):
# A hygiene worker on the loop's default executor, never finished.
gw._deferred_agent_workers = {asyncio.get_event_loop().create_future(): object()}
@pytest.mark.asyncio
@pytest.mark.parametrize("arm", [_arm_cron, _arm_api, _arm_deferred], ids=["cron", "api", "deferred"])
async def test_live_writer_outside_the_executor_skips_the_session_db_close(monkeypatch, arm):
"""A cron job, API-server run or deferred worker that outlived the drain must not have state.db
closed under it (#102198).
None of them run on ``self._executor`` (scheduler pool / loop default executor), so the executor
join above the close block never sees them; the close has to consult their counters too.
The executor must still be sealed on this path (#101118).
"""
import hermes_state_registry
events = []
gw = _FakeGateway(events)
arm(gw)
monkeypatch.setattr(
hermes_state_registry, "close_all", lambda: events.append("close_all") or 0
)
await gw_mod.GatewayRunner.stop(gw)
assert "close:session_db" not in events and "close_all" not in events, (
f"SessionDB closed despite a live {arm.__name__[5:]} writer: {events}"
)
assert gw._executor_closing is True, "executor left unsealed on the outside-writer path"
@pytest.mark.asyncio
async def test_cancelled_api_handler_worker_still_blocks_session_db_close(monkeypatch):
"""A cancelled request handler must not let state.db be closed under its live worker (#116535).
The handler-side ``_inflight_agent_runs`` count drops in the handler's ``finally`` when the
handler task is cancelled, while the executor thread behind ``run_in_executor`` is still
blocked in the turn. Only the worker-scoped count still sees that thread, so the SessionDB
close gate must consult it alongside the handler snapshot -- and must not close until the
worker itself exits.
"""
import hermes_state_registry
from gateway.platforms import api_server_runs as api_runs
events = []
gw = _FakeGateway(events)
monkeypatch.setattr(
hermes_state_registry, "close_all", lambda: events.append("close_all") or 0
)
release = threading.Event()
worker_started = threading.Event()
def _blocked_turn():
worker_started.set()
assert release.wait(5.0), "test tore down while the worker was still blocked"
events.append("worker_done")
loop = asyncio.get_running_loop()
async def _handler():
# Same shape as the api_server call sites: the worker-scoped count is taken before
# run_in_executor and released in the worker's own finally, so cancelling this task
# drops the handler side while the thread keeps holding the worker side.
return await api_runs._submit_api_worker(loop, _blocked_turn)
task = asyncio.ensure_future(_handler())
assert await loop.run_in_executor(None, worker_started.wait, 5.0), "worker never started"
task.cancel()
with suppress(asyncio.CancelledError):
await task
assert api_runs.api_worker_live_count() == 1, (
"cancelled handler took the worker count with it"
)
await gw_mod.GatewayRunner.stop(gw)
assert "close:session_db" not in events and "close_all" not in events, (
f"SessionDB closed while the API worker was still alive: {events}"
)
release.set()
for _ in range(100):
if "worker_done" in events:
break
await asyncio.sleep(0.05)
assert "worker_done" in events, "worker never finished"
assert api_runs.api_worker_live_count() == 0, "worker exit did not release the count"
def test_shutdown_executor_defaults_to_no_wait():
"""The no-argument call keeps the historical fire-and-forget contract."""
gw = _FakeGateway([])
release = threading.Event()
started = threading.Event()
def _slow():
started.set()
release.wait(5.0)
future = gw._executor.submit(_slow)
assert started.wait(2.0)
began = time.monotonic()
still_live = gw_mod.GatewayRunner._shutdown_executor(gw)
elapsed = time.monotonic() - began
assert elapsed < 0.5, f"default call waited {elapsed:.2f}s"
assert still_live == 1
release.set()
future.result(timeout=5)
def test_shutdown_executor_reports_a_stuck_worker():
"""A worker that outlives the budget is reported, not waited on forever."""
gw = _FakeGateway([])
release = threading.Event()
started = threading.Event()
def _stuck():
started.set()
release.wait(5.0)
future = gw._executor.submit(_stuck)
assert started.wait(2.0)
began = time.monotonic()
still_live = gw_mod.GatewayRunner._shutdown_executor(gw, drain_timeout=0.2)
elapsed = time.monotonic() - began
assert still_live == 1
assert 0.15 <= elapsed < 2.0, f"budget not honoured: {elapsed:.2f}s"
release.set()
future.result(timeout=5)
def test_shutdown_executor_without_executor_returns_zero():
gw = _FakeGateway([])
gw._executor.shutdown(wait=True)
gw._executor = None
assert gw_mod.GatewayRunner._shutdown_executor(gw, drain_timeout=1.0) == 0