| # Copyright 2018 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. |
| |
| import contextlib |
| import logging |
| import os |
| import random |
| import re |
| from xml.etree import ElementTree |
| |
| from autotest_lib.client.common_lib import utils as common_utils |
| from autotest_lib.client.common_lib import error |
| from autotest_lib.server.cros import lockfile |
| |
| PERF_MODULE_NAME_PREFIX = 'CTS.' |
| |
| @contextlib.contextmanager |
| def lock(filename): |
| """Prevents other autotest/tradefed instances from accessing cache. |
| |
| @param filename: The file to be locked. |
| """ |
| filelock = lockfile.FileLock(filename) |
| # It is tempting just to call filelock.acquire(3600). But the implementation |
| # has very poor temporal granularity (timeout/10), which is unsuitable for |
| # our needs. See /usr/lib64/python2.7/site-packages/lockfile/ |
| attempts = 0 |
| while not filelock.i_am_locking(): |
| try: |
| attempts += 1 |
| logging.info('Waiting for cache lock...') |
| # We must not use a random integer as the filelock implementations |
| # may underflow an integer division. |
| filelock.acquire(random.uniform(0.0, pow(2.0, attempts))) |
| except (lockfile.AlreadyLocked, lockfile.LockTimeout): |
| # Our goal is to wait long enough to be sure something very bad |
| # happened to the locking thread. 11 attempts is between 15 and |
| # 30 minutes. |
| if attempts > 11: |
| # Normally we should aqcuire the lock immediately. Once we |
| # wait on the order of 10 minutes either the dev server IO is |
| # overloaded or a lock didn't get cleaned up. Take one for the |
| # team, break the lock and report a failure. This should fix |
| # the lock for following tests. If the failure affects more than |
| # one job look for a deadlock or dev server overload. |
| age = filelock.age_of_lock() |
| logging.error('Permanent lock failure. Lock age = %d.', age) |
| # Breaking a lock is a destructive operation that causes |
| # abort of locking jobs, cleanup and redowloading of cache |
| # contents. When the error was due to overloaded server, |
| # it cause even more cascade of lock errors. Wait 4 hours |
| # before breaking the lock. Tasks inside the critical section |
| # is about downloading a few gigabytes of files. Taking 4 hours |
| # strongly implies the job had went wrong. |
| if age > 4 * 60 * 60: |
| logging.error('Trying to break lock.') |
| filelock.break_lock() |
| raise error.TestFail('Error: permanent cache lock failure.') |
| else: |
| logging.info('Acquired cache lock after %d attempts.', attempts) |
| try: |
| yield |
| finally: |
| filelock.release() |
| logging.info('Released cache lock.') |
| |
| |
| @contextlib.contextmanager |
| def adb_keepalive(targets, extra_paths): |
| """A context manager that keeps the adb connection alive. |
| |
| AdbKeepalive will spin off a new process that will continuously poll for |
| adb's connected state, and will attempt to reconnect if it ever goes down. |
| This is the only way we can currently recover safely from (intentional) |
| reboots. |
| |
| @param target: the hostname and port of the DUT. |
| @param extra_paths: any additional components to the PATH environment |
| variable. |
| """ |
| from autotest_lib.client.common_lib.cros import adb_keepalive as module |
| # |__file__| returns the absolute path of the compiled bytecode of the |
| # module. We want to run the original .py file, so we need to change the |
| # extension back. |
| script_filename = module.__file__.replace('.pyc', '.py') |
| jobs = [common_utils.BgJob( |
| [script_filename, target], |
| nickname='adb_keepalive', |
| stderr_level=logging.DEBUG, |
| stdout_tee=common_utils.TEE_TO_LOGS, |
| stderr_tee=common_utils.TEE_TO_LOGS, |
| extra_paths=extra_paths) for target in targets] |
| |
| try: |
| yield |
| finally: |
| # The adb_keepalive.py script runs forever until SIGTERM is sent. |
| for job in jobs: |
| common_utils.nuke_subprocess(job.sp) |
| common_utils.join_bg_jobs(jobs) |
| |
| |
| def parse_tradefed_testresults_xml(test_result_xml_path, waivers=None): |
| """ Check the result from tradefed through test_results.xml |
| @param waivers: a set() of tests which are permitted to fail. |
| """ |
| waived_count = dict() |
| failed_tests = set() |
| try: |
| root = ElementTree.parse(test_result_xml_path) |
| for module in root.iter('Module'): |
| module_name = module.get('name') |
| for testcase in module.iter('TestCase'): |
| testcase_name = testcase.get('name') |
| for test in testcase.iter('Test'): |
| test_case = test.get('name') |
| test_res = test.get('result') |
| test_name = '%s#%s' % (testcase_name, test_case) |
| |
| if test_res == "fail": |
| test_fail = test.find('Failure') |
| failed_message = test_fail.get('message') |
| failed_stacktrace = test_fail.find('StackTrace').text |
| |
| if waivers and test_name in waivers: |
| waived_count[test_name] = ( |
| waived_count.get(test_name, 0) + 1) |
| else: |
| failed_tests.add(test_name) |
| |
| # Check for test completion. |
| for summary in root.iter('Summary'): |
| modules_done = summary.get('modules_done') |
| modules_total = summary.get('modules_total') |
| |
| if failed_tests: |
| logging.error('Failed (but not waived) tests:\n%s', |
| '\n'.join(sorted(failed_tests))) |
| |
| waived = [] |
| for testname, fail_count in waived_count.items(): |
| waived += [testname] * fail_count |
| logging.info('Waived failure for %s %d time(s)', |
| testname, fail_count) |
| logging.info('>> Total waived = %s', waived) |
| return waived, True |
| |
| except Exception as e: |
| logging.warning( |
| 'Exception raised in ' |
| '|tradefed_utils.parse_tradefed_result_xml|: {' |
| '0}'.format(e)) |
| |
| def parse_tradefed_result(result, waivers=None): |
| """Check the result from the tradefed output. |
| |
| @param result: The result stdout string from the tradefed command. |
| @param waivers: a set() of tests which are permitted to fail. |
| @return List of the waived tests. |
| """ |
| # Regular expressions for start/end messages of each test-run chunk. |
| abi_re = r'arm\S*|x86\S*' |
| # TODO(kinaba): use the current running module name. |
| module_re = r'\S+' |
| start_re = re.compile(r'(?:Start|Continu)ing (%s) %s with' |
| r' (\d+(?:,\d+)?) test' % (abi_re, module_re)) |
| end_re = re.compile(r'(%s) %s (?:complet|fail)ed in .*\.' |
| r' (\d+) passed, (\d+) failed, (\d+) not executed' % |
| (abi_re, module_re)) |
| fail_re = re.compile(r'I/ConsoleReporter.* (\S+) fail:') |
| inaccurate_re = re.compile(r'IMPORTANT: Some modules failed to run to ' |
| 'completion, tests counts may be inaccurate') |
| abis = set() |
| waived_count = dict() |
| failed_tests = set() |
| accurate = True |
| for line in result.splitlines(): |
| match = start_re.search(line) |
| if match: |
| abis = abis.union([match.group(1)]) |
| continue |
| match = end_re.search(line) |
| if match: |
| abi = match.group(1) |
| if abi not in abis: |
| logging.error('Trunk end with %s abi but have not seen ' |
| 'any trunk start with this abi.(%s)', abi, line) |
| continue |
| match = fail_re.search(line) |
| if match: |
| testname = match.group(1) |
| if waivers and testname in waivers: |
| waived_count[testname] = waived_count.get(testname, 0) + 1 |
| else: |
| failed_tests.add(testname) |
| continue |
| # b/66899135, tradefed may reported inaccuratly with `list results`. |
| # Add warning if summary section shows that the result is inacurrate. |
| match = inaccurate_re.search(line) |
| if match: |
| accurate = False |
| |
| logging.info('Total ABIs: %s', abis) |
| if failed_tests: |
| logging.error('Failed (but not waived) tests:\n%s', |
| '\n'.join(sorted(failed_tests))) |
| |
| # TODO(dhaddock): Find a more robust way to apply waivers. |
| waived = [] |
| for testname, fail_count in waived_count.items(): |
| if fail_count > len(abis): |
| # This should be an error.TestFail, but unfortunately |
| # tradefed has a bug that emits "fail" twice when a |
| # test failed during teardown. It will anyway causes |
| # a test count inconsistency and visible on the dashboard. |
| logging.error('Found %d failures for %s but there are only %d ' |
| 'abis: %s', fail_count, testname, len(abis), abis) |
| fail_count = len(abis) |
| waived += [testname] * fail_count |
| logging.info('Waived failure for %s %d time(s)', testname, fail_count) |
| logging.info('Total waived = %s', waived) |
| return waived, accurate |
| |
| |
| # A similar implementation in Java can be found at |
| # https://android.googlesource.com/platform/test/suite_harness/+/refs/heads/\ |
| # pie-cts-dev/common/util/src/com/android/compatibility/common/util/\ |
| # ResultHandler.java |
| def get_test_result_xml_path(results_destination): |
| """Get the path of test_result.xml from the last session. |
| Raises: |
| Should never raise! |
| """ |
| try: |
| last_result_path = None |
| for dir in os.listdir(results_destination): |
| result_dir = os.path.join(results_destination, dir) |
| result_path = os.path.join(result_dir, 'test_result.xml') |
| # We use the lexicographically largest path, because |dir| are |
| # of format YYYY.MM.DD_HH.MM.SS. The last session will always |
| # have the latest date which leads to the lexicographically |
| # largest path. |
| if last_result_path and last_result_path > result_path: |
| continue |
| # We need to check for `islink` as `isdir` returns true if |
| # |result_dir| is a symbolic link to a directory. |
| if not os.path.isdir(result_dir) or os.path.islink(result_dir): |
| continue |
| if not os.path.exists(result_path): |
| continue |
| last_result_path = result_path |
| return last_result_path |
| except Exception as e: |
| logging.warning( |
| 'Exception raised in ' |
| '|tradefed_utils.get_test_result_xml_path|: {' |
| '0}'.format(e)) |
| |
| |
| def get_perf_metrics_from_test_result_xml(result_path, resultsdir): |
| """Parse test_result.xml and each <Metric /> is mapped to a dict that |
| can be used as kwargs of |TradefedTest.output_perf_value|. |
| Raises: |
| Should never raise! |
| """ |
| try: |
| root = ElementTree.parse(result_path) |
| for module in root.iter('Module'): |
| module_name = module.get('name') |
| for testcase in module.iter('TestCase'): |
| testcase_name = testcase.get('name') |
| for test in testcase.iter('Test'): |
| test_name = test.get('name') |
| for metric in test.iter('Metric'): |
| score_type = metric.get('score_type') |
| if score_type not in ['higher_better', 'lower_better']: |
| logging.warning( |
| 'Unsupported score_type in %s/%s/%s', |
| module_name, testcase_name, test_name) |
| continue |
| higher_is_better = (score_type == 'higher_better') |
| units = metric.get('score_unit') |
| yield dict( |
| description=testcase_name + '#' + test_name, |
| value=metric[0].text, |
| units=units, |
| higher_is_better=higher_is_better, |
| resultsdir=os.path.join(resultsdir, 'tests', |
| PERF_MODULE_NAME_PREFIX + module_name) |
| ) |
| except Exception as e: |
| logging.warning( |
| 'Exception raised in ' |
| '|tradefed_utils.get_perf_metrics_from_test_result_xml|: {' |
| '0}'.format(e)) |