Repository: microsoft/ptvsd

Issue:
Add ability to limit number of lines in stack trace returned for exceptions
## Environment data

- PTVSD version: Master
- OS and version: N/A
- Python version (& distribution if applicable, e.g. Anaconda): N/A
- Using VS Code or Visual Studio: VSC

## Suggestion
* VSC will pass some setting to PTVSD
* The presence of this setting tells PTVSD to limit number of lines (frames) in stack trace returned with the exception info.

https://github.com/Microsoft/vscode-python/issues/2075#issuecomment-402299091


Discussion:

Resolving patch:
diff --git a/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_api.py b/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_api.py
--- a/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_api.py
+++ b/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_api.py
@@ -126,12 +126,13 @@ def request_stack(self, py_db, seq, thread_id, fmt=None, timeout=.5):
         else:
             py_db.post_internal_command(internal_get_thread_stack, '*')
 
-    def request_exception_info_json(self, py_db, request, thread_id):
+    def request_exception_info_json(self, py_db, request, thread_id, max_frames):
         py_db.post_method_as_internal_command(
             thread_id,
             internal_get_exception_details_json,
             request,
             thread_id,
+            max_frames,
             set_additional_thread_info=set_additional_thread_info,
             iter_visible_frames_info=py_db.cmd_factory._iter_visible_frames_info)
 
diff --git a/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py b/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py
--- a/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py
+++ b/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py
@@ -1077,7 +1077,7 @@ def internal_get_description(dbg, seq, thread_id, frame_id, expression):
         dbg.writer.add_command(cmd)
 
 
-def internal_get_exception_details_json(dbg, request, thread_id, set_additional_thread_info=None, iter_visible_frames_info=None):
+def internal_get_exception_details_json(dbg, request, thread_id, max_frames, set_additional_thread_info=None, iter_visible_frames_info=None):
     ''' Fetch exception details
     '''
     try:
@@ -1143,7 +1143,7 @@ def internal_get_exception_details_json(dbg, request, thread_id, set_additional_
             except:
                 pass
 
-        stack_str = ''.join(traceback.format_list(frames))
+        stack_str = ''.join(traceback.format_list(frames[-max_frames:]))
 
         # This is an extra bit of data used by Visual Studio
         source_path = frames[0][0] if frames else ''
diff --git a/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_process_net_command_json.py b/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_process_net_command_json.py
--- a/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_process_net_command_json.py
+++ b/src/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_process_net_command_json.py
@@ -190,6 +190,8 @@ def _set_debug_options(self, py_db, args):
             args.get('options'),
             args.get('debugOptions'),
         )
+        self._debug_options['args'] = args
+
         debug_stdlib = self._debug_options.get('DEBUG_STDLIB', False)
         self.api.set_use_libraries_filter(py_db, not debug_stdlib)
 
@@ -491,8 +493,9 @@ def on_exceptioninfo_request(self, py_db, request):
         '''
         # : :type exception_into_arguments: ExceptionInfoArguments
         exception_into_arguments = request.arguments
-        thread_id = exception_into_arguments.threadId
-        self.api.request_exception_info_json(py_db, request, thread_id)
+        thread_id = exception_into_arguments.threadId            
+        max_frames = int(self._debug_options['args'].get('maxExceptionStackFrames', 0))
+        self.api.request_exception_info_json(py_db, request, thread_id, max_frames)
 
     def on_scopes_request(self, py_db, request):
         '''


Test patch:
diff --git a/src/ptvsd/_vendored/pydevd/tests_python/resources/_debugger_case_large_exception_stack.py b/src/ptvsd/_vendored/pydevd/tests_python/resources/_debugger_case_large_exception_stack.py
new file mode 100644
--- /dev/null
+++ b/src/ptvsd/_vendored/pydevd/tests_python/resources/_debugger_case_large_exception_stack.py
@@ -0,0 +1,11 @@
+def method1(n):
+    if n <= 0:
+        raise IndexError('foo')
+    method2(n-1)
+
+def method2(n):
+    method1(n-1)
+
+if __name__ == '__main__':
+    method1(100)
+    print('TEST SUCEEDED!')
\ No newline at end of file
diff --git a/src/ptvsd/_vendored/pydevd/tests_python/test_debugger_json.py b/src/ptvsd/_vendored/pydevd/tests_python/test_debugger_json.py
--- a/src/ptvsd/_vendored/pydevd/tests_python/test_debugger_json.py
+++ b/src/ptvsd/_vendored/pydevd/tests_python/test_debugger_json.py
@@ -1140,11 +1140,28 @@ def test_evaluate(case_setup):
         writer.finished_ok = True
 
 
-def test_exception_details(case_setup):
-    with case_setup.test_file('_debugger_case_exceptions.py') as writer:
+@pytest.mark.parametrize('max_frames', ['default', 'all', 10])  # -1 = default, 0 = all, 10 = 10 frames
+def test_exception_details(case_setup, max_frames):
+    with case_setup.test_file('_debugger_case_large_exception_stack.py') as writer:
         json_facade = JsonFacade(writer)
 
         writer.write_set_protocol('http_json')
+        if max_frames == 'all':
+            json_facade.write_launch(maxExceptionStackFrames=0)
+            # trace back compresses repeated text
+            min_expected_lines = 100
+            max_expected_lines = 220
+        elif max_frames == 'default':
+            json_facade.write_launch()
+            # default is all frames
+            # trace back compresses repeated text
+            min_expected_lines = 100
+            max_expected_lines = 220
+        else:
+            json_facade.write_launch(maxExceptionStackFrames=max_frames)
+            min_expected_lines = 10
+            max_expected_lines = 21
+
         json_facade.write_set_exception_breakpoints(['raised'])
 
         json_facade.write_make_initial_run()
