Skip to content

Commit 169f7eb

Browse files
Copilotrchiodo
andcommitted
Defer frame acquisition in sys.monitoring callbacks to reduce object retention
In _return_event: move _getframe(1) call after step_cmd == -1 early return check, avoiding unnecessary frame creation when not stepping. In _start_method_event: pass depth=1 to _get_func_code_info instead of frame object, deferring frame creation to after always_skip_code check. In _unwind_event and _raise_event: wrap frame/arg usage in try/finally to explicitly clear references after use, preventing exception tracebacks from keeping frame objects alive. Regenerated _pydevd_sys_monitoring_cython.pyx from updated Python source. Co-authored-by: rchiodo <19672699+rchiodo@users.noreply.github.com>
1 parent 8156d36 commit 169f7eb

3 files changed

Lines changed: 190 additions & 84 deletions

File tree

src/debugpy/_vendored/pydevd/_pydevd_sys_monitoring/_pydevd_sys_monitoring.py

Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -895,36 +895,43 @@ def _unwind_event(code, instruction, exc):
895895

896896
# print('_unwind_event', code, exc)
897897
frame = _getframe(1)
898-
arg = (type(exc), exc, exc.__traceback__)
899-
900-
has_caught_exception_breakpoint_in_pydb = (
901-
py_db.break_on_caught_exceptions or py_db.break_on_user_uncaught_exceptions or py_db.has_plugin_exception_breaks
902-
)
898+
try:
899+
arg = (type(exc), exc, exc.__traceback__)
903900

