Files
cleveragents-core/features/steps/security_async_steps.py
T
freemo ec0b7631d0
CI / lint (push) Successful in 12s
CI / build (push) Successful in 15s
CI / quality (push) Successful in 23s
CI / typecheck (push) Successful in 36s
CI / benchmark-regression (push) Has been skipped
CI / security (push) Successful in 46s
CI / unit_tests (push) Successful in 3m3s
CI / integration_tests (push) Successful in 3m31s
CI / docker (push) Successful in 40s
CI / coverage (push) Successful in 5m34s
CI / benchmark-publish (push) Successful in 19m15s
CI / benchmark-publish (pull_request) Has been skipped
CI / lint (pull_request) Successful in 13s
CI / build (pull_request) Successful in 14s
CI / quality (pull_request) Successful in 17s
CI / security (pull_request) Successful in 34s
CI / typecheck (pull_request) Has been cancelled
CI / unit_tests (pull_request) Has been cancelled
CI / coverage (pull_request) Has been cancelled
CI / benchmark-regression (pull_request) Has been cancelled
CI / integration_tests (pull_request) Has been cancelled
CI / docker (pull_request) Has been cancelled
refactor(a2a): rename ACP module and symbols to A2A standard
Renamed src/cleveragents/acp/ to src/cleveragents/a2a/ and all 13
Acp* classes to A2a* per ADR-047 (A2A Standard Adoption). Updated
all imports, structlog event names (acp.* → a2a.*), field names
(acp_version → a2a_version), and test references across the entire
codebase. This is a cosmetic rename only — no behavioral changes.

ISSUES CLOSED: #688
2026-03-12 14:38:57 +00:00

503 lines
16 KiB
Python

