Files
hermes-agent/tests/gateway/test_pending_drain_race.py
T
Teknium 28834a2098 test: raise tight wall-clock bounds that flaked on loaded CI runners
Seven test files asserted sub-2s wall-clock bounds (elapsed < 0.5/1.0s,
stop(timeout=1.0), event waits of 0.5-2s). Under CI load these fired on
healthy code: main run 33455779041 alone flaked 6 of them in one pass
(observed 1.01s vs 0.5, 1.20s vs 1.0, 3.61s vs 3.0, 1.55s vs 1.0,
stop(1.0) returning False, lease TTL 0.1s expiring before the authority
change was observed).

Per the AGENTS.md flake policy (waits >= 2s), bounds are raised to 5s+
while keeping their teeth: every hang path they guard blocks for 10s+
(release.wait holds), so the loosened bounds still distinguish bounded
from unbounded behavior. The authority-loss test gets a 30s lease TTL so
lease expiry can no longer preempt the authority-change assertion.
2026-09-01 10:52:42 -07:00

229 lines
7.7 KiB
Python

"""Regression tests: pending-drain + finally-cleanup races must not spawn
duplicate agents OR silently drop messages that arrived during cleanup.
Two related races in gateway/platforms/base.py:_process_message_background:
1. Pending-drain path (previous line 1931):
``del self._active_sessions[session_key]`` opened a window where a
concurrent inbound message could pass the Level-1 guard, spawn its
own _process_message_background, and run simultaneously with the
recursive drain. Two agents on one session_key = duplicate responses.
2. Finally-cleanup path (previous line 1990-1991):
Between the awaits in finally (typing_task, stop_typing) and the
``del self._active_sessions[session_key]``, a new message could
land in _pending_messages. The del ran anyway, and the message was
silently dropped — user never got a reply.
Fix: keep the _active_sessions entry live across the turn chain and
clear the Event instead of deleting; in finally, drain any
late-arrival pending message by spawning a task instead of
dropping it.
"""
import asyncio
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")
)
@pytest.mark.asyncio
async def test_pending_drain_keeps_active_session_guard_live():
"""Fix for R5: during pending-drain cleanup, _active_sessions must stay
populated so concurrent inbound messages can't spawn a duplicate
_process_message_background. We only CLEAR the Event, never delete."""
adapter = _make_adapter()
sk = _sk()
# Register a slow handler so the agent is "mid-processing" when the
# pending message arrives.
first_started = asyncio.Event()
release_first = asyncio.Event()
second_processed = asyncio.Event()
handoff_entered = asyncio.Event()
release_handoff = asyncio.Event()
async def handler(event):
first_started.set()
await release_first.wait()
if event.text == "M2":
second_processed.set()
return "done"
adapter._message_handler = handler
original_stop_refresh = adapter._stop_typing_refresh
async def stop_typing_during_handoff(*args, **kwargs):
handoff_entered.set()
await release_handoff.wait()
return await original_stop_refresh(*args, **kwargs)
adapter._stop_typing_refresh = stop_typing_during_handoff
# Spawn M1 through handle_message.
await adapter.handle_message(_make_event(text="M1"))
# Wait until M1 is actively running inside the handler.
await asyncio.wait_for(first_started.wait(), timeout=5.0)
# Assert: session is active.
assert sk in adapter._active_sessions
active_event = adapter._active_sessions[sk]
# Simulate pending message (M2) queued while M1 runs.
adapter._pending_messages[sk] = _make_event(text="M2")
# Release M1 — pending-drain block now runs. During its cleanup
# awaits, _active_sessions[sk] must remain populated (same object
# reference) so any M3 arriving in that window hits the busy-handler.
release_first.set()
try:
# Pause inside the handoff's typing cleanup. Production has already
# cleared the guard and has not yet transferred task ownership.
await asyncio.wait_for(handoff_entered.wait(), timeout=5.0)
# Across the drain transition, the Event object must be the SAME
# reference (not replaced, not deleted).
assert sk in adapter._active_sessions, (
"_active_sessions[session_key] was deleted during pending-drain — "
"opens a window for duplicate-agent spawn"
)
assert adapter._active_sessions[sk] is active_event, (
"_active_sessions[session_key] was replaced during pending-drain — "
"the old Event may have waiters that now won't be signaled"
)
# Finish drain without relying on scheduler speed.
release_handoff.set()
await asyncio.wait_for(second_processed.wait(), timeout=5.0)
finally:
release_handoff.set()
await adapter.cancel_background_tasks()
@pytest.mark.asyncio
async def test_finally_cleanup_drains_late_arrival_pending():
"""Fix for R6: if a message lands in _pending_messages during the
finally-block cleanup awaits, the finally must spawn a drain task
instead of deleting _active_sessions and dropping the message."""
adapter = _make_adapter()
sk = _sk()
processed = []
late_processed = asyncio.Event()
async def handler(event):
processed.append(event.text)
if event.text == "LATE":
late_processed.set()
return "ok"
adapter._message_handler = handler
# Instrument stop_typing to inject a late-arrival pending message
# during the finally-block await window. This exactly simulates the
# R6 race: the message arrives after the response has been sent but
# before _active_sessions is deleted.
original_stop = adapter.stop_typing if hasattr(adapter, "stop_typing") else None
injected = {"done": False}
async def stop_typing_injects_pending(*args, **kwargs):
# Yield so the injection happens mid-await.
await asyncio.sleep(0)
if not injected["done"]:
adapter._pending_messages[sk] = _make_event(text="LATE")
injected["done"] = True
if original_stop:
return await original_stop(*args, **kwargs)
return None
adapter.stop_typing = stop_typing_injects_pending
# Send M1.
await adapter.handle_message(_make_event(text="M1"))
# Drain: wait for the late-drain task itself to process LATE.
await asyncio.wait_for(late_processed.wait(), timeout=5.0)
await adapter.cancel_background_tasks()
assert "M1" in processed, "M1 was not processed"
assert "LATE" in processed, (
"Late-arrival pending message was silently dropped — finally "
"cleanup should have spawned a drain task"
)
@pytest.mark.asyncio
async def test_no_pending_cleans_up_normally():
"""Regression guard: when no pending message exists, the finally
block must still delete _active_sessions as before (no leak)."""
adapter = _make_adapter()
sk = _sk()
async def handler(event):
return "ok"
adapter._message_handler = handler
await adapter.handle_message(_make_event(text="solo"))
# Await the task that owns this session rather than sampling cleanup after
# an arbitrary wall-clock delay.
owner_task = adapter._session_tasks[sk]
await asyncio.wait_for(asyncio.shield(owner_task), timeout=5.0)
assert sk not in adapter._active_sessions, (
"_active_sessions was not cleaned up after a normal turn with no pending"
)
assert sk not in adapter._pending_messages
await adapter.cancel_background_tasks()