904-
if has_caught_exception_breakpoint_in_pydb:
905-
_should_stop, frame, user_uncaught_exc_info = should_stop_on_exception(
906-
py_db, thread_info.additional_info, frame, thread_info.thread, arg, None, is_unwind=True
901+
has_caught_exception_breakpoint_in_pydb = (
902+
py_db.break_on_caught_exceptions or py_db.break_on_user_uncaught_exceptions or py_db.has_plugin_exception_breaks
907903
)
908-
if user_uncaught_exc_info:
909-
# TODO: Check: this may no longer be needed as in the unwind we know it's
910-
# an exception bubbling up (wait for all tests to pass to check it).
911-
if func_code_info.try_except_container_obj is None:
912-
container_obj = _TryExceptContainerObj(py_db.collect_try_except_info(frame.f_code))
913-
func_code_info.try_except_container_obj = container_obj
914-
915-
is_unhandled = is_unhandled_exception(
916-
func_code_info.try_except_container_obj, py_db, frame, user_uncaught_exc_info[1], user_uncaught_exc_info[2]
904+
905+
if has_caught_exception_breakpoint_in_pydb:
906+
_should_stop, frame, user_uncaught_exc_info = should_stop_on_exception(
907+
py_db, thread_info.additional_info, frame, thread_info.thread, arg, None, is_unwind=True
917908
)
909+
if user_uncaught_exc_info:
910+
# TODO: Check: this may no longer be needed as in the unwind we know it's
911+
# an exception bubbling up (wait for all tests to pass to check it).
912+
if func_code_info.try_except_container_obj is None:
913+
container_obj = _TryExceptContainerObj(py_db.collect_try_except_info(frame.f_code))
914+
func_code_info.try_except_container_obj = container_obj
915+
916+
is_unhandled = is_unhandled_exception(
917+
func_code_info.try_except_container_obj, py_db, frame, user_uncaught_exc_info[1], user_uncaught_exc_info[2]
918+
)
919+
920+
if is_unhandled:
921+
handle_exception(py_db, thread_info.thread, frame, user_uncaught_exc_info[0], EXCEPTION_TYPE_USER_UNHANDLED)
922+
return
918923

919-
if is_unhandled:
920-
handle_exception(py_db, thread_info.thread, frame, user_uncaught_exc_info[0], EXCEPTION_TYPE_USER_UNHANDLED)
924+
break_on_uncaught_exceptions = py_db.break_on_uncaught_exceptions
925+
if break_on_uncaught_exceptions:
926+
if frame is _get_unhandled_exception_frame(exc, 1):
927+
stop_on_unhandled_exception(py_db, thread_info.thread, thread_info.additional_info, arg)
921928
return
922-
923-
break_on_uncaught_exceptions = py_db.break_on_uncaught_exceptions
924-
if break_on_uncaught_exceptions:
925-
if frame is _get_unhandled_exception_frame(exc, 1):
926-
stop_on_unhandled_exception(py_db, thread_info.thread, thread_info.additional_info, arg)
927-
return
929+
finally:
930+
# Clear frame and arg references to avoid preventing garbage collection
931+
# of objects referenced from the frame's locals. The arg tuple also
932+
# contains the traceback which may hold frame references.
933+
del frame
934+
del arg
928935

929936

930937
# fmt: off
@@ -967,21 +974,28 @@ def _raise_event(code, instruction, exc):
967974
return
968975

969976
frame = _getframe(1)
970-
arg = (type(exc), exc, exc.__traceback__)
977+
try:
978+
arg = (type(exc), exc, exc.__traceback__)
971979

972-
# Compute the previous exception info (if any). We use it to check if the exception
973-
# should be stopped
974-
prev_exc_info = _thread_local_info._user_uncaught_exc_info if hasattr(_thread_local_info, "_user_uncaught_exc_info") else None
975-
should_stop, frame, _user_uncaught_exc_info = should_stop_on_exception(
976-
py_db, thread_info.additional_info, frame, thread_info.thread, arg, prev_exc_info
977-
)
980+
# Compute the previous exception info (if any). We use it to check if the exception
981+
# should be stopped
982+
prev_exc_info = _thread_local_info._user_uncaught_exc_info if hasattr(_thread_local_info, "_user_uncaught_exc_info") else None
983+
should_stop, frame, _user_uncaught_exc_info = should_stop_on_exception(
984+
py_db, thread_info.additional_info, frame, thread_info.thread, arg, prev_exc_info
985+
)
978986

979-
# Save the current exception info for the next raise event.
980-
_thread_local_info._user_uncaught_exc_info = _user_uncaught_exc_info
987+
# Save the current exception info for the next raise event.
988+
_thread_local_info._user_uncaught_exc_info = _user_uncaught_exc_info
981989

982-
# print('!!!! should_stop (in raise)', should_stop)
983-
if should_stop:
984-
handle_exception(py_db, thread_info.thread, frame, arg, EXCEPTION_TYPE_HANDLED)
990+
# print('!!!! should_stop (in raise)', should_stop)
991+
if should_stop:
992+
handle_exception(py_db, thread_info.thread, frame, arg, EXCEPTION_TYPE_HANDLED)
993+
finally:
994+
# Clear frame and arg references to avoid preventing garbage collection
995+
# of objects referenced from the frame's locals. The arg tuple also
996+
# contains the traceback which may hold frame references.
997+
del frame
998+
del arg
985999

9861000

9871001
# fmt: off
@@ -1083,13 +1097,16 @@ def _return_event(code, instruction, retval):
10831097

10841098
info = thread_info.additional_info
10851099

1086-
# We know the frame depth.
1087-
frame = _getframe(1)
1088-
10891100
step_cmd = info.pydev_step_cmd
10901101
if step_cmd == -1:
10911102
return
10921103

1104+
# We know the frame depth.
1105+
# Note: getting the frame lazily (only when we need it) is important
1106+
# because obtaining a frame creates a reference that will keep all
1107+
# objects in that frame alive until this callback returns.
1108+
frame = _getframe(1)
1109+
10931110
if info.suspend_type != PYTHON_SUSPEND:
10941111
# Plugin stepping
10951112
if func_code_info.plugin_return_stepping:
@@ -1703,13 +1720,20 @@ def _start_method_event(code, instruction_offset):
17031720
# threads may still want it...
17041721
return
17051722

1706-
frame = _getframe(1)
1707-
func_code_info = _get_func_code_info(code, frame)
1723+
# Note: passing depth=1 instead of the frame avoids creating a frame reference
1724+
# unnecessarily in the fast path (cache hit). The frame will only be obtained
1725+
# inside _get_func_code_info if needed (cache miss).
1726+
func_code_info = _get_func_code_info(code, 1)
17081727
if func_code_info.always_skip_code:
17091728
# if DEBUG:
17101729
# print('disable (always skip)')
17111730
return monitor.DISABLE
17121731

1732+
# Only get the frame when we actually need it (after the always_skip_code check).
1733+
# Obtaining a frame creates a reference that keeps all objects in that frame
1734+
# alive until this callback returns.
1735+
frame = _getframe(1)
1736+
17131737
keep_enabled: bool = _enable_code_tracing(py_db, thread_info.additional_info, func_code_info, code, frame, True)
17141738

17151739
if func_code_info.function_breakpoint_found:

src/debugpy/_vendored/pydevd/_pydevd_sys_monitoring/_pydevd_sys_monitoring_cython.pyx

Lines changed: 66 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -901,36 +901,43 @@ cdef _unwind_event(code, instruction, exc):
901901

902902
# print('_unwind_event', code, exc)
903903
frame = _getframe(1)
904-
arg = (type(exc), exc, exc.__traceback__)
905-
906-
has_caught_exception_breakpoint_in_pydb = (
907-
py_db.break_on_caught_exceptions or py_db.break_on_user_uncaught_exceptions or py_db.has_plugin_exception_breaks
908-
)
904+
try:
905+
arg = (type(exc), exc, exc.__traceback__)
909906

910-
if has_caught_exception_breakpoint_in_pydb:
911-
_should_stop, frame, user_uncaught_exc_info = should_stop_on_exception(
912-
py_db, thread_info.additional_info, frame, thread_info.thread, arg, None, is_unwind=True
907+
has_caught_exception_breakpoint_in_pydb = (
908+
py_db.break_on_caught_exceptions or py_db.break_on_user_uncaught_exceptions or py_db.has_plugin_exception_breaks
913909
)
914-
if user_uncaught_exc_info:
915-
# TODO: Check: this may no longer be needed as in the unwind we know it's
916-
# an exception bubbling up (wait for all tests to pass to check it).
917-
if func_code_info.try_except_container_obj is None:
918-
container_obj = _TryExceptContainerObj(py_db.collect_try_except_info(frame.f_code))
919-
func_code_info.try_except_container_obj = container_obj
920-
921-
is_unhandled = is_unhandled_exception(
922-
func_code_info.try_except_container_obj, py_db, frame, user_uncaught_exc_info[1], user_uncaught_exc_info[2]
910+
911+
if has_caught_exception_breakpoint_in_pydb:
912+
_should_stop, frame, user_uncaught_exc_info = should_stop_on_exception(
913+
py_db, thread_info.additional_info, frame, thread_info.thread, arg, None, is_unwind=True
923914
)
915+
if user_uncaught_exc_info:
916+
# TODO: Check: this may no longer be needed as in the unwind we know it's
917+
# an exception bubbling up (wait for all tests to pass to check it).
918+
if func_code_info.try_except_container_obj is None:
919+
container_obj = _TryExceptContainerObj(py_db.collect_try_except_info(frame.f_code))
920+
func_code_info.try_except_container_obj = container_obj
921+
922+
is_unhandled = is_unhandled_exception(
923+
func_code_info.try_except_container_obj, py_db, frame, user_uncaught_exc_info[1], user_uncaught_exc_info[2]
924+
)
925+
926+
if is_unhandled:
927+
handle_exception(py_db, thread_info.thread, frame, user_uncaught_exc_info[0], EXCEPTION_TYPE_USER_UNHANDLED)
928+
return
924929

925-
if is_unhandled:
926-
handle_exception(py_db, thread_info.thread, frame, user_uncaught_exc_info[0], EXCEPTION_TYPE_USER_UNHANDLED)
930+
break_on_uncaught_exceptions = py_db.break_on_uncaught_exceptions
931+
if break_on_uncaught_exceptions:
932+
if frame is _get_unhandled_exception_frame(exc, 1):
933+
stop_on_unhandled_exception(py_db, thread_info.thread, thread_info.additional_info, arg)
927934
return
928-
929-
break_on_uncaught_exceptions = py_db.break_on_uncaught_exceptions
930-
if break_on_uncaught_exceptions:
931-
if frame is _get_unhandled_exception_frame(exc, 1):
932-
stop_on_unhandled_exception(py_db, thread_info.thread, thread_info.additional_info, arg)
933-
return
935+
finally:
936+
# Clear frame and arg references to avoid preventing garbage collection
937+
# of objects referenced from the frame's locals. The arg tuple also
938+
# contains the traceback which may hold frame references.
939+
del frame
940+
del arg
934941

935942

936943
# fmt: off
@@ -973,21 +980,28 @@ cdef _raise_event(code, instruction, exc):
973980
return
974981

975982
frame = _getframe(1)
976-
arg = (type(exc), exc, exc.__traceback__)
983+
try:
984+
arg = (type(exc), exc, exc.__traceback__)
977985

978-
# Compute the previous exception info (if any). We use it to check if the exception
979-
# should be stopped
980-
prev_exc_info = _thread_local_info._user_uncaught_exc_info if hasattr(_thread_local_info, "_user_uncaught_exc_info") else None
981-
should_stop, frame, _user_uncaught_exc_info = should_stop_on_exception(
982-
py_db, thread_info.additional_info, frame, thread_info.thread, arg, prev_exc_info
983-
)
986+
# Compute the previous exception info (if any). We use it to check if the exception
987+
# should be stopped
988+
prev_exc_info = _thread_local_info._user_uncaught_exc_info if hasattr(_thread_local_info, "_user_uncaught_exc_info") else None
989+
should_stop, frame, _user_uncaught_exc_info = should_stop_on_exception(
990+
py_db, thread_info.additional_info, frame, thread_info.thread, arg, prev_exc_info
991+
)
984992

985-
# Save the current exception info for the next raise event.
986-
_thread_local_info._user_uncaught_exc_info = _user_uncaught_exc_info
993+
# Save the current exception info for the next raise event.
994+
_thread_local_info._user_uncaught_exc_info = _user_uncaught_exc_info
987995

988-
# print('!!!! should_stop (in raise)', should_stop)
989-
if should_stop:
990-
handle_exception(py_db, thread_info.thread, frame, arg, EXCEPTION_TYPE_HANDLED)
996+
# print('!!!! should_stop (in raise)', should_stop)
997+
if should_stop:
998+
handle_exception(py_db, thread_info.thread, frame, arg, EXCEPTION_TYPE_HANDLED)
999+
finally:
1000+
# Clear frame and arg references to avoid preventing garbage collection
1001+
# of objects referenced from the frame's locals. The arg tuple also
1002+
# contains the traceback which may hold frame references.
1003+
del frame
1004+
del arg
9911005

9921006

9931007
# fmt: off
@@ -1089,13 +1103,16 @@ cdef _return_event(code, instruction, retval):
10891103

10901104
info = thread_info.additional_info
10911105

1092-
# We know the frame depth.
1093-
frame = _getframe(1)
1094-
10951106
step_cmd = info.pydev_step_cmd
10961107
if step_cmd == -1:
10971108
return
10981109

1110+
# We know the frame depth.
1111+
# Note: getting the frame lazily (only when we need it) is important
1112+
# because obtaining a frame creates a reference that will keep all
1113+
# objects in that frame alive until this callback returns.
1114+
frame = _getframe(1)
1115+
10991116
if info.suspend_type != PYTHON_SUSPEND:
11001117
# Plugin stepping
11011118
if func_code_info.plugin_return_stepping:
@@ -1709,13 +1726,20 @@ cdef _start_method_event(code, instruction_offset):
17091726
# threads may still want it...
17101727
return
17111728

1712-
frame = _getframe(1)
1713-
func_code_info = _get_func_code_info(code, frame)
1729+
# Note: passing depth=1 instead of the frame avoids creating a frame reference
1730+
# unnecessarily in the fast path (cache hit). The frame will only be obtained
1731+
# inside _get_func_code_info if needed (cache miss).
1732+
func_code_info = _get_func_code_info(code, 1)
17141733
if func_code_info.always_skip_code:
17151734
# if DEBUG:
17161735
# print('disable (always skip)')
17171736
return monitor.DISABLE
17181737

1738+
# Only get the frame when we actually need it (after the always_skip_code check).
1739+
# Obtaining a frame creates a reference that keeps all objects in that frame
1740+
# alive until this callback returns.
1741+
frame = _getframe(1)
1742+
17191743
keep_enabled: bool = _enable_code_tracing(py_db, thread_info.additional_info, func_code_info, code, frame, True)
17201744

17211745
if func_code_info.function_breakpoint_found:

src/debugpy/_vendored/pydevd/tests_python/test_sys_monitoring.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,61 @@ def b(): # line 4
292292

293293
method()
294294
monitor.set_events(DEBUGGER_ID, 0)
295+
296+
297+
def test_frame_references_not_held(with_monitoring):
298+
"""
299+
Test that sys.monitoring callbacks don't keep frame references alive
300+
longer than necessary, which would prevent garbage collection of objects
301+
referenced by locals in those frames.
302+
303+
See: https://github.com/microsoft/debugpy/issues/1813
304+
"""
305+
import gc
306+
import weakref
307+
308+
class _TestObj:
309+
"""Object that should be promptly garbage collected."""
310+
311+
def __init__(self, name):
312+
self.name = name
313+
314+
refs = []
315+
316+
def _start_method(code, offset):
317+
# Gets frame only if needed (simulating the improved pattern).
318+
frame = sys._getframe(1)
319+
if "test_sys_monitoring" in code.co_filename:
320+
monitor.set_local_events(DEBUGGER_ID, code, monitor.events.PY_RETURN)
321+
322+
def _return_method(code, offset, retval):
323+
# In the fixed code, the frame is only obtained when step_cmd != -1.
324+
# Here we simulate the common path where no stepping is happening.
325+
pass # No frame obtained when not needed
326+
327+
monitor.set_events(DEBUGGER_ID, monitor.events.PY_START)
328+
monitor.register_callback(DEBUGGER_ID, monitor.events.PY_START, _start_method)
329+
monitor.register_callback(DEBUGGER_ID, monitor.events.PY_RETURN, _return_method)
330+
331+
def create_obj(name):
332+
obj = _TestObj(name)
333+
refs.append(weakref.ref(obj))
334+
return 42 # obj should be freed when this function returns
335+
336+
# Create several objects
337+
for i in range(10):
338+
create_obj(f"obj_{i}")
339+
340+
# Force garbage collection
341+
gc.collect()
342+
gc.collect()
343+
gc.collect()
344+
345+
# All objects should have been freed
346+
alive = sum(1 for ref in refs if ref() is not None)
347+
assert alive == 0, (
348+
f"Expected 0 objects alive, but {alive}/{len(refs)} are still alive. "
349+
f"Frame references may be keeping objects alive."
350+
)
351+
352+
monitor.set_events(DEBUGGER_ID, 0)

0 commit comments

Comments
 (0)