New launch option: "onTerminate":"KeyboardInterrupt" allows for a soft-kill. Fixes #1022

This commit is contained in:
Fabio Zadrozny 2022-08-19 10:04:42 -03:00
parent 61321253e7
commit 8157273a28
14 changed files with 4541 additions and 4246 deletions

View file

@ -28,7 +28,7 @@ import ctypes
from _pydevd_bundle.pydevd_collect_bytecode_info import code_to_bytecode_representation
import itertools
import linecache
from _pydevd_bundle.pydevd_utils import DAPGrouper
from _pydevd_bundle.pydevd_utils import DAPGrouper, interrupt_main_thread
from _pydevd_bundle.pydevd_daemon_thread import run_as_pydevd_daemon_thread
from _pydevd_bundle.pydevd_thread_lifecycle import pydevd_find_thread_by_id, resume_threads
import tokenize
@ -1076,6 +1076,9 @@ class PyDevdAPI(object):
def set_terminate_child_processes(self, py_db, terminate_child_processes):
py_db.terminate_child_processes = terminate_child_processes
def set_terminate_keyboard_interrupt(self, py_db, terminate_keyboard_interrupt):
py_db.terminate_keyboard_interrupt = terminate_keyboard_interrupt
def terminate_process(self, py_db):
'''
Terminates the current process (and child processes if the option to also terminate
@ -1097,6 +1100,12 @@ class PyDevdAPI(object):
self.terminate_process(py_db)
def request_terminate_process(self, py_db):
if py_db.terminate_keyboard_interrupt:
if not py_db.keyboard_interrupt_requested:
py_db.keyboard_interrupt_requested = True
interrupt_main_thread()
return
# We mark with a terminate_requested to avoid that paused threads start running
# (we should terminate as is without letting any paused thread run).
py_db.terminate_requested = True

File diff suppressed because it is too large Load diff

View file

@ -169,6 +169,7 @@ from _pydevd_bundle.pydevd_frame_utils import add_exception_to_frame, just_raise
from _pydevd_bundle.pydevd_utils import get_clsname_for_code
from pydevd_file_utils import get_abs_path_real_path_and_base_from_frame
from _pydevd_bundle.pydevd_comm_constants import constant_to_str, CMD_SET_FUNCTION_BREAK
import sys
try:
from _pydevd_bundle.pydevd_bytecode_utils import get_smart_step_into_variant_from_frame_offset
except ImportError:
@ -737,10 +738,10 @@ cdef class PyDBFrame:
# cost is still high (maybe we could use code-generation in the future and make the code
# generation be better split among what each part does).
# DEBUG = '_debugger_case_generator.py' in frame.f_code.co_filename
main_debugger, abs_path_canonical_path_and_base, info, thread, frame_skips_cache, frame_cache_key = self._args
# if DEBUG: print('frame trace_dispatch %s %s %s %s %s %s, stop: %s' % (frame.f_lineno, frame.f_code.co_name, frame.f_code.co_filename, event, constant_to_str(info.pydev_step_cmd), arg, info.pydev_step_stop))
try:
# DEBUG = '_debugger_case_generator.py' in frame.f_code.co_filename
main_debugger, abs_path_canonical_path_and_base, info, thread, frame_skips_cache, frame_cache_key = self._args
# if DEBUG: print('frame trace_dispatch %s %s %s %s %s %s, stop: %s' % (frame.f_lineno, frame.f_code.co_name, frame.f_code.co_filename, event, constant_to_str(info.pydev_step_cmd), arg, info.pydev_step_stop))
info.is_tracing += 1
# TODO: This shouldn't be needed. The fact that frame.f_lineno
@ -1139,7 +1140,15 @@ cdef class PyDBFrame:
frame_skips_cache[line_cache_key] = 0
except:
pydev_log.exception()
# Unfortunately Python itself stops the tracing when it originates from
# the tracing function, so, we can't do much about it (just let the user know).
exc = sys.exc_info()[0]
cmd = main_debugger.cmd_factory.make_console_message(
'%s raised from within the callback set in sys.settrace.\nDebugging will be disabled for this thread (%s).\n' % (exc, thread,))
main_debugger.writer.add_command(cmd)
if not issubclass(exc, (KeyboardInterrupt, SystemExit)):
pydev_log.exception()
raise
# step handling. We stop when we hit the right frame
@ -1364,22 +1373,22 @@ cdef class PyDBFrame:
info.pydev_step_cmd = -1
info.pydev_state = 1
except KeyboardInterrupt:
raise
except:
try:
pydev_log.exception()
info.pydev_original_step_cmd = -1
info.pydev_step_cmd = -1
info.pydev_step_stop = None
except:
# if we are quitting, let's stop the tracing
if main_debugger.quitting:
return None if is_call else NO_FTRACE
# if we are quitting, let's stop the tracing
if main_debugger.quitting:
return None if is_call else NO_FTRACE
return self.trace_dispatch
except:
# Unfortunately Python itself stops the tracing when it originates from
# the tracing function, so, we can't do much about it (just let the user know).
exc = sys.exc_info()[0]
cmd = main_debugger.cmd_factory.make_console_message(
'%s raised from within the callback set in sys.settrace.\nDebugging will be disabled for this thread (%s).\n' % (exc, thread,))
main_debugger.writer.add_command(cmd)
if not issubclass(exc, (KeyboardInterrupt, SystemExit)):
pydev_log.exception()
raise
return self.trace_dispatch
finally:
info.is_tracing -= 1

View file

@ -10,6 +10,7 @@ from _pydevd_bundle.pydevd_frame_utils import add_exception_to_frame, just_raise
from _pydevd_bundle.pydevd_utils import get_clsname_for_code
from pydevd_file_utils import get_abs_path_real_path_and_base_from_frame
from _pydevd_bundle.pydevd_comm_constants import constant_to_str, CMD_SET_FUNCTION_BREAK
import sys
try:
from _pydevd_bundle.pydevd_bytecode_utils import get_smart_step_into_variant_from_frame_offset
except ImportError:
@ -590,10 +591,10 @@ class PyDBFrame:
# cost is still high (maybe we could use code-generation in the future and make the code
# generation be better split among what each part does).
# DEBUG = '_debugger_case_generator.py' in frame.f_code.co_filename
main_debugger, abs_path_canonical_path_and_base, info, thread, frame_skips_cache, frame_cache_key = self._args
# if DEBUG: print('frame trace_dispatch %s %s %s %s %s %s, stop: %s' % (frame.f_lineno, frame.f_code.co_name, frame.f_code.co_filename, event, constant_to_str(info.pydev_step_cmd), arg, info.pydev_step_stop))
try:
# DEBUG = '_debugger_case_generator.py' in frame.f_code.co_filename
main_debugger, abs_path_canonical_path_and_base, info, thread, frame_skips_cache, frame_cache_key = self._args
# if DEBUG: print('frame trace_dispatch %s %s %s %s %s %s, stop: %s' % (frame.f_lineno, frame.f_code.co_name, frame.f_code.co_filename, event, constant_to_str(info.pydev_step_cmd), arg, info.pydev_step_stop))
info.is_tracing += 1
# TODO: This shouldn't be needed. The fact that frame.f_lineno
@ -992,7 +993,15 @@ class PyDBFrame:
frame_skips_cache[line_cache_key] = 0
except:
pydev_log.exception()
# Unfortunately Python itself stops the tracing when it originates from
# the tracing function, so, we can't do much about it (just let the user know).
exc = sys.exc_info()[0]
cmd = main_debugger.cmd_factory.make_console_message(
'%s raised from within the callback set in sys.settrace.\nDebugging will be disabled for this thread (%s).\n' % (exc, thread,))
main_debugger.writer.add_command(cmd)
if not issubclass(exc, (KeyboardInterrupt, SystemExit)):
pydev_log.exception()
raise
# step handling. We stop when we hit the right frame
@ -1217,22 +1226,22 @@ class PyDBFrame:
info.pydev_step_cmd = -1
info.pydev_state = STATE_RUN
except KeyboardInterrupt:
raise
except:
try:
pydev_log.exception()
info.pydev_original_step_cmd = -1
info.pydev_step_cmd = -1
info.pydev_step_stop = None
except:
# if we are quitting, let's stop the tracing
if main_debugger.quitting:
return None if is_call else NO_FTRACE
# if we are quitting, let's stop the tracing
if main_debugger.quitting:
return None if is_call else NO_FTRACE
return self.trace_dispatch
except:
# Unfortunately Python itself stops the tracing when it originates from
# the tracing function, so, we can't do much about it (just let the user know).
exc = sys.exc_info()[0]
cmd = main_debugger.cmd_factory.make_console_message(
'%s raised from within the callback set in sys.settrace.\nDebugging will be disabled for this thread (%s).\n' % (exc, thread,))
main_debugger.writer.add_command(cmd)
if not issubclass(exc, (KeyboardInterrupt, SystemExit)):
pydev_log.exception()
raise
return self.trace_dispatch
finally:
info.is_tracing -= 1

View file

@ -305,6 +305,13 @@ class NetCommandFactoryJson(NetCommandFactory):
event = OutputEvent(body)
return NetCommand(CMD_WRITE_TO_CONSOLE, 0, event, is_json=True)
@overrides(NetCommandFactory.make_console_message)
def make_console_message(self, msg):
category = 'console'
body = OutputEventBody(msg, category)
event = OutputEvent(body)
return NetCommand(CMD_WRITE_TO_CONSOLE, 0, event, is_json=True)
_STEP_REASONS = set([
CMD_STEP_INTO,
CMD_STEP_INTO_MY_CODE,

View file

@ -131,6 +131,9 @@ class NetCommandFactory(object):
def make_warning_message(self, msg):
return self.make_io_message(msg, 2)
def make_console_message(self, msg):
return self.make_io_message(msg, 2)
def make_io_message(self, msg, ctx):
'''
@param msg: the message to pass to the debug server

