Files
cleveragents-core/features/steps/security_async_steps.py
T
khyari hamza 5d5d3fde2c fix(security): close async resources and leaks
Add AsyncResourceTracker (core/async_cleanup.py) providing a central
registry for async resources with timeout-bounded close_all(), async
context manager support, and a __del__ finalizer that logs leaked
resources by name.

Enhance LangGraphBridge with cleanup_tasks_async() that awaits
in-flight tasks with a deadline instead of fire-and-forget cancel().
Add cancellation_reasons dict to trace why tasks were cancelled.

Add StateManager.close() to properly release checkpoint file handles
and complete the RxPY BehaviorSubject. Add AcpEventQueue.close() to
dispose all subscriptions.

Includes 14 Behave scenarios (67 steps), Robot integration tests,
ASV benchmarks, and docs/reference/async_safety.md.

ISSUES CLOSED: #321
2026-02-26 14:30:43 +00:00

422 lines
14 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.acp.events import AcpEventQueue
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:
await asyncio.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()
logging.getLogger("cleveragents.core.async_cleanup").addHandler(context.log_handler)
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 ACP event queue with {count:d} active subscriptions")
def step_create_event_queue(context, count):
context.event_queue = AcpEventQueue()
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)