diff --git a/tests/test_infected_asyncio.py b/tests/test_infected_asyncio.py index d3524a6b..8157e6d4 100644 --- a/tests/test_infected_asyncio.py +++ b/tests/test_infected_asyncio.py @@ -32,6 +32,22 @@ from tractor.trionics import BroadcastReceiver from tractor._testing import expect_ctxc +# Per-test zombie-subactor reaper. Opt-in (NOT autouse) — +# see `tractor._testing.pytest.reap_subactors_per_test`'s +# docstring for the full rationale. This module specifically +# needs it because tests like +# `test_echoserver_detailed_mechanics[KeyboardInterrupt]` +# and the `test_sigint_closes_lifetime_stack[*]` matrix have +# been observed to hang past pytest's wall-clock under +# `main_thread_forkserver`, leaving subactor forks that +# squat on registrar resources and cascade-fail every +# subsequent test (`test_inter_peer_cancellation`, +# `test_legacy_one_way_streaming`, etc.). +pytestmark = pytest.mark.usefixtures( + 'reap_subactors_per_test', +) + + @pytest.fixture( scope='module', ) diff --git a/tractor/_testing/pytest.py b/tractor/_testing/pytest.py index 801ecafc..bb2e98dc 100644 --- a/tractor/_testing/pytest.py +++ b/tractor/_testing/pytest.py @@ -327,6 +327,55 @@ def _reap_orphaned_subactors(): reap(pids, grace=3.0) +@pytest.fixture +def reap_subactors_per_test(): + ''' + Per-test (function-scoped) zombie-subactor reaper — + **opt-in**, NOT autouse. + + When a test's teardown fails to fully cancel its actor + tree (e.g. an asyncio cancel-cascade times out under + `main_thread_forkserver`, pytest hits its 200s wall- + clock and abandons), the leftover subactor lingers as a + direct child of `pytest` and squats on whatever + registrar port / UDS path / shm segment it had bound. + Subsequent tests trying to allocate the same resource + fail — and with backends that bind a session-shared + `reg_addr`, that means EVERY following test in the + suite cascades. The session-scoped sibling + (`_reap_orphaned_subactors`) only kicks in at session + end which is too late to save the cascade. + + Apply at module-level on the topically-problematic + test files via: + + ```python + pytestmark = pytest.mark.usefixtures( + 'reap_subactors_per_test', + ) + ``` + + Or per-test via the same `usefixtures` mark on a + specific function. Intentionally NOT autouse so the + fixture's presence on a module signals "this module's + teardown is known-leaky enough to contaminate + siblings"; the visibility helps future-us track down + root causes rather than burying them under blanket + cleanup. + + ''' + import os + parent_pid: int = os.getpid() + yield + from tractor._testing._reap import ( + find_descendants, + reap, + ) + pids: list[int] = find_descendants(parent_pid) + if pids: + reap(pids, grace=3.0) + + @pytest.fixture(scope='session') def debug_mode( request: pytest.FixtureRequest,