Files
hermes-agent/tests/gateway/test_35994_reset_button_deadlock.py
T
kshitijk4poor ab2f4602de refactor: MessageEvent to gateway/platforms/event.py; ElicitationHandler takes a call_context thunk
Breaks the two import cycles that forced Protocol stand-ins in the F821 sweep, so the two
sites now name the real types.

gateway/platforms/event.py (new leaf): MessageType, ProcessingOutcome, MessageEvent moved
out of base.py verbatim. Their only dependency is gateway.session.SessionSource; base.py
imported helpers.py at module level, so helpers could not name MessageEvent. Now
TextBatchAggregator is typed by the real MessageEvent. 249 importers repointed
(`from gateway.platforms.base import` -> `.event`, preserving each import's layout);
gateway.platforms.__init__ re-exports from .event. The three revert-scheduled PLUGIN-COMPAT
pointers that named these symbols (gateway.slash_commands → MessageType, dingtalk → MessageType,
photon → ProcessingOutcome) and their COMPAT_MANIFEST rows now target gateway.platforms.event.
Docs updated: ADDING_A_PLATFORM.md, adding-platform-adapters.md (en + zh-Hans).

tools/mcp_tool_sampling.py: ElicitationHandler no longer holds a back-reference to its
MCPServerTask (mcp_tool imports sampling, so the task type cannot be named there). It only
ever read owner._pending_call_context, so it takes `call_context: Callable[[], Context | None]`
and MCPServerTask passes `lambda: self._pending_call_context`. The consent call is one
`functools.partial`, run directly or inside the captured Context.

ty on the 11 touched production files vs origin/main: 0 new diagnostics, 14 resolved.
(The one `source: SessionSource = None` diagnostic moves with the class; typing it Optional
exposes ~60 unguarded call sites — separate follow-up.)

Tests: tests/gateway + tests/plugins + tests/tools + touched files, 18,235 passed; the 31
failures reproduce identically on origin/main (macOS /private/tmp, systemd socket,
long-path fixtures, live-service tests).
2026-09-07 22:47:33 +05:30

201 lines
7.4 KiB
Python

"""Regression test for #35994: Telegram /new confirm-button deadlock.
The /new confirmation button callback runs the slash-confirm handler on the
asyncio event loop (see GatewayRunner._request_slash_confirm). That handler
calls _handle_reset_command, which used to invoke the SYNCHRONOUS, potentially
long-blocking _cleanup_agent_resources (agent.close() tears down terminal
sandboxes / browser daemons / background processes; shutdown_memory_provider()
may make a network call) inline on the loop. A slow teardown wedged the entire
event loop, so the bot went silent until a manual restart.
The fix offloads _cleanup_agent_resources to a worker thread with a bounded
timeout, so the loop is never blocked and a stuck teardown degrades gracefully.
"""
import asyncio
import logging
import threading
import time
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from gateway.config import GatewayConfig, Platform, PlatformConfig
from gateway.platforms.event import MessageEvent
from gateway.session import SessionEntry, SessionSource, build_session_key
def _make_source() -> SessionSource:
return SessionSource(
platform=Platform.TELEGRAM,
user_id="u1",
chat_id="c1",
user_name="tester",
chat_type="dm",
)
def _make_event(text: str) -> MessageEvent:
return MessageEvent(text=text, source=_make_source(), message_id="m1")
def _make_runner_with_cached_agent(close_fn):
"""Build a bare GatewayRunner with a cached agent whose close() runs
``close_fn`` (used to simulate slow / blocking teardown)."""
from gateway.run import GatewayRunner
runner = object.__new__(GatewayRunner)
runner.config = GatewayConfig(
platforms={Platform.TELEGRAM: PlatformConfig(enabled=True, token="***")}
)
adapter = MagicMock()
adapter.send = AsyncMock()
runner.adapters = {Platform.TELEGRAM: adapter}
runner._voice_mode = {}
runner.hooks = SimpleNamespace(emit=AsyncMock(), loaded_hooks=False)
runner._session_model_overrides = {}
runner._pending_model_notes = {}
runner._background_tasks = set()
session_key = build_session_key(_make_source())
session_entry = SessionEntry(
session_key=session_key, session_id="sess-old",
created_at=datetime.now(), updated_at=datetime.now(),
platform=Platform.TELEGRAM, chat_type="dm",
)
new_entry = SessionEntry(
session_key=session_key, session_id="sess-new",
created_at=datetime.now(), updated_at=datetime.now(),
platform=Platform.TELEGRAM, chat_type="dm",
)
runner.session_store = MagicMock()
runner.session_store.reset_session.return_value = new_entry
runner.session_store._entries = {session_key: session_entry}
runner.session_store._generate_session_key.return_value = session_key
runner._running_agents = {}
runner._pending_messages = {}
runner._pending_approvals = {}
runner._session_db = None
runner._is_user_authorized = lambda _source: True
runner._format_session_info = lambda: ""
# Enable the cache-lock path (this is what the button callback exercises)
runner._agent_cache_lock = threading.RLock()
agent = MagicMock()
agent.close = close_fn
agent.shutdown_memory_provider = MagicMock()
runner._agent_cache = {session_key: agent}
return runner
@pytest.mark.asyncio
async def test_reset_does_not_block_event_loop_during_cleanup():
"""#35994: a slow agent.close() must NOT block the event loop. A
concurrent loop task must keep ticking WHILE close() is still blocking
(proving cleanup was offloaded to a worker thread, not run inline on
the loop). With the pre-fix inline call, the loop is frozen for the
whole duration of close() and no ticks accumulate until it returns."""
close_started = threading.Event()
release = threading.Event()
def slow_close():
close_started.set()
# Block the WORKER thread (not the loop) until released.
release.wait(timeout=5)
runner = _make_runner_with_cached_agent(slow_close)
ticks = {"n": 0}
stop = threading.Event()
async def _heartbeat():
while not stop.is_set():
ticks["n"] += 1
await asyncio.sleep(0.005)
hb = asyncio.create_task(_heartbeat())
reset_task = asyncio.create_task(
runner._handle_reset_command(_make_event("/new"))
)
# Wait until close() has actually started blocking in its worker thread.
for _ in range(200):
if close_started.is_set():
break
await asyncio.sleep(0.005)
assert close_started.is_set(), "close() never ran"
# Now sample ticks while close() is STILL blocking. If the loop were
# frozen (pre-fix inline call), this stays ~0.
ticks_at_block = ticks["n"]
await asyncio.sleep(0.1)
ticks_during_block = ticks["n"] - ticks_at_block
release.set()
await reset_task
stop.set()
await hb
assert ticks_during_block >= 5, (
f"event loop was blocked during agent cleanup (#35994): only "
f"{ticks_during_block} ticks while close() was running"
)
runner.session_store.reset_session.assert_called_once()
@pytest.mark.asyncio
async def test_reset_completes_when_cleanup_raises(caplog):
"""#35994: if the offloaded cleanup itself raises, the handler swallows it
(logs a warning) and still rotates the session — it must not abort /new.
Note: _cleanup_agent_resources swallows its own internal errors, so to
exercise the handler's `except Exception` branch we make the cleanup call
itself raise (patched on the instance), then assert the warning fired —
proving the branch executed rather than the success path.
"""
runner = _make_runner_with_cached_agent(lambda: None)
def boom_cleanup(_agent):
raise RuntimeError("cleanup blew up")
runner._cleanup_agent_resources = boom_cleanup
with caplog.at_level(logging.WARNING, logger="gateway.run"):
result = await asyncio.wait_for(
runner._handle_reset_command(_make_event("/new")), timeout=3
)
assert any(
"failed during /new reset" in r.message and "#35994" in r.message
for r in caplog.records
), "expected the cleanup-failure warning to be logged (except branch not hit)"
runner.session_store.reset_session.assert_called_once()
assert result is not None
@pytest.mark.asyncio
async def test_reset_completes_when_cleanup_times_out(caplog):
"""#35994: if cleanup exceeds the bounded timeout, the reset still completes
(graceful degradation) and the timeout warning fires."""
import gateway.slash_commands as _sc
# Force the wait_for to time out immediately, closing the offloaded awaitable
# so no worker thread dangles past the test.
async def _instant_timeout(aw, timeout=None):
if asyncio.iscoroutine(aw):
aw.close()
raise asyncio.TimeoutError
runner = _make_runner_with_cached_agent(lambda: None)
with caplog.at_level(logging.WARNING, logger="gateway.run"):
with patch.object(_sc.asyncio, "wait_for", _instant_timeout):
result = await runner._handle_reset_command(_make_event("/new"))
assert any(
"exceeded" in r.message and "#35994" in r.message for r in caplog.records
), "expected the timeout warning to be logged"
runner.session_store.reset_session.assert_called_once()
assert result is not None