Repository: microsoft/ptvsd

Issue:
BreakOnSystemExitZero debug option is not respected
## Environment data

- PTVSD version: 4.1.3
- OS and version: Windows 10
- Python version (& distribution if applicable, e.g. Anaconda): 3.6
- Using VS Code or Visual Studio: VS

## Actual behavior

Debugger breaks on `sys.exit(0)` even when the option to break on zero is disabled:

![image](https://user-images.githubusercontent.com/1696845/46029474-60732e00-c0a8-11e8-8ce4-88ab0dc8008f.png)


## Expected  behavior

Debugger only breaks on `sys.exit(0)` if the option is enabled.

## Steps to reproduce:
1. In tools, options, python, debugging, uncheck "Break on SystemExit exception with exit code of zero"
2. Debug this code:
```
sys.exit(0)
```

Edit: FYI all Python exceptions were unchecked:
![image](https://user-images.githubusercontent.com/1696845/46029708-0a52ba80-c0a9-11e8-9121-fc4b58f45289.png)



Discussion:
VSC may not have a way to set this option, but when using VSC with the old ptvsd, it was not breaking on sys.exit(0) and now it is breaking on it with new ptvsd.
/cc @DonJayamanne 
Please fix this soon. It's so annoying.
Also reported at https://developercommunity.visualstudio.com/content/problem/362033/sysexit0-gives-us-an-error-instead-of-exit.html
This is the expected behavior after discussing with @huguesv . The debugger currently does not follow this table. When fixing this bug we must ensure the behavior matches this.

| WaitOnNormalExit | WaitOnAbnormalExit | BreakOnSystemExitZero | Expected Behavior |
|------------------|--------------------|-----------------------|-------------------|
| False | False | False | <ul><li>Don't exception break if exitcode = 0</li><li>Exception break if exitcode ≠ 0</li> <li>Don't wait for user input</li></ul> |
| False | False | True | <ul><li>Always exception break on SystemExit</li><li>Don't wait for user input</li></ul> |
| False | True | False | <ul><li>Don't exception break if exitcode = 0</li> <li>Exception break if exitcode ≠ 0</li> <li>Wait for user input if exitcode ≠ 0</li></ul> |
| False | True | True | <ul><li>Always exception break on SystemExit</li> <li>Wait for user input if exitcode ≠ 0</li></ul> |
| True | False | False | <ul><li>Don't exception break if exitcode = 0</li> <li>Exception break if exitcode ≠ 0</li> <li>Wait for user input if exitcode = 0</li></ul> |
| True | False | True | <ul><li>Always exception break on SystemExit</li> <li>Wait for user input if exitcode = 0</li></ul> |
| True | True | False | <ul><li>Don't exception break if exitcode = 0</li> <li>Exception break if exitcode ≠ 0</li> <li>Always wait for user input</li></ul> |
| True | True | True | <ul><li>Always exception break on SystemExit</li> <li>Always wait for user input</li></ul> |

Resolving patch:
diff --git a/src/ptvsd/wrapper.py b/src/ptvsd/wrapper.py
--- a/src/ptvsd/wrapper.py
+++ b/src/ptvsd/wrapper.py
@@ -819,6 +819,7 @@ def bool_parser(str):
 DEBUG_OPTIONS_PARSER = {
     'WAIT_ON_ABNORMAL_EXIT': bool_parser,
     'WAIT_ON_NORMAL_EXIT': bool_parser,
+    'BREAK_SYSTEMEXIT_ZERO': bool_parser,
     'REDIRECT_OUTPUT': bool_parser,
     'VERSION': unquote,
     'INTERPRETER_OPTIONS': unquote,
@@ -837,6 +838,7 @@ def bool_parser(str):
     'RedirectOutput': 'REDIRECT_OUTPUT=True',
     'WaitOnNormalExit': 'WAIT_ON_NORMAL_EXIT=True',
     'WaitOnAbnormalExit': 'WAIT_ON_ABNORMAL_EXIT=True',
+    'BreakOnSystemExitZero': 'BREAK_SYSTEMEXIT_ZERO=True',
     'Django': 'DJANGO_DEBUG=True',
     'Flask': 'FLASK_DEBUG=True',
     'Jinja': 'FLASK_DEBUG=True',
@@ -890,6 +892,7 @@ def _parse_debug_options(opts):
     """Debug options are semicolon separated key=value pairs
         WAIT_ON_ABNORMAL_EXIT=True|False
         WAIT_ON_NORMAL_EXIT=True|False
+        BREAK_SYSTEMEXIT_ZERO=True|False
         REDIRECT_OUTPUT=True|False
         VERSION=string
         INTERPRETER_OPTIONS=string
@@ -2570,8 +2573,23 @@ def on_pydevd_thread_suspend_single_notification(self, seq, args):
             exc_name, exc_desc, _, _ = \
                 self._parse_exception_details(resp_args, include_stack=False)
 
-        extra['allThreadsStopped'] = True
+        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
 
+        extra['allThreadsStopped'] = True
         self.send_event(
             'stopped',
             reason=reason,


Test patch:
diff --git a/tests/conftest.py b/tests/conftest.py
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -115,8 +115,9 @@ def factory(source):
         line = source[0]
         indent = len(line) - len(line.lstrip())
         source = [line[indent:] for line in source]
-        source = ''.join(source)
 
+        # Write it to file.
+        source = ''.join(source)
         tmpfile = tmpdir.join(name + '.py')
         assert not tmpfile.check()
         # NOTE: This is a requirement with using pyfile. Adding this
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
@@ -6,6 +6,7 @@
 
 import pytest
 
+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
@@ -138,3 +139,72 @@ def raise_without_except():
             session.send_request('continue').wait_for_response(freeze=False)
 
         session.wait_for_exit()
+
+
+@pytest.mark.parametrize('raised', ['raised', ''])
+@pytest.mark.parametrize('uncaught', ['uncaught', ''])
+@pytest.mark.parametrize('zero', ['zero', ''])
+@pytest.mark.parametrize('exit_code', [0, 1, 'nan'])
+def test_systemexit(pyfile, run_as, start_method, raised, uncaught, zero, 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,))
+        try:
+            sys.exit(exit_code) #@handled
+        except SystemExit:
+            pass
+        sys.exit(exit_code) #@unhandled
+
+    line_numbers = get_marked_line_numbers(code_to_debug)
+
+    filters = []
+    if raised:
+        filters += ['raised']
+    if uncaught:
+        filters += ['uncaught']
+
+    with DebugSession() as session:
+        session.program_args = [repr(exit_code)]
+        if zero:
+            session.debug_options += ['BreakOnSystemExitZero']
+        session.initialize(
+            target=(run_as, code_to_debug),
+            start_method=start_method,
+            ignore_unobserved=[Event('continued'), Event('stopped')],
+            expected_returncode=ANY.int,
+        )
+        session.send_request('setExceptionBreakpoints', {
+            'filters': filters
+        }).wait_for_response()
+        session.start_debugging()
+
+        # When breaking on raised exceptions, we'll stop on both lines,
+        # unless it's SystemExit(0) and we asked to ignore that.
+        if raised and (zero or exit_code != 0):
+            hit = session.wait_for_thread_stopped(reason='exception')
+            frames = hit.stacktrace.body['stackFrames']
+            assert frames[0]['line'] == line_numbers['handled']
+            session.send_request('continue').wait_for_response(freeze=False)
+
+            hit = session.wait_for_thread_stopped(reason='exception')
+            frames = hit.stacktrace.body['stackFrames']
+            assert frames[0]['line'] == line_numbers['unhandled']
+            session.send_request('continue').wait_for_response(freeze=False)
+
+        # When breaking on uncaught exceptions, we'll stop on the second line,
+        # unless it's SystemExit(0) and we asked to ignore that.
+        # Note that if both raised and uncaught filters are set, there will be
+        # two stop for the second line - one for exception being raised, and one
+        # for it unwinding the stack without finding a handler. The block above
+        # takes care of the first stop, so here we just take care of the second.
+        if uncaught and (zero or exit_code != 0):
+            hit = session.wait_for_thread_stopped(reason='exception')
+            frames = hit.stacktrace.body['stackFrames']
+            assert frames[0]['line'] == line_numbers['unhandled']
+            session.send_request('continue').wait_for_response(freeze=False)
+
+        session.wait_for_exit()
diff --git a/tests/helpers/__init__.py b/tests/helpers/__init__.py
--- a/tests/helpers/__init__.py
+++ b/tests/helpers/__init__.py
@@ -5,6 +5,7 @@
 from __future__ import print_function, with_statement, absolute_import
 
 import os
+import re
 import sys
 import threading
 import time
@@ -67,4 +68,26 @@ def get_unique_port(base):
     return base + n
 
 
+# Given a path to a Python source file, extracts line numbers for
+# all lines that are marked with #@. For example, given this file:
+#
+#   print(1) #@foo
+#   print(2)
+#   print(3) #@bar
+#
+# the function will return:
+#
+#   {'foo': 1, 'bar': 3}
+#
+def get_marked_line_numbers(path):
+    with open(path) as f:
+        lines = {}
+        for i, line in enumerate(f):
+            match = re.search(r'#@(.*?)\s*$', line)
+            if match:
+                marker = match.group(1)
+                lines[marker] = i + 1
+    return lines
+
+
 from .printer import print