hermes-agent/tests/gateway/test_pending_drain_no_recursion.py
Teknium 39975613b1
test: prune wave 2 + speed fixes — 28,106 → 19,757 test functions, suite wall 315s → 294s
Second, deeper pass over tools/gateway/hermes_cli plus first pass over
the trees wave 1 missed (acp, acp_adapter, skills, computer_use, docker,
dashboard, conformance, monitoring, secret_sources, hermes_state,
providers). Same rubric as wave 1 (AGENTS.md test policy); security,
alternation/caching invariants, issue-number regressions, and E2E kept.

Real test-quality fixes found and rooted out along the way:
- tests/tools/test_command_guards.py made real auxiliary-LLM HTTPS calls
  (DEFAULT_CONFIG smart-approval leaked in) — pinned approval
  mode=manual via autouse fixture: 17.4s → 0.4s.
- test_model_switch_custom_providers.py / test_user_providers_model_switch.py
  silently probed live provider catalogs (~2s/test) — stubbed
  cached_provider_model_ids/provider_model_ids/fetch_api_models.
- test_telegram_noise_filter.py: 15-platform copy-paste matrix over
  shared gateway.run logic → 3 representative platforms (55s → 3.9s).
- test_gateway_shutdown.py: stop()'s 5s interrupt-deadline loop spun on
  MagicMock agents — interrupt.side_effect now clears _running_agents
  (22s → 1.0s).
- test_gateway_inactivity_timeout.py poll-harness timings shrunk 3-5x
  (24s → 1.1s); test_mcp_stability.py backoff/SIGTERM-grace sleeps
  patched (15.4s → 2.5s); test_async_delegation.py negative-drain wait
  5s → 0.5s.
- test_telegram_init_deadline.py: loop-block margin restored to 1.0s
  with rationale comment — the watchdog-dump assertion needs the loop
  blocked well past deadline+grace under parallel load (flaked once in
  the 40-worker verification run at a 0.2s margin).

Verification: full hermetic suite via scripts/run_tests.sh —
2,438 files, 21,718 tests passed, 0 failed, 293.9s wall.
Suite totals vs original baseline: 46,820 → 19,757 test functions
(−57.8%), wall 583.5s → 293.9s (−50%), subprocess CPU 13,564s → 11,623s.
2026-07-29 13:39:40 -07:00

292 lines
10 KiB
Python

