diff --git a/docs/reference/changelog.rst b/docs/reference/changelog.rst index 4897188c..92e61835 100644 --- a/docs/reference/changelog.rst +++ b/docs/reference/changelog.rst @@ -9,10 +9,16 @@ Changelog - Added ``typing-extensions`` as additional dependency for Python ``<3.10`` `#1045 `_ +0.25.3 (2025-01-28) +=================== +- Avoid errors in cleanup of async generators when event loop is already closed `#1040 `_ + + 0.25.2 (2025-01-08) =================== - Call ``loop.shutdown_asyncgens()`` before closing the event loop to ensure async generators are closed in the same manner as ``asyncio.run`` does `#1034 `_ + 0.25.1 (2025-01-02) =================== - Fixes an issue that caused a broken event loop when a function-scoped test was executed in between two tests with wider loop scope `#950 `_ @@ -28,7 +34,6 @@ Changelog - Propagates `contextvars` set in async fixtures to other fixtures and tests on Python 3.11 and above. `#1008 `_ - 0.24.0 (2024-08-22) =================== - BREAKING: Updated minimum supported pytest version to v8.2.0 diff --git a/pytest_asyncio/plugin.py b/pytest_asyncio/plugin.py index 2ec504ff..8a1d8733 100644 --- a/pytest_asyncio/plugin.py +++ b/pytest_asyncio/plugin.py @@ -1190,10 +1190,14 @@ def _provide_event_loop() -> Iterator[asyncio.AbstractEventLoop]: try: yield loop finally: - try: - loop.run_until_complete(loop.shutdown_asyncgens()) - finally: - loop.close() + # cleanup the event loop if it hasn't been cleaned up already + if not loop.is_closed(): + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + except Exception as e: + warnings.warn(f"Error cleaning up asyncio loop: {e}", RuntimeWarning) + finally: + loop.close() @pytest.fixture(scope="session") diff --git a/tests/test_event_loop_fixture.py b/tests/test_event_loop_fixture.py index 447d15d5..5417a14d 100644 --- a/tests/test_event_loop_fixture.py +++ b/tests/test_event_loop_fixture.py @@ -80,3 +80,61 @@ async def generator_fn(): ) result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W", "default") result.assert_outcomes(passed=1, warnings=0) + + +def test_event_loop_already_closed( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + import pytest_asyncio + pytest_plugins = 'pytest_asyncio' + + @pytest_asyncio.fixture + async def _event_loop(): + return asyncio.get_running_loop() + + @pytest.fixture + def cleanup_after(_event_loop): + yield + # fixture has its own cleanup code + _event_loop.close() + + @pytest.mark.asyncio + async def test_something(cleanup_after): + await asyncio.sleep(0.01) + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W", "default") + result.assert_outcomes(passed=1, warnings=0) + + +def test_event_loop_fixture_asyncgen_error( + pytester: Pytester, +): + pytester.makeini("[pytest]\nasyncio_default_fixture_loop_scope = function") + pytester.makepyfile( + dedent( + """\ + import asyncio + import pytest + + pytest_plugins = 'pytest_asyncio' + + @pytest.mark.asyncio + async def test_something(): + # mock shutdown_asyncgen failure + loop = asyncio.get_running_loop() + async def fail(): + raise RuntimeError("mock error cleaning up...") + loop.shutdown_asyncgens = fail + """ + ) + ) + result = pytester.runpytest_subprocess("--asyncio-mode=strict", "-W", "default") + result.assert_outcomes(passed=1, warnings=1)