From a8309a3742e1f8be2a5fa39d7be69bdec58afb90 Mon Sep 17 00:00:00 2001 From: Pavel Minaev Date: Mon, 11 Feb 2019 16:34:27 -0800 Subject: [PATCH] Fix #841: BreakOnSystemExitZero debug option is not respected --- src/ptvsd/wrapper.py | 20 ++++++++++- tests/func/test_exception.py | 68 ++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/ptvsd/wrapper.py b/src/ptvsd/wrapper.py index 06543557..ebcd71aa 100644 --- 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 @@ DEBUG_OPTIONS_BY_FLAG = { '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 @@ class VSCodeMessageProcessor(VSCLifecycleMsgProcessor): 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, diff --git a/tests/func/test_exception.py b/tests/func/test_exception.py index f30b0b11..ff481bed 100644 --- a/tests/func/test_exception.py +++ b/tests/func/test_exception.py @@ -6,6 +6,7 @@ from __future__ import print_function, with_statement, absolute_import import pytest +from tests.helpers import print from tests.helpers.session import DebugSession from tests.helpers.timeline import Event from tests.helpers.pattern import ANY, Path @@ -138,3 +139,70 @@ def test_vsc_exception_options_raise_without_except(pyfile, run_as, start_method 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) + except SystemExit: + pass + sys.exit(exit_code) + + 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'] == 7 + 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'] == 10 + 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'] == 10 + session.send_request('continue').wait_for_response(freeze=False) + + session.wait_for_exit()