Skip to content

Uncaught Exceptions breakpoint lifetime extends exception object #1999

Description

Issue

When enabling "Uncaught Exceptions breakpoint" with debugpy this lifetime extends exceptions and in return their traceback until the next unwind event afterwards. This leads to behaviour changes under the debugger. The following minimal reproducer shows this behaviour change

import weakref

class Test:
    def test(self):
        raise Exception

  a = Test()
  b = weakref.ref(a)
  try:
      a.test()
  except:
      pass
  finally:
      a = None

  # None unless running under debugpy with "Uncaught Exceptions breakpoint" enabled
  print(b())

since Exception tracebacks contain a lot of references this can keep a lot of things alive for longer than expected.

This wasn't an issue prior to the sys.monitoring based implementation for Python 3.12.

Original Message

I am unsure what exactly changes since we didn't use debugpy in around a year. In the meantime we also switched from Python 3.11 to Python 3.13.
Our code base relies on objects being destroyed e.g. so signals are disconnected when debugging our code base.

When attempting to use debugpy again with our recent versions we experience crashes because the objects are kept alive via a frame object and so the signals aren't disconnected. Presumably this is caused by changes like the python 3.12 low impact monitoring.

Is it known what causes this + what would be the best way to avoid this?

Activity

  1. changed the title [-]debugpy keeps frames alive[/-] [+]debugpy keeps objects alive via the frame object[/+] on Feb 24, 2026
  2. rchiodo commented on Feb 24, 2026

    @rchiodo
    Contributor

    Do you have some code that reproduces the issue? It's likely our eval that's adding a reference to something. 3.12 changed how everything worked.

  3. maxbachmann commented on Feb 24, 2026

    @maxbachmann
    ContributorAuthor

    Not right now but I can try to write a smaller reproducer.

  4. maxbachmann commented on Mar 4, 2026

    @maxbachmann
    ContributorAuthor

    signal.py
    mySignal.cpp

    I finally managed to make a reproducer for my issue I can share

    The repo includes a signal.py to debug and a mySignal.cpp for a C++ signal that is required to trigger the issue.
    To compile the C++ signal on Linux I did run

    pip install pybind11
    c++ -O3 -Wall -shared -std=c++11 -fPIC $(python3 -m pybind11 --includes) mySignal.cpp -o mySignal$(python3-config --extension-suffix)
    

    Running signal.py without debugpy on python3.11/3.13 just prints switch twice and then exits.
    Running it under debugpy (python3.11) in vs code also just prints switch twice and then exits.
    Running it under debugpy (python3.13) in vs code triggers the assert in line 72.

    This occurs because the reference is kept alive -> the signal continues to call it when I already expect it to be dead.
    I don't have any breakpoints or watched variables.

  5. rchiodo commented on Mar 4, 2026

    @rchiodo
    Contributor

    Thanks for the repro. Might be a while before somebody gets to this though.

    I was going to try this. It should theoretically show where the references are coming from.

    import objgraph
    objgraph.show_backrefs(obj, max_depth=5)
    
  6. maxbachmann commented on Mar 4, 2026

    @maxbachmann
    ContributorAuthor

    I did slighly extend using

    def main():
        a = StateMachine(GameState)
        a.execute()
        import weakref
    
        state = weakref.ref(a.state)
        while not shutdown:
            nextFrame()
            b = state()
            if b is not None:
                import objgraph
                objgraph.show_backrefs([b], max_depth=5)
            b = None
    
        a.leave()

    without debugpy this exports nothing. With debugpy it exports

    Image

    which isn't particularly helpful. The refcount at this point is 3. It's worth noting that the lifetime is only extended. So after the next nextFrame call it is eventually dropped. However this is already too late since it leads to the signal getting called on the already "dead" object.

  7. rchiodo commented on Mar 4, 2026

    @rchiodo
    Contributor

    If it is eventually dropped, that sounds like a potential normal case? How can you be sure that references will immediately go to zero when you get rid of your reference?

    I'd hazard a guess that it's something in the sys.monitoring callbacks that requires another function to be called until it gets rid of all of its references.

  8. maxbachmann commented on Mar 4, 2026

    @maxbachmann
    ContributorAuthor

    There probably is no strict guarantee on anything since as far as I know Python generally has no lifetime guarantees.
    It doesn't appear like something in the sys.monitoring system itself since I can just subscribe to all events without running into this:

    TOOL_ID = 1
    monitoring.use_tool_id(TOOL_ID, "demo-monitor")
    EVENTS = 0
    for name in dir(monitoring.events):
        if name.isupper() and name != "NO_EVENTS":
            event = getattr(monitoring.events, name)
            EVENTS |= event
            monitoring.register_callback(TOOL_ID, event, on_call)
    
    monitoring.set_events(TOOL_ID, EVENTS)
    

    I don't see why without any breakpoints/watch variables debugpy would have to continue holding references after the function returned. Especially since it didn't have to do so in the past.

    Overall it just isn't a great experience when behaviour changes under a debugger.

  9. rchiodo commented on Mar 4, 2026

    @rchiodo
    Contributor

    Debuggers try to not impact the debuggee, but that's not always possible - especially with soft mode debuggers like debugpy is. For example, the debugger definitely causes other modules to load into the program.

    I'd say references that go away after a longer period of time is likely an expectation of running a debugger.

    But maybe in this case it's a simple fix. Like we clear out some variables after a callback.

  10. maxbachmann commented on Mar 4, 2026

    @maxbachmann
    ContributorAuthor

    For reference if instead of objgraph I use gc.get_referrers(b) I get:
    <class 'frame'> <frame at 0x7f8afecd7740, file 'signal.py', line 30, code run>.

    This is:

     def run(self):
          while True:
              yield
    
      self.__frame = self.run()
      next(self.__frame)
    

    and inside the callback from C++ I set self.__frame to None. Something continues to keep this frame alive which in turn keeps the self reference alive.

  11. 27 remaining items

  12. rchiodo commented on Mar 6, 2026

    @rchiodo
    Contributor

    That's what I get for asking copilot. It made it sound like this was a special case.

  13. maxbachmann commented on Mar 6, 2026

    @maxbachmann
    ContributorAuthor

    I could write a patch that does it by attaching a tag to the exception. It's just up to debugpy/pydevd whether that is considered acceptable since that is a different side effect. For us it would be much better but maybe it's a problem for others.

    Maybe this is also something to bring up to Cpython since if we really need to get the frame on the first raise I don't really see a way to handle this without either the lifetime extension (which is pretty problematic for exceptions due to the heavy traceback) or some kind of weakref on the exception.

  14. rchiodo commented on Mar 6, 2026

    @rchiodo
    Contributor

    Attaching a tag causes the problem of the debugger showing that information in the debugger but private fields seem okay.

    I'm not sure how it solves the problem though? You mean save the original frame with the exception itself as a weakref? No obviously not since we can't weakref the exception.

    I guess I'm not sure what the tag buys us.

  15. maxbachmann commented on Mar 6, 2026

    @maxbachmann
    ContributorAuthor

    The tag buys us that it "makes" the object weakrefable. So then you could e.g. save the id + frame and unset them via a weakref.finalize on the tag

  16. rchiodo commented on Mar 6, 2026

    @rchiodo
    Contributor

    Ah because the tag would go out of scope when the exception did. Good idea.

  17. rchiodo commented on Mar 6, 2026

    @rchiodo
    Contributor

    I wonder if there's any other fields on an exception we can use for the same thing. Maybe not as it would be hard to tell when they went out of scope.

  18. maxbachmann commented on Mar 7, 2026

    @maxbachmann
    ContributorAuthor

    In the pr you mentioned:

    I think this is a problem. It's not the same frame then. It's why the frame is cached too. I'm seem to recall fixing some issues around this when we first started using the sys.monitoring stuff and that frame needs to be cached as it's computed when the exception is first unwound.

    It would be interesting to know what exactly the problem was. Because overall my preferred solution would be something along the lines of

    def _is_base_frame(frame) -> bool:
        if not frame:
            return False
    
        if f_unhandled.f_back is None:
            return True
    
        filename = frame.f_code.co_filename
        name = splitext(basename(filename))[0]
    
        if name == "threading":
            if frame.f_code.co_name in ("__bootstrap", "_bootstrap", "__bootstrap_inner", "_bootstrap_inner", "run"):
                return True
    
        elif name == "pydev_monkey":
            if frame.f_code.co_name == "__call__":
                return True
    
        elif name == "pydevd":
            if frame.f_code.co_name in ("_exec", "run", "main"):
                return True
    
        elif name == "pydevd_runpy":
            if frame.f_code.co_name.startswith(("run", "_run")):
                return True
    
        elif name == "<frozen runpy>":
            if frame.f_code.co_name.startswith(("run", "_run")):
                return True
    
        elif name == "runpy":
            if frame.f_code.co_name.startswith(("run", "_run")):
                return True
    
        return False

    The one thing I know is that it would continue to trigger unwinds if these "base frame" functions don't catch them and we need to make sure we don't break again. But at this point the current thread can probably be considered dead -> can just store that the thread no longer has to break for this

  19. rchiodo commented on Mar 7, 2026

    @rchiodo
    Contributor

    It was this commit here:

    fabioz/PyDev.Debugger@6342533#diff-aa670b2adf5fb01421af691a2ce0a6926a83af2be39f1e462198bd015ab64bba

    Doesn't seem like I added any specific tests for it though.

  20. maxbachmann commented on Mar 7, 2026

    @maxbachmann
    ContributorAuthor

    It's worth noting that if someone enables the Uncaught Exception feature during an exception raise he will always also get the base frame calculation later on. So if that is an issue it could be an issue for them as well.

    In addition when disabling it after a raise it will leak the exception since update_monitor_events doesn't reset it

  21. rchiodo commented on Mar 9, 2026

    @rchiodo
    Contributor

    Thanks for the PR. Submitted.

  22. maxbachmann commented on Mar 9, 2026

    @maxbachmann
    ContributorAuthor

    Is it already clear when there will be a new release for debugpy alongside the Vs code plugin?

  23. rchiodo commented on Mar 9, 2026

    @rchiodo
    Contributor

    Sorry but there's no set release schedule. It's more like when we get enough bug fixes that it seems worthwhile. Releasing usually takes me half a day so I try not to do it unless there's something a lot of people want.

  24. brettcannon commented on Mar 9, 2026

    @brettcannon
    Member

    there's been some question on when CPython reuses object ids. Would it be possible for an Exception that was thrown while unwinding another exception for it to have the same object id of another if that other exception isn't reference anymore?

    Object IDs are memory addresses in CPython, so getting reused values requires the first object with an ID to be garbage collected.

    https://docs.python.org/3/library/functions.html#id

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

Fixed in next releaseThis issue has been fixed, but won't be available to customers until the next release.user responded

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions