blob: 455467b17a3dde7c9a52923fa55b3aa64457eb06 [file] [log] [blame]
#
# Copyright 2018 The Cobalt Authors. All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Raspi implementation of Starboard launcher abstraction."""
import functools
import logging
import os
import re
import signal
import six
import sys
import threading
import time
import contextlib
import pexpect
from starboard.tools import abstract_launcher
from starboard.raspi.shared import retry
class TargetPathError(ValueError):
pass
# pylint: disable=unused-argument
def _sigint_or_sigterm_handler(signum, frame):
"""Clean up and exit with status |signum|.
Args:
signum: Signal number that triggered this callback. Passed in when the
signal handler is called by python runtime.
frame: Current stack frame. Passed in when the signal handler is called by
python runtime.
"""
sys.exit(signum)
# First call returns True, otherwise return false.
def first_run():
v = globals()
if 'first_run' not in v:
v['first_run'] = False
return True
return False
class Launcher(abstract_launcher.AbstractLauncher):
"""Class for launching Cobalt/tools on Raspi."""
_STARTUP_TIMEOUT_SECONDS = 1800
_RASPI_USERNAME = 'pi'
_RASPI_PASSWORD = 'raspberry'
_SSH_LOGIN_SIGNAL = 'cobalt-launcher-login-success'
_SSH_SLEEP_SIGNAL = 'cobalt-launcher-done-sleeping'
_RASPI_PROMPT = 'pi@raspberrypi:'
# pexpect times out each second to allow Kill to quickly stop a test run
_PEXPECT_TIMEOUT = 1
# SSH shell command retries
_PEXPECT_SPAWN_RETRIES = 20
# pexpect.sendline retries
_PEXPECT_SENDLINE_RETRIES = 3
# Old process kill retries
_KILL_RETRIES = 3
_PEXPECT_SHUTDOWN_SLEEP_TIME = 3
# Time to wait after processes were killed
_PROCESS_KILL_SLEEP_TIME = 10
# Retrys for getting a clean prompt
_PROMPT_WAIT_MAX_RETRIES = 5
# Wait up to 10 seconds for the password prompt from the raspi
_PEXPECT_PASSWORD_TIMEOUT_MAX_RETRIES = 10
# Wait up to 600 seconds for new output from the raspi
_PEXPECT_READLINE_TIMEOUT_MAX_RETRIES = 600
# Delay between subsequent SSH commands
_INTER_COMMAND_DELAY_SECONDS = 1.5
# This is used to strip ansi color codes from pexpect output.
_PEXPECT_SANITIZE_LINE_RE = re.compile(r'\x1b[^m]*m')
# Exceptions to retry
_RETRY_EXCEPTIONS = (pexpect.TIMEOUT, pexpect.ExceptionPexpect,
pexpect.exceptions.EOF, OSError)
def __init__(self, platform, target_name, config, device_id, **kwargs):
# pylint: disable=super-with-arguments
super().__init__(platform, target_name, config, device_id, **kwargs)
env = os.environ.copy()
env.update(self.env_variables)
self.full_env = env
if not self.device_id:
self.device_id = self.full_env.get('RASPI_ADDR')
if not self.device_id:
raise ValueError(
'Unable to determine target, please pass it in, or set RASPI_ADDR '
'environment variable.')
self.startup_timeout_seconds = Launcher._STARTUP_TIMEOUT_SECONDS
self.pexpect_process = None
self._InitPexpectCommands()
self.run_inactive = threading.Event()
self.run_inactive.set()
self.shutdown_initiated = threading.Event()
self.log_targets = kwargs.get('log_targets', True)
signal.signal(signal.SIGINT, functools.partial(_sigint_or_sigterm_handler))
signal.signal(signal.SIGTERM, functools.partial(_sigint_or_sigterm_handler))
self.last_run_pexpect_cmd = ''
def _GetAndCheckTestFile(self, target_name):
# TODO(b/218889313): This should reference the bin/ subdir when that's
# used.
test_dir = os.path.join(self.out_directory, 'install', target_name)
test_file = target_name
test_path = os.path.join(test_dir, test_file)
if not os.path.isfile(test_path):
raise TargetPathError(f'TargetPath ({test_path}) must be a file.')
return test_file
def _GetAndCheckTestFileWithFallback(self):
try:
return self._GetAndCheckTestFile(self.target_name + '_loader')
except TargetPathError:
# For starboard level test targets like player_filter_tests built as an
# executable, return the target name.
return self._GetAndCheckTestFile(self.target_name)
def _InitPexpectCommands(self):
"""Initializes all of the pexpect commands needed for running the test."""
# Ensure no trailing slashes
self.out_directory = self.out_directory.rstrip('/')
test_file = self._GetAndCheckTestFileWithFallback()
raspi_user_hostname = Launcher._RASPI_USERNAME + '@' + self.device_id
# Use the basename of the out directory as a common directory on the device
# so content can be reused for several targets w/o re-syncing for each one.
raspi_test_dir = os.path.basename(self.out_directory)
raspi_test_path = os.path.join(raspi_test_dir, test_file, test_file)
# rsync command setup
options = '-avzLh'
source = os.path.join(self.out_directory, 'install') + '/'
destination = f'{raspi_user_hostname}:~/{raspi_test_dir}/'
self.rsync_command = 'rsync ' + options + ' ' + source + ' ' + destination
# ssh command setup
self.ssh_command = 'ssh -t ' + raspi_user_hostname + ' TERM=dumb bash -l'
# escape command line metacharacters in the flags
flags = ' '.join(self.target_command_line_params)
meta_chars = '()[]{}%!^"<>&|'
meta_re = re.compile('(' + '|'.join(
re.escape(char) for char in list(meta_chars)) + ')')
escaped_flags = re.subn(meta_re, r'\\\1', flags)[0]
# test output tags
self.test_complete_tag = f'TEST-{time.time()}'
self.test_success_tag = 'succeeded'
self.test_failure_tag = 'failed'
# test command setup
test_base_command = raspi_test_path + ' ' + escaped_flags
test_success_output = (f' && echo {self.test_complete_tag}'
f'{self.test_success_tag}')
test_failure_output = (f' || echo {self.test_complete_tag}'
f'{self.test_failure_tag}')
self.test_command = (f'{test_base_command} {test_success_output}'
f'{test_failure_output}')
# pylint: disable=no-method-argument
def _CommandBackoff():
time.sleep(Launcher._INTER_COMMAND_DELAY_SECONDS)
def _ShutdownBackoff(self):
Launcher._CommandBackoff()
return self.shutdown_initiated.is_set()
@retry.retry(
exceptions=_RETRY_EXCEPTIONS,
retries=_PEXPECT_SPAWN_RETRIES,
backoff=_CommandBackoff)
def _PexpectSpawnAndConnect(self, command):
"""Spawns a process with pexpect and connect to the raspi.
Args:
command: The command to use when spawning the pexpect process.
"""
logging.info('executing: %s', command)
kwargs = {} if six.PY2 else {'encoding': 'utf-8'}
self.pexpect_process = pexpect.spawn(
command, timeout=Launcher._PEXPECT_TIMEOUT, **kwargs)
# Let pexpect output directly to our output stream
self.pexpect_process.logfile_read = self.output_file
expected_prompts = [
r'.*Are\syou\ssure.*', # Fingerprint verification
r'.* password:', # Password prompt
'.*[a-zA-Z]+.*', # Any other text input
]
# pylint: disable=unnecessary-lambda
@retry.retry(
exceptions=Launcher._RETRY_EXCEPTIONS,
retries=Launcher._PEXPECT_PASSWORD_TIMEOUT_MAX_RETRIES,
backoff=lambda: self._ShutdownBackoff(),
wrap_exceptions=False)
def _inner():
i = self.pexpect_process.expect(expected_prompts)
if i == 0:
self._PexpectSendLine('yes')
elif i == 1:
self._PexpectSendLine(Launcher._RASPI_PASSWORD)
else:
# If any other input comes in, maybe we've logged in with rsa key or
# raspi does not have password. Check if we've logged in by echoing
# a special sentence and expect it back.
self._PexpectSendLine('echo ' + Launcher._SSH_LOGIN_SIGNAL)
i = self.pexpect_process.expect([Launcher._SSH_LOGIN_SIGNAL])
_inner()
@retry.retry(
exceptions=_RETRY_EXCEPTIONS,
retries=_PEXPECT_SENDLINE_RETRIES,
wrap_exceptions=False)
def _PexpectSendLine(self, cmd):
"""Send lines to Pexpect and record the last command for logging purposes"""
logging.info('sending >> : %s ', cmd)
self.last_run_pexpect_cmd = cmd
self.pexpect_process.sendline(cmd)
def _PexpectReadLines(self):
"""Reads all lines from the pexpect process."""
while True:
# pylint: disable=unnecessary-lambda
line = retry.with_retry(
self.pexpect_process.readline,
exceptions=Launcher._RETRY_EXCEPTIONS,
retries=Launcher._PEXPECT_READLINE_TIMEOUT_MAX_RETRIES,
backoff=lambda: self.shutdown_initiated.is_set(),
wrap_exceptions=False)
# Sanitize the line to remove ansi color codes.
line = Launcher._PEXPECT_SANITIZE_LINE_RE.sub('', line)
self.output_file.flush()
if not line:
return
# Check for the test complete tag. It will be followed by either a
# success or failure tag.
if line.startswith(self.test_complete_tag):
if line.find(self.test_success_tag) != -1:
self.return_value = 0
return
def _Sleep(self, val):
self._PexpectSendLine(f'sleep {val};echo {Launcher._SSH_SLEEP_SIGNAL}')
self.pexpect_process.expect([Launcher._SSH_SLEEP_SIGNAL])
def _CleanupPexpectProcess(self):
"""Closes current pexpect process."""
if self.pexpect_process is not None and self.pexpect_process.isalive():
# Check if kernel logged OOM kill or any other system failure message
if self.return_value:
logging.info('Sending dmesg')
with contextlib.suppress(Launcher._RETRY_EXCEPTIONS):
self._PexpectSendLine('dmesg -P --color=never | tail -n 100')
time.sleep(self._PEXPECT_SHUTDOWN_SLEEP_TIME)
with contextlib.suppress(Launcher._RETRY_EXCEPTIONS):
self.pexpect_process.readlines()
logging.info('Done sending dmesg')
# Send ctrl-c to the raspi and close the process.
with contextlib.suppress(Launcher._RETRY_EXCEPTIONS):
self._PexpectSendLine(chr(3))
time.sleep(self._PEXPECT_TIMEOUT) # Allow time for normal shutdown
with contextlib.suppress(Launcher._RETRY_EXCEPTIONS):
self.pexpect_process.close()
def _WaitForPrompt(self):
"""Sends empty commands, until a bash prompt is returned"""
def backoff():
self._PexpectSendLine('echo ' + Launcher._SSH_SLEEP_SIGNAL)
return self._ShutdownBackoff()
retry.with_retry(
lambda: self.pexpect_process.expect(self._RASPI_PROMPT),
exceptions=Launcher._RETRY_EXCEPTIONS,
retries=Launcher._PROMPT_WAIT_MAX_RETRIES,
backoff=backoff,
wrap_exceptions=False)
@retry.retry(
exceptions=_RETRY_EXCEPTIONS,
retries=_KILL_RETRIES,
backoff=_CommandBackoff)
def _KillExistingCobaltProcesses(self):
"""If there are leftover Cobalt processes, kill them.
It is possible that a previous process did not exit cleanly.
Zombie Cobalt instances can block the WebDriver port or
cause other problems.
"""
logging.info('Killing existing processes')
self._PexpectSendLine(
'pkill -9 -ef "(cobalt)|(crashpad_handler)|(elf_loader)"')
self._WaitForPrompt()
# Print the return code of pkill. 0 if a process was halted
self._PexpectSendLine('echo PROCKILL:${?}')
i = self.pexpect_process.expect([r'PROCKILL:0', r'PROCKILL:(\d+)'])
if i == 0:
logging.warning('Forced to pkill existing instance(s) of cobalt. '
'Pausing to ensure no further operations are run '
'before processes shut down.')
time.sleep(Launcher._PROCESS_KILL_SLEEP_TIME)
logging.info('Done killing existing processes')
def Run(self):
"""Runs launcher's executable on the target raspi.
Returns:
Whether or not the run finished successfully.
"""
if self.log_targets:
logging.info('-' * 32)
logging.info('Starting to run target: %s', self.target_name)
logging.info('=' * 32)
self.return_value = 1
try:
# Notify other threads that the run is now active
self.run_inactive.clear()
# rsync the test files to the raspi
if not self.shutdown_initiated.is_set():
self._PexpectSpawnAndConnect(self.rsync_command)
if not self.shutdown_initiated.is_set():
self._PexpectReadLines()
# ssh into the raspi and run the test
if not self.shutdown_initiated.is_set():
self._PexpectSpawnAndConnect(self.ssh_command)
self._Sleep(self._INTER_COMMAND_DELAY_SECONDS)
# Execute debugging commands on the first run
first_run_commands = []
if self.test_result_xml_path:
first_run_commands.append(f'touch {self.test_result_xml_path}')
first_run_commands.extend(['free -mh', 'ps -ux', 'df -h'])
if first_run():
for cmd in first_run_commands:
if not self.shutdown_initiated.is_set():
self._PexpectSendLine(cmd)
def _readline():
line = self.pexpect_process.readline()
self.output_file.write(line)
retry.with_retry(
_readline,
exceptions=Launcher._RETRY_EXCEPTIONS,
retries=Launcher._PROMPT_WAIT_MAX_RETRIES)
self._WaitForPrompt()
self.output_file.flush()
self._Sleep(self._INTER_COMMAND_DELAY_SECONDS)
self._KillExistingCobaltProcesses()
self._Sleep(self._INTER_COMMAND_DELAY_SECONDS)
if not self.shutdown_initiated.is_set():
self._PexpectSendLine(self.test_command)
self._PexpectReadLines()
except retry.RetriesExceeded:
logging.exception('Command retry exceeded (cmd: %s)',
self.last_run_pexpect_cmd)
except pexpect.EOF:
logging.exception('pexpect encountered EOF while reading line. (cmd: %s)',
self.last_run_pexpect_cmd)
except pexpect.TIMEOUT:
logging.exception('pexpect timed out while reading line. (cmd: %s)',
self.last_run_pexpect_cmd)
except Exception: # pylint: disable=broad-except
logging.exception('Error occurred while running test. (cmd: %s)',
self.last_run_pexpect_cmd)
finally:
self._CleanupPexpectProcess()
# Notify other threads that the run is no longer active
self.run_inactive.set()
if self.log_targets:
logging.info('-' * 32)
logging.info('Finished running target: %s', self.target_name)
logging.info('=' * 32)
return self.return_value
def Kill(self):
"""Stops the run so that the launcher can be killed."""
sys.stderr.write('\n***Killing Launcher***\n')
if self.run_inactive.is_set():
return
# Initiate the shutdown. This causes the run to abort within one second.
self.shutdown_initiated.set()
# Wait up to three seconds for the run to be set to inactive.
self.run_inactive.wait(Launcher._PEXPECT_SHUTDOWN_SLEEP_TIME)
def GetDeviceIp(self):
"""Gets the device IP."""
return self.device_id
def GetDeviceOutputPath(self):
"""Writable path where test targets can output files"""
return '/tmp'