Re-impl as `DebugStatus.maybe_enter_repl_fixture()`

Dropping the `_maybe_open_repl_fixture()` approach and instead using
a `DebugStatus._fixture_stack = ExitStack()` which provides for much
simpler support around both sync and async pausing APIs thanks to only
invoking `repl_fixture.__exit__()` on actual `PdbREPL` interaction being
complete!

Deats,
- all `repl_fixture` detection logic still happens in one place (the new
  method) but we aren't limited to closing it via an immediate post REPL
  `.__exit__()` call which instead is triggered by,
- `DebugStatus.release()` which now calls `._fixture_stack.close()` and
  thus only invokes `repl_fixture.__exit__()` when user REPL-ing is
  **actually complete** an arbitrary amount of debugging time later.
- include the notes for `@acm` support above the new method, though not
  sure if they're as relevant any more?

Benefits,
- we can drop the previously added indent levels from
  `_enter_repl_sync()` and `_post_mortem()`.
- now we automatically have support for the `.pause_from_sync()` API
  since `_enter_repl_sync()` doesn't close the prior
  `_maybe_open_repl_fixture()` immediately when `debug_func=None`; the
  user's `__exit__()` is only ever called once `.release()` is.

Other,
- add big 'CASE' comments around the various blocks in
  `.pause_from_sync()`, i was having trouble figuring out which i was
  using from a `breakpoint()` in a dependent app..
repl_fixture
Tyler Goodlet 2025-05-18 12:33:05 -04:00
parent d716c57234
commit 2a37f6abdd
3 changed files with 207 additions and 194 deletions

View File

