From e29b662be96702c2d6610aa7fd1307e99a05da3b Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Thu, 3 Sep 2026 20:56:30 +0000 Subject: [PATCH 01/25] [Android] Add exit code constants and ProcessExitInfo data model --- .../_internal/platforms/android/constants.py | 30 +++++++++++++++++++ .../_internal/platforms/android/util.py | 12 ++++++++ 2 files changed, 42 insertions(+) diff --git a/src/clusterfuzz/_internal/platforms/android/constants.py b/src/clusterfuzz/_internal/platforms/android/constants.py index a797b3a5a0e..8b1b4546302 100644 --- a/src/clusterfuzz/_internal/platforms/android/constants.py +++ b/src/clusterfuzz/_internal/platforms/android/constants.py @@ -86,3 +86,33 @@ # 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: + """Android process exit reasons from ApplicationExitInfo.""" + + 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 + + +class ExitStatus: + """Process exit signal statuses.""" + + SIGILL = 4 + SIGABRT = 6 + SIGKILL = 9 + SIGSEGV = 11 diff --git a/src/clusterfuzz/_internal/platforms/android/util.py b/src/clusterfuzz/_internal/platforms/android/util.py index 3cab131b78a..27f93023679 100644 --- a/src/clusterfuzz/_internal/platforms/android/util.py +++ b/src/clusterfuzz/_internal/platforms/android/util.py @@ -13,6 +13,7 @@ # limitations under the License. """Utility functions for Android device.""" +from dataclasses import dataclass import os from clusterfuzz._internal.metrics import logs @@ -20,6 +21,17 @@ from clusterfuzz._internal.system import environment +@dataclass(frozen=True) +class ProcessExitInfo: + """DTO representing process exit metadata from dumpsys activity exit-info.""" + + reason: int + reason_name: str # e.g., 'APP CRASH(NATIVE)', 'SIGNALED' + subreason: int + subreason_name: str # e.g., 'UNKNOWN', 'ISOLATED NOT NEEDED' + status: int + + def get_device_path(local_path): """Returns device path for the given local path.""" root_directory = environment.get_root_directory() From 05336829feb876fe7c6f2185e38577db1bcdc1e1 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Thu, 3 Sep 2026 22:38:57 +0000 Subject: [PATCH 02/25] [Android] Adds reference to docs --- src/clusterfuzz/_internal/platforms/android/constants.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/clusterfuzz/_internal/platforms/android/constants.py b/src/clusterfuzz/_internal/platforms/android/constants.py index 8b1b4546302..97cdd551f2e 100644 --- a/src/clusterfuzz/_internal/platforms/android/constants.py +++ b/src/clusterfuzz/_internal/platforms/android/constants.py @@ -89,7 +89,9 @@ class ExitReason: - """Android process exit reasons from ApplicationExitInfo.""" + """Android process exit reasons from ApplicationExitInfo. Taken from + https://developer.android.com/reference/android/app/ApplicationExitInfo + """ EXIT_SELF = 1 SIGNALED = 2 @@ -110,7 +112,9 @@ class ExitReason: class ExitStatus: - """Process exit signal statuses.""" + """Process exit signal statuses. Taken from + https://developer.android.com/reference/android/app/ApplicationExitInfo + """ SIGILL = 4 SIGABRT = 6 From 69565547218b9bcb599fb6eea696c60a6e191c40 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Fri, 4 Sep 2026 17:13:06 +0000 Subject: [PATCH 03/25] [Android] Adds missing reasons & adds link to android docs --- src/clusterfuzz/_internal/platforms/android/constants.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/clusterfuzz/_internal/platforms/android/constants.py b/src/clusterfuzz/_internal/platforms/android/constants.py index 97cdd551f2e..3db69e40abf 100644 --- a/src/clusterfuzz/_internal/platforms/android/constants.py +++ b/src/clusterfuzz/_internal/platforms/android/constants.py @@ -109,11 +109,13 @@ class ExitReason: FREEZER = 14 PACKAGE_STATE_CHANGE = 15 PACKAGE_UPDATED = 16 + REASON_MEMORY_LIMITER = 17 + REASON_ANOMALY = 18 class ExitStatus: - """Process exit signal statuses. Taken from - https://developer.android.com/reference/android/app/ApplicationExitInfo + """Process exit signal statuses. Taken from: + https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/core/java/android/os/Process.java?q=content:SIGNAL_KILL """ SIGILL = 4 From fa1ec04537557dd9bf326fa7f67fb3630ba23fe7 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Fri, 4 Sep 2026 18:01:06 +0000 Subject: [PATCH 04/25] [Android] Adds more docs links! --- src/clusterfuzz/_internal/platforms/android/util.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/clusterfuzz/_internal/platforms/android/util.py b/src/clusterfuzz/_internal/platforms/android/util.py index 27f93023679..8a111aed1d5 100644 --- a/src/clusterfuzz/_internal/platforms/android/util.py +++ b/src/clusterfuzz/_internal/platforms/android/util.py @@ -23,7 +23,11 @@ @dataclass(frozen=True) class ProcessExitInfo: - """DTO representing process exit metadata from dumpsys activity exit-info.""" + """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: int reason_name: str # e.g., 'APP CRASH(NATIVE)', 'SIGNALED' From bf4c9a84ca80e04b82b93c0dbec3e60cc4b188a3 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Fri, 4 Sep 2026 22:12:38 +0000 Subject: [PATCH 05/25] [Android] Update enums to be used inside our DTO --- src/clusterfuzz/_internal/platforms/android/constants.py | 5 +++-- src/clusterfuzz/_internal/platforms/android/util.py | 8 ++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/clusterfuzz/_internal/platforms/android/constants.py b/src/clusterfuzz/_internal/platforms/android/constants.py index 3db69e40abf..7f73ecfc9e8 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' @@ -88,7 +89,7 @@ DEVICES_WITH_NO_FALLBACK_QUEUE_LIST = ['ANDROID:PIXEL6'] -class ExitReason: +class ExitReason(IntEnum): """Android process exit reasons from ApplicationExitInfo. Taken from https://developer.android.com/reference/android/app/ApplicationExitInfo """ @@ -113,7 +114,7 @@ class ExitReason: REASON_ANOMALY = 18 -class ExitStatus: +class ExitStatus(IntEnum): """Process exit signal statuses. Taken from: https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/core/java/android/os/Process.java?q=content:SIGNAL_KILL """ diff --git a/src/clusterfuzz/_internal/platforms/android/util.py b/src/clusterfuzz/_internal/platforms/android/util.py index 8a111aed1d5..6b9c7f5404a 100644 --- a/src/clusterfuzz/_internal/platforms/android/util.py +++ b/src/clusterfuzz/_internal/platforms/android/util.py @@ -29,11 +29,11 @@ class ProcessExitInfo: 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: int - reason_name: str # e.g., 'APP CRASH(NATIVE)', 'SIGNALED' + 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: int + subreason_name: str # e.g., 'UNKNOWN', 'ISOLATED_NOT_NEEDED' + status: android.constants.ExitStatus | int def get_device_path(local_path): From 28645804f3cc51c620bd934345e2c862743a2281 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Fri, 4 Sep 2026 22:27:41 +0000 Subject: [PATCH 06/25] [Android] Removes one of cs links --- src/clusterfuzz/_internal/platforms/android/constants.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/clusterfuzz/_internal/platforms/android/constants.py b/src/clusterfuzz/_internal/platforms/android/constants.py index 7f73ecfc9e8..b47b03c77e3 100644 --- a/src/clusterfuzz/_internal/platforms/android/constants.py +++ b/src/clusterfuzz/_internal/platforms/android/constants.py @@ -115,10 +115,12 @@ class ExitReason(IntEnum): class ExitStatus(IntEnum): - """Process exit signal statuses. Taken from: - https://cs.android.com/android/platform/superproject/+/android-latest-release:frameworks/base/core/java/android/os/Process.java?q=content:SIGNAL_KILL + """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 From 11bb96bdb08d1650b1f4a0715599d210a4bb15c0 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Fri, 4 Sep 2026 22:29:35 +0000 Subject: [PATCH 07/25] [Android] Adds reason uknown --- src/clusterfuzz/_internal/platforms/android/constants.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/clusterfuzz/_internal/platforms/android/constants.py b/src/clusterfuzz/_internal/platforms/android/constants.py index b47b03c77e3..0ab329e98ec 100644 --- a/src/clusterfuzz/_internal/platforms/android/constants.py +++ b/src/clusterfuzz/_internal/platforms/android/constants.py @@ -94,6 +94,7 @@ class ExitReason(IntEnum): https://developer.android.com/reference/android/app/ApplicationExitInfo """ + UNKNOWN = 0 EXIT_SELF = 1 SIGNALED = 2 LOW_MEMORY = 3 From 88635147b675bf27040e6c7be4add9492694b42e Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Thu, 3 Sep 2026 20:57:59 +0000 Subject: [PATCH 08/25] [Android] Implement exit info extraction and process crash parsing --- .../_internal/platforms/android/adb.py | 15 ++ .../_internal/platforms/android/logger.py | 6 + .../_internal/platforms/android/util.py | 134 ++++++++++ .../tests/core/platforms/android/util_test.py | 252 ++++++++++++++++++ 4 files changed, 407 insertions(+) create mode 100644 src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py diff --git a/src/clusterfuzz/_internal/platforms/android/adb.py b/src/clusterfuzz/_internal/platforms/android/adb.py index 7d3c712206b..89d32b961f9 100755 --- a/src/clusterfuzz/_internal/platforms/android/adb.py +++ b/src/clusterfuzz/_internal/platforms/android/adb.py @@ -915,3 +915,18 @@ 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. + + 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 add0d7f0efc..8ae9c75f160 100644 --- a/src/clusterfuzz/_internal/platforms/android/logger.py +++ b/src/clusterfuzz/_internal/platforms/android/logger.py @@ -124,3 +124,9 @@ 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 6b9c7f5404a..75963920328 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: @@ -106,3 +121,122 @@ def can_testcase_run_on_platform(testcase_platform_id, current_platform_id): return True return False + + +# Matching: "pid=" in dumpsys activity exit-info +_PID_REGEX = r"\bpid=(\d+)" + + +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. + """ + if not app_package: + return None + + logcat_output = logger.log_activity_manager_output() + if not 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: + """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 not app_package or target_pid is None: + return None + + dumpsys_output = adb.get_activity_exit_info(app_package) + if not dumpsys_output: + return None + + current_pid = None + for line in dumpsys_output.splitlines(): + pid_match = re.search(_PID_REGEX, line) + if pid_match: + current_pid = int(pid_match.group(1)) + + if current_pid == target_pid: + reason_match = re.search(_REASON_STATUS_REGEX, line) + if reason_match: + reason, reason_name, subreason, subreason_name, status = ( + reason_match.groups()) + return ProcessExitInfo( + reason=int(reason), + reason_name=reason_name or '', + subreason=int(subreason), + subreason_name=subreason_name or '', + status=int(status), + ) + + return None + + +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: + 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: + return False + + pid = get_latest_pid_for_package(app_package) + if not pid: + return False + + 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 00000000000..13f73d38a61 --- /dev/null +++ b/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py @@ -0,0 +1,252 @@ +# 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_or_none_package(self): + """Checks that get_latest_pid_for_package returns None when app_package is empty or None.""" + self.mock.log_activity_manager_output.return_value = ( + 'I/ActivityManager( 100): Start proc 1234:com.example.app/u0a123') + self.assertIsNone(util.get_latest_pid_for_package('')) + self.assertIsNone(util.get_latest_pid_for_package(None)) + + 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')) + + +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_happy_path_parsed(self): + """Checks that get_exit_info_for_pid correctly parses dumpsys activity exit-info block for a matching target PID.""" + 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=5, + reason_name='APP_CRASH(NATIVE)', + subreason=0, + subreason_name='UNKNOWN', + status=11, + ), + ) + + def test_missing_reason_names_in_parentheses(self): + """Checks that get_exit_info_for_pid parses numerical exit info when reason and subreason names are omitted in output.""" + self.mock.get_activity_exit_info.return_value = ( + 'ApplicationExitInfo #0:\n' + ' pid=4321 uid=10001\n' + ' reason=2 subreason=0 status=9\n') + exit_info = util.get_exit_info_for_pid('com.example.app', 4321) + self.assertEqual( + exit_info, + util.ProcessExitInfo( + reason=2, + reason_name='', + subreason=0, + subreason_name='', + status=9, + ), + ) + + def test_pid_not_found(self): + """Checks that get_exit_info_for_pid returns None when the target PID is absent from dumpsys activity exit-info output.""" + self.mock.get_activity_exit_info.return_value = ( + 'ApplicationExitInfo #0:\n' + ' pid=1111 uid=10001\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.assertIsNone(exit_info) + + def test_none_pid_or_empty_package(self): + """Checks that get_exit_info_for_pid returns None when target_pid is None or app_package is empty.""" + self.assertIsNone(util.get_exit_info_for_pid('com.example.app', None)) + + def test_dumpsys_output_empty(self): + """Checks that get_exit_info_for_pid returns None when dumpsys activity exit-info returns empty string or None.""" + self.mock.get_activity_exit_info.return_value = '' + self.assertIsNone(util.get_exit_info_for_pid('com.example.app', 4321)) + + self.mock.get_activity_exit_info.return_value = None + self.assertIsNone(util.get_exit_info_for_pid('com.example.app', 4321)) + + def test_malformed_reason_line(self): + """Checks that get_exit_info_for_pid returns None when pid matches but subsequent lines do not contain valid reason metadata.""" + self.mock.get_activity_exit_info.return_value = ( + 'ApplicationExitInfo #0:\n' + ' pid=4321 uid=10001\n' + ' invalid reason info block here\n' + ' another invalid line\n') + exit_info = util.get_exit_info_for_pid('com.example.app', 4321) + self.assertIsNone(exit_info) + + +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_crash_anr(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('')) From 8fa93283264792d4fdcdf20451cb53cc7fc28a6b Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Thu, 3 Sep 2026 21:30:10 +0000 Subject: [PATCH 09/25] [Android] Simplify PID check --- .../_internal/platforms/android/adb.py | 1 - .../_internal/platforms/android/logger.py | 1 - .../_internal/platforms/android/util.py | 32 +++++++++---------- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/src/clusterfuzz/_internal/platforms/android/adb.py b/src/clusterfuzz/_internal/platforms/android/adb.py index 89d32b961f9..21ab1ba302c 100755 --- a/src/clusterfuzz/_internal/platforms/android/adb.py +++ b/src/clusterfuzz/_internal/platforms/android/adb.py @@ -929,4 +929,3 @@ def get_activity_exit_info(app_package: str) -> str: 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 8ae9c75f160..95224c4c6b4 100644 --- a/src/clusterfuzz/_internal/platforms/android/logger.py +++ b/src/clusterfuzz/_internal/platforms/android/logger.py @@ -129,4 +129,3 @@ def log_output_before_last_reboot(): 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 75963920328..f5c01ae496f 100644 --- a/src/clusterfuzz/_internal/platforms/android/util.py +++ b/src/clusterfuzz/_internal/platforms/android/util.py @@ -123,10 +123,6 @@ def can_testcase_run_on_platform(testcase_platform_id, current_platform_id): return False -# Matching: "pid=" in dumpsys activity exit-info -_PID_REGEX = r"\bpid=(\d+)" - - def get_latest_pid_for_package(app_package: str) -> int | None: """Gets the latest PID for an application package from logcat. @@ -176,22 +172,24 @@ def get_exit_info_for_pid(app_package: str, current_pid = None for line in dumpsys_output.splitlines(): - pid_match = re.search(_PID_REGEX, line) + pid_match = re.search(r"\bpid=(\d+)", line) if pid_match: current_pid = int(pid_match.group(1)) - if current_pid == target_pid: - reason_match = re.search(_REASON_STATUS_REGEX, line) - if reason_match: - reason, reason_name, subreason, subreason_name, status = ( - reason_match.groups()) - return ProcessExitInfo( - reason=int(reason), - reason_name=reason_name or '', - subreason=int(subreason), - subreason_name=subreason_name or '', - status=int(status), - ) + if current_pid is None or current_pid != target_pid: + continue + + reason_match = re.search(_REASON_STATUS_REGEX, line) + if reason_match: + reason, reason_name, subreason, subreason_name, status = ( + reason_match.groups()) + return ProcessExitInfo( + reason=int(reason), + reason_name=reason_name or '', + subreason=int(subreason), + subreason_name=subreason_name or '', + status=int(status), + ) return None From 944e6022039b9359bb6394513608d823f1036e15 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Fri, 4 Sep 2026 23:15:06 +0000 Subject: [PATCH 10/25] [Android] Checks for unknown exit info, simplifies UT & adds logs --- .../_internal/platforms/android/adb.py | 12 ++ .../_internal/platforms/android/util.py | 74 ++++++++--- .../tests/core/platforms/android/util_test.py | 116 ++++++++++++------ 3 files changed, 145 insertions(+), 57 deletions(-) diff --git a/src/clusterfuzz/_internal/platforms/android/adb.py b/src/clusterfuzz/_internal/platforms/android/adb.py index 21ab1ba302c..3857f9fbd9d 100755 --- a/src/clusterfuzz/_internal/platforms/android/adb.py +++ b/src/clusterfuzz/_internal/platforms/android/adb.py @@ -919,6 +919,18 @@ def write_data_to_file(contents, file_path, should_reboot=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. diff --git a/src/clusterfuzz/_internal/platforms/android/util.py b/src/clusterfuzz/_internal/platforms/android/util.py index f5c01ae496f..ccb3e90beff 100644 --- a/src/clusterfuzz/_internal/platforms/android/util.py +++ b/src/clusterfuzz/_internal/platforms/android/util.py @@ -151,23 +151,27 @@ def get_latest_pid_for_package(app_package: str) -> int | None: return None -def get_exit_info_for_pid(app_package: str, - target_pid: int) -> ProcessExitInfo | None: +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: - app_package: Name of the application package. + 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 app_package or target_pid is None: - return None - - dumpsys_output = adb.get_activity_exit_info(app_package) - if not dumpsys_output: + if not dumpsys_output or target_pid is None: return None current_pid = None @@ -180,20 +184,54 @@ def get_exit_info_for_pid(app_package: str, continue reason_match = re.search(_REASON_STATUS_REGEX, line) - if reason_match: - reason, reason_name, subreason, subreason_name, status = ( - reason_match.groups()) - return ProcessExitInfo( - reason=int(reason), - reason_name=reason_name or '', - subreason=int(subreason), - subreason_name=subreason_name or '', - status=int(status), - ) + 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_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 not app_package or target_pid is None: + 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. diff --git a/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py b/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py index 13f73d38a61..8ef9ab66f8f 100644 --- a/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py +++ b/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py @@ -76,81 +76,119 @@ def test_empty_logs_or_no_match(self): self.assertIsNone(util.get_latest_pid_for_package('com.example.app')) -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', - ]) +# 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 get_exit_info_for_pid correctly parses dumpsys activity exit-info block for a matching target PID.""" - self.mock.get_activity_exit_info.return_value = ( + """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.get_exit_info_for_pid('com.example.app', 4321) - self.mock.get_activity_exit_info.assert_called_once_with('com.example.app') + exit_info = util._parse_exit_info_from_dumpsys(dumpsys_output, 4321) self.assertEqual( exit_info, util.ProcessExitInfo( - reason=5, + reason=constants.ExitReason.CRASH_NATIVE, reason_name='APP_CRASH(NATIVE)', subreason=0, subreason_name='UNKNOWN', - status=11, + status=constants.ExitStatus.SIGSEGV, ), ) def test_missing_reason_names_in_parentheses(self): - """Checks that get_exit_info_for_pid parses numerical exit info when reason and subreason names are omitted in output.""" - self.mock.get_activity_exit_info.return_value = ( - 'ApplicationExitInfo #0:\n' - ' pid=4321 uid=10001\n' - ' reason=2 subreason=0 status=9\n') - exit_info = util.get_exit_info_for_pid('com.example.app', 4321) + """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=2, + reason=constants.ExitReason.SIGNALED, reason_name='', subreason=0, subreason_name='', - status=9, + status=constants.ExitStatus.SIGKILL, ), ) def test_pid_not_found(self): - """Checks that get_exit_info_for_pid returns None when the target PID is absent from dumpsys activity exit-info output.""" - self.mock.get_activity_exit_info.return_value = ( + """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.get_exit_info_for_pid('com.example.app', 4321) + exit_info = util._parse_exit_info_from_dumpsys(dumpsys_output, 4321) self.assertIsNone(exit_info) - def test_none_pid_or_empty_package(self): - """Checks that get_exit_info_for_pid returns None when target_pid is None or app_package is empty.""" - self.assertIsNone(util.get_exit_info_for_pid('com.example.app', None)) - def test_dumpsys_output_empty(self): - """Checks that get_exit_info_for_pid returns None when dumpsys activity exit-info returns empty string or None.""" - self.mock.get_activity_exit_info.return_value = '' - self.assertIsNone(util.get_exit_info_for_pid('com.example.app', 4321)) - - self.mock.get_activity_exit_info.return_value = None - self.assertIsNone(util.get_exit_info_for_pid('com.example.app', 4321)) + """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 get_exit_info_for_pid returns None when pid matches but subsequent lines do not contain valid reason metadata.""" - self.mock.get_activity_exit_info.return_value = ( + """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' - ' invalid reason info block here\n' - ' another invalid line\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.assertIsNone(exit_info) + 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_or_empty_package(self): + """Checks that get_exit_info_for_pid returns None when target_pid is None or app_package is empty.""" + self.assertIsNone(util.get_exit_info_for_pid('com.example.app', None)) + self.assertIsNone(util.get_exit_info_for_pid('', 4321)) class ActivityCrashedTest(unittest.TestCase): From 509f67699c6829c00e735e3deec2bfd599736364 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Fri, 4 Sep 2026 23:25:30 +0000 Subject: [PATCH 11/25] [Android] Removes defensive checks & adds logs --- .../_internal/platforms/android/util.py | 137 +++++++++--------- .../tests/core/platforms/android/util_test.py | 12 +- 2 files changed, 70 insertions(+), 79 deletions(-) diff --git a/src/clusterfuzz/_internal/platforms/android/util.py b/src/clusterfuzz/_internal/platforms/android/util.py index ccb3e90beff..565615e12c4 100644 --- a/src/clusterfuzz/_internal/platforms/android/util.py +++ b/src/clusterfuzz/_internal/platforms/android/util.py @@ -51,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() @@ -132,11 +194,9 @@ def get_latest_pid_for_package(app_package: str) -> int | None: Returns: PID of the package's latest process if found, None otherwise. """ - if not app_package: - return None - 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()): @@ -151,68 +211,6 @@ def get_latest_pid_for_package(app_package: str) -> int | None: return None -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_exit_info_for_pid(app_package: str, target_pid: int) -> ProcessExitInfo | None: """Fetches and parses dumpsys activity exit-info output for target_pid. @@ -225,7 +223,8 @@ def get_exit_info_for_pid(app_package: str, ProcessExitInfo object if metadata for target_pid is found and parsed, None otherwise. """ - if not app_package or target_pid is None: + 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) @@ -242,6 +241,7 @@ def activity_crashed(exit_info: ProcessExitInfo | None) -> bool: 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, @@ -260,7 +260,6 @@ def activity_crashed(exit_info: ProcessExitInfo | None) -> bool: 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. @@ -268,11 +267,11 @@ def activity_crashed_by_package(app_package: str) -> bool: 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) - if not pid: - return False 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 index 8ef9ab66f8f..0376a47b346 100644 --- a/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py +++ b/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py @@ -59,13 +59,6 @@ def test_package_prefix_not_matched(self): pid = util.get_latest_pid_for_package('com.example.app') self.assertIsNone(pid) - def test_empty_or_none_package(self): - """Checks that get_latest_pid_for_package returns None when app_package is empty or None.""" - self.mock.log_activity_manager_output.return_value = ( - 'I/ActivityManager( 100): Start proc 1234:com.example.app/u0a123') - self.assertIsNone(util.get_latest_pid_for_package('')) - self.assertIsNone(util.get_latest_pid_for_package(None)) - 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 = '' @@ -185,10 +178,9 @@ def test_fetches_and_parses(self): ), ) - def test_none_pid_or_empty_package(self): - """Checks that get_exit_info_for_pid returns None when target_pid is None or app_package is empty.""" + 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)) - self.assertIsNone(util.get_exit_info_for_pid('', 4321)) class ActivityCrashedTest(unittest.TestCase): From 756a828a6dde9dcbae9356cb0cc3ab084ea2a591 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Tue, 8 Sep 2026 17:10:54 +0000 Subject: [PATCH 12/25] [Android] Fix UT typo --- .../_internal/tests/core/platforms/android/util_test.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py b/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py index 0376a47b346..8cc7b379bdd 100644 --- a/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py +++ b/src/clusterfuzz/_internal/tests/core/platforms/android/util_test.py @@ -208,7 +208,7 @@ def test_crash_signaled(self): ) self.assertTrue(util.activity_crashed(exit_info)) - def test_crash_anr(self): + def test_regular_app_crash(self): """Checks that activity_crashed returns True for CRASH.""" exit_info = util.ProcessExitInfo( reason=constants.ExitReason.CRASH, From 71a22b21129e34828ec0524a2379fd49c6e1cafb Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Thu, 3 Sep 2026 20:58:10 +0000 Subject: [PATCH 13/25] [Android] Integrate exit code handling into process_handler --- .../_internal/system/process_handler.py | 16 +++++- .../tests/core/system/process_handler_test.py | 50 +++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) 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/system/process_handler_test.py b/src/clusterfuzz/_internal/tests/core/system/process_handler_test.py index a4712cb75ca..b98442f7296 100644 --- a/src/clusterfuzz/_internal/tests/core/system/process_handler_test.py +++ b/src/clusterfuzz/_internal/tests/core/system/process_handler_test.py @@ -149,3 +149,53 @@ 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 = mock.Mock(reason=5, status=11) + 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) From 15461cf8174bcf6354ffb6d92e6242304ebc9ccf Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Tue, 8 Sep 2026 17:23:07 +0000 Subject: [PATCH 14/25] [Android] Remove unnecesary mock --- .../_internal/tests/core/system/process_handler_test.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 b98442f7296..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 @@ -180,7 +182,12 @@ def setUp(self): 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 = mock.Mock(reason=5, status=11) + 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 From 9569d38a1bcfb1b1c4fa95de60c3379e23bf2642 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Thu, 3 Sep 2026 20:58:51 +0000 Subject: [PATCH 15/25] [Android] Refine bad build check and testcase manager execution --- .../_internal/bot/testcase_manager.py | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/src/clusterfuzz/_internal/bot/testcase_manager.py b/src/clusterfuzz/_internal/bot/testcase_manager.py index d62a9345819..a42627c8ad5 100644 --- a/src/clusterfuzz/_internal/bot/testcase_manager.py +++ b/src/clusterfuzz/_internal/bot/testcase_manager.py @@ -332,7 +332,12 @@ def run_testcase(thread_index, file_path, gestures, env_copy): app_directory = environment.get_value('APP_DIR') environment.set_value('PIDS', '[]') + logs.info( + f'Running testcase (thread {thread_index}): file_path={file_path}, ' + f'needs_http={needs_http}, gestures={gestures}') + # Get command line options. + command = get_command_line_for_application( file_path, user_profile_index=thread_index, needs_http=needs_http) @@ -1243,7 +1248,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 +1279,40 @@ 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()}') # 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']): + 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) + 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, From b5a332ff57f46ffaeb136dd340f79d9aaa9d9e10 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Fri, 4 Sep 2026 17:14:51 +0000 Subject: [PATCH 16/25] Removes duplicated code --- src/clusterfuzz/_internal/bot/testcase_manager.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/src/clusterfuzz/_internal/bot/testcase_manager.py b/src/clusterfuzz/_internal/bot/testcase_manager.py index a42627c8ad5..29fbb542f1d 100644 --- a/src/clusterfuzz/_internal/bot/testcase_manager.py +++ b/src/clusterfuzz/_internal/bot/testcase_manager.py @@ -1323,19 +1323,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() From cc738b49c5de2a46a4d0e0a24c57fa640542ae62 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Fri, 4 Sep 2026 17:53:27 +0000 Subject: [PATCH 17/25] [Android] Add comments for bad build check --- src/clusterfuzz/_internal/bot/testcase_manager.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/clusterfuzz/_internal/bot/testcase_manager.py b/src/clusterfuzz/_internal/bot/testcase_manager.py index 29fbb542f1d..17a4a977ec4 100644 --- a/src/clusterfuzz/_internal/bot/testcase_manager.py +++ b/src/clusterfuzz/_internal/bot/testcase_manager.py @@ -1296,6 +1296,8 @@ def check_for_bad_build(job_type: str, # 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. + # 3. 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 From 8ad9f8e34329d64c7ffaa50725e70c3af78a8b25 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Tue, 8 Sep 2026 17:36:22 +0000 Subject: [PATCH 18/25] [Android] Reorder comments --- src/clusterfuzz/_internal/bot/testcase_manager.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/clusterfuzz/_internal/bot/testcase_manager.py b/src/clusterfuzz/_internal/bot/testcase_manager.py index 17a4a977ec4..f99bf06339e 100644 --- a/src/clusterfuzz/_internal/bot/testcase_manager.py +++ b/src/clusterfuzz/_internal/bot/testcase_manager.py @@ -1292,12 +1292,8 @@ def check_for_bad_build(job_type: str, f'is_crash={crash_result.is_crash(ignore_state=True)}, ' f'crash_type={crash_result.get_type()}') - # 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. - # 3. On Android, if the application process is not running after startup, - # the build is bad. + # 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 @@ -1311,6 +1307,10 @@ def check_for_bad_build(job_type: str, '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. 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']): From 7f538b7131daeff1725e22a096d95da11bc3acd9 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Mon, 7 Sep 2026 18:30:58 +0000 Subject: [PATCH 19/25] [Debug logs] Enables debug level logs --- src/clusterfuzz/_internal/metrics/logs.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/clusterfuzz/_internal/metrics/logs.py b/src/clusterfuzz/_internal/metrics/logs.py index a66fa138ba4..27def4ab00a 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. @@ -768,6 +770,9 @@ def warning(message, **extras): """Logs the warning message.""" 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.""" From 349912e24d6f6c7ea61482348c3152be4a888e7a Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Mon, 7 Sep 2026 18:36:32 +0000 Subject: [PATCH 20/25] [Debug logs][test] Adds debug log to confirm that indeed its seen --- src/python/bot/startup/run_bot.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/python/bot/startup/run_bot.py b/src/python/bot/startup/run_bot.py index 236a5eda962..961db6d046d 100644 --- a/src/python/bot/startup/run_bot.py +++ b/src/python/bot/startup/run_bot.py @@ -170,6 +170,7 @@ def task_loop(): execution_count = 0 max_task_executions = _get_max_task_executions() + logs.debug('Starting task loop.', is_debug_log=True) while True: stacktrace = '' exception_occurred = False From 341e9ec19704e6e9263177d778b6ad31aedb0307 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Mon, 7 Sep 2026 20:36:48 +0000 Subject: [PATCH 21/25] Revert "[Debug logs][test] Adds debug log to confirm that indeed its seen" This reverts commit e61947c9c54b5b3478299380fa551df87080b808. --- src/python/bot/startup/run_bot.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/python/bot/startup/run_bot.py b/src/python/bot/startup/run_bot.py index 961db6d046d..236a5eda962 100644 --- a/src/python/bot/startup/run_bot.py +++ b/src/python/bot/startup/run_bot.py @@ -170,7 +170,6 @@ def task_loop(): execution_count = 0 max_task_executions = _get_max_task_executions() - logs.debug('Starting task loop.', is_debug_log=True) while True: stacktrace = '' exception_occurred = False From 2bcef34fd784591f9f1ca4efbf10956cd0579e2c Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Mon, 7 Sep 2026 21:09:40 +0000 Subject: [PATCH 22/25] [Android] Adding debug logs for ADB calls and results --- src/clusterfuzz/_internal/platforms/android/adb.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/clusterfuzz/_internal/platforms/android/adb.py b/src/clusterfuzz/_internal/platforms/android/adb.py index 3857f9fbd9d..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( From b0b82172aae51aadb3b452cff3a0f77b1c129191 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Mon, 7 Sep 2026 21:15:27 +0000 Subject: [PATCH 23/25] [Android] Test case command is now a debug level option --- src/clusterfuzz/_internal/bot/testcase_manager.py | 9 ++++----- src/clusterfuzz/_internal/metrics/logs.py | 2 ++ 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/clusterfuzz/_internal/bot/testcase_manager.py b/src/clusterfuzz/_internal/bot/testcase_manager.py index f99bf06339e..49aaafbfbb3 100644 --- a/src/clusterfuzz/_internal/bot/testcase_manager.py +++ b/src/clusterfuzz/_internal/bot/testcase_manager.py @@ -332,15 +332,14 @@ def run_testcase(thread_index, file_path, gestures, env_copy): app_directory = environment.get_value('APP_DIR') environment.set_value('PIDS', '[]') - logs.info( - f'Running testcase (thread {thread_index}): file_path={file_path}, ' - f'needs_http={needs_http}, gestures={gestures}') - # Get command line options. - 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, diff --git a/src/clusterfuzz/_internal/metrics/logs.py b/src/clusterfuzz/_internal/metrics/logs.py index 27def4ab00a..138144abea1 100644 --- a/src/clusterfuzz/_internal/metrics/logs.py +++ b/src/clusterfuzz/_internal/metrics/logs.py @@ -770,10 +770,12 @@ def warning(message, **extras): """Logs the warning message.""" 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) From 81e40db406cfb728d7dc1eaf6164f09db2ce632c Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Mon, 7 Sep 2026 21:20:34 +0000 Subject: [PATCH 24/25] [Android] Reordering log levels at fetch_artifact.py --- .../platforms/android/fetch_artifact.py | 29 +++++++++---------- .../platforms/android/symbols_downloader.py | 4 +-- 2 files changed, 16 insertions(+), 17 deletions(-) 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/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'] From e5a59a6a07ca699f527b33f61f2eea572b13e693 Mon Sep 17 00:00:00 2001 From: Ivan Barba Date: Tue, 8 Sep 2026 17:48:50 +0000 Subject: [PATCH 25/25] [Android] Adds additional logging --- src/clusterfuzz/_internal/platforms/android/util.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/clusterfuzz/_internal/platforms/android/util.py b/src/clusterfuzz/_internal/platforms/android/util.py index 565615e12c4..2c8b3e6cf14 100644 --- a/src/clusterfuzz/_internal/platforms/android/util.py +++ b/src/clusterfuzz/_internal/platforms/android/util.py @@ -56,6 +56,7 @@ def _to_enum(enum_cls, raw_value: int | str): try: return enum_cls(int(raw_value)) except (ValueError, TypeError): + logs.debug(f'[Android] Could not convert {raw_value} to {enum_cls}') return None