Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,24 @@ while True:
...
```

### `trigger_exception_handler()` function
`debugpy.trigger_exception_handler(e)` starts post-mortem debugging of a caught exception, similar in spirit to `pdb.post_mortem()`: if a client is attached, it pauses execution as if the exception were uncaught. Pass an exception, a `(type, value, traceback)` tuple, or nothing inside an except block to use the current exception. On resume the program continues as normal; if no client is attached the call does nothing, like `breakpoint()`.

By default (`as_uncaught=True`) the stop respects the exception breakpoint configuration in the debugger UI: if breaking on uncaught exceptions isn't enabled, or its filters exclude the exception, nothing happens. Pass `as_uncaught=False` to stop unconditionally. Since the traceback has already been unwound, you can inspect frames and evaluate expressions at the stop, but not step through the code that raised.

```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
@@ -1,6 +1,6 @@
from _pydev_bundle import pydev_log
from _pydevd_bundle import pydevd_import_class
from _pydevd_bundle.pydevd_frame_utils import add_exception_to_frame
from _pydevd_bundle.pydevd_frame_utils import add_exception_to_frame, remove_exception_from_frame
from _pydev_bundle._pydev_saved_modules import threading


Expand Down Expand Up @@ -155,14 +155,19 @@ def stop_on_unhandled_exception(py_db, thread, additional_info, arg):
return

frames_byid = dict([(id(frame), frame) for frame in frames])
# Attach __exception__ so conditions/expressions can reference it; always
# detach as the thread may keep running (do_stop re-adds it while suspended).
add_exception_to_frame(user_frame, arg)
if exception_breakpoint.condition is not None:
eval_result = py_db.handle_breakpoint_condition(additional_info, exception_breakpoint, user_frame)
if not eval_result:
return

if exception_breakpoint.expression is not None:
py_db.handle_breakpoint_expression(exception_breakpoint, additional_info, user_frame)
try:
if exception_breakpoint.condition is not None:
eval_result = py_db.handle_breakpoint_condition(additional_info, exception_breakpoint, user_frame)
if not eval_result:
return

if exception_breakpoint.expression is not None:
py_db.handle_breakpoint_expression(exception_breakpoint, additional_info, user_frame)
finally:
remove_exception_from_frame(user_frame)

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.

On Python 3.14, an existing __exception__ local makes remove_exception_from_frame() raise ValueError, aborting the default stop and potentially masking the caught exception after an unconditional stop. Preserve and restore any previous binding rather than unconditionally removing it, with regression tests for both as_uncaught modes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Oh, you're right. It does fail on 3.14. I've fixed this with a context manager that restores the previous state, using it in both places.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Interestingly, Claude says this is also a problem on HEAD. It shows me this repro testcase for the userUnhandled exception handler, which seemingly hangs the unit test unless the context manager/restore method is used there:

import pytest

from tests import debug
from tests.patterns import some
from tests.timeline import Event


def test_uu_repro(pyfile, target, run):
    @pyfile
    def code_to_debug():
        import threading

        import debuggee

        debuggee.setup()

        def thread_target():
            __exception__ = "sentinel"  # real local in the user boundary frame
            raise ValueError("boom")  # @raise

        t = threading.Thread(target=thread_target)
        t.start()
        t.join()
        print("main continued after thread")  # @done

    with debug.Session() as session:
        session.config["justMyCode"] = True
        session.expected_exit_code = some.int
        with run(session, target(code_to_debug)):
            session.request(
                "setExceptionBreakpoints", {"filters": ["userUnhandled"]}
            )
            session.set_breakpoints(code_to_debug, ["done"])

        session.wait_for_stop("exception")
        session.request_continue()
        session.wait_for_next(Event("exited") | Event("terminated"))

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.

The tests do run in 3.14 as far as I know, so it should be working in main

@nshepperd nshepperd Jul 17, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Right, but you didn't have a unit test that puts an existing local named __exception__, as far as i can see.


try:
additional_info.pydev_message = exception_breakpoint.qname
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1866,6 +1866,51 @@ 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 and returns the previous state,
to be restored with resume_current_thread_tracing().
"""
try:
thread_info = _thread_local_info.thread_info
except:
# Create the ThreadInfo if missing; monitoring events create it with
# tracing enabled by default, which would defeat the suspension.
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).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

I think how it is currently is actually correct. True is the default state (for everything except pydevd-daemon threads), in the sense that every other monitoring event sets thread_info like that if it doesn't exist. So that's what we should restore.

Comment thread
nshepperd marked this conversation as resolved.
return False
previous_state = thread_info.trace
Comment thread
nshepperd marked this conversation as resolved.
thread_info.trace = False
return previous_state


# fmt: off

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:1887
The except branch still creates thread_info via _get_thread_info(True, 1) (default trace=True) and returns previous_state = True, contradicting the docstring's "returns the previous state" for a thread that was never traced (Skeptic, Architect, and Rules all re-confirm this). However, the Advocate's caller-trace shows the literal return False requested last round is not a clean fix: returning False before setting thread_info.trace = False skips suppression during the stop (defeating the PR's PEP-669 purpose on this path), while returning False after suspending would leave the fresh thread permanently at trace=False (a suppression leak). The current behavior — suppress during the stop, then resume to the thread's True default — is actually functionally correct. Resolve the contract honestly: either clarify the docstring that for a freshly-created ThreadInfo "previous state" is the thread's default (enabled), or, if a fresh thread should end untraced, suspend during the stop AND return False so resume is skipped. Mirror whatever you choose in the .pyx and regenerate the .c.

[verified]

# 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
Loading