"""Regression test for #17758 — chained pending-message drains must not
grow the call stack.
Before the fix, ``_process_message_background`` finished a turn, found a
pending follow-up, and drained it via ``await
self._process_message_background(pending_event, session_key)``. Each
queued follow-up added a frame to the call stack instead of starting
fresh, so under sustained pending-queue activity the C stack would
exhaust at ~2000 nested frames and the process would crash with
SIGSEGV.
After the fix, the in-band drain spawns a fresh task (mirroring the
late-arrival drain pattern), so the stack stays bounded regardless of
chain length.
We assert the invariant directly: count nested
``_process_message_background`` frames at handler entry across a chain
of N follow-ups. Recursion makes depth grow linearly (1, 2, 3, …, N);
task spawning keeps it constant (1 every time).
"""
import asyncio
import sys
from unittest.mock import AsyncMock
import pytest
from gateway.config import Platform, PlatformConfig
from gateway.platforms.base import (
BasePlatformAdapter,
MessageEvent,
MessageType,
)
from gateway.session import SessionSource, build_session_key
class _StubAdapter(BasePlatformAdapter):
async def connect(self, *, is_reconnect: bool = False):
pass
async def disconnect(self):
pass
async def send(self, chat_id, text, **kwargs):
return None
async def get_chat_info(self, chat_id):
return {}
def _make_adapter():
adapter = _StubAdapter(PlatformConfig(enabled=True, token="t"), Platform.TELEGRAM)
adapter._send_with_retry = AsyncMock(return_value=None)
return adapter
def _make_event(text="hi", chat_id="42"):
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=SessionSource(platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm"),
)
def _sk(chat_id="42"):
return build_session_key(
SessionSource(platform=Platform.TELEGRAM, chat_id=chat_id, chat_type="dm")
)
def _count_pmb_frames() -> int:
"""Walk the current call stack and count nested
``_process_message_background`` frames. Used to detect recursive
in-band drains."""
f = sys._getframe()
n = 0
while f is not None:
if f.f_code.co_name == "_process_message_background":
n += 1
f = f.f_back
return n
@pytest.mark.asyncio
async def test_in_band_drain_does_not_grow_stack():
"""Issue #17758: chained pending-message drains must not recurse.
Queue a fresh pending message inside each handler invocation so the
in-band drain block fires for every turn in the chain. After N
turns, the recorded stack depth at handler entry must stay bounded.
Pre-fix, depths would be 1, 2, 3, …, N; post-fix, depths are 1
every time because each drain runs in its own task.
"""
N = 12
adapter = _make_adapter()
sk = _sk()
depths: list[int] = []
next_index = [1]
async def handler(event):
depths.append(_count_pmb_frames())
if next_index[0] < N:
adapter._pending_messages[sk] = _make_event(text=f"M{next_index[0]}")
next_index[0] += 1
return "ok"
adapter._message_handler = handler
await adapter.handle_message(_make_event(text="M0"))
# Drain the chain. Each turn schedules the next via the in-band
# drain block, so we wait until N handler runs have completed and
# the session has been released.
for _ in range(400):
if len(depths) >= N and sk not in adapter._active_sessions:
break
await asyncio.sleep(0.01)
await adapter.cancel_background_tasks()
assert len(depths) == N, (
f"expected {N} handler runs in the chain, got {len(depths)}: depths={depths!r}"
)
max_depth = max(depths)
assert max_depth <= 2, (
f"in-band drain is recursing instead of spawning a fresh task — "
f"stack depth grew with chain length: {depths!r}"
)
# ---------------------------------------------------------------------------
# Follow-up guardrails (belt-and-suspenders on top of the #17758 fix).
#
# The in-band drain hand-off changed cleanup semantics in three subtle ways
# that the original fix reasoned about but didn't test directly. These
# tests pin each invariant so future refactors can't silently regress them.
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_normal_path_releases_session_guard():
"""The common path — one message, nothing queued — must still
fully release ``_active_sessions[sk]`` and ``_session_tasks[sk]``
through the end-of-finally block.
The #17758 fix moved ``_release_session_guard(...)`` under an
``if current_task is self._session_tasks.get(session_key)``
conditional. For the 99%-common case (no pending message, no
handoff) ``current_task`` IS the stored task, so the guard must
still fire. This test would fail if the conditional were ever
tightened in a way that dropped the normal path."""
adapter = _make_adapter()
sk = _sk()
async def handler(event):
return "ok"
adapter._message_handler = handler
await adapter.handle_message(_make_event(text="solo"))
# Wait for the single-shot handler to fully unwind.
for _ in range(200):
if sk not in adapter._active_sessions and sk not in adapter._session_tasks:
break
await asyncio.sleep(0.01)
await adapter.cancel_background_tasks()
assert sk not in adapter._active_sessions, (
"normal-path unwind left _active_sessions[sk] populated — future "
"messages would take the busy-handler path forever"
)
assert sk not in adapter._session_tasks, (
"normal-path unwind left _session_tasks[sk] populated — "
"stale-lock detection will treat a dead task as alive"
)
@pytest.mark.asyncio
async def test_drain_task_cancellation_releases_session():
"""If the in-band drain task is cancelled (e.g. user sent ``/stop``
mid-drain), the session guard and task registry must still get
cleaned up — the cancelled drain task's own ``finally`` runs and
fires ``_release_session_guard``.
The #17758 fix transfers ownership of ``_session_tasks[sk]`` to
the drain task; the drain task's ``except asyncio.CancelledError``
branch must then own the cleanup. Without this test a future
refactor could move cancellation handling in a way that leaves
the session permanently pinned as busy after a cancel."""
adapter = _make_adapter()
sk = _sk()
turn_started = asyncio.Event()
drain_hit_handler = asyncio.Event()
async def handler(event):
if event.text == "M0":
# Queue a pending follow-up so an in-band drain task gets spawned.
adapter._pending_messages[sk] = _make_event(text="M1")
turn_started.set()
return "ok"
# M1 is the drained follow-up — hang so we can cancel the drain task.
drain_hit_handler.set()
try:
await asyncio.sleep(0.2)
except asyncio.CancelledError:
raise
adapter._message_handler = handler
await adapter.handle_message(_make_event(text="M0"))
# Wait for the drain task to actually start running M1.
await asyncio.wait_for(drain_hit_handler.wait(), timeout=2)
# Cancel the drain task mid-handler.
drain_task = adapter._session_tasks.get(sk)
assert drain_task is not None, "in-band drain did not install a drain task"
assert not drain_task.done(), "drain task finished before we could cancel"
drain_task.cancel()
# Drain task's finally must release both registries.
for _ in range(200):
if sk not in adapter._active_sessions and sk not in adapter._session_tasks:
break
await asyncio.sleep(0.01)
await adapter.cancel_background_tasks()
assert sk not in adapter._active_sessions, (
"cancelled drain task did not release _active_sessions[sk] — "
"the session stays permanently pinned as busy after a /stop mid-drain"
)
assert sk not in adapter._session_tasks, (
"cancelled drain task did not release _session_tasks[sk] — "
"stale-lock detection will treat the dead task as alive"
)
@pytest.mark.asyncio
async def test_late_arrival_drain_still_fires_when_no_in_band_drain():
"""The late-arrival drain in ``finally`` must still spawn a fresh
task when no in-band drain preceded it.
Pre-#17758 this path already existed; the #17758 follow-up guard
only re-queues when ``_session_tasks[sk] is not current_task``.
For a late-arrival with no in-band drain, ``_session_tasks[sk]``
IS the current task, so the ``else`` branch must fire and spawn
a drain task for the queued message.
Queue a pending message *after* M0's handler returns (so the
in-band drain block sees nothing) but *before* ``finally`` runs
the late-arrival check — we do this by hooking ``_stop_typing``,
which runs in finally before the late-arrival check."""
adapter = _make_adapter()
sk = _sk()
results: list[str] = []
original_stop_typing = getattr(adapter, "stop_typing", None)
async def injecting_stop_typing(chat_id):
# Simulate a message landing during the cleanup awaits.
adapter._pending_messages[sk] = _make_event(text="late")
if original_stop_typing:
await original_stop_typing(chat_id)
adapter.stop_typing = injecting_stop_typing
async def handler(event):
results.append(event.text)
return "ok"
adapter._message_handler = handler
await adapter.handle_message(_make_event(text="first"))
# Wait for the late-arrival drain task to finish the second event.
for _ in range(400):
if "late" in results and sk not in adapter._active_sessions:
break
await asyncio.sleep(0.01)
await adapter.cancel_background_tasks()
assert "first" in results, "original message handler did not run"
assert "late" in results, (
"late-arrival drain did not spawn a drain task — a message that "
"landed during cleanup awaits was silently dropped"
)