Skip to content

Commit

Permalink
Show chained exception frames in stack. Fixes microsoft#1042
Browse files Browse the repository at this point in the history
  • Loading branch information
fabioz committed Sep 22, 2022
1 parent 3d81707 commit 6157ed0
Show file tree
Hide file tree
Showing 8 changed files with 129 additions and 69 deletions.
4 changes: 1 addition & 3 deletions src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py
Original file line number Diff line number Diff line change
Expand Up @@ -1420,7 +1420,6 @@ def build_exception_info_response(dbg, thread_id, request_seq, set_additional_th
try:
try:
frames_list = dbg.suspended_frames_manager.get_frames_list(thread_id)
memo = set()
while frames_list is not None and len(frames_list):
frames = []

Expand Down Expand Up @@ -1483,8 +1482,7 @@ def build_exception_info_response(dbg, thread_id, request_seq, set_additional_th
stack_str += frames_list.exc_context_msg
stack_str_lst.append(stack_str)

frames_list = create_frames_list_from_exception_cause(
frames_list.trace_obj, None, frames_list.exc_type, frames_list.exc_desc, memo)
frames_list = frames_list.chained_frames_list
if frames_list is None or not frames_list:
break

Expand Down
58 changes: 27 additions & 31 deletions src/debugpy/_vendored/pydevd/_pydevd_bundle/pydevd_frame_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ def __init__(self):
# This is to know whether an exception was extracted from a __cause__ or __context__.
self.exc_context_msg = ''

self.chained_frames_list = None

def append(self, frame):
self._frames.append(frame)

Expand Down Expand Up @@ -128,7 +130,13 @@ def __repr__(self):
lst.append('\n ')
lst.append(repr(frame))
lst.append(',')

if self.chained_frames_list is not None:
lst.append('\n--- Chained ---\n')
lst.append(str(self.chained_frames_list))

lst.append('\n)')

return ''.join(lst)

__str__ = __repr__
Expand All @@ -142,7 +150,8 @@ def __init__(self, frame, f_lineno, f_back):
self.f_back = f_back
self.f_trace = None
original_code = frame.f_code
self.f_code = FCode(original_code.co_name , original_code.co_filename)
name = original_code.co_name
self.f_code = FCode(name, original_code.co_filename)

@property
def f_locals(self):
Expand All @@ -152,6 +161,11 @@ def f_locals(self):
def f_globals(self):
return self._base_frame.f_globals

def __str__(self):
return "<_DummyFrameWrapper, file '%s', line %s, %s" % (self.f_code.co_filename, self.f_lineno, self.f_code.co_name)

__repr__ = __str__


_cause_message = (
"\nThe above exception was the direct cause "
Expand Down Expand Up @@ -231,36 +245,6 @@ def create_frames_list_from_traceback(trace_obj, frame, exc_type, exc_desc, exce
lst.append((tb.tb_frame, tb.tb_lineno))
tb = tb.tb_next

curr = exc_desc
memo = set()
while True:
initial = curr
try:
curr = getattr(initial, '__cause__', None)
except Exception:
curr = None

if curr is None:
try:
curr = getattr(initial, '__context__', None)
except Exception:
curr = None

if curr is None or id(curr) in memo:
break

# The traceback module does this, so, let's play safe here too...
memo.add(id(curr))

tb = getattr(curr, '__traceback__', None)

while tb is not None:
# Note: we don't use the actual tb.tb_frame because if the cause of the exception
# uses the same frame object, the id(frame) would be the same and the frame_id_to_lineno
# would be wrong as the same frame needs to appear with 2 different lines.
lst.append((_DummyFrameWrapper(tb.tb_frame, tb.tb_lineno, None), tb.tb_lineno))
tb = tb.tb_next

frames_list = None

for tb_frame, tb_lineno in reversed(lst):
Expand Down Expand Up @@ -290,6 +274,18 @@ def create_frames_list_from_traceback(trace_obj, frame, exc_type, exc_desc, exce
if len(frames_list) > 0:
frames_list.current_frame = frames_list.last_frame()

curr = frames_list
memo = set()
memo.add(id(exc_desc))

while True:
chained = create_frames_list_from_exception_cause(None, None, None, curr.exc_desc, memo)
if chained is None:
break
else:
curr.chained_frames_list = chained
curr = chained

return frames_list


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,7 @@ def make_get_thread_stack_message(self, py_db, seq, thread_id, topmost_frame, fm
frames_list = pydevd_frame_utils.create_frames_list_from_frame(topmost_frame)

for frame_id, frame, method_name, original_filename, filename_in_utf8, lineno, applied_mapping, show_as_current_frame in self._iter_visible_frames_info(
py_db, frames_list
py_db, frames_list, flatten_chained=True
):

try:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,33 +164,46 @@ def make_thread_killed_message(self, tid):
except:
return self.make_error_message(0, get_exception_traceback_str())

def _iter_visible_frames_info(self, py_db, frames_list):
def _iter_visible_frames_info(self, py_db, frames_list, flatten_chained=False):
assert frames_list.__class__ == FramesList
for frame in frames_list:
show_as_current_frame = frame is frames_list.current_frame
if frame.f_code is None:
pydev_log.info('Frame without f_code: %s', frame)
continue # IronPython sometimes does not have it!
is_chained = False
while True:
for frame in frames_list:
show_as_current_frame = frame is frames_list.current_frame
if frame.f_code is None:
pydev_log.info('Frame without f_code: %s', frame)
continue # IronPython sometimes does not have it!

method_name = frame.f_code.co_name # method name (if in method) or ? if global
if method_name is None:
pydev_log.info('Frame without co_name: %s', frame)
continue # IronPython sometimes does not have it!
method_name = frame.f_code.co_name # method name (if in method) or ? if global
if method_name is None:
pydev_log.info('Frame without co_name: %s', frame)
continue # IronPython sometimes does not have it!

abs_path_real_path_and_base = get_abs_path_real_path_and_base_from_frame(frame)
if py_db.get_file_type(frame, abs_path_real_path_and_base) == py_db.PYDEV_FILE:
# Skip pydevd files.
frame = frame.f_back
continue
if is_chained:
method_name = '[Chained Exc: %s] %s' % (frames_list.exc_desc, method_name)

frame_id = id(frame)
lineno = frames_list.frame_id_to_lineno.get(frame_id, frame.f_lineno)
abs_path_real_path_and_base = get_abs_path_real_path_and_base_from_frame(frame)
if py_db.get_file_type(frame, abs_path_real_path_and_base) == py_db.PYDEV_FILE:
# Skip pydevd files.
frame = frame.f_back
continue

filename_in_utf8, lineno, changed = py_db.source_mapping.map_to_client(abs_path_real_path_and_base[0], lineno)
new_filename_in_utf8, applied_mapping = pydevd_file_utils.map_file_to_client(filename_in_utf8)
applied_mapping = applied_mapping or changed
frame_id = id(frame)
lineno = frames_list.frame_id_to_lineno.get(frame_id, frame.f_lineno)

yield frame_id, frame, method_name, abs_path_real_path_and_base[0], new_filename_in_utf8, lineno, applied_mapping, show_as_current_frame
filename_in_utf8, lineno, changed = py_db.source_mapping.map_to_client(abs_path_real_path_and_base[0], lineno)
new_filename_in_utf8, applied_mapping = pydevd_file_utils.map_file_to_client(filename_in_utf8)
applied_mapping = applied_mapping or changed

yield frame_id, frame, method_name, abs_path_real_path_and_base[0], new_filename_in_utf8, lineno, applied_mapping, show_as_current_frame

if not flatten_chained:
break

frames_list = frames_list.chained_frames_list
if frames_list is None or len(frames_list) == 0:
break
is_chained = True

def make_thread_stack_str(self, py_db, frames_list):
assert frames_list.__class__ == FramesList
Expand All @@ -200,7 +213,7 @@ def make_thread_stack_str(self, py_db, frames_list):

try:
for frame_id, frame, method_name, _original_filename, filename_in_utf8, lineno, _applied_mapping, _show_as_current_frame in self._iter_visible_frames_info(
py_db, frames_list
py_db, frames_list, flatten_chained=True
):

# print("file is ", filename_in_utf8)
Expand Down
10 changes: 5 additions & 5 deletions src/debugpy/_vendored/pydevd/tests_python/test_debugger.py
Original file line number Diff line number Diff line change
Expand Up @@ -1657,13 +1657,13 @@ def additional_output_checks(writer, stdout, stderr):
name_and_lines.append((frame['name'], frame['line']))

assert name_and_lines == [
('method2', '2'),
('method', '6'),
('foobar', '16'),
('handle', '10'),
('foobar', '18'),
('foobar', '20'),
('<module>', '23'),
('[Chained Exc: another while handling] foobar', '18'),
('[Chained Exc: another while handling] handle', '10'),
('[Chained Exc: TEST SUCEEDED] foobar', '16'),
('[Chained Exc: TEST SUCEEDED] method', '6'),
('[Chained Exc: TEST SUCEEDED] method2', '2'),
]

hit = writer.wait_for_breakpoint_hit(REASON_UNCAUGHT_EXCEPTION)
Expand Down
31 changes: 25 additions & 6 deletions src/debugpy/_vendored/pydevd/tests_python/test_debugger_json.py
Original file line number Diff line number Diff line change
Expand Up @@ -751,7 +751,15 @@ def additional_output_checks(writer, stdout, stderr):
stack_frames = json_hit.stack_trace_response.body.stackFrames
# Note that the additional context doesn't really appear in the stack
# frames, only in the details.
assert [x['name'] for x in stack_frames] == ['foobar', '<module>']
assert [x['name'] for x in stack_frames] == [
'foobar',
'<module>',
'[Chained Exc: another while handling] foobar',
'[Chained Exc: another while handling] handle',
'[Chained Exc: TEST SUCEEDED] foobar',
'[Chained Exc: TEST SUCEEDED] method',
'[Chained Exc: TEST SUCEEDED] method2',
]

body = exc_info_response.body
assert body.exceptionId.endswith('RuntimeError')
Expand All @@ -760,10 +768,16 @@ def additional_output_checks(writer, stdout, stderr):

# Check that we have all the lines (including the cause/context) in the stack trace.
import re
lines_and_names = re.findall(r',\sline\s(\d+),\sin\s([\w|<|>]+)', body.details.stackTrace)
lines_and_names = re.findall(r',\sline\s(\d+),\sin\s(\[Chained Exception\]\s)?([\w|<|>]+)', body.details.stackTrace)
assert lines_and_names == [
('16', 'foobar'), ('6', 'method'), ('2', 'method2'), ('18', 'foobar'), ('10', 'handle'), ('20', 'foobar'), ('23', '<module>')
]
('16', '', 'foobar'),
('6', '', 'method'),
('2', '', 'method2'),
('18', '', 'foobar'),
('10', '', 'handle'),
('20', '', 'foobar'),
('23', '', '<module>'),
], 'Did not find the expected names in:\n%s' % (body.details.stackTrace,)

json_facade.write_continue()

Expand Down Expand Up @@ -802,7 +816,12 @@ def additional_output_checks(writer, stdout, stderr):
stack_frames = json_hit.stack_trace_response.body.stackFrames
# Note that the additional context doesn't really appear in the stack
# frames, only in the details.
assert [x['name'] for x in stack_frames] == ['method', '<module>']
assert [x['name'] for x in stack_frames] == [
'method',
'<module>',
"[Chained Exc: 'foo'] method",
"[Chained Exc: 'foo'] method2",
]

body = exc_info_response.body
assert body.exceptionId == 'Exception'
Expand Down Expand Up @@ -3297,7 +3316,7 @@ def test_exception_details(case_setup, max_frames):
else:
json_facade.write_launch(maxExceptionStackFrames=max_frames)
min_expected_lines = 10
max_expected_lines = 21
max_expected_lines = 22

json_facade.write_set_exception_breakpoints(['raised'])

Expand Down
34 changes: 34 additions & 0 deletions src/debugpy/_vendored/pydevd/tests_python/test_frame_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import sys
from _pydevd_bundle.pydevd_constants import EXCEPTION_TYPE_USER_UNHANDLED


def test_create_frames_list_from_traceback():

def method():
raise RuntimeError('first')

def method1():
try:
method()
except Exception as e:
raise RuntimeError('second') from e

def method2():
try:
method1()
except Exception as e:
raise RuntimeError('third') from e

try:
method2()
except Exception as e:
exc_type, exc_desc, trace_obj = sys.exc_info()
frame = sys._getframe()

from _pydevd_bundle.pydevd_frame_utils import create_frames_list_from_traceback
frames_list = create_frames_list_from_traceback(trace_obj, frame, exc_type, exc_desc, exception_type=EXCEPTION_TYPE_USER_UNHANDLED)
assert str(frames_list.exc_desc) == 'third'
assert str(frames_list.chained_frames_list.exc_desc) == 'second'
assert str(frames_list.chained_frames_list.chained_frames_list.exc_desc) == 'first'
assert frames_list.chained_frames_list.chained_frames_list.chained_frames_list is None

2 changes: 1 addition & 1 deletion tests/debugpy/test_exception.py
Original file line number Diff line number Diff line change
Expand Up @@ -354,7 +354,7 @@ def do_something2(n):
max_frames, (min_expected_lines, max_expected_lines) = {
"all": (0, (100, 221)),
"default": (None, (100, 221)),
10: (10, (10, 21)),
10: (10, (10, 22)),
}[max_frames]
if max_frames is not None:
session.config["maxExceptionStackFrames"] = max_frames
Expand Down

0 comments on commit 6157ed0

Please sign in to comment.