Repository: microsoft/ptvsd

Issue:
SystemExit is detected on Python 2 but not on Python 3
It seems that wrapper.py has some code to deal with this to auto-resume, but I didn't dig further... what's happening now is that on Python 2 the debugger breaks on `SystemExit(0)` but on Python 3 it doesn't (it should probably be consistent on both cases).

Note that on `SystemExit(1)` it always stops in any case.


Discussion:
There's a separate setting just for SystemExit(0) in ptvsd. We must not be implementing that one correctly on Python 2.

https://github.com/Microsoft/ptvsd/issues/841#issuecomment-436059489
This is a regression due to https://github.com/Microsoft/ptvsd/commit/957b01002dcd33f0f45232caf03b463d43b46a72#diff-b29f720860019d961dde440c375682f4R1127. In our `SystemExit` handling code, we expect exception names to be reported fully qualified - which means that it's `exceptions.SystemExit` on Python 2, and unqualified `SystemExit` on Python 3. With the change, the name that's reported from pydevd is `__class__.__name__`, which is always unqualified, so our check fails. 
Not entirely sure it's a regression actually, the code might have been wrong all along - the test that was supposed to catch this has a defect.

Resolving patch:
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
@@ -1123,12 +1123,25 @@ def internal_get_exception_details_json(dbg, request, thread_id, set_additional_
             finally:
                 topmost_frame = None
 
+        name = 'exception: type unknown'
+        if exc_type is not None:
+            try:
+                name = exc_type.__qualname__
+            except:
+                try:
+                    name = exc_type.__name__
+                except:
+                    try:
+                        name = str(exc_type)
+                    except:
+                        pass
+
+        description = 'exception: no description'
         if exc_desc is not None:
-            name = exc_desc.__class__.__name__
-            description = '%s' % (exc_desc,)
-        else:
-            name = 'exception: type unknown'
-            description = 'exception: no description'
+            try:
+                description = str(exc_desc)
+            except:
+                pass
 
         stack_str = ''.join(traceback.format_list(frames))
 
diff --git a/src/ptvsd/messaging.py b/src/ptvsd/messaging.py
--- a/src/ptvsd/messaging.py
+++ b/src/ptvsd/messaging.py
@@ -100,7 +100,11 @@ def read_json(self):
             raise IOError('Content-Length is missing or invalid')
 
         try:
-            body = self._reader.read(length)
+            body = b''
+            while length > 0:
+                chunk = self._reader.read(length)
+                body += chunk
+                length -= len(chunk)
         except Exception:
             if self._is_closing:
                 raise EOFError
diff --git a/src/ptvsd/wrapper.py b/src/ptvsd/wrapper.py
--- a/src/ptvsd/wrapper.py
+++ b/src/ptvsd/wrapper.py
@@ -1109,6 +1109,7 @@ def __init__(
         self.is_process_created_lock = threading.Lock()
         self.thread_map = IDMap()
         self._path_mappings = []
+        self._success_exitcodes = []
         self.internals_filter = InternalsFilter()
         self.new_thread_lock = threading.Lock()
 
@@ -1352,6 +1353,11 @@ def _handle_launch_or_attach(self, request, args):
 
         self._initialize_path_maps(args)
 
+        default_success_exitcodes = [0]
+        if self.debug_options.get('DJANGO_DEBUG', False):
+            default_success_exitcodes += [3]
+        self._success_exitcodes = args.get('successExitCodes', default_success_exitcodes)
+
     def _handle_detach(self):
         ptvsd.log.info('Detaching ...')
         # TODO: Skip if already detached?
@@ -1836,21 +1842,25 @@ def on_pydevd_thread_suspend_single_notification(self, seq, args):
             exc_name = resp_args['body']['exceptionId']
             exc_desc = resp_args['body']['description']
 
-        if not self.debug_options.get('BREAK_SYSTEMEXIT_ZERO', False):
-            # SystemExit is qualified on Python 2, and unqualified on Python 3
-            sysexit_exc_name = 'exceptions.SystemExit' if sys.version_info < (3,) else 'SystemExit'
-            if exc_name == sysexit_exc_name:
-                try:
-                    exit_code = int(exc_desc)
-                except ValueError:
-                    # It is legal to invoke exit() with a non-integer argument, and SystemExit will
-                    # pass that through. It's considered an error exit, same as non-zero integer.
-                    ignore = False
-                else:
-                    ignore = (exit_code == 0)
-                if ignore:
-                    self._resume_all_threads()
-                    return
+        if not self.debug_options.get('BREAK_SYSTEMEXIT_ZERO', False) and exc_name == 'SystemExit':
+            ptvsd.log.info('{0}({1!r})', exc_name, exc_desc)
+            try:
+                exit_code = int(exc_desc)
+            except ValueError:
+                # It is legal to invoke exit() with a non-integer argument, and SystemExit will
+                # pass that through. It's considered an error exit, same as non-zero integer.
+                ptvsd.log.info('Exit code {0!r} cannot be converted to int, treating as failure', exc_desc)
+                ignore = False
+            else:
+                ignore = exit_code in self._success_exitcodes
+                ptvsd.log.info(
+                    'Process exiting with {0} exit code {1}',
+                    'success' if ignore else 'failure',
+                    exc_desc,
+                )
+            if ignore:
+                self._resume_all_threads()
+                return
 
         extra['allThreadsStopped'] = True
         self.send_event(


Test patch:
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
@@ -194,7 +194,7 @@ def code_to_debug():
         session.initialize(
             target=(run_as, code_to_debug),
             start_method=start_method,
-            ignore_unobserved=[Event('continued'), Event('stopped')],
+            ignore_unobserved=[Event('continued')],
             expected_returncode=ANY.int,
         )
         session.send_request('setExceptionBreakpoints', {
@@ -311,3 +311,36 @@ def code_to_debug():
             session.send_request('continue').wait_for_response(freeze=False)
 
         session.wait_for_exit()
+
+
+@pytest.mark.parametrize('exit_code', [0, 3])
+def test_success_exitcodes(pyfile, run_as, start_method, exit_code):
+    @pyfile
+    def code_to_debug():
+        from dbgimporter import import_and_enable_debugger
+        import_and_enable_debugger()
+        import sys
+        exit_code = eval(sys.argv[1])
+        print('sys.exit(%r)' % (exit_code,))
+        sys.exit(exit_code)
+
+    with DebugSession() as session:
+        session.program_args = [repr(exit_code)]
+        session.success_exitcodes = [3]
+        session.initialize(
+            target=(run_as, code_to_debug),
+            start_method=start_method,
+            ignore_unobserved=[Event('continued')],
+            # Exit code doesn't match: https://github.com/Microsoft/ptvsd/issues/1278
+            expected_returncode=ANY.int,
+        )
+        session.send_request('setExceptionBreakpoints', {
+            'filters': ['uncaught']
+        }).wait_for_response()
+        session.start_debugging()
+
+        if exit_code == 0:
+            session.wait_for_thread_stopped(reason='exception')
+            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
@@ -52,6 +52,7 @@ def __init__(self, start_method='launch', ptvsd_port=None, pid=None):
         self.multiprocess_port_range = None
         self.debug_options = ['RedirectOutput']
         self.path_mappings = []
+        self.success_exitcodes = None
         self.rules = []
         self.env = os.environ.copy()
         self.env['PYTHONPATH'] = os.path.dirname(debuggee.__file__)
@@ -496,11 +497,14 @@ def handshake(self):
         self.wait_for_next(Event('initialized', {}))
 
         request = 'launch' if self.start_method == 'launch' else 'attach'
-        self.send_request(request, {
+        args = {
             'debugOptions': self.debug_options,
             'pathMappings': self.path_mappings,
             'rules': self.rules,
-        }).wait_for_response()
+        }
+        if self.success_exitcodes is not None:
+            args['successExitCodes'] = self.success_exitcodes
+        self.send_request(request, args).wait_for_response()
 
         if not self.no_debug:
             # Issue 'threads' so that we get the 'thread' event for the main thread now,
diff --git a/tests/test_socket.py b/tests/test_socket.py
--- a/tests/test_socket.py
+++ b/tests/test_socket.py
@@ -20,8 +20,8 @@ def test_reuse_same_address_port(self):
         # sockets. This to prevent accidental changes to socket options. In Windows
         # SO_REUSEADDR flag allows two sockets to bind to same address:port combination.
         # Windows should always use SO_EXCLUSIVEADDRUSE
+        sock1 = create_server(self.HOST1, 0)
         try:
-            sock1 = create_server(self.HOST1, 0)
             _, PORT1 = sock1.getsockname()
             with pytest.raises(Exception):
                 create_server(self.HOST1, PORT1)