diff --git a/poetry.lock b/poetry.lock index 08dbfd5f..9215b275 100644 --- a/poetry.lock +++ b/poetry.lock @@ -40,6 +40,21 @@ files = [ {file = "backports_asyncio_runner-1.2.0.tar.gz", hash = "sha256:a5aa7b2b7d8f8bfcaa2b57313f70792df84e32a2a746f585213373f900b42162"}, ] +[[package]] +name = "blockbuster" +version = "1.5.26" +description = "Utility to detect blocking calls in the async event loop" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "blockbuster-1.5.26-py3-none-any.whl", hash = "sha256:f8e53fb2dd4b6c6ec2f04907ddbd063ca7cd1ef587d24448ef4e50e81e3a79bb"}, + {file = "blockbuster-1.5.26.tar.gz", hash = "sha256:cc3ce8c70fa852a97ee3411155f31e4ad2665cd1c6c7d2f8bb1851dab61dc629"}, +] + +[package.dependencies] +forbiddenfruit = {version = ">=0.1.4", markers = "implementation_name == \"cpython\""} + [[package]] name = "certifi" version = "2025.1.31" @@ -348,6 +363,18 @@ files = [ [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "forbiddenfruit" +version = "0.1.4" +description = "Patch python built-in objects" +optional = false +python-versions = "*" +groups = ["dev"] +markers = "implementation_name == \"cpython\"" +files = [ + {file = "forbiddenfruit-0.1.4.tar.gz", hash = "sha256:e3f7e66561a29ae129aac139a85d610dbf3dd896128187ed5454b6421f624253"}, +] + [[package]] name = "idna" version = "3.15" @@ -1003,7 +1030,7 @@ version = "2.7.0" description = "HTTP library with thread-safe connection pooling, file post, and more." optional = false python-versions = ">=3.10" -groups = ["dev", "docs"] +groups = ["docs"] files = [ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"}, {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"}, @@ -1018,4 +1045,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.10" -content-hash = "d5c5962b015fd229695b0a8024a848e7f11878a3efc36cb9a48c2279f48a9777" +content-hash = "c0d62f11cb94761d233c73a46ff3f626640ae3ef3af9a2ece9f37095a47d4c71" diff --git a/pyproject.toml b/pyproject.toml index b63728c6..94e4a08f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ cython = "^3.2.4" setuptools = ">=65.6.3,<83.0.0" pytest-timeout = "^2.1.0" pytest-codspeed = ">=5.0.2,<6.0" +blockbuster = ">=1.5.5,<2.0.0" [tool.poetry.group.docs.dependencies] sphinx = "^7.4.7 || ^8.1.3" diff --git a/tests/conftest.py b/tests/conftest.py index 1a76efe9..573b9394 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from __future__ import annotations import threading -from collections.abc import AsyncGenerator, Generator +from collections.abc import AsyncGenerator, Generator, Iterator from unittest.mock import patch import pytest @@ -15,6 +15,64 @@ from zeroconf._services import info as service_info from zeroconf.asyncio import AsyncZeroconf +try: + from blockbuster import BlockBuster, blockbuster_ctx +except ImportError: # platforms without blockbuster (e.g. PyPy under QEMU) + BlockBuster = None # type: ignore[assignment,misc] + blockbuster_ctx = None # type: ignore[assignment] + +_BENCHMARKS_DIR = "tests/benchmarks" + +# Tests that perform sync IO inside the asyncio event loop and trip +# blockbuster. Marked xfail (strict=False) so CI stays green; pop +# entries as the underlying blocking calls get fixed. Most of the +# `test_async_service_registration*` and `test_async_tasks` entries +# share a single root cause: `Zeroconf.async_close()` -> ... -> +# `ServiceBrowser.cancel()` calls `Thread.join()` to drain the +# dedicated browser thread, and on Python 3.10-3.12 the thread is +# still alive when the join happens. `test_use_asyncio_false_*` is +# by design (sync bootstrap when `use_asyncio=False` is requested from +# inside a running loop); `test_run_coro_with_timeout` exercises the +# sync-from-thread bridge intentionally. The strict=False marker keeps +# the suite green on the Python versions where the race resolves the +# other way. +_KNOWN_BLOCKING: frozenset[str] = frozenset( + { + "tests/test_asyncio.py::test_async_service_registration", + "tests/test_asyncio.py::test_async_service_registration_with_server_missing", + "tests/test_asyncio.py::test_async_service_registration_same_server_different_ports", + "tests/test_asyncio.py::test_async_service_registration_same_server_same_ports", + "tests/test_asyncio.py::test_async_tasks", + "tests/test_core.py::Framework::test_use_asyncio_false_forces_thread_when_loop_running", + "tests/utils/test_asyncio.py::test_run_coro_with_timeout", + } +) + + +def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None: + """Mark known-blocking tests xfail so blockbuster doesn't fail the suite.""" + if blockbuster_ctx is None: + return + marker = pytest.mark.xfail( + reason="blockbuster: blocking call in asyncio path", + strict=False, + ) + for item in items: + if item.nodeid in _KNOWN_BLOCKING: + item.add_marker(marker) + + +@pytest.fixture(autouse=True) +def blockbuster( + request: pytest.FixtureRequest, +) -> Iterator[BlockBuster | None]: + """Fail any test that performs a blocking call inside the asyncio loop.""" + if blockbuster_ctx is None or _BENCHMARKS_DIR in str(request.node.fspath): + yield None + return + with blockbuster_ctx() as bb: + yield bb + @pytest.fixture(autouse=True) def verify_threads_ended():