Repository: microsoft/ptvsd

Issue:
Unhandled exception breaks on the wrong line in with block
## Environment data

- PTVSD version: 4.1.2
- OS and version: Windows
- Python version (& distribution if applicable, e.g. Anaconda): Python 3.6 64-bit
- Using VS Code or Visual Studio: Visual Studio 2017 15.8

## Actual behavior

Debugger breaks on the last line of the with block ie `print('done')` line

## Expected  behavior

Debugger breaks on the line that causes the exception inside the with block ie `json.loads` line

## Steps to reproduce:

Use this code (assumes a hello.json file is present). Note that json  module was not imported, which will cause an exception to be raised.:
```
with open('hello.json') as f:
    text = json.loads(f)
    print(text)
    print('done')
```

Start debugging.
Exception is reported on line 4, instead of line 2

By comparison, this code behaves as expected, breaks on line 2
```
f = open('hello.json')
text = json.loads(f)
print(text)
print('done')
```



Discussion:

Resolving patch:
diff --git a/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py b/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py
--- a/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py
+++ b/ptvsd/_vendored/pydevd/_pydevd_bundle/pydevd_comm.py
@@ -769,7 +769,15 @@ def make_thread_killed_message(self, id):
         except:
             return self.make_error_message(0, get_exception_traceback_str())
 
-    def make_thread_stack_str(self, frame):
+    def make_thread_stack_str(self, frame, frame_to_lineno=None):
+        '''
+        :param frame_to_lineno:
+            If available, the line number for the frame will be gotten from this dict,
+            otherwise frame.f_lineno will be used (needed for unhandled exceptions as
+            the place where we report may be different from the place where it's raised).
+        '''
+        if frame_to_lineno is None:
+            frame_to_lineno = {}
         make_valid_xml_value = pydevd_xml.make_valid_xml_value
         cmd_text_list = []
         append = cmd_text_list.append
@@ -801,7 +809,7 @@ def make_thread_stack_str(self, frame):
 
                 # print("file is ", filename_in_utf8)
 
-                lineno = str(curr_frame.f_lineno)
+                lineno = frame_to_lineno.get(curr_frame, curr_frame.f_lineno)
                 # print("line is ", lineno)
 
                 # Note: variables are all gotten 'on-demand'.
@@ -822,6 +830,7 @@ def make_thread_suspend_str(
         stop_reason=None,
         message=None,
         suspend_type="trace",
+        frame_to_lineno=None
         ):
         """
         :return tuple(str,str):
@@ -860,17 +869,17 @@ def make_thread_suspend_str(
         if suspend_type is not None:
             append(' suspend_type="%s"' % (suspend_type,))
         append('>')
-        thread_stack_str = self.make_thread_stack_str(frame)
+        thread_stack_str = self.make_thread_stack_str(frame, frame_to_lineno)
         append(thread_stack_str)
         append("</thread></xml>")
 
         return ''.join(cmd_text_list), thread_stack_str
 
-    def make_thread_suspend_message(self, thread_id, frame, stop_reason, message, suspend_type):
+    def make_thread_suspend_message(self, thread_id, frame, stop_reason, message, suspend_type, frame_to_lineno=None):
         try:
             
             thread_suspend_str, thread_stack_str = self.make_thread_suspend_str(
-                thread_id, frame, stop_reason, message, suspend_type)
+                thread_id, frame, stop_reason, message, suspend_type, frame_to_lineno=frame_to_lineno)
             cmd = NetCommand(CMD_THREAD_SUSPEND, 0, thread_suspend_str)
             cmd.thread_stack_str = thread_stack_str
             return cmd
diff --git a/ptvsd/_vendored/pydevd/pydevd.py b/ptvsd/_vendored/pydevd/pydevd.py
--- a/ptvsd/_vendored/pydevd/pydevd.py
+++ b/ptvsd/_vendored/pydevd/pydevd.py
@@ -790,10 +790,14 @@ def cancel_async_evaluation(self, thread_id, frame_id):
         finally:
             self._main_lock.release()
 
-    def do_wait_suspend(self, thread, frame, event, arg, suspend_type="trace", send_suspend_message=True): #@UnusedVariable
+    def do_wait_suspend(self, thread, frame, event, arg, suspend_type="trace", send_suspend_message=True, is_unhandled_exception=False): #@UnusedVariable
         """ busy waits until the thread state changes to RUN
         it expects thread's state as attributes of the thread.
         Upon running, processes any outstanding Stepping commands.
