| """ |
| Part of the LLVM Project, under the Apache License v2.0 with LLVM Exceptions. |
| See https://llvm.org/LICENSE.txt for license information. |
| SPDX-License-Identifier: Apache-2.0 WITH LLVM-exception |
| |
| Provides classes used by the test results reporting infrastructure |
| within the LLDB test suite. |
| |
| |
| This module provides process-management support for the LLDB test |
| running infrastructure. |
| """ |
| |
| # System imports |
| import os |
| import re |
| import signal |
| import subprocess |
| import sys |
| import threading |
| |
| |
| class CommunicatorThread(threading.Thread): |
| """Provides a thread class that communicates with a subprocess.""" |
| |
| def __init__(self, process, event, output_file): |
| super(CommunicatorThread, self).__init__() |
| # Don't let this thread prevent shutdown. |
| self.daemon = True |
| self.process = process |
| self.pid = process.pid |
| self.event = event |
| self.output_file = output_file |
| self.output = None |
| |
| def run(self): |
| try: |
| # Communicate with the child process. |
| # This will not complete until the child process terminates. |
| self.output = self.process.communicate() |
| except Exception as exception: # pylint: disable=broad-except |
| if self.output_file: |
| self.output_file.write( |
| "exception while using communicate() for pid: {}\n".format( |
| exception)) |
| finally: |
| # Signal that the thread's run is complete. |
| self.event.set() |
| |
| |
| # Provides a regular expression for matching gtimeout-based durations. |
| TIMEOUT_REGEX = re.compile(r"(^\d+)([smhd])?$") |
| |
| |
| def timeout_to_seconds(timeout): |
| """Converts timeout/gtimeout timeout values into seconds. |
| |
| @param timeout a timeout in the form of xm representing x minutes. |
| |
| @return None if timeout is None, or the number of seconds as a float |
| if a valid timeout format was specified. |
| """ |
| if timeout is None: |
| return None |
| else: |
| match = TIMEOUT_REGEX.match(timeout) |
| if match: |
| value = float(match.group(1)) |
| units = match.group(2) |
| if units is None: |
| # default is seconds. No conversion necessary. |
| return value |
| elif units == 's': |
| # Seconds. No conversion necessary. |
| return value |
| elif units == 'm': |
| # Value is in minutes. |
| return 60.0 * value |
| elif units == 'h': |
| # Value is in hours. |
| return (60.0 * 60.0) * value |
| elif units == 'd': |
| # Value is in days. |
| return 24 * (60.0 * 60.0) * value |
| else: |
| raise Exception("unexpected units value '{}'".format(units)) |
| else: |
| raise Exception("could not parse TIMEOUT spec '{}'".format( |
| timeout)) |
| |
| |
| class ProcessHelper(object): |
| """Provides an interface for accessing process-related functionality. |
| |
| This class provides a factory method that gives the caller a |
| platform-specific implementation instance of the class. |
| |
| Clients of the class should stick to the methods provided in this |
| base class. |
| |
| \see ProcessHelper.process_helper() |
| """ |
| |
| def __init__(self): |
| super(ProcessHelper, self).__init__() |
| |
| @classmethod |
| def process_helper(cls): |
| """Returns a platform-specific ProcessHelper instance. |
| @return a ProcessHelper instance that does the right thing for |
| the current platform. |
| """ |
| |
| # If you add a new platform, create an instance here and |
| # return it. |
| if os.name == "nt": |
| return WindowsProcessHelper() |
| else: |
| # For all POSIX-like systems. |
| return UnixProcessHelper() |
| |
| def create_piped_process(self, command, new_process_group=True): |
| # pylint: disable=no-self-use,unused-argument |
| # As expected. We want derived classes to implement this. |
| """Creates a subprocess.Popen-based class with I/O piped to the parent. |
| |
| @param command the command line list as would be passed to |
| subprocess.Popen(). Use the list form rather than the string form. |
| |
| @param new_process_group indicates if the caller wants the |
| process to be created in its own process group. Each OS handles |
| this concept differently. It provides a level of isolation and |
| can simplify or enable terminating the process tree properly. |
| |
| @return a subprocess.Popen-like object. |
| """ |
| raise Exception("derived class must implement") |
| |
| def supports_soft_terminate(self): |
| # pylint: disable=no-self-use |
| # As expected. We want derived classes to implement this. |
| """Indicates if the platform supports soft termination. |
| |
| Soft termination is the concept of a terminate mechanism that |
| allows the target process to shut down nicely, but with the |
| catch that the process might choose to ignore it. |
| |
| Platform supporter note: only mark soft terminate as supported |
| if the target process has some way to evade the soft terminate |
| request; otherwise, just support the hard terminate method. |
| |
| @return True if the platform supports a soft terminate mechanism. |
| """ |
| # By default, we do not support a soft terminate mechanism. |
| return False |
| |
| def soft_terminate(self, popen_process, log_file=None, want_core=True): |
| # pylint: disable=no-self-use,unused-argument |
| # As expected. We want derived classes to implement this. |
| """Attempts to terminate the process in a polite way. |
| |
| This terminate method is intended to give the child process a |
| chance to clean up and exit on its own, possibly with a request |
| to drop a core file or equivalent (i.e. [mini-]crashdump, crashlog, |
| etc.) If new_process_group was set in the process creation method |
| and the platform supports it, this terminate call will attempt to |
| kill the whole process tree rooted in this child process. |
| |
| @param popen_process the subprocess.Popen-like object returned |
| by one of the process-creation methods of this class. |
| |
| @param log_file file-like object used to emit error-related |
| logging info. May be None if no error-related info is desired. |
| |
| @param want_core True if the caller would like to get a core |
| dump (or the analogous crash report) from the terminated process. |
| """ |
| popen_process.terminate() |
| |
| def hard_terminate(self, popen_process, log_file=None): |
| # pylint: disable=no-self-use,unused-argument |
| # As expected. We want derived classes to implement this. |
| """Attempts to terminate the process immediately. |
| |
| This terminate method is intended to kill child process in |
| a manner in which the child process has no ability to block, |
| and also has no ability to clean up properly. If new_process_group |
| was specified when creating the process, and if the platform |
| implementation supports it, this will attempt to kill the |
| whole process tree rooted in the child process. |
| |
| @param popen_process the subprocess.Popen-like object returned |
| by one of the process-creation methods of this class. |
| |
| @param log_file file-like object used to emit error-related |
| logging info. May be None if no error-related info is desired. |
| """ |
| popen_process.kill() |
| |
| def was_soft_terminate(self, returncode, with_core): |
| # pylint: disable=no-self-use,unused-argument |
| # As expected. We want derived classes to implement this. |
| """Returns if Popen-like object returncode matches soft terminate. |
| |
| @param returncode the returncode from the Popen-like object that |
| terminated with a given return code. |
| |
| @param with_core indicates whether the returncode should match |
| a core-generating return signal. |
| |
| @return True when the returncode represents what the system would |
| issue when a soft_terminate() with the given with_core arg occurred; |
| False otherwise. |
| """ |
| if not self.supports_soft_terminate(): |
| # If we don't support soft termination on this platform, |
| # then this should always be False. |
| return False |
| else: |
| # Once a platform claims to support soft terminate, it |
| # needs to be able to identify it by overriding this method. |
| raise Exception("platform needs to implement") |
| |
| def was_hard_terminate(self, returncode): |
| # pylint: disable=no-self-use,unused-argument |
| # As expected. We want derived classes to implement this. |
| """Returns if Popen-like object returncode matches that of a hard |
| terminate attempt. |
| |
| @param returncode the returncode from the Popen-like object that |
| terminated with a given return code. |
| |
| @return True when the returncode represents what the system would |
| issue when a hard_terminate() occurred; False |
| otherwise. |
| """ |
| raise Exception("platform needs to implement") |
| |
| def soft_terminate_signals(self): |
| # pylint: disable=no-self-use |
| """Retrieve signal numbers that can be sent to soft terminate. |
| @return a list of signal numbers that can be sent to soft terminate |
| a process, or None if not applicable. |
| """ |
| return None |
| |
| def is_exceptional_exit(self, popen_status): |
| """Returns whether the program exit status is exceptional. |
| |
| Returns whether the return code from a Popen process is exceptional |
| (e.g. signals on POSIX systems). |
| |
| Derived classes should override this if they can detect exceptional |
| program exit. |
| |
| @return True if the given popen_status represents an exceptional |
| program exit; False otherwise. |
| """ |
| return False |
| |
| def exceptional_exit_details(self, popen_status): |
| """Returns the normalized exceptional exit code and a description. |
| |
| Given an exceptional exit code, returns the integral value of the |
| exception (e.g. signal number for POSIX) and a description (e.g. |
| signal name on POSIX) for the result. |
| |
| Derived classes should override this if they can detect exceptional |
| program exit. |
| |
| It is fine to not implement this so long as is_exceptional_exit() |
| always returns False. |
| |
| @return (normalized exception code, symbolic exception description) |
| """ |
| raise Exception("exception_exit_details() called on unsupported class") |
| |
| |
| class UnixProcessHelper(ProcessHelper): |
| """Provides a ProcessHelper for Unix-like operating systems. |
| |
| This implementation supports anything that looks Posix-y |
| (e.g. Darwin, Linux, *BSD, etc.) |
| """ |
| |
| def __init__(self): |
| super(UnixProcessHelper, self).__init__() |
| |
| @classmethod |
| def _create_new_process_group(cls): |
| """Creates a new process group for the calling process.""" |
| os.setpgid(os.getpid(), os.getpid()) |
| |
| def create_piped_process(self, command, new_process_group=True): |
| # Determine what to run after the fork but before the exec. |
| if new_process_group: |
| preexec_func = self._create_new_process_group |
| else: |
| preexec_func = None |
| |
| # Create the process. |
| process = subprocess.Popen( |
| command, |
| stdin=subprocess.PIPE, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| universal_newlines=True, # Elicits automatic byte -> string decoding in Py3 |
| close_fds=True, |
| preexec_fn=preexec_func) |
| |
| # Remember whether we're using process groups for this |
| # process. |
| process.using_process_groups = new_process_group |
| return process |
| |
| def supports_soft_terminate(self): |
| # POSIX does support a soft terminate via: |
| # * SIGTERM (no core requested) |
| # * SIGQUIT (core requested if enabled, see ulimit -c) |
| return True |
| |
| @classmethod |
| def _validate_pre_terminate(cls, popen_process, log_file): |
| # Validate args. |
| if popen_process is None: |
| raise ValueError("popen_process is None") |
| |
| # Ensure we have something that looks like a valid process. |
| if popen_process.pid < 1: |
| if log_file: |
| log_file.write("skipping soft_terminate(): no process id") |
| return False |
| |
| # We only do the process liveness check if we're not using |
| # process groups. With process groups, checking if the main |
| # inferior process is dead and short circuiting here is no |
| # good - children of it in the process group could still be |
| # alive, and they should be killed during a timeout. |
| if not popen_process.using_process_groups: |
| # Don't kill if it's already dead. |
| popen_process.poll() |
| if popen_process.returncode is not None: |
| # It has a returncode. It has already stopped. |
| if log_file: |
| log_file.write( |
| "requested to terminate pid {} but it has already " |
| "terminated, returncode {}".format( |
| popen_process.pid, popen_process.returncode)) |
| # Move along... |
| return False |
| |
| # Good to go. |
| return True |
| |
| def _kill_with_signal(self, popen_process, log_file, signum): |
| # Validate we're ready to terminate this. |
| if not self._validate_pre_terminate(popen_process, log_file): |
| return |
| |
| # Choose kill mechanism based on whether we're targeting |
| # a process group or just a process. |
| try: |
| if popen_process.using_process_groups: |
| # if log_file: |
| # log_file.write( |
| # "sending signum {} to process group {} now\n".format( |
| # signum, popen_process.pid)) |
| os.killpg(popen_process.pid, signum) |
| else: |
| # if log_file: |
| # log_file.write( |
| # "sending signum {} to process {} now\n".format( |
| # signum, popen_process.pid)) |
| os.kill(popen_process.pid, signum) |
| except OSError as error: |
| import errno |
| if error.errno == errno.ESRCH: |
| # This is okay - failed to find the process. It may be that |
| # that the timeout pre-kill hook eliminated the process. We'll |
| # ignore. |
| pass |
| else: |
| raise |
| |
| def soft_terminate(self, popen_process, log_file=None, want_core=True): |
| # Choose signal based on desire for core file. |
| if want_core: |
| # SIGQUIT will generate core by default. Can be caught. |
| signum = signal.SIGQUIT |
| else: |
| # SIGTERM is the traditional nice way to kill a process. |
| # Can be caught, doesn't generate a core. |
| signum = signal.SIGTERM |
| |
| self._kill_with_signal(popen_process, log_file, signum) |
| |
| def hard_terminate(self, popen_process, log_file=None): |
| self._kill_with_signal(popen_process, log_file, signal.SIGKILL) |
| |
| def was_soft_terminate(self, returncode, with_core): |
| if with_core: |
| return returncode == -signal.SIGQUIT |
| else: |
| return returncode == -signal.SIGTERM |
| |
| def was_hard_terminate(self, returncode): |
| return returncode == -signal.SIGKILL |
| |
| def soft_terminate_signals(self): |
| return [signal.SIGQUIT, signal.SIGTERM] |
| |
| def is_exceptional_exit(self, popen_status): |
| return popen_status < 0 |
| |
| @classmethod |
| def _signal_names_by_number(cls): |
| return dict( |
| (k, v) for v, k in reversed(sorted(signal.__dict__.items())) |
| if v.startswith('SIG') and not v.startswith('SIG_')) |
| |
| def exceptional_exit_details(self, popen_status): |
| signo = -popen_status |
| signal_names_by_number = self._signal_names_by_number() |
| signal_name = signal_names_by_number.get(signo, "") |
| return (signo, signal_name) |
| |
| |
| class WindowsProcessHelper(ProcessHelper): |
| """Provides a Windows implementation of the ProcessHelper class.""" |
| |
| def __init__(self): |
| super(WindowsProcessHelper, self).__init__() |
| |
| def create_piped_process(self, command, new_process_group=True): |
| if new_process_group: |
| # We need this flag if we want os.kill() to work on the subprocess. |
| creation_flags = subprocess.CREATE_NEW_PROCESS_GROUP |
| else: |
| creation_flags = 0 |
| |
| return subprocess.Popen( |
| command, |
| stdin=subprocess.PIPE, |
| stdout=subprocess.PIPE, |
| stderr=subprocess.PIPE, |
| universal_newlines=True, # Elicits automatic byte -> string decoding in Py3 |
| creationflags=creation_flags) |
| |
| def was_hard_terminate(self, returncode): |
| return returncode != 0 |
| |
| |
| class ProcessDriver(object): |
| """Drives a child process, notifies on important events, and can timeout. |
| |
| Clients are expected to derive from this class and override the |
| on_process_started and on_process_exited methods if they want to |
| hook either of those. |
| |
| This class supports timing out the child process in a platform-agnostic |
| way. The on_process_exited method is informed if the exit was natural |
| or if it was due to a timeout. |
| """ |
| |
| def __init__(self, soft_terminate_timeout=10.0): |
| super(ProcessDriver, self).__init__() |
| self.process_helper = ProcessHelper.process_helper() |
| self.pid = None |
| # Create the synchronization event for notifying when the |
| # inferior dotest process is complete. |
| self.done_event = threading.Event() |
| self.io_thread = None |
| self.process = None |
| # Number of seconds to wait for the soft terminate to |
| # wrap up, before moving to more drastic measures. |
| # Might want this longer if core dumps are generated and |
| # take a long time to write out. |
| self.soft_terminate_timeout = soft_terminate_timeout |
| # Number of seconds to wait for the hard terminate to |
| # wrap up, before giving up on the io thread. This should |
| # be fast. |
| self.hard_terminate_timeout = 5.0 |
| self.returncode = None |
| |
| # ============================================= |
| # Methods for subclasses to override if desired. |
| # ============================================= |
| |
| def on_process_started(self): |
| pass |
| |
| def on_process_exited(self, command, output, was_timeout, exit_status): |
| pass |
| |
| def on_timeout_pre_kill(self): |
| """Called after the timeout interval elapses but before killing it. |
| |
| This method is added to enable derived classes the ability to do |
| something to the process prior to it being killed. For example, |
| this would be a good spot to run a program that samples the process |
| to see what it was doing (or not doing). |
| |
| Do not attempt to reap the process (i.e. use wait()) in this method. |
| That will interfere with the kill mechanism and return code processing. |
| """ |
| |
| def write(self, content): |
| # pylint: disable=no-self-use |
| # Intended - we want derived classes to be able to override |
| # this and use any self state they may contain. |
| sys.stdout.write(content) |
| |
| # ============================================================== |
| # Operations used to drive processes. Clients will want to call |
| # one of these. |
| # ============================================================== |
| |
| def run_command(self, command): |
| # Start up the child process and the thread that does the |
| # communication pump. |
| self._start_process_and_io_thread(command) |
| |
| # Wait indefinitely for the child process to finish |
| # communicating. This indicates it has closed stdout/stderr |
| # pipes and is done. |
| self.io_thread.join() |
| self.returncode = self.process.wait() |
| if self.returncode is None: |
| raise Exception( |
| "no exit status available for pid {} after the " |
| " inferior dotest.py should have completed".format( |
| self.process.pid)) |
| |
| # Notify of non-timeout exit. |
| self.on_process_exited( |
| command, |
| self.io_thread.output, |
| False, |
| self.returncode) |
| |
| def run_command_with_timeout(self, command, timeout, want_core): |
| # Figure out how many seconds our timeout description is requesting. |
| timeout_seconds = timeout_to_seconds(timeout) |
| |
| # Start up the child process and the thread that does the |
| # communication pump. |
| self._start_process_and_io_thread(command) |
| |
| self._wait_with_timeout(timeout_seconds, command, want_core) |
| |
| # ================ |
| # Internal details. |
| # ================ |
| |
| def _start_process_and_io_thread(self, command): |
| # Create the process. |
| self.process = self.process_helper.create_piped_process(command) |
| self.pid = self.process.pid |
| self.on_process_started() |
| |
| # Ensure the event is cleared that is used for signaling |
| # from the communication() thread when communication is |
| # complete (i.e. the inferior process has finished). |
| self.done_event.clear() |
| |
| self.io_thread = CommunicatorThread( |
| self.process, self.done_event, self.write) |
| self.io_thread.start() |
| |
| def _attempt_soft_kill(self, want_core): |
| # The inferior dotest timed out. Attempt to clean it |
| # with a non-drastic method (so it can clean up properly |
| # and/or generate a core dump). Often the OS can't guarantee |
| # that the process will really terminate after this. |
| self.process_helper.soft_terminate( |
| self.process, |
| want_core=want_core, |
| log_file=self) |
| |
| # Now wait up to a certain timeout period for the io thread |
| # to say that the communication ended. If that wraps up |
| # within our soft terminate timeout, we're all done here. |
| self.io_thread.join(self.soft_terminate_timeout) |
| if not self.io_thread.is_alive(): |
| # stdout/stderr were closed on the child process side. We |
| # should be able to wait and reap the child process here. |
| self.returncode = self.process.wait() |
| # We terminated, and the done_trying result is n/a |
| terminated = True |
| done_trying = None |
| else: |
| self.write("soft kill attempt of process {} timed out " |
| "after {} seconds\n".format( |
| self.process.pid, self.soft_terminate_timeout)) |
| terminated = False |
| done_trying = False |
| return terminated, done_trying |
| |
| def _attempt_hard_kill(self): |
| # Instruct the process to terminate and really force it to |
| # happen. Don't give the process a chance to ignore. |
| self.process_helper.hard_terminate( |
| self.process, |
| log_file=self) |
| |
| # Reap the child process. This should not hang as the |
| # hard_kill() mechanism is supposed to really kill it. |
| # Improvement option: |
| # If this does ever hang, convert to a self.process.poll() |
| # loop checking on self.process.returncode until it is not |
| # None or the timeout occurs. |
| self.returncode = self.process.wait() |
| |
| # Wait a few moments for the io thread to finish... |
| self.io_thread.join(self.hard_terminate_timeout) |
| if self.io_thread.is_alive(): |
| # ... but this is not critical if it doesn't end for some |
| # reason. |
| self.write( |
| "hard kill of process {} timed out after {} seconds waiting " |
| "for the io thread (ignoring)\n".format( |
| self.process.pid, self.hard_terminate_timeout)) |
| |
| # Set if it terminated. (Set up for optional improvement above). |
| terminated = self.returncode is not None |
| # Nothing else to try. |
| done_trying = True |
| |
| return terminated, done_trying |
| |
| def _attempt_termination(self, attempt_count, want_core): |
| if self.process_helper.supports_soft_terminate(): |
| # When soft termination is supported, we first try to stop |
| # the process with a soft terminate. Failing that, we try |
| # the hard terminate option. |
| if attempt_count == 1: |
| return self._attempt_soft_kill(want_core) |
| elif attempt_count == 2: |
| return self._attempt_hard_kill() |
| else: |
| # We don't have anything else to try. |
| terminated = self.returncode is not None |
| done_trying = True |
| return terminated, done_trying |
| else: |
| # We only try the hard terminate option when there |
| # is no soft terminate available. |
| if attempt_count == 1: |
| return self._attempt_hard_kill() |
| else: |
| # We don't have anything else to try. |
| terminated = self.returncode is not None |
| done_trying = True |
| return terminated, done_trying |
| |
| def _wait_with_timeout(self, timeout_seconds, command, want_core): |
| # Allow up to timeout seconds for the io thread to wrap up. |
| # If that completes, the child process should be done. |
| completed_normally = self.done_event.wait(timeout_seconds) |
| if completed_normally: |
| # Reap the child process here. |
| self.returncode = self.process.wait() |
| else: |
| |
| # Allow derived classes to do some work after we detected |
| # a timeout but before we touch the timed-out process. |
| self.on_timeout_pre_kill() |
| |
| # Prepare to stop the process |
| process_terminated = completed_normally |
| terminate_attempt_count = 0 |
| |
| # Try as many attempts as we support for trying to shut down |
| # the child process if it's not already shut down. |
| while not process_terminated: |
| terminate_attempt_count += 1 |
| # Attempt to terminate. |
| process_terminated, done_trying = self._attempt_termination( |
| terminate_attempt_count, want_core) |
| # Check if there's nothing more to try. |
| if done_trying: |
| # Break out of our termination attempt loop. |
| break |
| |
| # At this point, we're calling it good. The process |
| # finished gracefully, was shut down after one or more |
| # attempts, or we failed but gave it our best effort. |
| self.on_process_exited( |
| command, |
| self.io_thread.output, |
| not completed_normally, |
| self.returncode) |
| |
| |
| def patched_init(self, *args, **kwargs): |
| self.original_init(*args, **kwargs) |
| # Initialize our condition variable that protects wait()/poll(). |
| self.wait_condition = threading.Condition() |
| |
| |
| def patched_wait(self, *args, **kwargs): |
| self.wait_condition.acquire() |
| try: |
| result = self.original_wait(*args, **kwargs) |
| # The process finished. Signal the condition. |
| self.wait_condition.notify_all() |
| return result |
| finally: |
| self.wait_condition.release() |
| |
| |
| def patched_poll(self, *args, **kwargs): |
| self.wait_condition.acquire() |
| try: |
| result = self.original_poll(*args, **kwargs) |
| if self.returncode is not None: |
| # We did complete, and we have the return value. |
| # Signal the event to indicate we're done. |
| self.wait_condition.notify_all() |
| return result |
| finally: |
| self.wait_condition.release() |
| |
| |
| def patch_up_subprocess_popen(): |
| subprocess.Popen.original_init = subprocess.Popen.__init__ |
| subprocess.Popen.__init__ = patched_init |
| |
| subprocess.Popen.original_wait = subprocess.Popen.wait |
| subprocess.Popen.wait = patched_wait |
| |
| subprocess.Popen.original_poll = subprocess.Popen.poll |
| subprocess.Popen.poll = patched_poll |
| |
| # Replace key subprocess.Popen() threading-unprotected methods with |
| # threading-protected versions. |
| patch_up_subprocess_popen() |