diff --git a/src/clusterfuzz/_internal/platforms/android/adb.py b/src/clusterfuzz/_internal/platforms/android/adb.py index 7d3c712206..3857f9fbd9 100755 --- a/src/clusterfuzz/_internal/platforms/android/adb.py +++ b/src/clusterfuzz/_internal/platforms/android/adb.py @@ -915,3 +915,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/logger.py b/src/clusterfuzz/_internal/platforms/android/logger.py index add0d7f0ef..95224c4c6b 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/util.py b/src/clusterfuzz/_internal/platforms/android/util.py index 6b9c7f5404..565615e12c 100644 --- a/src/clusterfuzz/_internal/platforms/android/util.py +++ b/src/clusterfuzz/_internal/platforms/android/util.py @@ -15,11 +15,26 @@ 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: @@ -36,6 +51,68 @@ class ProcessExitInfo: 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): + 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.""" root_directory = environment.get_root_directory() @@ -106,3 +183,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/tests/core/platforms/android/util_test.py b/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py new file mode 100644 index 0000000000..8cc7b379bd --- /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(''))