Port debugging examples off `run_in_actor`

The 8 `examples/debugging/` scripts driven by the pexpect'd
`test_debugger.py` REPL-flows (#477 removal),

- blocking one-shots (`subactor_error`, `subactor_breakpoint`,
  `shielded_pause`): straight `to_actor.run(fn, an=an)` — the
  boxed error/`BdbQuit` raises in the root's task.
- `multi_subactors`: introduces the "collect all errors" pattern
  — each one-shot catches + stashes its `RemoteActorError` (vs
  raising) so no child's crash cancels its siblings before
  they've had their own REPL sessions, then a
  `BaseExceptionGroup` of the lot raises at the end; preserves
  the legacy teardown-reap REPL flow exactly (28/28 debugger
  suite unchanged).
- `multi_nested_subactors_error_up_through_nurseries` +
  `root_cancelled_but_child_is_in_tty_lock`: recursive
  `spawn_until` levels each block on their one-shot child; the
  parallel spawner-trees run as bg task-nursery one-shots where
  the first tree's error cancels the other.
- `multi_subactor_root_errors` +
  `root_timeout_while_child_crashed`: `start_actor()` + bg
  `Portal.run()` tasks so the root's own error/timeout races the
  already-crashed children, same as before.
- `sync_bp`: TODO-comment x-ref update only.

`test_debugger.py`: the nested-nurseries test's final-output
patterns update to the new relay shape — the LAST-released leaf
REPL's error chain wins each level's relay-vs-cancel race and
relays as a `collapse_eg()`-annotated collapsed chain, while the
sibling tree is cancelled + absorbed. (The legacy teardown-reap
grouped BOTH the `name_error` and bp-quit chains — explaining
the previously-mysterious "extra" `src_uid`/`relay_uid` patterns
noted in the old TODO.)

Gate: `tests/devx/test_debugger.py` = 28 passed, 6 skipped —
identical to the pre-migration baseline.

(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 12:24:29 -04:00
parent d8af5f125a
commit a3057cb24f
10 changed files with 179 additions and 133 deletions

View File

@ -1,3 +1,5 @@
from functools import partial
import trio import trio
import tractor import tractor
@ -21,25 +23,36 @@ async def breakpoint_forever():
async def spawn_until(depth=0): async def spawn_until(depth=0):
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as n: async with (
tractor.open_nursery() as an,
trio.open_nursery() as tn,
):
if depth < 1: if depth < 1:
await n.run_in_actor(breakpoint_forever) tn.start_soon(
partial(
p = await n.run_in_actor( tractor.to_actor.run,
name_error, breakpoint_forever,
name='name_error' an=an,
) )
)
await trio.sleep(0.5) await trio.sleep(0.5)
# rx and propagate error from child # rx and propagate error from child
await p.result() await tractor.to_actor.run(
name_error,
an=an,
name='name_error',
)
else: else:
# recusrive call to spawn another process branching layer of # recusrive call to spawn another process branching layer of
# the tree # the tree; blocks (up) each level until the leaf's
# `name_error` relays through.
depth -= 1 depth -= 1
await n.run_in_actor( await tractor.to_actor.run(
spawn_until, spawn_until,
an=an,
depth=depth, depth=depth,
name=f'spawn_until_{depth}', name=f'spawn_until_{depth}',
) )
@ -65,34 +78,33 @@ async def main():
python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...) python -m tractor._child --uid ('spawn_until_0', 'de918e6d ...)
""" """
async with tractor.open_nursery( async with (
tractor.open_nursery(
debug_mode=True, debug_mode=True,
loglevel='pdb', loglevel='pdb',
) as n: ) as an,
trio.open_nursery() as tn,
# spawn both actors ):
portal = await n.run_in_actor( # spawn both spawner trees as concurrent one-shots; the
# first tree's (relayed) error cancels the other.
tn.start_soon(
partial(
tractor.to_actor.run,
spawn_until, spawn_until,
an=an,
depth=3, depth=3,
name='spawner0', name='spawner0',
) )
portal1 = await n.run_in_actor( )
tn.start_soon(
partial(
tractor.to_actor.run,
spawn_until, spawn_until,
an=an,
depth=4, depth=4,
name='spawner1', name='spawner1',
) )
)
# TODO: test this case as well where the parent don't see
# the sub-actor errors by default and instead expect a user
# ctrl-c to kill the root.
with trio.move_on_after(3):
await trio.sleep_forever()
# gah still an issue here.
await portal.result()
# should never get here
await portal1.result()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -16,11 +16,11 @@ async def spawn_error():
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as n: async with tractor.open_nursery() as n:
portal = await n.run_in_actor( return await tractor.to_actor.run(
name_error, name_error,
an=n,
name='name_error_1', name='name_error_1',
) )
return await portal.result()
async def main(): async def main():
@ -38,29 +38,36 @@ async def main():
- root actor should then fail on assert - root actor should then fail on assert
- program termination - program termination
""" """
async with tractor.open_nursery( async with (
tractor.open_nursery(
debug_mode=True, debug_mode=True,
loglevel='devx', loglevel='devx',
) as n: ) as an,
trio.open_nursery() as tn,
):
# spawn both actors..
portal = await an.start_actor(
'name_error',
enable_modules=[__name__],
)
portal1 = await an.start_actor(
'spawn_error',
enable_modules=[__name__],
)
# spawn both actors # ..and bg-schedule their erroring tasks.
portal = await n.run_in_actor( tn.start_soon(portal.run, name_error)
name_error, tn.start_soon(portal1.run, spawn_error)
name='name_error',
) # yield to the bg tasks so both RPC requests are
portal1 = await n.run_in_actor( # submitted (and start crashing) before the root's own
spawn_error, # error below (the legacy `run_in_actor()` submitted
name='spawn_error', # in-line with each spawn).
) await trio.sleep(0.5)
# trigger a root actor error # trigger a root actor error
assert 0 assert 0
# attempt to collect results (which raises error in parent)
# still has some issues where the parent seems to get stuck
await portal.result()
await portal1.result()
if __name__ == '__main__': if __name__ == '__main__':
trio.run(main) trio.run(main)

View File

@ -18,11 +18,11 @@ async def spawn_error():
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as n: async with tractor.open_nursery() as n:
portal = await n.run_in_actor( return await tractor.to_actor.run(
name_error, name_error,
an=n,
name='name_error_1', name='name_error_1',
) )
return await portal.result()
async def main(): async def main():
@ -36,17 +36,39 @@ async def main():
`-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...) `-python -m tractor._child --uid ('spawn_error', '52ee14a5 ...)
`-python -m tractor._child --uid ('name_error', '3391222c ...) `-python -m tractor._child --uid ('name_error', '3391222c ...)
""" """
errors: list[BaseException] = []
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
# loglevel='runtime', # loglevel='runtime',
) as n: ) as an:
# Spawn both actors, don't bother with collecting results async def run_and_collect(fn):
# (would result in a different debugger outcome due to parent's '''
# cancellation). One-shot whose (boxed) error is stashed instead of
await n.run_in_actor(breakpoint_forever) raised so a sibling's crash never cancels the others
await n.run_in_actor(name_error) before they've had their own debugger sessions (the
await n.run_in_actor(spawn_error) "collect all errors" the legacy `run_in_actor()` API
did implicitly at nursery teardown).
'''
try:
await tractor.to_actor.run(fn, an=an)
except tractor.RemoteActorError as rae:
errors.append(rae)
# Spawn all one-shot task actors, collecting (vs.
# raising) their errors.
async with trio.open_nursery() as tn:
tn.start_soon(run_and_collect, breakpoint_forever)
tn.start_soon(run_and_collect, name_error)
tn.start_soon(run_and_collect, spawn_error)
if errors:
raise BaseExceptionGroup(
'multi_subactors errored!',
errors,
)
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -1,3 +1,5 @@
from functools import partial
import trio import trio
import tractor import tractor
@ -10,14 +12,14 @@ async def name_error():
async def spawn_until(depth=0): async def spawn_until(depth=0):
""""A nested nursery that triggers another ``NameError``. """"A nested nursery that triggers another ``NameError``.
""" """
async with tractor.open_nursery() as n: async with tractor.open_nursery() as an:
if depth < 1: if depth < 1:
# await n.run_in_actor('breakpoint_forever', breakpoint_forever) await tractor.to_actor.run(name_error, an=an)
await n.run_in_actor(name_error)
else: else:
depth -= 1 depth -= 1
await n.run_in_actor( await tractor.to_actor.run(
spawn_until, spawn_until,
an=an,
depth=depth, depth=depth,
name=f'spawn_until_{depth}', name=f'spawn_until_{depth}',
) )
@ -37,28 +39,33 @@ async def main():
python -m tractor._child --uid ('name_error', '6c2733b8 ...) python -m tractor._child --uid ('name_error', '6c2733b8 ...)
''' '''
async with tractor.open_nursery( async with (
tractor.open_nursery(
debug_mode=True, debug_mode=True,
enable_transports=['uds'], # TODO, apss this via osenv? enable_transports=['uds'], # TODO, apss this via osenv?
loglevel='devx', # XXX, required for test! loglevel='devx', # XXX, required for test!
) as n: ) as an,
trio.open_nursery() as tn,
# spawn both actors ):
portal = await n.run_in_actor( # spawn the deeper tree in the bg..
spawn_until, tn.start_soon(
depth=0, partial(
name='spawner0', tractor.to_actor.run,
)
portal1 = await n.run_in_actor(
spawn_until, spawn_until,
an=an,
depth=1, depth=1,
name='spawner1', name='spawner1',
) )
)
# nursery cancellation should be triggered due to propagated # ..while blocking on the shallow (faster to fail) tree
# error from child. # whose propagated error triggers nursery cancellation.
await portal.result() await tractor.to_actor.run(
await portal1.result() spawn_until,
an=an,
depth=0,
name='spawner0',
)
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -13,17 +13,24 @@ async def main():
simultaneously. simultaneously.
''' '''
async with tractor.open_nursery( async with (
tractor.open_nursery(
debug_mode=True, debug_mode=True,
# loglevel='debug' # ?XXX required? # loglevel='debug' # ?XXX required?
) as n: ) as n,
trio.open_nursery() as tn,
# spawn both actors ):
portal = await n.run_in_actor(key_error) # spawn the actor..
portal = await n.start_actor(
'key_error',
enable_modules=[__name__],
)
print( print(
f'Child is up @ {portal.chan.aid.reprol()}' f'Child is up @ {portal.chan.aid.reprol()}'
) )
# ..then schedule its erroring task in the bg while the
# root blocks below.
tn.start_soon(portal.run, key_error)
# XXX: originally a bug caused by this is where root would enter # XXX: originally a bug caused by this is where root would enter
# the debugger and clobber the tty used by the repl even though # the debugger and clobber the tty used by the repl even though

View File

@ -75,10 +75,10 @@ async def main():
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
) as n: ) as n:
portal: tractor.Portal = await n.run_in_actor( await tractor.to_actor.run(
cancelled_before_pause, cancelled_before_pause,
an=n,
) )
await portal.wait_for_result()
# ensure the same works in the root actor! # ensure the same works in the root actor!
await pm_on_cancelled() await pm_on_cancelled()

View File

@ -19,10 +19,12 @@ async def main():
loglevel='cancel', loglevel='cancel',
) as n: ) as n:
portal = await n.run_in_actor( # parks awaiting a result which only arrives once the
# user quits (`BdbQuit`s) the child's REPL loop.
await tractor.to_actor.run(
breakpoint_forever, breakpoint_forever,
an=n,
) )
await portal.wait_for_result()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -12,16 +12,12 @@ async def main():
) as an: ) as an:
# TODO: ideally the REPL arrives at this frame in the parent, # TODO: ideally the REPL arrives at this frame in the parent,
# ABOVE the @api_frame of `Portal.run_in_actor()` (which # ABOVE the @api_frame of `to_actor.run()` ..
# should eventually not even be a portal method ... XD)
# await tractor.pause() # await tractor.pause()
p: tractor.Portal = await an.run_in_actor(name_error)
# with this style, should raise on this line # the one-shot blocks on the subactor's result so the
await p.wait_for_result() # boxed `NameError` raises right here.
await tractor.to_actor.run(name_error, an=an)
# with this alt style should raise at `open_nusery()`
# return await p.wait_for_result()
if __name__ == '__main__': if __name__ == '__main__':

View File

@ -90,7 +90,7 @@ async def main() -> None:
# TODO: 3 sub-actor usage cases: # TODO: 3 sub-actor usage cases:
# -[x] via a `.open_context()` # -[x] via a `.open_context()`
# -[ ] via a `.run_in_actor()` call # -[ ] via a `to_actor.run()` call
# -[ ] via a `.run()` # -[ ] via a `.run()`
# -[ ] via a `.to_thread.run_sync()` in subactor # -[ ] via a `.to_thread.run_sync()` in subactor
async with p.open_context( async with p.open_context(

View File

@ -849,43 +849,36 @@ def test_multi_nested_subactors_error_through_nurseries(
break break
# boxed source errors # boxed source errors
#
# NB post-#477 (`to_actor.run()` one-shots in local
# task-nurseries) the final relay is the LAST-released
# (leaf) REPL's error chain: it wins each level's
# relay-vs-cancel race so every level's single-member
# group gets unwrapped by the runtime's `collapse_eg()`
# (annotated at each actor boundary) while the sibling
# tree ('spawner1') is cancelled + absorbed. The legacy
# `run_in_actor()` teardown-reap instead grouped BOTH the
# `name_error` and bp-quit chains into the final dump
# (the previously-unexplained "extra" patterns).
expect_patts: list[str] = [ expect_patts: list[str] = [
"NameError: name 'doggypants' is not defined",
"tractor._exceptions.RemoteActorError:", "tractor._exceptions.RemoteActorError:",
"('name_error'",
# first level subtrees # each level's unwrapped-single-member-group
# "tractor._exceptions.RemoteActorError: ('spawner0'", # annotation + the first-level subtree's boundary
"src_uid=('spawner0'", # footer.
"( ^^^ this exc was collapsed from a group ^^^ )",
# "tractor._exceptions.RemoteActorError: ('spawner1'", "------ ('spawner0'",
# propagation of errors up through nested subtrees
# "tractor._exceptions.RemoteActorError: ('spawn_until_0'",
# "tractor._exceptions.RemoteActorError: ('spawn_until_1'",
# "tractor._exceptions.RemoteActorError: ('spawn_until_2'",
# ^-NOTE-^ old RAE repr, new one is below with a field
# showing the src actor's uid.
"src_uid=('spawn_until_2'",
] ]
# XXX, I HAVE NO IDEA why these patts only show on the
# `trio`-spawner but it seems to have something to do with
# what gets dumped in prior-prompt latches somehow??
# TODO for claude, explain and or work through how this is
# happening but ONLY WHEN RUN FROM THE TEST, bc when i try to
# run the test script manually the correct output ALWAYS seems
# to be in the last `str(child.before.decode())` output !?!?
if ( if (
not is_forking_spawner not is_forking_spawner
and and
last_send_char == 'q' last_send_char == 'q'
): ):
expect_patts += [ expect_patts += [
# expect the pdb-quit exc. # expect the pdb-quit exc relayed from the leaf's
# bp-loop child.
"bdb.BdbQuit", "bdb.BdbQuit",
# BUT WHY these dude!? "src_uid=('breakpoint_forever'",
"src_uid=('spawn_until_0'",
"relay_uid=('spawn_until_1'",
] ]
assert_before( assert_before(