@ -64,7 +64,6 @@ from tractor._exceptions import (
)
from ._trace import (
_pause,
_maybe_open_repl_fixture,
)
from ._tty_lock import (
DebugStatus,
@ -143,65 +142,65 @@ def _post_mortem(
'''
__tracebackhide__: bool = hide_tb
with _maybe_open_repl_fixture(
# 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,
) as enter_repl:
if not enter_repl:
return
)
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>
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?>'
except NoRuntime:
actor_repr: str = '<no-actor-runtime?>'
try:
task_repr: Task = trio.lowlevel.current_task()
except RuntimeError:
task_repr: str = '<unknown-Task>'
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'
# 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
# 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,
)
# 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()
# 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(

View File

@ -28,8 +28,6 @@ import asyncio
import bdb
from contextlib import (
AbstractContextManager,
contextmanager as cm,
nullcontext,
)
from functools import (
partial,
@ -37,7 +35,6 @@ from functools import (
import inspect
import threading
from typing import (
Iterator,
Callable,
TYPE_CHECKING,
)
@ -89,7 +86,7 @@ if TYPE_CHECKING:
from tractor._runtime import (
Actor,
)
from ._post_mortem import BoxedMaybeException
# from ._post_mortem import BoxedMaybeException
from ._repl import PdbREPL
log = get_logger(__package__)
@ -99,69 +96,6 @@ _repl_fail_msg: str|None = (
'Failed to REPl via `_pause()` '
)
# TODO, support @acm?
# -[ ] what about a return-proto for determining
# whether the REPL should be allowed to enage?
# -[ ] consider factoring this `_repl_fixture` block into
# a common @cm somehow so it can be repurposed both here and
# in `._pause()`??
# -[ ] we could also use the `ContextDecorator`-type in that
# case to simply decorate the `_enter_repl_sync()` closure?
# |_https://docs.python.org/3/library/contextlib.html#using-a-context-manager-as-a-function-decorator
@cm
def _maybe_open_repl_fixture(
repl: PdbREPL,
# ^XXX **always provided** by the low-level REPL-invoker,
# - _post_mortem()
# - _pause()
repl_fixture: (
AbstractContextManager[bool]
|None
) = None,
boxed_maybe_exc: BoxedMaybeException|None = None,
) -> Iterator[bool]:
'''
Maybe open a pre/post REPL entry "fixture" `@cm` provided by the
user, the caller should use the delivered `bool` to determine
whether to engage the `PdbREPL`.
'''
if not (
repl_fixture
or
(rt_repl_fixture := _state._runtime_vars.get('repl_fixture'))
):
_repl_fixture = nullcontext(
enter_result=True,
)
else:
_repl_fixture = (
repl_fixture
or
rt_repl_fixture
)(
repl=repl,
maybe_bxerr=boxed_maybe_exc
)
with _repl_fixture as enter_repl:
# XXX when the fixture doesn't allow it, skip
# the crash-handler REPL and raise now!
if not enter_repl:
log.pdb(
f'pdbp-REPL blocked by a `repl_fixture()` which yielded `False` !\n'
f'repl_fixture: {repl_fixture}\n'
f'rt_repl_fixture: {rt_repl_fixture}\n'
)
yield False # no don't enter REPL
return
yield True # yes enter REPL
async def _pause(
debug_func: Callable|partial|None,
@ -255,90 +189,86 @@ async def _pause(
) -> None:
__tracebackhide__: bool = hide_tb
# TODO, support @acm?
# -[ ] what about a return-proto for determining
# whether the REPL should be allowed to enage?
# nonlocal repl_fixture
with _maybe_open_repl_fixture(
# maybe enter any user fixture
enter_repl: bool = DebugStatus.maybe_enter_repl_fixture(
repl=repl,
repl_fixture=repl_fixture,
) as enter_repl:
if not enter_repl:
return
)
if not enter_repl:
return
debug_func_name: str = (
debug_func.func.__name__ if debug_func else 'None'
)
debug_func_name: str = (
debug_func.func.__name__ if debug_func else 'None'
)
# TODO: do we want to support using this **just** for the
# locking / common code (prolly to help address #320)?
task_status.started((task, repl))
try:
if debug_func:
# block here one (at the appropriate frame *up*) where
# ``breakpoint()`` was awaited and begin handling stdio.
log.devx(
'Entering sync world of the `pdb` REPL for task..\n'
f'{repl}\n'
f' |_{task}\n'
)
# TODO: do we want to support using this **just** for the
# locking / common code (prolly to help address #320)?
task_status.started((task, repl))
try:
if debug_func:
# block here one (at the appropriate frame *up*) where
# ``breakpoint()`` was awaited and begin handling stdio.
log.devx(
'Entering sync world of the `pdb` REPL for task..\n'
f'{repl}\n'
f' |_{task}\n'
)
# set local task on process-global state to avoid
# recurrent entries/requests from the same
# actor-local task.
DebugStatus.repl_task = task
if repl:
DebugStatus.repl = repl
else:
log.error(
'No REPl instance set before entering `debug_func`?\n'
f'{debug_func}\n'
)
# invoke the low-level REPL activation routine which itself
# should call into a `Pdb.set_trace()` of some sort.
debug_func(
repl=repl,
hide_tb=hide_tb,
**debug_func_kwargs,
# set local task on process-global state to avoid
# recurrent entries/requests from the same
# actor-local task.
DebugStatus.repl_task = task
if repl:
DebugStatus.repl = repl
else:
log.error(
'No REPl instance set before entering `debug_func`?\n'
f'{debug_func}\n'
)
# TODO: maybe invert this logic and instead
# do `assert debug_func is None` when
# `called_from_sync`?
else:
if (
called_from_sync
and
not DebugStatus.is_main_trio_thread()
):
assert called_from_bg_thread
assert DebugStatus.repl_task is not task
return (task, repl)
except trio.Cancelled:
log.exception(
'Cancelled during invoke of internal\n\n'
f'`debug_func = {debug_func_name}`\n'
# invoke the low-level REPL activation routine which itself
# should call into a `Pdb.set_trace()` of some sort.
debug_func(
repl=repl,
hide_tb=hide_tb,
**debug_func_kwargs,
)
# XXX NOTE: DON'T release lock yet
raise
except BaseException:
__tracebackhide__: bool = False
log.exception(
'Failed to invoke internal\n\n'
f'`debug_func = {debug_func_name}`\n'
)
# NOTE: OW this is ONLY called from the
# `.set_continue/next` hooks!
DebugStatus.release(cancel_req_task=True)
# TODO: maybe invert this logic and instead
# do `assert debug_func is None` when
# `called_from_sync`?
else:
if (
called_from_sync
and
not DebugStatus.is_main_trio_thread()
):
assert called_from_bg_thread
assert DebugStatus.repl_task is not task
raise
return (task, repl)
log.devx(
except trio.Cancelled:
log.exception(
'Cancelled during invoke of internal\n\n'
f'`debug_func = {debug_func_name}`\n'
)
# XXX NOTE: DON'T release lock yet
raise
except BaseException:
__tracebackhide__: bool = False
log.exception(
'Failed to invoke internal\n\n'
f'`debug_func = {debug_func_name}`\n'
)
# NOTE: OW this is ONLY called from the
# `.set_continue/next` hooks!
DebugStatus.release(cancel_req_task=True)
raise
log.debug(
'Entering `._pause()` for requesting task\n'
f'|_{task}\n'
)
@ -352,7 +282,7 @@ async def _pause(
or
DebugStatus.repl_release.is_set()
):
log.devx(
log.debug(
'Setting new `DebugStatus.repl_release: trio.Event` for requesting task\n'
f'|_{task}\n'
)
@ -431,7 +361,7 @@ async def _pause(
'with no request ctx !?!?'
)
log.devx(
log.debug(
f'attempting to {acq_prefix}acquire '
f'{ctx_line}'
)
@ -1022,6 +952,8 @@ def pause_from_sync(
# noop: non-cancelled `.to_thread`
# `trio.Cancelled`: cancelled `.to_thread`
# CASE: bg-thread spawned via `trio.to_thread`
# -----
# when called from a (bg) thread, run an async task in a new
# thread which will call `._pause()` manually with special
# handling for root-actor caller usage.
@ -1107,6 +1039,9 @@ def pause_from_sync(
# '`tractor.pause[_from_sync]()` not yet supported '
# 'for infected `asyncio` mode!'
# )
#
# CASE: bg-thread running `asyncio.Task`
# -----
elif (
not is_trio_thread
and
@ -1184,7 +1119,9 @@ def pause_from_sync(
f'- greenback.bestow_portal(<task>)\n'
)
else: # we are presumably the `trio.run()` + main thread
# CASE: `trio.run()` + "main thread"
# -----
else:
# raises on not-found by default
greenback: ModuleType = maybe_import_greenback()
@ -1286,7 +1223,9 @@ def _sync_pause_from_builtin(
Proxy call `.pause_from_sync()` but indicate the caller is the
`breakpoint()` built-in.
Note: this assigned to `os.environ['PYTHONBREAKPOINT']` inside `._root`
Note: this always assigned to `os.environ['PYTHONBREAKPOINT']`
inside `._root.open_root_actor()` whenever `debug_mode=True` is
set.
'''
pause_from_sync(

View File

@ -22,7 +22,9 @@ Root-actor TTY mutex-locking machinery.
from __future__ import annotations
import asyncio
from contextlib import (
AbstractContextManager,
asynccontextmanager as acm,
ExitStack,
)
import textwrap
import threading
@ -75,6 +77,9 @@ if TYPE_CHECKING:
from ._repl import (
PdbREPL,
)
from ._post_mortem import (
BoxedMaybeException,
)
log = get_logger(__name__)
@ -601,6 +606,10 @@ class DebugStatus:
# request.
repl: PdbREPL|None = None
# any `repl_fixture` provided by user are entered and
# latered closed on `.release()`
_fixture_stack = ExitStack()
# TODO: yet again this looks like a task outcome where we need
# to sync to the completion of one task (and get its result)
# being used everywhere for syncing..
@ -803,6 +812,70 @@ class DebugStatus:
return False
# TODO, support @acm?
# -[ ] what about a return-proto for determining
# whether the REPL should be allowed to enage?
# -[x] consider factoring this `_repl_fixture` block into
# a common @cm somehow so it can be repurposed both here and
# in `._pause()`??
# -[ ] we could also use the `ContextDecorator`-type in that
# case to simply decorate the `_enter_repl_sync()` closure?
# |_https://docs.python.org/3/library/contextlib.html#using-a-context-manager-as-a-function-decorator
@classmethod
def maybe_enter_repl_fixture(
cls,
# ^XXX **always provided** by the low-level REPL-invoker,
# - _post_mortem()
# - _pause()
repl: PdbREPL,
# maybe pre/post REPL entry
repl_fixture: (
AbstractContextManager[bool]
|None
) = None,
# if called from crashed context, provided by
# `open_crash_handler()`
boxed_maybe_exc: BoxedMaybeException|None = None,
) -> bool:
'''
Maybe open a pre/post REPL entry "fixture" `@cm` provided by the
user, the caller should use the delivered `bool` to determine
whether to engage the `PdbREPL`.
'''
if not (
repl_fixture
or
(rt_repl_fixture := _state._runtime_vars.get('repl_fixture'))
):
return True # YES always enter
_repl_fixture = (
repl_fixture
or
rt_repl_fixture
)
enter_repl: bool = DebugStatus._fixture_stack.enter_context(
_repl_fixture(
repl=repl,
maybe_bxerr=boxed_maybe_exc,
)
)
if not enter_repl:
log.pdb(
f'pdbp-REPL blocked by a `repl_fixture()` which yielded `False` !\n'
f'repl_fixture: {repl_fixture}\n'
f'rt_repl_fixture: {rt_repl_fixture}\n'
)
log.devx(
f'User provided `repl_fixture` entered with,\n'
f'{repl_fixture!r} -> {enter_repl!r}\n'
)
return enter_repl
@classmethod
# @pdbp.hideframe
def release(
@ -890,6 +963,8 @@ class DebugStatus:
if current_actor(err_on_no_runtime=False):
cls.unshield_sigint()
cls._fixture_stack.close()
# TODO: use the new `@lowlevel.singleton` for this!
def get_debug_req() -> DebugStatus|None: