Port `test_nested_multierrors` off `run_in_actor`

Third `test_cancellation.py` group of the `run_in_actor` removal
(#477),

- `spawn_and_error` fans out each level's erroring one-shots as
  concurrent `to_actor.run(fn, an=an)` tasks in a local `trio`
  task-nursery (recursing per spawner subactor), as does the
  test-body's top-level spawner loop.
- the deterministic exact-breadth nested-BEG shape dies with the
  legacy teardown-reap: each level now groups whatever subset of
  sub-tree errors relay before the first one's cancel wins, and
  a single-member group gets unwrapped by the runtime's own
  `collapse_eg()` at every actor boundary — so a fully-raced
  tree relays a bare `RemoteActorError` chain.
- loosen the shape walk accordingly: accept a lone
  `RemoteActorError` or a 1..breadth group whose members box
  `ExceptionGroup` (multi-relay), `AssertionError` (collapsed
  leaf chain), `RemoteActorError` (re-boxed collapsed chain) or
  `BaseExceptionGroup` (runtime reap-deadline `Cancelled`
  upgrade); fold the windows-only tolerances into the same walk.
- raced sibling `trio.Cancelled`s are now ABSORBED by the
  task-nursery instead of landing in the group, so the MTF
  shape-mismatch xfail should consistently xpass — note added to
  drop the marker once CI confirms.
- add an `else: pytest.fail()` so a silently-clean tree can no
  longer pass.

Gate: both depths green on `trio` (10 consecutive runs) +
`mp_spawn`.

(this patch was generated in some part by [`claude-code`][claude-code-gh])
[claude-code-gh]: https://github.com/anthropics/claude-code
drop_ria_nursery
Gud Boi 2026-07-06 11:47:47 -04:00
parent 697c6152a6
commit fa8799d5bb
1 changed files with 102 additions and 57 deletions

View File

@ -450,8 +450,20 @@ async def spawn_and_error(
breadth: int, breadth: int,
depth: int, depth: int,
) -> None: ) -> None:
'''
Recursively spawn a breadth-wide level of erroring one-shot
subactors as concurrent `to_actor.run()` tasks; the leaf level
errors ~simultaneously and each level's task-nursery groups
whatever `RemoteActorError`s relay before the first one's
cancel wins, boxing the (`ExceptionGroup`-shaped) group into
this actor's own relayed error.
'''
name = tractor.current_actor().name name = tractor.current_actor().name
async with tractor.open_nursery() as nursery: async with (
tractor.open_nursery() as an,
trio.open_nursery() as tn,
):
for i in range(breadth): for i in range(breadth):
if depth > 0: if depth > 0:
@ -471,7 +483,14 @@ async def spawn_and_error(
kwargs = { kwargs = {
'name': f'{name}_errorer_{i}', 'name': f'{name}_errorer_{i}',
} }
await nursery.run_in_actor(*args, **kwargs) tn.start_soon(
partial(
tractor.to_actor.run,
*args,
an=an,
**kwargs,
)
)
# NOTE: `main_thread_forkserver` capture-fd hang class is no # NOTE: `main_thread_forkserver` capture-fd hang class is no
@ -513,7 +532,11 @@ async def test_nested_multierrors(
depth: int, depth: int,
): ):
''' '''
Test that failed actor sets are wrapped in `BaseExceptionGroup`s. Test that a nested tree of concurrently failing one-shot
subactors tears down cleanly, relaying (whatever subset of)
the leaf `AssertionError`s (that win the per-level
relay-vs-cancel race) re-boxed/grouped at each actor
boundary.
Parametrized over recursion `depth {1, 3}`: Parametrized over recursion `depth {1, 3}`:
@ -563,6 +586,13 @@ async def test_nested_multierrors(
# fork-spawn jitter + UDS-contention widens both `t1` and # fork-spawn jitter + UDS-contention widens both `t1` and
# `t2` further. # `t2` further.
# #
# NB post-#477 (`to_actor.run()` fan-out in a local
# task-nursery) a race-tripped sibling's `Cancelled` is
# ABSORBED by the task-nursery instead of landing in the
# group — the raced case now shows as a *smaller* BEG, so
# this marker should consistently `xpass`; drop it once CI
# confirms.
#
# With `strict=False` the clean-cascade cases (most # With `strict=False` the clean-cascade cases (most
# depth=1 runs, rare depth=3 runs) report as `xpassed` # depth=1 runs, rare depth=3 runs) report as `xpassed`
# while the race-tripped cases report as `xfailed` — # while the race-tripped cases report as `xfailed` —
@ -667,66 +697,81 @@ async def test_nested_multierrors(
async with fail_after_w_trace(timeout): async with fail_after_w_trace(timeout):
try: try:
async with tractor.open_nursery() as nursery: async with (
tractor.open_nursery() as nursery,
trio.open_nursery() as tn,
):
for i in range(subactor_breadth): for i in range(subactor_breadth):
await nursery.run_in_actor( tn.start_soon(
partial(
tractor.to_actor.run,
spawn_and_error, spawn_and_error,
an=nursery,
name=f'spawner_{i}', name=f'spawner_{i}',
breadth=subactor_breadth, breadth=subactor_breadth,
depth=depth, depth=depth,
) )
except BaseExceptionGroup as err: )
assert len(err.exceptions) == subactor_breadth except (
for subexc in err.exceptions: BaseExceptionGroup,
tractor.RemoteActorError,
# verify first level actor errors are wrapped as remote ) as err:
if _friggin_windows: # group membership is bounded by the relay-vs-cancel
# race: the first spawner-tree's error cancels its
# siblings, whose own errors only group when relayed
# first; a fully-raced tree even collapses (via the
# runtime's own `collapse_eg()` unwrapping each level's
# single-member group) to a bare `RemoteActorError`
# re-boxing the leaf `AssertionError` at every actor
# boundary. The deterministic exact-breadth nested-BEG
# was the legacy `run_in_actor()` reap-all-at-teardown.
subexcs: list[BaseException] = (
err.exceptions
if isinstance(err, BaseExceptionGroup)
else [err]
)
assert 1 <= len(subexcs) <= subactor_breadth
for subexc in subexcs:
if (
_friggin_windows
and
isinstance(subexc, trio.Cancelled)
):
# windows is often too slow and cancellation seems # windows is often too slow and cancellation seems
# to happen before an actor is spawned # to happen before an actor is spawned
if isinstance(subexc, trio.Cancelled):
continue continue
elif isinstance(subexc, tractor.RemoteActorError):
# on windows it seems we can't exactly be sure wtf
# will happen..
assert subexc.boxed_type in (
tractor.RemoteActorError,
trio.Cancelled,
BaseExceptionGroup,
)
elif isinstance(subexc, BaseExceptionGroup):
for subsub in subexc.exceptions:
if subsub in (tractor.RemoteActorError,):
subsub = subsub.boxed_type
assert type(subsub) in (
trio.Cancelled,
BaseExceptionGroup,
)
else:
assert isinstance(subexc, tractor.RemoteActorError) assert isinstance(subexc, tractor.RemoteActorError)
if depth > 0 and subactor_breadth > 1: accepted: tuple[Type[BaseException], ...] = (
# XXX not sure what's up with this.. # ≥2 sub-tree errors relayed before the
# on windows sometimes spawning is just too slow and # cancel-cascade won → grouped per-level.
# we get back the (sent) cancel signal instead ExceptionGroup,
if _friggin_windows: # every level collapsed down to its lone
if isinstance(subexc, tractor.RemoteActorError): # relayed (leaf) error.
assert subexc.boxed_type in ( AssertionError,
BaseExceptionGroup, # a mid-level spawner relays an
tractor.RemoteActorError # already-boxed (collapsed) leaf chain,
) # re-boxing the `RemoteActorError` itself.
else:
assert isinstance(subexc, BaseExceptionGroup)
else:
assert subexc.boxed_type is ExceptionGroup
else:
assert subexc.boxed_type in (
tractor.RemoteActorError, tractor.RemoteActorError,
trio.Cancelled # under heavy load a runtime-internal reap
# deadline can inject a `trio.Cancelled`
# into a child's group before relay (the
# same class the depth=3 throttle-xfail
# covers) upgrading it from an
# `ExceptionGroup`.
BaseExceptionGroup,
)
if _friggin_windows:
# on windows it seems we can't exactly be
# sure wtf will happen..
accepted += (
trio.Cancelled,
)
assert subexc.boxed_type in accepted
else:
pytest.fail(
'Should have raised a (grouped) `RemoteActorError`?'
) )