# Copyright (c) 2014 The Chromium OS Authors. All rights reserved. # Use of this source code is governed by a BSD-style license that can be # found in the LICENSE file. # This module helps launch pseudomodem as a subprocess. It helps with the # initial setup of pseudomodem, as well as ensures proper cleanup. # For details about the options accepted by pseudomodem, please check the # |pseudomodem| module. # This module also doubles as the python entry point to run pseudomodem from the # command line. To avoid confusion, please use the shell script run_pseudomodem # to run pseudomodem from command line. import dbus import json import logging import os import pwd import signal import stat import sys import subprocess import tempfile import common from autotest_lib.client.bin import utils from autotest_lib.client.common_lib import error from autotest_lib.client.cros import service_stopper from autotest_lib.client.cros.cellular import mm1_constants from autotest_lib.client.cros.cellular import net_interface import pm_constants import pseudomodem # TODO(pprabhu) Move this to the right utils file. # pprabhu: I haven't yet figured out which of the myriad utils files I should # update. There is an implementation of |nuke_subprocess| that does not take # timeout_hint_seconds in common_lib/utils.py, but |poll_for_condition| # is not available there. def nuke_subprocess(subproc, timeout_hint_seconds=0): """ Attempt to kill the given subprocess via an escalating series of signals. Between each attempt, the process is given |timeout_hint_seconds| to clean up. So, the function may take up to 3 * |timeout_hint_seconds| time to finish. @param subproc: The python subprocess to nuke. @param timeout_hint_seconds: The time to wait between successive attempts. @returns: The result from the subprocess, None if we failed to kill it. """ # check if the subprocess is still alive, first if subproc.poll() is not None: return subproc.poll() signal_queue = [signal.SIGINT, signal.SIGTERM, signal.SIGKILL] for sig in signal_queue: logging.info('Nuking %s with %s', subproc.pid, sig) utils.signal_pid(subproc.pid, sig) try: utils.poll_for_condition( lambda: subproc.poll() is not None, timeout=timeout_hint_seconds) return subproc.poll() except utils.TimeoutError: pass return None class PseudoModemManagerContextException(Exception): """ Exception class for exceptions raised by PseudoModemManagerContext. """ pass class PseudoModemManagerContext(object): """ A context to launch pseudomodem in background. Tests should use |PeudoModemManagerContext| to launch pseudomodem. It is intended to be used with the |with| clause like so: with PseudoModemManagerContext(...): # Run test pseudomodem will be launch in a subprocess safely when entering the |with| block, and cleaned up when exiting. """ SHORT_TIMEOUT_SECONDS = 4 # Some actions are dependent on hardware cooperating. We need to wait longer # for these. Try to minimize using this constant. WAIT_FOR_HARDWARE_TIMEOUT_SECONDS = 12 TEMP_FILE_PREFIX = 'pseudomodem_' REAL_MANAGER_SERVICES = ['modemmanager', 'cromo'] REAL_MANAGER_PROCESSES = ['ModemManager', 'cromo'] TEST_OBJECT_ARG_FLAGS = ['test-modem-arg', 'test-sim-arg', 'test-state-machine-factory-arg'] def __init__(self, use_pseudomodem, flags_map=None, block_output=True, bus=None): """ @param use_pseudomodem: This flag can be used to treat pseudomodem as a no-op. When |True|, pseudomodem is launched as expected. When |False|, this operation is a no-op, and pseudomodem will not be launched. @param flags_map: This is a map of pseudomodem arguments. See |pseudomodem| module for the list of supported arguments. For example, to launch pseudomodem with a modem of family 3GPP, use: with PseudoModemManager(True, flags_map={'family' : '3GPP}): # Do stuff @param block_output: If True, output from the pseudomodem process is not piped to stdout. This is the default. @param bus: A handle to the dbus.SystemBus. If you use dbus in your tests, you should obtain a handle to the bus and pass it in here. Not doing so can cause incompatible mainloop settings in the dbus module. """ self._use_pseudomodem = use_pseudomodem self._block_output = block_output self._temp_files = [] self.cmd_line_flags = self._ConvertMapToFlags(flags_map if flags_map else {}) self._service_stopper = service_stopper.ServiceStopper( self.REAL_MANAGER_SERVICES) self._net_interface = None self._null_pipe = None self._exit_error_file_path = None self._pseudomodem_process = None self._bus = bus if not self._bus: # Currently, the glib mainloop, or a wrapper thereof are the only # mainloops we ever use with dbus. So, it's a comparatively safe bet # to set that up as the mainloop here. # Ideally, if a test wants to use dbus, it should pass us its own # bus. dbus_loop = dbus.mainloop.glib.DBusGMainLoop() self._bus = dbus.SystemBus(private=True, mainloop=dbus_loop) @property def cmd_line_flags(self): """ The command line flags that will be passed to pseudomodem. """ return self._cmd_line_flags @cmd_line_flags.setter def cmd_line_flags(self, val): """ Set the command line flags to be passed to pseudomodem. @param val: The flags. """ logging.info('Command line flags for pseudomodem set to: |%s|', val) self._cmd_line_flags = val def __enter__(self): return self.Start() def __exit__(self, *args): return self.Stop(*args) def Start(self): """ Start the context. This launches pseudomodem. """ if not self._use_pseudomodem: return self self._CheckPseudoModemArguments() self._service_stopper.stop_services() self._WaitForRealModemManagersToDie() self._net_interface = net_interface.PseudoNetInterface() self._net_interface.Setup() toplevel = os.path.dirname(os.path.realpath(__file__)) cmd = [os.path.join(toplevel, 'pseudomodem.py')] cmd = cmd + self.cmd_line_flags fd, self._exit_error_file_path = self._CreateTempFile() os.close(fd) # We don't need the fd. cmd = cmd + [pseudomodem.EXIT_ERROR_FILE_FLAG, self._exit_error_file_path] # Setup health checker for child process. signal.signal(signal.SIGCHLD, self._SigchldHandler) if self._block_output: self._null_pipe = open(os.devnull, 'w') self._pseudomodem_process = subprocess.Popen( cmd, preexec_fn=PseudoModemManagerContext._SetUserModem, close_fds=True, stdout=self._null_pipe, stderr=self._null_pipe) else: self._pseudomodem_process = subprocess.Popen( cmd, preexec_fn=PseudoModemManagerContext._SetUserModem, close_fds=True) self._EnsurePseudoModemUp() return self def Stop(self, *args): """ Exit the context. This terminates pseudomodem. """ if not self._use_pseudomodem: return # Remove health check on child process. signal.signal(signal.SIGCHLD, signal.SIG_DFL) if self._pseudomodem_process: if self._pseudomodem_process.poll() is None: if (nuke_subprocess(self._pseudomodem_process, self.SHORT_TIMEOUT_SECONDS) is None): logging.warning('Failed to clean up the launched ' 'pseudomodem process') self._pseudomodem_process = None if self._null_pipe: self._null_pipe.close() self._null_pipe = None if self._net_interface: self._net_interface.Teardown() self._net_interface = None self._DeleteTempFiles() self._service_stopper.restore_services() def _ConvertMapToFlags(self, flags_map): """ Convert the argument map given to the context to flags for pseudomodem. @param flags_map: A map of flags. The keys are the names of the flags accepted by pseudomodem. The value, if not None, is the value for that flag. We do not support |None| as the value for a flag. @returns: the list of flags to pass to pseudomodem. """ cmd_line_flags = [] for key, value in flags_map.iteritems(): cmd_line_flags.append('--' + key) if key in self.TEST_OBJECT_ARG_FLAGS: cmd_line_flags.append(self._DumpArgToFile(value)) elif value: cmd_line_flags.append(value) return cmd_line_flags def _DumpArgToFile(self, arg): """ Dump a given python list to a temp file in json format. This is used to pass arguments to custom objects from tests that are to be instantiated by pseudomodem. The argument must be a list. When running pseudomodem, this list will be unpacked to get the arguments. @returns: Absolute path to the tempfile created. """ fd, arg_file_path = self._CreateTempFile() arg_file = os.fdopen(fd, 'wb') json.dump(arg, arg_file) arg_file.close() return arg_file_path def _WaitForRealModemManagersToDie(self): """ Wait for real modem managers to quit. Die otherwise. Sometimes service stopper does not kill ModemManager process, if it is launched by something other than upstart. We want to ensure that the process is dead before continuing. This method can block for up to a minute. Sometimes, ModemManager can take up to a 10 seconds to die after service stopper has stopped it. We wait for it to clean up before concluding that the process is here to stay. @raises: PseudoModemManagerContextException if a modem manager process does not quit in a reasonable amount of time. """ def _IsProcessRunning(process): try: utils.run('pgrep -x %s' % process) return True except error.CmdError: return False for manager in self.REAL_MANAGER_PROCESSES: try: utils.poll_for_condition( lambda:not _IsProcessRunning(manager), timeout=self.WAIT_FOR_HARDWARE_TIMEOUT_SECONDS) except utils.TimeoutError: err_msg = ('%s is still running. ' 'It may interfere with pseudomodem.' % manager) logging.error(err_msg) raise PseudoModemManagerContextException(err_msg) def _CheckPseudoModemArguments(self): """ Parse the given pseudomodem arguments. By parsing the arguments in the context, we can provide early feedback about incorrect arguments. """ pseudomodem.ParseArguments(self.cmd_line_flags) @staticmethod def _SetUserModem(): """ Set the unix user of the calling process to |modem|. This functions is called by the launched subprocess so that pseudomodem can be launched as the |modem| user. On encountering an error, this method will terminate the process. """ try: pwd_data = pwd.getpwnam(pm_constants.MM1_USER) except KeyError as e: logging.error('Could not find uid for user %s [%s]', pm_constants.MM1_USER, str(e)) sys.exit(1) logging.debug('Setting UID to %d', pwd_data.pw_uid) try: os.setuid(pwd_data.pw_uid) except OSError as e: logging.error('Could not set uid to %d [%s]', pwd_data.pw_uid, str(e)) sys.exit(1) def _EnsurePseudoModemUp(self): """ Makes sure that pseudomodem in child process is ready. """ def _LivenessCheck(): try: testing_object = self._bus.get_object( mm1_constants.I_MODEM_MANAGER, pm_constants.TESTING_PATH) return testing_object.IsAlive( dbus_interface=pm_constants.I_TESTING) except dbus.DBusException as e: logging.debug('LivenessCheck: No luck yet. (%s)', str(e)) return False utils.poll_for_condition( _LivenessCheck, timeout=self.SHORT_TIMEOUT_SECONDS, exception=PseudoModemManagerContextException( 'pseudomodem did not initialize properly.')) def _CreateTempFile(self): """ Creates a tempfile such that the child process can read/write it. The file path is stored in a list so that the file can be deleted later using |_DeleteTempFiles|. @returns: (fd, arg_file_path) fd: A file descriptor for the created file. arg_file_path: Full path of the created file. """ fd, arg_file_path = tempfile.mkstemp(prefix=self.TEMP_FILE_PREFIX) self._temp_files.append(arg_file_path) # Set file permissions so that pseudomodem process can read/write it. cur_mod = os.stat(arg_file_path).st_mode os.chmod(arg_file_path, cur_mod | stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP | stat.S_IWOTH) return fd, arg_file_path def _DeleteTempFiles(self): """ Deletes all temp files created by this context. """ for file_path in self._temp_files: try: os.remove(file_path) except OSError as e: logging.warning('Failed to delete temp file: %s (error %s)', file_path, str(e)) def _SigchldHandler(self, signum, frame): """ Signal handler for SIGCHLD. This is setup while the pseudomodem subprocess is running. A call to this signal handler may signify early termination of the subprocess. @param signum: The signal number. @param frame: Ignored. """ if not self._pseudomodem_process: # We can receive a SIGCHLD even before the setup of the child # process is complete. return if self._pseudomodem_process.poll() is not None: # See if child process left detailed error report error_reason, error_traceback = pseudomodem.ExtractExitError( self._exit_error_file_path) logging.error('pseudomodem child process quit early!') logging.error('Reason: %s', error_reason) for line in error_traceback: logging.error('Traceback: %s', line.strip()) raise PseudoModemManagerContextException( 'pseudomodem quit early! (%s)' % error_reason)