Reorg `.devx.debug` into sub-mods!
Which cleans out the pkg-mod to just the expected exports with (its longstanding todo comment list) and thus a separation-of-concerns and smaller mod-file sizes via the following new sub-mods: - `._trace` for the `.pause()`/`breakpoint()`/`pdb.set_trace()`-style APIs including all sync-caller variants. - `._post_mortem` to contain our async `.post_mortem()` and all other public crash handling APIs for use from sync callers. - `._sync` for the high-level syncing helper-routines used throughout the runtime to avoid multi-proc TTY use collisions. And also, - remove `hide_runtime_frames()` since moved to `.devx._frame_stack`.repl_fixture
parent
69267ae656
commit
f1e9926b79
File diff suppressed because it is too large
Load Diff
|
@ -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,
|
||||
_maybe_open_repl_fixture,
|
||||
)
|
||||
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
|
||||
|
||||
with _maybe_open_repl_fixture(
|
||||
repl_fixture=repl_fixture,
|
||||
boxed_maybe_exc=boxed_maybe_exc,
|
||||
) as enter_repl:
|
||||
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
|
|
@ -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
Loading…
Reference in New Issue