From fbe1d172e121129cea13d4fbc248d909443f4d52 Mon Sep 17 00:00:00 2001 From: Steve Kirkland Date: Mon, 9 Feb 2026 17:14:32 +0000 Subject: [PATCH 01/32] Maze v11 test [full ci] --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index e6450bfc2c..93073868a2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,7 @@ services: PUBLISH_PASS: maze-runner: - image: 855461928731.dkr.ecr.us-west-1.amazonaws.com/maze-runner-releases:latest-v10-cli + image: 855461928731.dkr.ecr.us-west-1.amazonaws.com/maze-runner:tms-appium-lib-core-cli environment: <<: *common-environment BITBAR_USERNAME: From 25efa108e5c7f79c0488693d8f343ac01bc313db Mon Sep 17 00:00:00 2001 From: Steve Kirkland Date: Mon, 9 Feb 2026 17:39:02 +0000 Subject: [PATCH 02/32] w3c touch --- features/steps/android_steps.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/features/steps/android_steps.rb b/features/steps/android_steps.rb index 1cbce33408..f9d39ec8e5 100644 --- a/features/steps/android_steps.rb +++ b/features/steps/android_steps.rb @@ -29,10 +29,8 @@ def execute_command(action, scenario_name = '') end def press_at(x, y) - touch_action = Appium::TouchAction.new - touch_action.press({x: x, y: y}).wait(1).release begin - touch_action.perform + Maze::Api::Appium::UiManager.new.touch_at(x, y) rescue Selenium::WebDriver::Error::ServerError # Just ignore it, the press still seems to work end @@ -125,6 +123,7 @@ def wait_for_app_state(expected_state) (1..count).each { |i| begin press_at 500, 300 + sleep 1 rescue Selenium::WebDriver::Error::ElementNotInteractableError, Selenium::WebDriver::Error::InvalidElementStateError # Ignore it end From 50d8c274d7b96ce3d76ee4098f94c928690be85a Mon Sep 17 00:00:00 2001 From: Steve Kirkland Date: Mon, 9 Feb 2026 18:38:28 +0000 Subject: [PATCH 03/32] Bump Maze Runner to v11 [full ci] --- Gemfile | 2 +- docker-compose.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Gemfile b/Gemfile index 1c02df1d61..65eafb6ef6 100644 --- a/Gemfile +++ b/Gemfile @@ -4,7 +4,7 @@ source "https://rubygems.org" #gem 'bugsnag-maze-runner', path: '../maze-runner' # Or a specific release: -gem 'bugsnag-maze-runner', '~>10.0' +gem 'bugsnag-maze-runner', '~>11.0' # Or follow master: #gem 'bugsnag-maze-runner', git: 'https://github.com/bugsnag/maze-runner' diff --git a/docker-compose.yml b/docker-compose.yml index 93073868a2..56ae1cae58 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -50,7 +50,7 @@ services: PUBLISH_PASS: maze-runner: - image: 855461928731.dkr.ecr.us-west-1.amazonaws.com/maze-runner:tms-appium-lib-core-cli + image: 855461928731.dkr.ecr.us-west-1.amazonaws.com/maze-runner-releases:latest-v11-cli environment: <<: *common-environment BITBAR_USERNAME: From 3e7a26b57de40c9116db0634f9a1dbbbe333e04f Mon Sep 17 00:00:00 2001 From: Steve Kirkland Date: Tue, 10 Feb 2026 17:26:00 +0000 Subject: [PATCH 04/32] Remove dependency on openssl 4 [full ci] --- Gemfile | 2 -- 1 file changed, 2 deletions(-) diff --git a/Gemfile b/Gemfile index 65eafb6ef6..d78cfd4bb9 100644 --- a/Gemfile +++ b/Gemfile @@ -10,5 +10,3 @@ gem 'bugsnag-maze-runner', '~>11.0' #gem 'bugsnag-maze-runner', git: 'https://github.com/bugsnag/maze-runner' gem "license_finder", "~> 7.0" - -gem "openssl", "~> 4.0" From 4bece8660276a320b9f99d3733d55b78f844e3d0 Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 29 Jan 2026 10:51:16 +0000 Subject: [PATCH 05/32] feat(oom): provide a separate error handling path for `OutOfMemoryError`s --- .../main/java/com/bugsnag/android/Client.java | 6 ++++ .../com/bugsnag/android/ExceptionHandler.java | 20 ++++++++++++ .../com/bugsnag/android/OutOfMemoryHandler.kt | 14 ++++++++ .../bugsnag/android/ThrowableExtensions.kt | 32 +++++++++++++++++++ .../android/internal/ImmutableConfig.kt | 4 +-- 5 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 bugsnag-android-core/src/main/java/com/bugsnag/android/OutOfMemoryHandler.kt diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java index 8289bd1a60..8067f64590 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java @@ -938,6 +938,12 @@ void notifyInternalWithErrorOptions(@NonNull Event event, } } + void setOutOfMemoryHandler(@Nullable OutOfMemoryHandler handler) { + if (exceptionHandler != null) { + exceptionHandler.setOutOfMemoryHandler(handler); + } + } + /** * Returns the current buffer of breadcrumbs that will be sent with captured events. This * ordered list represents the most recent breadcrumbs to be captured up to the limit diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java index e199c9011e..de095c8015 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java @@ -21,6 +21,8 @@ class ExceptionHandler implements UncaughtExceptionHandler { private final Logger logger; private boolean enabled = true; + private OutOfMemoryHandler outOfMemoryHandler = null; + ExceptionHandler(Client client, Logger logger) { this.client = client; this.logger = logger; @@ -37,6 +39,10 @@ void uninstall() { Thread.setDefaultUncaughtExceptionHandler(originalHandler); } + public void setOutOfMemoryHandler(OutOfMemoryHandler outOfMemoryHandler) { + this.outOfMemoryHandler = outOfMemoryHandler; + } + @Override public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwable) { try { @@ -44,6 +50,11 @@ public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwab return; } + if (throwable instanceof OutOfMemoryError && + tryHandleOutOfMemory((OutOfMemoryError) throwable)) { + return; + } + boolean strictModeThrowable = strictModeHandler.isStrictModeThrowable(throwable); // Notify any subscribed clients of the uncaught exception @@ -88,4 +99,13 @@ private void forwardToOriginalHandler(@NonNull Thread thread, @NonNull Throwable logger.w("Exception", throwable); } } + + private boolean tryHandleOutOfMemory(OutOfMemoryError oom) { + OutOfMemoryHandler handler = outOfMemoryHandler; + if (handler == null) { + return false; + } + + return handler.onOutOfMemory(oom); + } } diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/OutOfMemoryHandler.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/OutOfMemoryHandler.kt new file mode 100644 index 0000000000..2dad87f6a6 --- /dev/null +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/OutOfMemoryHandler.kt @@ -0,0 +1,14 @@ +package com.bugsnag.android + +internal fun interface OutOfMemoryHandler { + /** + * Called when an `OutOfMemoryError` is reported but before it is handled by Bugsnag. This + * can be used to either fully-process the `OutOfMemoryError` on a safe path, or can + * attempt to free more memory to allow the `OutOfMemoryError` error to be processed and + * reported (as a normal [Event]). + * + * @return true if the `OutOfMemoryError` was fully reported by this handler, `false` if normal + * error reporting should continue + */ + fun onOutOfMemory(oom: OutOfMemoryError): Boolean +} diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/ThrowableExtensions.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/ThrowableExtensions.kt index cf56fd51c7..49d87338d1 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/ThrowableExtensions.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/ThrowableExtensions.kt @@ -1,6 +1,11 @@ @file:JvmName("ThrowableUtils") package com.bugsnag.android +/** + * The maximum number of causes to consider when unrolling the cause chain for a Throwable. + */ +private const val MAX_CAUSE_COUNT = 100 + /** * Unroll the list of causes for this Throwable, handling any recursion that may appear within * the chain. The first element returned will be this Throwable, and the last will be the root @@ -17,3 +22,30 @@ internal fun Throwable.safeUnrollCauses(): List { return causes.toList() } + +internal inline fun Throwable.anyCauseMatches(action: (Throwable) -> Boolean): Boolean { + var current: Throwable? = this + var slow: Throwable? = this + var advanceSlow = false + var depth = 0 + + while (current != null && depth < MAX_CAUSE_COUNT) { + if (action(current)) { + return true + } + + current = current.cause + + // Floyd's cycle detection: move slow pointer every other iteration + if (advanceSlow) { + slow = slow?.cause + if (current === slow) { + return false + } + } + advanceSlow = !advanceSlow + depth++ + } + + return false +} diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt index cb147f1f15..9d062ca673 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/ImmutableConfig.kt @@ -21,10 +21,10 @@ import com.bugsnag.android.NoopLogger import com.bugsnag.android.Session import com.bugsnag.android.Telemetry import com.bugsnag.android.ThreadSendPolicy +import com.bugsnag.android.anyCauseMatches import com.bugsnag.android.errorApiHeaders import com.bugsnag.android.internal.dag.Provider import com.bugsnag.android.internal.dag.ValueProvider -import com.bugsnag.android.safeUnrollCauses import com.bugsnag.android.sessionApiHeaders import java.io.File import java.util.regex.Pattern @@ -132,7 +132,7 @@ data class ImmutableConfig( */ @VisibleForTesting internal fun shouldDiscardByErrorClass(exc: Throwable): Boolean { - return exc.safeUnrollCauses().any { throwable -> + return exc.anyCauseMatches { throwable -> val errorClass = throwable.javaClass.name shouldDiscardByErrorClass(errorClass) } From c44e51884c2567724abf0222f84c908ca7a23030 Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 29 Jan 2026 10:51:54 +0000 Subject: [PATCH 06/32] feat(oom): add basic OutOfMemory handling to the NDK module, allowing OOMs to be reported with the pre-allocated memory in the NDK --- .../main/java/com/bugsnag/android/Client.java | 18 +++++ .../com/bugsnag/android/ExceptionHandler.java | 4 + .../com/bugsnag/android/OutOfMemoryHandler.kt | 2 +- .../src/main/CMakeLists.txt | 1 + .../java/com/bugsnag/android/NdkPlugin.kt | 1 + .../com/bugsnag/android/ndk/NativeBridge.kt | 10 ++- .../src/main/jni/bugsnag_ndk.c | 58 ++++++++++++++ .../src/main/jni/event.h | 2 +- .../src/main/jni/handlers/cpp_handler.cpp | 3 + .../src/main/jni/handlers/signal_handler.c | 2 + .../src/main/jni/jni_cache.c | 16 ++++ .../src/main/jni/jni_cache.h | 10 +++ .../src/main/jni/utils/java_stacktrace.c | 80 +++++++++++++++++++ .../src/main/jni/utils/java_stacktrace.h | 17 ++++ .../main/jni/utils/serializer/event_writer.c | 35 ++++++-- .../src/main/jni/utils/string.c | 2 - .../src/main/jni/utils/string.h | 5 -- 17 files changed, 251 insertions(+), 15 deletions(-) create mode 100644 bugsnag-plugin-android-ndk/src/main/jni/utils/java_stacktrace.c create mode 100644 bugsnag-plugin-android-ndk/src/main/jni/utils/java_stacktrace.h diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java index 8067f64590..c2e962bed1 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java @@ -938,12 +938,30 @@ void notifyInternalWithErrorOptions(@NonNull Event event, } } + /** + * Override or intercept the default error handling for {@link OutOfMemoryError}s. + * + * @param handler the new handler to use (or null to revert to normal error handling for OOMs) + */ void setOutOfMemoryHandler(@Nullable OutOfMemoryHandler handler) { if (exceptionHandler != null) { exceptionHandler.setOutOfMemoryHandler(handler); } } + /** + * Return the currently defined {@link OutOfMemoryHandler} if one is being used. + * + * @return the current {@code OutOfMemoryHandler} or null + */ + @Nullable + OutOfMemoryHandler getOutOfMemoryHandler() { + if(exceptionHandler == null) { + return null; + } + return exceptionHandler.getOutOfMemoryHandler(); + } + /** * Returns the current buffer of breadcrumbs that will be sent with captured events. This * ordered list represents the most recent breadcrumbs to be captured up to the limit diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java index de095c8015..718cbfa0fd 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java @@ -43,6 +43,10 @@ public void setOutOfMemoryHandler(OutOfMemoryHandler outOfMemoryHandler) { this.outOfMemoryHandler = outOfMemoryHandler; } + public OutOfMemoryHandler getOutOfMemoryHandler() { + return outOfMemoryHandler; + } + @Override public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwable) { try { diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/OutOfMemoryHandler.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/OutOfMemoryHandler.kt index 2dad87f6a6..54f06af9d0 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/OutOfMemoryHandler.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/OutOfMemoryHandler.kt @@ -1,6 +1,6 @@ package com.bugsnag.android -internal fun interface OutOfMemoryHandler { +fun interface OutOfMemoryHandler { /** * Called when an `OutOfMemoryError` is reported but before it is handled by Bugsnag. This * can be used to either fully-process the `OutOfMemoryError` on a safe path, or can diff --git a/bugsnag-plugin-android-ndk/src/main/CMakeLists.txt b/bugsnag-plugin-android-ndk/src/main/CMakeLists.txt index acb5079306..cf0a685d8a 100644 --- a/bugsnag-plugin-android-ndk/src/main/CMakeLists.txt +++ b/bugsnag-plugin-android-ndk/src/main/CMakeLists.txt @@ -17,6 +17,7 @@ add_library( # Specifies the name of the library. jni/handlers/signal_handler.c jni/handlers/cpp_handler.cpp jni/utils/crash_info.c + jni/utils/java_stacktrace.c jni/utils/serializer/buffered_writer.c jni/utils/serializer/event_writer.c jni/utils/serializer/BSG_KSJSONCodec.c diff --git a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NdkPlugin.kt b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NdkPlugin.kt index 2882b55b8f..aa1402054d 100644 --- a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NdkPlugin.kt +++ b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NdkPlugin.kt @@ -28,6 +28,7 @@ internal class NdkPlugin : Plugin { val nativeBridge = NativeBridge(client.bgTaskService) client.addObserver(nativeBridge) client.setupNdkPlugin() + client.setOutOfMemoryHandler(nativeBridge) return nativeBridge } diff --git a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt index 3062f8357f..bd734dedba 100644 --- a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt +++ b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt @@ -3,6 +3,7 @@ package com.bugsnag.android.ndk import android.os.Build import com.bugsnag.android.BreadcrumbType import com.bugsnag.android.NativeInterface +import com.bugsnag.android.OutOfMemoryHandler import com.bugsnag.android.StateEvent import com.bugsnag.android.StateEvent.AddBreadcrumb import com.bugsnag.android.StateEvent.AddMetadata @@ -31,7 +32,7 @@ import kotlin.concurrent.withLock /** * Observes changes in the Bugsnag environment, propagating them to the native layer */ -class NativeBridge(private val bgTaskService: BackgroundTaskService) : StateObserver { +class NativeBridge(private val bgTaskService: BackgroundTaskService) : StateObserver, OutOfMemoryHandler { private val lock = ReentrantLock() private val installed = AtomicBoolean(false) @@ -73,6 +74,7 @@ class NativeBridge(private val bgTaskService: BackgroundTaskService) : StateObse } private external fun addBreadcrumb(name: String, type: Int, timestamp: String, metadata: Any) + external fun reportOutOfMemory(oom: OutOfMemoryError) external fun addMetadataString(tab: String, key: String, value: String) external fun addMetadataDouble(tab: String, key: String, value: Double) external fun addMetadataBoolean(tab: String, key: String, value: Boolean) @@ -104,6 +106,12 @@ class NativeBridge(private val bgTaskService: BackgroundTaskService) : StateObse external fun setStaticJsonData(data: String) external fun setInternalMetricsEnabled(enabled: Boolean) + override fun onOutOfMemory(oom: OutOfMemoryError): Boolean { + reportOutOfMemory(oom) + // consume the OutOfMemoryError + return true + } + override fun onStateChange(event: StateEvent) { if (isInvalidMessage(event)) return diff --git a/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c b/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c index 50ed4cc049..c5c82a3582 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c +++ b/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c @@ -15,6 +15,7 @@ #include "jni_cache.h" #include "metadata.h" #include "safejni.h" +#include "utils/java_stacktrace.h" #include "utils/serializer.h" #include "utils/string.h" @@ -957,6 +958,63 @@ static void JNI_NativeBridge_setInternalMetricsEnabled(JNIEnv *env, bsg_set_internal_metrics_enabled(enabled); } +static void JNI_NativeBridge_reportOutOfMemory(JNIEnv *env, jobject thiz, + jobject oom) { + if (bsg_global_env == NULL || !bsg_begin_handling_crash()) { + return; + } + + bsg_environment *bsg_env = bsg_global_env; + bugsnag_event *event = &bsg_env->next_event; + + // Mark as unhandled error (OOM is always unhandled) + event->unhandled = true; + event->unhandled_events++; + + // Set error class and message + bsg_error *error = &event->error; + bsg_strncpy(error->errorClass, "java.lang.OutOfMemoryError", + sizeof(error->errorClass)); + + bsg_strncpy(error->type, "android", sizeof(error->type)); + + // Extract message from the exception if available + if (bsg_jni_cache != NULL && bsg_jni_cache->initialized) { + jstring message = (jstring)bsg_safe_call_object_method( + env, oom, bsg_jni_cache->Throwable_getMessage); + if (message != NULL) { + const char *msg = bsg_safe_get_string_utf_chars(env, message); + if (msg != NULL) { + bsg_strncpy(error->errorMessage, msg, sizeof(error->errorMessage)); + bsg_safe_release_string_utf_chars(env, message, msg); + } + bsg_safe_delete_local_ref(env, message); + } + + // Extract Java stack trace + jobjectArray stack_trace = (jobjectArray)bsg_safe_call_object_method( + env, oom, bsg_jni_cache->Throwable_getStackTrace); + bsg_copy_java_stacktrace(env, stack_trace, error); + } + + // If we couldn't get Java stack trace, fall back to native unwinding + if (error->frame_count == 0) { + error->frame_count = + bsg_unwind_concurrent_stack(error->stacktrace, NULL, NULL); + } + + // Run OnError callbacks + if (!bsg_run_on_error()) { + goto cleanup; + } + + bsg_serialize_event_to_file(bsg_env); + bsg_serialize_last_run_info_to_file(bsg_env); + +cleanup: + bsg_finish_handling_crash(); +} + // These headers are included here to ensure that the JNI methods are // declared (above) as static, avoiding the need to forward declare them diff --git a/bugsnag-plugin-android-ndk/src/main/jni/event.h b/bugsnag-plugin-android-ndk/src/main/jni/event.h index 6d3a02e85a..5ca46709a5 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/event.h +++ b/bugsnag-plugin-android-ndk/src/main/jni/event.h @@ -28,7 +28,7 @@ /** * Version of the bugsnag_event struct. Serialized to report header. */ -#define BUGSNAG_EVENT_VERSION 15 +#define BUGSNAG_EVENT_VERSION 16 #ifdef __cplusplus extern "C" { diff --git a/bugsnag-plugin-android-ndk/src/main/jni/handlers/cpp_handler.cpp b/bugsnag-plugin-android-ndk/src/main/jni/handlers/cpp_handler.cpp index c02e8371bf..e4f7e46cb0 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/handlers/cpp_handler.cpp +++ b/bugsnag-plugin-android-ndk/src/main/jni/handlers/cpp_handler.cpp @@ -71,6 +71,9 @@ void bsg_handle_cpp_terminate() { sizeof(bsg_global_env->next_event.error.errorClass)); } + bsg_strncpy(bsg_global_env->next_event.error.type, "c", + sizeof(bsg_global_env->next_event.error.type)); + if (bsg_run_on_error()) { bsg_increment_unhandled_count(&bsg_global_env->next_event); bsg_serialize_event_to_file(bsg_global_env); diff --git a/bugsnag-plugin-android-ndk/src/main/jni/handlers/signal_handler.c b/bugsnag-plugin-android-ndk/src/main/jni/handlers/signal_handler.c index ca4c681b3d..3eff129b0f 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/handlers/signal_handler.c +++ b/bugsnag-plugin-android-ndk/src/main/jni/handlers/signal_handler.c @@ -294,6 +294,8 @@ void bsg_handle_signal(int signum, siginfo_t *info, } bsg_strncpy(bsg_global_env->next_event.error.errorMessage, error_message, sizeof(bsg_global_env->next_event.error.errorMessage)); + bsg_strncpy(bsg_global_env->next_event.error.type, "c", + sizeof(bsg_global_env->next_event.error.type)); break; } } diff --git a/bugsnag-plugin-android-ndk/src/main/jni/jni_cache.c b/bugsnag-plugin-android-ndk/src/main/jni/jni_cache.c index 300a9c73ca..4873b8fda3 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/jni_cache.c +++ b/bugsnag-plugin-android-ndk/src/main/jni/jni_cache.c @@ -206,6 +206,22 @@ bool bsg_jni_cache_init(JNIEnv *env) { CACHE_STATIC_METHOD(OpaqueValue, OpaqueValue_makeSafe, "makeSafe", "(Ljava/lang/Object;)Ljava/lang/Object;"); + CACHE_CLASS(Throwable, "java/lang/Throwable"); + CACHE_METHOD(Throwable, Throwable_getMessage, "getMessage", + "()Ljava/lang/String;"); + CACHE_METHOD(Throwable, Throwable_getStackTrace, "getStackTrace", + "()[Ljava/lang/StackTraceElement;"); + + CACHE_CLASS(StackTraceElement, "java/lang/StackTraceElement"); + CACHE_METHOD(StackTraceElement, StackTraceElement_getFileName, "getFileName", + "()Ljava/lang/String;"); + CACHE_METHOD(StackTraceElement, StackTraceElement_getClassName, + "getClassName", "()Ljava/lang/String;"); + CACHE_METHOD(StackTraceElement, StackTraceElement_getMethodName, + "getMethodName", "()Ljava/lang/String;"); + CACHE_METHOD(StackTraceElement, StackTraceElement_getLineNumber, + "getLineNumber", "()I"); + CACHE_ENUM_CONSTANT(ErrorType_C, "com/bugsnag/android/ErrorType", "C"); CACHE_CLASS(NativeBridge, "com/bugsnag/android/ndk/NativeBridge"); diff --git a/bugsnag-plugin-android-ndk/src/main/jni/jni_cache.h b/bugsnag-plugin-android-ndk/src/main/jni/jni_cache.h index 3528633637..85176eb38f 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/jni_cache.h +++ b/bugsnag-plugin-android-ndk/src/main/jni/jni_cache.h @@ -81,6 +81,16 @@ typedef struct { jobject ErrorType_C; + jclass Throwable; + jmethodID Throwable_getMessage; + jmethodID Throwable_getStackTrace; + + jclass StackTraceElement; + jmethodID StackTraceElement_getFileName; + jmethodID StackTraceElement_getClassName; + jmethodID StackTraceElement_getMethodName; + jmethodID StackTraceElement_getLineNumber; + jclass NativeBridge; jclass NdkPlugin; } bsg_jni_cache_t; diff --git a/bugsnag-plugin-android-ndk/src/main/jni/utils/java_stacktrace.c b/bugsnag-plugin-android-ndk/src/main/jni/utils/java_stacktrace.c new file mode 100644 index 0000000000..b5c6402d6b --- /dev/null +++ b/bugsnag-plugin-android-ndk/src/main/jni/utils/java_stacktrace.c @@ -0,0 +1,80 @@ +#include "java_stacktrace.h" +#include "jni_cache.h" +#include "safejni.h" +#include "string.h" + +void bsg_copy_java_stacktrace(JNIEnv *env, jobject stack_trace, + bsg_error *error) { + if (stack_trace == NULL) { + error->frame_count = 0; + return; + } + + jsize stack_length = bsg_safe_get_array_length(env, stack_trace); + int frame_count = + stack_length < BUGSNAG_FRAMES_MAX ? stack_length : BUGSNAG_FRAMES_MAX; + + for (int i = 0; i < frame_count; i++) { + (*env)->PushLocalFrame(env, 5); + jobject stack_element = + bsg_safe_get_object_array_element(env, stack_trace, i); + if (stack_element == NULL) { + (*env)->PopLocalFrame(env, NULL); + continue; + } + + bugsnag_stackframe *frame = &error->stacktrace[i]; + + // Get file name + jstring file_name = (jstring)bsg_safe_call_object_method( + env, stack_element, bsg_jni_cache->StackTraceElement_getFileName); + if (file_name != NULL) { + const char *class_chars = bsg_safe_get_string_utf_chars(env, file_name); + if (class_chars != NULL) { + bsg_strncpy(frame->filename, class_chars, sizeof(frame->filename)); + bsg_safe_release_string_utf_chars(env, file_name, class_chars); + } + } + + // Get class name and set copy it into frame->method + jstring class_name = (jstring)bsg_safe_call_object_method( + env, stack_element, bsg_jni_cache->StackTraceElement_getClassName); + size_t class_length = 0; + if (file_name != NULL) { + const char *class_chars = bsg_safe_get_string_utf_chars(env, class_name); + if (class_chars != NULL && *class_chars != 0) { + class_length = + bsg_strncpy(frame->method, class_chars, sizeof(frame->method)); + bsg_safe_release_string_utf_chars(env, class_name, class_chars); + } + } + + // Get method name & concat to the frame->method + jstring method_name = (jstring)bsg_safe_call_object_method( + env, stack_element, bsg_jni_cache->StackTraceElement_getMethodName); + if (method_name != NULL) { + const char *method_chars = + bsg_safe_get_string_utf_chars(env, method_name); + if (method_chars != NULL) { + if (class_length + 1 < sizeof(frame->method)) { + char *method = &frame->method[class_length]; + *method = '.'; + method++; + + size_t remaining = sizeof(frame->method) - class_length - 1; + bsg_strncpy(method, method_chars, remaining); + } + + bsg_safe_release_string_utf_chars(env, method_name, method_chars); + } + } + + // Get line number + frame->line_number = bsg_safe_call_int_method( + env, stack_element, bsg_jni_cache->StackTraceElement_getLineNumber); + + (*env)->PopLocalFrame(env, NULL); + } + + error->frame_count = frame_count; +} diff --git a/bugsnag-plugin-android-ndk/src/main/jni/utils/java_stacktrace.h b/bugsnag-plugin-android-ndk/src/main/jni/utils/java_stacktrace.h new file mode 100644 index 0000000000..1bd8d22c0f --- /dev/null +++ b/bugsnag-plugin-android-ndk/src/main/jni/utils/java_stacktrace.h @@ -0,0 +1,17 @@ +#ifndef BUGSNAG_ANDROID_JAVA_STACKTRACE_H +#define BUGSNAG_ANDROID_JAVA_STACKTRACE_H + +#include "event.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif + +void bsg_copy_java_stacktrace(JNIEnv *env, jobject stack_trace_, + bsg_error *error); + +#ifdef __cplusplus +} +#endif +#endif // BUGSNAG_ANDROID_JAVA_STACKTRACE_H diff --git a/bugsnag-plugin-android-ndk/src/main/jni/utils/serializer/event_writer.c b/bugsnag-plugin-android-ndk/src/main/jni/utils/serializer/event_writer.c index deff0a7feb..16cf0bbd02 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/utils/serializer/event_writer.c +++ b/bugsnag-plugin-android-ndk/src/main/jni/utils/serializer/event_writer.c @@ -326,6 +326,22 @@ static bool bsg_write_severity_reason(BSG_KSJSONEncodeContext *json, return false; } +static bool bsg_write_java_stackframe(BSG_KSJSONEncodeContext *json, + bugsnag_stackframe *frame) { + CHECKED(bsg_ksjsonbeginObject(json, NULL)); + { + CHECKED(JSON_LIMITED_STRING_ELEMENT("method", frame->method)); + CHECKED(JSON_LIMITED_STRING_ELEMENT("file", frame->filename)); + CHECKED( + bsg_ksjsonaddIntegerElement(json, "lineNumber", frame->line_number)); + } + CHECKED(bsg_ksjsonendContainer(json)); + + return true; +error: + return false; +} + static bool bsg_write_stackframe(BSG_KSJSONEncodeContext *json, bugsnag_stackframe *frame, bool isPC) { @@ -377,11 +393,17 @@ static bool bsg_write_stackframe(BSG_KSJSONEncodeContext *json, static bool bsg_write_stacktrace(BSG_KSJSONEncodeContext *json, bugsnag_stackframe *stacktrace, - size_t frame_count) { + size_t frame_count, bool is_native_error) { for (int findex = 0; findex < frame_count; findex++) { - if (!bsg_write_stackframe(json, &stacktrace[findex], findex == 0)) { - goto error; + if (is_native_error) { + if (!bsg_write_stackframe(json, &stacktrace[findex], findex == 0)) { + goto error; + } + } else { + if (!bsg_write_java_stackframe(json, &stacktrace[findex])) { + goto error; + } } } @@ -391,13 +413,16 @@ static bool bsg_write_stacktrace(BSG_KSJSONEncodeContext *json, } static bool bsg_write_error(BSG_KSJSONEncodeContext *json, bsg_error *error) { + bool is_native_error = + strncmp(error->type, "android", sizeof(error->type)) != 0; + CHECKED(bsg_ksjsonbeginArray(json, "exceptions")); { CHECKED(bsg_ksjsonbeginObject(json, NULL)); { CHECKED(JSON_LIMITED_STRING_ELEMENT("errorClass", error->errorClass)); CHECKED(JSON_LIMITED_STRING_ELEMENT("message", error->errorMessage)); - CHECKED(JSON_CONSTANT_ELEMENT("type", "c")); + CHECKED(JSON_LIMITED_STRING_ELEMENT("type", error->type)); const ssize_t frame_count = error->frame_count; // assuming that the initial frame is the program counter. This logic will @@ -407,7 +432,7 @@ static bool bsg_write_error(BSG_KSJSONEncodeContext *json, bsg_error *error) { if (frame_count > 0) { CHECKED(bsg_ksjsonbeginArray(json, "stacktrace")); { - if (!bsg_write_stacktrace(json, error->stacktrace, frame_count)) { + if (!bsg_write_stacktrace(json, error->stacktrace, frame_count, is_native_error)) { goto error; } } diff --git a/bugsnag-plugin-android-ndk/src/main/jni/utils/string.c b/bugsnag-plugin-android-ndk/src/main/jni/utils/string.c index 5774ebdca3..9a186984ff 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/utils/string.c +++ b/bugsnag-plugin-android-ndk/src/main/jni/utils/string.c @@ -6,8 +6,6 @@ // Anything more than this and we shouldn't even be sending or using it. const size_t STRING_MAX_LENGTH = 1024 * 1024 * 10; -void bsg_strcpy(char *dst, const char *src) { bsg_strncpy(dst, src, INT_MAX); } - size_t bsg_strlen(const char *str) { if (str == NULL) { return 0; diff --git a/bugsnag-plugin-android-ndk/src/main/jni/utils/string.h b/bugsnag-plugin-android-ndk/src/main/jni/utils/string.h index 7e79f041da..4e4c090121 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/utils/string.h +++ b/bugsnag-plugin-android-ndk/src/main/jni/utils/string.h @@ -8,11 +8,6 @@ extern "C" { #endif -/** - * Copy the contents of src to dst where src is null-terminated - */ -void bsg_strcpy(char *dst, const char *src) __asyncsafe; - /** * Return the length of a string, or 0 if the pointer is NULL. */ From 32e2e0a6e61184250f5b603f9b29e7f8aac6f9d5 Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 29 Jan 2026 16:15:11 +0000 Subject: [PATCH 07/32] feat(oom): correctly report C errors as `signal` and Android errors as `unhandledException` in the NDK event serializer --- .../src/main/jni/bugsnag_ndk.c | 11 ++++++++-- .../main/jni/utils/serializer/event_writer.c | 22 ++++++++++++++----- .../src/test/cpp/test_bsg_event.c | 4 ++-- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c b/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c index c5c82a3582..6a68284de2 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c +++ b/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c @@ -960,11 +960,17 @@ static void JNI_NativeBridge_setInternalMetricsEnabled(JNIEnv *env, static void JNI_NativeBridge_reportOutOfMemory(JNIEnv *env, jobject thiz, jobject oom) { - if (bsg_global_env == NULL || !bsg_begin_handling_crash()) { + + bsg_environment *bsg_env = request_env_write_lock(); + + // we treat an OOM as a native "crash" to prevent the signal/cpp errors from + // conflicting with the OOM use of the bsg_global_env->next_event (giving us + // exclusive access) + if (!bsg_begin_handling_crash()) { + release_env_write_lock(); return; } - bsg_environment *bsg_env = bsg_global_env; bugsnag_event *event = &bsg_env->next_event; // Mark as unhandled error (OOM is always unhandled) @@ -1013,6 +1019,7 @@ static void JNI_NativeBridge_reportOutOfMemory(JNIEnv *env, jobject thiz, cleanup: bsg_finish_handling_crash(); + release_env_write_lock(); } // These headers are included here to ensure that the JNI methods are diff --git a/bugsnag-plugin-android-ndk/src/main/jni/utils/serializer/event_writer.c b/bugsnag-plugin-android-ndk/src/main/jni/utils/serializer/event_writer.c index 16cf0bbd02..87bfb96cb8 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/utils/serializer/event_writer.c +++ b/bugsnag-plugin-android-ndk/src/main/jni/utils/serializer/event_writer.c @@ -309,16 +309,25 @@ static bool bsg_write_severity_reason(BSG_KSJSONEncodeContext *json, CHECKED(bsg_ksjsonbeginObject(json, "severityReason")); { + bsg_error *error = &event->error; + // unhandled == false always means that the state has been overridden by the // user, as this codepath is only executed for unhandled native errors CHECKED(bsg_ksjsonaddBooleanElement(json, "unhandledOverridden", !event->unhandled)); - CHECKED(JSON_CONSTANT_ELEMENT("type", "signal")); - bsg_error *error = &event->error; - CHECKED(bsg_ksjsonbeginObject(json, "attributes")); - { CHECKED(JSON_LIMITED_STRING_ELEMENT("signalType", error->errorClass)); } - CHECKED(bsg_ksjsonendContainer(json)); + if (error->type[0] == 'a' && error->type[1] == 'n' && + error->type[2] == 'd' && error->type[3] == 'r' && + error->type[4] == 'o' && error->type[5] == 'i' && + error->type[6] == 'd' && error->type[7] == 0) { + CHECKED(JSON_CONSTANT_ELEMENT("type", "unhandledException")); + } else { + CHECKED(JSON_CONSTANT_ELEMENT("type", "signal")); + + CHECKED(bsg_ksjsonbeginObject(json, "attributes")); + { CHECKED(JSON_LIMITED_STRING_ELEMENT("signalType", error->errorClass)); } + CHECKED(bsg_ksjsonendContainer(json)); + } } CHECKED(bsg_ksjsonendContainer(json)); return true; @@ -432,7 +441,8 @@ static bool bsg_write_error(BSG_KSJSONEncodeContext *json, bsg_error *error) { if (frame_count > 0) { CHECKED(bsg_ksjsonbeginArray(json, "stacktrace")); { - if (!bsg_write_stacktrace(json, error->stacktrace, frame_count, is_native_error)) { + if (!bsg_write_stacktrace(json, error->stacktrace, frame_count, + is_native_error)) { goto error; } } diff --git a/bugsnag-plugin-android-ndk/src/test/cpp/test_bsg_event.c b/bugsnag-plugin-android-ndk/src/test/cpp/test_bsg_event.c index b89a4dc635..359a027969 100644 --- a/bugsnag-plugin-android-ndk/src/test/cpp/test_bsg_event.c +++ b/bugsnag-plugin-android-ndk/src/test/cpp/test_bsg_event.c @@ -42,7 +42,7 @@ bugsnag_event *init_event() { bsg_strncpy(event->error.errorClass, "SIGSEGV", sizeof(event->error.errorClass)); bsg_strncpy(event->error.errorMessage, "Whoops!", sizeof(event->error.errorMessage)); - bsg_strncpy(event->error.type, "C", sizeof(event->error.type)); + bsg_strncpy(event->error.type, "c", sizeof(event->error.type)); event->error.frame_count = 1; bsg_strncpy(event->error.stacktrace->method, "foo()", sizeof(event->error.stacktrace->method)); @@ -307,7 +307,7 @@ TEST test_error_message(void) { TEST test_error_type(void) { bugsnag_event *event = init_event(); - ASSERT_STR_EQ("C", event->error.type); + ASSERT_STR_EQ("c", event->error.type); bugsnag_error_set_error_type(event, "C++"); ASSERT_STR_EQ("C++", bugsnag_error_get_error_type(event)); free(event); From 9c604306e79ed28cb0925b100b5d5ebe729c3ae3 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 3 Feb 2026 09:11:53 +0000 Subject: [PATCH 08/32] chore(oom): added the the OomHandler to the API dump --- bugsnag-android-core/api/bugsnag-android-core.api | 4 ++++ bugsnag-plugin-android-ndk/api/bugsnag-plugin-android-ndk.api | 4 +++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/bugsnag-android-core/api/bugsnag-android-core.api b/bugsnag-android-core/api/bugsnag-android-core.api index 6d573db093..83e5dad9ec 100644 --- a/bugsnag-android-core/api/bugsnag-android-core.api +++ b/bugsnag-android-core/api/bugsnag-android-core.api @@ -700,6 +700,10 @@ public abstract interface class com/bugsnag/android/OnSessionCallback { public abstract fun onSession (Lcom/bugsnag/android/Session;)Z } +public abstract interface class com/bugsnag/android/OutOfMemoryHandler { + public abstract fun onOutOfMemory (Ljava/lang/OutOfMemoryError;)Z +} + public abstract interface class com/bugsnag/android/Plugin { public abstract fun load (Lcom/bugsnag/android/Client;)V public abstract fun unload ()V diff --git a/bugsnag-plugin-android-ndk/api/bugsnag-plugin-android-ndk.api b/bugsnag-plugin-android-ndk/api/bugsnag-plugin-android-ndk.api index 1caa264b9f..dfbff8c983 100644 --- a/bugsnag-plugin-android-ndk/api/bugsnag-plugin-android-ndk.api +++ b/bugsnag-plugin-android-ndk/api/bugsnag-plugin-android-ndk.api @@ -3,7 +3,7 @@ public final class com/bugsnag/android/ndk/BugsnagNDK { public static final fun refreshSymbolTable ()V } -public final class com/bugsnag/android/ndk/NativeBridge : com/bugsnag/android/internal/StateObserver { +public final class com/bugsnag/android/ndk/NativeBridge : com/bugsnag/android/OutOfMemoryHandler, com/bugsnag/android/internal/StateObserver { public fun (Lcom/bugsnag/android/internal/BackgroundTaskService;)V public final fun addBreadcrumb (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)V public final fun addFeatureFlag (Ljava/lang/String;Ljava/lang/String;)V @@ -23,10 +23,12 @@ public final class com/bugsnag/android/ndk/NativeBridge : com/bugsnag/android/in public final fun install (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZIZII)V public final fun notifyAddCallback (Ljava/lang/String;)V public final fun notifyRemoveCallback (Ljava/lang/String;)V + public fun onOutOfMemory (Ljava/lang/OutOfMemoryError;)Z public fun onStateChange (Lcom/bugsnag/android/StateEvent;)V public final fun pausedSession ()V public final fun refreshSymbolTable ()V public final fun removeMetadata (Ljava/lang/String;Ljava/lang/String;)V + public final fun reportOutOfMemory (Ljava/lang/OutOfMemoryError;)V public final fun setInternalMetricsEnabled (Z)V public final fun setStaticJsonData (Ljava/lang/String;)V public final fun startedSession (Ljava/lang/String;Ljava/lang/String;II)V From 5b261d731850bdaa60db62cc5bd5c77a6bb70c26 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 3 Feb 2026 09:22:17 +0000 Subject: [PATCH 09/32] chore(oom): formatting fixes --- .../src/main/java/com/bugsnag/android/Client.java | 2 +- .../src/main/java/com/bugsnag/android/ExceptionHandler.java | 4 ++-- bugsnag-plugin-android-ndk/detekt-baseline.xml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java index c2e962bed1..41a9ef7015 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java @@ -956,7 +956,7 @@ void setOutOfMemoryHandler(@Nullable OutOfMemoryHandler handler) { */ @Nullable OutOfMemoryHandler getOutOfMemoryHandler() { - if(exceptionHandler == null) { + if (exceptionHandler == null) { return null; } return exceptionHandler.getOutOfMemoryHandler(); diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java index 718cbfa0fd..3cccb14d59 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/ExceptionHandler.java @@ -54,8 +54,8 @@ public void uncaughtException(@NonNull Thread thread, @NonNull Throwable throwab return; } - if (throwable instanceof OutOfMemoryError && - tryHandleOutOfMemory((OutOfMemoryError) throwable)) { + if (throwable instanceof OutOfMemoryError + && tryHandleOutOfMemory((OutOfMemoryError) throwable)) { return; } diff --git a/bugsnag-plugin-android-ndk/detekt-baseline.xml b/bugsnag-plugin-android-ndk/detekt-baseline.xml index af4ed869eb..7c118a712b 100644 --- a/bugsnag-plugin-android-ndk/detekt-baseline.xml +++ b/bugsnag-plugin-android-ndk/detekt-baseline.xml @@ -5,7 +5,7 @@ CyclomaticComplexMethod:NativeBridge.kt$NativeBridge$override fun onStateChange(event: StateEvent) LongParameterList:NativeBridge.kt$NativeBridge$( apiKey: String, reportingDirectory: String, lastRunInfoPath: String, eventUUID: String, consecutiveLaunchCrashes: Int, autoDetectNdkCrashes: Boolean, apiLevel: Int, is32bit: Boolean, threadSendPolicy: Int, maxBreadcrumbs: Int, ) SwallowedException:ReportDiscardScanner.kt$ReportDiscardScanner$ex: Exception - TooManyFunctions:NativeBridge.kt$NativeBridge : StateObserver + TooManyFunctions:NativeBridge.kt$NativeBridge : StateObserverOutOfMemoryHandler UseCheckOrError:ResourceUtils.kt$throw IllegalStateException("Failed to read JSON from $resourceName") From 0dd9355d44a5eca89c33c6d9fb76f48c968c2be4 Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 4 Feb 2026 15:03:26 +0000 Subject: [PATCH 10/32] refactor(oom): extracted the native OutOfMemoryHandler registration into a Plugin so that the behaviour is opt-in --- .../java/com/bugsnag/android/Bugsnag.java | 20 ++++++++++ .../main/java/com/bugsnag/android/Client.java | 7 ++-- .../android/NativeOutOfMemoryPlugin.kt | 40 +++++++++++++++++++ .../java/com/bugsnag/android/NdkPlugin.kt | 1 - 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NativeOutOfMemoryPlugin.kt diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Bugsnag.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Bugsnag.java index d09b946f0c..69b1b42c44 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Bugsnag.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Bugsnag.java @@ -498,6 +498,26 @@ public static void clearFeatureFlags() { getClient().clearFeatureFlags(); } + /** + * Override or intercept the default error handling for {@link OutOfMemoryError}s. + * + * @param handler the new handler to use (or null to revert to normal error handling for OOMs) + * @see #getOutOfMemoryHandler() + */ + public static void setOutOfMemoryHandler(@Nullable OutOfMemoryHandler handler) { + getClient().setOutOfMemoryHandler(handler); + } + + /** + * Return the currently defined {@link OutOfMemoryHandler} if one is being used. + * + * @return the current {@code OutOfMemoryHandler} or null if none is set + */ + @Nullable + public static OutOfMemoryHandler getOutOfMemoryHandler() { + return getClient().getOutOfMemoryHandler(); + } + /** * Get the current Bugsnag Client instance. */ diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java index 41a9ef7015..38da469ca9 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java @@ -942,8 +942,9 @@ void notifyInternalWithErrorOptions(@NonNull Event event, * Override or intercept the default error handling for {@link OutOfMemoryError}s. * * @param handler the new handler to use (or null to revert to normal error handling for OOMs) + * @see #getOutOfMemoryHandler() */ - void setOutOfMemoryHandler(@Nullable OutOfMemoryHandler handler) { + public void setOutOfMemoryHandler(@Nullable OutOfMemoryHandler handler) { if (exceptionHandler != null) { exceptionHandler.setOutOfMemoryHandler(handler); } @@ -952,10 +953,10 @@ void setOutOfMemoryHandler(@Nullable OutOfMemoryHandler handler) { /** * Return the currently defined {@link OutOfMemoryHandler} if one is being used. * - * @return the current {@code OutOfMemoryHandler} or null + * @return the current {@code OutOfMemoryHandler} or null if none is set */ @Nullable - OutOfMemoryHandler getOutOfMemoryHandler() { + public OutOfMemoryHandler getOutOfMemoryHandler() { if (exceptionHandler == null) { return null; } diff --git a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NativeOutOfMemoryPlugin.kt b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NativeOutOfMemoryPlugin.kt new file mode 100644 index 0000000000..fc94067259 --- /dev/null +++ b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NativeOutOfMemoryPlugin.kt @@ -0,0 +1,40 @@ +package com.bugsnag.android + +/** + * Installs an NDK based [OutOfMemoryHandler] into the client. OutOfMemoryErrors will be redirected + * to the NDK crash reporter, where they can be reported without allocating more Android Runtime + * memory. To use this plugin, add it to your Bugsnag Configuration: + * + * ```kotlin + * configuration.addPlugin(NativeOutOfMemoryPlugin()) + * Bugsnag.start(this, configuration) + * ``` + * + * This makes reporting of `OutOfMemoryError`s significantly more reliable, but comes with one + * current caveat: thread stack traces will not be captured or reported (this may change in the + * future). + * + * This plugin is useful if you are receiving `OutOfMemoryError` reports that do not include their + * stack traces, as these are typically a result of error reporters attempting to allocate the + * error report itself. This plugin side-steps the problem by using pre-allocated native + * memory to capture and store the `OutOfMemoryError` for delivery on restart. + */ +class NativeOutOfMemoryPlugin : Plugin { + private var previousOomHandler: OutOfMemoryHandler? = null + private var client: Client? = null + + override fun load(client: Client) { + val ndkPlugin = client.getPlugin(NdkPlugin::class.java) as? NdkPlugin ?: return + val bridge = ndkPlugin.nativeBridge ?: return + + this.client = client + this.previousOomHandler = client.outOfMemoryHandler + client.outOfMemoryHandler = bridge + } + + override fun unload() { + client?.outOfMemoryHandler = previousOomHandler + previousOomHandler = null + client = null + } +} diff --git a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NdkPlugin.kt b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NdkPlugin.kt index aa1402054d..2882b55b8f 100644 --- a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NdkPlugin.kt +++ b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NdkPlugin.kt @@ -28,7 +28,6 @@ internal class NdkPlugin : Plugin { val nativeBridge = NativeBridge(client.bgTaskService) client.addObserver(nativeBridge) client.setupNdkPlugin() - client.setOutOfMemoryHandler(nativeBridge) return nativeBridge } From 39ff732849a0113f0c59fb53df9cf1663d6f919f Mon Sep 17 00:00:00 2001 From: jason Date: Wed, 4 Feb 2026 15:37:15 +0000 Subject: [PATCH 11/32] test(Throwable.anyCauseMatches): added unit tests for Throwable.anyCauseMatches --- .../bugsnag/android/ThrowableExtensions.kt | 2 +- .../android/ThrowableCauseMatchingTest.kt | 49 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 bugsnag-android-core/src/test/java/com/bugsnag/android/ThrowableCauseMatchingTest.kt diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/ThrowableExtensions.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/ThrowableExtensions.kt index 49d87338d1..3e527366a8 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/ThrowableExtensions.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/ThrowableExtensions.kt @@ -36,7 +36,7 @@ internal inline fun Throwable.anyCauseMatches(action: (Throwable) -> Boolean): B current = current.cause - // Floyd's cycle detection: move slow pointer every other iteration + // Floyd's cycle detection (no allocation required) if (advanceSlow) { slow = slow?.cause if (current === slow) { diff --git a/bugsnag-android-core/src/test/java/com/bugsnag/android/ThrowableCauseMatchingTest.kt b/bugsnag-android-core/src/test/java/com/bugsnag/android/ThrowableCauseMatchingTest.kt new file mode 100644 index 0000000000..590808e38d --- /dev/null +++ b/bugsnag-android-core/src/test/java/com/bugsnag/android/ThrowableCauseMatchingTest.kt @@ -0,0 +1,49 @@ +package com.bugsnag.android + +import android.os.RemoteException +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.IOException +import java.util.concurrent.ExecutionException + +class ThrowableCauseMatchingTest { + @Test + fun matchSingle() { + val root = RuntimeException() + assertTrue(root.anyCauseMatches { it is RuntimeException }) + assertFalse(root.anyCauseMatches { it is RemoteException }) + } + + @Test + fun simpleChain() { + val root = IOException() + val wrapper1 = RuntimeException(root) + val wrapper2 = ExecutionException(wrapper1) + + assertTrue(wrapper2.anyCauseMatches { it is IOException }) + assertTrue(wrapper2.anyCauseMatches { it is RuntimeException }) + assertTrue(wrapper2.anyCauseMatches { it is ExecutionException }) + assertFalse(wrapper2.anyCauseMatches { it is RemoteException }) + } + + @Test + fun complexRecursiveChain() { + val root = IOException("Root cause") + val wrapper1 = RuntimeException("Wrapper 1", root) + val wrapper2 = ExecutionException("Wrapper 2", wrapper1) + val wrapper3 = IllegalStateException("Wrapper 3", wrapper2) + val wrapper4 = IllegalArgumentException("Wrapper 4") + wrapper4.initCause(wrapper3) + + // Create circular reference: root points back to wrapper4 + root.initCause(wrapper4) + + assertTrue(wrapper4.anyCauseMatches { it is IOException }) + assertTrue(wrapper4.anyCauseMatches { it is RuntimeException }) + assertTrue(wrapper4.anyCauseMatches { it is ExecutionException }) + assertTrue(wrapper4.anyCauseMatches { it is IllegalStateException }) + assertTrue(wrapper4.anyCauseMatches { it is IllegalArgumentException }) + assertFalse(wrapper4.anyCauseMatches { it is RemoteException }) + } +} From 127320372a14a8952f0afb09db5d8194da8d80dc Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 5 Feb 2026 08:31:12 +0000 Subject: [PATCH 12/32] test(NativeOutOfMemoryPlugin): added mazerunner scenario for the NativeOutOfMemoryPlugin --- .../mazerunner/bugsnag-dependency.gradle | 1 + .../scenarios/NativeOOMHandlerScenario.kt | 30 +++++++++++++++++++ features/full_tests/oom.feature | 12 ++++++++ 3 files changed, 43 insertions(+) create mode 100644 features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt diff --git a/features/fixtures/mazerunner/bugsnag-dependency.gradle b/features/fixtures/mazerunner/bugsnag-dependency.gradle index fac0af607c..09a833898f 100644 --- a/features/fixtures/mazerunner/bugsnag-dependency.gradle +++ b/features/fixtures/mazerunner/bugsnag-dependency.gradle @@ -17,6 +17,7 @@ dependencies { // compile with the NDK scenarios by default project.logger.lifecycle("Compiling full mazerunner fixture with ANR/NDK scenarios") implementation "com.bugsnag:bugsnag-android:9.9.9" + implementation "com.bugsnag:bugsnag-plugin-android-ndk:9.9.9" implementation "com.bugsnag:bugsnag-plugin-android-apphang:9.9.9" implementation "com.bugsnag:bugsnag-plugin-android-okhttp:9.9.9" implementation "com.bugsnag:bugsnag-plugin-android-exitinfo:9.9.9" diff --git a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt new file mode 100644 index 0000000000..d49a37aaa6 --- /dev/null +++ b/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt @@ -0,0 +1,30 @@ +package com.bugsnag.android.mazerunner.scenarios + +import android.content.Context +import com.bugsnag.android.Configuration +import com.bugsnag.android.NativeOutOfMemoryPlugin +import java.nio.ByteBuffer +import kotlin.random.Random + +private const val MB = 1024 * 1024 + +class NativeOOMHandlerScenario( + config: Configuration, + context: Context, + eventMetadata: String? +) : Scenario(config, context, eventMetadata) { + init { + config.addPlugin(NativeOutOfMemoryPlugin()) + } + + override fun startScenario() { + super.startScenario() + + val buffers = mutableListOf() + while (true) { + val newBuffer = ByteBuffer.allocate(MB) + Random.nextBytes(newBuffer.array()) + buffers.add(newBuffer) + } + } +} diff --git a/features/full_tests/oom.feature b/features/full_tests/oom.feature index ee89168e27..306a6c3e01 100644 --- a/features/full_tests/oom.feature +++ b/features/full_tests/oom.feature @@ -11,3 +11,15 @@ Feature: Reporting OOMs And the error payload field "notifier.name" equals "Android Bugsnag Notifier" And the error payload field "events" is an array with 1 elements And the exception "errorClass" equals "java.lang.OutOfMemoryError" + + Scenario: NativeOutOfMemoryPlugin captures OutOfMemoryError + When I run "NativeOOMHandlerScenario" and relaunch the crashed app + And I configure Bugsnag for "NativeOOMHandlerScenario" + Then I wait to receive an error + And the error is valid for the error reporting API version "4.0" for the "Android Bugsnag Notifier" notifier + And the error payload field "notifier.name" equals "Android Bugsnag Notifier" + And the error payload field "events" is an array with 1 elements + And the exception "errorClass" equals "java.lang.OutOfMemoryError" + # Currently this is the easiest way to know the event came from the NativeOutOfMemoryPlugin + # once thread stacktraces are also captured the scenario will need another way to check + And the error payload field "events.0.threads" is an array with 0 elements \ No newline at end of file From 6574e4fe8e961feaec06e359dff1d6c7df92aff2 Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 5 Feb 2026 12:08:52 +0000 Subject: [PATCH 13/32] fix(oom): moved the OutOfMemoryHandler implementation for NativeOutOfMemoryPlugin to the plugin instead of NativeBridge --- .../api/bugsnag-android-core.api | 4 ++++ .../api/bugsnag-plugin-android-ndk.api | 10 ++++++++-- bugsnag-plugin-android-ndk/detekt-baseline.xml | 2 +- .../bugsnag/android/NativeOutOfMemoryPlugin.kt | 16 +++++++++++----- .../java/com/bugsnag/android/ndk/NativeBridge.kt | 9 +-------- 5 files changed, 25 insertions(+), 16 deletions(-) diff --git a/bugsnag-android-core/api/bugsnag-android-core.api b/bugsnag-android-core/api/bugsnag-android-core.api index 83e5dad9ec..ec8014b094 100644 --- a/bugsnag-android-core/api/bugsnag-android-core.api +++ b/bugsnag-android-core/api/bugsnag-android-core.api @@ -88,6 +88,7 @@ public final class com/bugsnag/android/Bugsnag { public static fun getLastRunInfo ()Lcom/bugsnag/android/LastRunInfo; public static fun getMetadata (Ljava/lang/String;)Ljava/util/Map; public static fun getMetadata (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Object; + public static fun getOutOfMemoryHandler ()Lcom/bugsnag/android/OutOfMemoryHandler; public static fun getUser ()Lcom/bugsnag/android/User; public static fun isStarted ()Z public static fun leaveBreadcrumb (Ljava/lang/String;)V @@ -103,6 +104,7 @@ public final class com/bugsnag/android/Bugsnag { public static fun resumeSession ()Z public static fun setContext (Ljava/lang/String;)V public static fun setGroupingDiscriminator (Ljava/lang/String;)Ljava/lang/String; + public static fun setOutOfMemoryHandler (Lcom/bugsnag/android/OutOfMemoryHandler;)V public static fun setUser (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public static fun start (Landroid/content/Context;)Lcom/bugsnag/android/Client; public static fun start (Landroid/content/Context;Lcom/bugsnag/android/Configuration;)Lcom/bugsnag/android/Client; @@ -147,6 +149,7 @@ public class com/bugsnag/android/Client : com/bugsnag/android/CallbackAware, com public fun getLastRunInfo ()Lcom/bugsnag/android/LastRunInfo; public fun getMetadata (Ljava/lang/String;)Ljava/util/Map; public fun getMetadata (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/Object; + public fun getOutOfMemoryHandler ()Lcom/bugsnag/android/OutOfMemoryHandler; public fun getUser ()Lcom/bugsnag/android/User; public fun leaveBreadcrumb (Ljava/lang/String;)V public fun leaveBreadcrumb (Ljava/lang/String;Ljava/util/Map;Lcom/bugsnag/android/BreadcrumbType;)V @@ -161,6 +164,7 @@ public class com/bugsnag/android/Client : com/bugsnag/android/CallbackAware, com public fun resumeSession ()Z public fun setContext (Ljava/lang/String;)V public fun setGroupingDiscriminator (Ljava/lang/String;)Ljava/lang/String; + public fun setOutOfMemoryHandler (Lcom/bugsnag/android/OutOfMemoryHandler;)V public fun setUser (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V public fun startSession ()V } diff --git a/bugsnag-plugin-android-ndk/api/bugsnag-plugin-android-ndk.api b/bugsnag-plugin-android-ndk/api/bugsnag-plugin-android-ndk.api index dfbff8c983..3081c7a8af 100644 --- a/bugsnag-plugin-android-ndk/api/bugsnag-plugin-android-ndk.api +++ b/bugsnag-plugin-android-ndk/api/bugsnag-plugin-android-ndk.api @@ -1,9 +1,16 @@ +public final class com/bugsnag/android/NativeOutOfMemoryPlugin : com/bugsnag/android/OutOfMemoryHandler, com/bugsnag/android/Plugin { + public fun ()V + public fun load (Lcom/bugsnag/android/Client;)V + public fun onOutOfMemory (Ljava/lang/OutOfMemoryError;)Z + public fun unload ()V +} + public final class com/bugsnag/android/ndk/BugsnagNDK { public static final field INSTANCE Lcom/bugsnag/android/ndk/BugsnagNDK; public static final fun refreshSymbolTable ()V } -public final class com/bugsnag/android/ndk/NativeBridge : com/bugsnag/android/OutOfMemoryHandler, com/bugsnag/android/internal/StateObserver { +public final class com/bugsnag/android/ndk/NativeBridge : com/bugsnag/android/internal/StateObserver { public fun (Lcom/bugsnag/android/internal/BackgroundTaskService;)V public final fun addBreadcrumb (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/Object;)V public final fun addFeatureFlag (Ljava/lang/String;Ljava/lang/String;)V @@ -23,7 +30,6 @@ public final class com/bugsnag/android/ndk/NativeBridge : com/bugsnag/android/Ou public final fun install (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;IZIZII)V public final fun notifyAddCallback (Ljava/lang/String;)V public final fun notifyRemoveCallback (Ljava/lang/String;)V - public fun onOutOfMemory (Ljava/lang/OutOfMemoryError;)Z public fun onStateChange (Lcom/bugsnag/android/StateEvent;)V public final fun pausedSession ()V public final fun refreshSymbolTable ()V diff --git a/bugsnag-plugin-android-ndk/detekt-baseline.xml b/bugsnag-plugin-android-ndk/detekt-baseline.xml index 7c118a712b..af4ed869eb 100644 --- a/bugsnag-plugin-android-ndk/detekt-baseline.xml +++ b/bugsnag-plugin-android-ndk/detekt-baseline.xml @@ -5,7 +5,7 @@ CyclomaticComplexMethod:NativeBridge.kt$NativeBridge$override fun onStateChange(event: StateEvent) LongParameterList:NativeBridge.kt$NativeBridge$( apiKey: String, reportingDirectory: String, lastRunInfoPath: String, eventUUID: String, consecutiveLaunchCrashes: Int, autoDetectNdkCrashes: Boolean, apiLevel: Int, is32bit: Boolean, threadSendPolicy: Int, maxBreadcrumbs: Int, ) SwallowedException:ReportDiscardScanner.kt$ReportDiscardScanner$ex: Exception - TooManyFunctions:NativeBridge.kt$NativeBridge : StateObserverOutOfMemoryHandler + TooManyFunctions:NativeBridge.kt$NativeBridge : StateObserver UseCheckOrError:ResourceUtils.kt$throw IllegalStateException("Failed to read JSON from $resourceName") diff --git a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NativeOutOfMemoryPlugin.kt b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NativeOutOfMemoryPlugin.kt index fc94067259..cde610c074 100644 --- a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NativeOutOfMemoryPlugin.kt +++ b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/NativeOutOfMemoryPlugin.kt @@ -19,17 +19,17 @@ package com.bugsnag.android * error report itself. This plugin side-steps the problem by using pre-allocated native * memory to capture and store the `OutOfMemoryError` for delivery on restart. */ -class NativeOutOfMemoryPlugin : Plugin { +class NativeOutOfMemoryPlugin : Plugin, OutOfMemoryHandler { private var previousOomHandler: OutOfMemoryHandler? = null private var client: Client? = null + private var ndkPlugin: NdkPlugin? = null override fun load(client: Client) { - val ndkPlugin = client.getPlugin(NdkPlugin::class.java) as? NdkPlugin ?: return - val bridge = ndkPlugin.nativeBridge ?: return - this.client = client + ndkPlugin = client.getPlugin(NdkPlugin::class.java) as? NdkPlugin + this.previousOomHandler = client.outOfMemoryHandler - client.outOfMemoryHandler = bridge + client.outOfMemoryHandler = this } override fun unload() { @@ -37,4 +37,10 @@ class NativeOutOfMemoryPlugin : Plugin { previousOomHandler = null client = null } + + override fun onOutOfMemory(oom: OutOfMemoryError): Boolean { + ndkPlugin?.nativeBridge?.reportOutOfMemory(oom) + // consume the OutOfMemoryError + return true + } } diff --git a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt index bd734dedba..bd8f5a43f3 100644 --- a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt +++ b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt @@ -3,7 +3,6 @@ package com.bugsnag.android.ndk import android.os.Build import com.bugsnag.android.BreadcrumbType import com.bugsnag.android.NativeInterface -import com.bugsnag.android.OutOfMemoryHandler import com.bugsnag.android.StateEvent import com.bugsnag.android.StateEvent.AddBreadcrumb import com.bugsnag.android.StateEvent.AddMetadata @@ -32,7 +31,7 @@ import kotlin.concurrent.withLock /** * Observes changes in the Bugsnag environment, propagating them to the native layer */ -class NativeBridge(private val bgTaskService: BackgroundTaskService) : StateObserver, OutOfMemoryHandler { +class NativeBridge(private val bgTaskService: BackgroundTaskService) : StateObserver { private val lock = ReentrantLock() private val installed = AtomicBoolean(false) @@ -106,12 +105,6 @@ class NativeBridge(private val bgTaskService: BackgroundTaskService) : StateObse external fun setStaticJsonData(data: String) external fun setInternalMetricsEnabled(enabled: Boolean) - override fun onOutOfMemory(oom: OutOfMemoryError): Boolean { - reportOutOfMemory(oom) - // consume the OutOfMemoryError - return true - } - override fun onStateChange(event: StateEvent) { if (isInvalidMessage(event)) return From 2cb25ca70a6b4f1165ed9068ea97627f77348bbd Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 5 Feb 2026 14:46:44 +0000 Subject: [PATCH 14/32] test(NativeOutOfMemoryPlugin): moved NativeOOMHandlerScenario to the cxx-bugsnag scenarios to avoid dependency issues --- .../android/mazerunner/scenarios/NativeOOMHandlerScenario.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) rename features/fixtures/mazerunner/{jvm-scenarios => cxx-scenarios-bugsnag}/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt (80%) diff --git a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt b/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt similarity index 80% rename from features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt rename to features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt index d49a37aaa6..7a4e2421e3 100644 --- a/features/fixtures/mazerunner/jvm-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt +++ b/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt @@ -3,10 +3,12 @@ package com.bugsnag.android.mazerunner.scenarios import android.content.Context import com.bugsnag.android.Configuration import com.bugsnag.android.NativeOutOfMemoryPlugin +import com.bugsnag.android.mazerunner.log import java.nio.ByteBuffer import kotlin.random.Random private const val MB = 1024 * 1024 +private const val MB10 = 10 * MB class NativeOOMHandlerScenario( config: Configuration, @@ -22,7 +24,8 @@ class NativeOOMHandlerScenario( val buffers = mutableListOf() while (true) { - val newBuffer = ByteBuffer.allocate(MB) + log("Allocating 10mb of memory") + val newBuffer = ByteBuffer.allocate(MB10) Random.nextBytes(newBuffer.array()) buffers.add(newBuffer) } From cd953cca2dc5ab6029fd3be6575e133a33c0936f Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 5 Feb 2026 16:33:45 +0000 Subject: [PATCH 15/32] test(NativeOutOfMemoryPlugin): added a native OOM marker to the event in a native on_error callback --- .../cxx-scenarios-bugsnag/detekt-baseline.xml | 1 + .../src/main/cpp/cxx-scenarios-bugsnag.cpp | 11 ++++++++++- .../scenarios/NativeOOMHandlerScenario.kt | 14 +++++++++++++- features/full_tests/oom.feature | 4 +--- 4 files changed, 25 insertions(+), 5 deletions(-) diff --git a/features/fixtures/mazerunner/cxx-scenarios-bugsnag/detekt-baseline.xml b/features/fixtures/mazerunner/cxx-scenarios-bugsnag/detekt-baseline.xml index a4f1610554..a9a8771e2e 100644 --- a/features/fixtures/mazerunner/cxx-scenarios-bugsnag/detekt-baseline.xml +++ b/features/fixtures/mazerunner/cxx-scenarios-bugsnag/detekt-baseline.xml @@ -11,6 +11,7 @@ MagicNumber:HandledExceptionWithUsageScenario.kt$HandledExceptionWithUsageScenario$10 MagicNumber:MetadataStringsTooLargeScenario.kt$MetadataStringsTooLargeScenario$3 MagicNumber:MetadataStringsTooLargeScenario.kt$MetadataStringsTooLargeScenario$995000 + MagicNumber:NativeOOMHandlerScenario.kt$NativeOOMHandlerScenario$10 MagicNumber:UnhandledExceptionWithUsageScenario.kt$UnhandledExceptionWithUsageScenario$10 diff --git a/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/cpp/cxx-scenarios-bugsnag.cpp b/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/cpp/cxx-scenarios-bugsnag.cpp index 1e42e191a7..e6fd298e8a 100644 --- a/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/cpp/cxx-scenarios-bugsnag.cpp +++ b/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/cpp/cxx-scenarios-bugsnag.cpp @@ -411,7 +411,6 @@ int __attribute__((optnone)) get_the_null_value() { return *the_value; } -extern "C" JNIEXPORT jint JNICALL Java_com_bugsnag_android_mazerunner_scenarios_CXXRefreshSymbolTableDuringCrashScenario_activate( JNIEnv *env, jobject thiz) { @@ -419,4 +418,14 @@ Java_com_bugsnag_android_mazerunner_scenarios_CXXRefreshSymbolTableDuringCrashSc return get_the_null_value(); } +static bool mark_event_as_native_oom(void *event) { + bugsnag_event_add_metadata_bool(event, "OutOfMemory", "NativeOOM", true); + return true; +} + +JNIEXPORT void JNICALL +Java_com_bugsnag_android_mazerunner_scenarios_NativeOOMHandlerScenario_configure(JNIEnv *env, jobject thiz) { + bugsnag_add_on_error(&mark_event_as_native_oom); +} + } diff --git a/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt b/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt index 7a4e2421e3..ba97666ea3 100644 --- a/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt +++ b/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt @@ -15,19 +15,31 @@ class NativeOOMHandlerScenario( context: Context, eventMetadata: String? ) : Scenario(config, context, eventMetadata) { + + companion object { + init { + System.loadLibrary("cxx-scenarios-bugsnag") + } + } + init { config.addPlugin(NativeOutOfMemoryPlugin()) } + external fun configure() + override fun startScenario() { super.startScenario() + configure() + val buffers = mutableListOf() while (true) { - log("Allocating 10mb of memory") val newBuffer = ByteBuffer.allocate(MB10) Random.nextBytes(newBuffer.array()) buffers.add(newBuffer) + + log("Allocated 10mb of memory. Now retaining ${buffers.size * 10}mb of memory") } } } diff --git a/features/full_tests/oom.feature b/features/full_tests/oom.feature index 306a6c3e01..dea7bec072 100644 --- a/features/full_tests/oom.feature +++ b/features/full_tests/oom.feature @@ -20,6 +20,4 @@ Feature: Reporting OOMs And the error payload field "notifier.name" equals "Android Bugsnag Notifier" And the error payload field "events" is an array with 1 elements And the exception "errorClass" equals "java.lang.OutOfMemoryError" - # Currently this is the easiest way to know the event came from the NativeOutOfMemoryPlugin - # once thread stacktraces are also captured the scenario will need another way to check - And the error payload field "events.0.threads" is an array with 0 elements \ No newline at end of file + And the event "metaData.OutOfMemory.NativeOOM" is true From cdb8c49e9267ae000710515fc82191f939e39c9b Mon Sep 17 00:00:00 2001 From: jason Date: Fri, 6 Feb 2026 08:25:42 +0000 Subject: [PATCH 16/32] test(NativeOutOfMemoryPlugin): change NativeOutOfMemoryPlugin to use the same logic as OomScenario to burn memory --- .../scenarios/NativeOOMHandlerScenario.kt | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt b/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt index ba97666ea3..3ede8a0ded 100644 --- a/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt +++ b/features/fixtures/mazerunner/cxx-scenarios-bugsnag/src/main/java/com/bugsnag/android/mazerunner/scenarios/NativeOOMHandlerScenario.kt @@ -3,12 +3,7 @@ package com.bugsnag.android.mazerunner.scenarios import android.content.Context import com.bugsnag.android.Configuration import com.bugsnag.android.NativeOutOfMemoryPlugin -import com.bugsnag.android.mazerunner.log -import java.nio.ByteBuffer -import kotlin.random.Random - -private const val MB = 1024 * 1024 -private const val MB10 = 10 * MB +import java.util.LinkedList class NativeOOMHandlerScenario( config: Configuration, @@ -16,6 +11,8 @@ class NativeOOMHandlerScenario( eventMetadata: String? ) : Scenario(config, context, eventMetadata) { + private val queue = LinkedList>() + companion object { init { System.loadLibrary("cxx-scenarios-bugsnag") @@ -33,13 +30,15 @@ class NativeOOMHandlerScenario( configure() - val buffers = mutableListOf() while (true) { - val newBuffer = ByteBuffer.allocate(MB10) - Random.nextBytes(newBuffer.array()) - buffers.add(newBuffer) - - log("Allocated 10mb of memory. Now retaining ${buffers.size * 10}mb of memory") + val array = Array(Int.MAX_VALUE) { + val input = "It's Supercalifragilisticexpialidocious! \n" + + "Even though the memory allocation\n" + + "Is really quite atrocious " + String(input.toByteArray()) // ensures new object created + } + + queue.add(array) } } } From 00a75d018b1c40d11cc82917a0ea993356de9282 Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 12 Feb 2026 10:31:00 +0000 Subject: [PATCH 17/32] chore(oom): added NativeOutOfMemoryPlugin changelog --- CHANGELOG.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd8312c605..eb1b38a3d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## TBD + +### Enhancements + +* Added `NativeOutOfMemoryPlugin` as a new way to report `OutOfMemoryError`s that uses pre-allocated memory in the NDK module instead of allocating an `Event` object. When used `OutOfMemoryError`s will be more reliably reported, but will not be passed to `OnErrorCallback`s (`OnSendCallback` works as expected). + [#2384](https://github.com/bugsnag/bugsnag-android/pull/2384) + ## 6.24.0 (2026-02-11) ### Enhancements From 8a4397fca8a46961213621384795eef67857856d Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 27 Nov 2025 13:46:07 +0000 Subject: [PATCH 18/32] feat(AppHang): added `recoveryTimeMillis` to the AppHang plugin --- .../android/BugsnagAppHangPluginTest.kt | 104 ---------------- .../android/LooperMonitorThreadTest.kt | 112 ++++++++++++++++++ 2 files changed, 112 insertions(+), 104 deletions(-) delete mode 100644 bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/BugsnagAppHangPluginTest.kt create mode 100644 bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt diff --git a/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/BugsnagAppHangPluginTest.kt b/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/BugsnagAppHangPluginTest.kt deleted file mode 100644 index 6c644d4b71..0000000000 --- a/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/BugsnagAppHangPluginTest.kt +++ /dev/null @@ -1,104 +0,0 @@ -package com.bugsnag.android - -import android.os.Handler -import android.os.HandlerThread -import org.junit.After -import org.junit.Before -import org.junit.Test -import org.mockito.ArgumentMatchers.any -import org.mockito.Mockito -import org.mockito.Mockito.times -import org.mockito.Mockito.verify -import org.mockito.Mockito.verifyNoInteractions -import java.util.concurrent.CountDownLatch -import java.lang.Thread as JThread - -private const val APP_HANG_THRESHOLD = 100L - -class BugsnagAppHangPluginTest { - private lateinit var handlerThread: HandlerThread - private lateinit var plugin: BugsnagAppHangPlugin - private lateinit var client: Client - private lateinit var handler: Handler - - @Before - fun setup() { - handlerThread = HandlerThread("Test Thread") - handlerThread.start() - handler = Handler(handlerThread.looper) - - plugin = BugsnagAppHangPlugin( - AppHangConfiguration( - appHangThresholdMillis = APP_HANG_THRESHOLD, - watchedLooper = handlerThread.looper - ) - ) - - client = Mockito.mock() - plugin.load(client) - plugin.startMonitoring() - } - - @After - fun shutdown() { - plugin.unload() - handlerThread.quit() - } - - @Test - fun testIdleHandlerThread() { - JThread.sleep(APP_HANG_THRESHOLD * 5) - verifyNoInteractions(client) - } - - @Test - fun testBelowThresholdEvents() { - val countDownLatch = CountDownLatch(10) - repeat(countDownLatch.count.toInt()) { - handler.post { - JThread.sleep(APP_HANG_THRESHOLD / 2) - countDownLatch.countDown() - } - } - - verifyNoInteractions(client) - } - - @Test - fun appHang() { - val countDownLatch = CountDownLatch(1) - handler.post { - // wait long enough for 2+ AppHang triggers to happen - JThread.sleep(APP_HANG_THRESHOLD * 3) - countDownLatch.countDown() - } - - countDownLatch.await() - - // we should have reported exactly 1 AppHang - verify(client, times(1)) - .notify(any(AppHangException::class.java), any()) - } - - @Test - fun appHangRecoverHang() { - val countDownLatch = CountDownLatch(2) - - handler.post { - // wait long enough for 2+ AppHang triggers to happen - JThread.sleep(APP_HANG_THRESHOLD * 3) - countDownLatch.countDown() - - handler.postDelayed({ - JThread.sleep(APP_HANG_THRESHOLD * 3) - countDownLatch.countDown() - }, APP_HANG_THRESHOLD) - } - - countDownLatch.await() - - // we should have reported exactly 1 AppHangs - verify(client, times(2)) - .notify(any(AppHangException::class.java), any()) - } -} diff --git a/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt b/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt new file mode 100644 index 0000000000..3a6b8bbb6c --- /dev/null +++ b/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt @@ -0,0 +1,112 @@ +package com.bugsnag.android + +import android.os.Handler +import android.os.HandlerThread +import com.bugsnag.android.internal.LooperMonitorThread +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.lang.Thread as JThread + +private const val APP_HANG_THRESHOLD = 100L +private const val RECOVERY_TIME = 800L + +class LooperMonitorThreadTest { + private lateinit var handlerThread: HandlerThread + private lateinit var monitorThread: LooperMonitorThread + private lateinit var handler: Handler + + private var appHangCount = 0 + + @Before + fun setup() { + appHangCount = 0 + + handlerThread = HandlerThread("Test Thread") + handlerThread.start() + handler = Handler(handlerThread.looper) + + monitorThread = LooperMonitorThread( + watchedLooper = handlerThread.looper, + appHangThresholdMillis = APP_HANG_THRESHOLD, + recoveryTimeMillis = RECOVERY_TIME, + onAppHangDetected = { appHangCount++ } + ) + + monitorThread.startMonitoring() + } + + @After + fun shutdown() { + monitorThread.stopMonitoring() + handlerThread.quit() + } + + @Test + fun testIdleHandlerThread() { + JThread.sleep(APP_HANG_THRESHOLD * 5) + assertEquals("no AppHangs expected", 0, appHangCount) + } + + @Test + fun testBelowThresholdEvents() { + val countDownLatch = CountDownLatch(10) + val task = object : Runnable { + override fun run() { + JThread.sleep(APP_HANG_THRESHOLD / 2) + countDownLatch.countDown() + + if (countDownLatch.count > 0) { + handler.postDelayed(this, 1L) + } + } + } + handler.postDelayed(task, 1) + + countDownLatch.await() + assertEquals("no AppHangs expected", 0, appHangCount) + } + + @Test + fun appHang() { + val countDownLatch = CountDownLatch(1) + handler.postDelayed({ + // wait long enough for 2+ AppHang triggers to happen + JThread.sleep(APP_HANG_THRESHOLD * 3) + countDownLatch.countDown() + }, 1) + + countDownLatch.await() + + assertEquals("exactly 1 AppHang expected", 1, appHangCount) + } + + @Test + fun appHangRecoverHang() { + val countDownLatch = CountDownLatch(3) + + handler.postDelayed({ + JThread.sleep(APP_HANG_THRESHOLD * 2) + countDownLatch.countDown() + + handler.postDelayed({ + // this AppHang is within the recovery time, so should be ignored + JThread.sleep(APP_HANG_THRESHOLD * 2) + countDownLatch.countDown() + + handler.postDelayed({ + // outside the recovery time now, so this should be reported + JThread.sleep(APP_HANG_THRESHOLD * 2) + countDownLatch.countDown() + }, RECOVERY_TIME * 2) + }, RECOVERY_TIME / 2) + }, 1) + + countDownLatch.await() + + // we should have reported exactly 2 AppHangs + assertEquals("exactly 2 AppHangs expected", 2, appHangCount) + } +} From 220d4c44d3ed38a890c56491ce79bc13f945d363 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 1 Dec 2025 11:05:59 +0000 Subject: [PATCH 19/32] test(AppHang): increase the unit test `APP_HANG_THRESHOLD` to reduce flakes --- .../java/com/bugsnag/android/LooperMonitorThreadTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt b/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt index 3a6b8bbb6c..a0de0a4c79 100644 --- a/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt +++ b/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt @@ -10,7 +10,7 @@ import org.junit.Test import java.util.concurrent.CountDownLatch import java.lang.Thread as JThread -private const val APP_HANG_THRESHOLD = 100L +private const val APP_HANG_THRESHOLD = 200L private const val RECOVERY_TIME = 800L class LooperMonitorThreadTest { From 20e12c107c30c13a328decb699b800fdc3bbbb01 Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 12 Feb 2026 15:28:37 +0000 Subject: [PATCH 20/32] feat(AppHang): added appHangCooldownMillis to debounce app hang errors in performance constrained apps --- CHANGELOG.md | 2 + .../api/bugsnag-plugin-android-apphang.api | 6 +- .../android/LooperMonitorThreadTest.kt | 20 +-- .../bugsnag/android/SequentialAppHangsTest.kt | 136 ++++++++++++++++++ .../bugsnag/android/AppHangConfiguration.kt | 12 ++ .../bugsnag/android/BugsnagAppHangPlugin.kt | 2 + .../android/internal/LooperMonitorThread.kt | 35 ++++- 7 files changed, 196 insertions(+), 17 deletions(-) create mode 100644 bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/SequentialAppHangsTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index eb1b38a3d3..2fcd965d45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ * Added `NativeOutOfMemoryPlugin` as a new way to report `OutOfMemoryError`s that uses pre-allocated memory in the NDK module instead of allocating an `Event` object. When used `OutOfMemoryError`s will be more reliably reported, but will not be passed to `OnErrorCallback`s (`OnSendCallback` works as expected). [#2384](https://github.com/bugsnag/bugsnag-android/pull/2384) +* Added `appHangCooldownMillis` to the AppHangPlugin to control the number of AppHang errors produced when the app is performance constrained + []() ## 6.24.0 (2026-02-11) diff --git a/bugsnag-plugin-android-apphang/api/bugsnag-plugin-android-apphang.api b/bugsnag-plugin-android-apphang/api/bugsnag-plugin-android-apphang.api index c1fbc6a390..715b92e10d 100644 --- a/bugsnag-plugin-android-apphang/api/bugsnag-plugin-android-apphang.api +++ b/bugsnag-plugin-android-apphang/api/bugsnag-plugin-android-apphang.api @@ -1,11 +1,13 @@ public final class com/bugsnag/android/AppHangConfiguration { public fun ()V - public fun (JLandroid/os/Looper;Ljava/lang/Long;J)V - public synthetic fun (JLandroid/os/Looper;Ljava/lang/Long;JILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (JLandroid/os/Looper;Ljava/lang/Long;JJ)V + public synthetic fun (JLandroid/os/Looper;Ljava/lang/Long;JJILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getAppHangCooldownMillis ()J public final fun getAppHangThresholdMillis ()J public final fun getStackSamplingIntervalMillis ()J public final fun getStackSamplingThresholdMillis ()Ljava/lang/Long; public final fun getWatchedLooper ()Landroid/os/Looper; + public final fun setAppHangCooldownMillis (J)V public final fun setAppHangThresholdMillis (J)V public final fun setStackSamplingIntervalMillis (J)V public final fun setStackSamplingThresholdMillis (Ljava/lang/Long;)V diff --git a/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt b/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt index a0de0a4c79..d013d6101b 100644 --- a/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt +++ b/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/LooperMonitorThreadTest.kt @@ -11,7 +11,7 @@ import java.util.concurrent.CountDownLatch import java.lang.Thread as JThread private const val APP_HANG_THRESHOLD = 200L -private const val RECOVERY_TIME = 800L +private const val COOLDOWN_TIME = 800L class LooperMonitorThreadTest { private lateinit var handlerThread: HandlerThread @@ -31,8 +31,10 @@ class LooperMonitorThreadTest { monitorThread = LooperMonitorThread( watchedLooper = handlerThread.looper, appHangThresholdMillis = APP_HANG_THRESHOLD, - recoveryTimeMillis = RECOVERY_TIME, - onAppHangDetected = { appHangCount++ } + appHangCooldownMillis = COOLDOWN_TIME, + samplingThresholdMillis = 0, + samplingRateMillis = 0, + onAppHangDetected = { _, _ -> appHangCount++ } ) monitorThread.startMonitoring() @@ -92,21 +94,23 @@ class LooperMonitorThreadTest { countDownLatch.countDown() handler.postDelayed({ - // this AppHang is within the recovery time, so should be ignored + // This AppHang is within the cooldown period, so should be suppressed + // Starts 100ms after first ends, detected at 300ms total (well within 800ms cooldown) JThread.sleep(APP_HANG_THRESHOLD * 2) countDownLatch.countDown() handler.postDelayed({ - // outside the recovery time now, so this should be reported + // This AppHang is after the cooldown period, so should be reported + // Starts 800ms after second ends, giving enough time for cooldown to expire JThread.sleep(APP_HANG_THRESHOLD * 2) countDownLatch.countDown() - }, RECOVERY_TIME * 2) - }, RECOVERY_TIME / 2) + }, COOLDOWN_TIME + 200L) + }, 100L) }, 1) countDownLatch.await() - // we should have reported exactly 2 AppHangs + // First and third AppHangs should be reported, second suppressed by cooldown assertEquals("exactly 2 AppHangs expected", 2, appHangCount) } } diff --git a/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/SequentialAppHangsTest.kt b/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/SequentialAppHangsTest.kt new file mode 100644 index 0000000000..9cb4550898 --- /dev/null +++ b/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/SequentialAppHangsTest.kt @@ -0,0 +1,136 @@ +package com.bugsnag.android + +import android.os.Handler +import android.os.HandlerThread +import com.bugsnag.android.internal.LooperMonitorThread +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.lang.Thread as JThread + +private const val APP_HANG_THRESHOLD = 200L + +/** + * Tests for LooperMonitorThread without cooldown period configured. + * All detected AppHangs should be reported. + */ +class SequentialAppHangsTest { + private lateinit var handlerThread: HandlerThread + private lateinit var monitorThread: LooperMonitorThread + private lateinit var handler: Handler + + private var appHangCount = 0 + + @Before + fun setup() { + appHangCount = 0 + + handlerThread = HandlerThread("Test Thread") + handlerThread.start() + handler = Handler(handlerThread.looper) + + monitorThread = LooperMonitorThread( + watchedLooper = handlerThread.looper, + appHangThresholdMillis = APP_HANG_THRESHOLD, + appHangCooldownMillis = 0L, // No cooldown + samplingThresholdMillis = 0, + samplingRateMillis = 0, + onAppHangDetected = { _, _ -> appHangCount++ } + ) + + monitorThread.startMonitoring() + } + + @After + fun shutdown() { + monitorThread.stopMonitoring() + handlerThread.quit() + } + + @Test + fun testIdleHandlerThread() { + JThread.sleep(APP_HANG_THRESHOLD * 5) + assertEquals("no AppHangs expected", 0, appHangCount) + } + + @Test + fun testBelowThresholdEvents() { + val countDownLatch = CountDownLatch(10) + val task = object : Runnable { + override fun run() { + JThread.sleep(APP_HANG_THRESHOLD / 2) + countDownLatch.countDown() + + if (countDownLatch.count > 0) { + handler.postDelayed(this, 1L) + } + } + } + handler.postDelayed(task, 1) + + countDownLatch.await() + assertEquals("no AppHangs expected", 0, appHangCount) + } + + @Test + fun appHang() { + val countDownLatch = CountDownLatch(1) + handler.postDelayed({ + // wait long enough for 2+ AppHang triggers to happen + JThread.sleep(APP_HANG_THRESHOLD * 3) + countDownLatch.countDown() + }, 1) + + countDownLatch.await() + + assertEquals("exactly 1 AppHang expected", 1, appHangCount) + } + + @Test + fun appHangRecoverHang() { + val countDownLatch = CountDownLatch(2) + + handler.postDelayed({ + JThread.sleep(APP_HANG_THRESHOLD * 2) + countDownLatch.countDown() + + handler.postDelayed({ + // Without cooldown, this AppHang should also be reported + JThread.sleep(APP_HANG_THRESHOLD * 2) + countDownLatch.countDown() + }, 100L) // Small delay to ensure recovery between hangs + }, 1) + + countDownLatch.await() + + // Without cooldown, both AppHangs should be reported + assertEquals("exactly 2 AppHangs expected", 2, appHangCount) + } + + @Test + fun multipleSequentialHangs() { + val countDownLatch = CountDownLatch(3) + + handler.postDelayed({ + JThread.sleep(APP_HANG_THRESHOLD * 2) + countDownLatch.countDown() + + handler.postDelayed({ + JThread.sleep(APP_HANG_THRESHOLD * 2) + countDownLatch.countDown() + + handler.postDelayed({ + JThread.sleep(APP_HANG_THRESHOLD * 2) + countDownLatch.countDown() + }, 100L) + }, 100L) + }, 1) + + countDownLatch.await() + + // Without cooldown, all 3 AppHangs should be reported + assertEquals("exactly 3 AppHangs expected", 3, appHangCount) + } +} diff --git a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/AppHangConfiguration.kt b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/AppHangConfiguration.kt index afd398b25e..3b4198e987 100644 --- a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/AppHangConfiguration.kt +++ b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/AppHangConfiguration.kt @@ -49,6 +49,18 @@ class AppHangConfiguration( * Defaults to 50 */ var stackSamplingIntervalMillis: Long = DEFAULT_SAMPLING_INTERVAL, + /** + * The cooldown period in milliseconds after an AppHang has been reported. During this period, + * subsequent AppHangs will be suppressed to prevent over-reporting when the application is + * running on very slow devices or under extreme conditions. + * + * For example, if set to 5000ms (5 seconds), after an AppHang is reported, any additional + * AppHangs detected within the next 5 seconds will be ignored. This helps avoid flooding + * error reports when a device is experiencing sustained performance issues. + * + * Set to 0 (default) to disable the cooldown period and report all detected AppHangs. + */ + var appHangCooldownMillis: Long = 0L, ) { constructor() : this(DEFAULT_APP_HANG_THRESHOLD) diff --git a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/BugsnagAppHangPlugin.kt b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/BugsnagAppHangPlugin.kt index 49c2e0a58e..4abcfb344d 100644 --- a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/BugsnagAppHangPlugin.kt +++ b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/BugsnagAppHangPlugin.kt @@ -13,6 +13,7 @@ class BugsnagAppHangPlugin @JvmOverloads constructor( private val appHangThresholdMillis = configuration.appHangThresholdMillis private val samplingThresholdMillis = configuration.stackSamplingThresholdMillis ?: 0 private val samplingRateMillis = configuration.stackSamplingIntervalMillis + private val appHangCooldownMillis = configuration.appHangCooldownMillis private val watchedLooper = configuration.watchedLooper private var client: Client? = null @@ -69,6 +70,7 @@ class BugsnagAppHangPlugin @JvmOverloads constructor( monitorThread = LooperMonitorThread( watchedLooper, appHangThresholdMillis, + appHangCooldownMillis, if (samplingThresholdMillis in 1..appHangThresholdMillis) samplingThresholdMillis else 0, if (samplingRateMillis in 1..appHangThresholdMillis) samplingRateMillis else 0, this::reportAppHang diff --git a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt index 6756668be9..3291a1996c 100644 --- a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt +++ b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt @@ -6,14 +6,11 @@ import android.os.SystemClock import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.locks.LockSupport -import kotlin.compareTo -import kotlin.text.compareTo -import kotlin.text.get -import kotlin.text.set internal class LooperMonitorThread( watchedLooper: Looper, private val appHangThresholdMillis: Long, + private val appHangCooldownMillis: Long, private val samplingThresholdMillis: Long, private val samplingRateMillis: Long, private val onAppHangDetected: (timeSinceLastHeartbeat: Long, ThreadSampler?) -> Unit @@ -30,6 +27,9 @@ internal class LooperMonitorThread( @Volatile private var lastHeartbeatTimestamp = 0L + @Volatile + private var lastReportedHangTimestamp = 0L + private val isRunning = AtomicBoolean(false) private var isAppHangDetected = false @@ -45,6 +45,7 @@ internal class LooperMonitorThread( fun stopMonitoring() { if (isRunning.compareAndSet(true, false)) { handler.removeCallbacks(heartbeat) + lastReportedHangTimestamp = 0L LockSupport.unpark(this) } } @@ -53,13 +54,22 @@ internal class LooperMonitorThread( LockSupport.unpark(this) } - private fun reportAppHang(timeSinceLastHeartbeat: Long) { + private fun reportAppHang(currentTime: Long, timeSinceLastHeartbeat: Long): Boolean { if (isAppHangDetected) { - return + return false + } + + if (appHangCooldownMillis > 0L && lastReportedHangTimestamp > 0L) { + val timeSinceLastReport = currentTime - lastReportedHangTimestamp + if (timeSinceLastReport < appHangCooldownMillis) { + return false + } } isAppHangDetected = true + lastReportedHangTimestamp = currentTime onAppHangDetected(timeSinceLastHeartbeat, threadSampler) + return true } override fun run() { @@ -84,7 +94,18 @@ internal class LooperMonitorThread( } if (currentTimeSinceHeartbeat >= appHangThresholdMillis) { - reportAppHang(currentTimeSinceHeartbeat) + val hangReported = reportAppHang(currentTime, currentTimeSinceHeartbeat) + + // If we reported a hang and cooldown is configured, sleep for the cooldown period + // to give the app breathing room to recover + if (hangReported && appHangCooldownMillis > 0L) { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(appHangCooldownMillis)) + + // After cooldown, force a heartbeat to reset state + if (isRunning.get() && handler.post(heartbeat)) { + continue + } + } } if (!handler.post(heartbeat)) { From 3d5c8b822376c67cc1239a7959a9a52d9bce1aa4 Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 12 Feb 2026 16:08:20 +0000 Subject: [PATCH 21/32] chore(AppHang): minor cleanup of LooperMonitorThread for readability --- .../android/internal/LooperMonitorThread.kt | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt index 3291a1996c..c1845b98c6 100644 --- a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt +++ b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt @@ -81,7 +81,7 @@ internal class LooperMonitorThread( // Wait until next sample time or hang detection time, whichever comes first val waitMillis = calculateNextWaitTime(now, timeSinceHeartbeat) - LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(waitMillis)) + parkWithTimeoutMs(waitMillis) if (!isRunning.get()) break @@ -99,21 +99,20 @@ internal class LooperMonitorThread( // If we reported a hang and cooldown is configured, sleep for the cooldown period // to give the app breathing room to recover if (hangReported && appHangCooldownMillis > 0L) { - LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(appHangCooldownMillis)) - - // After cooldown, force a heartbeat to reset state - if (isRunning.get() && handler.post(heartbeat)) { - continue - } + parkWithTimeoutMs(appHangCooldownMillis) } } - if (!handler.post(heartbeat)) { + if (isRunning.get() && !handler.post(heartbeat)) { isRunning.set(false) } } } + private fun parkWithTimeoutMs(timeout: Long) { + LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(timeout)) + } + private fun calculateNextWaitTime(now: Long, timeSinceHeartbeat: Long): Long { if (lastHeartbeatTimestamp <= 0L) return appHangThresholdMillis if (timeSinceHeartbeat >= appHangThresholdMillis) return Long.MAX_VALUE From ed828b7482b39498d37963b353648c070a2155b6 Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 12 Feb 2026 16:08:40 +0000 Subject: [PATCH 22/32] chore(Makefile): added apiCheck to the `make check` target --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 02d584f674..779c2a4944 100644 --- a/Makefile +++ b/Makefile @@ -85,6 +85,6 @@ endif .PHONY: check check: - @./gradlew lint detekt ktlintCheck checkstyle + @./gradlew lint detekt ktlintCheck checkstyle apiCheck @./scripts/run-cpp-check.sh @./scripts/run-clang-format-ci-check.sh \ No newline at end of file From 6e29df18c9bf2f0abbae919ea66b1ae07bfd85d2 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 23 Feb 2026 09:48:29 +0000 Subject: [PATCH 23/32] feat(rooting): moved root detection to a dedicated background thread so that it doesn't block startup --- CHANGELOG.md | 4 ++- .../api/bugsnag-android-core.api | 7 ++++ bugsnag-android-core/detekt-baseline.xml | 3 +- .../main/java/com/bugsnag/android/Client.java | 6 ++-- .../com/bugsnag/android/ClientObservable.kt | 4 +++ .../bugsnag/android/DataCollectionModule.kt | 10 +++--- .../bugsnag/android/DeviceDataCollector.kt | 5 +-- .../java/com/bugsnag/android/StateEvent.kt | 2 ++ .../android/internal/RootDetectionProvider.kt | 32 +++++++++++++++++++ .../android/internal/dag/ConfigModule.kt | 2 +- .../android/internal/dag/ContextModule.kt | 7 +--- .../bugsnag/android/internal/dag/Provider.kt | 16 ++++++---- .../internal/dag/SystemServiceModule.kt | 7 +--- .../com/bugsnag/android/ndk/NativeBridge.kt | 3 ++ .../src/main/jni/bugsnag_ndk.c | 13 ++++++++ 15 files changed, 89 insertions(+), 32 deletions(-) create mode 100644 bugsnag-android-core/src/main/java/com/bugsnag/android/internal/RootDetectionProvider.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fcd965d45..6f89d2c3c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,9 @@ * Added `NativeOutOfMemoryPlugin` as a new way to report `OutOfMemoryError`s that uses pre-allocated memory in the NDK module instead of allocating an `Event` object. When used `OutOfMemoryError`s will be more reliably reported, but will not be passed to `OnErrorCallback`s (`OnSendCallback` works as expected). [#2384](https://github.com/bugsnag/bugsnag-android/pull/2384) * Added `appHangCooldownMillis` to the AppHangPlugin to control the number of AppHang errors produced when the app is performance constrained - []() + [#2389](https://github.com/bugsnag/bugsnag-android/pull/2389) +* Moved root/jailbreak detection onto a background thread so that it no longer blocks startup (this should improve startup performance in most common cases) + [#2391](https://github.com/bugsnag/bugsnag-android/pull/2391) ## 6.24.0 (2026-02-11) diff --git a/bugsnag-android-core/api/bugsnag-android-core.api b/bugsnag-android-core/api/bugsnag-android-core.api index ec8014b094..6bfa024573 100644 --- a/bugsnag-android-core/api/bugsnag-android-core.api +++ b/bugsnag-android-core/api/bugsnag-android-core.api @@ -877,6 +877,10 @@ public final class com/bugsnag/android/StateEvent$StartSession : com/bugsnag/and public final fun getUnhandledCount ()I } +public final class com/bugsnag/android/StateEvent$SynchronizeState : com/bugsnag/android/StateEvent { + public static final field INSTANCE Lcom/bugsnag/android/StateEvent$SynchronizeState; +} + public final class com/bugsnag/android/StateEvent$UpdateContext : com/bugsnag/android/StateEvent { public final field context Ljava/lang/String; public fun (Ljava/lang/String;)V @@ -1179,6 +1183,7 @@ public final class com/bugsnag/android/internal/TaskType : java/lang/Enum { public abstract interface class com/bugsnag/android/internal/dag/Provider { public abstract fun get ()Ljava/lang/Object; public abstract fun getOrNull ()Ljava/lang/Object; + public abstract fun isComplete ()Z } public abstract class com/bugsnag/android/internal/dag/RunnableProvider : com/bugsnag/android/internal/dag/Provider, java/lang/Runnable { @@ -1186,6 +1191,7 @@ public abstract class com/bugsnag/android/internal/dag/RunnableProvider : com/bu public fun get ()Ljava/lang/Object; public fun getOrNull ()Ljava/lang/Object; public abstract fun invoke ()Ljava/lang/Object; + public fun isComplete ()Z public final fun run ()V } @@ -1197,6 +1203,7 @@ public final class com/bugsnag/android/internal/dag/ValueProvider : com/bugsnag/ public fun get ()Ljava/lang/Object; public fun getOrNull ()Ljava/lang/Object; public fun hashCode ()I + public fun isComplete ()Z public fun toString ()Ljava/lang/String; } diff --git a/bugsnag-android-core/detekt-baseline.xml b/bugsnag-android-core/detekt-baseline.xml index 398e5c8809..87867c7730 100644 --- a/bugsnag-android-core/detekt-baseline.xml +++ b/bugsnag-android-core/detekt-baseline.xml @@ -15,7 +15,7 @@ LongParameterList:AppWithState.kt$AppWithState$( binaryArch: String?, id: String?, releaseStage: String?, version: String?, codeBundleId: String?, buildUuid: Provider<String?>?, type: String?, versionCode: Number?, /** * The number of milliseconds the application was running before the event occurred */ var duration: Number?, /** * The number of milliseconds the application was running in the foreground before the * event occurred */ var durationInForeground: Number?, /** * Whether the application was in the foreground when the event occurred */ var inForeground: Boolean?, /** * Whether the application was launching when the event occurred */ var isLaunching: Boolean? ) LongParameterList:AppWithState.kt$AppWithState$( binaryArch: String?, id: String?, releaseStage: String?, version: String?, codeBundleId: String?, buildUuid: String?, type: String?, versionCode: Number?, /** * The number of milliseconds the application was running before the event occurred */ duration: Number?, /** * The number of milliseconds the application was running in the foreground before the * event occurred */ durationInForeground: Number?, /** * Whether the application was in the foreground when the event occurred */ inForeground: Boolean?, /** * Whether the application was launching when the event occurred */ isLaunching: Boolean? ) LongParameterList:AppWithState.kt$AppWithState$( config: ImmutableConfig, binaryArch: String?, id: String?, releaseStage: String?, version: String?, codeBundleId: String?, duration: Number?, durationInForeground: Number?, inForeground: Boolean?, isLaunching: Boolean? ) - LongParameterList:DataCollectionModule.kt$DataCollectionModule$( contextModule: ContextModule, configModule: ConfigModule, systemServiceModule: SystemServiceModule, trackerModule: TrackerModule, bgTaskService: BackgroundTaskService, connectivity: Connectivity, deviceIdStore: Provider<DeviceIdStore>, memoryTrimState: MemoryTrimState ) + LongParameterList:DataCollectionModule.kt$DataCollectionModule$( contextModule: ContextModule, configModule: ConfigModule, systemServiceModule: SystemServiceModule, trackerModule: TrackerModule, bgTaskService: BackgroundTaskService, connectivity: Connectivity, deviceIdStore: Provider<DeviceIdStore>, memoryTrimState: MemoryTrimState, clientObservable: ClientObservable ) LongParameterList:Device.kt$Device$( buildInfo: DeviceBuildInfo, /** * The Application Binary Interface used */ var cpuAbi: Array<String>?, /** * Whether the device has been jailbroken */ var jailbroken: Boolean?, /** * A UUID generated by Bugsnag and used for the individual application on a device */ var id: String?, /** * The IETF language tag of the locale used */ var locale: String?, /** * The total number of bytes of memory on the device */ var totalMemory: Long?, /** * A collection of names and their versions of the primary languages, frameworks or * runtimes that the application is running on */ runtimeVersions: MutableMap<String, Any>? ) LongParameterList:DeviceBuildInfo.kt$DeviceBuildInfo$( val manufacturer: String?, val model: String?, val osVersion: String?, val apiLevel: Int?, val osBuild: String?, val fingerprint: String?, val tags: String?, val brand: String?, val cpuAbis: Array<String>? ) LongParameterList:DeviceDataCollector.kt$DeviceDataCollector$( private val connectivity: Connectivity, private val appContext: Context, resources: Resources, private val deviceIdStore: Provider<DeviceIdStore.DeviceIds?>, private val buildInfo: DeviceBuildInfo, private val dataDirectory: File, private val rootedFuture: Provider<Boolean>?, private val bgTaskService: BackgroundTaskService, private val logger: Logger ) @@ -84,7 +84,6 @@ SwallowedException:ConnectivityCompat.kt$ConnectivityLegacy$e: NullPointerException SwallowedException:ContextExtensions.kt$exc: RuntimeException SwallowedException:DeviceDataCollector.kt$DeviceDataCollector$e: Throwable - SwallowedException:DeviceDataCollector.kt$DeviceDataCollector$exc: Exception SwallowedException:DeviceDataCollector.kt$DeviceDataCollector$exception: Exception SwallowedException:DeviceIdFilePersistence.kt$DeviceIdFilePersistence$exc: OverlappingFileLockException SwallowedException:EventStore.kt$EventStore$exception: RejectedExecutionException diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java index 38da469ca9..029231c993 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java @@ -128,7 +128,7 @@ public Client(@NonNull Context androidContext, @NonNull String apiKey) { * @param configuration a configuration for the Client */ public Client(@NonNull Context androidContext, @NonNull final Configuration configuration) { - ContextModule contextModule = new ContextModule(androidContext, bgTaskService); + ContextModule contextModule = new ContextModule(androidContext); appContext = contextModule.getCtx(); notifier = configuration.getNotifier(); @@ -186,7 +186,7 @@ public Unit invoke(Boolean hasConnection, String networkState) { // lookup system services final SystemServiceModule systemServiceModule = - new SystemServiceModule(contextModule, bgTaskService); + new SystemServiceModule(contextModule); // setup further state trackers and data collection TrackerModule trackerModule = new TrackerModule(configModule, @@ -195,7 +195,7 @@ public Unit invoke(Boolean hasConnection, String networkState) { DataCollectionModule dataCollectionModule = new DataCollectionModule(contextModule, configModule, systemServiceModule, trackerModule, bgTaskService, connectivity, storageModule.getDeviceIdStore(), - memoryTrimState); + memoryTrimState, clientObservable); // load the device + user information userState = storageModule.loadUser(configuration.getUser()); diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/ClientObservable.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/ClientObservable.kt index 989ea30fba..4811f29578 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/ClientObservable.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/ClientObservable.kt @@ -31,4 +31,8 @@ internal class ClientObservable : BaseObservable() { fun postNdkDeliverPending() { updateState { StateEvent.DeliverPending } } + + fun postSynchronizeState() { + updateState { StateEvent.SynchronizeState } + } } diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/DataCollectionModule.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/DataCollectionModule.kt index 4b9edee875..5530357253 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/DataCollectionModule.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/DataCollectionModule.kt @@ -2,6 +2,7 @@ package com.bugsnag.android import android.os.Environment import com.bugsnag.android.internal.BackgroundTaskService +import com.bugsnag.android.internal.RootDetectionProvider import com.bugsnag.android.internal.dag.BackgroundDependencyModule import com.bugsnag.android.internal.dag.ConfigModule import com.bugsnag.android.internal.dag.ContextModule @@ -20,7 +21,8 @@ internal class DataCollectionModule( bgTaskService: BackgroundTaskService, connectivity: Connectivity, deviceIdStore: Provider, - memoryTrimState: MemoryTrimState + memoryTrimState: MemoryTrimState, + clientObservable: ClientObservable ) : BackgroundDependencyModule(bgTaskService) { private val ctx = contextModule.ctx @@ -41,10 +43,8 @@ internal class DataCollectionModule( ) } - private val rootDetection = provider { - val rootDetector = RootDetector(logger = logger, deviceBuildInfo = deviceBuildInfo) - rootDetector.isRooted() - } + private val rootDetection = RootDetectionProvider(deviceBuildInfo, clientObservable, logger) + .apply { start() } val deviceDataCollector = provider { DeviceDataCollector( diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceDataCollector.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceDataCollector.kt index a2e4a06f84..6557e7fe29 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceDataCollector.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceDataCollector.kt @@ -122,9 +122,10 @@ internal class DeviceDataCollector( } private fun checkIsRooted(): Boolean { + val rooted = rootedFuture ?: return false return try { - rootedFuture != null && rootedFuture.get() - } catch (exc: Exception) { + rooted.isComplete && rooted.get() + } catch (_: Exception) { false } } diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/StateEvent.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/StateEvent.kt index 4a227f9547..d45546dd4e 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/StateEvent.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/StateEvent.kt @@ -82,4 +82,6 @@ sealed class StateEvent { // JvmField allows direct field access optimizations ) : StateEvent() object ClearFeatureFlags : StateEvent() + + object SynchronizeState : StateEvent() } diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/RootDetectionProvider.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/RootDetectionProvider.kt new file mode 100644 index 0000000000..34176cd999 --- /dev/null +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/RootDetectionProvider.kt @@ -0,0 +1,32 @@ +package com.bugsnag.android.internal + +import com.bugsnag.android.ClientObservable +import com.bugsnag.android.DeviceBuildInfo +import com.bugsnag.android.Logger +import com.bugsnag.android.RootDetector +import com.bugsnag.android.internal.dag.RunnableProvider + +internal class RootDetectionProvider( + private val deviceBuildInfo: DeviceBuildInfo, + private val clientObservable: ClientObservable, + private val logger: Logger, +) : RunnableProvider() { + var isRooted: Boolean = false + private set + + fun start() { + // root detection can take 100+ms so we always have a dedicated background thread for it + // we fire an event once we're finished to let any downstream notifiers know the result + val worker = Thread(this, "Bugsnag Worker") + worker.priority = Thread.MIN_PRIORITY + worker.isDaemon = true + worker.start() + } + + override fun invoke(): Boolean { + val rootDetector = RootDetector(logger = logger, deviceBuildInfo = deviceBuildInfo) + isRooted = rootDetector.isRooted() + clientObservable.postSynchronizeState() + return isRooted + } +} diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/ConfigModule.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/ConfigModule.kt index aece93e476..503b3d6d8a 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/ConfigModule.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/ConfigModule.kt @@ -14,6 +14,6 @@ internal class ConfigModule( configuration: Configuration, connectivity: Connectivity, bgTaskExecutor: BackgroundTaskService -) : BackgroundDependencyModule(bgTaskExecutor) { +) : DependencyModule { val config = sanitiseConfiguration(contextModule.ctx, configuration, connectivity, bgTaskExecutor) } diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/ContextModule.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/ContextModule.kt index 0d36ce64bf..6947d11ae8 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/ContextModule.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/ContextModule.kt @@ -1,17 +1,12 @@ package com.bugsnag.android.internal.dag import android.content.Context -import com.bugsnag.android.internal.BackgroundTaskService /** * A dependency module which accesses the application context object, falling back to the supplied * context if it is the base context. */ -internal class ContextModule( - appContext: Context, - bgTaskService: BackgroundTaskService -) : BackgroundDependencyModule(bgTaskService) { - +internal class ContextModule(appContext: Context) : DependencyModule { val ctx: Context = when (appContext.applicationContext) { null -> appContext else -> appContext.applicationContext diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/Provider.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/Provider.kt index 6eb4408439..2d50a739b0 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/Provider.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/Provider.kt @@ -21,6 +21,8 @@ interface Provider { * to calculate the value. */ fun get(): E + + val isComplete: Boolean } /** @@ -40,6 +42,12 @@ abstract class RunnableProvider : Provider, Runnable { @Volatile private var value: Any? = null + override val isComplete + get() = when (state.get()) { + TASK_STATE_PENDING, TASK_STATE_RUNNING -> false + else -> true + } + /** * Calculate the value of this [Provider]. This function will be called at-most once by [run]. * Do not call this function directly, instead use [get] and [getOrNull] which implement the @@ -91,18 +99,13 @@ abstract class RunnableProvider : Provider, Runnable { */ private fun awaitResult() { synchronized(this) { - while (!isComplete()) { + while (!isComplete) { @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") (this as Object).wait() } } } - private fun isComplete() = when (state.get()) { - TASK_STATE_PENDING, TASK_STATE_RUNNING -> false - else -> true - } - /** * The main entry point for a provider, typically called by a worker thread from * [BackgroundTaskService]. If [run] has already been called this will be a no-op (including @@ -178,4 +181,5 @@ abstract class RunnableProvider : Provider, Runnable { data class ValueProvider(private val value: T) : Provider { override fun getOrNull(): T? = get() override fun get(): T = value + override val isComplete: Boolean get() = true } diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/SystemServiceModule.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/SystemServiceModule.kt index 1c7cd6cf02..1ff3a2dda3 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/SystemServiceModule.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/internal/dag/SystemServiceModule.kt @@ -2,16 +2,11 @@ package com.bugsnag.android.internal.dag import com.bugsnag.android.getActivityManager import com.bugsnag.android.getStorageManager -import com.bugsnag.android.internal.BackgroundTaskService /** * A dependency module which provides a reference to Android system services. */ -internal class SystemServiceModule( - contextModule: ContextModule, - bgTaskService: BackgroundTaskService -) : BackgroundDependencyModule(bgTaskService) { - +internal class SystemServiceModule(contextModule: ContextModule) : DependencyModule { val storageManager = contextModule.ctx.getStorageManager() val activityManager = contextModule.ctx.getActivityManager() } diff --git a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt index bd8f5a43f3..e58ce92e32 100644 --- a/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt +++ b/bugsnag-plugin-android-ndk/src/main/java/com/bugsnag/android/ndk/NativeBridge.kt @@ -73,6 +73,7 @@ class NativeBridge(private val bgTaskService: BackgroundTaskService) : StateObse } private external fun addBreadcrumb(name: String, type: Int, timestamp: String, metadata: Any) + private external fun synchronizeState() external fun reportOutOfMemory(oom: OutOfMemoryError) external fun addMetadataString(tab: String, key: String, value: String) external fun addMetadataDouble(tab: String, key: String, value: Double) @@ -171,6 +172,8 @@ class NativeBridge(private val bgTaskService: BackgroundTaskService) : StateObse is StateEvent.ClearFeatureFlag -> clearFeatureFlag(event.name) is StateEvent.ClearFeatureFlags -> clearFeatureFlags() + is StateEvent.SynchronizeState -> synchronizeState() + else -> { // ignore } diff --git a/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c b/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c index 6a68284de2..aefc71d681 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c +++ b/bugsnag-plugin-android-ndk/src/main/jni/bugsnag_ndk.c @@ -958,6 +958,19 @@ static void JNI_NativeBridge_setInternalMetricsEnabled(JNIEnv *env, bsg_set_internal_metrics_enabled(enabled); } +static void JNI_NativeBridge_synchronizeState(JNIEnv *env, jobject thiz) { + bsg_environment *bsg_env = request_env_write_lock(); + + if (bsg_env == NULL) { + goto end; + } + + bsg_populate_event(env, &bsg_env->next_event); + +end: + release_env_write_lock(); +} + static void JNI_NativeBridge_reportOutOfMemory(JNIEnv *env, jobject thiz, jobject oom) { From 98d7d07e84b24e0cb6046e0d131a78635d2b5d8c Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 23 Feb 2026 11:43:50 +0000 Subject: [PATCH 24/32] test(CXXMarkLaunchCompletedScenario): changed CXXMarkLaunchCompletedScenario to use OnSendCallback instead of InterceptingDelivery --- .../scenarios/CXXMarkLaunchCompletedScenario.kt | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/features/fixtures/mazerunner/cxx-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXMarkLaunchCompletedScenario.kt b/features/fixtures/mazerunner/cxx-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXMarkLaunchCompletedScenario.kt index 03af8158d5..8a30efdacc 100644 --- a/features/fixtures/mazerunner/cxx-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXMarkLaunchCompletedScenario.kt +++ b/features/fixtures/mazerunner/cxx-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXMarkLaunchCompletedScenario.kt @@ -1,11 +1,10 @@ package com.bugsnag.android.mazerunner.scenarios import android.content.Context +import android.os.Handler +import android.os.Looper import com.bugsnag.android.Bugsnag import com.bugsnag.android.Configuration -import com.bugsnag.android.createDefaultDelivery -import com.bugsnag.android.mazerunner.InterceptingDelivery -import java.util.concurrent.atomic.AtomicInteger /** * Sends an NDK error to Bugsnag after markLaunchCompleted() is invoked. @@ -16,19 +15,18 @@ internal class CXXMarkLaunchCompletedScenario( eventMetadata: String? ) : Scenario(config, context, eventMetadata) { - private val deliveryCount = AtomicInteger(0) - external fun crash() init { config.launchDurationMillis = 0 System.loadLibrary("cxx-scenarios") - // wait for Bugsnag.notify() to complete before triggering NDK crash - config.delivery = InterceptingDelivery(createDefaultDelivery()) { - if (deliveryCount.incrementAndGet() == 1) { + config.addOnSend { _ -> + Handler(Looper.getMainLooper()).post { crash() } + + true } } From 582d8f6776ddfb44331a9e3c3a9d021bb2df6f2f Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 23 Feb 2026 12:16:48 +0000 Subject: [PATCH 25/32] fix(DeviceID): DeviceIdStore.load should be thread safe to avoid double device ID generation --- bugsnag-android-core/detekt-baseline.xml | 2 +- .../src/main/java/com/bugsnag/android/Client.java | 2 +- .../main/java/com/bugsnag/android/DataCollectionModule.kt | 4 ++-- .../src/main/java/com/bugsnag/android/DeviceIdStore.kt | 1 + .../src/main/java/com/bugsnag/android/StorageModule.kt | 6 +++++- 5 files changed, 10 insertions(+), 5 deletions(-) diff --git a/bugsnag-android-core/detekt-baseline.xml b/bugsnag-android-core/detekt-baseline.xml index 87867c7730..f1bbc8b837 100644 --- a/bugsnag-android-core/detekt-baseline.xml +++ b/bugsnag-android-core/detekt-baseline.xml @@ -15,7 +15,7 @@ LongParameterList:AppWithState.kt$AppWithState$( binaryArch: String?, id: String?, releaseStage: String?, version: String?, codeBundleId: String?, buildUuid: Provider<String?>?, type: String?, versionCode: Number?, /** * The number of milliseconds the application was running before the event occurred */ var duration: Number?, /** * The number of milliseconds the application was running in the foreground before the * event occurred */ var durationInForeground: Number?, /** * Whether the application was in the foreground when the event occurred */ var inForeground: Boolean?, /** * Whether the application was launching when the event occurred */ var isLaunching: Boolean? ) LongParameterList:AppWithState.kt$AppWithState$( binaryArch: String?, id: String?, releaseStage: String?, version: String?, codeBundleId: String?, buildUuid: String?, type: String?, versionCode: Number?, /** * The number of milliseconds the application was running before the event occurred */ duration: Number?, /** * The number of milliseconds the application was running in the foreground before the * event occurred */ durationInForeground: Number?, /** * Whether the application was in the foreground when the event occurred */ inForeground: Boolean?, /** * Whether the application was launching when the event occurred */ isLaunching: Boolean? ) LongParameterList:AppWithState.kt$AppWithState$( config: ImmutableConfig, binaryArch: String?, id: String?, releaseStage: String?, version: String?, codeBundleId: String?, duration: Number?, durationInForeground: Number?, inForeground: Boolean?, isLaunching: Boolean? ) - LongParameterList:DataCollectionModule.kt$DataCollectionModule$( contextModule: ContextModule, configModule: ConfigModule, systemServiceModule: SystemServiceModule, trackerModule: TrackerModule, bgTaskService: BackgroundTaskService, connectivity: Connectivity, deviceIdStore: Provider<DeviceIdStore>, memoryTrimState: MemoryTrimState, clientObservable: ClientObservable ) + LongParameterList:DataCollectionModule.kt$DataCollectionModule$( contextModule: ContextModule, configModule: ConfigModule, systemServiceModule: SystemServiceModule, trackerModule: TrackerModule, bgTaskService: BackgroundTaskService, connectivity: Connectivity, deviceId: Provider<DeviceIdStore.DeviceIds?>, memoryTrimState: MemoryTrimState, clientObservable: ClientObservable ) LongParameterList:Device.kt$Device$( buildInfo: DeviceBuildInfo, /** * The Application Binary Interface used */ var cpuAbi: Array<String>?, /** * Whether the device has been jailbroken */ var jailbroken: Boolean?, /** * A UUID generated by Bugsnag and used for the individual application on a device */ var id: String?, /** * The IETF language tag of the locale used */ var locale: String?, /** * The total number of bytes of memory on the device */ var totalMemory: Long?, /** * A collection of names and their versions of the primary languages, frameworks or * runtimes that the application is running on */ runtimeVersions: MutableMap<String, Any>? ) LongParameterList:DeviceBuildInfo.kt$DeviceBuildInfo$( val manufacturer: String?, val model: String?, val osVersion: String?, val apiLevel: Int?, val osBuild: String?, val fingerprint: String?, val tags: String?, val brand: String?, val cpuAbis: Array<String>? ) LongParameterList:DeviceDataCollector.kt$DeviceDataCollector$( private val connectivity: Connectivity, private val appContext: Context, resources: Resources, private val deviceIdStore: Provider<DeviceIdStore.DeviceIds?>, private val buildInfo: DeviceBuildInfo, private val dataDirectory: File, private val rootedFuture: Provider<Boolean>?, private val bgTaskService: BackgroundTaskService, private val logger: Logger ) diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java index 029231c993..a248fc1735 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Client.java @@ -194,7 +194,7 @@ public Unit invoke(Boolean hasConnection, String networkState) { DataCollectionModule dataCollectionModule = new DataCollectionModule(contextModule, configModule, systemServiceModule, trackerModule, - bgTaskService, connectivity, storageModule.getDeviceIdStore(), + bgTaskService, connectivity, storageModule.getDeviceId(), memoryTrimState, clientObservable); // load the device + user information diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/DataCollectionModule.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/DataCollectionModule.kt index 5530357253..f81c826aeb 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/DataCollectionModule.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/DataCollectionModule.kt @@ -20,7 +20,7 @@ internal class DataCollectionModule( trackerModule: TrackerModule, bgTaskService: BackgroundTaskService, connectivity: Connectivity, - deviceIdStore: Provider, + deviceId: Provider, memoryTrimState: MemoryTrimState, clientObservable: ClientObservable ) : BackgroundDependencyModule(bgTaskService) { @@ -51,7 +51,7 @@ internal class DataCollectionModule( connectivity, ctx, ctx.resources, - deviceIdStore.map { it.load() }, + deviceId, deviceBuildInfo, dataDir, rootDetection, diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceIdStore.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceIdStore.kt index a0a2cab06f..922e3c9c0e 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceIdStore.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/DeviceIdStore.kt @@ -62,6 +62,7 @@ internal class DeviceIdStore @JvmOverloads @Suppress("LongParameterList") constr return internalPersistence.loadDeviceId(true) } + @Synchronized fun load(): DeviceIds? { if (deviceIds != null) { return deviceIds diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/StorageModule.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/StorageModule.kt index 46a07da023..7f230ac1af 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/StorageModule.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/StorageModule.kt @@ -34,11 +34,15 @@ internal class StorageModule( ) } + val deviceId = deviceIdStore.map { + it.load() + } + val userStore = provider { UserStore( immutableConfig.persistUser, bugsnagDir, - deviceIdStore.map { it.load() }, + deviceId, sharedPrefMigrator = sharedPrefMigrator, logger = immutableConfig.logger ) From 1dba6a0a8a1248b5eef0fd6f5dc5b8947c95a4ef Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 23 Feb 2026 14:39:42 +0000 Subject: [PATCH 26/32] fix(ndk): corrected the maximum length of the `event->context` for `bugsnag_event_set_context` --- bugsnag-plugin-android-ndk/src/main/jni/event.c | 2 +- features/full_tests/native_api.feature | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bugsnag-plugin-android-ndk/src/main/jni/event.c b/bugsnag-plugin-android-ndk/src/main/jni/event.c index 7ba3320b80..be5291e0ef 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/event.c +++ b/bugsnag-plugin-android-ndk/src/main/jni/event.c @@ -339,7 +339,7 @@ char *bugsnag_event_get_context(void *event_ptr) { void bugsnag_event_set_context(void *event_ptr, const char *value) { bugsnag_event *event = (bugsnag_event *)event_ptr; bsg_notify_api_called(event, BSG_API_EVENT_SET_CONTEXT); - bsg_strncpy(event->context, value, sizeof(event->context)); + bsg_strncpy(event->context, value, sizeof(event->context) - 1); } char *bugsnag_event_get_grouping_discriminator(void *event_ptr) { diff --git a/features/full_tests/native_api.feature b/features/full_tests/native_api.feature index ffbb704173..7688983051 100644 --- a/features/full_tests/native_api.feature +++ b/features/full_tests/native_api.feature @@ -12,7 +12,7 @@ Feature: Native API | SIGILL | | SIGTRAP | And the event "app.version" equals "22.312.749.78.300.810.24.167.32" - And the event "context" equals "ObservableSessionInitializerStringParserStringSessionProxyGloba" + And the event "context" equals "ObservableSessionInitializerStringParserStringSessionProxyGlob" And the event "unhandled" is true Scenario: Use the NDK methods without "env" after calling "bugsnag_start" From 06886aa7a510ddb0be68ede8cc40ae792f1a5950 Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 23 Feb 2026 15:26:14 +0000 Subject: [PATCH 27/32] fix(ndk): populate_app_data should retrieve isLaunching from the `app` metadata rather than hard-coding it to `true` --- bugsnag-plugin-android-ndk/src/main/jni/metadata.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bugsnag-plugin-android-ndk/src/main/jni/metadata.c b/bugsnag-plugin-android-ndk/src/main/jni/metadata.c index c6c8235b75..f3d8344ab3 100644 --- a/bugsnag-plugin-android-ndk/src/main/jni/metadata.c +++ b/bugsnag-plugin-android-ndk/src/main/jni/metadata.c @@ -174,7 +174,7 @@ static void populate_app_data(JNIEnv *env, bugsnag_event *event) { copy_map_value_string(env, data, "id", event->app.id, sizeof(event->app.id)); event->app.in_foreground = get_map_value_bool(env, data, "inForeground"); - event->app.is_launching = true; + event->app.is_launching = get_map_value_bool(env, data, "isLaunching"); char name[64]; copy_map_value_string(env, data, "name", name, sizeof(name)); From cc58462b6d7f7d6b66cee1eba45599e2a0f5359a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:34:27 +0000 Subject: [PATCH 28/32] build(deps): bump gradle/actions from 5.0.1 to 5.0.2 Bumps [gradle/actions](https://github.com/gradle/actions) from 5.0.1 to 5.0.2. - [Release notes](https://github.com/gradle/actions/releases) - [Commits](https://github.com/gradle/actions/compare/f29f5a9d7b09a7c6b29859002d29d24e1674c884...0723195856401067f7a2779048b490ace7a47d7c) --- updated-dependencies: - dependency-name: gradle/actions dependency-version: 5.0.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 2 +- .github/workflows/scorecard.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8c13944ff2..1d9b18f9aa 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -47,7 +47,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2 with: submodules: recursive - - uses: gradle/actions/wrapper-validation@f29f5a9d7b09a7c6b29859002d29d24e1674c884 #v5.0.1 + - uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c #v5.0.2 - uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 #v5.2.0 with: diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 84df20ff04..3eaff0a3e7 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -77,4 +77,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - uses: gradle/actions/wrapper-validation@f29f5a9d7b09a7c6b29859002d29d24e1674c884 # v5.0.1 + - uses: gradle/actions/wrapper-validation@0723195856401067f7a2779048b490ace7a47d7c # v5.0.2 From 930fc104ef05081355efa801d63fa2582221218a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 00:34:43 +0000 Subject: [PATCH 29/32] build(deps): bump github/codeql-action from 4.32.2 to 4.32.4 Bumps [github/codeql-action](https://github.com/github/codeql-action) from 4.32.2 to 4.32.4. - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2...89a39a4e59826350b863aa6b6252a07ad50cf83e) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.32.4 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/codeql.yml | 4 ++-- .github/workflows/scorecard.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 8c13944ff2..582cca44b1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -64,7 +64,7 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 #v4.32.2 + uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e #v4.32.4 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. @@ -83,6 +83,6 @@ jobs: ./gradlew --no-daemon assemble - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 #v4.32.2 + uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e #v4.32.4 with: category: "/language:${{matrix.language}}" diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index 84df20ff04..4dd366eadb 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -68,7 +68,7 @@ jobs: # Upload the results to GitHub's code scanning dashboard (optional). # Commenting out will disable upload of results to your repo's Code Scanning dashboard - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4.32.2 + uses: github/codeql-action/upload-sarif@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4 with: sarif_file: results.sarif From 696a8be92504125bdb6b4ec51856eace23698f98 Mon Sep 17 00:00:00 2001 From: jason Date: Tue, 24 Feb 2026 09:25:26 +0000 Subject: [PATCH 30/32] test(CXXMarkLaunchCompletedScenario): change back to InterceptingDelivery but also post the crash back onto the main looper --- .../scenarios/CXXMarkLaunchCompletedScenario.kt | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/features/fixtures/mazerunner/cxx-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXMarkLaunchCompletedScenario.kt b/features/fixtures/mazerunner/cxx-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXMarkLaunchCompletedScenario.kt index 8a30efdacc..3b7dc1965f 100644 --- a/features/fixtures/mazerunner/cxx-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXMarkLaunchCompletedScenario.kt +++ b/features/fixtures/mazerunner/cxx-scenarios/src/main/java/com/bugsnag/android/mazerunner/scenarios/CXXMarkLaunchCompletedScenario.kt @@ -5,6 +5,8 @@ import android.os.Handler import android.os.Looper import com.bugsnag.android.Bugsnag import com.bugsnag.android.Configuration +import com.bugsnag.android.createDefaultDelivery +import com.bugsnag.android.mazerunner.InterceptingDelivery /** * Sends an NDK error to Bugsnag after markLaunchCompleted() is invoked. @@ -18,15 +20,14 @@ internal class CXXMarkLaunchCompletedScenario( external fun crash() init { - config.launchDurationMillis = 0 System.loadLibrary("cxx-scenarios") - config.addOnSend { _ -> + config.launchDurationMillis = 0 + + config.delivery = InterceptingDelivery(createDefaultDelivery()) { result -> Handler(Looper.getMainLooper()).post { crash() } - - true } } From d95eefe3ec42c2ce4ec4d477251e7484a21fa586 Mon Sep 17 00:00:00 2001 From: jason Date: Thu, 26 Feb 2026 16:23:03 +0000 Subject: [PATCH 31/32] fix(AppHang): reduce the frequency and overhead of the AppHang heartbeats --- CHANGELOG.md | 5 + .../detekt-baseline.xml | 7 + .../bugsnag/android/SequentialAppHangsTest.kt | 4 +- .../android/internal/LooperMonitorThread.kt | 179 +++++++++++------- 4 files changed, 125 insertions(+), 70 deletions(-) create mode 100644 bugsnag-plugin-android-apphang/detekt-baseline.xml diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f89d2c3c6..e0facc3f55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,11 @@ * Moved root/jailbreak detection onto a background thread so that it no longer blocks startup (this should improve startup performance in most common cases) [#2391](https://github.com/bugsnag/bugsnag-android/pull/2391) +### Bug Fixes + +* Reduced the heartbeat overheads of `BugsnagAppHangPlugin` reducing both the CPU cost of a heartbeat and the frequency. + [#2395](https://github.com/bugsnag/bugsnag-android/pull/2395) + ## 6.24.0 (2026-02-11) ### Enhancements diff --git a/bugsnag-plugin-android-apphang/detekt-baseline.xml b/bugsnag-plugin-android-apphang/detekt-baseline.xml new file mode 100644 index 0000000000..ddf6cf6dc9 --- /dev/null +++ b/bugsnag-plugin-android-apphang/detekt-baseline.xml @@ -0,0 +1,7 @@ + + + + + LoopWithTooManyJumpStatements:LooperMonitorThread.kt$LooperMonitorThread$while + + diff --git a/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/SequentialAppHangsTest.kt b/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/SequentialAppHangsTest.kt index 9cb4550898..0dac76382b 100644 --- a/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/SequentialAppHangsTest.kt +++ b/bugsnag-plugin-android-apphang/src/androidTest/java/com/bugsnag/android/SequentialAppHangsTest.kt @@ -60,11 +60,11 @@ class SequentialAppHangsTest { val countDownLatch = CountDownLatch(10) val task = object : Runnable { override fun run() { - JThread.sleep(APP_HANG_THRESHOLD / 2) + JThread.sleep((APP_HANG_THRESHOLD / 2) - 10) countDownLatch.countDown() if (countDownLatch.count > 0) { - handler.postDelayed(this, 1L) + handler.postDelayed(this, 10L) } } } diff --git a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt index c1845b98c6..4685d7a684 100644 --- a/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt +++ b/bugsnag-plugin-android-apphang/src/main/java/com/bugsnag/android/internal/LooperMonitorThread.kt @@ -3,9 +3,7 @@ package com.bugsnag.android.internal import android.os.Handler import android.os.Looper import android.os.SystemClock -import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.locks.LockSupport internal class LooperMonitorThread( watchedLooper: Looper, @@ -18,9 +16,13 @@ internal class LooperMonitorThread( private val handler: Handler = Handler(watchedLooper) private val threadSampler: ThreadSampler? = - if (samplingThresholdMillis > 0) ThreadSampler(watchedLooper.thread) + if (isSamplingEnabled) ThreadSampler(watchedLooper.thread) else null + private val heartbeatInterval = + if (isSamplingEnabled) samplingThresholdMillis / 2 + else appHangThresholdMillis / 2 + @Volatile private var lastStackSampleTimestamp = 0L @@ -30,12 +32,25 @@ internal class LooperMonitorThread( @Volatile private var lastReportedHangTimestamp = 0L - private val isRunning = AtomicBoolean(false) - + @Volatile private var isAppHangDetected = false + private var lastHeartbeatPostTimestamp = 0L + + private val isRunning = AtomicBoolean(false) + private val heartbeat: Runnable = Heartbeat() + private val isSamplingEnabled: Boolean + get() = samplingThresholdMillis > 0 + + /** + * A heartbeat is pending iff the timestamp we posted the latest one at is after the last + * heartbeat timestamp. + */ + private val isHeartbeatPending + get() = lastHeartbeatPostTimestamp > lastHeartbeatTimestamp + fun startMonitoring() { if (isRunning.compareAndSet(false, true)) { start() @@ -46,14 +61,10 @@ internal class LooperMonitorThread( if (isRunning.compareAndSet(true, false)) { handler.removeCallbacks(heartbeat) lastReportedHangTimestamp = 0L - LockSupport.unpark(this) + interrupt() } } - internal fun resetHeartbeatTimer() { - LockSupport.unpark(this) - } - private fun reportAppHang(currentTime: Long, timeSinceLastHeartbeat: Long): Boolean { if (isAppHangDetected) { return false @@ -72,95 +83,127 @@ internal class LooperMonitorThread( return true } - override fun run() { - handler.post(heartbeat) + /** + * Calculate how long (in millis) the monitor thread should go to sleep for. The exact value + * depends on what the next expected event is: + * + * - normal operation: (time to sample start || time to app hang) / 2 + * - sampling: sampling interval time + * - app hang: app cooldown time + */ + private fun timeUntilNextWakeup(): Long { + if (isAppHangDetected && appHangCooldownMillis > 0) { + return appHangCooldownMillis + } - while (isRunning.get()) { - val now = SystemClock.uptimeMillis() + if (isSamplingEnabled) { + val now = currentTime() val timeSinceHeartbeat = now - lastHeartbeatTimestamp - // Wait until next sample time or hang detection time, whichever comes first - val waitMillis = calculateNextWaitTime(now, timeSinceHeartbeat) - parkWithTimeoutMs(waitMillis) + if (timeSinceHeartbeat > samplingThresholdMillis) { + return samplingRateMillis + } + } + + return heartbeatInterval + } - if (!isRunning.get()) break + override fun run() { + lastHeartbeatTimestamp = currentTime() + while (isRunning.get()) { + if (!postHeartbeat()) { + break + } - val currentTime = SystemClock.uptimeMillis() - val currentTimeSinceHeartbeat = currentTime - lastHeartbeatTimestamp + waitForMillis(timeUntilNextWakeup()) - if (shouldTakeSample(currentTime, currentTimeSinceHeartbeat)) { - threadSampler?.captureSample() - lastStackSampleTimestamp = currentTime + // early exit upon termination + if (!isRunning.get()) { + break } - if (currentTimeSinceHeartbeat >= appHangThresholdMillis) { - val hangReported = reportAppHang(currentTime, currentTimeSinceHeartbeat) + val now = currentTime() + val timeSinceHeartbeat = now - lastHeartbeatTimestamp - // If we reported a hang and cooldown is configured, sleep for the cooldown period - // to give the app breathing room to recover - if (hangReported && appHangCooldownMillis > 0L) { - parkWithTimeoutMs(appHangCooldownMillis) + if (isHeartbeatPending) { + if (shouldTakeSample(now)) { + threadSampler?.captureSample() + lastStackSampleTimestamp = now } - } - if (isRunning.get() && !handler.post(heartbeat)) { - isRunning.set(false) + if (timeSinceHeartbeat >= appHangThresholdMillis) { + reportAppHang(now, timeSinceHeartbeat) + } + } else { + isAppHangDetected = false + threadSampler?.resetSampling() + lastStackSampleTimestamp = 0L } } } - private fun parkWithTimeoutMs(timeout: Long) { - LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(timeout)) - } + /** + * Post the heartbeat message to the monitored queue, returning `true` if the monitoring + * loop should continue or `false` if we should stop attempting to monitor the thread + */ + private fun postHeartbeat(): Boolean { + if (!isRunning.get()) { + return false + } else if (isHeartbeatPending) { + return true + } - private fun calculateNextWaitTime(now: Long, timeSinceHeartbeat: Long): Long { - if (lastHeartbeatTimestamp <= 0L) return appHangThresholdMillis - if (timeSinceHeartbeat >= appHangThresholdMillis) return Long.MAX_VALUE + // we track the "post" timestamp before sending it so that the "post" is always <= heartbeat + lastHeartbeatPostTimestamp = currentTime() - val timeToHang = appHangThresholdMillis - timeSinceHeartbeat - if (threadSampler == null) return timeToHang + if (!handler.post(heartbeat)) { + isRunning.set(false) + return false + } - return calculateTimeToNextStackSample(now, timeToHang, timeSinceHeartbeat) + return true } - private fun calculateTimeToNextStackSample( - now: Long, - timeToHang: Long, - timeSinceHeartbeat: Long - ): Long { - return if (lastStackSampleTimestamp > 0L) { - // Already sampling - wait for next sample - val timeToNextSample = samplingRateMillis - (now - lastStackSampleTimestamp) - minOf(timeToNextSample, timeToHang) - } else { - val timeToSamplingStart = samplingThresholdMillis - timeSinceHeartbeat - minOf(timeToSamplingStart, timeToHang) + private fun waitForMillis(timeout: Long) { + val endTime = currentTime() + timeout + while (isRunning.get()) { + val remainingTime = endTime - currentTime() + if (remainingTime <= 0) { + break + } + + try { + sleep(remainingTime) + } catch (_: InterruptedException) { + } } } - private fun shouldTakeSample(currentTime: Long, timeSinceHeartbeat: Long): Boolean { - if (threadSampler == null) return false - if (timeSinceHeartbeat < samplingThresholdMillis) return false + private fun shouldTakeSample(now: Long): Boolean { + if (threadSampler == null) { + return false + } - val timeSinceLastSample = if (lastStackSampleTimestamp <= 0L) { - Long.MAX_VALUE - } else { - currentTime - lastStackSampleTimestamp + val timeSinceHeartbeat = now - lastHeartbeatTimestamp + if (timeSinceHeartbeat < samplingThresholdMillis) { + return false } + val timeSinceLastSample = + if (lastStackSampleTimestamp <= 0L) { + Long.MAX_VALUE + } else { + now - lastStackSampleTimestamp + } + return timeSinceLastSample >= samplingRateMillis } + internal fun currentTime(): Long = SystemClock.uptimeMillis() + private inner class Heartbeat : Runnable { override fun run() { - lastHeartbeatTimestamp = SystemClock.uptimeMillis() - isAppHangDetected = false - - // Reset sampler when thread recovers - threadSampler?.resetSampling() - lastStackSampleTimestamp = 0L - - resetHeartbeatTimer() + lastHeartbeatTimestamp = currentTime() } override fun toString(): String { From 7abb05c8028be306a17a58b634ba9ee458841afe Mon Sep 17 00:00:00 2001 From: jason Date: Mon, 2 Mar 2026 14:07:02 +0000 Subject: [PATCH 32/32] v6.25.0 --- CHANGELOG.md | 2 +- .../src/main/java/com/bugsnag/android/Notifier.kt | 2 +- examples/sdk-app-example/gradle/libs.versions.toml | 2 +- gradle.properties | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e0facc3f55..7192558727 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## TBD +## 6.25.0 (2026-03-02) ### Enhancements diff --git a/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt b/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt index df70dc5e21..02e5cbe390 100644 --- a/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt +++ b/bugsnag-android-core/src/main/java/com/bugsnag/android/Notifier.kt @@ -7,7 +7,7 @@ import java.io.IOException */ class Notifier @JvmOverloads constructor( var name: String = "Android Bugsnag Notifier", - var version: String = "6.24.0", + var version: String = "6.25.0", var url: String = "https://bugsnag.com" ) : JsonStream.Streamable { diff --git a/examples/sdk-app-example/gradle/libs.versions.toml b/examples/sdk-app-example/gradle/libs.versions.toml index 0a90955776..bc45b92433 100644 --- a/examples/sdk-app-example/gradle/libs.versions.toml +++ b/examples/sdk-app-example/gradle/libs.versions.toml @@ -2,7 +2,7 @@ activityCompose = "1.8.0" agp = "8.10.0" appcompat = "1.6.1" -bugsnag-android = "6.24.0" +bugsnag-android = "6.25.0" bugsnag-gradle = "0.4.0" composeBom = "2024.09.00" coreKtx = "1.16.0" diff --git a/gradle.properties b/gradle.properties index c53c682627..21f4cf23e9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,7 +11,7 @@ org.gradle.jvmargs=-Xmx4096m # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects org.gradle.parallel=true -VERSION_NAME=6.24.0 +VERSION_NAME=6.25.0 GROUP=com.bugsnag POM_SCM_URL=https://github.com/bugsnag/bugsnag-android POM_SCM_CONNECTION=scm:git@github.com:bugsnag/bugsnag-android.git