"""Step definitions for async resource cleanup and leak prevention."""
import asyncio
import logging
import tempfile
from pathlib import Path
from behave import given, then, when
from cleveragents.a2a.events import A2aEventQueue
from cleveragents.core.async_cleanup import AsyncResourceTracker
from cleveragents.langgraph.state import GraphState, StateManager
# ---------------------------------------------------------------------------
# Helpers — lightweight mock async resource (lives in steps, not src/)
# ---------------------------------------------------------------------------
class MockAsyncResource:
"""Test double for an async-closable resource."""
def __init__(self, name: str, close_delay: float = 0.0) -> None:
self.name = name
self.close_delay = close_delay
self.closed = False
self.close_count = 0
async def close(self) -> None:
if self.close_delay > 0:
# Use the original (un-patched) asyncio.sleep so timeout-based
# tests observe real wall-clock delays.
_real_sleep = getattr(asyncio, "_original_sleep", asyncio.sleep)
await _real_sleep(self.close_delay)
self.closed = True
self.close_count += 1
# ---------------------------------------------------------------------------
# Background
# ---------------------------------------------------------------------------
@given("I have an async resource tracker")
def step_create_tracker(context):
context.tracker = AsyncResourceTracker()
context.resources = {}
context.log_handler = _CapturingHandler()
_logger = logging.getLogger("cleveragents.core.async_cleanup")
# Remove stale handlers from previous scenarios (sequential mode)
for h in list(_logger.handlers):
if isinstance(h, _CapturingHandler):
_logger.removeHandler(h)
_logger.addHandler(context.log_handler)
# Ensure the logger propagates warnings even if a prior test disabled them.
_logger.setLevel(logging.DEBUG)
_logger.disabled = False
context.warnings_logged = context.log_handler.records
# ---------------------------------------------------------------------------
# Givens
# ---------------------------------------------------------------------------
@given('I have a mock async resource named "{name}"')
def step_create_mock_resource(context, name):
resource = MockAsyncResource(name)
context.resources[name] = resource
context.current_resource = resource
context.current_resource_name = name
@given(
'I have a mock async resource named "{name}" that takes {seconds:g} seconds to close'
)
def step_create_slow_resource(context, name, seconds):
resource = MockAsyncResource(name, close_delay=seconds)
context.resources[name] = resource
context.current_resource = resource
context.current_resource_name = name
@given("I have {count:d} mock async resources with varied close times")
def step_create_multiple_resources(context, count):
context.multi_resources = []
for i in range(count):
name = f"resource-{i}"
resource = MockAsyncResource(name, close_delay=0.01 * (i + 1))
context.resources[name] = resource
context.multi_resources.append((name, resource))
context.tracker.register(name, resource)
@given("I register the resource with the tracker")
def step_register_resource(context):
context.tracker.register(context.current_resource_name, context.current_resource)
@given("I have an async task tracked by the bridge")
def step_create_bridge_task(context):
from cleveragents.langgraph.bridge import RxPyLangGraphBridge
from cleveragents.reactive.stream_router import ReactiveStreamRouter
router = ReactiveStreamRouter()
context.bridge = RxPyLangGraphBridge(router)
context.cancellation_reasons = {}
async def _long_running():
try:
await asyncio.sleep(100)
except asyncio.CancelledError:
return
loop = asyncio.new_event_loop()
async def _setup():
task = asyncio.create_task(_long_running())
context.bridge._active_tasks.add(task)
context.bridge_task = task
loop.run_until_complete(_setup())
context.bridge_loop = loop
@given("I have a state manager with a temporary checkpoint directory")
def step_create_state_manager(context):
context.temp_dir = tempfile.mkdtemp()
context.checkpoint_dir = Path(context.temp_dir)
context.state_manager = StateManager(
initial_state=GraphState(),
checkpoint_dir=context.checkpoint_dir,
)
@given("I have an A2A event queue with {count:d} active subscriptions")
def step_create_event_queue(context, count):
context.event_queue = A2aEventQueue()
context.subscription_ids = []
for _ in range(count):
sub_id = context.event_queue.subscribe_local(lambda _evt: None)
context.subscription_ids.append(sub_id)
@given("I have a bridge with {count:d} slow async tasks")
def step_create_bridge_with_slow_tasks(context, count):
from cleveragents.langgraph.bridge import RxPyLangGraphBridge
from cleveragents.reactive.stream_router import ReactiveStreamRouter
router = ReactiveStreamRouter()
context.bridge = RxPyLangGraphBridge(router)
loop = asyncio.new_event_loop()
async def _setup():
for _ in range(count):
async def _slow():
try:
await asyncio.sleep(100)
except asyncio.CancelledError:
return
task = asyncio.create_task(_slow())
context.bridge._active_tasks.add(task)
loop.run_until_complete(_setup())
context.bridge_loop = loop
# ---------------------------------------------------------------------------
# Whens
# ---------------------------------------------------------------------------
@when("I register the resource with the tracker")
def step_when_register_resource(context):
context.tracker.register(context.current_resource_name, context.current_resource)
@when("I close all tracked resources")
def step_close_all(context):
loop = asyncio.new_event_loop()
loop.run_until_complete(context.tracker.close_all())
loop.close()
@when("I close all tracked resources with a {timeout:g} second timeout")
def step_close_all_with_timeout(context, timeout):
loop = asyncio.new_event_loop()
loop.run_until_complete(context.tracker.close_all(timeout=timeout))
loop.close()
@when("the tracker finalizer runs without close_all")
def step_run_finalizer(context):
context.tracker._warn_unclosed()
@when("I request graceful cleanup with a {timeout:g} second timeout")
def step_graceful_bridge_cleanup(context, timeout):
loop = context.bridge_loop
async def _cleanup():
await context.bridge.cleanup_tasks_async(timeout=timeout)
loop.run_until_complete(_cleanup())
@when('I cancel the task with reason "{reason}"')
def step_cancel_with_reason(context, reason):
loop = context.bridge_loop
async def _cancel():
await context.bridge.cancel_task_with_reason(context.bridge_task, reason)
loop.run_until_complete(_cancel())
context.cancel_reason = reason
@when("I save a checkpoint and then close the state manager")
def step_save_and_close_state_manager(context):
context.state_manager.update_state(
{"messages": [{"role": "user", "content": "test"}]}
)
context.state_manager._save_checkpoint()
context.state_manager.close()
@when("I close the event queue")
def step_close_event_queue(context):
context.event_queue.close()
@when('I try to register another resource named "{name}"')
def step_try_register_duplicate(context, name):
try:
dup_resource = MockAsyncResource(name)
context.tracker.register(name, dup_resource)
context.lsp_error = None
except ValueError as exc:
context.lsp_error = exc
@when("I try to register a resource with an empty name")
def step_try_register_empty_name(context):
try:
context.tracker.register("", MockAsyncResource("empty"))
context.lsp_error = None
except ValueError as exc:
context.lsp_error = exc
@when('I try to register a None resource with name "{name}"')
def step_try_register_none_resource(context, name):
try:
context.tracker.register(name, None) # type: ignore[arg-type]
context.lsp_error = None
except ValueError as exc:
context.lsp_error = exc
@when("I use the tracker as an async context manager and register the resource")
def step_use_context_manager(context):
loop = asyncio.new_event_loop()
async def _run():
async with context.tracker as tracker:
tracker.register(context.current_resource_name, context.current_resource)
loop.run_until_complete(_run())
loop.close()
@when("I close all tracked resources twice")
def step_close_all_twice(context):
loop = asyncio.new_event_loop()
loop.run_until_complete(context.tracker.close_all())
loop.run_until_complete(context.tracker.close_all())
loop.close()
@when("I run enhanced cleanup with a {timeout:g} second timeout")
def step_enhanced_bridge_cleanup(context, timeout):
loop = context.bridge_loop
async def _cleanup():
await context.bridge.cleanup_tasks_async(timeout=timeout)
loop.run_until_complete(_cleanup())
# ---------------------------------------------------------------------------
# Thens
# ---------------------------------------------------------------------------
@then('the resource "{name}" should be closed')
def step_assert_resource_closed(context, name):
resource = context.resources[name]
assert resource.closed, f"Resource '{name}' was not closed"
@then("the tracker should have zero open resources")
def step_assert_zero_open(context):
assert context.tracker.open_count == 0, (
f"Expected 0 open resources, got {context.tracker.open_count}"
)
@then("all {count:d} resources should be closed")
def step_assert_all_closed(context, count):
closed_count = sum(1 for _, r in context.multi_resources if r.closed)
assert closed_count == count, f"Expected {count} closed, got {closed_count}"
@then('a warning should be logged mentioning "{text}"')
def step_assert_warning_logged(context, text):
warnings = [r for r in context.log_handler.records if r.levelno >= logging.WARNING]
matching = [r for r in warnings if text in r.getMessage()]
assert matching, (
f"No warning mentioning '{text}' found. "
f"Warnings: {[r.getMessage() for r in warnings]}"
)
@then("the task should be cancelled")
def step_assert_task_cancelled(context):
task = context.bridge_task
assert task.done(), "Task should be done after cleanup"
@then("the bridge should have no active tasks")
def step_assert_no_active_tasks(context):
assert len(context.bridge._active_tasks) == 0, (
f"Expected 0 active tasks, got {len(context.bridge._active_tasks)}"
)
@then("a warning should be logged about forced termination")
def step_assert_forced_termination_warning(context):
warnings = [r for r in context.log_handler.records if r.levelno >= logging.WARNING]
matching = [
r
for r in warnings
if "timeout" in r.getMessage().lower() or "forced" in r.getMessage().lower()
]
assert matching, (
f"No forced termination warning found. "
f"Warnings: {[r.getMessage() for r in warnings]}"
)
@then("the tracker should report the timed-out resource")
def step_assert_timeout_reported(context):
assert len(context.tracker.timed_out_resources) > 0, (
"Expected at least one timed-out resource"
)
@then('the cancellation reason should be "{reason}"')
def step_assert_cancellation_reason(context, reason):
assert context.bridge.cancellation_reasons.get(context.bridge_task) == reason, (
f"Expected reason '{reason}', "
f"got '{context.bridge.cancellation_reasons.get(context.bridge_task)}'"
)
@then("the checkpoint file should exist and be readable")
def step_assert_checkpoint_exists(context):
checkpoints = list(context.checkpoint_dir.glob("checkpoint_*.json"))
assert len(checkpoints) >= 1, "No checkpoint files found"
content = checkpoints[0].read_text(encoding="utf-8")
assert len(content) > 0, "Checkpoint file is empty"
@then("the state manager should be marked as closed")
def step_assert_state_manager_closed(context):
assert context.state_manager.is_closed, "StateManager should be closed"
@then("all subscriptions should be removed")
def step_assert_subscriptions_removed(context):
assert len(context.event_queue._subscriptions) == 0, (
f"Expected 0 subscriptions, got {len(context.event_queue._subscriptions)}"
)
@then("the subscription count should be zero")
def step_assert_subscription_count_zero(context):
assert len(context.event_queue._subscriptions) == 0
@then('the resource "{name}" should be closed after exiting the context')
def step_assert_closed_after_context(context, name):
resource = context.resources[name]
assert resource.closed, f"Resource '{name}' should be closed after context exit"
@then('the resource "{name}" should be closed exactly once')
def step_assert_closed_once(context, name):
resource = context.resources[name]
assert resource.closed, f"Resource '{name}' was not closed"
assert resource.close_count == 1, (
f"Resource '{name}' was closed {resource.close_count} times, expected 1"
)
@then("completed tasks should be logged")
def step_assert_tasks_logged(context):
infos = [r for r in context.log_handler.records if r.levelno >= logging.INFO]
has_cleanup_log = any(
"cleanup" in r.getMessage().lower() or "task" in r.getMessage().lower()
for r in infos
)
# The cleanup operation should have been logged, and tasks cleared
assert has_cleanup_log or len(context.bridge._active_tasks) == 0
# ---------------------------------------------------------------------------
# Logging capture helper
# ---------------------------------------------------------------------------
class _CapturingHandler(logging.Handler):
"""Captures log records for assertion in tests."""
def __init__(self) -> None:
super().__init__()
self.records: list[logging.LogRecord] = []
def emit(self, record: logging.LogRecord) -> None:
self.records.append(record)
# ---------------------------------------------------------------------------
# Steps for review-feedback scenarios (T1-T4)
# ---------------------------------------------------------------------------
@when('I try to register a resource named "{name}" after close_all')
def step_try_register_after_close(context, name):
try:
context.tracker.register(name, MockAsyncResource(name))
context.runtime_error = None
except RuntimeError as exc:
context.runtime_error = exc
@then('a RuntimeError should be raised mentioning "{text}"')
def step_assert_runtime_error(context, text):
assert context.runtime_error is not None, (
"Expected RuntimeError but none was raised"
)
assert text in str(context.runtime_error), (
f"Expected '{text}' in error message, got: {context.runtime_error}"
)
@when("I close the state manager")
def step_close_state_manager(context):
context.state_manager.close()
@when("I try to update state after close")
def step_try_update_after_close(context):
try:
context.state_manager.update_state(
{"messages": [{"role": "user", "content": "x"}]}
)
context.runtime_error = None
except RuntimeError as exc:
context.runtime_error = exc
@when("I try to reset state after close")
def step_try_reset_after_close(context):
try:
context.state_manager.reset()
context.runtime_error = None
except RuntimeError as exc:
context.runtime_error = exc
@when("I try to publish an event after close")
def step_try_publish_after_close(context):
from cleveragents.a2a.models import A2aEvent
try:
context.event_queue.publish(A2aEvent(event_type="test", data={}))
context.runtime_error = None
except RuntimeError as exc:
context.runtime_error = exc
@then("the event queue is_closed should be False")
def step_assert_queue_not_closed(context):
assert not context.event_queue.is_closed, "Expected is_closed=False"
@then("the event queue is_closed should be True")
def step_assert_queue_closed(context):
assert context.event_queue.is_closed, "Expected is_closed=True"