diff --git a/src/clusterfuzz/_internal/bot/testcase_manager.py b/src/clusterfuzz/_internal/bot/testcase_manager.py index d62a9345819..49aaafbfbb3 100644 --- a/src/clusterfuzz/_internal/bot/testcase_manager.py +++ b/src/clusterfuzz/_internal/bot/testcase_manager.py @@ -336,6 +336,10 @@ def run_testcase(thread_index, file_path, gestures, env_copy): command = get_command_line_for_application( file_path, user_profile_index=thread_index, needs_http=needs_http) + logs.debug( + f'[TestcaseManager] Running testcase (thread {thread_index}): ' + f'command={command}, needs_http={needs_http}, gestures={gestures}') + # Run testcase. return process_handler.run_process( command, @@ -1243,7 +1247,8 @@ def check_for_bad_build(job_type: str, os.environ['APP_ARGS'] = job_default_args try: - command = get_command_line_for_application(file_to_run='', needs_http=False) + command = get_command_line_for_application( + file_to_run='', needs_http=False, write_command_line_file=True) finally: if orig_app_args is not None: os.environ['APP_ARGS'] = orig_app_args @@ -1273,19 +1278,42 @@ def check_for_bad_build(job_type: str, process_handler.terminate_stale_application_instances() # Check if the build is bad. + logs.info( + f'Starting bad build check for {job_type} at r{crash_revision} with ' + f'command: {command} (timeout={fast_warmup_timeout})') return_code, crash_time, output = process_handler.run_process( command, timeout=fast_warmup_timeout, current_working_directory=app_directory) crash_result = CrashResult(return_code, crash_time, output) + logs.info( + f'Bad build check run_process completed: return_code={return_code}, ' + f'is_crash={crash_result.is_crash(ignore_state=True)}, ' + f'crash_type={crash_result.get_type()}') + # On Android, if the application process is not running after startup, the + # build is bad. + if environment.is_android(): + package_name = android.app.get_package_name() + if (package_name and + not android.adb.get_process_and_child_pids(package_name)): + is_bad_build = True + build_run_console_output = utils.get_crash_stacktrace_output( + command, output, output) + logs.info( + f'Bad build for {job_type} detected at r{crash_revision}: ' + f'application process for {package_name} is not running after ' + 'startup.', + raw_output=output, + output=build_run_console_output) # 1. Need to account for startup crashes with no crash state. E.g. failed to # load shared library. So, ignore state for comparison. # 2. Ignore leaks as they don't block a build from reporting regular crashes # and also don't impact regression range calculations. - if (crash_result.is_crash(ignore_state=True) and - not crash_result.should_ignore() and - not crash_result.get_type() in ['Direct-leak', 'Indirect-leak']): + elif (crash_result.is_crash(ignore_state=True) and + not crash_result.should_ignore() and + not crash_result.get_type() in ['Direct-leak', 'Indirect-leak']): + is_bad_build = True build_run_console_output = utils.get_crash_stacktrace_output( command, @@ -1296,19 +1324,6 @@ def check_for_bad_build(job_type: str, f'return code = {return_code}, crash type = {crash_result.get_type()}', raw_output=output, output=build_run_console_output) - elif environment.is_android(): - package_name = android.app.get_package_name() - if (package_name and - not android.adb.get_process_and_child_pids(package_name)): - is_bad_build = True - build_run_console_output = utils.get_crash_stacktrace_output( - command, output, output) - logs.info( - f'Bad build for {job_type} detected at r{crash_revision}: ' - f'application process for {package_name} is not running after ' - 'startup.', - raw_output=output, - output=build_run_console_output) # Exit all running instances. process_handler.terminate_stale_application_instances() diff --git a/src/clusterfuzz/_internal/metrics/logs.py b/src/clusterfuzz/_internal/metrics/logs.py index a66fa138ba4..138144abea1 100644 --- a/src/clusterfuzz/_internal/metrics/logs.py +++ b/src/clusterfuzz/_internal/metrics/logs.py @@ -53,6 +53,8 @@ _is_already_handling_uncaught = False _default_extras = {} +BASE_LOGGING_LEVEL = logging.DEBUG + def _increment_error_count(): """"Increment the error count metric.""" @@ -115,7 +117,7 @@ def get_handler_config(filename, backup_count): return { 'class': 'logging.handlers.RotatingFileHandler', - 'level': logging.INFO, + 'level': BASE_LOGGING_LEVEL, 'formatter': 'simple', 'filename': file_path, 'maxBytes': max_bytes, @@ -396,7 +398,7 @@ def json_fields_filter(record): def configure_appengine(): """Configure logging for App Engine.""" - logging.getLogger().setLevel(logging.INFO) + logging.getLogger().setLevel(BASE_LOGGING_LEVEL) if os.getenv('LOCAL_DEVELOPMENT') or environment.is_running_unit_tests(): return @@ -458,12 +460,12 @@ def k8s_label_filter(record): return True handler.addFilter(k8s_label_filter) - handler.setLevel(logging.INFO) + handler.setLevel(BASE_LOGGING_LEVEL) formatter = JsonFormatter() handler.setFormatter(formatter) logging.getLogger().addHandler(handler) - logging.getLogger().setLevel(logging.INFO) + logging.getLogger().setLevel(BASE_LOGGING_LEVEL) def configure_cloud_logging(): @@ -527,7 +529,7 @@ def cloud_label_filter(record): return True handler.addFilter(cloud_label_filter) - handler.setLevel(logging.INFO) + handler.setLevel(BASE_LOGGING_LEVEL) formatter = JsonFormatter() handler.setFormatter(formatter) @@ -549,7 +551,7 @@ def configure_swarming(name: str, extras: dict[str, str] | None = None) -> None: configure_cloud_logging() logger = logging.getLogger(name) - logger.setLevel(logging.INFO) + logger.setLevel(BASE_LOGGING_LEVEL) set_logger(logger) sys.excepthook = uncaught_exception_handler @@ -574,13 +576,13 @@ def configure(name, extras=None): return if _console_logging_enabled(): - logging.basicConfig(level=logging.INFO) + logging.basicConfig(level=BASE_LOGGING_LEVEL) if _file_logging_enabled(): config.dictConfig(get_logging_config_dict(name)) if _cloud_logging_enabled(): configure_cloud_logging() logger = logging.getLogger(name) - logger.setLevel(logging.INFO) + logger.setLevel(BASE_LOGGING_LEVEL) set_logger(logger) # Set _default_extras so they can be used later. @@ -769,6 +771,11 @@ def warning(message, **extras): emit(logging.WARN, message, exc_info=sys.exc_info(), **extras) +def debug(message, **extras): + """Logs the debug message.""" + emit(logging.DEBUG, message, **extras) + + def error(message, **extras): """Logs the error in the error log file.""" exception = extras.pop('exception', None) diff --git a/src/clusterfuzz/_internal/platforms/android/adb.py b/src/clusterfuzz/_internal/platforms/android/adb.py index 7d3c712206b..57ba4fa7d8c 100755 --- a/src/clusterfuzz/_internal/platforms/android/adb.py +++ b/src/clusterfuzz/_internal/platforms/android/adb.py @@ -679,14 +679,18 @@ def run_command(cmd, log_output=False, timeout=None, recover=True): if isinstance(cmd, list): cmd = ' '.join([str(i) for i in cmd]) if log_output: - logs.info('Running: adb %s' % cmd) + logs.info(f'[ADB] Running: {cmd}') + else: + logs.debug(f'[ADB] Running: {cmd}') if not timeout: timeout = ADB_TIMEOUT output = execute_command(get_adb_command_line(cmd), timeout) if not recover: if log_output: - logs.info('Output: (%s)' % output) + logs.info(f'[ADB] Output for {cmd}: {output}') + else: + logs.debug(f'[ADB] Output for {cmd}: {output}') return output device_not_found_string_with_serial = DEVICE_NOT_FOUND_STRING.format( @@ -915,3 +919,29 @@ def write_data_to_file(contents, file_path, should_reboot=True): else: # Manually revert /system to read-only since we aren't rebooting. run_shell_command('mount -o ro,remount /system', root=True) + + +def get_activity_exit_info(app_package: str) -> str: + """Get dumpsys activity exit-info output for the given application package. + Example dumpsys output: + package: org.chromium.chrome + Historical Process Exit for uid=10154 + ApplicationExitInfo #0: + timestamp=2026-09-03 11:19:03.480 pid=27098 realUid=10154 + packageUid=10154 definingUid=10154 user=0 + process=org.chromium.chrome reason=2 (SIGNALED) + subreason=0 (UNKNOWN) status=9 + importance=100 pss=0.00 rss=0.00 state=empty trace=null + description=null + anrInfo=null + + + Args: + app_package: Name of the application package. + + Returns: + Dumpsys output for the application package. + """ + dumpsys_output = run_shell_command( + ['dumpsys', 'activity', 'exit-info', app_package]) + return dumpsys_output diff --git a/src/clusterfuzz/_internal/platforms/android/constants.py b/src/clusterfuzz/_internal/platforms/android/constants.py index a797b3a5a0e..0ab329e98ec 100644 --- a/src/clusterfuzz/_internal/platforms/android/constants.py +++ b/src/clusterfuzz/_internal/platforms/android/constants.py @@ -13,6 +13,7 @@ # limitations under the License. """Common constants.""" +from enum import IntEnum import re DEVICE_DOWNLOAD_DIR = '/sdcard/Download' @@ -86,3 +87,42 @@ # Restrict pixel6 from picking up generic Android jobs to avoid # Binary Mismatch: Hence, 'ANDROID:PIXEL6' is added to the list. DEVICES_WITH_NO_FALLBACK_QUEUE_LIST = ['ANDROID:PIXEL6'] + + +class ExitReason(IntEnum): + """Android process exit reasons from ApplicationExitInfo. Taken from + https://developer.android.com/reference/android/app/ApplicationExitInfo + """ + + UNKNOWN = 0 + EXIT_SELF = 1 + SIGNALED = 2 + LOW_MEMORY = 3 + CRASH = 4 + CRASH_NATIVE = 5 + ANR = 6 + INITIALIZATION_FAILURE = 7 + PERMISSION_CHANGE = 8 + EXCESSIVE_RESOURCE_USAGE = 9 + USER_REQUESTED = 10 + USER_STOPPED = 11 + DEPENDENCY_DIED = 12 + OTHER_KILLS_BY_SYSTEM = 13 + FREEZER = 14 + PACKAGE_STATE_CHANGE = 15 + PACKAGE_UPDATED = 16 + REASON_MEMORY_LIMITER = 17 + REASON_ANOMALY = 18 + + +class ExitStatus(IntEnum): + """Process exit signal statuses corresponding to POSIX signals. + + See: https://developer.android.com/reference/android/os/Process + """ + + SIGQUIT = 3 + SIGILL = 4 + SIGABRT = 6 + SIGKILL = 9 + SIGSEGV = 11 diff --git a/src/clusterfuzz/_internal/platforms/android/fetch_artifact.py b/src/clusterfuzz/_internal/platforms/android/fetch_artifact.py index 066269ee4d8..44eb9ebd3fb 100644 --- a/src/clusterfuzz/_internal/platforms/android/fetch_artifact.py +++ b/src/clusterfuzz/_internal/platforms/android/fetch_artifact.py @@ -56,7 +56,7 @@ def _call_android_api_enabled(): Disabled always if invoked in a uworker """ if environment.is_uworker(): - logs.info('AndroidBuildAPI access disabled for uworker.') + logs.debug('AndroidBuildAPI access disabled for uworker.') return False flag = feature_flags.FeatureFlags.CALL_ANDROID_API.flag @@ -66,10 +66,10 @@ def _call_android_api_enabled(): def _download_artifact(client, bid, target, attempt_id, name, output_directory, output_filename): """Download one artifact.""" - logs.info('reached download_artifact') - logs.info('artifact to download: %s' % name) - logs.info('output_directory: %s' % output_directory) - logs.info('output_filename: %s' % output_filename) + logs.debug('reached download_artifact') + logs.debug('artifact to download: %s' % name) + logs.debug('output_directory: %s' % output_directory) + logs.debug('output_filename: %s' % output_filename) logs.info( 'AndroidBuildAPI download_artifact started.', @@ -126,9 +126,9 @@ def _download_artifact(client, bid, target, attempt_id, name, output_directory, status='skipped_exists') return output_path - logs.info('Downloading artifact %s.' % name) + logs.debug('Downloading artifact %s.' % name) output_dir = os.path.dirname(output_path) - logs.info('Output dir: %s' % output_dir) + logs.debug('Output dir: %s' % output_dir) if not os.path.exists(output_dir): logs.info(f'Creating directory {output_dir}') os.makedirs(output_dir, exist_ok=True) @@ -147,7 +147,7 @@ def _download_artifact(client, bid, target, attempt_id, name, output_directory, success = client.download_artifact_file(bid, target, attempt_id, name, output_path) if not success: - logs.error( + logs.warning( 'AndroidBuildAPI download_artifact failed.', api_version=API_VERSION, operation='download_artifact', @@ -185,7 +185,7 @@ def _get_artifacts_for_build(client, target=target) return [] - logs.info( + logs.debug( 'AndroidBuildAPI get_artifacts_for_build started.', api_version=API_VERSION, operation='get_artifacts_for_build', @@ -196,7 +196,7 @@ def _get_artifacts_for_build(client, artifacts = client.list_artifacts(bid, target, attempt_id, regexp=regexp) - logs.info( + logs.debug( 'AndroidBuildAPI get_artifacts_for_build completed.', api_version=API_VERSION, operation='get_artifacts_for_build', @@ -219,13 +219,13 @@ def _get_client(): build_apiary_service_account_private_key = db_config.get_value( 'build_apiary_service_account_private_key') if not build_apiary_service_account_private_key: - logs.info( + logs.warning( 'Android build apiary credentials are not set, skip artifact fetch.') return None key_dict = json.loads(build_apiary_service_account_private_key) - logs.info( + logs.debug( 'AndroidBuildAPI client initialization started.', api_version=API_VERSION) try: @@ -241,7 +241,7 @@ def _get_client(): def _get_stable_build_info(): """Return stable artifact for cuttlefish branch and target.""" - logs.info('Reached get_stable_build_info') + logs.debug('Reached get_stable_build_info') stable_build_info = STABLE_CUTTLEFISH_BUILD try: @@ -260,8 +260,7 @@ def _get_stable_build_info(): def get_latest_artifact_info(branch, target, signed=False, stable_build=False): """Return latest artifact for a branch and target.""" if not _call_android_api_enabled(): - logs.warning( - 'Android build API is disabled by feature flag call_android_api.') + logs.info('Android build API is disabled by feature flag call_android_api.') return None client = _get_client() diff --git a/src/clusterfuzz/_internal/platforms/android/logger.py b/src/clusterfuzz/_internal/platforms/android/logger.py index add0d7f0efc..95224c4c6b4 100644 --- a/src/clusterfuzz/_internal/platforms/android/logger.py +++ b/src/clusterfuzz/_internal/platforms/android/logger.py @@ -124,3 +124,8 @@ def log_output(additional_flags=''): def log_output_before_last_reboot(): """Return log data from last reboot without noise and some normalization.""" return log_output(additional_flags='-L') + + +def log_activity_manager_output(): + """Return activity manager log output.""" + return adb.run_command(['logcat', '-d', '-s', 'ActivityManager:I']) diff --git a/src/clusterfuzz/_internal/platforms/android/symbols_downloader.py b/src/clusterfuzz/_internal/platforms/android/symbols_downloader.py index 55a906b5498..b4343a3f578 100644 --- a/src/clusterfuzz/_internal/platforms/android/symbols_downloader.py +++ b/src/clusterfuzz/_internal/platforms/android/symbols_downloader.py @@ -198,8 +198,8 @@ def download_trusty_symbols_if_needed(symbols_directory, app_name, bid): if not bid: build_info = fetch_artifact.get_latest_artifact_info(branch, ab_target) if not build_info: - logs.error(f'Unable to fetch build info for branch {branch} ' - f'and target {ab_target}.') + logs.warning(f'Unable to fetch build info for branch {branch} ' + f'and target {ab_target}.') return bid = build_info['bid'] diff --git a/src/clusterfuzz/_internal/platforms/android/util.py b/src/clusterfuzz/_internal/platforms/android/util.py index 3cab131b78a..2c8b3e6cf14 100644 --- a/src/clusterfuzz/_internal/platforms/android/util.py +++ b/src/clusterfuzz/_internal/platforms/android/util.py @@ -13,12 +13,106 @@ # limitations under the License. """Utility functions for Android device.""" +from dataclasses import dataclass import os +import re from clusterfuzz._internal.metrics import logs from clusterfuzz._internal.platforms import android from clusterfuzz._internal.system import environment +from . import adb +from . import constants +from . import logger + +# Matching: "Start proc :/" +_START_PROC_REGEX = r"Start proc (\d+):(\S+?)/" + +# Matching: "reason= () subreason=" +# "() status=" +# e.g.: "reason=5 (APP_CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11" or +# "reason=2 (SIGNALED) subreason=0 (UNKNOWN) status=9" +_REASON_STATUS_REGEX = (r"reason=(\d+)(?:\s*\((.*)\))?\s+subreason=(\d+)" + r"(?:\s*\((.*)\))?\s+status=(\d+)") + + +@dataclass(frozen=True) +class ProcessExitInfo: + """DTO representing process exit metadata from dumpsys activity exit-info. + + For a full list of android exit info reasons and subreasons, see: + https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/proto_logging/stats/enums/app_shared/app_enums.proto;l=270?q=content:subreason + """ + + reason: android.constants.ExitReason | int + reason_name: str # e.g., 'APP_CRASH(NATIVE)', 'SIGNALED' + subreason: int + subreason_name: str # e.g., 'UNKNOWN', 'ISOLATED_NOT_NEEDED' + status: android.constants.ExitStatus | int + + +def _to_enum(enum_cls, raw_value: int | str): + """Converts raw_value to an Enum member, or returns None if invalid.""" + try: + return enum_cls(int(raw_value)) + except (ValueError, TypeError): + logs.debug(f'[Android] Could not convert {raw_value} to {enum_cls}') + return None + + +def _parse_exit_info_from_dumpsys(dumpsys_output: str, + target_pid: int) -> ProcessExitInfo | None: + """Parses dumpsys activity exit-info output for target_pid. + + Args: + dumpsys_output: Output text from `dumpsys activity exit-info`. + target_pid: Process ID to extract exit metadata for. + + Returns: + ProcessExitInfo object if metadata for target_pid is found and parsed, + None otherwise. + """ + if not dumpsys_output or target_pid is None: + return None + + current_pid = None + for line in dumpsys_output.splitlines(): + pid_match = re.search(r"\bpid=(\d+)", line) + if pid_match: + current_pid = int(pid_match.group(1)) + + if current_pid is None or current_pid != target_pid: + continue + + reason_match = re.search(_REASON_STATUS_REGEX, line) + if not reason_match: + continue + + reason, reason_name, subreason, subreason_name, status = ( + reason_match.groups()) + + parsed_reason = _to_enum(constants.ExitReason, reason) + if parsed_reason is None: + logs.warning(f'[Android] Unexpected process exit reason code {reason} ' + f'for PID {target_pid}.') + parsed_reason = constants.ExitReason.UNKNOWN + + parsed_status = _to_enum(constants.ExitStatus, status) + if parsed_status is None: + logs.warning(f'[Android] Unexpected process exit status code {status} ' + f'for PID {target_pid}.') + parsed_status = int(status) + + return ProcessExitInfo( + reason=parsed_reason, + reason_name=reason_name or '', + subreason=int(subreason), + subreason_name=subreason_name or '', + status=parsed_status, + ) + + return None + def get_device_path(local_path): """Returns device path for the given local path.""" @@ -90,3 +184,95 @@ def can_testcase_run_on_platform(testcase_platform_id, current_platform_id): return True return False + + +def get_latest_pid_for_package(app_package: str) -> int | None: + """Gets the latest PID for an application package from logcat. + + Args: + app_package: Name of the target application package. + + Returns: + PID of the package's latest process if found, None otherwise. + """ + logcat_output = logger.log_activity_manager_output() + if not logcat_output: + logs.info(f'[Android][{app_package}] PID not found, no logcat output') + return None + + for line in reversed(logcat_output.splitlines()): + match = re.search(_START_PROC_REGEX, line) + if not match: + continue + + pid, process_name = match.groups() + if process_name == app_package or process_name.startswith( + f'{app_package}:'): + return int(pid) + return None + + +def get_exit_info_for_pid(app_package: str, + target_pid: int) -> ProcessExitInfo | None: + """Fetches and parses dumpsys activity exit-info output for target_pid. + + Args: + app_package: Name of the application package. + target_pid: Process ID to extract exit metadata for. + + Returns: + ProcessExitInfo object if metadata for target_pid is found and parsed, + None otherwise. + """ + if target_pid is None: + logs.info(f'[Android][{app_package}] Exit info not found, PID not given') + return None + + dumpsys_output = adb.get_activity_exit_info(app_package) + return _parse_exit_info_from_dumpsys(dumpsys_output, target_pid) + + +def activity_crashed(exit_info: ProcessExitInfo | None) -> bool: + """Evaluates whether process exit info corresponds to an activity crash. + + Args: + exit_info: ProcessExitInfo instance or None. + + Returns: + True if exit_info indicates an activity crash, False otherwise. + """ + if not exit_info: + logs.warning('[Android] Exit info empty, not checking for crashes.') + return False + + if exit_info.reason in (constants.ExitReason.CRASH_NATIVE, + constants.ExitReason.SIGNALED): + return exit_info.status in ( + constants.ExitStatus.SIGSEGV, + constants.ExitStatus.SIGKILL, + constants.ExitStatus.SIGABRT, + constants.ExitStatus.SIGILL, + ) + if exit_info.reason == constants.ExitReason.CRASH: + return True + return False + + +def activity_crashed_by_package(app_package: str) -> bool: + """Checks whether the latest process for a package crashed. + + Args: + app_package: Name of the application package to check. + + Returns: + True if the package's latest process crashed, False otherwise. + """ + if not app_package: + logs.warning(f'[Android][{app_package}] App package not given, ' + 'not checking for crashes.') + return False + + pid = get_latest_pid_for_package(app_package) + + exit_info = get_exit_info_for_pid(app_package, pid) + return activity_crashed(exit_info) diff --git a/src/clusterfuzz/_internal/system/process_handler.py b/src/clusterfuzz/_internal/system/process_handler.py index 27f697e6fbd..e2b62e05c70 100644 --- a/src/clusterfuzz/_internal/system/process_handler.py +++ b/src/clusterfuzz/_internal/system/process_handler.py @@ -174,6 +174,7 @@ def run_process(cmdline, if is_android: # Clear the log upfront. android.logger.clear_log() + initial_uptime = android.adb.time_since_last_reboot() # Run the app. adb_output = android.adb.run_command( @@ -253,15 +254,26 @@ def run_process(cmdline, # waits for device to be online. time.sleep(ANDROID_CRASH_LOGCAT_WAIT_TIME) output = android.logger.log_output() + app_package = android.app.get_package_name() + process_pid = android.util.get_latest_pid_for_package(app_package) + exit_info = android.util.get_exit_info_for_pid(app_package, process_pid) - if android.constants.LOW_MEMORY_REGEX.search(output): + if android.util.activity_crashed(exit_info): + logs.warning(f'Activity Crashed with: {exit_info}') + return_code = exit_info.reason + + elif android.constants.LOW_MEMORY_REGEX.search(output): # If the device is low on memory, we should force reboot and bail out to # prevent device from getting in a frozen state. logs.info('Device is low on memory, rebooting.', output=output) android.adb.hard_reset() android.adb.wait_for_device() - elif android.adb.time_since_last_reboot() < time.time() - start_time: + elif android.adb.time_since_last_reboot() < initial_uptime: + logs.info( + 'Device rebooted mid-run', + output=f'initial uptime: {initial_uptime}, ' + f'current uptime: {android.adb.time_since_last_reboot()}') # Check if a reboot has happened, if yes, append log output before reboot # and kernel logs content to output. log_before_last_reboot = android.logger.log_output_before_last_reboot() diff --git a/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py b/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py new file mode 100644 index 00000000000..8cc7b379bdd --- /dev/null +++ b/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py @@ -0,0 +1,282 @@ +# Copyright 2026 Google LLC +# +# 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. +"""Tests process exit info parsing and activity crash utilities.""" + +import unittest + +from clusterfuzz._internal.platforms.android import constants +from clusterfuzz._internal.platforms.android import util +from clusterfuzz._internal.tests.test_libs import helpers as test_helpers + + +class GetLatestPidForPackageTest(unittest.TestCase): + """Tests get_latest_pid_for_package.""" + + def setUp(self): + test_helpers.patch(self, [ + 'clusterfuzz._internal.platforms.android.logger.log_activity_manager_output', + ]) + + def test_single_match(self): + """Checks that get_latest_pid_for_package parses and returns PID for a matching package process start line.""" + self.mock.log_activity_manager_output.return_value = ( + 'I/ActivityManager( 100): Start proc 1234:com.example.app/u0a123 ' + 'for activity com.example.app/.MainActivity') + pid = util.get_latest_pid_for_package('com.example.app') + self.assertEqual(pid, 1234) + + def test_multiple_matches_returns_latest(self): + """Checks that get_latest_pid_for_package returns the most recent PID when logcat contains multiple process start entries for the package.""" + self.mock.log_activity_manager_output.return_value = ( + 'I/ActivityManager( 100): Start proc 1234:com.example.app/u0a123\n' + 'I/ActivityManager( 100): Start proc 5678:com.example.app/u0a123') + pid = util.get_latest_pid_for_package('com.example.app') + self.assertEqual(pid, 5678) + + def test_package_name_with_subprocess(self): + """Checks that get_latest_pid_for_package matches subprocesses prefixed with the package name.""" + self.mock.log_activity_manager_output.return_value = ( + 'I/ActivityManager( 100): Start proc 4321:com.example.app:sandboxed_process/u0a123' + ) + pid = util.get_latest_pid_for_package('com.example.app') + self.assertEqual(pid, 4321) + + def test_package_prefix_not_matched(self): + """Checks that get_latest_pid_for_package does not match package names that only share a prefix.""" + self.mock.log_activity_manager_output.return_value = ( + 'I/ActivityManager( 100): Start proc 9999:com.example.app2/u0a123') + pid = util.get_latest_pid_for_package('com.example.app') + self.assertIsNone(pid) + + def test_empty_logs_or_no_match(self): + """Checks that get_latest_pid_for_package returns None when logcat is empty or contains no matching log lines.""" + self.mock.log_activity_manager_output.return_value = '' + self.assertIsNone(util.get_latest_pid_for_package('com.example.app')) + + self.mock.log_activity_manager_output.return_value = ( + 'I/ActivityManager( 100): Some unrelated log message') + self.assertIsNone(util.get_latest_pid_for_package('com.example.app')) + + +# pylint: disable=protected-access +class ParseExitInfoFromDumpsysTest(unittest.TestCase): + """Tests _parse_exit_info_from_dumpsys directly without mocking I/O.""" + + def test_happy_path_parsed(self): + """Checks that _parse_exit_info_from_dumpsys correctly parses dumpsys activity exit-info block for a matching target PID.""" + dumpsys_output = ( + 'ApplicationExitInfo #0:\n' + ' timestamp=1600000000 pid=4321 uid=10001 package=com.example.app\n' + ' reason=5 (APP_CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11\n') + exit_info = util._parse_exit_info_from_dumpsys(dumpsys_output, 4321) + self.assertEqual( + exit_info, + util.ProcessExitInfo( + reason=constants.ExitReason.CRASH_NATIVE, + reason_name='APP_CRASH(NATIVE)', + subreason=0, + subreason_name='UNKNOWN', + status=constants.ExitStatus.SIGSEGV, + ), + ) + + def test_missing_reason_names_in_parentheses(self): + """Checks that _parse_exit_info_from_dumpsys parses numerical exit info when reason and subreason names are omitted in output.""" + dumpsys_output = ('ApplicationExitInfo #0:\n' + ' pid=4321 uid=10001\n' + ' reason=2 subreason=0 status=9\n') + exit_info = util._parse_exit_info_from_dumpsys(dumpsys_output, 4321) + self.assertEqual( + exit_info, + util.ProcessExitInfo( + reason=constants.ExitReason.SIGNALED, + reason_name='', + subreason=0, + subreason_name='', + status=constants.ExitStatus.SIGKILL, + ), + ) + + def test_pid_not_found(self): + """Checks that _parse_exit_info_from_dumpsys returns None when the target PID is absent from dumpsys activity exit-info output.""" + dumpsys_output = ( + 'ApplicationExitInfo #0:\n' + ' pid=1111 uid=10001\n' + ' reason=5 (APP_CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11\n') + exit_info = util._parse_exit_info_from_dumpsys(dumpsys_output, 4321) + self.assertIsNone(exit_info) + + def test_dumpsys_output_empty(self): + """Checks that _parse_exit_info_from_dumpsys returns None when dumpsys output is empty string or None.""" + self.assertIsNone(util._parse_exit_info_from_dumpsys('', 4321)) + self.assertIsNone(util._parse_exit_info_from_dumpsys(None, 4321)) + + def test_malformed_reason_line(self): + """Checks that _parse_exit_info_from_dumpsys returns None when pid matches but subsequent lines do not contain valid reason metadata.""" + dumpsys_output = ('ApplicationExitInfo #0:\n' + ' pid=4321 uid=10001\n' + ' invalid reason info block here\n' + ' another invalid line\n') + exit_info = util._parse_exit_info_from_dumpsys(dumpsys_output, 4321) + self.assertIsNone(exit_info) + + def test_unknown_exit_reason_or_status(self): + """Checks that _parse_exit_info_from_dumpsys handles unknown reason or status integer codes gracefully.""" + dumpsys_output = ( + 'ApplicationExitInfo #0:\n' + ' pid=4321 uid=10001\n' + ' reason=999 (UNKNOWN_FUTURE_REASON) subreason=0 (UNKNOWN) status=888\n' + ) + exit_info = util._parse_exit_info_from_dumpsys(dumpsys_output, 4321) + self.assertEqual( + exit_info, + util.ProcessExitInfo( + reason=constants.ExitReason.UNKNOWN, + reason_name='UNKNOWN_FUTURE_REASON', + subreason=0, + subreason_name='UNKNOWN', + status=888, + ), + ) + + +class GetExitInfoForPidTest(unittest.TestCase): + """Tests get_exit_info_for_pid.""" + + def setUp(self): + test_helpers.patch(self, [ + 'clusterfuzz._internal.platforms.android.adb.get_activity_exit_info', + ]) + + def test_fetches_and_parses(self): + """Checks that get_exit_info_for_pid fetches output from adb and returns parsed ProcessExitInfo.""" + self.mock.get_activity_exit_info.return_value = ( + 'ApplicationExitInfo #0:\n' + ' timestamp=1600000000 pid=4321 uid=10001 package=com.example.app\n' + ' reason=5 (APP_CRASH(NATIVE)) subreason=0 (UNKNOWN) status=11\n') + exit_info = util.get_exit_info_for_pid('com.example.app', 4321) + self.mock.get_activity_exit_info.assert_called_once_with('com.example.app') + self.assertEqual( + exit_info, + util.ProcessExitInfo( + reason=constants.ExitReason.CRASH_NATIVE, + reason_name='APP_CRASH(NATIVE)', + subreason=0, + subreason_name='UNKNOWN', + status=constants.ExitStatus.SIGSEGV, + ), + ) + + def test_none_pid(self): + """Checks that get_exit_info_for_pid returns None when target_pid is None.""" + self.assertIsNone(util.get_exit_info_for_pid('com.example.app', None)) + + +class ActivityCrashedTest(unittest.TestCase): + """Tests activity_crashed.""" + + def test_crash_app_crash_native(self): + """Checks that activity_crashed returns True for CRASH_NATIVE with status SIGSEGV.""" + exit_info = util.ProcessExitInfo( + reason=constants.ExitReason.CRASH_NATIVE, + reason_name='APP_CRASH(NATIVE)', + subreason=0, + subreason_name='', + status=constants.ExitStatus.SIGSEGV, + ) + self.assertTrue(util.activity_crashed(exit_info)) + + def test_crash_signaled(self): + """Checks that activity_crashed returns True for SIGNALED with status SIGKILL.""" + exit_info = util.ProcessExitInfo( + reason=constants.ExitReason.SIGNALED, + reason_name='SIGNALED', + subreason=0, + subreason_name='', + status=constants.ExitStatus.SIGKILL, + ) + self.assertTrue(util.activity_crashed(exit_info)) + + def test_regular_app_crash(self): + """Checks that activity_crashed returns True for CRASH.""" + exit_info = util.ProcessExitInfo( + reason=constants.ExitReason.CRASH, + reason_name='CRASH', + subreason=0, + subreason_name='', + status=0, + ) + self.assertTrue(util.activity_crashed(exit_info)) + + def test_normal_exit_reason(self): + """Checks that activity_crashed returns False for normal exit reason EXIT_SELF.""" + exit_info = util.ProcessExitInfo( + reason=constants.ExitReason.EXIT_SELF, + reason_name='EXIT_SELF', + subreason=0, + subreason_name='', + status=0, + ) + self.assertFalse(util.activity_crashed(exit_info)) + + def test_crash_reason_untracked_status(self): + """Checks that activity_crashed returns False for CRASH_NATIVE when status is not in crash signal list.""" + exit_info = util.ProcessExitInfo( + reason=constants.ExitReason.CRASH_NATIVE, + reason_name='APP_CRASH', + subreason=0, + subreason_name='', + status=0, + ) + self.assertFalse(util.activity_crashed(exit_info)) + + def test_none_exit_info(self): + """Checks that activity_crashed returns False when exit_info is None.""" + self.assertFalse(util.activity_crashed(None)) + + +class ActivityCrashedByPackageTest(unittest.TestCase): + """Tests activity_crashed_by_package.""" + + def setUp(self): + test_helpers.patch(self, [ + 'clusterfuzz._internal.platforms.android.util.get_latest_pid_for_package', + 'clusterfuzz._internal.platforms.android.util.get_exit_info_for_pid', + ]) + + def test_fetch_crashed(self): + """Checks that activity_crashed_by_package fetches PID and exit info dynamically and returns True for crash.""" + self.mock.get_latest_pid_for_package.return_value = 1234 + self.mock.get_exit_info_for_pid.return_value = util.ProcessExitInfo( + reason=4, reason_name='ANR', subreason=0, subreason_name='', status=0) + self.assertTrue(util.activity_crashed_by_package('com.example.app')) + self.mock.get_latest_pid_for_package.assert_called_once_with( + 'com.example.app') + self.mock.get_exit_info_for_pid.assert_called_once_with( + 'com.example.app', 1234) + + def test_no_pid(self): + """Checks that activity_crashed_by_package returns False when no PID is found for package.""" + self.mock.get_latest_pid_for_package.return_value = None + self.assertFalse(util.activity_crashed_by_package('com.example.app')) + + def test_no_exit_info(self): + """Checks that activity_crashed_by_package returns False when PID is found but get_exit_info_for_pid returns None.""" + self.mock.get_latest_pid_for_package.return_value = 1234 + self.mock.get_exit_info_for_pid.return_value = None + self.assertFalse(util.activity_crashed_by_package('com.example.app')) + + def test_empty_package(self): + """Checks that activity_crashed_by_package returns False when app_package is empty.""" + self.assertFalse(util.activity_crashed_by_package('')) diff --git a/src/clusterfuzz/_internal/tests/core/system/process_handler_test.py b/src/clusterfuzz/_internal/tests/core/system/process_handler_test.py index a4712cb75ca..7feb8bf9a1d 100644 --- a/src/clusterfuzz/_internal/tests/core/system/process_handler_test.py +++ b/src/clusterfuzz/_internal/tests/core/system/process_handler_test.py @@ -16,6 +16,8 @@ import unittest from unittest import mock +from clusterfuzz._internal.platforms.android import constants +from clusterfuzz._internal.platforms.android import util from clusterfuzz._internal.system import process_handler from clusterfuzz._internal.tests.test_libs import helpers as test_helpers @@ -149,3 +151,58 @@ def test_process_1_no_terminate_with_wrong_case(self): def test_process_1_no_kill_with_wrong_case(self): process_handler.terminate_processes_matching_cmd_line('/a/b/C', kill=True) self.assertEqual(0, self.mock.terminate_process.call_count) + + +class RunProcessAndroidTest(unittest.TestCase): + """Tests run_process on Android platform.""" + + def setUp(self): + test_helpers.patch_environ(self) + test_helpers.patch(self, [ + 'clusterfuzz._internal.system.environment.platform', + 'clusterfuzz._internal.platforms.android.logger.clear_log', + 'clusterfuzz._internal.platforms.android.logger.log_output', + 'clusterfuzz._internal.platforms.android.adb.time_since_last_reboot', + 'clusterfuzz._internal.platforms.android.adb.run_command', + 'clusterfuzz._internal.platforms.android.adb.get_ps_output', + 'clusterfuzz._internal.platforms.android.app.get_package_name', + 'clusterfuzz._internal.platforms.android.app.stop', + 'clusterfuzz._internal.platforms.android.util.get_latest_pid_for_package', + 'clusterfuzz._internal.platforms.android.util.get_exit_info_for_pid', + 'clusterfuzz._internal.platforms.android.util.activity_crashed', + 'time.sleep', + ]) + + self.mock.platform.return_value = 'ANDROID' + self.mock.get_package_name.return_value = 'com.example.app' + self.mock.time_since_last_reboot.return_value = 100.0 + self.mock.log_output.return_value = '' + self.mock.run_command.return_value = '' + self.mock.get_ps_output.return_value = '' + + def test_run_process_android_activity_crashed(self): + """Checks that run_process sets return_code from exit_info.reason and logs warning when an Android activity crash is detected.""" + exit_info = util.ProcessExitInfo( + reason=constants.ExitReason.CRASH_NATIVE, + reason_name='APP CRASH(NATIVE)', + subreason=0, + subreason_name='', + status=constants.ExitStatus.SIGSEGV) + self.mock.get_latest_pid_for_package.return_value = 1234 + self.mock.get_exit_info_for_pid.return_value = exit_info + self.mock.activity_crashed.return_value = True + + return_code, _, _ = process_handler.run_process( + 'am start -n com.example.app/.MainActivity') + self.mock.activity_crashed.assert_called_once_with(exit_info) + self.assertEqual(return_code, 5) + + def test_run_process_android_no_crash(self): + """Checks that run_process returns 0 return_code when Android activity has not crashed.""" + self.mock.get_latest_pid_for_package.return_value = 1234 + self.mock.get_exit_info_for_pid.return_value = None + self.mock.activity_crashed.return_value = False + + return_code, _, _ = process_handler.run_process( + 'am start -n com.example.app/.MainActivity') + self.assertEqual(return_code, 0)