From b376eb033262a45305cec72b896996f750c004a8 Mon Sep 17 00:00:00 2001 From: goodboy Date: Mon, 27 Apr 2026 21:41:02 -0400 Subject: [PATCH] Add opt-in `reap_subactors_per_test` fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Function-scoped, NON-autouse zombie-subactor reaper for modules whose teardown is known-leaky enough to cascade- fail every following test in a session. Sibling to the autouse session-scoped `_reap_orphaned_subactors`. The session-scoped one fires at session end — too late to save tests that follow a hung/leaky test in the suite. The new fixture, opted into via `pytestmark = pytest.mark.usefixtures(...)`, runs between tests in a problem-module so a leftover subactor from test N can't squat on registrar ports / UDS paths / shm segments needed by tests N+1, N+2, ... Intentionally NOT autouse — the fixture's presence on a module signals "this module's teardown leaks; please root-cause instead of relying forever on cleanup". A visibility-vs-convenience trade picked in favor of the former. Apply to `tests/test_infected_asyncio.py` since both recent full-suite runs (parallel-tpt-proto + TCP-only) showed the cascade originating in this file's KBI- and SIGINT-flavored tests under `main_thread_forkserver`. Module-comment names the specific offenders so future de-flake work has a starting point. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_infected_asyncio.py | 16 +++++++++++ tractor/_testing/pytest.py | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+) 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,