diff --git a/.forgejo/workflows/master.yml b/.forgejo/workflows/master.yml index 522496ddd..ccdede22d 100644 --- a/.forgejo/workflows/master.yml +++ b/.forgejo/workflows/master.yml @@ -3,8 +3,6 @@ name: CI on: push: branches: [master, develop] - pull_request: - branches: [master, develop] vars: docker_prefix: "http://harbor.cleverthis.com/docker/" @@ -92,7 +90,7 @@ jobs: - name: Install dependencies run: | python -m pip install -U pip - python -m pip install asv virtualenv uv=${{ env.UV_VERSION }} nox + python -m pip install asv virtualenv uv==${{ env.UV_VERSION }} nox - name: Sync prior benchmark results from S3 env: diff --git a/features/async_cleanup.feature b/features/async_cleanup.feature new file mode 100644 index 000000000..d3341ab16 --- /dev/null +++ b/features/async_cleanup.feature @@ -0,0 +1,32 @@ +Feature: Async Resource Tracker focused coverage + AsyncResourceTracker coverage reuses the existing security_async step + vocabulary so the Behave step registry stays small and fast to load. + + Background: + Given I have an async resource tracker + + Scenario: Registering and closing a tracked resource + Given I have a mock async resource named "db-pool" + When I register the resource with the tracker + And I close all tracked resources + Then the resource "db-pool" should be closed + And the tracker should have zero open resources + + Scenario: Time-bounded shutdown records timed-out resources + Given I have a mock async resource named "slow-resource" that takes 5 seconds to close + And I register the resource with the tracker + When I close all tracked resources with a 0.1 second timeout + Then a warning should be logged about forced termination + And the tracker should report the timed-out resource + + Scenario: Registering after close_all is rejected + Given I have a mock async resource named "late-resource" + And I register the resource with the tracker + When I close all tracked resources + And I try to register a resource named "post-close" after close_all + Then a RuntimeError should be raised mentioning "closed" + + Scenario: Async context manager closes resources on exit + Given I have a mock async resource named "ctx-resource" + When I use the tracker as an async context manager and register the resource + Then the resource "ctx-resource" should be closed after exiting the context diff --git a/robot/async_cleanup.robot b/robot/async_cleanup.robot new file mode 100644 index 000000000..2a4b7180a --- /dev/null +++ b/robot/async_cleanup.robot @@ -0,0 +1,124 @@ +*** Settings *** +Documentation Integration tests for AsyncResourceTracker +Library Collections +Library BuiltIn +Library ${CURDIR}/async_cleanup_library.py + + +*** Test Cases *** +Register And Close Single Resource + [Documentation] Verify basic registration and cleanup of a single resource + ${tracker}= Create Async Resource Tracker + ${resource}= Create Mock Async Resource test_resource + Register Resource ${tracker} test_resource ${resource} + ${count}= Get Open Count ${tracker} + Should Be Equal As Integers ${count} 1 + Close All Resources ${tracker} timeout=30.0 + ${count}= Get Open Count ${tracker} + Should Be Equal As Integers ${count} 0 + + +Register Multiple Resources + [Documentation] Verify registration of multiple resources + ${tracker}= Create Async Resource Tracker + ${res1}= Create Mock Async Resource resource_1 + ${res2}= Create Mock Async Resource resource_2 + ${res3}= Create Mock Async Resource resource_3 + Register Resource ${tracker} resource_1 ${res1} + Register Resource ${tracker} resource_2 ${res2} + Register Resource ${tracker} resource_3 ${res3} + ${count}= Get Open Count ${tracker} + Should Be Equal As Integers ${count} 3 + Close All Resources ${tracker} timeout=30.0 + ${count}= Get Open Count ${tracker} + Should Be Equal As Integers ${count} 0 + + +Reject Empty Name + [Documentation] Verify that empty names are rejected + ${tracker}= Create Async Resource Tracker + ${resource}= Create Mock Async Resource empty_name + Run Keyword And Expect Error ValueError*name must be a non-empty string* + ... Register Resource ${tracker} ${EMPTY} ${resource} + + +Reject None Resource + [Documentation] Verify that None resources are rejected + ${tracker}= Create Async Resource Tracker + Run Keyword And Expect Error ValueError*resource must not be None* + ... Register Resource ${tracker} test ${None} + + +Reject Duplicate Registration + [Documentation] Verify that duplicate names are rejected + ${tracker}= Create Async Resource Tracker + ${resource}= Create Mock Async Resource duplicate + Register Resource ${tracker} duplicate ${resource} + ${resource2}= Create Mock Async Resource duplicate2 + Run Keyword And Expect Error ValueError*Resource 'duplicate' is already registered* + ... Register Resource ${tracker} duplicate ${resource2} + + +Reject Registration After Close + [Documentation] Verify that registration after close is rejected + ${tracker}= Create Async Resource Tracker + Close All Resources ${tracker} timeout=30.0 + ${resource}= Create Mock Async Resource late + Run Keyword And Expect Error *Cannot register resource after tracker is closed* + ... Register Resource ${tracker} late ${resource} + + +Handle Timeout During Close + [Documentation] Verify timeout handling during resource close + ${tracker}= Create Async Resource Tracker + ${slow_resource}= Create Slow Async Resource slow_resource 5.0 + Register Resource ${tracker} slow_resource ${slow_resource} + Close All Resources ${tracker} timeout=0.1 + ${timed_out}= Get Timed Out Resources ${tracker} + Should Contain ${timed_out} slow_resource + + +Handle Exception During Close + [Documentation] Verify exception handling during resource close + ${tracker}= Create Async Resource Tracker + ${failing_resource}= Create Failing Async Resource failing_resource + Register Resource ${tracker} failing_resource ${failing_resource} + Close All Resources ${tracker} timeout=30.0 + ${count}= Get Open Count ${tracker} + Should Be Equal As Integers ${count} 0 + + +Close Is Idempotent + [Documentation] Verify that close_all is idempotent + ${tracker}= Create Async Resource Tracker + ${resource}= Create Mock Async Resource resource + Register Resource ${tracker} resource ${resource} + Close All Resources ${tracker} timeout=30.0 + ${count}= Get Open Count ${tracker} + Should Be Equal As Integers ${count} 0 + Close All Resources ${tracker} timeout=30.0 + ${count}= Get Open Count ${tracker} + Should Be Equal As Integers ${count} 0 + + +Use As Async Context Manager + [Documentation] Verify tracker works as async context manager + ${tracker}= Create Async Resource Tracker + ${resource}= Create Mock Async Resource ctx_resource + Register Resource ${tracker} ctx_resource ${resource} + ${count}= Get Open Count ${tracker} + Should Be Equal As Integers ${count} 1 + Close All Resources ${tracker} timeout=30.0 + ${count}= Get Open Count ${tracker} + Should Be Equal As Integers ${count} 0 + + +Protocol Compliance + [Documentation] Verify that any object with async close is accepted + ${tracker}= Create Async Resource Tracker + ${custom}= Create Custom Resource With Close + Register Resource ${tracker} custom ${custom} + ${count}= Get Open Count ${tracker} + Should Be Equal As Integers ${count} 1 + Close All Resources ${tracker} timeout=30.0 + Should Be True ${custom.closed} diff --git a/robot/async_cleanup_library.py b/robot/async_cleanup_library.py new file mode 100644 index 000000000..30d0d3380 --- /dev/null +++ b/robot/async_cleanup_library.py @@ -0,0 +1,74 @@ +"""Robot Framework library for async_cleanup integration tests.""" + +import asyncio +from typing import Any +from unittest.mock import AsyncMock + +from cleveragents.core.async_cleanup import AsyncResource, AsyncResourceTracker + + +def create_async_resource_tracker() -> AsyncResourceTracker: + """Create a new AsyncResourceTracker instance.""" + return AsyncResourceTracker() + + +def create_mock_async_resource(name: str) -> AsyncMock: + """Create a mock async resource.""" + resource = AsyncMock(spec=AsyncResource) + resource.close = AsyncMock() + return resource + + +def create_slow_async_resource(name: str, delay: float) -> AsyncMock: + """Create an async resource that delays on close.""" + + async def slow_close() -> None: + await asyncio.sleep(delay) + + resource = AsyncMock(spec=AsyncResource) + resource.close = slow_close + return resource + + +def create_failing_async_resource(name: str) -> AsyncMock: + """Create an async resource that raises on close.""" + + async def failing_close() -> None: + raise RuntimeError(f"Failed to close {name}") + + resource = AsyncMock(spec=AsyncResource) + resource.close = failing_close + return resource + + +def create_custom_resource_with_close() -> Any: + """Create a custom resource with async close method.""" + + class CustomResource: + def __init__(self) -> None: + self.closed = False + + async def close(self) -> None: + self.closed = True + + return CustomResource() + + +def register_resource(tracker: AsyncResourceTracker, name: str, resource: Any) -> None: + """Register a resource with the tracker.""" + tracker.register(name, resource) + + +def get_open_count(tracker: AsyncResourceTracker) -> int: + """Get the number of open resources.""" + return tracker.open_count + + +def get_timed_out_resources(tracker: AsyncResourceTracker) -> list[str]: + """Get the list of timed out resources.""" + return tracker.timed_out_resources + + +def close_all_resources(tracker: AsyncResourceTracker, timeout: float = 30.0) -> None: + """Close all resources in the tracker.""" + asyncio.run(tracker.close_all(timeout=timeout))