+        
+        :param is_unhandled_exception:
+            If True we should use the line of the exception instead of the current line in the frame
+            as the paused location on the top-level frame (exception info must be passed on 'arg').
         """
         self.process_internal_commands()
         thread_stack_str = ''   # @UnusedVariable -- this is here so that `make_get_thread_stack_message`
@@ -801,7 +805,15 @@ def do_wait_suspend(self, thread, frame, event, arg, suspend_type="trace", send_
         
         if send_suspend_message:
             message = thread.additional_info.pydev_message
-            cmd = self.cmd_factory.make_thread_suspend_message(get_thread_id(thread), frame, thread.stop_reason, message, suspend_type)
+            frame_to_lineno = {}
+            if is_unhandled_exception:
+                # arg must be the exception info (tuple(exc_type, exc, traceback))
+                tb = arg[2]
+                while tb is not None:
+                    frame_to_lineno[tb.tb_frame] = tb.tb_lineno
+                    tb = tb.tb_next
+            cmd = self.cmd_factory.make_thread_suspend_message(get_thread_id(thread), frame, thread.stop_reason, message, suspend_type, frame_to_lineno=frame_to_lineno)
+            frame_to_lineno.clear()
             thread_stack_str = cmd.thread_stack_str  # @UnusedVariable -- `make_get_thread_stack_message` uses it later.
             self.writer.add_command(cmd)
 
@@ -926,7 +938,7 @@ def stop_on_unhandled_exception(self, thread, frame, frames_byid, arg):
             try:
                 add_exception_to_frame(frame, arg)
                 self.set_suspend(thread, CMD_ADD_EXCEPTION_BREAK)
-                self.do_wait_suspend(thread, frame, 'exception', arg, "trace")
+                self.do_wait_suspend(thread, frame, 'exception', arg, "trace", is_unhandled_exception=True)
             except:
                 pydev_log.error("We've got an error while stopping in post-mortem: %s\n" % (arg[0],))
         finally:


Test patch:
diff --git a/ptvsd/_vendored/pydevd/tests_python/debugger_unittest.py b/ptvsd/_vendored/pydevd/tests_python/debugger_unittest.py
--- a/ptvsd/_vendored/pydevd/tests_python/debugger_unittest.py
+++ b/ptvsd/_vendored/pydevd/tests_python/debugger_unittest.py
@@ -284,18 +284,6 @@ def add_command_line_args(self, args):
         ret = writer.update_command_line_args(ret)  # Provide a hook for the writer
         return args + ret
 
-    def check_case_simple(self, target, test_file):
-
-        class WriterThreadCase(AbstractWriterThread):
-
-            TEST_FILE = _get_debugger_test_file(test_file)
-
-            def run(self):
-                return target(self)
-
-        with self.check_case(WriterThreadCase) as writer:
-            yield writer
-
     @contextmanager
     def check_case(self, writer_class):
         if callable(writer_class):
@@ -503,6 +491,16 @@ def get_pydevd_file(self):
         dirname = os.path.dirname(dirname)
         return os.path.abspath(os.path.join(dirname, 'pydevd.py'))
 
+    def get_line_index_with_content(self, line_content):
+        '''
+        :return the line index which has the given content (1-based).
+        '''
+        with open(self.TEST_FILE, 'r') as stream:
+            for i_line, line in enumerate(stream):
+                if line_content in line:
+                    return i_line + 1
+        raise AssertionError('Did not find: %s in %s' % (line_content, self.TEST_FILE))
+
     def get_cwd(self):
         return os.path.dirname(self.get_pydevd_file())
 
@@ -524,7 +522,7 @@ def write(self, s):
         meaning = ID_TO_MEANING.get(re.search(r'\d+', s).group(), '')
         if meaning:
             meaning += ': '
-            
+
         self.log.append('write: %s%s' % (meaning, s,))
 
         if SHOW_WRITES_AND_READS:
@@ -885,6 +883,9 @@ def write_list_threads(self):
     def wait_for_list_threads(self, seq):
         return self.wait_for_message(lambda msg:msg.startswith('502\t%s' % (seq,)))
 
+    def wait_for_get_thread_stack_message(self):
+        return self.wait_for_message(lambda msg:msg.startswith('%s\t' % (CMD_GET_THREAD_STACK,)))
+
     def wait_for_message(self, accept_message, unquote_msg=True, expect_xml=True):
         import untangle
         from io import StringIO
diff --git a/ptvsd/_vendored/pydevd/tests_python/resources/_debugger_case_unhandled_exception_get_stack.py b/ptvsd/_vendored/pydevd/tests_python/resources/_debugger_case_unhandled_exception_get_stack.py
new file mode 100644
--- /dev/null
+++ b/ptvsd/_vendored/pydevd/tests_python/resources/_debugger_case_unhandled_exception_get_stack.py
@@ -0,0 +1,12 @@
+from contextlib import contextmanager
+
+
+@contextmanager
+def something():
+    yield
+
+
+with something():
+    raise ValueError('TEST SUCEEDED')  # break line on unhandled exception
+    print('a')
+    print('b')
diff --git a/ptvsd/_vendored/pydevd/tests_python/test_debugger.py b/ptvsd/_vendored/pydevd/tests_python/test_debugger.py
--- a/ptvsd/_vendored/pydevd/tests_python/test_debugger.py
+++ b/ptvsd/_vendored/pydevd/tests_python/test_debugger.py
@@ -1212,6 +1212,33 @@ def test_case_set_next_statement(case_setup):
         writer.finished_ok = True
 
 
+def test_unhandled_exceptions_get_stack(case_setup_unhandled_exceptions):
+
+    with case_setup_unhandled_exceptions.test_file(
+            '_debugger_case_unhandled_exception_get_stack.py') as writer:
+
+        writer.write_add_exception_breakpoint_with_policy('Exception', "0", "1", "0")
+        writer.write_make_initial_run()
+
+        hit = writer.wait_for_breakpoint_hit(REASON_UNCAUGHT_EXCEPTION)
+        writer.write_get_thread_stack(hit.thread_id)
+
+        msg = writer.wait_for_get_thread_stack_message()
+        files = [frame['file'] for frame in  msg.thread.frame]
+        assert msg.thread['id'] == hit.thread_id
+        if not files[0].endswith('_debugger_case_unhandled_exception_get_stack.py'):
+            raise AssertionError('Expected to find _debugger_case_unhandled_exception_get_stack.py in files[0]. Found: %s' % ('\n'.join(files),))
+
+        assert len(msg.thread.frame) == 0  # No back frames (stopped in main).
+        assert msg.thread.frame['name'] == '<module>'
+        assert msg.thread.frame['line'] == str(writer.get_line_index_with_content('break line on unhandled exception'))
+
+        writer.write_run_thread(hit.thread_id)
+
+        writer.log.append('Marking finished ok.')
+        writer.finished_ok = True
+
+
 @pytest.mark.skipif(not IS_CPYTHON, reason='Only for Python.')
 def test_case_get_next_statement_targets(case_setup):
     with case_setup.test_file('_debugger_case_get_next_statement_targets.py') as writer:
@@ -1739,7 +1766,7 @@ def _ignore_stderr_line(line):
 
         for request_thread_id in thread_id_to_name:
             writer.write_get_thread_stack(request_thread_id)
-            msg = writer.wait_for_message(lambda msg:msg.startswith('%s\t' % (CMD_GET_THREAD_STACK,)))
+            msg = writer.wait_for_get_thread_stack_message()
             files = [frame['file'] for frame in  msg.thread.frame]
             assert msg.thread['id'] == request_thread_id
             if not files[0].endswith('_debugger_case_get_thread_stack.py'):
diff --git a/ptvsd/_vendored/pydevd/tests_python/test_tracing_on_top_level.py b/ptvsd/_vendored/pydevd/tests_python/test_tracing_on_top_level.py
--- a/ptvsd/_vendored/pydevd/tests_python/test_tracing_on_top_level.py
+++ b/ptvsd/_vendored/pydevd/tests_python/test_tracing_on_top_level.py
@@ -39,15 +39,15 @@ class DummyPyDb(PyDB):
     def __init__(self):
         PyDB.__init__(self, set_as_global=False)
 
-    def do_wait_suspend(self, thread, frame, event, arg, suspend_type="trace", send_suspend_message=True):
+    def do_wait_suspend(
+            self, thread, frame, event, arg, *args, **kwargs):
         from _pydevd_bundle.pydevd_constants import STATE_RUN
         info = thread.additional_info
         info.pydev_step_cmd = -1
         info.pydev_step_stop = None
         info.pydev_state = STATE_RUN
 
-        return PyDB.do_wait_suspend(
-            self, thread, frame, event, arg, suspend_type=suspend_type, send_suspend_message=send_suspend_message)
+        return PyDB.do_wait_suspend(self, thread, frame, event, arg, *args, **kwargs)
 
 
 class _TraceTopLevel(object):