Skip to content

Commit dae4cc2

Browse files
committed
Fix evaluation of variables from chained exception frames
1 parent 6d265e0 commit dae4cc2

3 files changed

Lines changed: 159 additions & 0 deletions

File tree

src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_suspended_frames.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,21 @@ def track(self, thread_id, frames_list, frame_custom_thread_id=None):
404404

405405
self._frame_id_to_main_thread_id[frame_id] = thread_id
406406

407+
# Also track frames from chained exceptions (e.g. __cause__ / __context__)
408+
# so that variable evaluation works for chained exception frames displayed
409+
# in the call stack.
410+
chained = getattr(frames_list, 'chained_frames_list', None)
411+
while chained is not None and len(chained) > 0:
412+
for frame in chained:
413+
frame_id = id(frame)
414+
self._frame_id_to_frame[frame_id] = frame
415+
_FrameVariable(self.py_db, frame, self._register_variable)
416+
self._suspended_frames_manager._variable_reference_to_frames_tracker[frame_id] = self
417+
frame_ids_from_thread.append(frame_id)
418+
419+
self._frame_id_to_main_thread_id[frame_id] = thread_id
420+
chained = getattr(chained, 'chained_frames_list', None)
421+
407422
frame = None
408423

409424
def untrack_all(self):

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,80 @@ def additional_output_checks(writer, stdout, stderr):
926926
writer.finished_ok = True
927927

928928

929+
def test_case_chained_exception_variables(case_setup_dap, pyfile):
930+
"""
931+
When stopped on a chained exception, variable evaluation must work for
932+
frames belonging to the chained (cause) exception, not just the primary one.
933+
"""
934+
935+
@pyfile
936+
def target():
937+
def inner():
938+
cause_var = "from_cause" # noqa
939+
raise RuntimeError("the cause")
940+
941+
def outer():
942+
outer_var = "from_outer" # noqa
943+
try:
944+
inner()
945+
except Exception as e:
946+
raise ValueError("the effect") from e # raise line
947+
948+
outer()
949+
950+
def check_test_suceeded_msg(self, stdout, stderr):
951+
return "the cause" in "".join(stderr)
952+
953+
def additional_output_checks(writer, stdout, stderr):
954+
assert 'raise RuntimeError("the cause")' in stderr
955+
assert 'raise ValueError("the effect") from e' in stderr
956+
957+
with case_setup_dap.test_file(
958+
target,
959+
EXPECTED_RETURNCODE=1,
960+
check_test_suceeded_msg=check_test_suceeded_msg,
961+
additional_output_checks=additional_output_checks,
962+
) as writer:
963+
json_facade = JsonFacade(writer)
964+
965+
json_facade.write_launch(justMyCode=False)
966+
json_facade.write_set_exception_breakpoints(["uncaught"])
967+
json_facade.write_make_initial_run()
968+
969+
json_hit = json_facade.wait_for_thread_stopped(
970+
reason="exception", line=writer.get_line_index_with_content("raise line")
971+
)
972+
973+
stack_frames = json_hit.stack_trace_response.body.stackFrames
974+
975+
# Find the chained exception frames.
976+
chained_frames = [f for f in stack_frames if f["name"].startswith("[Chained Exc:")]
977+
assert len(chained_frames) > 0, "Expected chained exception frames in stack trace"
978+
979+
# Verify variables can be retrieved for chained frames (this is the
980+
# operation that previously failed with "Unable to find thread to
981+
# evaluate variable reference.").
982+
for chained_frame in chained_frames:
983+
variables_response = json_facade.get_variables_response(chained_frame["id"])
984+
assert variables_response.success
985+
986+
# Find the inner() chained frame and verify its local variable.
987+
inner_frames = [f for f in chained_frames if "inner" in f["name"]]
988+
assert len(inner_frames) == 1
989+
variables_response = json_facade.get_variables_response(inner_frames[0]["id"])
990+
var_names = [v["name"] for v in variables_response.body.variables]
991+
assert "cause_var" in var_names, "Expected 'cause_var' in chained frame variables, got: %s" % var_names
992+
993+
# Also verify that primary frame variables still work.
994+
primary_frame_id = json_hit.frame_id
995+
variables_response = json_facade.get_variables_response(primary_frame_id)
996+
assert variables_response.success
997+
998+
json_facade.write_continue()
999+
1000+
writer.finished_ok = True
1001+
1002+
9291003
def test_case_throw_exc_reason_shown(case_setup_dap):
9301004

9311005
def check_test_suceeded_msg(self, stdout, stderr):

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,3 +156,73 @@ def test_get_child_variables():
156156
raise AssertionError("Expected to find variable named: %s" % (TOO_LARGE_ATTR,))
157157
if not found_len:
158158
raise AssertionError("Expected to find variable named: len()")
159+
160+
161+
def test_chained_exception_frames_tracked():
162+
"""
163+
When an exception has chained causes (__cause__ / __context__), the chained
164+
frames are shown in the call stack. Variable evaluation must also work for
165+
those frames, which requires them to be registered in the
166+
SuspendedFramesManager. Uses a 3-level chain to verify all levels are walked.
167+
"""
168+
from _pydevd_bundle.pydevd_suspended_frames import SuspendedFramesManager
169+
from _pydevd_bundle.pydevd_constants import EXCEPTION_TYPE_USER_UNHANDLED
170+
171+
def level0():
172+
local0 = "from_level_0" # noqa
173+
raise RuntimeError("level_0")
174+
175+
def level1():
176+
local1 = "from_level_1" # noqa
177+
try:
178+
level0()
179+
except Exception as e:
180+
raise TypeError("level_1") from e
181+
182+
def level2():
183+
local2 = "from_level_2" # noqa
184+
try:
185+
level1()
186+
except Exception as e:
187+
raise ValueError("level_2") from e
188+
189+
try:
190+
level2()
191+
except Exception:
192+
exc_type, exc_desc, trace_obj = sys.exc_info()
193+
frame = sys._getframe()
194+
frames_list = pydevd_frame_utils.create_frames_list_from_traceback(
195+
trace_obj, frame, exc_type, exc_desc,
196+
exception_type=EXCEPTION_TYPE_USER_UNHANDLED,
197+
)
198+
199+
# Collect all chained levels.
200+
chained_levels = []
201+
cur = frames_list
202+
while getattr(cur, "chained_frames_list", None) is not None:
203+
chained_levels.append(cur.chained_frames_list)
204+
cur = cur.chained_frames_list
205+
assert len(chained_levels) == 2
206+
207+
suspended_frames_manager = SuspendedFramesManager()
208+
with suspended_frames_manager.track_frames(_DummyPyDB()) as tracker:
209+
thread_id = "thread1"
210+
tracker.track(thread_id, frames_list)
211+
212+
# Primary and all chained frames must be tracked.
213+
for f in frames_list:
214+
assert suspended_frames_manager.get_thread_id_for_variable_reference(id(f)) == thread_id
215+
for level in chained_levels:
216+
for f in level:
217+
assert suspended_frames_manager.get_thread_id_for_variable_reference(id(f)) == thread_id
218+
219+
# Variable retrieval must work for the deepest chained frames.
220+
for f in chained_levels[-1]:
221+
assert suspended_frames_manager.get_variable(id(f)).get_children_variables() is not None
222+
223+
# After untracking, all references must be gone.
224+
for f in frames_list:
225+
assert suspended_frames_manager.get_thread_id_for_variable_reference(id(f)) is None
226+
for level in chained_levels:
227+
for f in level:
228+
assert suspended_frames_manager.get_thread_id_for_variable_reference(id(f)) is None

0 commit comments

Comments
 (0)