"""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