Port `test_cancellation` multierror cluster off `run_in_actor`
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-codedrop_ria_nursery
parent
efcac594c1
commit
ba4af0ad1a
|
|
@ -16,6 +16,7 @@ from tractor._testing import (
|
||||||
tractor_test,
|
tractor_test,
|
||||||
)
|
)
|
||||||
from tractor._testing.trace import FailAfterWTraceFactory
|
from tractor._testing.trace import FailAfterWTraceFactory
|
||||||
|
from tractor.trionics import gather_contexts
|
||||||
from .conftest import no_windows
|
from .conftest import no_windows
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -68,6 +69,23 @@ async def assert_err(delay=0):
|
||||||
assert 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():
|
async def sleep_forever():
|
||||||
await trio.sleep_forever()
|
await trio.sleep_forever()
|
||||||
|
|
||||||
|
|
@ -106,22 +124,17 @@ def test_remote_error(
|
||||||
registry_addrs=[reg_addr],
|
registry_addrs=[reg_addr],
|
||||||
) as nursery:
|
) as nursery:
|
||||||
|
|
||||||
# on a remote type error caused by bad input args
|
# `to_actor.run()` blocks on the one-shot's result and
|
||||||
# this should raise directly which means we **don't** get
|
# raises the remote error directly here in the caller's
|
||||||
# an exception group outside the nursery since the error
|
# task (a bad-arg `TypeError` likewise relays as a
|
||||||
# here and the far end task error are one in the same?
|
# `RemoteActorError`).
|
||||||
portal = await nursery.run_in_actor(
|
try:
|
||||||
|
await tractor.to_actor.run(
|
||||||
assert_err,
|
assert_err,
|
||||||
|
an=nursery,
|
||||||
name='errorer',
|
name='errorer',
|
||||||
**args
|
**args
|
||||||
)
|
)
|
||||||
|
|
||||||
# get result(s) from main task
|
|
||||||
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()
|
|
||||||
except tractor.RemoteActorError as err:
|
except tractor.RemoteActorError as err:
|
||||||
assert err.boxed_type == errtype
|
assert err.boxed_type == errtype
|
||||||
print("Look Maa that actor failed hard, hehh")
|
print("Look Maa that actor failed hard, hehh")
|
||||||
|
|
@ -162,113 +175,48 @@ def test_multierror(
|
||||||
set_fork_aware_capture, #: Callable,
|
set_fork_aware_capture, #: Callable,
|
||||||
):
|
):
|
||||||
'''
|
'''
|
||||||
Verify we raise a ``BaseExceptionGroup`` out of a nursery where
|
Verify concurrent one-shot subactors erroring propagate a remote
|
||||||
more then one actor errors.
|
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 def main():
|
||||||
async with tractor.open_nursery(
|
async with tractor.open_nursery(
|
||||||
registry_addrs=[reg_addr],
|
registry_addrs=[reg_addr],
|
||||||
) as nursery:
|
) as an:
|
||||||
|
|
||||||
await nursery.run_in_actor(assert_err, name='errorer1')
|
portals = [
|
||||||
portal2 = await nursery.run_in_actor(assert_err, name='errorer2')
|
await an.start_actor(
|
||||||
|
f'errorer{i}',
|
||||||
# get result(s) from main task
|
enable_modules=[__name__],
|
||||||
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
|
|
||||||
|
|
||||||
# here we should get a ``BaseExceptionGroup`` containing exceptions
|
|
||||||
# from both subactors
|
|
||||||
|
|
||||||
with pytest.raises(BaseExceptionGroup):
|
|
||||||
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
|
|
||||||
)
|
)
|
||||||
|
for i in range(2)
|
||||||
|
]
|
||||||
|
|
||||||
# with pytest.raises(trio.MultiError) as exc_info:
|
# both one-shot subactors error concurrently, so the
|
||||||
# NOTE, `trio.TooSlowError` from `fail_after_w_trace`
|
# `gather_contexts()` task-nursery collects them into a
|
||||||
# bubbles UN-wrapped if `open_nursery.__aexit__` never
|
# `BaseExceptionGroup` (was two non-blocking
|
||||||
# gets re-entered; wrapped inside a `BaseExceptionGroup`
|
# `run_in_actor()`s reaped at nursery teardown).
|
||||||
# if it did. Accept both shapes so the matcher itself
|
async with gather_contexts(
|
||||||
# doesn't lie about *what* failed.
|
mngrs=[
|
||||||
with pytest.raises(
|
p.open_context(assert_err_ctx)
|
||||||
(BaseExceptionGroup, trio.TooSlowError),
|
for p in portals
|
||||||
) as exc_info:
|
],
|
||||||
|
):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises((
|
||||||
|
BaseExceptionGroup,
|
||||||
|
tractor.RemoteActorError,
|
||||||
|
)):
|
||||||
trio.run(main)
|
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():
|
async def do_nothing():
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue