Skip to content
Open
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:
...
```

### `trigger_exception_handler()` function
Debugpy supports a `trigger_exception_handler()` function for post-mortem inspection of a caught exception, similar in spirit to `pdb.post_mortem()`. Call `debugpy.trigger_exception_handler(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 trigger_exception_handler() 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.trigger_exception_handler(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 @@ -20,6 +20,7 @@
"listen",
"log_to",
"trace_this_thread",
"trigger_exception_handler",
"wait_for_client",
]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1866,6 +1866,56 @@ 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:
# Create the thread info if it doesn't exist yet: if tracing is not
# explicitly disabled here, the first monitoring event on this thread
# would create it with tracing enabled.
thread_info = _get_thread_info(True, 1)
if thread_info is None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📍 src/debugpy/_vendored/pydevd/_pydevd_sys_monitoring/_pydevd_sys_monitoring.py:1875
When the except branch freshly creates a ThreadInfo via _get_thread_info(True, 1) (tracing enabled), it still reports previous_state = True. The caller's finally then sees saved == True and re-enables tracing on a thread that was not previously traced, contradicting "restore previous state" and potentially causing unexpected breakpoint stops on background threads. When the ThreadInfo is created here rather than found, return False so the caller skips the resume. Mirror the same fix in the .pyx (and regenerate the .c).

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(True, 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

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -1858,6 +1858,56 @@ 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() to restore.
"""
try:
thread_info = _thread_local_info.thread_info
except:
# Create the thread info if it doesn't exist yet: if tracing is not
# explicitly disabled here, the first monitoring event on this thread
# would create it with tracing enabled.
thread_info = _get_thread_info(True, 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.
"""
try:
thread_info = _thread_local_info.thread_info
except:
thread_info = _get_thread_info(True, 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
45 changes: 45 additions & 0 deletions src/debugpy/_vendored/pydevd/pydevd.py
Original file line number Diff line number Diff line change
Expand Up @@ -2427,6 +2427,51 @@ def do_stop_on_unhandled_exception(self, thread, frame, frames_byid, arg):
remove_exception_from_frame(frame)
frame = None

def trigger_exception_handler(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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📍 src/debugpy/_vendored/pydevd/pydevd.py:2430
The as_uncaught=False path silently no-ops when the traceback contains only library frames (user_frame stays None → debug log + return). The README/docstring imply as_uncaught=False always enters post-mortem, so a caller wrapping a library-only exception gets a surprising silent non-stop. At minimum document this; consider surfacing it more loudly than a debug-level log.

pydev_log.debug("trigger_exception_handler: 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:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📍 src/debugpy/_vendored/pydevd/pydevd.py:2466
self.stop_on_unhandled_exception(self, thread, additional_info, excinfo) is correct — line 765 binds the module-level function as an instance attribute, so the explicit self is the intended py_db first arg. It is NOT a bug, but it reads like a double-self mistake and is a maintenance trap a future reader may "fix" and break. Add a one-line comment (e.g. # instance attr holds a module-level fn; first arg is py_db).

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📍 src/debugpy/_vendored/pydevd/pydevd.py:2455
The Skeptic verified (reproduced with a snippet: tracing restored? False) that suspend_current_thread_tracing(), set_additional_thread_info, and is_tracing += 1 all run outside the try. If anything raises in that window — most realistically a KeyboardInterrupt during a live debug session, or a failure in set_additional_thread_info — the finally calling resume_current_thread_tracing() never runs, leaving thread_info.trace = False permanently and silently killing breakpoints on that thread for the rest of the session. Move the suspend + is_tracing += 1 setup inside the try so the finally always restores state.


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 trigger_exception_handler(
__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.trigger_exception_handler() # Uses current exception
raise
"""


Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📍 src/debugpy/public_api.py:215
The as_uncaught parameter is never documented in the public docstring or README, despite changing a non-obvious, inverted-feeling contract: True respects the uncaught-exception breakpoint filters (may not stop), while False forces post-mortem regardless. Document both as_uncaught semantics and note the as_uncaught=False + library-only-traceback case.

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 trigger_exception_handler(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("trigger_exception_handler() ignored - no exception info")
return

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

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

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

pydb.trigger_exception_handler(excinfo, as_uncaught=as_uncaught)

Loading
Loading