Files
aiturk-hermes-ide/tests/gateway/test_shutdown_executor_quiesce.py
T

267 lines
8.4 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
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
def _active_api_run_count(self):
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):
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 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