View file

@ -333,6 +333,9 @@ class PyDevJsonCommandProcessor(object):
terminate_child_processes = args.get('terminateChildProcesses', True)
self.api.set_terminate_child_processes(py_db, terminate_child_processes)
terminate_keyboard_interrupt = args.get('onTerminate', 'kill') == 'KeyboardInterrupt'
self.api.set_terminate_keyboard_interrupt(py_db, terminate_keyboard_interrupt)
variable_presentation = args.get('variablePresentation', None)
if isinstance(variable_presentation, dict):

View file

@ -374,7 +374,7 @@ class DAPGrouper(object):
return ''
def interrupt_main_thread(main_thread):
def interrupt_main_thread(main_thread=None):
'''
Generates a KeyboardInterrupt in the main thread by sending a Ctrl+C
or by calling thread.interrupt_main().
@ -386,6 +386,9 @@ def interrupt_main_thread(main_thread):
when the next Python instruction is about to be executed (so, it won't interrupt
a sleep(1000)).
'''
if main_thread is None:
main_thread = threading.main_thread()
pydev_log.debug('Interrupt main thread.')
called = False
try:

View file

@ -161,14 +161,19 @@ if __name__ == '__main__':
use_cython = os.getenv('PYDEVD_USE_CYTHON', '').lower()
# Note: don't import pydevd during build (so, accept just yes/no in this case).
if use_cython == 'yes':
print("Building")
build()
elif use_cython == 'no':
print("Removing binaries")
remove_binaries(['.pyd', '.so'])
elif not use_cython:
# Regular process
if '--no-regenerate-files' not in sys.argv:
print("Generating dont trace files")
generate_dont_trace_files()
print("Generating cython modules")
generate_cython_module()
print("Building")
build()
else:
raise RuntimeError('Unexpected value for PYDEVD_USE_CYTHON: %s (accepted: yes, no)' % (use_cython,))

