Add initial repl_fixture support, enter/exit hooks around the debugger sys! #28

Open
goodboy wants to merge 13 commits from repl_fixture into pytest_pluginize
31 changed files with 4006 additions and 3415 deletions

View File

@ -29,7 +29,7 @@ async def bp_then_error(
to_trio.send_nowait('start') to_trio.send_nowait('start')
# NOTE: what happens here inside the hook needs some refinement.. # NOTE: what happens here inside the hook needs some refinement..
# => seems like it's still `._debug._set_trace()` but # => seems like it's still `.debug._set_trace()` but
# we set `Lock.local_task_in_debug = 'sync'`, we probably want # we set `Lock.local_task_in_debug = 'sync'`, we probably want
# some further, at least, meta-data about the task/actor in debug # some further, at least, meta-data about the task/actor in debug
# in terms of making it clear it's `asyncio` mucking about. # in terms of making it clear it's `asyncio` mucking about.

View File

@ -4,6 +4,11 @@ import sys
import trio import trio
import tractor import tractor
# ensure mod-path is correct!
from tractor.devx.debug import (
_sync_pause_from_builtin as _sync_pause_from_builtin,
)
async def main() -> None: async def main() -> None:
@ -13,19 +18,20 @@ async def main() -> None:
async with tractor.open_nursery( async with tractor.open_nursery(
debug_mode=True, debug_mode=True,
loglevel='devx',
) as an: ) as an:
assert an assert an
assert ( assert (
(pybp_var := os.environ['PYTHONBREAKPOINT']) (pybp_var := os.environ['PYTHONBREAKPOINT'])
== ==
'tractor.devx._debug._sync_pause_from_builtin' 'tractor.devx.debug._sync_pause_from_builtin'
) )
# TODO: an assert that verifies the hook has indeed been, hooked # TODO: an assert that verifies the hook has indeed been, hooked
# XD # XD
assert ( assert (
(pybp_hook := sys.breakpointhook) (pybp_hook := sys.breakpointhook)
is not tractor.devx._debug._set_trace is not tractor.devx.debug._set_trace
) )
print( print(

View File

@ -6,7 +6,7 @@ import tractor
# TODO: only import these when not running from test harness? # TODO: only import these when not running from test harness?
# can we detect `pexpect` usage maybe? # can we detect `pexpect` usage maybe?
# from tractor.devx._debug import ( # from tractor.devx.debug import (
# get_lock, # get_lock,
# get_debug_req, # get_debug_req,
# ) # )

View File

@ -61,6 +61,7 @@ dev = [
# `tractor.devx` tooling # `tractor.devx` tooling
"greenback>=1.2.1,<2", "greenback>=1.2.1,<2",
"stackscope>=0.2.2,<0.3", "stackscope>=0.2.2,<0.3",
"typing-extensions>=4.13.2", # needed for stackscope
"pyperclip>=1.9.0", "pyperclip>=1.9.0",
"prompt-toolkit>=3.0.50", "prompt-toolkit>=3.0.50",
"xonsh>=0.19.2", "xonsh>=0.19.2",

View File

@ -16,7 +16,7 @@ from pexpect.spawnbase import SpawnBase
from tractor._testing import ( from tractor._testing import (
mk_cmd, mk_cmd,
) )
from tractor.devx._debug import ( from tractor.devx.debug import (
_pause_msg as _pause_msg, _pause_msg as _pause_msg,
_crash_msg as _crash_msg, _crash_msg as _crash_msg,
_repl_fail_msg as _repl_fail_msg, _repl_fail_msg as _repl_fail_msg,
@ -111,7 +111,7 @@ def ctlc(
# XXX: disable pygments highlighting for auto-tests # XXX: disable pygments highlighting for auto-tests
# since some envs (like actions CI) will struggle # since some envs (like actions CI) will struggle
# the the added color-char encoding.. # the the added color-char encoding..
from tractor.devx._debug import TractorConfig from tractor.devx.debug import TractorConfig
TractorConfig.use_pygements = False TractorConfig.use_pygements = False
yield use_ctlc yield use_ctlc

View File

@ -528,7 +528,7 @@ def test_multi_daemon_subactors(
# now the root actor won't clobber the bp_forever child # now the root actor won't clobber the bp_forever child
# during it's first access to the debug lock, but will instead # during it's first access to the debug lock, but will instead
# wait for the lock to release, by the edge triggered # wait for the lock to release, by the edge triggered
# ``devx._debug.Lock.no_remote_has_tty`` event before sending cancel messages # ``devx.debug.Lock.no_remote_has_tty`` event before sending cancel messages
# (via portals) to its underlings B) # (via portals) to its underlings B)
# at some point here there should have been some warning msg from # at some point here there should have been some warning msg from

View File

@ -889,7 +889,7 @@ async def manage_file(
# NOTE: turns out you don't even need to sched an aio task # NOTE: turns out you don't even need to sched an aio task
# since the original issue, even though seemingly was due to # since the original issue, even though seemingly was due to
# the guest-run being abandoned + a `._debug.pause()` inside # the guest-run being abandoned + a `.debug.pause()` inside
# `._runtime._async_main()` (which was originally trying to # `._runtime._async_main()` (which was originally trying to
# debug the `.lifetime_stack` not closing), IS NOT actually # debug the `.lifetime_stack` not closing), IS NOT actually
# the core issue? # the core issue?
@ -1101,7 +1101,7 @@ def test_sigint_closes_lifetime_stack(
# => completed using `.bestow_portal(task)` inside # => completed using `.bestow_portal(task)` inside
# `.to_asyncio._run_asyncio_task()` right? # `.to_asyncio._run_asyncio_task()` right?
# -[ ] translation func to get from `asyncio` task calling to # -[ ] translation func to get from `asyncio` task calling to
# `._debug.wait_for_parent_stdin_hijack()` which does root # `.debug.wait_for_parent_stdin_hijack()` which does root
# call to do TTY locking. # call to do TTY locking.
# #
def test_sync_breakpoint(): def test_sync_breakpoint():

View File

@ -292,7 +292,7 @@ class Context:
# - `._runtime._invoke()` will check this flag before engaging # - `._runtime._invoke()` will check this flag before engaging
# the crash handler REPL in such cases where the "callee" # the crash handler REPL in such cases where the "callee"
# raises the cancellation, # raises the cancellation,
# - `.devx._debug.lock_stdio_for_peer()` will set it to `False` if # - `.devx.debug.lock_stdio_for_peer()` will set it to `False` if
# the global tty-lock has been configured to filter out some # the global tty-lock has been configured to filter out some
# actors from being able to acquire the debugger lock. # actors from being able to acquire the debugger lock.
_enter_debugger_on_cancel: bool = True _enter_debugger_on_cancel: bool = True
@ -1234,8 +1234,8 @@ class Context:
# ?XXX, should already be set in `._deliver_msg()` right? # ?XXX, should already be set in `._deliver_msg()` right?
if self._outcome_msg is not Unresolved: if self._outcome_msg is not Unresolved:
# from .devx import _debug # from .devx import debug
# await _debug.pause() # await debug.pause()
assert self._outcome_msg is outcome_msg assert self._outcome_msg is outcome_msg
else: else:
self._outcome_msg = outcome_msg self._outcome_msg = outcome_msg
@ -2170,7 +2170,7 @@ async def open_context_from_portal(
# debugging the tractor-runtime itself using it's # debugging the tractor-runtime itself using it's
# own `.devx.` tooling! # own `.devx.` tooling!
# #
# await _debug.pause() # await debug.pause()
# CASE 2: context was cancelled by local task calling # CASE 2: context was cancelled by local task calling
# `.cancel()`, we don't raise and the exit block should # `.cancel()`, we don't raise and the exit block should
@ -2237,7 +2237,7 @@ async def open_context_from_portal(
# NOTE: `Context.cancel()` is conversely NEVER CALLED in # NOTE: `Context.cancel()` is conversely NEVER CALLED in
# the `ContextCancelled` "self cancellation absorbed" case # the `ContextCancelled` "self cancellation absorbed" case
# handled in the block above ^^^ !! # handled in the block above ^^^ !!
# await _debug.pause() # await debug.pause()
# log.cancel( # log.cancel(
match scope_err: match scope_err:
case trio.Cancelled: case trio.Cancelled:
@ -2252,11 +2252,11 @@ async def open_context_from_portal(
) )
if debug_mode(): if debug_mode():
# async with _debug.acquire_debug_lock(portal.actor.uid): # async with debug.acquire_debug_lock(portal.actor.uid):
# pass # pass
# TODO: factor ^ into below for non-root cases? # TODO: factor ^ into below for non-root cases?
# #
from .devx._debug import maybe_wait_for_debugger from .devx.debug import maybe_wait_for_debugger
was_acquired: bool = await maybe_wait_for_debugger( was_acquired: bool = await maybe_wait_for_debugger(
# header_msg=( # header_msg=(
# 'Delaying `ctx.cancel()` until debug lock ' # 'Delaying `ctx.cancel()` until debug lock '
@ -2319,8 +2319,8 @@ async def open_context_from_portal(
raise raise
# yes this worx! # yes this worx!
# from .devx import _debug # from .devx import debug
# await _debug.pause() # await debug.pause()
# an exception type boxed in a `RemoteActorError` # an exception type boxed in a `RemoteActorError`
# is returned (meaning it was obvi not raised) # is returned (meaning it was obvi not raised)
@ -2355,7 +2355,7 @@ async def open_context_from_portal(
# where the root is waiting on the lock to clear but the # where the root is waiting on the lock to clear but the
# child has already cleared it and clobbered IPC. # child has already cleared it and clobbered IPC.
if debug_mode(): if debug_mode():
from .devx._debug import maybe_wait_for_debugger from .devx.debug import maybe_wait_for_debugger
await maybe_wait_for_debugger() await maybe_wait_for_debugger()
# though it should be impossible for any tasks # though it should be impossible for any tasks

View File

@ -35,7 +35,7 @@ from .log import (
) )
from . import _state from . import _state
from .devx import ( from .devx import (
_debug, _frame_stack,
pformat, pformat,
) )
from .to_asyncio import run_as_asyncio_guest from .to_asyncio import run_as_asyncio_guest
@ -116,7 +116,7 @@ def _trio_main(
Entry point for a `trio_run_in_process` subactor. Entry point for a `trio_run_in_process` subactor.
''' '''
_debug.hide_runtime_frames() _frame_stack.hide_runtime_frames()
_state._current_actor = actor _state._current_actor = actor
trio_main = partial( trio_main = partial(

View File

@ -44,7 +44,10 @@ from ._runtime import (
# Arbiter as Registry, # Arbiter as Registry,
async_main, async_main,
) )
from .devx import _debug from .devx import (
debug,
_frame_stack,
)
from . import _spawn from . import _spawn
from . import _state from . import _state
from . import log from . import log
@ -67,7 +70,7 @@ from ._exceptions import (
logger = log.get_logger('tractor') logger = log.get_logger('tractor')
# TODO: stick this in a `@acm` defined in `devx._debug`? # TODO: stick this in a `@acm` defined in `devx.debug`?
# -[ ] also maybe consider making this a `wrapt`-deco to # -[ ] also maybe consider making this a `wrapt`-deco to
# save an indent level? # save an indent level?
# #
@ -89,7 +92,7 @@ async def maybe_block_bp(
debug_mode debug_mode
and maybe_enable_greenback and maybe_enable_greenback
and ( and (
maybe_mod := await _debug.maybe_init_greenback( maybe_mod := await debug.maybe_init_greenback(
raise_not_found=False, raise_not_found=False,
) )
) )
@ -99,7 +102,7 @@ async def maybe_block_bp(
'Enabling `tractor.pause_from_sync()` support!\n' 'Enabling `tractor.pause_from_sync()` support!\n'
) )
os.environ['PYTHONBREAKPOINT'] = ( os.environ['PYTHONBREAKPOINT'] = (
'tractor.devx._debug._sync_pause_from_builtin' 'tractor.devx.debug._sync_pause_from_builtin'
) )
_state._runtime_vars['use_greenback'] = True _state._runtime_vars['use_greenback'] = True
bp_blocked = False bp_blocked = False
@ -178,7 +181,7 @@ async def open_root_actor(
hide_tb: bool = True, hide_tb: bool = True,
# XXX, proxied directly to `.devx._debug._maybe_enter_pm()` # XXX, proxied directly to `.devx.debug._maybe_enter_pm()`
# for REPL-entry logic. # for REPL-entry logic.
debug_filter: Callable[ debug_filter: Callable[
[BaseException|BaseExceptionGroup], [BaseException|BaseExceptionGroup],
@ -223,12 +226,12 @@ async def open_root_actor(
len(enable_transports) == 1 len(enable_transports) == 1
), 'No multi-tpt support yet!' ), 'No multi-tpt support yet!'
_debug.hide_runtime_frames() _frame_stack.hide_runtime_frames()
__tracebackhide__: bool = hide_tb __tracebackhide__: bool = hide_tb
# attempt to retreive ``trio``'s sigint handler and stash it # attempt to retreive ``trio``'s sigint handler and stash it
# on our debugger lock state. # on our debugger lock state.
_debug.DebugStatus._trio_handler = signal.getsignal(signal.SIGINT) debug.DebugStatus._trio_handler = signal.getsignal(signal.SIGINT)
# mark top most level process as root actor # mark top most level process as root actor
_state._runtime_vars['_is_root'] = True _state._runtime_vars['_is_root'] = True
@ -283,7 +286,7 @@ async def open_root_actor(
# expose internal debug module to every actor allowing for # expose internal debug module to every actor allowing for
# use of ``await tractor.pause()`` # use of ``await tractor.pause()``
enable_modules.append('tractor.devx._debug') enable_modules.append('tractor.devx.debug._tty_lock')
# if debug mode get's enabled *at least* use that level of # if debug mode get's enabled *at least* use that level of
# logging for some informative console prompts. # logging for some informative console prompts.
@ -465,7 +468,7 @@ async def open_root_actor(
# TODO, in beginning to handle the subsubactor with # TODO, in beginning to handle the subsubactor with
# crashed grandparent cases.. # crashed grandparent cases..
# #
# was_locked: bool = await _debug.maybe_wait_for_debugger( # was_locked: bool = await debug.maybe_wait_for_debugger(
# child_in_debug=True, # child_in_debug=True,
# ) # )
# XXX NOTE XXX see equiv note inside # XXX NOTE XXX see equiv note inside
@ -473,7 +476,7 @@ async def open_root_actor(
# non-root or root-that-opened-this-mahually case we # non-root or root-that-opened-this-mahually case we
# wait for the local actor-nursery to exit before # wait for the local actor-nursery to exit before
# exiting the transport channel handler. # exiting the transport channel handler.
entered: bool = await _debug._maybe_enter_pm( entered: bool = await debug._maybe_enter_pm(
err, err,
api_frame=inspect.currentframe(), api_frame=inspect.currentframe(),
debug_filter=debug_filter, debug_filter=debug_filter,

View File

@ -57,7 +57,7 @@ from ._exceptions import (
unpack_error, unpack_error,
) )
from .devx import ( from .devx import (
_debug, debug,
add_div, add_div,
) )
from . import _state from . import _state
@ -266,7 +266,7 @@ async def _errors_relayed_via_ipc(
# TODO: a debug nursery when in debug mode! # TODO: a debug nursery when in debug mode!
# async with maybe_open_debugger_nursery() as debug_tn: # async with maybe_open_debugger_nursery() as debug_tn:
# => see matching comment in side `._debug._pause()` # => see matching comment in side `.debug._pause()`
rpc_err: BaseException|None = None rpc_err: BaseException|None = None
try: try:
yield # run RPC invoke body yield # run RPC invoke body
@ -318,7 +318,7 @@ async def _errors_relayed_via_ipc(
'RPC task crashed, attempting to enter debugger\n' 'RPC task crashed, attempting to enter debugger\n'
f'|_{ctx}' f'|_{ctx}'
) )
entered_debug = await _debug._maybe_enter_pm( entered_debug = await debug._maybe_enter_pm(
err, err,
api_frame=inspect.currentframe(), api_frame=inspect.currentframe(),
) )
@ -462,7 +462,7 @@ async def _invoke(
): ):
# XXX for .pause_from_sync()` usage we need to make sure # XXX for .pause_from_sync()` usage we need to make sure
# `greenback` is boostrapped in the subactor! # `greenback` is boostrapped in the subactor!
await _debug.maybe_init_greenback() await debug.maybe_init_greenback()
# TODO: possibly a specially formatted traceback # TODO: possibly a specially formatted traceback
# (not sure what typing is for this..)? # (not sure what typing is for this..)?
@ -751,7 +751,7 @@ async def _invoke(
and 'Cancel scope stack corrupted' in scope_error.args[0] and 'Cancel scope stack corrupted' in scope_error.args[0]
): ):
log.exception('Cancel scope stack corrupted!?\n') log.exception('Cancel scope stack corrupted!?\n')
# _debug.mk_pdb().set_trace() # debug.mk_pdb().set_trace()
# always set this (child) side's exception as the # always set this (child) side's exception as the
# local error on the context # local error on the context
@ -779,7 +779,7 @@ async def _invoke(
# don't pop the local context until we know the # don't pop the local context until we know the
# associated child isn't in debug any more # associated child isn't in debug any more
await _debug.maybe_wait_for_debugger() await debug.maybe_wait_for_debugger()
ctx: Context = actor._contexts.pop(( ctx: Context = actor._contexts.pop((
chan.uid, chan.uid,
cid, cid,
@ -983,7 +983,7 @@ async def process_messages(
# XXX NOTE XXX don't start entire actor # XXX NOTE XXX don't start entire actor
# runtime cancellation if this actor is # runtime cancellation if this actor is
# currently in debug mode! # currently in debug mode!
pdb_complete: trio.Event|None = _debug.DebugStatus.repl_release pdb_complete: trio.Event|None = debug.DebugStatus.repl_release
if pdb_complete: if pdb_complete:
await pdb_complete.wait() await pdb_complete.wait()

View File

@ -44,6 +44,7 @@ from functools import partial
import importlib import importlib
import importlib.util import importlib.util
import os import os
from pathlib import Path
from pprint import pformat from pprint import pformat
import signal import signal
import sys import sys
@ -96,7 +97,7 @@ from ._exceptions import (
MsgTypeError, MsgTypeError,
unpack_error, unpack_error,
) )
from .devx import _debug from .devx import debug
from ._discovery import get_registry from ._discovery import get_registry
from ._portal import Portal from ._portal import Portal
from . import _state from . import _state
@ -111,8 +112,22 @@ if TYPE_CHECKING:
log = get_logger('tractor') log = get_logger('tractor')
def _get_mod_abspath(module): def _get_mod_abspath(module: ModuleType) -> Path:
return os.path.abspath(module.__file__) return Path(module.__file__).absolute()
def get_mod_nsps2fps(mod_ns_paths: list[str]) -> dict[str, str]:
'''
Deliver a table of py module namespace-path-`str`s mapped to
their "physical" `.py` file paths in the file-sys.
'''
nsp2fp: dict[str, str] = {}
for nsp in mod_ns_paths:
mod: ModuleType = importlib.import_module(nsp)
nsp2fp[nsp] = str(_get_mod_abspath(mod))
return nsp2fp
class Actor: class Actor:
@ -219,13 +234,14 @@ class Actor:
# will be passed to children # will be passed to children
self._parent_main_data = _mp_fixup_main._mp_figure_out_main() self._parent_main_data = _mp_fixup_main._mp_figure_out_main()
# TODO? only add this when `is_debug_mode() == True` no?
# always include debugging tools module # always include debugging tools module
enable_modules.append('tractor.devx._debug') if _state.is_root_process():
enable_modules.append('tractor.devx.debug._tty_lock')
self.enable_modules: dict[str, str] = {} self.enable_modules: dict[str, str] = get_mod_nsps2fps(
for name in enable_modules: mod_ns_paths=enable_modules,
mod: ModuleType = importlib.import_module(name) )
self.enable_modules[name] = _get_mod_abspath(mod)
self._mods: dict[str, ModuleType] = {} self._mods: dict[str, ModuleType] = {}
self.loglevel: str = loglevel self.loglevel: str = loglevel
@ -391,7 +407,6 @@ class Actor:
def load_modules( def load_modules(
self, self,
# debug_mode: bool = False,
) -> None: ) -> None:
''' '''
Load explicitly enabled python modules from local fs after Load explicitly enabled python modules from local fs after
@ -413,6 +428,9 @@ class Actor:
parent_data['init_main_from_path']) parent_data['init_main_from_path'])
status: str = 'Attempting to import enabled modules:\n' status: str = 'Attempting to import enabled modules:\n'
modpath: str
filepath: str
for modpath, filepath in self.enable_modules.items(): for modpath, filepath in self.enable_modules.items():
# XXX append the allowed module to the python path which # XXX append the allowed module to the python path which
# should allow for relative (at least downward) imports. # should allow for relative (at least downward) imports.
@ -729,25 +747,33 @@ class Actor:
f'Received invalid non-`SpawnSpec` payload !?\n' f'Received invalid non-`SpawnSpec` payload !?\n'
f'{spawnspec}\n' f'{spawnspec}\n'
) )
# ^^XXX TODO XXX^^^
# ^^TODO XXX!! when the `SpawnSpec` fails to decode # when the `SpawnSpec` fails to decode the above will
# the above will raise a `MsgTypeError` which if we # raise a `MsgTypeError` which if we do NOT ALSO
# do NOT ALSO RAISE it will tried to be pprinted in # RAISE it will tried to be pprinted in the
# the log.runtime() below.. # log.runtime() below..
# #
# SO we gotta look at how other `chan.recv()` calls # SO we gotta look at how other `chan.recv()` calls
# are wrapped and do the same for this spec receive! # are wrapped and do the same for this spec receive!
# -[ ] see `._rpc` likely has the answer? # -[ ] see `._rpc` likely has the answer?
# ^^^XXX NOTE XXX^^^, can't be called here!
# #
# XXX NOTE, can't be called here in subactor
# bc we haven't yet received the
# `SpawnSpec._runtime_vars: dict` which would
# declare whether `debug_mode` is set!
# breakpoint() # breakpoint()
# import pdbp; pdbp.set_trace() # import pdbp; pdbp.set_trace()
#
# => bc we haven't yet received the
# `spawnspec._runtime_vars` which contains
# `debug_mode: bool`..
# `SpawnSpec.bind_addrs`
# ---------------------
accept_addrs: list[UnwrappedAddress] = spawnspec.bind_addrs accept_addrs: list[UnwrappedAddress] = spawnspec.bind_addrs
# TODO: another `Struct` for rtvs.. # `SpawnSpec._runtime_vars`
# -------------------------
# => update process-wide globals
# TODO! -[ ] another `Struct` for rtvs..
rvs: dict[str, Any] = spawnspec._runtime_vars rvs: dict[str, Any] = spawnspec._runtime_vars
if rvs['_debug_mode']: if rvs['_debug_mode']:
from .devx import ( from .devx import (
@ -805,18 +831,20 @@ class Actor:
f'self._infected_aio = {aio_attr}\n' f'self._infected_aio = {aio_attr}\n'
) )
if aio_rtv: if aio_rtv:
assert trio_runtime.GLOBAL_RUN_CONTEXT.runner.is_guest assert (
# ^TODO^ possibly add a `sniffio` or trio_runtime.GLOBAL_RUN_CONTEXT.runner.is_guest
# `trio` pub-API for `is_guest_mode()`? # and
# ^TODO^ possibly add a `sniffio` or
# `trio` pub-API for `is_guest_mode()`?
)
rvs['_is_root'] = False # obvi XD rvs['_is_root'] = False # obvi XD
# update process-wide globals
_state._runtime_vars.update(rvs) _state._runtime_vars.update(rvs)
# XXX: ``msgspec`` doesn't support serializing tuples # `SpawnSpec.reg_addrs`
# so just cash manually here since it's what our # ---------------------
# internals expect. # => update parent provided registrar contact info
# #
self.reg_addrs = [ self.reg_addrs = [
# TODO: we don't really NEED these as tuples? # TODO: we don't really NEED these as tuples?
@ -827,12 +855,24 @@ class Actor:
for val in spawnspec.reg_addrs for val in spawnspec.reg_addrs
] ]
# TODO: better then monkey patching.. # `SpawnSpec.enable_modules`
# -[ ] maybe read the actual f#$-in `._spawn_spec` XD # ---------------------
for _, attr, value in pretty_struct.iter_fields( # => extend RPC-python-module (capabilities) with
spawnspec, # those permitted by parent.
): #
setattr(self, attr, value) # NOTE, only the root actor should have
# a pre-permitted entry for `.devx.debug._tty_lock`.
assert not self.enable_modules
self.enable_modules.update(
spawnspec.enable_modules
)
self._parent_main_data = spawnspec._parent_main_data
# XXX QUESTION(s)^^^
# -[ ] already set in `.__init__()` right, but how is
# it diff from this blatant parent copy?
# -[ ] do we need/want the .__init__() value in
# just the root case orr?
return ( return (
chan, chan,
@ -930,7 +970,7 @@ class Actor:
# kill any debugger request task to avoid deadlock # kill any debugger request task to avoid deadlock
# with the root actor in this tree # with the root actor in this tree
debug_req = _debug.DebugStatus debug_req = debug.DebugStatus
lock_req_ctx: Context = debug_req.req_ctx lock_req_ctx: Context = debug_req.req_ctx
if ( if (
lock_req_ctx lock_req_ctx
@ -940,7 +980,7 @@ class Actor:
msg += ( msg += (
f'\n' f'\n'
f'-> Cancelling active debugger request..\n' f'-> Cancelling active debugger request..\n'
f'|_{_debug.Lock.repr()}\n\n' f'|_{debug.Lock.repr()}\n\n'
f'|_{lock_req_ctx}\n\n' f'|_{lock_req_ctx}\n\n'
) )
# lock_req_ctx._scope.cancel() # lock_req_ctx._scope.cancel()
@ -1266,7 +1306,7 @@ async def async_main(
# attempt to retreive ``trio``'s sigint handler and stash it # attempt to retreive ``trio``'s sigint handler and stash it
# on our debugger state. # on our debugger state.
_debug.DebugStatus._trio_handler = signal.getsignal(signal.SIGINT) debug.DebugStatus._trio_handler = signal.getsignal(signal.SIGINT)
is_registered: bool = False is_registered: bool = False
try: try:
@ -1360,7 +1400,7 @@ async def async_main(
# try: # try:
# actor.load_modules() # actor.load_modules()
# except ModuleNotFoundError as err: # except ModuleNotFoundError as err:
# _debug.pause_from_sync() # debug.pause_from_sync()
# import pdbp; pdbp.set_trace() # import pdbp; pdbp.set_trace()
# raise # raise
@ -1393,7 +1433,7 @@ async def async_main(
# tranport address bind errors - normally it's # tranport address bind errors - normally it's
# something silly like the wrong socket-address # something silly like the wrong socket-address
# passed via a config or CLI Bo # passed via a config or CLI Bo
entered_debug: bool = await _debug._maybe_enter_pm( entered_debug: bool = await debug._maybe_enter_pm(
oserr, oserr,
) )
if not entered_debug: if not entered_debug:
@ -1431,7 +1471,7 @@ async def async_main(
waddr = wrap_address(addr) waddr = wrap_address(addr)
assert waddr.is_valid assert waddr.is_valid
except AssertionError: except AssertionError:
await _debug.pause() await debug.pause()
async with get_registry(addr) as reg_portal: async with get_registry(addr) as reg_portal:
for accept_addr in accept_addrs: for accept_addr in accept_addrs:
@ -1549,7 +1589,7 @@ async def async_main(
# prevents any `infected_aio` actor from continuing # prevents any `infected_aio` actor from continuing
# and any callbacks in the `ls` here WILL NOT be # and any callbacks in the `ls` here WILL NOT be
# called!! # called!!
# await _debug.pause(shield=True) # await debug.pause(shield=True)
ls.close() ls.close()
@ -1562,7 +1602,7 @@ async def async_main(
# #
# if actor.name == 'brokerd.ib': # if actor.name == 'brokerd.ib':
# with CancelScope(shield=True): # with CancelScope(shield=True):
# await _debug.breakpoint() # await debug.breakpoint()
# Unregister actor from the registry-sys / registrar. # Unregister actor from the registry-sys / registrar.
if ( if (
@ -1751,7 +1791,7 @@ class Arbiter(Actor):
waddr: Address = wrap_address(addr) waddr: Address = wrap_address(addr)
if not waddr.is_valid: if not waddr.is_valid:
# should never be 0-dynamic-os-alloc # should never be 0-dynamic-os-alloc
await _debug.pause() await debug.pause()
self._registry[uid] = addr self._registry[uid] = addr

View File

@ -34,7 +34,7 @@ from typing import (
import trio import trio
from trio import TaskStatus from trio import TaskStatus
from .devx._debug import ( from .devx.debug import (
maybe_wait_for_debugger, maybe_wait_for_debugger,
acquire_debug_lock, acquire_debug_lock,
) )

View File

@ -41,16 +41,24 @@ _current_actor: Actor|None = None # type: ignore # noqa
_last_actor_terminated: Actor|None = None _last_actor_terminated: Actor|None = None
# TODO: mk this a `msgspec.Struct`! # TODO: mk this a `msgspec.Struct`!
# -[ ] type out all fields obvi!
# -[ ] (eventually) mk wire-ready for monitoring?
_runtime_vars: dict[str, Any] = { _runtime_vars: dict[str, Any] = {
'_debug_mode': False, # root of actor-process tree info
'_is_root': False, '_is_root': False, # bool
'_root_mailbox': (None, None), '_root_mailbox': (None, None), # tuple[str|None, str|None]
# registrar info
'_registry_addrs': [], '_registry_addrs': [],
'_is_infected_aio': False, # `debug_mode: bool` settings
'_debug_mode': False, # bool
'repl_fixture': False, # |AbstractContextManager[bool]
# for `tractor.pause_from_sync()` & `breakpoint()` support # for `tractor.pause_from_sync()` & `breakpoint()` support
'use_greenback': False, 'use_greenback': False,
# infected-`asyncio`-mode: `trio` running as guest.
'_is_infected_aio': False,
} }

View File

@ -426,8 +426,8 @@ class MsgStream(trio.abc.Channel):
self._closed = re self._closed = re
# if caught_eoc: # if caught_eoc:
# # from .devx import _debug # # from .devx import debug
# # await _debug.pause() # # await debug.pause()
# with trio.CancelScope(shield=True): # with trio.CancelScope(shield=True):
# await rx_chan.aclose() # await rx_chan.aclose()

View File

@ -31,7 +31,7 @@ import warnings
import trio import trio
from .devx._debug import maybe_wait_for_debugger from .devx.debug import maybe_wait_for_debugger
from ._addr import ( from ._addr import (
UnwrappedAddress, UnwrappedAddress,
mk_uuid, mk_uuid,

View File

@ -26,7 +26,7 @@ import os
import pathlib import pathlib
import tractor import tractor
from tractor.devx._debug import ( from tractor.devx.debug import (
BoxedMaybeException, BoxedMaybeException,
) )
from .pytest import ( from .pytest import (

View File

@ -20,7 +20,7 @@ Runtime "developer experience" utils and addons to aid our
and working with/on the actor runtime. and working with/on the actor runtime.
""" """
from ._debug import ( from .debug import (
maybe_wait_for_debugger as maybe_wait_for_debugger, maybe_wait_for_debugger as maybe_wait_for_debugger,
acquire_debug_lock as acquire_debug_lock, acquire_debug_lock as acquire_debug_lock,
breakpoint as breakpoint, breakpoint as breakpoint,

File diff suppressed because it is too large Load Diff

View File

@ -20,13 +20,18 @@ as it pertains to improving the grok-ability of our runtime!
''' '''
from __future__ import annotations from __future__ import annotations
from contextlib import (
_GeneratorContextManager,
_AsyncGeneratorContextManager,
)
from functools import partial from functools import partial
import inspect import inspect
import textwrap
from types import ( from types import (
FrameType, FrameType,
FunctionType, FunctionType,
MethodType, MethodType,
# CodeType, CodeType,
) )
from typing import ( from typing import (
Any, Any,
@ -34,6 +39,9 @@ from typing import (
Type, Type,
) )
import pdbp
from tractor.log import get_logger
import trio
from tractor.msg import ( from tractor.msg import (
pretty_struct, pretty_struct,
NamespacePath, NamespacePath,
@ -41,6 +49,8 @@ from tractor.msg import (
import wrapt import wrapt
log = get_logger(__name__)
# TODO: yeah, i don't love this and we should prolly just # TODO: yeah, i don't love this and we should prolly just
# write a decorator that actually keeps a stupid ref to the func # write a decorator that actually keeps a stupid ref to the func
# obj.. # obj..
@ -301,3 +311,70 @@ def api_frame(
# error_set: set[BaseException], # error_set: set[BaseException],
# ) -> TracebackType: # ) -> TracebackType:
# ... # ...
def hide_runtime_frames() -> dict[FunctionType, CodeType]:
'''
Hide call-stack frames for various std-lib and `trio`-API primitives
such that the tracebacks presented from our runtime are as minimized
as possible, particularly from inside a `PdbREPL`.
'''
# XXX HACKZONE XXX
# hide exit stack frames on nurseries and cancel-scopes!
# |_ so avoid seeing it when the `pdbp` REPL is first engaged from
# inside a `trio.open_nursery()` scope (with no line after it
# in before the block end??).
#
# TODO: FINALLY got this workin originally with
# `@pdbp.hideframe` around the `wrapper()` def embedded inside
# `_ki_protection_decoratior()`.. which is in the module:
# /home/goodboy/.virtualenvs/tractor311/lib/python3.11/site-packages/trio/_core/_ki.py
#
# -[ ] make an issue and patch for `trio` core? maybe linked
# to the long outstanding `pdb` one below?
# |_ it's funny that there's frame hiding throughout `._run.py`
# but not where it matters on the below exit funcs..
#
# -[ ] provide a patchset for the lonstanding
# |_ https://github.com/python-trio/trio/issues/1155
#
# -[ ] make a linked issue to ^ and propose allowing all the
# `._core._run` code to have their `__tracebackhide__` value
# configurable by a `RunVar` to allow getting scheduler frames
# if desired through configuration?
#
# -[ ] maybe dig into the core `pdb` issue why the extra frame is shown
# at all?
#
funcs: list[FunctionType] = [
trio._core._run.NurseryManager.__aexit__,
trio._core._run.CancelScope.__exit__,
_GeneratorContextManager.__exit__,
_AsyncGeneratorContextManager.__aexit__,
_AsyncGeneratorContextManager.__aenter__,
trio.Event.wait,
]
func_list_str: str = textwrap.indent(
"\n".join(f.__qualname__ for f in funcs),
prefix=' |_ ',
)
log.devx(
'Hiding the following runtime frames by default:\n'
f'{func_list_str}\n'
)
codes: dict[FunctionType, CodeType] = {}
for ref in funcs:
# stash a pre-modified version of each ref's code-obj
# so it can be reverted later if needed.
codes[ref] = ref.__code__
pdbp.hideframe(ref)
#
# pdbp.hideframe(trio._core._run.NurseryManager.__aexit__)
# pdbp.hideframe(trio._core._run.CancelScope.__exit__)
# pdbp.hideframe(_GeneratorContextManager.__exit__)
# pdbp.hideframe(_AsyncGeneratorContextManager.__aexit__)
# pdbp.hideframe(_AsyncGeneratorContextManager.__aenter__)
# pdbp.hideframe(trio.Event.wait)
return codes

View File

@ -49,7 +49,7 @@ from tractor import (
_state, _state,
log as logmod, log as logmod,
) )
from tractor.devx import _debug from tractor.devx import debug
log = logmod.get_logger(__name__) log = logmod.get_logger(__name__)
@ -82,7 +82,7 @@ def dump_task_tree() -> None:
if ( if (
current_sigint_handler current_sigint_handler
is not is not
_debug.DebugStatus._trio_handler debug.DebugStatus._trio_handler
): ):
sigint_handler_report: str = ( sigint_handler_report: str = (
'The default `trio` SIGINT handler was replaced?!' 'The default `trio` SIGINT handler was replaced?!'
@ -237,7 +237,7 @@ def enable_stack_on_sig(
try: try:
import stackscope import stackscope
except ImportError: except ImportError:
log.warning( log.error(
'`stackscope` not installed for use in debug mode!' '`stackscope` not installed for use in debug mode!'
) )
return None return None
@ -255,8 +255,8 @@ def enable_stack_on_sig(
dump_tree_on_sig, dump_tree_on_sig,
) )
log.devx( log.devx(
'Enabling trace-trees on `SIGUSR1` ' f'Enabling trace-trees on `SIGUSR1` '
'since `stackscope` is installed @ \n' f'since `stackscope` is installed @ \n'
f'{stackscope!r}\n\n' f'{stackscope!r}\n\n'
f'With `SIGUSR1` handler\n' f'With `SIGUSR1` handler\n'
f'|_{dump_tree_on_sig}\n' f'|_{dump_tree_on_sig}\n'

View File

@ -0,0 +1,100 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
Multi-actor debugging for da peeps!
'''
from __future__ import annotations
from tractor.log import get_logger
from ._repl import (
PdbREPL as PdbREPL,
mk_pdb as mk_pdb,
TractorConfig as TractorConfig,
)
from ._tty_lock import (
DebugStatus as DebugStatus,
DebugStateError as DebugStateError,
)
from ._trace import (
Lock as Lock,
_pause_msg as _pause_msg,
_repl_fail_msg as _repl_fail_msg,
_set_trace as _set_trace,
_sync_pause_from_builtin as _sync_pause_from_builtin,
breakpoint as breakpoint,
maybe_init_greenback as maybe_init_greenback,
maybe_import_greenback as maybe_import_greenback,
pause as pause,
pause_from_sync as pause_from_sync,
)
from ._post_mortem import (
BoxedMaybeException as BoxedMaybeException,
maybe_open_crash_handler as maybe_open_crash_handler,
open_crash_handler as open_crash_handler,
post_mortem as post_mortem,
_crash_msg as _crash_msg,
_maybe_enter_pm as _maybe_enter_pm,
)
from ._sync import (
maybe_wait_for_debugger as maybe_wait_for_debugger,
acquire_debug_lock as acquire_debug_lock,
)
from ._sigint import (
sigint_shield as sigint_shield,
_ctlc_ignore_header as _ctlc_ignore_header
)
log = get_logger(__name__)
# ----------------
# XXX PKG TODO XXX
# ----------------
# refine the internal impl and APIs!
#
# -[ ] rework `._pause()` and it's branch-cases for root vs.
# subactor:
# -[ ] `._pause_from_root()` + `_pause_from_subactor()`?
# -[ ] do the de-factor based on bg-thread usage in
# `.pause_from_sync()` & `_pause_from_bg_root_thread()`.
# -[ ] drop `debug_func == None` case which is confusing af..
# -[ ] factor out `_enter_repl_sync()` into a util func for calling
# the `_set_trace()` / `_post_mortem()` APIs?
#
# -[ ] figure out if we need `acquire_debug_lock()` and/or re-implement
# it as part of the `.pause_from_sync()` rework per above?
#
# -[ ] pair the `._pause_from_subactor()` impl with a "debug nursery"
# that's dynamically allocated inside the `._rpc` task thus
# avoiding the `._service_n.start()` usage for the IPC request?
# -[ ] see the TODO inside `._rpc._errors_relayed_via_ipc()`
#
# -[ ] impl a `open_debug_request()` which encaps all
# `request_root_stdio_lock()` task scheduling deats
# + `DebugStatus` state mgmt; which should prolly be re-branded as
# a `DebugRequest` type anyway AND with suppoort for bg-thread
# (from root actor) usage?
#
# -[ ] handle the `xonsh` case for bg-root-threads in the SIGINT
# handler!
# -[ ] do we need to do the same for subactors?
# -[ ] make the failing tests finally pass XD
#
# -[ ] simplify `maybe_wait_for_debugger()` to be a root-task only
# API?
# -[ ] currently it's implemented as that so might as well make it
# formal?

View File

@ -0,0 +1,411 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
Post-mortem debugging APIs and surrounding machinery for both
sync and async contexts.
Generally we maintain the same semantics a `pdb.post.mortem()` but
with actor-tree-wide sync/cooperation around any (sub)actor's use of
the root's TTY.
'''
from __future__ import annotations
import bdb
from contextlib import (
AbstractContextManager,
contextmanager as cm,
nullcontext,
)
from functools import (
partial,
)
import inspect
import sys
import traceback
from typing import (
Callable,
Sequence,
Type,
TYPE_CHECKING,
)
from types import (
TracebackType,
FrameType,
)
from msgspec import Struct
import trio
from tractor._exceptions import (
NoRuntime,
)
from tractor import _state
from tractor._state import (
current_actor,
debug_mode,
)
from tractor.log import get_logger
from tractor._exceptions import (
is_multi_cancelled,
)
from ._trace import (
_pause,
)
from ._tty_lock import (
DebugStatus,
)
from ._repl import (
PdbREPL,
mk_pdb,
TractorConfig as TractorConfig,
)
if TYPE_CHECKING:
from trio.lowlevel import Task
from tractor._runtime import (
Actor,
)
_crash_msg: str = (
'Opening a pdb REPL in crashed actor'
)
log = get_logger(__package__)
class BoxedMaybeException(Struct):
'''
Box a maybe-exception for post-crash introspection usage
from the body of a `open_crash_handler()` scope.
'''
value: BaseException|None = None
# handler can suppress crashes dynamically
raise_on_exit: bool|Sequence[Type[BaseException]] = True
def pformat(self) -> str:
'''
Repr the boxed `.value` error in more-than-string
repr form.
'''
if not self.value:
return f'<{type(self).__name__}( .value=None )>\n'
return (
f'<{type(self.value).__name__}(\n'
f' |_.value = {self.value}\n'
f')>\n'
)
__repr__ = pformat
def _post_mortem(
repl: PdbREPL, # normally passed by `_pause()`
# XXX all `partial`-ed in by `post_mortem()` below!
tb: TracebackType,
api_frame: FrameType,
shield: bool = False,
hide_tb: bool = True,
# maybe pre/post REPL entry
repl_fixture: (
AbstractContextManager[bool]
|None
) = None,
boxed_maybe_exc: BoxedMaybeException|None = None,
) -> None:
'''
Enter the ``pdbpp`` port mortem entrypoint using our custom
debugger instance.
'''
__tracebackhide__: bool = hide_tb
# maybe enter any user fixture
enter_repl: bool = DebugStatus.maybe_enter_repl_fixture(
repl=repl,
repl_fixture=repl_fixture,
boxed_maybe_exc=boxed_maybe_exc,
)
if not enter_repl:
return
try:
actor: Actor = current_actor()
actor_repr: str = str(actor.uid)
# ^TODO, instead a nice runtime-info + maddr + uid?
# -[ ] impl a `Actor.__repr()__`??
# |_ <task>:<thread> @ <actor>
except NoRuntime:
actor_repr: str = '<no-actor-runtime?>'
try:
task_repr: Task = trio.lowlevel.current_task()
except RuntimeError:
task_repr: str = '<unknown-Task>'
# TODO: print the actor supervion tree up to the root
# here! Bo
log.pdb(
f'{_crash_msg}\n'
f'x>(\n'
f' |_ {task_repr} @ {actor_repr}\n'
)
# XXX NOTE(s) on `pdbp.xpm()` version..
#
# - seems to lose the up-stack tb-info?
# - currently we're (only) replacing this from `pdbp.xpm()`
# to add the `end=''` to the print XD
#
print(traceback.format_exc(), end='')
caller_frame: FrameType = api_frame.f_back
# NOTE, see the impl details of these in the lib to
# understand usage:
# - `pdbp.post_mortem()`
# - `pdbp.xps()`
# - `bdb.interaction()`
repl.reset()
repl.interaction(
frame=caller_frame,
# frame=None,
traceback=tb,
)
# XXX NOTE XXX: this is abs required to avoid hangs!
#
# Since we presume the post-mortem was enaged to
# a task-ending error, we MUST release the local REPL request
# so that not other local task nor the root remains blocked!
DebugStatus.release()
async def post_mortem(
*,
tb: TracebackType|None = None,
api_frame: FrameType|None = None,
hide_tb: bool = False,
# TODO: support shield here just like in `pause()`?
# shield: bool = False,
**_pause_kwargs,
) -> None:
'''
Our builtin async equivalient of `pdb.post_mortem()` which can be
used inside exception handlers.
It's also used for the crash handler when `debug_mode == True` ;)
'''
__tracebackhide__: bool = hide_tb
tb: TracebackType = tb or sys.exc_info()[2]
# TODO: do upward stack scan for highest @api_frame and
# use its parent frame as the expected user-app code
# interact point.
api_frame: FrameType = api_frame or inspect.currentframe()
# TODO, move to submod `._pausing` or ._api? _trace
await _pause(
debug_func=partial(
_post_mortem,
api_frame=api_frame,
tb=tb,
),
hide_tb=hide_tb,
**_pause_kwargs
)
async def _maybe_enter_pm(
err: BaseException,
*,
tb: TracebackType|None = None,
api_frame: FrameType|None = None,
hide_tb: bool = False,
# only enter debugger REPL when returns `True`
debug_filter: Callable[
[BaseException|BaseExceptionGroup],
bool,
] = lambda err: not is_multi_cancelled(err),
**_pause_kws,
):
if (
debug_mode()
# NOTE: don't enter debug mode recursively after quitting pdb
# Iow, don't re-enter the repl if the `quit` command was issued
# by the user.
and not isinstance(err, bdb.BdbQuit)
# XXX: if the error is the likely result of runtime-wide
# cancellation, we don't want to enter the debugger since
# there's races between when the parent actor has killed all
# comms and when the child tries to contact said parent to
# acquire the tty lock.
# Really we just want to mostly avoid catching KBIs here so there
# might be a simpler check we can do?
and
debug_filter(err)
):
api_frame: FrameType = api_frame or inspect.currentframe()
tb: TracebackType = tb or sys.exc_info()[2]
await post_mortem(
api_frame=api_frame,
tb=tb,
**_pause_kws,
)
return True
else:
return False
# TODO: better naming and what additionals?
# - [ ] optional runtime plugging?
# - [ ] detection for sync vs. async code?
# - [ ] specialized REPL entry when in distributed mode?
# -[x] hide tb by def
# - [x] allow ignoring kbi Bo
@cm
def open_crash_handler(
catch: set[BaseException] = {
BaseException,
},
ignore: set[BaseException] = {
KeyboardInterrupt,
trio.Cancelled,
},
hide_tb: bool = True,
repl_fixture: (
AbstractContextManager[bool] # pre/post REPL entry
|None
) = None,
raise_on_exit: bool|Sequence[Type[BaseException]] = True,
):
'''
Generic "post mortem" crash handler using `pdbp` REPL debugger.
We expose this as a CLI framework addon to both `click` and
`typer` users so they can quickly wrap cmd endpoints which get
automatically wrapped to use the runtime's `debug_mode: bool`
AND `pdbp.pm()` around any code that is PRE-runtime entry
- any sync code which runs BEFORE the main call to
`trio.run()`.
'''
__tracebackhide__: bool = hide_tb
# TODO, yield a `outcome.Error`-like boxed type?
# -[~] use `outcome.Value/Error` X-> frozen!
# -[x] write our own..?
# -[ ] consider just wtv is used by `pytest.raises()`?
#
boxed_maybe_exc = BoxedMaybeException(
raise_on_exit=raise_on_exit,
)
err: BaseException
try:
yield boxed_maybe_exc
except tuple(catch) as err:
boxed_maybe_exc.value = err
if (
type(err) not in ignore
and
not is_multi_cancelled(
err,
ignore_nested=ignore
)
):
try:
# use our re-impl-ed version of `pdbp.xpm()`
_post_mortem(
repl=mk_pdb(),
tb=sys.exc_info()[2],
api_frame=inspect.currentframe().f_back,
hide_tb=hide_tb,
repl_fixture=repl_fixture,
boxed_maybe_exc=boxed_maybe_exc,
)
except bdb.BdbQuit:
__tracebackhide__: bool = False
raise err
if (
raise_on_exit is True
or (
raise_on_exit is not False
and (
set(raise_on_exit)
and
type(err) in raise_on_exit
)
)
and
boxed_maybe_exc.raise_on_exit == raise_on_exit
):
raise err
@cm
def maybe_open_crash_handler(
pdb: bool|None = None,
hide_tb: bool = True,
**kwargs,
):
'''
Same as `open_crash_handler()` but with bool input flag
to allow conditional handling.
Normally this is used with CLI endpoints such that if the --pdb
flag is passed the pdb REPL is engaed on any crashes B)
'''
__tracebackhide__: bool = hide_tb
if pdb is None:
pdb: bool = _state.is_debug_mode()
rtctx = nullcontext(
enter_result=BoxedMaybeException()
)
if pdb:
rtctx = open_crash_handler(
hide_tb=hide_tb,
**kwargs,
)
with rtctx as boxed_maybe_exc:
yield boxed_maybe_exc

View File

@ -0,0 +1,207 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
`pdpp.Pdb` extentions/customization and other delegate usage.
'''
from functools import (
cached_property,
)
import os
import pdbp
from tractor._state import (
is_root_process,
)
from ._tty_lock import (
Lock,
DebugStatus,
)
class TractorConfig(pdbp.DefaultConfig):
'''
Custom `pdbp` config which tries to use the best tradeoff
between pretty and minimal.
'''
use_pygments: bool = True
sticky_by_default: bool = False
enable_hidden_frames: bool = True
# much thanks @mdmintz for the hot tip!
# fixes line spacing issue when resizing terminal B)
truncate_long_lines: bool = False
# ------ - ------
# our own custom config vars mostly
# for syncing with the actor tree's singleton
# TTY `Lock`.
class PdbREPL(pdbp.Pdb):
'''
Add teardown hooks and local state describing any
ongoing TTY `Lock` request dialog.
'''
# override the pdbp config with our coolio one
# NOTE: this is only loaded when no `~/.pdbrc` exists
# so we should prolly pass it into the .__init__() instead?
# i dunno, see the `DefaultFactory` and `pdb.Pdb` impls.
DefaultConfig = TractorConfig
status = DebugStatus
# NOTE: see details in stdlib's `bdb.py`
# def user_exception(self, frame, exc_info):
# '''
# Called when we stop on an exception.
# '''
# log.warning(
# 'Exception during REPL sesh\n\n'
# f'{frame}\n\n'
# f'{exc_info}\n\n'
# )
# NOTE: this actually hooks but i don't see anyway to detect
# if an error was caught.. this is why currently we just always
# call `DebugStatus.release` inside `_post_mortem()`.
# def preloop(self):
# print('IN PRELOOP')
# super().preloop()
# TODO: cleaner re-wrapping of all this?
# -[ ] figure out how to disallow recursive .set_trace() entry
# since that'll cause deadlock for us.
# -[ ] maybe a `@cm` to call `super().<same_meth_name>()`?
# -[ ] look at hooking into the `pp` hook specially with our
# own set of pretty-printers?
# * `.pretty_struct.Struct.pformat()`
# * `.pformat(MsgType.pld)`
# * `.pformat(Error.tb_str)`?
# * .. maybe more?
#
def set_continue(self):
try:
super().set_continue()
finally:
# NOTE: for subactors the stdio lock is released via the
# allocated RPC locker task, so for root we have to do it
# manually.
if (
is_root_process()
and
Lock._debug_lock.locked()
and
DebugStatus.is_main_trio_thread()
):
# Lock.release(raise_on_thread=False)
Lock.release()
# XXX AFTER `Lock.release()` for root local repl usage
DebugStatus.release()
def set_quit(self):
try:
super().set_quit()
finally:
if (
is_root_process()
and
Lock._debug_lock.locked()
and
DebugStatus.is_main_trio_thread()
):
# Lock.release(raise_on_thread=False)
Lock.release()
# XXX after `Lock.release()` for root local repl usage
DebugStatus.release()
# XXX NOTE: we only override this because apparently the stdlib pdb
# bois likes to touch the SIGINT handler as much as i like to touch
# my d$%&.
def _cmdloop(self):
self.cmdloop()
@cached_property
def shname(self) -> str | None:
'''
Attempt to return the login shell name with a special check for
the infamous `xonsh` since it seems to have some issues much
different from std shells when it comes to flushing the prompt?
'''
# SUPER HACKY and only really works if `xonsh` is not used
# before spawning further sub-shells..
shpath = os.getenv('SHELL', None)
if shpath:
if (
os.getenv('XONSH_LOGIN', default=False)
or 'xonsh' in shpath
):
return 'xonsh'
return os.path.basename(shpath)
return None
def mk_pdb() -> PdbREPL:
'''
Deliver a new `PdbREPL`: a multi-process safe `pdbp.Pdb`-variant
using the magic of `tractor`'s SC-safe IPC.
B)
Our `pdb.Pdb` subtype accomplishes multi-process safe debugging
by:
- mutexing access to the root process' std-streams (& thus parent
process TTY) via an IPC managed `Lock` singleton per
actor-process tree.
- temporarily overriding any subactor's SIGINT handler to shield
during live REPL sessions in sub-actors such that cancellation
is never (mistakenly) triggered by a ctrl-c and instead only by
explicit runtime API requests or after the
`pdb.Pdb.interaction()` call has returned.
FURTHER, the `pdbp.Pdb` instance is configured to be `trio`
"compatible" from a SIGINT handling perspective; we mask out
the default `pdb` handler and instead apply `trio`s default
which mostly addresses all issues described in:
- https://github.com/python-trio/trio/issues/1155
The instance returned from this factory should always be
preferred over the default `pdb[p].set_trace()` whenever using
a `pdb` REPL inside a `trio` based runtime.
'''
pdb = PdbREPL()
# XXX: These are the important flags mentioned in
# https://github.com/python-trio/trio/issues/1155
# which resolve the traceback spews to console.
pdb.allow_kbdint = True
pdb.nosigint = True
return pdb

View File

@ -0,0 +1,333 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
A custom SIGINT handler which mainly shields actor (task)
cancellation during REPL interaction.
'''
from __future__ import annotations
from typing import (
TYPE_CHECKING,
)
import trio
from tractor.log import get_logger
from tractor._state import (
current_actor,
is_root_process,
)
from ._repl import (
PdbREPL,
)
from ._tty_lock import (
any_connected_locker_child,
DebugStatus,
Lock,
)
if TYPE_CHECKING:
from tractor.ipc import (
Channel,
)
from tractor._runtime import (
Actor,
)
log = get_logger(__name__)
_ctlc_ignore_header: str = (
'Ignoring SIGINT while debug REPL in use'
)
def sigint_shield(
signum: int,
frame: 'frame', # type: ignore # noqa
*args,
) -> None:
'''
Specialized, debugger-aware SIGINT handler.
In childred we always ignore/shield for SIGINT to avoid
deadlocks since cancellation should always be managed by the
supervising parent actor. The root actor-proces is always
cancelled on ctrl-c.
'''
__tracebackhide__: bool = True
actor: Actor = current_actor()
def do_cancel():
# If we haven't tried to cancel the runtime then do that instead
# of raising a KBI (which may non-gracefully destroy
# a ``trio.run()``).
if not actor._cancel_called:
actor.cancel_soon()
# If the runtime is already cancelled it likely means the user
# hit ctrl-c again because teardown didn't fully take place in
# which case we do the "hard" raising of a local KBI.
else:
raise KeyboardInterrupt
# only set in the actor actually running the REPL
repl: PdbREPL|None = DebugStatus.repl
# TODO: maybe we should flatten out all these cases using
# a match/case?
#
# root actor branch that reports whether or not a child
# has locked debugger.
if is_root_process():
# log.warning(
log.devx(
'Handling SIGINT in root actor\n'
f'{Lock.repr()}'
f'{DebugStatus.repr()}\n'
)
# try to see if the supposed (sub)actor in debug still
# has an active connection to *this* actor, and if not
# it's likely they aren't using the TTY lock / debugger
# and we should propagate SIGINT normally.
any_connected: bool = any_connected_locker_child()
problem = (
f'root {actor.uid} handling SIGINT\n'
f'any_connected: {any_connected}\n\n'
f'{Lock.repr()}\n'
)
if (
(ctx := Lock.ctx_in_debug)
and
(uid_in_debug := ctx.chan.uid) # "someone" is (ostensibly) using debug `Lock`
):
name_in_debug: str = uid_in_debug[0]
assert not repl
# if not repl: # but it's NOT us, the root actor.
# sanity: since no repl ref is set, we def shouldn't
# be the lock owner!
assert name_in_debug != 'root'
# IDEAL CASE: child has REPL as expected
if any_connected: # there are subactors we can contact
# XXX: only if there is an existing connection to the
# (sub-)actor in debug do we ignore SIGINT in this
# parent! Otherwise we may hang waiting for an actor
# which has already terminated to unlock.
#
# NOTE: don't emit this with `.pdb()` level in
# root without a higher level.
log.runtime(
_ctlc_ignore_header
+
f' by child '
f'{uid_in_debug}\n'
)
problem = None
else:
problem += (
'\n'
f'A `pdb` REPL is SUPPOSEDLY in use by child {uid_in_debug}\n'
f'BUT, no child actors are IPC contactable!?!?\n'
)
# IDEAL CASE: root has REPL as expected
else:
# root actor still has this SIGINT handler active without
# an actor using the `Lock` (a bug state) ??
# => so immediately cancel any stale lock cs and revert
# the handler!
if not DebugStatus.repl:
# TODO: WHEN should we revert back to ``trio``
# handler if this one is stale?
# -[ ] maybe after a counts work of ctl-c mashes?
# -[ ] use a state var like `stale_handler: bool`?
problem += (
'No subactor is using a `pdb` REPL according `Lock.ctx_in_debug`?\n'
'BUT, the root should be using it, WHY this handler ??\n\n'
'So either..\n'
'- some root-thread is using it but has no `.repl` set?, OR\n'
'- something else weird is going on outside the runtime!?\n'
)
else:
# NOTE: since we emit this msg on ctl-c, we should
# also always re-print the prompt the tail block!
log.pdb(
_ctlc_ignore_header
+
f' by root actor..\n'
f'{DebugStatus.repl_task}\n'
f' |_{repl}\n'
)
problem = None
# XXX if one is set it means we ARE NOT operating an ideal
# case where a child subactor or us (the root) has the
# lock without any other detected problems.
if problem:
# detect, report and maybe clear a stale lock request
# cancel scope.
lock_cs: trio.CancelScope = Lock.get_locking_task_cs()
maybe_stale_lock_cs: bool = (
lock_cs is not None
and not lock_cs.cancel_called
)
if maybe_stale_lock_cs:
problem += (
'\n'
'Stale `Lock.ctx_in_debug._scope: CancelScope` detected?\n'
f'{Lock.ctx_in_debug}\n\n'
'-> Calling ctx._scope.cancel()!\n'
)
lock_cs.cancel()
# TODO: wen do we actually want/need this, see above.
# DebugStatus.unshield_sigint()
log.warning(problem)
# child actor that has locked the debugger
elif not is_root_process():
log.debug(
f'Subactor {actor.uid} handling SIGINT\n\n'
f'{Lock.repr()}\n'
)
rent_chan: Channel = actor._parent_chan
if (
rent_chan is None
or
not rent_chan.connected()
):
log.warning(
'This sub-actor thinks it is debugging '
'but it has no connection to its parent ??\n'
f'{actor.uid}\n'
'Allowing SIGINT propagation..'
)
DebugStatus.unshield_sigint()
repl_task: str|None = DebugStatus.repl_task
req_task: str|None = DebugStatus.req_task
if (
repl_task
and
repl
):
log.pdb(
_ctlc_ignore_header
+
f' by local task\n\n'
f'{repl_task}\n'
f' |_{repl}\n'
)
elif req_task:
log.debug(
_ctlc_ignore_header
+
f' by local request-task and either,\n'
f'- someone else is already REPL-in and has the `Lock`, or\n'
f'- some other local task already is replin?\n\n'
f'{req_task}\n'
)
# TODO can we remove this now?
# -[ ] does this path ever get hit any more?
else:
msg: str = (
'SIGINT shield handler still active BUT, \n\n'
)
if repl_task is None:
msg += (
'- No local task claims to be in debug?\n'
)
if repl is None:
msg += (
'- No local REPL is currently active?\n'
)
if req_task is None:
msg += (
'- No debug request task is active?\n'
)
log.warning(
msg
+
'Reverting handler to `trio` default!\n'
)
DebugStatus.unshield_sigint()
# XXX ensure that the reverted-to-handler actually is
# able to rx what should have been **this** KBI ;)
do_cancel()
# TODO: how to handle the case of an intermediary-child actor
# that **is not** marked in debug mode? See oustanding issue:
# https://github.com/goodboy/tractor/issues/320
# elif debug_mode():
# maybe redraw/print last REPL output to console since
# we want to alert the user that more input is expect since
# nothing has been done dur to ignoring sigint.
if (
DebugStatus.repl # only when current actor has a REPL engaged
):
flush_status: str = (
'Flushing stdout to ensure new prompt line!\n'
)
# XXX: yah, mega hack, but how else do we catch this madness XD
if (
repl.shname == 'xonsh'
):
flush_status += (
'-> ALSO re-flushing due to `xonsh`..\n'
)
repl.stdout.write(repl.prompt)
# log.warning(
log.devx(
flush_status
)
repl.stdout.flush()
# TODO: better console UX to match the current "mode":
# -[ ] for example if in sticky mode where if there is output
# detected as written to the tty we redraw this part underneath
# and erase the past draw of this same bit above?
# repl.sticky = True
# repl._print_if_sticky()
# also see these links for an approach from `ptk`:
# https://github.com/goodboy/tractor/issues/130#issuecomment-663752040
# https://github.com/prompt-toolkit/python-prompt-toolkit/blob/c2c6af8a0308f9e5d7c0e28cb8a02963fe0ce07a/prompt_toolkit/patch_stdout.py
else:
log.devx(
# log.warning(
'Not flushing stdout since not needed?\n'
f'|_{repl}\n'
)
# XXX only for tracing this handler
log.devx('exiting SIGINT')

View File

@ -0,0 +1,220 @@
# tractor: structured concurrent "actors".
# Copyright 2018-eternity Tyler Goodlet.
# This program is free software: you can redistribute it and/or
# modify it under the terms of the GNU Affero General Public License
# as published by the Free Software Foundation, either version 3 of
# the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Affero General Public License for more details.
# You should have received a copy of the GNU Affero General Public
# License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
'''
Debugger synchronization APIs to ensure orderly access and
non-TTY-clobbering graceful teardown.
'''
from __future__ import annotations
from contextlib import (
asynccontextmanager as acm,
)
from functools import (
partial,
)
from typing import (
AsyncGenerator,
Callable,
)
from tractor.log import get_logger
import trio
from trio.lowlevel import (
current_task,
Task,
)
from tractor._context import Context
from tractor._state import (
current_actor,
debug_mode,
is_root_process,
)
from ._repl import (
TractorConfig as TractorConfig,
)
from ._tty_lock import (
Lock,
request_root_stdio_lock,
any_connected_locker_child,
)
from ._sigint import (
sigint_shield as sigint_shield,
_ctlc_ignore_header as _ctlc_ignore_header
)
log = get_logger(__package__)
async def maybe_wait_for_debugger(
poll_steps: int = 2,
poll_delay: float = 0.1,
child_in_debug: bool = False,
header_msg: str = '',
_ll: str = 'devx',
) -> bool: # was locked and we polled?
if (
not debug_mode()
and
not child_in_debug
):
return False
logmeth: Callable = getattr(log, _ll)
msg: str = header_msg
if (
is_root_process()
):
# If we error in the root but the debugger is
# engaged we don't want to prematurely kill (and
# thus clobber access to) the local tty since it
# will make the pdb repl unusable.
# Instead try to wait for pdb to be released before
# tearing down.
ctx_in_debug: Context|None = Lock.ctx_in_debug
in_debug: tuple[str, str]|None = (
ctx_in_debug.chan.uid
if ctx_in_debug
else None
)
if in_debug == current_actor().uid:
log.debug(
msg
+
'Root already owns the TTY LOCK'
)
return True
elif in_debug:
msg += (
f'Debug `Lock` in use by subactor\n|\n|_{in_debug}\n'
)
# TODO: could this make things more deterministic?
# wait to see if a sub-actor task will be
# scheduled and grab the tty lock on the next
# tick?
# XXX => but it doesn't seem to work..
# await trio.testing.wait_all_tasks_blocked(cushion=0)
else:
logmeth(
msg
+
'Root immediately acquired debug TTY LOCK'
)
return False
for istep in range(poll_steps):
if (
Lock.req_handler_finished is not None
and not Lock.req_handler_finished.is_set()
and in_debug is not None
):
# caller_frame_info: str = pformat_caller_frame()
logmeth(
msg
+
'\n^^ Root is waiting on tty lock release.. ^^\n'
# f'{caller_frame_info}\n'
)
if not any_connected_locker_child():
Lock.get_locking_task_cs().cancel()
with trio.CancelScope(shield=True):
await Lock.req_handler_finished.wait()
log.devx(
f'Subactor released debug lock\n'
f'|_{in_debug}\n'
)
break
# is no subactor locking debugger currently?
if (
in_debug is None
and (
Lock.req_handler_finished is None
or Lock.req_handler_finished.is_set()
)
):
logmeth(
msg
+
'Root acquired tty lock!'
)
break
else:
logmeth(
'Root polling for debug:\n'
f'poll step: {istep}\n'
f'poll delya: {poll_delay}\n\n'
f'{Lock.repr()}\n'
)
with trio.CancelScope(shield=True):
await trio.sleep(poll_delay)
continue
return True
# else:
# # TODO: non-root call for #320?
# this_uid: tuple[str, str] = current_actor().uid
# async with acquire_debug_lock(
# subactor_uid=this_uid,
# ):
# pass
return False
@acm
async def acquire_debug_lock(
subactor_uid: tuple[str, str],
) -> AsyncGenerator[
trio.CancelScope|None,
tuple,
]:
'''
Request to acquire the TTY `Lock` in the root actor, release on
exit.
This helper is for actor's who don't actually need to acquired
the debugger but want to wait until the lock is free in the
process-tree root such that they don't clobber an ongoing pdb
REPL session in some peer or child!
'''
if not debug_mode():
yield None
return
task: Task = current_task()
async with trio.open_nursery() as n:
ctx: Context = await n.start(
partial(
request_root_stdio_lock,
actor_uid=subactor_uid,
task_uid=(task.name, id(task)),
)
)
yield ctx
ctx.cancel()

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -43,7 +43,7 @@ from trio import (
SocketListener, SocketListener,
) )
# from ..devx import _debug # from ..devx import debug
from .._exceptions import ( from .._exceptions import (
TransportClosed, TransportClosed,
) )
@ -107,7 +107,7 @@ async def handle_stream_from_peer(
server._no_more_peers = trio.Event() # unset by making new server._no_more_peers = trio.Event() # unset by making new
# TODO, debug_mode tooling for when hackin this lower layer? # TODO, debug_mode tooling for when hackin this lower layer?
# with _debug.maybe_open_crash_handler( # with debug.maybe_open_crash_handler(
# pdb=True, # pdb=True,
# ) as boxerr: # ) as boxerr:
@ -343,7 +343,7 @@ async def handle_stream_from_peer(
# - is root but `open_root_actor()` was # - is root but `open_root_actor()` was
# entered manually (in which case we do # entered manually (in which case we do
# the equiv wait there using the # the equiv wait there using the
# `devx._debug` sub-sys APIs). # `devx.debug` sub-sys APIs).
not local_nursery._implicit_runtime_started not local_nursery._implicit_runtime_started
): ):
log.runtime( log.runtime(
@ -456,8 +456,8 @@ async def handle_stream_from_peer(
and and
_state.is_debug_mode() _state.is_debug_mode()
): ):
from ..devx import _debug from ..devx import debug
pdb_lock = _debug.Lock pdb_lock = debug.Lock
pdb_lock._blocked.add(uid) pdb_lock._blocked.add(uid)
# TODO: NEEEDS TO BE TESTED! # TODO: NEEEDS TO BE TESTED!
@ -492,7 +492,7 @@ async def handle_stream_from_peer(
f'last disconnected child uid: {uid}\n' f'last disconnected child uid: {uid}\n'
f'locking child uid: {pdb_user_uid}\n' f'locking child uid: {pdb_user_uid}\n'
) )
await _debug.maybe_wait_for_debugger( await debug.maybe_wait_for_debugger(
child_in_debug=True child_in_debug=True
) )

View File

@ -608,7 +608,7 @@ async def drain_to_final_msg(
# #
# -[ ] make sure pause points work here for REPLing # -[ ] make sure pause points work here for REPLing
# the runtime itself; i.e. ensure there's no hangs! # the runtime itself; i.e. ensure there's no hangs!
# |_from tractor.devx._debug import pause # |_from tractor.devx.debug import pause
# await pause() # await pause()
# NOTE: we get here if the far end was # NOTE: we get here if the far end was

View File

@ -49,7 +49,7 @@ from tractor._state import (
_runtime_vars, _runtime_vars,
) )
from tractor._context import Unresolved from tractor._context import Unresolved
from tractor.devx import _debug from tractor.devx import debug
from tractor.log import ( from tractor.log import (
get_logger, get_logger,
StackLevelAdapter, StackLevelAdapter,
@ -479,7 +479,7 @@ def _run_asyncio_task(
if ( if (
debug_mode() debug_mode()
and and
(greenback := _debug.maybe_import_greenback( (greenback := debug.maybe_import_greenback(
force_reload=True, force_reload=True,
raise_not_found=False, raise_not_found=False,
)) ))
@ -841,7 +841,7 @@ async def translate_aio_errors(
except BaseException as _trio_err: except BaseException as _trio_err:
trio_err = chan._trio_err = _trio_err trio_err = chan._trio_err = _trio_err
# await tractor.pause(shield=True) # workx! # await tractor.pause(shield=True) # workx!
entered: bool = await _debug._maybe_enter_pm( entered: bool = await debug._maybe_enter_pm(
trio_err, trio_err,
api_frame=inspect.currentframe(), api_frame=inspect.currentframe(),
) )
@ -1406,7 +1406,7 @@ def run_as_asyncio_guest(
) )
# XXX make it obvi we know this isn't supported yet! # XXX make it obvi we know this isn't supported yet!
assert 0 assert 0
# await _debug.maybe_init_greenback( # await debug.maybe_init_greenback(
# force_reload=True, # force_reload=True,
# ) # )