From 72b4dc14616ceb8372d4728ef6d922cd28220507 Mon Sep 17 00:00:00 2001 From: Tyler Goodlet Date: Mon, 25 Mar 2024 16:09:32 -0400 Subject: [PATCH] Provision for infected-`asyncio` debug mode support It's **almost** there, we're just missing the final translation code to get from an `asyncio` side task to be able to call `.devx._debug..wait_for_parent_stdin_hijack()` to do root actor TTY locking. Then we just need to ensure internals also do the right thing with `greenback()` for equivalent sync `breakpoint()` style pause points. Since i'm deferring this until later, tossing in some xfail tests to `test_infected_asyncio` with TODOs for the needed implementation as well as eventual test org. By "provision" it means we add: - `greenback` init block to `_run_asyncio_task()` when debug mode is enabled (but which will currently rte when `asyncio` is detected) using `.bestow_portal()` around the `asyncio.Task`. - a call to `_debug.maybe_init_greenback()` in the `run_as_asyncio_guest()` guest-mode entry point. - as part of `._debug.Lock.is_main_trio_thread()` whenever the async-lib is not 'trio' error lock the backend name (which is obvi `'asyncio'` in this use case). --- examples/debugging/asyncio_bp.py | 4 ++- tests/test_infected_asyncio.py | 37 +++++++++++++++++++++- tractor/devx/_debug.py | 17 ++++++++-- tractor/to_asyncio.py | 54 ++++++++++++++++++++++++-------- 4 files changed, 95 insertions(+), 17 deletions(-) diff --git a/examples/debugging/asyncio_bp.py b/examples/debugging/asyncio_bp.py index b32ad1d..baddfe0 100644 --- a/examples/debugging/asyncio_bp.py +++ b/examples/debugging/asyncio_bp.py @@ -77,7 +77,9 @@ async def main( ) -> None: - async with tractor.open_nursery() as n: + async with tractor.open_nursery( + # debug_mode=True, + ) as n: p = await n.start_actor( 'aio_daemon', diff --git a/tests/test_infected_asyncio.py b/tests/test_infected_asyncio.py index 5ac463e..8d34bef 100644 --- a/tests/test_infected_asyncio.py +++ b/tests/test_infected_asyncio.py @@ -601,7 +601,8 @@ def test_echoserver_detailed_mechanics( pass else: pytest.fail( - "stream wasn't stopped after sentinel?!") + 'stream not stopped after sentinel ?!' + ) # TODO: the case where this blocks and # is cancelled by kbi or out of task cancellation @@ -613,3 +614,37 @@ def test_echoserver_detailed_mechanics( else: trio.run(main) + + +# TODO: debug_mode tests once we get support for `asyncio`! +# +# -[ ] need tests to wrap both scripts: +# - [ ] infected_asyncio_echo_server.py +# - [ ] debugging/asyncio_bp.py +# -[ ] consider moving ^ (some of) these ^ to `test_debugger`? +# +# -[ ] missing impl outstanding includes: +# - [x] for sync pauses we need to ensure we open yet another +# `greenback` portal in the asyncio task +# => completed using `.bestow_portal(task)` inside +# `.to_asyncio._run_asyncio_task()` right? +# -[ ] translation func to get from `asyncio` task calling to +# `._debug.wait_for_parent_stdin_hijack()` which does root +# call to do TTY locking. +# +def test_sync_breakpoint(): + ''' + Verify we can do sync-func/code breakpointing using the + `breakpoint()` builtin inside infected mode actors. + + ''' + pytest.xfail('This support is not implemented yet!') + + +def test_debug_mode_crash_handling(): + ''' + Verify mult-actor crash handling works with a combo of infected-`asyncio`-mode + and normal `trio` actors despite nested process trees. + + ''' + pytest.xfail('This support is not implemented yet!') diff --git a/tractor/devx/_debug.py b/tractor/devx/_debug.py index bb5740b..75be7a2 100644 --- a/tractor/devx/_debug.py +++ b/tractor/devx/_debug.py @@ -187,13 +187,18 @@ class Lock: `trio.to_thread.run_sync()`. ''' - return ( + is_trio_main = ( # TODO: since this is private, @oremanj says # we should just copy the impl for now.. trio._util.is_main_thread() and - sniffio.current_async_library() == 'trio' + (async_lib := sniffio.current_async_library()) == 'trio' ) + if not is_trio_main: + log.warning( + f'Current async-lib detected by `sniffio`: {async_lib}\n' + ) + return is_trio_main # XXX apparently unreliable..see ^ # ( # threading.current_thread() @@ -1114,6 +1119,14 @@ def pause_from_sync( '- `async with tractor.open_root_actor()`\n' ) + # NOTE: once supported, remove this AND the one + # inside `._pause()`! + if actor.is_infected_aio(): + raise RuntimeError( + '`tractor.pause[_from_sync]()` not yet supported ' + 'for infected `asyncio` mode!' + ) + # raises on not-found by default greenback: ModuleType = maybe_import_greenback() mdb: MultiActorPdb = mk_mpdb() diff --git a/tractor/to_asyncio.py b/tractor/to_asyncio.py index 7c88edd..585b0b0 100644 --- a/tractor/to_asyncio.py +++ b/tractor/to_asyncio.py @@ -33,10 +33,14 @@ from typing import ( import trio from outcome import Error -from .log import get_logger -from ._state import current_actor -from ._exceptions import AsyncioCancelled -from .trionics._broadcast import ( +from tractor.log import get_logger +from tractor._state import ( + current_actor, + debug_mode, +) +from tractor.devx import _debug +from tractor._exceptions import AsyncioCancelled +from tractor.trionics._broadcast import ( broadcast_receiver, BroadcastReceiver, ) @@ -64,9 +68,9 @@ class LinkedTaskChannel(trio.abc.Channel): _trio_exited: bool = False # set after ``asyncio.create_task()`` - _aio_task: asyncio.Task | None = None - _aio_err: BaseException | None = None - _broadcaster: BroadcastReceiver | None = None + _aio_task: asyncio.Task|None = None + _aio_err: BaseException|None = None + _broadcaster: BroadcastReceiver|None = None async def aclose(self) -> None: await self._from_aio.aclose() @@ -158,7 +162,9 @@ def _run_asyncio_task( ''' __tracebackhide__ = True if not current_actor().is_infected_aio(): - raise RuntimeError("`infect_asyncio` mode is not enabled!?") + raise RuntimeError( + "`infect_asyncio` mode is not enabled!?" + ) # ITC (inter task comms), these channel/queue names are mostly from # ``asyncio``'s perspective. @@ -187,7 +193,7 @@ def _run_asyncio_task( cancel_scope = trio.CancelScope() aio_task_complete = trio.Event() - aio_err: BaseException | None = None + aio_err: BaseException|None = None chan = LinkedTaskChannel( aio_q, # asyncio.Queue @@ -253,7 +259,7 @@ def _run_asyncio_task( if not inspect.isawaitable(coro): raise TypeError(f"No support for invoking {coro}") - task = asyncio.create_task( + task: asyncio.Task = asyncio.create_task( wait_on_coro_final_result( to_trio, coro, @@ -262,6 +268,18 @@ def _run_asyncio_task( ) chan._aio_task = task + # XXX TODO XXX get this actually workin.. XD + # maybe setup `greenback` for `asyncio`-side task REPLing + if ( + debug_mode() + and + (greenback := _debug.maybe_import_greenback( + force_reload=True, + raise_not_found=False, + )) + ): + greenback.bestow_portal(task) + def cancel_trio(task: asyncio.Task) -> None: ''' Cancel the calling ``trio`` task on error. @@ -269,7 +287,7 @@ def _run_asyncio_task( ''' nonlocal chan aio_err = chan._aio_err - task_err: BaseException | None = None + task_err: BaseException|None = None # only to avoid ``asyncio`` complaining about uncaptured # task exceptions @@ -349,11 +367,11 @@ async def translate_aio_errors( ''' trio_task = trio.lowlevel.current_task() - aio_err: BaseException | None = None + aio_err: BaseException|None = None # TODO: make thisi a channel method? def maybe_raise_aio_err( - err: Exception | None = None + err: Exception|None = None ) -> None: aio_err = chan._aio_err if ( @@ -531,6 +549,16 @@ def run_as_asyncio_guest( loop = asyncio.get_running_loop() trio_done_fut = asyncio.Future() + if debug_mode(): + # XXX make it obvi we know this isn't supported yet! + log.error( + 'Attempting to enter unsupported `greenback` init ' + 'from `asyncio` task..' + ) + await _debug.maybe_init_greenback( + force_reload=True, + ) + def trio_done_callback(main_outcome): if isinstance(main_outcome, Error):