View file

@ -90,9 +90,13 @@ def _generate_cython_from_files(target, modules):
# DO NOT edit manually!
''']
found = []
for mod in modules:
found.append(mod.__file__)
contents.append(get_cython_contents(mod.__file__))
print('Generating cython from: %s' % (found,))
with open(target, 'w') as stream:
stream.write(''.join(contents))
@ -206,6 +210,7 @@ def remove_if_exists(f):
def generate_cython_module():
print('Removing pydevd_cython.pyx')
remove_if_exists(os.path.join(root_dir, '_pydevd_bundle', 'pydevd_cython.pyx'))
target = os.path.join(root_dir, '_pydevd_bundle', 'pydevd_cython.pyx')

View file

@ -540,6 +540,13 @@ class PyDB(object):
# Determines whether we should terminate child processes when asked to terminate.
self.terminate_child_processes = True
# Determines whether we should try to do a soft terminate (i.e.: interrupt the main
# thread with a KeyboardInterrupt).
self.terminate_keyboard_interrupt = False
# Set to True after a keyboard interrupt is requested the first time.
self.keyboard_interrupt_requested = False
# These are the breakpoints received by the PyDevdAPI. They are meant to store
# the breakpoints in the api -- its actual contents are managed by the api.
self.api_received_breakpoints = {}

View file

@ -6370,6 +6370,62 @@ def test_logging_api(case_setup_multiprocessing, tmpdir):
writer.finished_ok = True
@pytest.mark.parametrize('soft_kill', [False, True])
def test_soft_terminate(case_setup, pyfile, soft_kill):
@pyfile
def target():
import time
try:
while True:
time.sleep(.2) # break here
except KeyboardInterrupt:
# i.e.: The test succeeds if a keyboard interrupt is received.
print('TEST SUCEEDED!')
raise
def check_test_suceeded_msg(self, stdout, stderr):
if soft_kill:
return 'TEST SUCEEDED' in ''.join(stdout)
else:
return 'TEST SUCEEDED' not in ''.join(stdout)
def additional_output_checks(writer, stdout, stderr):
if soft_kill:
assert "KeyboardInterrupt" in stderr
else:
assert not stderr
with case_setup.test_file(
target,
EXPECTED_RETURNCODE='any',
check_test_suceeded_msg=check_test_suceeded_msg,
additional_output_checks=additional_output_checks,
) as writer:
json_facade = JsonFacade(writer)
json_facade.write_launch(
onTerminate="KeyboardInterrupt" if soft_kill else "kill",
justMyCode=False
)
break_line = writer.get_line_index_with_content('break here')
json_facade.write_set_breakpoints(break_line)
json_facade.write_make_initial_run()
json_hit = json_facade.wait_for_thread_stopped(line=break_line)
# Interrupting when inside a breakpoint will actually make the
# debugger stop working in that thread (because there's no way
# to keep debugging after an exception exits the tracing).
json_facade.write_terminate()
if soft_kill:
json_facade.wait_for_json_message(
OutputEvent, lambda output_event: 'raised from within the callback set' in output_event.body.output)
writer.finished_ok = True
if __name__ == '__main__':
pytest.main(['-k', 'test_replace_process', '-s'])

View file

@ -77,6 +77,8 @@ class Client(components.Component):
only if and when the "launch" or "attach" response is sent.
"""
self._forward_terminate_request = False
self.known_subprocesses = set()
session.client = self
@ -180,7 +182,7 @@ class Client(components.Component):
"supportsSetExpression": True,
"supportsSetVariable": True,
"supportsValueFormattingOptions": True,
"supportsTerminateDebuggee": True,
"supportsTerminateRequest": True,
"supportsGotoTargetsRequest": True,
"supportsClipboardContext": True,
"exceptionBreakpointFilters": exception_breakpoint_filters,
@ -192,6 +194,7 @@ class Client(components.Component):
# See https://github.com/microsoft/vscode/issues/4902#issuecomment-368583522
# for the sequence of request and events necessary to orchestrate the start.
def _start_message_handler(f):
@components.Component.message_handler
def handle(self, request):
assert request.is_request("launch", "attach")
@ -404,6 +407,11 @@ class Client(components.Component):
if sudo and sys.platform == "win32":
raise request.cant_handle('"sudo":true is not supported on Windows.')
on_terminate = request("onTerminate", str, optional=True)
if on_terminate:
self._forward_terminate_request = on_terminate == "KeyboardInterrupt"
launcher_path = request("debugLauncherPath", os.path.dirname(launcher.__file__))
adapter_host = request("debugAdapterHost", "127.0.0.1")
@ -441,6 +449,10 @@ class Client(components.Component):
connect = request("connect", dict, optional=True)
pid = request("processId", (int, str), optional=True)
sub_pid = request("subProcessId", int, optional=True)
on_terminate = request("onTerminate", bool, optional=True)
if on_terminate:
self._forward_terminate_request = on_terminate == "KeyboardInterrupt"
if host != () or port != ():
if listen != ():
@ -637,6 +649,15 @@ class Client(components.Component):
@message_handler
def terminate_request(self, request):
if self._forward_terminate_request:
# According to the spec, terminate should try to do a gracefull shutdown.
# We do this in the server by interrupting the main thread with a Ctrl+C.
# To force the kill a subsequent request would do a disconnect.
#
# We only do this if the onTerminate option is set though (the default
# is a hard-kill for the process and subprocesses).
return self.server.channel.delegate(request)
self.session.finalize('client requested "terminate"', terminate_debuggee=True)
return {}

View file

@ -280,8 +280,7 @@ class Server(components.Component):
"supportsSetVariable": False,
"supportsStepBack": False,
"supportsStepInTargetsRequest": False,
"supportsTerminateDebuggee": False,
"supportsTerminateRequest": False,
"supportsTerminateRequest": True,
"supportsTerminateThreadsRequest": False,
"supportsValueFormattingOptions": False,
"exceptionBreakpointFilters": [],