Skip to content
Open
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
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 exception_on_frame
from _pydev_bundle._pydev_saved_modules import threading


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

frames_byid = dict([(id(frame), frame) for frame in frames])
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)
# Attach __exception__ so conditions/expressions can reference it; always
# detach as the thread may keep running (do_stop re-adds it while suspended).
with exception_on_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:
additional_info.pydev_message = exception_breakpoint.qname
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,32 @@ def remove_exception_from_frame(frame):
frame.f_locals.pop("__exception__", None)


_NO_EXCEPTION = object()


class exception_on_frame(object):
"""Exposes '__exception__' in the frame's locals during a stop, then restores
whatever was there before. Restoring by assignment (rather than deleting) avoids
a ValueError on 3.14+, where FrameLocalsProxy forbids deleting a real local -- as
happens when the user's own code has a variable named '__exception__'."""

def __init__(self, frame, exception_info):
self.frame = 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.

Hmm, I'm wondering if we can just stick the exception on the frame as an attribute. Modifying the locals is usually not so great because then the user sees it in the locals list (and the potential conflict). Although it did that before. But that might explain the error you were talking about with 3.14.

Something like this instead:

self.frame.__stored_exception = exception_info

Then removing it would just be deleting that attribute.

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.

TBH I'm not sure what __exception__ is used for. I assumed the point was for users to see it in the locals list (eg. vscode debugging terminal). Or maybe it's a legacy thing used by Eclipse?

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.

Looking at it, it seems it takes special precautions to remove it from the locals just for that reason. So that users don't see it. I think we can probably just change addExceptionToFrame and removeExceptionFromFrame by just adding an attribute on it. Then we wouldn't have to special case locals.

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.

That can't be the case because in actual fact I do see it when i use the debugger (with vanilla debugpy release). For example, in vscode's python debugger, __exception__ appears as "special variable" under the locals in the Variables pane. And afaict it's not used for anything internally, so if we were going to make it a frame attribute (no longer visible externally) we might as well delete it entirely. The purpose of the cleanup after the stop is so when the thread continues running (eg. userUnhandled exception path where it's not fatal) it doesn't clutter your locals during unrelated later stops.

self.exception_info = exception_info

def __enter__(self):
self._previous = self.frame.f_locals.get("__exception__", _NO_EXCEPTION)
add_exception_to_frame(self.frame, self.exception_info)
return self

def __exit__(self, *args):
if self._previous is _NO_EXCEPTION:
remove_exception_from_frame(self.frame)
else:
self.frame.f_locals["__exception__"] = self._previous
self.frame = None


FILES_WITH_IMPORT_HOOKS = ["pydev_monkey_qt.py", "pydev_import_hook.py"]


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
Loading