@@ -1157,6 +1174,8 @@ def test_exception_details(case_setup):
         assert body.exceptionId.endswith('IndexError')
         assert body.description == 'foo'
         assert body.details.kwargs['source'] == writer.TEST_FILE
+        stack_line_count = len(body.details.stackTrace.split('\n'))
+        assert  min_expected_lines <= stack_line_count <= max_expected_lines
 
         json_facade.write_set_exception_breakpoints([])  # Don't stop on reraises.
         writer.write_run_thread(hit.thread_id)
diff --git a/tests/func/test_exception.py b/tests/func/test_exception.py
--- a/tests/func/test_exception.py
+++ b/tests/func/test_exception.py
@@ -9,7 +9,7 @@
 from tests.helpers import print, get_marked_line_numbers
 from tests.helpers.session import DebugSession
 from tests.helpers.timeline import Event
-from tests.helpers.pattern import ANY, Path
+from tests.helpers.pattern import ANY, Path, Regex
 
 
 @pytest.mark.parametrize('raised', ['raisedOn', 'raisedOff'])
@@ -344,3 +344,76 @@ def code_to_debug():
             session.send_request('continue').wait_for_response(freeze=False)
 
         session.wait_for_exit()
+
+
+@pytest.mark.parametrize('max_frames', ['default', 'all', 10])
+def test_exception_stack(pyfile, run_as, start_method, max_frames):
+    @pyfile
+    def code_to_debug():
+        from dbgimporter import import_and_enable_debugger
+        import_and_enable_debugger()
+        def do_something(n):
+            if n <= 0:
+                raise ArithmeticError('bad code') # @unhandled
+            do_something2(n - 1)
+
+        def do_something2(n):
+            do_something(n-1)
+
+        do_something(100)
+
+    if max_frames == 'all':
+        # trace back compresses repeated text
+        min_expected_lines = 100
+        max_expected_lines = 220
+        args = {'maxExceptionStackFrames': 0}
+    elif max_frames == 'default':
+        # default is all frames
+        min_expected_lines = 100
+        max_expected_lines = 220
+        args = {}
+    else:
+        min_expected_lines = 10
+        max_expected_lines = 21
+        args = {'maxExceptionStackFrames': 10}
+
+    line_numbers = get_marked_line_numbers(code_to_debug)
+    with DebugSession() as session:
+        session.initialize(
+            target=(run_as, code_to_debug),
+            start_method=start_method,
+            ignore_unobserved=[Event('continued')],
+            expected_returncode=ANY.int,
+            args=args,
+        )
+        session.send_request('setExceptionBreakpoints', {
+            'filters': ['uncaught']
+        }).wait_for_response()
+        session.start_debugging()
+
+        hit = session.wait_for_thread_stopped(reason='exception')
+        frames = hit.stacktrace.body['stackFrames']
+        assert frames[0]['line'] == line_numbers['unhandled']
+
+        resp_exc_info = session.send_request('exceptionInfo', {
+            'threadId': hit.thread_id
+        }).wait_for_response()
+
+        expected = ANY.dict_with({
+            'exceptionId': Regex('ArithmeticError'),
+            'description': 'bad code',
+            'breakMode': 'unhandled',
+            'details': ANY.dict_with({
+                'typeName': Regex('ArithmeticError'),
+                'message': 'bad code',
+                'source': Path(code_to_debug),
+            }),
+        })
+        assert resp_exc_info.body == expected
+        stack_str = resp_exc_info.body['details']['stackTrace']
+        stack_line_count = len(stack_str.split('\n'))
+        assert min_expected_lines <= stack_line_count <= max_expected_lines
+
+        session.send_request('continue').wait_for_response(freeze=False)
+
+        session.wait_for_exit()
diff --git a/tests/helpers/session.py b/tests/helpers/session.py
--- a/tests/helpers/session.py
+++ b/tests/helpers/session.py
@@ -46,6 +46,7 @@ def __init__(self, start_method='launch', ptvsd_port=None, pid=None):
 
         self.target = ('code', 'print("OK")')
         self.start_method = start_method
+        self.start_method_args = {}
         self.no_debug = False
         self.ptvsd_port = ptvsd_port or PTVSD_PORT
         self.multiprocess = False
@@ -230,6 +231,7 @@ def _setup_session(self, **kwargs):
         ] + kwargs.pop('ignore_unobserved', [])
 
         self.env.update(kwargs.pop('env', {}))
+        self.start_method_args.update(kwargs.pop('args', {}))
 
         self.path_mappings += kwargs.pop('path_mappings', [])
         self.debug_options += kwargs.pop('debug_options', [])
@@ -497,14 +499,14 @@ def handshake(self):
         self.wait_for_next(Event('initialized', {}))
 
         request = 'launch' if self.start_method == 'launch' else 'attach'
-        args = {
+        self.start_method_args.update({
             'debugOptions': self.debug_options,
             'pathMappings': self.path_mappings,
             'rules': self.rules,
-        }
+        })
         if self.success_exitcodes is not None:
-            args['successExitCodes'] = self.success_exitcodes
-        self.send_request(request, args).wait_for_response()
+            self.start_method_args['successExitCodes'] = self.success_exitcodes
+        self.send_request(request, self.start_method_args).wait_for_response()
 
         if not self.no_debug:
             # Issue 'threads' so that we get the 'thread' event for the main thread now,