Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ wheelhouse/
*.dist-info/
.installed.cfg
*.egg
build/

# PyInstaller
# Usually these files are written by a python script from a template
Expand Down
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,22 @@ while True:
...
```

### `post_mortem()` function
Debugpy supports a `post_mortem()` function similar to `pdb.post_mortem()`. Call `debugpy.post_mortem(e)` with an exception or `(type(e),e,e.__traceback__)` tuple; or with no arguments in an except block. If the debugger is attached, it will pause execution and start post-mortem debugging of the exception stack as-if an uncaught exception. This respects breakpoint filters set by the debugger by default. When resuming afterward, the program will continue executing as normal (including unwinding the stack further if post_mortem() was invoked in a context manager's `__exit__` for example, or the exception is re-raised). If there's no client attached, this function does nothing, as breakpoint().

```python
import debugpy
debugpy.listen(...)

...
def risky_function():
raise ValueError("threw an exception")
try:
risky_function()
except Exception as e:
debugpy.post_mortem(e)
```

## Debugger logging

To enable debugger internal logging via CLI, the `--log-to` switch can be used:
Expand Down
1 change: 1 addition & 0 deletions src/debugpy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"is_client_connected",
"listen",
"log_to",
"post_mortem",
"trace_this_thread",
"wait_for_client",
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1823,6 +1823,53 @@ def stop_monitoring(all_threads=False):
thread_info.trace = False


# fmt: off
# IFDEF CYTHON
# cpdef bint suspend_current_thread_tracing():
# cdef ThreadInfo thread_info
# ELSE
def suspend_current_thread_tracing():
# ENDIF
# fmt: on
"""
Suspends tracing for the current thread.

Returns the previous tracing state (True if tracing was enabled, False otherwise).
This is useful for temporarily disabling tracing to prevent recursive debugging.

Use resume_current_thread_tracing() to restore.
"""
try:
thread_info = _thread_local_info.thread_info
except:
thread_info = _get_thread_info(False, 1)
if thread_info is None:
return False
previous_state = thread_info.trace
thread_info.trace = False
return previous_state


# fmt: off
# IFDEF CYTHON
# cpdef resume_current_thread_tracing():
# cdef ThreadInfo thread_info
# ELSE
def resume_current_thread_tracing():
# ENDIF
# fmt: on
"""
Resumes tracing for the current thread.
"""
try:
thread_info = _thread_local_info.thread_info
except:
thread_info = _get_thread_info(False, 1)
if thread_info is None:
return
thread_info.trace = True


def update_monitor_events(suspend_requested: Optional[bool]=None) -> None:
"""
This should be called when breakpoints change.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1829,6 +1829,81 @@ cpdef stop_monitoring(all_threads=False):
thread_info.trace = False


# fmt: off
# IFDEF CYTHON -- DONT EDIT THIS FILE (it is automatically generated)
cpdef bint suspend_current_thread_tracing():
cdef ThreadInfo thread_info
# ELSE
# def suspend_current_thread_tracing():
# ENDIF
# fmt: on
"""
Suspends tracing for the current thread.

Returns the previous tracing state (True if tracing was enabled, False otherwise).
This is useful for temporarily disabling tracing to prevent recursive debugging.

Use resume_current_thread_tracing() or set_current_thread_tracing_state() to restore.
"""
try:
thread_info = _thread_local_info.thread_info
except:
thread_info = _get_thread_info(False, 1)
if thread_info is None:
return False
previous_state = thread_info.trace
thread_info.trace = False
return previous_state


# fmt: off
# IFDEF CYTHON -- DONT EDIT THIS FILE (it is automatically generated)
cpdef resume_current_thread_tracing():
cdef ThreadInfo thread_info
# ELSE
# def resume_current_thread_tracing():
# ENDIF
# fmt: on
"""
Resumes tracing for the current thread.

This unconditionally enables tracing. For conditional restoration,
use set_current_thread_tracing_state().
"""
try:
thread_info = _thread_local_info.thread_info
except:
thread_info = _get_thread_info(False, 1)
if thread_info is None:
return
thread_info.trace = True


# fmt: off
# IFDEF CYTHON -- DONT EDIT THIS FILE (it is automatically generated)
cpdef set_current_thread_tracing_state(bint trace):
cdef ThreadInfo thread_info
# ELSE
# def set_current_thread_tracing_state(trace):
# ENDIF
# fmt: on
"""
Sets the tracing state for the current thread.

:param trace: True to enable tracing, False to disable.

This is typically used to restore a previously saved state from
suspend_current_thread_tracing().
"""
try:
thread_info = _thread_local_info.thread_info
except:
thread_info = _get_thread_info(False, 1)
if thread_info is None:
return
thread_info.trace = trace


def update_monitor_events(suspend_requested: Optional[bool]=None) -> None:
"""
This should be called when breakpoints change.
Expand Down
45 changes: 45 additions & 0 deletions src/debugpy/_vendored/pydevd/pydevd.py
Original file line number Diff line number Diff line change
Expand Up @@ -2392,6 +2392,51 @@ def do_stop_on_unhandled_exception(self, thread, frame, frames_byid, arg):
remove_exception_from_frame(frame)
frame = None

def post_mortem(self, excinfo, as_uncaught=True):
"""
Triggers post-mortem debugging as if handling an uncaught exception.

If as_uncaught is True (default), respects exception breakpoint configuration and applies breakpoint filters.

:param excinfo: A tuple of (exc_type, exc_value, exc_traceback).
"""
if not as_uncaught:
exctype, value, tb = excinfo

# Walk traceback to build frames list and find user frame
frames = []
user_frame = None
while tb is not None:
frame = tb.tb_frame
# Skip debugger-internal frames, use last user frame
if self.get_file_type(frame) is None:
user_frame = frame
frames.append(frame)
tb = tb.tb_next

if user_frame is None:
pydev_log.debug("post_mortem: no user frame found in traceback")
return

frames_byid = dict([(id(frame), frame) for frame in frames])

if PYDEVD_USE_SYS_MONITORING:
saved_sys_monitoring_trace = pydevd_sys_monitoring.suspend_current_thread_tracing()
thread = threading.current_thread()
additional_info = self.set_additional_thread_info(thread)
additional_info.is_tracing += 1

try:
if as_uncaught:
self.stop_on_unhandled_exception(self, thread, additional_info, excinfo)
else:
self.do_stop_on_unhandled_exception(thread, user_frame, frames_byid, excinfo)
finally:
if PYDEVD_USE_SYS_MONITORING:
if saved_sys_monitoring_trace:
pydevd_sys_monitoring.resume_current_thread_tracing()
additional_info.is_tracing -= 1

def set_trace_for_frame_and_parents(self, thread_ident: Optional[int], frame, **kwargs):
disable = kwargs.pop("disable", False)
assert not kwargs
Expand Down
27 changes: 27 additions & 0 deletions src/debugpy/public_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,33 @@ def trace_this_thread(__should_trace: bool):
"""


@_api()
def post_mortem(
__excinfo: typing.Tuple[type, BaseException, typing.Any] | BaseException | None = None,
as_uncaught: bool = True,
) -> None:
"""Stops the debugger on an unhandled exception.

If a debug client is connected, pauses execution as if an
unhandled exception was caught. This allows inspection of the
exception and call stack at the point of failure.

If no exception info is provided, uses sys.exc_info() to get
the current exception. If there is no current exception and no
argument is provided, does nothing.

Safe to call when no debugger is connected (returns immediately).

Example::

try:
risky_operation()
except Exception:
debugpy.postmortem() # Uses current exception
raise
"""


def get_cli_options() -> CliOptions | None:
"""Returns the CLI options that were processed by debugpy.

Expand Down
29 changes: 29 additions & 0 deletions src/debugpy/server/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,32 @@ def trace_this_thread(should_trace):
pydb.enable_tracing()
else:
pydb.disable_tracing()


def post_mortem(excinfo=None, as_uncaught=True):
ensure_logging()

if excinfo is None:
excinfo = sys.exc_info()

if isinstance(excinfo, BaseException):
excinfo = (type(excinfo), excinfo, excinfo.__traceback__)

exctype, value, tb = excinfo
if exctype is None or value is None or tb is None:
log.debug("postmortem() ignored - no exception info")
return

if not is_client_connected():
log.info("postmortem() ignored - debugger not attached")
return

log.debug("postmortem({0!r})", excinfo)

pydb = get_global_debugger()
if pydb is None:
log.warning("postmortem() ignored - no global debugger")
return

pydb.post_mortem(excinfo, as_uncaught=as_uncaught)

Loading