Repository navigation
Uncaught Exceptions breakpoint lifetime extends exception object #1999
Description
Activity
- changed the title
[-]debugpy keeps frames alive[/-][+]debugpy keeps objects alive via the frame object[/+]on Feb 24, 2026 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.
maxbachmann commented
on Feb 24, 2026 ContributorAuthorMore actionsNot right now but I can try to write a smaller reproducer.
maxbachmann commented
on Mar 4, 2026 ContributorAuthorMore actionsI finally managed to make a reproducer for my issue I can share
The repo includes a
signal.pyto debug and amySignal.cppfor a C++ signal that is required to trigger the issue.
To compile the C++ signal on Linux I did runpip 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.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)maxbachmann commented
on Mar 4, 2026 ContributorAuthorMore actionsI 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
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.
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.
maxbachmann commented
on Mar 4, 2026 ContributorAuthorMore actionsThere 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.
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.
maxbachmann commented
on Mar 4, 2026 ContributorAuthorMore actionsFor 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.
27 remaining items
That's what I get for asking copilot. It made it sound like this was a special case.
maxbachmann commented
on Mar 6, 2026 ContributorAuthorMore actionsI 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.
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.
maxbachmann commented
on Mar 6, 2026 ContributorAuthorMore actionsThe 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.finalizeon the tagAh because the tag would go out of scope when the exception did. Good idea.
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.
maxbachmann commented
on Mar 7, 2026 ContributorAuthorMore actionsIn 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
It was this commit here:
fabioz/PyDev.Debugger@6342533#diff-aa670b2adf5fb01421af691a2ce0a6926a83af2be39f1e462198bd015ab64bba
Doesn't seem like I added any specific tests for it though.
maxbachmann commented
on Mar 7, 2026 ContributorAuthorMore actionsIt'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_eventsdoesn't reset it- addedFixed in next releaseThis issue has been fixed, but won't be available to customers until the next release.This issue has been fixed, but won't be available to customers until the next release.
on Mar 9, 2026 Thanks for the PR. Submitted.
maxbachmann commented
on Mar 9, 2026 ContributorAuthorMore actionsIs it already clear when there will be a new release for debugpy alongside the Vs code plugin?
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.
Reacted by Max Bachmannthere'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.
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
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?