xref: /aosp_15_r20/external/autotest/client/cros/cellular/pseudomodem/pseudomodem_context.py (revision 9c5db1993ded3edbeafc8092d69fe5de2ee02df7)
1# Lint as: python2, python3
2# Copyright (c) 2014 The Chromium OS Authors. All rights reserved.
3# Use of this source code is governed by a BSD-style license that can be
4# found in the LICENSE file.
5
6# This module helps launch pseudomodem as a subprocess. It helps with the
7# initial setup of pseudomodem, as well as ensures proper cleanup.
8# For details about the options accepted by pseudomodem, please check the
9# |pseudomodem| module.
10# This module also doubles as the python entry point to run pseudomodem from the
11# command line. To avoid confusion, please use the shell script run_pseudomodem
12# to run pseudomodem from command line.
13
14from __future__ import absolute_import
15from __future__ import division
16from __future__ import print_function
17
18import dbus
19import json
20import logging
21import os
22import pwd
23import signal
24import six
25import stat
26import sys
27import subprocess
28import tempfile
29
30import common
31
32from autotest_lib.client.bin import utils
33from autotest_lib.client.common_lib import error
34from autotest_lib.client.cros import service_stopper
35from autotest_lib.client.cros.cellular import mm1_constants
36from autotest_lib.client.cros.cellular import net_interface
37
38
39from autotest_lib.client.cros.cellular.pseudomodem import pm_constants
40from autotest_lib.client.cros.cellular.pseudomodem import pseudomodem
41
42
43# TODO(pprabhu) Move this to the right utils file.
44# pprabhu: I haven't yet figured out which of the myriad utils files I should
45# update. There is an implementation of |nuke_subprocess| that does not take
46# timeout_hint_seconds in common_lib/utils.py, but |poll_for_condition|
47# is not available there.
48def nuke_subprocess(subproc, timeout_hint_seconds=0):
49    """
50    Attempt to kill the given subprocess via an escalating series of signals.
51
52    Between each attempt, the process is given |timeout_hint_seconds| to clean
53    up. So, the function may take up to 3 * |timeout_hint_seconds| time to
54    finish.
55
56    @param subproc: The python subprocess to nuke.
57    @param timeout_hint_seconds: The time to wait between successive attempts.
58    @returns: The result from the subprocess, None if we failed to kill it.
59
60    """
61    # check if the subprocess is still alive, first
62    if subproc.poll() is not None:
63        return subproc.poll()
64
65    signal_queue = [signal.SIGINT, signal.SIGTERM, signal.SIGKILL]
66    for sig in signal_queue:
67        logging.info('Nuking %s with %s', subproc.pid, sig)
68        utils.signal_pid(subproc.pid, sig)
69        try:
70            utils.poll_for_condition(
71                    lambda: subproc.poll() is not None,
72                    timeout=timeout_hint_seconds)
73            return subproc.poll()
74        except utils.TimeoutError:
75            pass
76    return None
77
78
79class PseudoModemManagerContextException(Exception):
80    """ Exception class for exceptions raised by PseudoModemManagerContext. """
81    pass
82
83
84class PseudoModemManagerContext(object):
85    """
86    A context to launch pseudomodem in background.
87
88    Tests should use |PeudoModemManagerContext| to launch pseudomodem. It is
89    intended to be used with the |with| clause like so:
90
91    with PseudoModemManagerContext(...):
92        # Run test
93
94    pseudomodem will be launch in a subprocess safely when entering the |with|
95    block, and cleaned up when exiting.
96
97    """
98    SHORT_TIMEOUT_SECONDS = 4
99    # Some actions are dependent on hardware cooperating. We need to wait longer
100    # for these. Try to minimize using this constant.
101    WAIT_FOR_HARDWARE_TIMEOUT_SECONDS = 12
102    TEMP_FILE_PREFIX = 'pseudomodem_'
103    REAL_MANAGER_SERVICES = ['modemmanager', 'cromo']
104    REAL_MANAGER_PROCESSES = ['ModemManager', 'cromo']
105    TEST_OBJECT_ARG_FLAGS = ['test-modem-arg',
106                             'test-sim-arg',
107                             'test-state-machine-factory-arg']
108
109    def __init__(self,
110                 use_pseudomodem,
111                 flags_map=None,
112                 block_output=True,
113                 bus=None):
114        """
115        @param use_pseudomodem: This flag can be used to treat pseudomodem as a
116                no-op. When |True|, pseudomodem is launched as expected. When
117                |False|, this operation is a no-op, and pseudomodem will not be
118                launched.
119        @param flags_map: This is a map of pseudomodem arguments. See
120                |pseudomodem| module for the list of supported arguments. For
121                example, to launch pseudomodem with a modem of family 3GPP, use:
122                    with PseudoModemManager(True, flags_map={'family' : '3GPP}):
123                        # Do stuff
124        @param block_output: If True, output from the pseudomodem process is not
125                piped to stdout. This is the default.
126        @param bus: A handle to the dbus.SystemBus. If you use dbus in your
127                tests, you should obtain a handle to the bus and pass it in
128                here. Not doing so can cause incompatible mainloop settings in
129                the dbus module.
130
131        """
132        self._use_pseudomodem = use_pseudomodem
133        self._block_output = block_output
134
135        self._temp_files = []
136        self.cmd_line_flags = self._ConvertMapToFlags(flags_map if flags_map
137                                                      else {})
138        self._service_stopper = service_stopper.ServiceStopper(
139                self.REAL_MANAGER_SERVICES)
140        self._net_interface = None
141        self._null_pipe = None
142        self._exit_error_file_path = None
143        self._pseudomodem_process = None
144
145        self._bus = bus
146        if not self._bus:
147            # Currently, the glib mainloop, or a wrapper thereof are the only
148            # mainloops we ever use with dbus. So, it's a comparatively safe bet
149            # to set that up as the mainloop here.
150            # Ideally, if a test wants to use dbus, it should pass us its own
151            # bus.
152            dbus_loop = dbus.mainloop.glib.DBusGMainLoop()
153            self._bus = dbus.SystemBus(private=True, mainloop=dbus_loop)
154
155
156    @property
157    def cmd_line_flags(self):
158        """ The command line flags that will be passed to pseudomodem. """
159        return self._cmd_line_flags
160
161
162    @cmd_line_flags.setter
163    def cmd_line_flags(self, val):
164        """
165        Set the command line flags to be passed to pseudomodem.
166
167        @param val: The flags.
168
169        """
170        logging.info('Command line flags for pseudomodem set to: |%s|', val)
171        self._cmd_line_flags = val
172
173
174    def __enter__(self):
175        return self.Start()
176
177
178    def __exit__(self, *args):
179        return self.Stop(*args)
180
181
182    def Start(self):
183        """ Start the context. This launches pseudomodem. """
184        if not self._use_pseudomodem:
185            return self
186
187        self._CheckPseudoModemArguments()
188
189        self._service_stopper.stop_services()
190        self._WaitForRealModemManagersToDie()
191
192        self._net_interface = net_interface.PseudoNetInterface()
193        self._net_interface.Setup()
194
195        toplevel = os.path.dirname(os.path.realpath(__file__))
196        cmd = [os.path.join(toplevel, 'pseudomodem.py')]
197        cmd = cmd + self.cmd_line_flags
198
199        fd, self._exit_error_file_path = self._CreateTempFile()
200        os.close(fd)  # We don't need the fd.
201        cmd = cmd + [pseudomodem.EXIT_ERROR_FILE_FLAG,
202                     self._exit_error_file_path]
203
204        # Setup health checker for child process.
205        signal.signal(signal.SIGCHLD, self._SigchldHandler)
206
207        if self._block_output:
208            self._null_pipe = open(os.devnull, 'w')
209            self._pseudomodem_process = subprocess.Popen(
210                    cmd,
211                    preexec_fn=PseudoModemManagerContext._SetUserModem,
212                    close_fds=True,
213                    stdout=self._null_pipe,
214                    stderr=self._null_pipe)
215        else:
216            self._pseudomodem_process = subprocess.Popen(
217                    cmd,
218                    preexec_fn=PseudoModemManagerContext._SetUserModem,
219                    close_fds=True)
220        self._EnsurePseudoModemUp()
221        return self
222
223
224    def Stop(self, *args):
225        """ Exit the context. This terminates pseudomodem. """
226        if not self._use_pseudomodem:
227            return
228
229        # Remove health check on child process.
230        signal.signal(signal.SIGCHLD, signal.SIG_DFL)
231
232        if self._pseudomodem_process:
233            if self._pseudomodem_process.poll() is None:
234                if (nuke_subprocess(self._pseudomodem_process,
235                                    self.SHORT_TIMEOUT_SECONDS) is
236                    None):
237                    logging.warning('Failed to clean up the launched '
238                                    'pseudomodem process')
239            self._pseudomodem_process = None
240
241        if self._null_pipe:
242            self._null_pipe.close()
243            self._null_pipe = None
244
245        if self._net_interface:
246            self._net_interface.Teardown()
247            self._net_interface = None
248
249        self._DeleteTempFiles()
250        self._service_stopper.restore_services()
251
252
253    def _ConvertMapToFlags(self, flags_map):
254        """
255        Convert the argument map given to the context to flags for pseudomodem.
256
257        @param flags_map: A map of flags. The keys are the names of the flags
258                accepted by pseudomodem. The value, if not None, is the value
259                for that flag. We do not support |None| as the value for a flag.
260        @returns: the list of flags to pass to pseudomodem.
261
262        """
263        cmd_line_flags = []
264        for key, value in six.iteritems(flags_map):
265            cmd_line_flags.append('--' + key)
266            if key in self.TEST_OBJECT_ARG_FLAGS:
267                cmd_line_flags.append(self._DumpArgToFile(value))
268            elif value:
269                cmd_line_flags.append(value)
270        return cmd_line_flags
271
272
273    def _DumpArgToFile(self, arg):
274        """
275        Dump a given python list to a temp file in json format.
276
277        This is used to pass arguments to custom objects from tests that
278        are to be instantiated by pseudomodem. The argument must be a list. When
279        running pseudomodem, this list will be unpacked to get the arguments.
280
281        @returns: Absolute path to the tempfile created.
282
283        """
284        fd, arg_file_path = self._CreateTempFile()
285        arg_file = os.fdopen(fd, 'wb')
286        json.dump(arg, arg_file)
287        arg_file.close()
288        return arg_file_path
289
290
291    def _WaitForRealModemManagersToDie(self):
292        """
293        Wait for real modem managers to quit. Die otherwise.
294
295        Sometimes service stopper does not kill ModemManager process, if it is
296        launched by something other than upstart. We want to ensure that the
297        process is dead before continuing.
298
299        This method can block for up to a minute. Sometimes, ModemManager can
300        take up to a 10 seconds to die after service stopper has stopped it. We
301        wait for it to clean up before concluding that the process is here to
302        stay.
303
304        @raises: PseudoModemManagerContextException if a modem manager process
305                does not quit in a reasonable amount of time.
306        """
307        def _IsProcessRunning(process):
308            try:
309                utils.run('pgrep -x %s' % process)
310                return True
311            except error.CmdError:
312                return False
313
314        for manager in self.REAL_MANAGER_PROCESSES:
315            try:
316                utils.poll_for_condition(
317                        lambda:not _IsProcessRunning(manager),
318                        timeout=self.WAIT_FOR_HARDWARE_TIMEOUT_SECONDS)
319            except utils.TimeoutError:
320                err_msg = ('%s is still running. '
321                           'It may interfere with pseudomodem.' %
322                           manager)
323                logging.error(err_msg)
324                raise PseudoModemManagerContextException(err_msg)
325
326
327    def _CheckPseudoModemArguments(self):
328        """
329        Parse the given pseudomodem arguments.
330
331        By parsing the arguments in the context, we can provide early feedback
332        about incorrect arguments.
333
334        """
335        pseudomodem.ParseArguments(self.cmd_line_flags)
336
337
338    @staticmethod
339    def _SetUserModem():
340        """
341        Set the unix user of the calling process to |modem|.
342
343        This functions is called by the launched subprocess so that pseudomodem
344        can be launched as the |modem| user.
345        On encountering an error, this method will terminate the process.
346
347        """
348        try:
349            pwd_data = pwd.getpwnam(pm_constants.MM1_USER)
350        except KeyError as e:
351            logging.error('Could not find uid for user %s [%s]',
352                          pm_constants.MM1_USER, str(e))
353            sys.exit(1)
354
355        logging.debug('Setting UID to %d', pwd_data.pw_uid)
356        try:
357            os.setuid(pwd_data.pw_uid)
358        except OSError as e:
359            logging.error('Could not set uid to %d [%s]',
360                          pwd_data.pw_uid, str(e))
361            sys.exit(1)
362
363
364    def _EnsurePseudoModemUp(self):
365        """ Makes sure that pseudomodem in child process is ready. """
366        def _LivenessCheck():
367            try:
368                testing_object = self._bus.get_object(
369                        mm1_constants.I_MODEM_MANAGER,
370                        pm_constants.TESTING_PATH)
371                return testing_object.IsAlive(
372                        dbus_interface=pm_constants.I_TESTING)
373            except dbus.DBusException as e:
374                logging.debug('LivenessCheck: No luck yet. (%s)', str(e))
375                return False
376
377        utils.poll_for_condition(
378                _LivenessCheck,
379                timeout=self.SHORT_TIMEOUT_SECONDS,
380                exception=PseudoModemManagerContextException(
381                        'pseudomodem did not initialize properly.'))
382
383
384    def _CreateTempFile(self):
385        """
386        Creates a tempfile such that the child process can read/write it.
387
388        The file path is stored in a list so that the file can be deleted later
389        using |_DeleteTempFiles|.
390
391        @returns: (fd, arg_file_path)
392                 fd: A file descriptor for the created file.
393                 arg_file_path: Full path of the created file.
394
395        """
396        fd, arg_file_path = tempfile.mkstemp(prefix=self.TEMP_FILE_PREFIX)
397        self._temp_files.append(arg_file_path)
398        # Set file permissions so that pseudomodem process can read/write it.
399        cur_mod = os.stat(arg_file_path).st_mode
400        os.chmod(arg_file_path,
401                 cur_mod | stat.S_IRGRP | stat.S_IROTH | stat.S_IWGRP |
402                 stat.S_IWOTH)
403        return fd, arg_file_path
404
405
406    def _DeleteTempFiles(self):
407        """ Deletes all temp files created by this context. """
408        for file_path in self._temp_files:
409            try:
410                os.remove(file_path)
411            except OSError as e:
412                logging.warning('Failed to delete temp file: %s (error %s)',
413                                file_path, str(e))
414
415
416    def _SigchldHandler(self, signum, frame):
417        """
418        Signal handler for SIGCHLD.
419
420        This is setup while the pseudomodem subprocess is running. A call to
421        this signal handler may signify early termination of the subprocess.
422
423        @param signum: The signal number.
424        @param frame: Ignored.
425
426        """
427        if not self._pseudomodem_process:
428            # We can receive a SIGCHLD even before the setup of the child
429            # process is complete.
430            return
431        if self._pseudomodem_process.poll() is not None:
432            # See if child process left detailed error report
433            error_reason, error_traceback = pseudomodem.ExtractExitError(
434                    self._exit_error_file_path)
435            logging.error('pseudomodem child process quit early!')
436            logging.error('Reason: %s', error_reason)
437            for line in error_traceback:
438                logging.error('Traceback: %s', line.strip())
439            raise PseudoModemManagerContextException(
440                    'pseudomodem quit early! (%s)' %
441                    error_reason)
442