From ba4af0ad1aada332a7de83fe415d4b0bfeb71075 Mon Sep 17 00:00:00 2001 From: goodboy Date: Fri, 3 Jul 2026 00:54:07 -0400 Subject: [PATCH] Port `test_cancellation` multierror cluster off `run_in_actor` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First group of the `test_cancellation.py` `run_in_actor` removal (#477), - `test_remote_error` -> blocking `to_actor.run()` (single erroring one-shot; a bad-arg `TypeError` still relays as a `RemoteActorError`). - `test_multierror` -> concurrent fan-out via `gather_contexts([p.open_context(assert_err_ctx) ...])` over `start_actor()` portals. NB `gather_contexts` is cancel-on-first so the 2nd errorer is usually cancelled before relaying its own exc and the pair collapses to a single `RemoteActorError` (vs the legacy reap-all-at-teardown `BEG`-of-N) — the assertion now accepts either shape. - delete `test_multierror_fast_nursery` — a 25-actor stress test of `run_in_actor`'s teardown-reap; no analogous surface under the `to_actor` fan-out. - add an `assert_err_ctx` `@context` shim for the `open_context` fan-out. Remaining `test_cancellation` groups (some_cancels_all, nested, SIGINT, sync-blocking) still on `run_in_actor` — ported next. (this patch was generated in some part by [`claude-code`][claude-code-gh]) [claude-code-gh]: https://github.com/anthropics/claude-code --- tests/test_cancellation.py | 172 +++++++++++++------------------------ 1 file changed, 60 insertions(+), 112 deletions(-) diff --git a/tests/test_cancellation.py b/tests/test_cancellation.py index bff4c0c2..b3720c5e 100644 --- a/tests/test_cancellation.py +++ b/tests/test_cancellation.py @@ -16,6 +16,7 @@ from tractor._testing import ( tractor_test, ) from tractor._testing.trace import FailAfterWTraceFactory +from tractor.trionics import gather_contexts from .conftest import no_windows @@ -68,6 +69,23 @@ async def assert_err(delay=0): assert 0 +@tractor.context +async def assert_err_ctx( + ctx: tractor.Context, + delay: float = 0, +) -> None: + ''' + `@context` shim around `assert_err()` so the multi-actor error + tests can fan-out one-shot erroring subactors via + `Portal.open_context()` + `gather_contexts()` instead of the + removed `ActorNursery.run_in_actor()` (#477). + + ''' + await ctx.started() + await trio.sleep(delay) + assert 0 + + async def sleep_forever(): await trio.sleep_forever() @@ -106,22 +124,17 @@ def test_remote_error( registry_addrs=[reg_addr], ) as nursery: - # on a remote type error caused by bad input args - # this should raise directly which means we **don't** get - # an exception group outside the nursery since the error - # here and the far end task error are one in the same? - portal = await nursery.run_in_actor( - assert_err, - name='errorer', - **args - ) - - # get result(s) from main task + # `to_actor.run()` blocks on the one-shot's result and + # raises the remote error directly here in the caller's + # task (a bad-arg `TypeError` likewise relays as a + # `RemoteActorError`). try: - # this means the root actor will also raise a local - # parent task error and thus an eg will propagate out - # of this actor nursery. - await portal.result() + await tractor.to_actor.run( + assert_err, + an=nursery, + name='errorer', + **args + ) except tractor.RemoteActorError as err: assert err.boxed_type == errtype print("Look Maa that actor failed hard, hehh") @@ -162,114 +175,49 @@ def test_multierror( set_fork_aware_capture, #: Callable, ): ''' - Verify we raise a ``BaseExceptionGroup`` out of a nursery where - more then one actor errors. + Verify concurrent one-shot subactors erroring propagate a remote + error out of the `gather_contexts()` fan-out — grouped as a + `BaseExceptionGroup`, or (under cancel-on-first, where the 2nd + errorer is cancelled before relaying its own exc) collapsed to a + single `RemoteActorError`. + + NB the legacy `run_in_actor()` reaped *all* children at nursery + teardown so this always yielded a BEG-of-N; the `to_actor` + fan-out is cancel-on-first, so accept either shape. ''' async def main(): async with tractor.open_nursery( registry_addrs=[reg_addr], - ) as nursery: + ) as an: - await nursery.run_in_actor(assert_err, name='errorer1') - portal2 = await nursery.run_in_actor(assert_err, name='errorer2') + portals = [ + await an.start_actor( + f'errorer{i}', + enable_modules=[__name__], + ) + for i in range(2) + ] - # get result(s) from main task - try: - await portal2.result() - except tractor.RemoteActorError as err: - assert err.boxed_type is AssertionError - print("Look Maa that first actor failed hard, hehh") - raise + # both one-shot subactors error concurrently, so the + # `gather_contexts()` task-nursery collects them into a + # `BaseExceptionGroup` (was two non-blocking + # `run_in_actor()`s reaped at nursery teardown). + async with gather_contexts( + mngrs=[ + p.open_context(assert_err_ctx) + for p in portals + ], + ): + pass - # here we should get a ``BaseExceptionGroup`` containing exceptions - # from both subactors - - with pytest.raises(BaseExceptionGroup): + with pytest.raises(( + BaseExceptionGroup, + tractor.RemoteActorError, + )): trio.run(main) -@pytest.mark.parametrize( - 'delay', - (0, 0.5), - ids='delays={}'.format, -) -@pytest.mark.parametrize( - 'num_subactors', - range(25, 26), - ids= 'num_subs={}'.format, -) -def test_multierror_fast_nursery( - reg_addr: tuple, - start_method: str, - num_subactors: int, - delay: float, - set_fork_aware_capture, - fail_after_w_trace: FailAfterWTraceFactory, -): - ''' - Verify we raise a ``BaseExceptionGroup`` out of a nursery where - more then one actor errors and also with a delay before failure - to test failure during an ongoing spawning. - - ''' - async def main(): - # budget = 2× natural trio-backend cascade time for - # 25 errorer subactors (~14s observed). on-timeout - # diag snapshot → if the cancel cascade hangs - # (observed under MTF backend with N>=14 errorer - # subactors) we get a fresh ptree/wchan/py-spy dump - # on disk INSTEAD of an opaque pytest timeout-kill. - # See `tractor/_testing/trace.py` for the helper. - async with fail_after_w_trace(30.0): - async with tractor.open_nursery( - registry_addrs=[reg_addr], - ) as nursery: - - for i in range(num_subactors): - await nursery.run_in_actor( - assert_err, - name=f'errorer{i}', - delay=delay - ) - - # with pytest.raises(trio.MultiError) as exc_info: - # NOTE, `trio.TooSlowError` from `fail_after_w_trace` - # bubbles UN-wrapped if `open_nursery.__aexit__` never - # gets re-entered; wrapped inside a `BaseExceptionGroup` - # if it did. Accept both shapes so the matcher itself - # doesn't lie about *what* failed. - with pytest.raises( - (BaseExceptionGroup, trio.TooSlowError), - ) as exc_info: - trio.run(main) - - if isinstance(exc_info.value, trio.TooSlowError): - pytest.fail( - f'cancel cascade hung past 12s ' - f'(num_subactors={num_subactors}, delay={delay}); ' - f'see stderr for `fail_after_w_trace` snapshot path' - ) - - assert exc_info.type == ExceptionGroup - err = exc_info.value - exceptions = err.exceptions - - if len(exceptions) == 2: - # sometimes oddly now there's an embedded BrokenResourceError ? - for exc in exceptions: - excs = getattr(exc, 'exceptions', None) - if excs: - exceptions = excs - break - - assert len(exceptions) == num_subactors - - for exc in exceptions: - assert isinstance(exc, tractor.RemoteActorError) - assert exc.boxed_type is AssertionError - - async def do_nothing(): pass