From f6fcbcd3e5c55eded019d200c1d1de62630a8118 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 20 May 2026 18:20:53 +0200 Subject: [PATCH 01/19] feat(android): Add service-managed NDK crash daemon Run the NDK crash daemon from an isolated Android service process so it can release the crashing app, merge the resulting tombstone, and send the enriched envelope before shutdown. --- buildSrc/src/main/java/Config.kt | 1 + gradle/libs.versions.toml | 1 + scripts/update-sentry-native-ndk.sh | 2 +- .../api/sentry-android-core.api | 1 + .../core/AndroidOptionsInitializer.java | 2 +- .../sentry/android/core/NdkIntegration.java | 19 +- .../android/core/TombstoneIntegration.java | 2 +- sentry-android-ndk-outproc/README.md | 13 + .../api/sentry-android-ndk-outproc.api | 42 ++ sentry-android-ndk-outproc/build.gradle.kts | 100 +++ .../src/main/AndroidManifest.xml | 10 + .../java/io/sentry/android/ndk/SentryNdk.java | 166 +++++ .../android/ndk/SentryNdkCrashService.java | 703 ++++++++++++++++++ .../sentry/android/ndk/NativeBackendTest.kt | 59 ++ sentry-android-ndk/api/sentry-android-ndk.api | 2 + sentry-android-ndk/build.gradle.kts | 5 + .../java/io/sentry/android/ndk/SentryNdk.java | 12 + .../io/sentry/android/ndk/SentryNdkUtil.java | 5 +- .../sentry/android/ndk/SentryNdkUtilTest.kt | 4 +- settings.gradle.kts | 1 + 20 files changed, 1140 insertions(+), 10 deletions(-) create mode 100644 sentry-android-ndk-outproc/README.md create mode 100644 sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api create mode 100644 sentry-android-ndk-outproc/build.gradle.kts create mode 100644 sentry-android-ndk-outproc/src/main/AndroidManifest.xml create mode 100644 sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java create mode 100644 sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java create mode 100644 sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt rename sentry-android-ndk/src/{main => inproc}/java/io/sentry/android/ndk/SentryNdk.java (93%) diff --git a/buildSrc/src/main/java/Config.kt b/buildSrc/src/main/java/Config.kt index 05786658ef3..647986ecdd1 100644 --- a/buildSrc/src/main/java/Config.kt +++ b/buildSrc/src/main/java/Config.kt @@ -97,6 +97,7 @@ object Config { val androidLibs = setOf( "sentry-android-core", "sentry-android-ndk", + "sentry-android-ndk-outproc", "sentry-android-fragment", "sentry-android-navigation", "sentry-android-navigation3", diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e5c2f9e2171..f353794eae3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -185,6 +185,7 @@ reactor-core = { module = "io.projectreactor:reactor-core", version = "3.5.3" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version.ref = "retrofit" } sentry-native-ndk = { module = "io.sentry:sentry-native-ndk", version = "0.17.4" } +sentry-native-ndk-outproc = { module = "io.sentry:sentry-native-ndk-outproc", version = "0.17.4" } servlet-api = { module = "javax.servlet:javax.servlet-api", version = "3.1.0" } servlet-jakarta-api = { module = "jakarta.servlet:jakarta.servlet-api", version = "6.1.0" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } diff --git a/scripts/update-sentry-native-ndk.sh b/scripts/update-sentry-native-ndk.sh index 0aef9ebb257..fa5aa12a636 100755 --- a/scripts/update-sentry-native-ndk.sh +++ b/scripts/update-sentry-native-ndk.sh @@ -16,7 +16,7 @@ set-version) echo "Setting sentry-native-ndk version to '$version'" - PATTERN='(module = "io\.sentry:sentry-native-ndk", version = ")[0-9.]+(")' + PATTERN='(module = "io\.sentry:sentry-native-ndk(?:-outproc)?", version = ")[0-9.]+(")' perl -pi -e "s/$PATTERN/\${1}$version\${2}/" "$GRADLE_NDK_FILEPATH" ;; *) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index e8b95794ea0..e6b08aeae4d 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -664,6 +664,7 @@ public class io/sentry/android/core/TombstoneIntegration$TombstonePolicy : io/se public fun getLastReportedTimestamp ()Ljava/lang/Long; public fun markReported (J)V public fun matches (Landroid/app/ApplicationExitInfo;)Z + public static fun mergeNativeCrashes (Lio/sentry/SentryEvent;Lio/sentry/SentryEvent;)V public fun shouldReportHistorical ()Z } diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java index 7626acfb1ba..18735929337 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/AndroidOptionsInitializer.java @@ -436,7 +436,7 @@ static void installDefaultIntegrations( // Integrations are registered in the same order. NDK before adding Watch outbox, // because sentry-native move files around and we don't want to watch that. final Class sentryNdkClass = loadClass.loadClass(SENTRY_NDK_CLASS_NAME, options.getLogger()); - options.addIntegration(new NdkIntegration(sentryNdkClass)); + options.addIntegration(new NdkIntegration(context, sentryNdkClass)); if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.S) { options.addIntegration(new TombstoneIntegration(context)); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java index 8353a32d65d..2ec1535efb7 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java @@ -2,6 +2,7 @@ import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; +import android.content.Context; import io.sentry.IScopes; import io.sentry.Integration; import io.sentry.SentryLevel; @@ -19,11 +20,17 @@ public final class NdkIntegration implements Integration, Closeable { public static final String SENTRY_NDK_CLASS_NAME = "io.sentry.android.ndk.SentryNdk"; + private final @Nullable Context context; private final @Nullable Class sentryNdkClass; private @Nullable SentryAndroidOptions options; public NdkIntegration(final @Nullable Class sentryNdkClass) { + this(null, sentryNdkClass); + } + + NdkIntegration(final @Nullable Context context, final @Nullable Class sentryNdkClass) { + this.context = context != null ? ContextUtils.getApplicationContext(context) : null; this.sentryNdkClass = sentryNdkClass; } @@ -50,9 +57,15 @@ public final void register(final @NotNull IScopes scopes, final @NotNull SentryO } try { - final Method method = sentryNdkClass.getMethod("init", SentryAndroidOptions.class); - final Object[] args = new Object[1]; - args[0] = this.options; + final Method method; + final Object[] args; + if (context != null) { + method = sentryNdkClass.getMethod("init", Context.class, SentryAndroidOptions.class); + args = new Object[] {context, this.options}; + } else { + method = sentryNdkClass.getMethod("init", SentryAndroidOptions.class); + args = new Object[] {this.options}; + } method.invoke(null, args); this.options.getLogger().log(SentryLevel.DEBUG, "NdkIntegration installed."); diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java index 43de4034ec5..832d1bb08a1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/TombstoneIntegration.java @@ -289,7 +289,7 @@ private void addNativeAttachmentsToTombstoneHint( } } - private void mergeNativeCrashes( + public static void mergeNativeCrashes( final @NotNull SentryEvent nativeEvent, final @NotNull SentryEvent tombstoneEvent) { // we take the event data verbatim from the Native SDK and only apply tombstone data where we // are sure that it will improve the outcome: diff --git a/sentry-android-ndk-outproc/README.md b/sentry-android-ndk-outproc/README.md new file mode 100644 index 00000000000..816f233535b --- /dev/null +++ b/sentry-android-ndk-outproc/README.md @@ -0,0 +1,13 @@ +# sentry-android-ndk-outproc + +Experimental opt-in alternative to `sentry-android-ndk`, with an Android service +that runs the native crash daemon. Depends on `sentry-native-ndk-outproc`. Select +it through the Sentry Gradle plugin: + +```kotlin +sentry { + ndkBackend.set("native") +} +``` + +The default is the inproc backend. diff --git a/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api b/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api new file mode 100644 index 00000000000..9d8d47c8f69 --- /dev/null +++ b/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api @@ -0,0 +1,42 @@ +public final class io/sentry/android/ndk/BuildConfig { + public static final field BUILD_TYPE Ljava/lang/String; + public static final field DEBUG Z + public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field SENTRY_PACKAGE_NAME Ljava/lang/String; + public static final field VERSION_NAME Ljava/lang/String; + public fun ()V +} + +public final class io/sentry/android/ndk/DebugImagesLoader : io/sentry/android/core/IDebugImagesLoader { + public fun (Lio/sentry/android/core/SentryAndroidOptions;Lio/sentry/ndk/NativeModuleListLoader;)V + public fun clearDebugImages ()V + public fun loadDebugImages ()Ljava/util/List; + public fun loadDebugImagesForAddresses (Ljava/util/Set;)Ljava/util/Set; +} + +public final class io/sentry/android/ndk/NdkScopeObserver : io/sentry/ScopeObserverAdapter { + public fun (Lio/sentry/SentryOptions;)V + public fun addAttachment (Lio/sentry/Attachment;)V + public fun addBreadcrumb (Lio/sentry/Breadcrumb;)V + public fun clearAttachments ()V + public fun removeExtra (Ljava/lang/String;)V + public fun removeTag (Ljava/lang/String;)V + public fun setExtra (Ljava/lang/String;Ljava/lang/String;)V + public fun setTag (Ljava/lang/String;Ljava/lang/String;)V + public fun setTrace (Lio/sentry/SpanContext;Lio/sentry/IScope;)V + public fun setUser (Lio/sentry/protocol/User;)V +} + +public final class io/sentry/android/ndk/SentryNdk { + public static fun close ()V + public static fun init (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)V + public static fun init (Lio/sentry/android/core/SentryAndroidOptions;)V +} + +public final class io/sentry/android/ndk/SentryNdkCrashService : android/app/Service { + public fun ()V + public fun onBind (Landroid/content/Intent;)Landroid/os/IBinder; + public fun onDestroy ()V + public fun onStartCommand (Landroid/content/Intent;II)I +} + diff --git a/sentry-android-ndk-outproc/build.gradle.kts b/sentry-android-ndk-outproc/build.gradle.kts new file mode 100644 index 00000000000..75c7a2e75d5 --- /dev/null +++ b/sentry-android-ndk-outproc/build.gradle.kts @@ -0,0 +1,100 @@ +import org.jetbrains.kotlin.config.KotlinCompilerVersion + +plugins { + id("com.android.library") + alias(libs.plugins.kotlin.android) + alias(libs.plugins.gradle.versions) +} + +android { + compileSdk = libs.versions.compileSdk.get().toInt() + namespace = "io.sentry.android.ndk" + + defaultConfig { + minSdk = libs.versions.minSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + ndk { abiFilters.addAll(Config.Android.abiFilters) } + + // for AGP 4.1 + buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + buildConfigField("String", "SENTRY_PACKAGE_NAME", "\"${project.name}\"") + } + + buildTypes { + getByName("debug") { consumerProguardFiles("../sentry-android-ndk/proguard-rules.pro") } + getByName("release") { consumerProguardFiles("../sentry-android-ndk/proguard-rules.pro") } + } + + // AGP 9 only generates unit tests for the testBuildType. The debug variant is + // disabled, so unit tests must target release to run at all. + testBuildType = "release" + + kotlin { compilerOptions.jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_1_8 } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + + // We run a full lint analysis as build part in CI, so skip vital checks for assemble tasks. + checkReleaseBuilds = false + } + + // needed because of Kotlin 1.4.x + configurations.all { resolutionStrategy.force(libs.jetbrains.annotations.get()) } + + buildFeatures { buildConfig = true } + + androidComponents.beforeVariants { + it.enable = !Config.Android.shouldSkipDebugVariant(it.buildType) + } + + // the default AGP version is too high, we keep this low for compatibility reasons + // see https://developer.android.com/build/releases/past-releases/ to find the AGP-NDK mapping + ndkVersion = "23.1.7779620" + + @Suppress("UnstableApiUsage") packagingOptions { jniLibs { useLegacyPackaging = true } } +} + +dependencies { + api(projects.sentry) + api(projects.sentryAndroidCore) + + compileOnly(libs.jetbrains.annotations) + + implementation(libs.sentry.native.ndk.outproc) + + testImplementation(kotlin(Config.kotlinStdLib, KotlinCompilerVersion.VERSION)) + testImplementation(libs.kotlin.test.junit) + testImplementation(libs.mockito.kotlin) + testImplementation(projects.sentryTestSupport) + testImplementation(libs.google.truth) + testImplementation(libs.roboelectric) +} + +configurations + .matching { + it.name == "releaseApiElements" || + it.name == "releaseRuntimeElements" || + it.name == "releaseVariantReleaseApiPublication" || + it.name == "releaseVariantReleaseRuntimePublication" + } + .configureEach { + outgoing.capability("${project.group}:${project.name}:${project.version}") + outgoing.capability("${project.group}:sentry-android-ndk:${project.version}") + } + +extensions.configure { + sourceSets.getByName("main").java.srcDir("../sentry-android-ndk/src/main/java") + sourceSets.getByName("test").java.srcDir("../sentry-android-ndk/src/test/java") + sourceSets.getByName("test").resources.srcDir("../sentry-android-ndk/src/test/resources") +} diff --git a/sentry-android-ndk-outproc/src/main/AndroidManifest.xml b/sentry-android-ndk-outproc/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..360057efb94 --- /dev/null +++ b/sentry-android-ndk-outproc/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java new file mode 100644 index 00000000000..279b802dc70 --- /dev/null +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -0,0 +1,166 @@ +package io.sentry.android.ndk; + +import android.app.ActivityManager; +import android.app.Application; +import android.content.Context; +import android.os.Build; +import io.sentry.android.core.NdkHandlerStrategy; +import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.ndk.NativeModuleListLoader; +import io.sentry.ndk.NdkOptions; +import io.sentry.util.Objects; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class SentryNdk { + + private static final @NotNull CountDownLatch loadLibraryLatch = new CountDownLatch(1); + + private SentryNdk() {} + + static { + new Thread( + () -> { + try { + //noinspection UnstableApiUsage + io.sentry.ndk.SentryNdk.loadNativeLibraries(); + } catch (Throwable t) { + // ignored + // if loadLibrary() fails, the later init() will throw an exception anyway + } finally { + loadLibraryLatch.countDown(); + } + }, + "SentryNdkLoadLibs") + .start(); + } + + /** + * Init the NDK integration + * + * @param options the SentryAndroidOptions + */ + public static void init(@NotNull final SentryAndroidOptions options) { + init(null, options); + } + + /** + * Init the NDK integration + * + * @param context the Android context + * @param options the SentryAndroidOptions + */ + public static void init( + @Nullable final Context context, @NotNull final SentryAndroidOptions options) { + if (context != null && isCrashServiceProcess(context)) { + options.getLogger().log(io.sentry.SentryLevel.DEBUG, "Skipping NDK init in crash service."); + return; + } + + SentryNdkUtil.addPackage(options.getSdkVersion()); + try { + if (loadLibraryLatch.await(2000, TimeUnit.MILLISECONDS)) { + final @NotNull NdkOptions ndkOptions = + new NdkOptions( + Objects.requireNonNull(options.getDsn(), "DSN is required for sentry-ndk"), + options.isDebug(), + Objects.requireNonNull( + options.getOutboxPath(), "outbox path is required for sentry-ndk"), + options.getRelease(), + options.getEnvironment(), + options.getDist(), + options.getMaxBreadcrumbs(), + options.getNativeSdkName()); + + final int handlerStrategy = options.getNdkHandlerStrategy(); + if (handlerStrategy == NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_DEFAULT.getValue()) { + ndkOptions.setNdkHandlerStrategy( + io.sentry.ndk.NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_DEFAULT); + } else if (handlerStrategy + == NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_CHAIN_AT_START.getValue()) { + ndkOptions.setNdkHandlerStrategy( + io.sentry.ndk.NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_CHAIN_AT_START); + } + + ndkOptions.setTombstoneEnabled( + options.isTombstoneEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S); + + final @Nullable Double tracesSampleRate = options.getTracesSampleRate(); + if (tracesSampleRate == null) { + ndkOptions.setTracesSampleRate(0.0f); + } else { + ndkOptions.setTracesSampleRate(tracesSampleRate.floatValue()); + } + + ndkOptions.setEnableAppHangTracking(options.isEnableNdkAppHangTracking()); + ndkOptions.setAppHangTimeoutMillis(options.getNdkAppHangTimeoutIntervalMillis()); + + if (context != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + SentryNdkCrashService.start(context, options); + } + + //noinspection UnstableApiUsage + io.sentry.ndk.SentryNdk.init(ndkOptions); + + // only add scope sync observer if the scope sync is enabled. + if (options.isEnableScopeSync()) { + options.addScopeObserver(new NdkScopeObserver(options)); + } + + options.setDebugImagesLoader(new DebugImagesLoader(options, new NativeModuleListLoader())); + } else { + throw new IllegalStateException("Timeout waiting for Sentry NDK library to load"); + } + } catch (InterruptedException e) { + throw new IllegalStateException( + "Thread interrupted while waiting for NDK libs to be loaded", e); + } + } + + /** Closes the NDK integration */ + public static void close() { + try { + if (loadLibraryLatch.await(2000, TimeUnit.MILLISECONDS)) { + //noinspection UnstableApiUsage + io.sentry.ndk.SentryNdk.close(); + } else { + throw new IllegalStateException("Timeout waiting for Sentry NDK library to load"); + } + } catch (InterruptedException e) { + throw new IllegalStateException( + "Thread interrupted while waiting for NDK libs to be loaded", e); + } + } + + private static boolean isCrashServiceProcess(final @NotNull Context context) { + final @NotNull String crashServiceProcess = context.getPackageName() + ".sentry_ndk_crash"; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + return crashServiceProcess.equals(Application.getProcessName()); + } + + final @Nullable ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + return false; + } + + final int pid = android.os.Process.myPid(); + final @Nullable List processes = + activityManager.getRunningAppProcesses(); + if (processes == null) { + return false; + } + + for (ActivityManager.RunningAppProcessInfo process : processes) { + if (process.pid == pid) { + return crashServiceProcess.equals(process.processName); + } + } + return false; + } +} diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java new file mode 100644 index 00000000000..714e14b6d8d --- /dev/null +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -0,0 +1,703 @@ +package io.sentry.android.ndk; + +import android.annotation.SuppressLint; +import android.app.ActivityManager; +import android.app.ApplicationExitInfo; +import android.app.Service; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.ServiceConnection; +import android.os.Binder; +import android.os.Build; +import android.os.IBinder; +import android.os.Parcel; +import android.os.ParcelFileDescriptor; +import android.os.Process; +import android.os.RemoteException; +import io.sentry.DateUtils; +import io.sentry.SentryEnvelope; +import io.sentry.SentryEnvelopeItem; +import io.sentry.SentryEvent; +import io.sentry.SentryItemType; +import io.sentry.SentryLevel; +import io.sentry.android.core.AndroidLogger; +import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.core.TombstoneIntegration; +import io.sentry.android.core.cache.AndroidEnvelopeCache; +import io.sentry.android.core.internal.tombstone.TombstoneParser; +import io.sentry.ndk.SentryNdk.CrashDaemon; +import java.io.BufferedInputStream; +import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +@ApiStatus.Internal +public final class SentryNdkCrashService extends Service { + + private static final @NotNull String EXTRA_NATIVE_DATABASE_PATH = + "io.sentry.android.ndk.NATIVE_DATABASE_PATH"; + private static final @NotNull String EXTRA_NATIVE_LIBRARY_DIR = + "io.sentry.android.ndk.NATIVE_LIBRARY_DIR"; + private static final @NotNull String EXTRA_TOMBSTONE_ENABLED = + "io.sentry.android.ndk.TOMBSTONE_ENABLED"; + private static final @NotNull String EXTRA_DEBUG = "io.sentry.android.ndk.DEBUG"; + private static final @NotNull String EXTRA_SHUTDOWN_TIMEOUT_MILLIS = + "io.sentry.android.ndk.SHUTDOWN_TIMEOUT_MILLIS"; + private static final @NotNull String EXTRA_IN_APP_INCLUDES = + "io.sentry.android.ndk.IN_APP_INCLUDES"; + private static final @NotNull String EXTRA_IN_APP_EXCLUDES = + "io.sentry.android.ndk.IN_APP_EXCLUDES"; + private static final @NotNull String BINDER_DESCRIPTOR = "io.sentry.android.ndk.CrashService"; + private static final int TRANSACTION_CONFIGURE = IBinder.FIRST_CALL_TRANSACTION; + + private static final @NotNull String MERGED_SUFFIX = ".merged"; + private static final long POLL_INTERVAL_MILLIS = 250; + private static final long MATCH_TOLERANCE_MILLIS = 30000; + + private final @NotNull Object lock = new Object(); + + private @Nullable Thread daemonWorker; + private volatile boolean running = false; + private @Nullable File nativeDatabaseDir; + private @Nullable String nativeLibraryDir; + private @Nullable String shmPath; + private int notifyReadFd = -1; + private int readyWriteFd = -1; + private int messageReadFd = -1; + private int appPid = -1; + private long appTid = 0; + private long shutdownTimeoutMillis = 0; + private @NotNull List inAppIncludes = new ArrayList<>(); + private @NotNull List inAppExcludes = new ArrayList<>(); + private boolean tombstoneEnabled = false; + private boolean debug = false; + private final @NotNull IBinder binder = + new Binder() { + @Override + protected boolean onTransact( + final int code, + final @NotNull Parcel data, + final @Nullable Parcel reply, + final int flags) + throws RemoteException { + if (code != TRANSACTION_CONFIGURE) { + return super.onTransact(code, data, reply, flags); + } + + data.enforceInterface(BINDER_DESCRIPTOR); + final @Nullable String nativeDatabasePath = data.readString(); + final @Nullable String nativeLibraryDir = data.readString(); + final @Nullable String shmPath = data.readString(); + final int appPid = data.readInt(); + final long appTid = data.readLong(); + final boolean tombstoneEnabled = data.readInt() == 1; + final boolean debug = data.readInt() == 1; + final long shutdownTimeoutMillis = data.readLong(); + final @Nullable ArrayList includes = data.createStringArrayList(); + final @Nullable ArrayList excludes = data.createStringArrayList(); + final @Nullable ParcelFileDescriptor notifyRead = data.readFileDescriptor(); + final @Nullable ParcelFileDescriptor readyWrite = data.readFileDescriptor(); + final @Nullable ParcelFileDescriptor messageRead = data.readFileDescriptor(); + + final boolean configured = + configureDaemon( + nativeDatabasePath, + nativeLibraryDir, + shmPath, + appPid, + appTid, + tombstoneEnabled, + debug, + shutdownTimeoutMillis, + includes, + excludes, + notifyRead, + readyWrite, + messageRead); + if (reply != null) { + reply.writeNoException(); + reply.writeInt(configured ? 1 : 0); + } + return true; + } + }; + + static boolean start( + final @NotNull Context context, final @NotNull SentryAndroidOptions options) { + final @Nullable String outboxPath = options.getOutboxPath(); + if (outboxPath == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Outbox path is null, not starting NDK crash service."); + return false; + } + + final @Nullable File cacheDir = new File(outboxPath).getParentFile(); + if (cacheDir == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Cache dir is null, not starting NDK crash service."); + return false; + } + + final File nativeDatabaseDir = new File(cacheDir, ".sentry-native"); + if (!nativeDatabaseDir.exists() && !nativeDatabaseDir.mkdirs()) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to create NDK database directory."); + return false; + } + + final long ipcId = System.nanoTime(); + final File shmFile = + new File(nativeDatabaseDir, ".sentry-shm-service-" + Long.toHexString(ipcId)); + + ParcelFileDescriptor notifyRead = null; + ParcelFileDescriptor notifyWrite = null; + ParcelFileDescriptor readyRead = null; + ParcelFileDescriptor readyWrite = null; + ParcelFileDescriptor messageWrite = null; + ParcelFileDescriptor messageRead = null; + try { + final ParcelFileDescriptor[] notifyPipe = ParcelFileDescriptor.createPipe(); + final ParcelFileDescriptor[] readyPipe = ParcelFileDescriptor.createPipe(); + notifyRead = notifyPipe[0]; + notifyWrite = notifyPipe[1]; + readyRead = readyPipe[0]; + readyWrite = readyPipe[1]; + final ParcelFileDescriptor[] messageSocket = ParcelFileDescriptor.createSocketPair(); + messageWrite = messageSocket[0]; + messageRead = messageSocket[1]; + + final Intent intent = new Intent(context, SentryNdkCrashService.class); + putServiceOptions(context, options, nativeDatabaseDir, intent); + + context.startService(intent); + final boolean configured = + configureService( + context, + intent, + nativeDatabaseDir.getPath(), + context.getApplicationInfo().nativeLibraryDir, + shmFile.getPath(), + Process.myPid(), + ipcId, + options.isTombstoneEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S, + options.isDebug(), + options.getShutdownTimeoutMillis(), + new ArrayList<>(options.getInAppIncludes()), + new ArrayList<>(options.getInAppExcludes()), + notifyRead, + readyWrite, + messageRead); + if (!configured) { + options.getLogger().log(SentryLevel.DEBUG, "NDK crash service did not configure."); + return false; + } + + io.sentry.ndk.SentryNdk.initCrashDaemon( + shmFile.getPath(), notifyWrite.detachFd(), readyRead.detachFd(), messageWrite.detachFd()); + return true; + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Failed to start NDK crash service."); + return false; + } finally { + closeQuietly(notifyRead); + closeQuietly(notifyWrite); + closeQuietly(readyRead); + closeQuietly(readyWrite); + closeQuietly(messageWrite); + closeQuietly(messageRead); + } + } + + @Override + public int onStartCommand(final @Nullable Intent intent, final int flags, final int startId) { + if (intent != null) { + final @Nullable String nativeDatabasePath = intent.getStringExtra(EXTRA_NATIVE_DATABASE_PATH); + if (nativeDatabasePath != null) { + nativeDatabaseDir = new File(nativeDatabasePath); + } + nativeLibraryDir = intent.getStringExtra(EXTRA_NATIVE_LIBRARY_DIR); + tombstoneEnabled = intent.getBooleanExtra(EXTRA_TOMBSTONE_ENABLED, false); + debug = intent.getBooleanExtra(EXTRA_DEBUG, false); + shutdownTimeoutMillis = intent.getLongExtra(EXTRA_SHUTDOWN_TIMEOUT_MILLIS, 0); + final @Nullable ArrayList includes = + intent.getStringArrayListExtra(EXTRA_IN_APP_INCLUDES); + final @Nullable ArrayList excludes = + intent.getStringArrayListExtra(EXTRA_IN_APP_EXCLUDES); + inAppIncludes = includes != null ? includes : new ArrayList<>(); + inAppExcludes = excludes != null ? excludes : new ArrayList<>(); + } + + synchronized (lock) { + startWorkersLocked(); + } + return START_NOT_STICKY; + } + + @Override + public void onDestroy() { + running = false; + final @Nullable Thread daemonThread = daemonWorker; + if (daemonThread != null) { + daemonThread.interrupt(); + } + super.onDestroy(); + } + + @Override + public @Nullable IBinder onBind(final @NotNull Intent intent) { + return binder; + } + + private static void closeQuietly(final @Nullable Closeable closeable) { + if (closeable != null) { + try { + closeable.close(); + } catch (IOException ignored) { + // ignored + } + } + } + + private static void putServiceOptions( + final @NotNull Context context, + final @NotNull SentryAndroidOptions options, + final @NotNull File nativeDatabaseDir, + final @NotNull Intent intent) { + intent.putExtra(EXTRA_NATIVE_DATABASE_PATH, nativeDatabaseDir.getPath()); + intent.putExtra(EXTRA_NATIVE_LIBRARY_DIR, context.getApplicationInfo().nativeLibraryDir); + intent.putExtra( + EXTRA_TOMBSTONE_ENABLED, + options.isTombstoneEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S); + intent.putExtra(EXTRA_DEBUG, options.isDebug()); + intent.putExtra(EXTRA_SHUTDOWN_TIMEOUT_MILLIS, options.getShutdownTimeoutMillis()); + intent.putStringArrayListExtra( + EXTRA_IN_APP_INCLUDES, new ArrayList<>(options.getInAppIncludes())); + intent.putStringArrayListExtra( + EXTRA_IN_APP_EXCLUDES, new ArrayList<>(options.getInAppExcludes())); + } + + private static boolean configureService( + final @NotNull Context context, + final @NotNull Intent intent, + final @NotNull String nativeDatabasePath, + final @Nullable String nativeLibraryDir, + final @NotNull String shmPath, + final int appPid, + final long appTid, + final boolean tombstoneEnabled, + final boolean debug, + final long shutdownTimeoutMillis, + final @NotNull ArrayList includes, + final @NotNull ArrayList excludes, + final @NotNull ParcelFileDescriptor notifyRead, + final @NotNull ParcelFileDescriptor readyWrite, + final @NotNull ParcelFileDescriptor messageRead) + throws InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final boolean[] configured = new boolean[] {false}; + final boolean[] bound = new boolean[] {false}; + final ExecutorService executor = Executors.newSingleThreadExecutor(); + + final ServiceConnection connection = + new ServiceConnection() { + @Override + public void onServiceConnected( + final @NotNull ComponentName name, final @NotNull IBinder service) { + configured[0] = + transactConfigure( + service, + nativeDatabasePath, + nativeLibraryDir, + shmPath, + appPid, + appTid, + tombstoneEnabled, + debug, + shutdownTimeoutMillis, + includes, + excludes, + notifyRead, + readyWrite, + messageRead); + latch.countDown(); + } + + @Override + public void onServiceDisconnected(final @NotNull ComponentName name) { + latch.countDown(); + } + }; + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + bound[0] = context.bindService(intent, Context.BIND_AUTO_CREATE, executor, connection); + } else { + bound[0] = context.bindService(intent, connection, Context.BIND_AUTO_CREATE); + } + if (!bound[0]) { + executor.shutdown(); + return false; + } + try { + return latch.await(10000, TimeUnit.MILLISECONDS) && configured[0]; + } finally { + if (bound[0]) { + context.unbindService(connection); + } + executor.shutdown(); + } + } + + private static boolean transactConfigure( + final @NotNull IBinder service, + final @NotNull String nativeDatabasePath, + final @Nullable String nativeLibraryDir, + final @NotNull String shmPath, + final int appPid, + final long appTid, + final boolean tombstoneEnabled, + final boolean debug, + final long shutdownTimeoutMillis, + final @NotNull ArrayList includes, + final @NotNull ArrayList excludes, + final @NotNull ParcelFileDescriptor notifyRead, + final @NotNull ParcelFileDescriptor readyWrite, + final @NotNull ParcelFileDescriptor messageRead) { + final Parcel data = Parcel.obtain(); + final Parcel reply = Parcel.obtain(); + try { + data.writeInterfaceToken(BINDER_DESCRIPTOR); + data.writeString(nativeDatabasePath); + data.writeString(nativeLibraryDir); + data.writeString(shmPath); + data.writeInt(appPid); + data.writeLong(appTid); + data.writeInt(tombstoneEnabled ? 1 : 0); + data.writeInt(debug ? 1 : 0); + data.writeLong(shutdownTimeoutMillis); + data.writeStringList(includes); + data.writeStringList(excludes); + data.writeFileDescriptor(notifyRead.getFileDescriptor()); + data.writeFileDescriptor(readyWrite.getFileDescriptor()); + data.writeFileDescriptor(messageRead.getFileDescriptor()); + if (!service.transact(TRANSACTION_CONFIGURE, data, reply, 0)) { + return false; + } + reply.readException(); + return reply.readInt() == 1; + } catch (Throwable ignored) { + return false; + } finally { + data.recycle(); + reply.recycle(); + } + } + + private boolean configureDaemon( + final @Nullable String nativeDatabasePath, + final @Nullable String nativeLibraryDir, + final @Nullable String shmPath, + final int appPid, + final long appTid, + final boolean tombstoneEnabled, + final boolean debug, + final long shutdownTimeoutMillis, + final @Nullable ArrayList includes, + final @Nullable ArrayList excludes, + final @Nullable ParcelFileDescriptor notifyRead, + final @Nullable ParcelFileDescriptor readyWrite, + final @Nullable ParcelFileDescriptor messageRead) { + if (nativeDatabasePath == null + || shmPath == null + || notifyRead == null + || readyWrite == null + || messageRead == null + || appPid < 0) { + return false; + } + + synchronized (lock) { + nativeDatabaseDir = new File(nativeDatabasePath); + this.nativeLibraryDir = nativeLibraryDir; + this.shmPath = shmPath; + this.appPid = appPid; + this.appTid = appTid; + this.tombstoneEnabled = tombstoneEnabled; + this.debug = debug; + this.shutdownTimeoutMillis = shutdownTimeoutMillis; + inAppIncludes = includes != null ? includes : new ArrayList<>(); + inAppExcludes = excludes != null ? excludes : new ArrayList<>(); + notifyReadFd = notifyRead.detachFd(); + readyWriteFd = readyWrite.detachFd(); + messageReadFd = messageRead.detachFd(); + startWorkersLocked(); + } + return true; + } + + private void startWorkersLocked() { + if (!running) { + running = true; + } + if (daemonWorker == null && isDaemonConfigured()) { + daemonWorker = new Thread(this::runDaemonWorker, "SentryNdkCrashService"); + daemonWorker.start(); + } + } + + private void runDaemonWorker() { + final SentryAndroidOptions options = new SentryAndroidOptions(); + options.setLogger(new AndroidLogger("SentryNdkCrash")); + options.setDebug(debug); + + try { + while (running) { + final @Nullable String path = shmPath; + if (path == null + || notifyReadFd < 0 + || readyWriteFd < 0 + || messageReadFd < 0 + || appPid < 0) { + return; + } + + try (final @Nullable CrashDaemon daemon = + io.sentry.ndk.SentryNdk.runCrashDaemon( + appPid, appTid, notifyReadFd, readyWriteFd, messageReadFd, path)) { + if (daemon == null || !running) { + return; + } + final @Nullable String envelopePath = daemon.getEnvelopePath(); + if (envelopePath == null) { + return; + } + + final long deadlineMillis = System.currentTimeMillis() + shutdownTimeoutMillis; + final File envelope = new File(envelopePath); + final @Nullable File mergedEnvelope = + tombstoneEnabled ? waitForMergedEnvelope(options, envelope, deadlineMillis) : null; + if (tombstoneEnabled && mergedEnvelope == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Sending NDK crash without tombstone enrichment."); + } + + final long remainingMillis = Math.max(0, deadlineMillis - System.currentTimeMillis()); + if (!daemon.sendEnvelope( + mergedEnvelope != null ? mergedEnvelope.getPath() : envelope.getPath(), + remainingMillis)) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to send NDK crash envelope."); + } + } + return; + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Error running NDK crash service."); + } finally { + synchronized (lock) { + daemonWorker = null; + notifyReadFd = -1; + readyWriteFd = -1; + messageReadFd = -1; + appPid = -1; + appTid = 0; + shmPath = null; + } + } + } + + private boolean isDaemonConfigured() { + return shmPath != null + && notifyReadFd >= 0 + && readyWriteFd >= 0 + && messageReadFd >= 0 + && appPid >= 0; + } + + private @Nullable File waitForMergedEnvelope( + final @NotNull SentryAndroidOptions options, + final @NotNull File envelope, + final long deadlineMillis) + throws InterruptedException { + do { + final @Nullable File mergedEnvelope = processEnvelope(options, envelope); + if (mergedEnvelope != null) { + return mergedEnvelope; + } + + final long remainingMillis = deadlineMillis - System.currentTimeMillis(); + if (remainingMillis <= 0) { + return null; + } + Thread.sleep(Math.min(POLL_INTERVAL_MILLIS, remainingMillis)); + } while (running); + return null; + } + + @SuppressLint("NewApi") + private @Nullable File processEnvelope( + final @NotNull SentryAndroidOptions options, final @NotNull File file) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return null; + } + + final @Nullable NativeEnvelope nativeEnvelope = readNativeEnvelope(options, file); + if (nativeEnvelope == null) { + return null; + } + + final @Nullable Date nativeTimestamp = nativeEnvelope.event.getTimestamp(); + if (nativeTimestamp == null) { + return null; + } + + final @Nullable ApplicationExitInfo exitInfo = findMatchingExitInfo(options, nativeTimestamp); + if (exitInfo == null) { + return null; + } + + try (final @Nullable InputStream tombstoneInputStream = exitInfo.getTraceInputStream()) { + if (tombstoneInputStream == null) { + return null; + } + + final SentryEvent tombstoneEvent; + try (final TombstoneParser parser = + new TombstoneParser( + tombstoneInputStream, inAppIncludes, inAppExcludes, nativeLibraryDir)) { + tombstoneEvent = parser.parse(); + } + tombstoneEvent.setTimestamp(DateUtils.getDateTime(exitInfo.getTimestamp())); + + TombstoneIntegration.TombstonePolicy.mergeNativeCrashes(nativeEnvelope.event, tombstoneEvent); + final File mergedEnvelope = writeMergedEnvelope(options, file, nativeEnvelope); + writeLastTombstoneMarker(file, exitInfo.getTimestamp()); + return mergedEnvelope; + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Failed to merge NDK tombstone envelope."); + } + return null; + } + + @SuppressLint("NewApi") + private @Nullable ApplicationExitInfo findMatchingExitInfo( + final @NotNull SentryAndroidOptions options, final @NotNull Date nativeTimestamp) { + final @Nullable ActivityManager activityManager = + (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); + if (activityManager == null) { + return null; + } + + final List exitInfos = + activityManager.getHistoricalProcessExitReasons(null, 0, 10); + ApplicationExitInfo bestMatch = null; + long bestDiff = Long.MAX_VALUE; + for (ApplicationExitInfo exitInfo : exitInfos) { + if (exitInfo.getReason() != ApplicationExitInfo.REASON_CRASH_NATIVE) { + continue; + } + final long diff = Math.abs(exitInfo.getTimestamp() - nativeTimestamp.getTime()); + if (diff < bestDiff) { + bestDiff = diff; + bestMatch = exitInfo; + } + } + + if (bestMatch != null && bestDiff <= MATCH_TOLERANCE_MILLIS) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Matched NDK tombstone with diff %d ms.", bestDiff); + return bestMatch; + } + return null; + } + + private @Nullable NativeEnvelope readNativeEnvelope( + final @NotNull SentryAndroidOptions options, final @NotNull File file) { + try (final InputStream stream = new BufferedInputStream(new FileInputStream(file))) { + final @Nullable SentryEnvelope envelope = options.getEnvelopeReader().read(stream); + if (envelope == null) { + return null; + } + for (SentryEnvelopeItem item : envelope.getItems()) { + if (item.getHeader().getType() != SentryItemType.Event) { + continue; + } + final @Nullable SentryEvent event = item.getEvent(options.getSerializer()); + if (event != null && "native".equals(event.getPlatform())) { + return new NativeEnvelope(envelope, event); + } + } + } catch (Throwable e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Failed to read NDK envelope."); + } + return null; + } + + private @NotNull File writeMergedEnvelope( + final @NotNull SentryAndroidOptions options, + final @NotNull File sourceFile, + final @NotNull NativeEnvelope nativeEnvelope) + throws Exception { + final List items = new ArrayList<>(); + items.add(SentryEnvelopeItem.fromEvent(options.getSerializer(), nativeEnvelope.event)); + for (SentryEnvelopeItem item : nativeEnvelope.envelope.getItems()) { + if (item.getHeader().getType() != SentryItemType.Event) { + items.add(item); + } + } + + final SentryEnvelope mergedEnvelope = + new SentryEnvelope(nativeEnvelope.envelope.getHeader(), items); + final File mergedFile = new File(sourceFile.getPath() + MERGED_SUFFIX); + final File tmpFile = new File(mergedFile.getPath() + ".tmp"); + try (final OutputStream outputStream = new FileOutputStream(tmpFile)) { + options.getSerializer().serialize(mergedEnvelope, outputStream); + } + if (!tmpFile.renameTo(mergedFile)) { + throw new IllegalStateException("Failed to publish merged envelope"); + } + options.getLogger().log(SentryLevel.DEBUG, "Wrote merged NDK tombstone envelope."); + return mergedFile; + } + + private void writeLastTombstoneMarker(final @NotNull File envelopeFile, final long timestamp) + throws Exception { + final @Nullable File runDir = envelopeFile.getParentFile(); + final @Nullable File nativeDir = runDir != null ? runDir.getParentFile() : null; + final @Nullable File cacheDir = nativeDir != null ? nativeDir.getParentFile() : null; + if (cacheDir == null) { + return; + } + final File marker = new File(cacheDir, AndroidEnvelopeCache.LAST_TOMBSTONE_REPORT); + try (final OutputStream outputStream = new FileOutputStream(marker)) { + outputStream.write(String.valueOf(timestamp).getBytes("UTF-8")); + } + } + + private static final class NativeEnvelope { + private final @NotNull SentryEnvelope envelope; + private final @NotNull SentryEvent event; + + private NativeEnvelope( + final @NotNull SentryEnvelope envelope, final @NotNull SentryEvent event) { + this.envelope = envelope; + this.event = event; + } + } +} diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt new file mode 100644 index 00000000000..83a924f7c98 --- /dev/null +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt @@ -0,0 +1,59 @@ +package io.sentry.android.ndk + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.ndk.NdkOptions +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31]) +class NativeBackendTest { + @Test + fun `starts service with tombstones disabled`() { + initialize(false) { assertThat(it.isTombstoneEnabled).isFalse() } + } + + @Test + fun `enables supported tombstone enrichment`() { + initialize(true) { assertThat(it.isTombstoneEnabled).isTrue() } + } + + @Test + @Config(sdk = [30]) + fun `disables unavailable tombstone enrichment`() { + initialize(true) { assertThat(it.isTombstoneEnabled).isFalse() } + } + + private fun initialize(tombstones: Boolean, verify: (NdkOptions) -> Unit) { + val context = mock() + val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + cacheDirPath = "/cache" + isTombstoneEnabled = tombstones + } + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { native -> + Mockito.mockStatic(SentryNdkCrashService::class.java).use { service -> + var captured: NdkOptions? = null + native + .`when` { io.sentry.ndk.SentryNdk.init(any()) } + .doAnswer { captured = it.arguments[0] as NdkOptions } + service.`when` { SentryNdkCrashService.start(context, options) }.thenReturn(true) + + SentryNdk.init(context, options) + + service.verify { SentryNdkCrashService.start(context, options) } + assertThat(captured).isNotNull() + verify(captured!!) + } + } + } +} diff --git a/sentry-android-ndk/api/sentry-android-ndk.api b/sentry-android-ndk/api/sentry-android-ndk.api index a7c5571d0bb..3de4c0802d6 100644 --- a/sentry-android-ndk/api/sentry-android-ndk.api +++ b/sentry-android-ndk/api/sentry-android-ndk.api @@ -2,6 +2,7 @@ public final class io/sentry/android/ndk/BuildConfig { public static final field BUILD_TYPE Ljava/lang/String; public static final field DEBUG Z public static final field LIBRARY_PACKAGE_NAME Ljava/lang/String; + public static final field SENTRY_PACKAGE_NAME Ljava/lang/String; public static final field VERSION_NAME Ljava/lang/String; public fun ()V } @@ -28,6 +29,7 @@ public final class io/sentry/android/ndk/NdkScopeObserver : io/sentry/ScopeObser public final class io/sentry/android/ndk/SentryNdk { public static fun close ()V + public static fun init (Landroid/content/Context;Lio/sentry/android/core/SentryAndroidOptions;)V public static fun init (Lio/sentry/android/core/SentryAndroidOptions;)V } diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index dff85906ca1..ee850ae918b 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -20,6 +20,7 @@ android { // for AGP 4.1 buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + buildConfigField("String", "SENTRY_PACKAGE_NAME", "\"${project.name}\"") } buildTypes { @@ -78,3 +79,7 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(projects.sentryTestSupport) } + +extensions.configure { + sourceSets.getByName("main").java.srcDir("src/inproc/java") +} diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk/src/inproc/java/io/sentry/android/ndk/SentryNdk.java similarity index 93% rename from sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java rename to sentry-android-ndk/src/inproc/java/io/sentry/android/ndk/SentryNdk.java index fae652f7a64..e2339ca911d 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk/src/inproc/java/io/sentry/android/ndk/SentryNdk.java @@ -1,5 +1,6 @@ package io.sentry.android.ndk; +import android.content.Context; import io.sentry.android.core.NdkHandlerStrategy; import io.sentry.android.core.SentryAndroidOptions; import io.sentry.ndk.NativeModuleListLoader; @@ -41,6 +42,17 @@ private SentryNdk() {} * @param options the SentryAndroidOptions */ public static void init(@NotNull final SentryAndroidOptions options) { + init(null, options); + } + + /** + * Init the NDK integration + * + * @param context the Android context + * @param options the SentryAndroidOptions + */ + public static void init( + @Nullable final Context context, @NotNull final SentryAndroidOptions options) { SentryNdkUtil.addPackage(options.getSdkVersion()); try { if (loadLibraryLatch.await(2000, TimeUnit.MILLISECONDS)) { diff --git a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdkUtil.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdkUtil.java index 9f92d2d4d79..182c5ceb867 100644 --- a/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdkUtil.java +++ b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdkUtil.java @@ -11,7 +11,7 @@ final class SentryNdkUtil { static { SentryIntegrationPackageStorage.getInstance() - .addPackage("maven:io.sentry:sentry-android-ndk", BuildConfig.VERSION_NAME); + .addPackage("maven:io.sentry:" + BuildConfig.SENTRY_PACKAGE_NAME, BuildConfig.VERSION_NAME); } private SentryNdkUtil() {} @@ -25,6 +25,7 @@ static void addPackage(@Nullable final SdkVersion sdkVersion) { if (sdkVersion == null) { return; } - sdkVersion.addPackage("maven:io.sentry:sentry-android-ndk", BuildConfig.VERSION_NAME); + sdkVersion.addPackage( + "maven:io.sentry:" + BuildConfig.SENTRY_PACKAGE_NAME, BuildConfig.VERSION_NAME); } } diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkUtilTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkUtilTest.kt index a3f5e9a8f93..c4ca0203a67 100644 --- a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkUtilTest.kt +++ b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkUtilTest.kt @@ -12,8 +12,8 @@ class SentryNdkUtilTest { SentryNdkUtil.addPackage(options.sdkVersion) assertTrue( options.sdkVersion!!.packageSet.any { - it.name == "maven:io.sentry:sentry-android-ndk" - it.version == BuildConfig.VERSION_NAME + it.name == "maven:io.sentry:${BuildConfig.SENTRY_PACKAGE_NAME}" && + it.version == BuildConfig.VERSION_NAME } ) } diff --git a/settings.gradle.kts b/settings.gradle.kts index 413ab5fd59b..059761eec34 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -47,6 +47,7 @@ include( "sentry-android-distribution", "sentry-android-core", "sentry-android-ndk", + "sentry-android-ndk-outproc", "sentry-android", "sentry-android-timber", "sentry-android-fragment", From 222a9342d1db35d2f4474f08dc9c7a6bf52f3c17 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 21 Oct 2026 15:19:02 +0200 Subject: [PATCH 02/19] Update CHANGELOG.md --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index acca00fc3c3..4e27502b69c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Features + +- Add experimental, opt-in out-of-process crash reporting on Android ([#6347](https://github.com/getsentry/sentry-java/pull/6347)) + ### Fixes - Report the number of dropped spans in client reports when a batched span envelope item is discarded, instead of a quantity of 1 (e.g. for Flutter span streaming) ([#6265](https://github.com/getsentry/sentry-java/pull/6265)) From e34fbfe4ae3d589d031d9db11a03fd9a7071d27e Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 09:19:12 +0200 Subject: [PATCH 03/19] fix(android): Narrow exception handling in the NDK crash service Replace the broad `Throwable` catches in `SentryNdkCrashService` with the exceptions the guarded code can throw, and restore the interrupt flag when the service bind is interrupted. --- .../sentry/android/ndk/SentryNdkCrashService.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java index 714e14b6d8d..22c07a38944 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -211,7 +211,11 @@ static boolean start( io.sentry.ndk.SentryNdk.initCrashDaemon( shmFile.getPath(), notifyWrite.detachFd(), readyRead.detachFd(), messageWrite.detachFd()); return true; - } catch (Throwable e) { + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + options.getLogger().log(SentryLevel.DEBUG, e, "Failed to start NDK crash service."); + return false; + } catch (IOException | SecurityException | IllegalStateException e) { options.getLogger().log(SentryLevel.DEBUG, e, "Failed to start NDK crash service."); return false; } finally { @@ -400,7 +404,7 @@ private static boolean transactConfigure( } reply.readException(); return reply.readInt() == 1; - } catch (Throwable ignored) { + } catch (RemoteException | RuntimeException ignored) { return false; } finally { data.recycle(); @@ -506,7 +510,7 @@ private void runDaemonWorker() { } return; } - } catch (Throwable e) { + } catch (InterruptedException e) { options.getLogger().log(SentryLevel.DEBUG, e, "Error running NDK crash service."); } finally { synchronized (lock) { @@ -588,7 +592,7 @@ private boolean isDaemonConfigured() { final File mergedEnvelope = writeMergedEnvelope(options, file, nativeEnvelope); writeLastTombstoneMarker(file, exitInfo.getTimestamp()); return mergedEnvelope; - } catch (Throwable e) { + } catch (Exception e) { options.getLogger().log(SentryLevel.DEBUG, e, "Failed to merge NDK tombstone envelope."); } return null; @@ -643,7 +647,7 @@ private boolean isDaemonConfigured() { return new NativeEnvelope(envelope, event); } } - } catch (Throwable e) { + } catch (Exception e) { options.getLogger().log(SentryLevel.DEBUG, e, "Failed to read NDK envelope."); } return null; From 2ee8e510629bb8aedf3cd6407978c2ee62eee3bb Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 09:28:01 +0200 Subject: [PATCH 04/19] fix(android): Stop the NDK crash service when its daemon exits The service was started with `startService` but never stopped, so its process kept running after `Sentry.close()` or after the app process died. Call `stopSelf` with the start ID that was current when the worker started, so a newer start from a relaunched app is not stopped. --- .../java/io/sentry/android/ndk/SentryNdkCrashService.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java index 22c07a38944..8872de2e5c8 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -72,6 +72,7 @@ public final class SentryNdkCrashService extends Service { private final @NotNull Object lock = new Object(); private @Nullable Thread daemonWorker; + private int lastStartId = 0; private volatile boolean running = false; private @Nullable File nativeDatabaseDir; private @Nullable String nativeLibraryDir; @@ -248,6 +249,7 @@ public int onStartCommand(final @Nullable Intent intent, final int flags, final } synchronized (lock) { + lastStartId = startId; startWorkersLocked(); } return START_NOT_STICKY; @@ -459,12 +461,13 @@ private void startWorkersLocked() { running = true; } if (daemonWorker == null && isDaemonConfigured()) { - daemonWorker = new Thread(this::runDaemonWorker, "SentryNdkCrashService"); + final int startId = lastStartId; + daemonWorker = new Thread(() -> runDaemonWorker(startId), "SentryNdkCrashService"); daemonWorker.start(); } } - private void runDaemonWorker() { + private void runDaemonWorker(final int startId) { final SentryAndroidOptions options = new SentryAndroidOptions(); options.setLogger(new AndroidLogger("SentryNdkCrash")); options.setDebug(debug); @@ -522,6 +525,7 @@ private void runDaemonWorker() { appTid = 0; shmPath = null; } + stopSelf(startId); } } From a4f1febe599fe22fa143202d3710e54c6acee4be Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 09:44:00 +0200 Subject: [PATCH 05/19] fix(android): Match the NDK crash service stop to its own start A new service process receives `onBind` before `onStartCommand`, so a worker started from the binder configuration could capture a stale start ID and never stop the service. Tag each start with its shared memory path and stop only when the latest start belongs to the exiting worker. --- .../sentry/android/ndk/SentryNdkCrashService.java | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java index 8872de2e5c8..4becc34c39e 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -62,6 +62,7 @@ public final class SentryNdkCrashService extends Service { "io.sentry.android.ndk.IN_APP_INCLUDES"; private static final @NotNull String EXTRA_IN_APP_EXCLUDES = "io.sentry.android.ndk.IN_APP_EXCLUDES"; + private static final @NotNull String EXTRA_SHM_PATH = "io.sentry.android.ndk.SHM_PATH"; private static final @NotNull String BINDER_DESCRIPTOR = "io.sentry.android.ndk.CrashService"; private static final int TRANSACTION_CONFIGURE = IBinder.FIRST_CALL_TRANSACTION; @@ -73,6 +74,7 @@ public final class SentryNdkCrashService extends Service { private @Nullable Thread daemonWorker; private int lastStartId = 0; + private @Nullable String lastStartShmPath; private volatile boolean running = false; private @Nullable File nativeDatabaseDir; private @Nullable String nativeLibraryDir; @@ -185,6 +187,7 @@ static boolean start( final Intent intent = new Intent(context, SentryNdkCrashService.class); putServiceOptions(context, options, nativeDatabaseDir, intent); + intent.putExtra(EXTRA_SHM_PATH, shmFile.getPath()); context.startService(intent); final boolean configured = @@ -250,6 +253,7 @@ public int onStartCommand(final @Nullable Intent intent, final int flags, final synchronized (lock) { lastStartId = startId; + lastStartShmPath = intent != null ? intent.getStringExtra(EXTRA_SHM_PATH) : null; startWorkersLocked(); } return START_NOT_STICKY; @@ -461,20 +465,19 @@ private void startWorkersLocked() { running = true; } if (daemonWorker == null && isDaemonConfigured()) { - final int startId = lastStartId; - daemonWorker = new Thread(() -> runDaemonWorker(startId), "SentryNdkCrashService"); + final @Nullable String path = shmPath; + daemonWorker = new Thread(() -> runDaemonWorker(path), "SentryNdkCrashService"); daemonWorker.start(); } } - private void runDaemonWorker(final int startId) { + private void runDaemonWorker(final @Nullable String path) { final SentryAndroidOptions options = new SentryAndroidOptions(); options.setLogger(new AndroidLogger("SentryNdkCrash")); options.setDebug(debug); try { while (running) { - final @Nullable String path = shmPath; if (path == null || notifyReadFd < 0 || readyWriteFd < 0 @@ -524,8 +527,10 @@ private void runDaemonWorker(final int startId) { appPid = -1; appTid = 0; shmPath = null; + if (path != null && path.equals(lastStartShmPath)) { + stopSelf(lastStartId); + } } - stopSelf(startId); } } From b407d9f442c9d3c65bce5e4948225fcab33c7885 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 09:57:18 +0200 Subject: [PATCH 06/19] fix(android): Write the NDK tombstone marker after a successful send The marker was written as soon as the merged envelope existed. If the send then failed with a network error, the daemon discarded the envelope and the next launch skipped the tombstone as already reported, so the crash was lost. Write the marker only once the send succeeds. --- .../android/ndk/SentryNdkCrashService.java | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java index 4becc34c39e..625a85d603e 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -499,7 +499,7 @@ private void runDaemonWorker(final @Nullable String path) { final long deadlineMillis = System.currentTimeMillis() + shutdownTimeoutMillis; final File envelope = new File(envelopePath); - final @Nullable File mergedEnvelope = + final @Nullable MergedEnvelope mergedEnvelope = tombstoneEnabled ? waitForMergedEnvelope(options, envelope, deadlineMillis) : null; if (tombstoneEnabled && mergedEnvelope == null) { options @@ -509,14 +509,16 @@ private void runDaemonWorker(final @Nullable String path) { final long remainingMillis = Math.max(0, deadlineMillis - System.currentTimeMillis()); if (!daemon.sendEnvelope( - mergedEnvelope != null ? mergedEnvelope.getPath() : envelope.getPath(), + mergedEnvelope != null ? mergedEnvelope.file.getPath() : envelope.getPath(), remainingMillis)) { options.getLogger().log(SentryLevel.DEBUG, "Failed to send NDK crash envelope."); + } else if (mergedEnvelope != null) { + writeLastTombstoneMarker(envelope, mergedEnvelope.tombstoneTimestamp); } } return; } - } catch (InterruptedException e) { + } catch (InterruptedException | IOException e) { options.getLogger().log(SentryLevel.DEBUG, e, "Error running NDK crash service."); } finally { synchronized (lock) { @@ -542,13 +544,13 @@ private boolean isDaemonConfigured() { && appPid >= 0; } - private @Nullable File waitForMergedEnvelope( + private @Nullable MergedEnvelope waitForMergedEnvelope( final @NotNull SentryAndroidOptions options, final @NotNull File envelope, final long deadlineMillis) throws InterruptedException { do { - final @Nullable File mergedEnvelope = processEnvelope(options, envelope); + final @Nullable MergedEnvelope mergedEnvelope = processEnvelope(options, envelope); if (mergedEnvelope != null) { return mergedEnvelope; } @@ -563,7 +565,7 @@ private boolean isDaemonConfigured() { } @SuppressLint("NewApi") - private @Nullable File processEnvelope( + private @Nullable MergedEnvelope processEnvelope( final @NotNull SentryAndroidOptions options, final @NotNull File file) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { return null; @@ -599,8 +601,7 @@ private boolean isDaemonConfigured() { TombstoneIntegration.TombstonePolicy.mergeNativeCrashes(nativeEnvelope.event, tombstoneEvent); final File mergedEnvelope = writeMergedEnvelope(options, file, nativeEnvelope); - writeLastTombstoneMarker(file, exitInfo.getTimestamp()); - return mergedEnvelope; + return new MergedEnvelope(mergedEnvelope, exitInfo.getTimestamp()); } catch (Exception e) { options.getLogger().log(SentryLevel.DEBUG, e, "Failed to merge NDK tombstone envelope."); } @@ -690,7 +691,7 @@ private boolean isDaemonConfigured() { } private void writeLastTombstoneMarker(final @NotNull File envelopeFile, final long timestamp) - throws Exception { + throws IOException { final @Nullable File runDir = envelopeFile.getParentFile(); final @Nullable File nativeDir = runDir != null ? runDir.getParentFile() : null; final @Nullable File cacheDir = nativeDir != null ? nativeDir.getParentFile() : null; @@ -713,4 +714,14 @@ private NativeEnvelope( this.event = event; } } + + private static final class MergedEnvelope { + private final @NotNull File file; + private final long tombstoneTimestamp; + + private MergedEnvelope(final @NotNull File file, final long tombstoneTimestamp) { + this.file = file; + this.tombstoneTimestamp = tombstoneTimestamp; + } + } } From b2beb30b1ef018e07e4f20479d7f53fb49107fee Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 11:15:44 +0200 Subject: [PATCH 07/19] fix(android): Bind the NDK crash service for the app's lifetime `startService` throws when the app starts in the background, and the started service stopped about a minute after the app left the foreground, so a later crash found no daemon. Native init then fell back to fork/exec, which cannot exec `libsentry-crash.so` from inside the APK with default packaging, and stalled startup for 10 s. Bind the service with `BIND_AUTO_CREATE` and keep the binding until `SentryNdk.close()`, so the service process keeps the app's importance and is not frozen. Let crash processing continue after `onDestroy`, which runs as soon as a crashed app's binding goes away. Fail NDK init instead of falling back to fork/exec when the service is unavailable: below API 30, without a context, or when binding or configuring fails. Move the inproc `SentryNdkTest` to inproc-only test sources, since outproc init now requires a context. --- sentry-android-ndk-outproc/README.md | 3 + .../api/sentry-android-ndk-outproc.api | 2 - .../java/io/sentry/android/ndk/SentryNdk.java | 43 +++- .../android/ndk/SentryNdkCrashService.java | 202 ++++++------------ .../sentry/android/ndk/NativeBackendTest.kt | 70 +++++- .../android/ndk/SentryNdkCrashServiceTest.kt | 106 +++++++++ .../ndk/SentryShadowParcelFileDescriptor.kt | 12 ++ sentry-android-ndk/build.gradle.kts | 1 + .../io/sentry/android/ndk/SentryNdkTest.kt | 0 9 files changed, 280 insertions(+), 159 deletions(-) create mode 100644 sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt create mode 100644 sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryShadowParcelFileDescriptor.kt rename sentry-android-ndk/src/{test => inprocTest}/java/io/sentry/android/ndk/SentryNdkTest.kt (100%) diff --git a/sentry-android-ndk-outproc/README.md b/sentry-android-ndk-outproc/README.md index 816f233535b..9a21f46dc7f 100644 --- a/sentry-android-ndk-outproc/README.md +++ b/sentry-android-ndk-outproc/README.md @@ -11,3 +11,6 @@ sentry { ``` The default is the inproc backend. + +The crash service needs API 30+. On older versions, native crash reporting is +disabled. diff --git a/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api b/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api index 9d8d47c8f69..ea482e03037 100644 --- a/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api +++ b/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api @@ -36,7 +36,5 @@ public final class io/sentry/android/ndk/SentryNdk { public final class io/sentry/android/ndk/SentryNdkCrashService : android/app/Service { public fun ()V public fun onBind (Landroid/content/Intent;)Landroid/os/IBinder; - public fun onDestroy ()V - public fun onStartCommand (Landroid/content/Intent;II)I } diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java index 279b802dc70..9a09702912d 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -1,8 +1,10 @@ package io.sentry.android.ndk; +import android.annotation.SuppressLint; import android.app.ActivityManager; import android.app.Application; import android.content.Context; +import android.content.ServiceConnection; import android.os.Build; import io.sentry.android.core.NdkHandlerStrategy; import io.sentry.android.core.SentryAndroidOptions; @@ -21,6 +23,11 @@ public final class SentryNdk { private static final @NotNull CountDownLatch loadLibraryLatch = new CountDownLatch(1); + @SuppressLint("StaticFieldLeak") // the application context from NdkIntegration + private static @Nullable Context serviceContext; + + private static @Nullable ServiceConnection serviceConnection; + private SentryNdk() {} static { @@ -62,6 +69,11 @@ public static void init( return; } + // no fork/exec fallback, the native crash daemon only runs in the crash service + if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + throw new IllegalStateException("The NDK crash service needs a context and API 30+"); + } + SentryNdkUtil.addPackage(options.getSdkVersion()); try { if (loadLibraryLatch.await(2000, TimeUnit.MILLISECONDS)) { @@ -100,12 +112,21 @@ public static void init( ndkOptions.setEnableAppHangTracking(options.isEnableNdkAppHangTracking()); ndkOptions.setAppHangTimeoutMillis(options.getNdkAppHangTimeoutIntervalMillis()); - if (context != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - SentryNdkCrashService.start(context, options); + final @Nullable ServiceConnection connection = + SentryNdkCrashService.start(context, options); + if (connection == null) { + throw new IllegalStateException("Failed to start the NDK crash service"); + } + serviceContext = context; + serviceConnection = connection; + + try { + //noinspection UnstableApiUsage + io.sentry.ndk.SentryNdk.init(ndkOptions); + } catch (IllegalStateException e) { + unbindCrashService(); + throw e; } - - //noinspection UnstableApiUsage - io.sentry.ndk.SentryNdk.init(ndkOptions); // only add scope sync observer if the scope sync is enabled. if (options.isEnableScopeSync()) { @@ -134,6 +155,18 @@ public static void close() { } catch (InterruptedException e) { throw new IllegalStateException( "Thread interrupted while waiting for NDK libs to be loaded", e); + } finally { + unbindCrashService(); + } + } + + private static void unbindCrashService() { + final @Nullable Context context = serviceContext; + final @Nullable ServiceConnection connection = serviceConnection; + serviceContext = null; + serviceConnection = null; + if (context != null && connection != null) { + context.unbindService(connection); } } diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java index 625a85d603e..7d32eb21a46 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -16,6 +16,7 @@ import android.os.Process; import android.os.RemoteException; import io.sentry.DateUtils; +import io.sentry.ILogger; import io.sentry.SentryEnvelope; import io.sentry.SentryEnvelopeItem; import io.sentry.SentryEvent; @@ -39,8 +40,6 @@ import java.util.Date; import java.util.List; import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; @@ -49,20 +48,6 @@ @ApiStatus.Internal public final class SentryNdkCrashService extends Service { - private static final @NotNull String EXTRA_NATIVE_DATABASE_PATH = - "io.sentry.android.ndk.NATIVE_DATABASE_PATH"; - private static final @NotNull String EXTRA_NATIVE_LIBRARY_DIR = - "io.sentry.android.ndk.NATIVE_LIBRARY_DIR"; - private static final @NotNull String EXTRA_TOMBSTONE_ENABLED = - "io.sentry.android.ndk.TOMBSTONE_ENABLED"; - private static final @NotNull String EXTRA_DEBUG = "io.sentry.android.ndk.DEBUG"; - private static final @NotNull String EXTRA_SHUTDOWN_TIMEOUT_MILLIS = - "io.sentry.android.ndk.SHUTDOWN_TIMEOUT_MILLIS"; - private static final @NotNull String EXTRA_IN_APP_INCLUDES = - "io.sentry.android.ndk.IN_APP_INCLUDES"; - private static final @NotNull String EXTRA_IN_APP_EXCLUDES = - "io.sentry.android.ndk.IN_APP_EXCLUDES"; - private static final @NotNull String EXTRA_SHM_PATH = "io.sentry.android.ndk.SHM_PATH"; private static final @NotNull String BINDER_DESCRIPTOR = "io.sentry.android.ndk.CrashService"; private static final int TRANSACTION_CONFIGURE = IBinder.FIRST_CALL_TRANSACTION; @@ -73,9 +58,6 @@ public final class SentryNdkCrashService extends Service { private final @NotNull Object lock = new Object(); private @Nullable Thread daemonWorker; - private int lastStartId = 0; - private @Nullable String lastStartShmPath; - private volatile boolean running = false; private @Nullable File nativeDatabaseDir; private @Nullable String nativeLibraryDir; private @Nullable String shmPath; @@ -140,14 +122,14 @@ protected boolean onTransact( } }; - static boolean start( + static @Nullable ServiceConnection start( final @NotNull Context context, final @NotNull SentryAndroidOptions options) { final @Nullable String outboxPath = options.getOutboxPath(); if (outboxPath == null) { options .getLogger() .log(SentryLevel.DEBUG, "Outbox path is null, not starting NDK crash service."); - return false; + return null; } final @Nullable File cacheDir = new File(outboxPath).getParentFile(); @@ -155,13 +137,13 @@ static boolean start( options .getLogger() .log(SentryLevel.DEBUG, "Cache dir is null, not starting NDK crash service."); - return false; + return null; } final File nativeDatabaseDir = new File(cacheDir, ".sentry-native"); if (!nativeDatabaseDir.exists() && !nativeDatabaseDir.mkdirs()) { options.getLogger().log(SentryLevel.DEBUG, "Failed to create NDK database directory."); - return false; + return null; } final long ipcId = System.nanoTime(); @@ -186,14 +168,11 @@ static boolean start( messageRead = messageSocket[1]; final Intent intent = new Intent(context, SentryNdkCrashService.class); - putServiceOptions(context, options, nativeDatabaseDir, intent); - intent.putExtra(EXTRA_SHM_PATH, shmFile.getPath()); - - context.startService(intent); - final boolean configured = + final @Nullable ServiceConnection connection = configureService( context, intent, + options.getLogger(), nativeDatabaseDir.getPath(), context.getApplicationInfo().nativeLibraryDir, shmFile.getPath(), @@ -207,21 +186,21 @@ static boolean start( notifyRead, readyWrite, messageRead); - if (!configured) { + if (connection == null) { options.getLogger().log(SentryLevel.DEBUG, "NDK crash service did not configure."); - return false; + return null; } io.sentry.ndk.SentryNdk.initCrashDaemon( shmFile.getPath(), notifyWrite.detachFd(), readyRead.detachFd(), messageWrite.detachFd()); - return true; + return connection; } catch (InterruptedException e) { Thread.currentThread().interrupt(); options.getLogger().log(SentryLevel.DEBUG, e, "Failed to start NDK crash service."); - return false; - } catch (IOException | SecurityException | IllegalStateException e) { + return null; + } catch (IOException | SecurityException e) { options.getLogger().log(SentryLevel.DEBUG, e, "Failed to start NDK crash service."); - return false; + return null; } finally { closeQuietly(notifyRead); closeQuietly(notifyWrite); @@ -232,43 +211,6 @@ static boolean start( } } - @Override - public int onStartCommand(final @Nullable Intent intent, final int flags, final int startId) { - if (intent != null) { - final @Nullable String nativeDatabasePath = intent.getStringExtra(EXTRA_NATIVE_DATABASE_PATH); - if (nativeDatabasePath != null) { - nativeDatabaseDir = new File(nativeDatabasePath); - } - nativeLibraryDir = intent.getStringExtra(EXTRA_NATIVE_LIBRARY_DIR); - tombstoneEnabled = intent.getBooleanExtra(EXTRA_TOMBSTONE_ENABLED, false); - debug = intent.getBooleanExtra(EXTRA_DEBUG, false); - shutdownTimeoutMillis = intent.getLongExtra(EXTRA_SHUTDOWN_TIMEOUT_MILLIS, 0); - final @Nullable ArrayList includes = - intent.getStringArrayListExtra(EXTRA_IN_APP_INCLUDES); - final @Nullable ArrayList excludes = - intent.getStringArrayListExtra(EXTRA_IN_APP_EXCLUDES); - inAppIncludes = includes != null ? includes : new ArrayList<>(); - inAppExcludes = excludes != null ? excludes : new ArrayList<>(); - } - - synchronized (lock) { - lastStartId = startId; - lastStartShmPath = intent != null ? intent.getStringExtra(EXTRA_SHM_PATH) : null; - startWorkersLocked(); - } - return START_NOT_STICKY; - } - - @Override - public void onDestroy() { - running = false; - final @Nullable Thread daemonThread = daemonWorker; - if (daemonThread != null) { - daemonThread.interrupt(); - } - super.onDestroy(); - } - @Override public @Nullable IBinder onBind(final @NotNull Intent intent) { return binder; @@ -284,27 +226,10 @@ private static void closeQuietly(final @Nullable Closeable closeable) { } } - private static void putServiceOptions( - final @NotNull Context context, - final @NotNull SentryAndroidOptions options, - final @NotNull File nativeDatabaseDir, - final @NotNull Intent intent) { - intent.putExtra(EXTRA_NATIVE_DATABASE_PATH, nativeDatabaseDir.getPath()); - intent.putExtra(EXTRA_NATIVE_LIBRARY_DIR, context.getApplicationInfo().nativeLibraryDir); - intent.putExtra( - EXTRA_TOMBSTONE_ENABLED, - options.isTombstoneEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S); - intent.putExtra(EXTRA_DEBUG, options.isDebug()); - intent.putExtra(EXTRA_SHUTDOWN_TIMEOUT_MILLIS, options.getShutdownTimeoutMillis()); - intent.putStringArrayListExtra( - EXTRA_IN_APP_INCLUDES, new ArrayList<>(options.getInAppIncludes())); - intent.putStringArrayListExtra( - EXTRA_IN_APP_EXCLUDES, new ArrayList<>(options.getInAppExcludes())); - } - - private static boolean configureService( + private static @Nullable ServiceConnection configureService( final @NotNull Context context, final @NotNull Intent intent, + final @NotNull ILogger logger, final @NotNull String nativeDatabasePath, final @Nullable String nativeLibraryDir, final @NotNull String shmPath, @@ -322,7 +247,6 @@ private static boolean configureService( final CountDownLatch latch = new CountDownLatch(1); final boolean[] configured = new boolean[] {false}; final boolean[] bound = new boolean[] {false}; - final ExecutorService executor = Executors.newSingleThreadExecutor(); final ServiceConnection connection = new ServiceConnection() { @@ -350,26 +274,35 @@ public void onServiceConnected( @Override public void onServiceDisconnected(final @NotNull ComponentName name) { + logger.log(SentryLevel.WARNING, "NDK crash service disconnected."); + latch.countDown(); + } + + @Override + public void onBindingDied(final @NotNull ComponentName name) { + logger.log(SentryLevel.WARNING, "NDK crash service binding died."); latch.countDown(); } }; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - bound[0] = context.bindService(intent, Context.BIND_AUTO_CREATE, executor, connection); + // deliver callbacks on the binder thread, the caller blocks on the latch + bound[0] = context.bindService(intent, Context.BIND_AUTO_CREATE, Runnable::run, connection); } else { bound[0] = context.bindService(intent, connection, Context.BIND_AUTO_CREATE); } if (!bound[0]) { - executor.shutdown(); - return false; + return null; } + boolean ready = false; try { - return latch.await(10000, TimeUnit.MILLISECONDS) && configured[0]; + ready = latch.await(10000, TimeUnit.MILLISECONDS) && configured[0]; + // stay bound, so the service process keeps the app's importance and is not frozen + return ready ? connection : null; } finally { - if (bound[0]) { + if (!ready) { context.unbindService(connection); } - executor.shutdown(); } } @@ -461,9 +394,6 @@ private boolean configureDaemon( } private void startWorkersLocked() { - if (!running) { - running = true; - } if (daemonWorker == null && isDaemonConfigured()) { final @Nullable String path = shmPath; daemonWorker = new Thread(() -> runDaemonWorker(path), "SentryNdkCrashService"); @@ -477,46 +407,40 @@ private void runDaemonWorker(final @Nullable String path) { options.setDebug(debug); try { - while (running) { - if (path == null - || notifyReadFd < 0 - || readyWriteFd < 0 - || messageReadFd < 0 - || appPid < 0) { + if (path == null || notifyReadFd < 0 || readyWriteFd < 0 || messageReadFd < 0 || appPid < 0) { + return; + } + + // keeps going after onDestroy, a crashed app's binding goes away mid-crash + try (final @Nullable CrashDaemon daemon = + io.sentry.ndk.SentryNdk.runCrashDaemon( + appPid, appTid, notifyReadFd, readyWriteFd, messageReadFd, path)) { + if (daemon == null) { + return; + } + final @Nullable String envelopePath = daemon.getEnvelopePath(); + if (envelopePath == null) { return; } - try (final @Nullable CrashDaemon daemon = - io.sentry.ndk.SentryNdk.runCrashDaemon( - appPid, appTid, notifyReadFd, readyWriteFd, messageReadFd, path)) { - if (daemon == null || !running) { - return; - } - final @Nullable String envelopePath = daemon.getEnvelopePath(); - if (envelopePath == null) { - return; - } - - final long deadlineMillis = System.currentTimeMillis() + shutdownTimeoutMillis; - final File envelope = new File(envelopePath); - final @Nullable MergedEnvelope mergedEnvelope = - tombstoneEnabled ? waitForMergedEnvelope(options, envelope, deadlineMillis) : null; - if (tombstoneEnabled && mergedEnvelope == null) { - options - .getLogger() - .log(SentryLevel.DEBUG, "Sending NDK crash without tombstone enrichment."); - } + final long deadlineMillis = System.currentTimeMillis() + shutdownTimeoutMillis; + final File envelope = new File(envelopePath); + final @Nullable MergedEnvelope mergedEnvelope = + tombstoneEnabled ? waitForMergedEnvelope(options, envelope, deadlineMillis) : null; + if (tombstoneEnabled && mergedEnvelope == null) { + options + .getLogger() + .log(SentryLevel.DEBUG, "Sending NDK crash without tombstone enrichment."); + } - final long remainingMillis = Math.max(0, deadlineMillis - System.currentTimeMillis()); - if (!daemon.sendEnvelope( - mergedEnvelope != null ? mergedEnvelope.file.getPath() : envelope.getPath(), - remainingMillis)) { - options.getLogger().log(SentryLevel.DEBUG, "Failed to send NDK crash envelope."); - } else if (mergedEnvelope != null) { - writeLastTombstoneMarker(envelope, mergedEnvelope.tombstoneTimestamp); - } + final long remainingMillis = Math.max(0, deadlineMillis - System.currentTimeMillis()); + if (!daemon.sendEnvelope( + mergedEnvelope != null ? mergedEnvelope.file.getPath() : envelope.getPath(), + remainingMillis)) { + options.getLogger().log(SentryLevel.DEBUG, "Failed to send NDK crash envelope."); + } else if (mergedEnvelope != null) { + writeLastTombstoneMarker(envelope, mergedEnvelope.tombstoneTimestamp); } - return; } } catch (InterruptedException | IOException e) { options.getLogger().log(SentryLevel.DEBUG, e, "Error running NDK crash service."); @@ -529,9 +453,6 @@ private void runDaemonWorker(final @Nullable String path) { appPid = -1; appTid = 0; shmPath = null; - if (path != null && path.equals(lastStartShmPath)) { - stopSelf(lastStartId); - } } } } @@ -549,7 +470,7 @@ private boolean isDaemonConfigured() { final @NotNull File envelope, final long deadlineMillis) throws InterruptedException { - do { + while (true) { final @Nullable MergedEnvelope mergedEnvelope = processEnvelope(options, envelope); if (mergedEnvelope != null) { return mergedEnvelope; @@ -560,8 +481,7 @@ private boolean isDaemonConfigured() { return null; } Thread.sleep(Math.min(POLL_INTERVAL_MILLIS, remainingMillis)); - } while (running); - return null; + } } @SuppressLint("NewApi") diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt index 83a924f7c98..f087253e5c6 100644 --- a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt @@ -1,9 +1,11 @@ package io.sentry.android.ndk import android.content.Context +import android.content.ServiceConnection import com.google.common.truth.Truth.assertThat import io.sentry.android.core.SentryAndroidOptions import io.sentry.ndk.NdkOptions +import kotlin.test.assertFailsWith import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito @@ -18,35 +20,81 @@ import org.robolectric.annotation.Config class NativeBackendTest { @Test fun `starts service with tombstones disabled`() { - initialize(false) { assertThat(it.isTombstoneEnabled).isFalse() } + initialize({ isTombstoneEnabled = false }) { assertThat(it.isTombstoneEnabled).isFalse() } } @Test fun `enables supported tombstone enrichment`() { - initialize(true) { assertThat(it.isTombstoneEnabled).isTrue() } + initialize({ isTombstoneEnabled = true }) { assertThat(it.isTombstoneEnabled).isTrue() } } @Test @Config(sdk = [30]) fun `disables unavailable tombstone enrichment`() { - initialize(true) { assertThat(it.isTombstoneEnabled).isFalse() } + initialize({ isTombstoneEnabled = true }) { assertThat(it.isTombstoneEnabled).isFalse() } } - private fun initialize(tombstones: Boolean, verify: (NdkOptions) -> Unit) { + @Test + fun `defaults missing traces sample rate to zero`() { + initialize({ tracesSampleRate = null }) { assertThat(it.tracesSampleRate).isEqualTo(0.0f) } + } + + @Test + fun `propagates traces sample rate`() { + initialize({ tracesSampleRate = 0.75 }) { assertThat(it.tracesSampleRate).isEqualTo(0.75f) } + } + + @Test + fun `does not enable app hang tracking by default`() { + initialize { + assertThat(it.isEnableAppHangTracking).isFalse() + assertThat(it.appHangTimeoutMillis).isEqualTo(5000L) + } + } + + @Test + fun `propagates app hang tracking options`() { + initialize({ + isEnableNdkAppHangTracking = true + ndkAppHangTimeoutIntervalMillis = 2000 + }) { + assertThat(it.isEnableAppHangTracking).isTrue() + assertThat(it.appHangTimeoutMillis).isEqualTo(2000L) + } + } + + @Test + fun `fails without a context`() { + assertFailsWith { SentryNdk.init(options()) } + } + + @Test + @Config(sdk = [29]) + fun `fails below API 30`() { + assertFailsWith { SentryNdk.init(mock(), options()) } + } + + private fun options() = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + cacheDirPath = "/cache" + } + + private fun initialize( + configure: SentryAndroidOptions.() -> Unit = {}, + verify: (NdkOptions) -> Unit, + ) { val context = mock() - val options = - SentryAndroidOptions().apply { - dsn = "https://key@sentry.io/proj" - cacheDirPath = "/cache" - isTombstoneEnabled = tombstones - } + val options = options().apply(configure) Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { native -> Mockito.mockStatic(SentryNdkCrashService::class.java).use { service -> var captured: NdkOptions? = null native .`when` { io.sentry.ndk.SentryNdk.init(any()) } .doAnswer { captured = it.arguments[0] as NdkOptions } - service.`when` { SentryNdkCrashService.start(context, options) }.thenReturn(true) + service + .`when` { SentryNdkCrashService.start(context, options) } + .thenReturn(mock()) SentryNdk.init(context, options) diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt new file mode 100644 index 00000000000..abf6a8a336f --- /dev/null +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt @@ -0,0 +1,106 @@ +package io.sentry.android.ndk + +import android.content.ComponentName +import android.os.Binder +import android.os.Parcel +import com.google.common.truth.Truth.assertThat +import io.sentry.android.core.SentryAndroidOptions +import io.sentry.ndk.NdkOptions +import kotlin.test.assertFailsWith +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.never +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [31], shadows = [SentryShadowParcelFileDescriptor::class]) +class SentryNdkCrashServiceTest { + private val app = RuntimeEnvironment.getApplication() + private val component = ComponentName(app, SentryNdkCrashService::class.java) + private val options = + SentryAndroidOptions().apply { + dsn = "https://key@sentry.io/proj" + cacheDirPath = app.cacheDir.absolutePath + } + + @Test + fun `binds without starting the service`() { + configure(true) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { + val connection = SentryNdkCrashService.start(app, options) + + assertThat(connection).isNotNull() + assertThat(shadowOf(app).boundServiceConnections).containsExactly(connection) + assertThat(shadowOf(app).unboundServiceConnections).isEmpty() + // robolectric records the bind intent as started, a startService call would add another + assertThat(shadowOf(app).allStartedServices).hasSize(1) + } + } + + @Test + fun `unbinds when the service does not configure`() { + configure(false) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { + assertThat(SentryNdkCrashService.start(app, options)).isNull() + + assertThat(shadowOf(app).boundServiceConnections).isEmpty() + assertThat(shadowOf(app).unboundServiceConnections).hasSize(1) + } + } + + @Test + fun `close unbinds the service`() { + configure(true) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { + SentryNdk.init(app, options) + val connection = shadowOf(app).boundServiceConnections.single() + + SentryNdk.close() + + assertThat(shadowOf(app).boundServiceConnections).isEmpty() + assertThat(shadowOf(app).unboundServiceConnections).containsExactly(connection) + } + } + + @Test + fun `init unbinds when native init fails`() { + configure(true) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { native -> + native + .`when` { io.sentry.ndk.SentryNdk.init(any()) } + .thenThrow(IllegalStateException()) + + assertFailsWith { SentryNdk.init(app, options) } + + assertThat(shadowOf(app).boundServiceConnections).isEmpty() + assertThat(shadowOf(app).unboundServiceConnections).hasSize(1) + } + } + + @Test + fun `init fails when the service does not bind`() { + shadowOf(app).declareComponentUnbindable(component) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { native -> + assertFailsWith { SentryNdk.init(app, options) } + + native.verify({ io.sentry.ndk.SentryNdk.init(any()) }, never()) + } + } + + private fun configure(configured: Boolean) { + val binder = + object : Binder() { + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + reply?.writeNoException() + reply?.writeInt(if (configured) 1 else 0) + return true + } + } + shadowOf(app).setComponentNameAndServiceForBindService(component, binder) + } +} diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryShadowParcelFileDescriptor.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryShadowParcelFileDescriptor.kt new file mode 100644 index 00000000000..229a4e70031 --- /dev/null +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryShadowParcelFileDescriptor.kt @@ -0,0 +1,12 @@ +package io.sentry.android.ndk + +import android.os.ParcelFileDescriptor +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.shadows.ShadowParcelFileDescriptor + +// the real detachFd needs Android's FileDescriptor ownership API, missing on the JVM +@Implements(ParcelFileDescriptor::class) +class SentryShadowParcelFileDescriptor : ShadowParcelFileDescriptor() { + @Suppress("unused") @Implementation fun detachFd(): Int = -1 +} diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index ee850ae918b..5f736bf662f 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -82,4 +82,5 @@ dependencies { extensions.configure { sourceSets.getByName("main").java.srcDir("src/inproc/java") + sourceSets.getByName("test").java.srcDir("src/inprocTest/java") } diff --git a/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt b/sentry-android-ndk/src/inprocTest/java/io/sentry/android/ndk/SentryNdkTest.kt similarity index 100% rename from sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt rename to sentry-android-ndk/src/inprocTest/java/io/sentry/android/ndk/SentryNdkTest.kt From 2a7640e9728a40a92fff6abea8e6dcacdd320b2c Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 12:04:27 +0200 Subject: [PATCH 08/19] fix(android): Match NDK tombstones by the app's pid The crash service picked the native crash exit info closest to the event's timestamp and ignored its pid. In a crash loop, the first poll can run before the current crash has an exit info, and the previous crash's tombstone was merged instead. Skip exit infos of other pids. The worker captures the pid when it starts, so a later configure call cannot change it. --- .../android/ndk/SentryNdkCrashService.java | 33 ++++++++++------- .../android/ndk/SentryNdkCrashServiceTest.kt | 35 +++++++++++++++++++ 2 files changed, 56 insertions(+), 12 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java index 7d32eb21a46..1af756beb10 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -44,6 +44,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.VisibleForTesting; @ApiStatus.Internal public final class SentryNdkCrashService extends Service { @@ -396,25 +397,26 @@ private boolean configureDaemon( private void startWorkersLocked() { if (daemonWorker == null && isDaemonConfigured()) { final @Nullable String path = shmPath; - daemonWorker = new Thread(() -> runDaemonWorker(path), "SentryNdkCrashService"); + final int pid = appPid; + daemonWorker = new Thread(() -> runDaemonWorker(path, pid), "SentryNdkCrashService"); daemonWorker.start(); } } - private void runDaemonWorker(final @Nullable String path) { + private void runDaemonWorker(final @Nullable String path, final int pid) { final SentryAndroidOptions options = new SentryAndroidOptions(); options.setLogger(new AndroidLogger("SentryNdkCrash")); options.setDebug(debug); try { - if (path == null || notifyReadFd < 0 || readyWriteFd < 0 || messageReadFd < 0 || appPid < 0) { + if (path == null || notifyReadFd < 0 || readyWriteFd < 0 || messageReadFd < 0 || pid < 0) { return; } // keeps going after onDestroy, a crashed app's binding goes away mid-crash try (final @Nullable CrashDaemon daemon = io.sentry.ndk.SentryNdk.runCrashDaemon( - appPid, appTid, notifyReadFd, readyWriteFd, messageReadFd, path)) { + pid, appTid, notifyReadFd, readyWriteFd, messageReadFd, path)) { if (daemon == null) { return; } @@ -426,7 +428,7 @@ private void runDaemonWorker(final @Nullable String path) { final long deadlineMillis = System.currentTimeMillis() + shutdownTimeoutMillis; final File envelope = new File(envelopePath); final @Nullable MergedEnvelope mergedEnvelope = - tombstoneEnabled ? waitForMergedEnvelope(options, envelope, deadlineMillis) : null; + tombstoneEnabled ? waitForMergedEnvelope(options, envelope, deadlineMillis, pid) : null; if (tombstoneEnabled && mergedEnvelope == null) { options .getLogger() @@ -468,10 +470,11 @@ private boolean isDaemonConfigured() { private @Nullable MergedEnvelope waitForMergedEnvelope( final @NotNull SentryAndroidOptions options, final @NotNull File envelope, - final long deadlineMillis) + final long deadlineMillis, + final int pid) throws InterruptedException { while (true) { - final @Nullable MergedEnvelope mergedEnvelope = processEnvelope(options, envelope); + final @Nullable MergedEnvelope mergedEnvelope = processEnvelope(options, envelope, pid); if (mergedEnvelope != null) { return mergedEnvelope; } @@ -486,7 +489,7 @@ private boolean isDaemonConfigured() { @SuppressLint("NewApi") private @Nullable MergedEnvelope processEnvelope( - final @NotNull SentryAndroidOptions options, final @NotNull File file) { + final @NotNull SentryAndroidOptions options, final @NotNull File file, final int pid) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { return null; } @@ -501,7 +504,8 @@ private boolean isDaemonConfigured() { return null; } - final @Nullable ApplicationExitInfo exitInfo = findMatchingExitInfo(options, nativeTimestamp); + final @Nullable ApplicationExitInfo exitInfo = + findMatchingExitInfo(options, nativeTimestamp, pid); if (exitInfo == null) { return null; } @@ -528,9 +532,13 @@ private boolean isDaemonConfigured() { return null; } + @VisibleForTesting @SuppressLint("NewApi") - private @Nullable ApplicationExitInfo findMatchingExitInfo( - final @NotNull SentryAndroidOptions options, final @NotNull Date nativeTimestamp) { + @Nullable + ApplicationExitInfo findMatchingExitInfo( + final @NotNull SentryAndroidOptions options, + final @NotNull Date nativeTimestamp, + final int pid) { final @Nullable ActivityManager activityManager = (ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); if (activityManager == null) { @@ -542,7 +550,8 @@ private boolean isDaemonConfigured() { ApplicationExitInfo bestMatch = null; long bestDiff = Long.MAX_VALUE; for (ApplicationExitInfo exitInfo : exitInfos) { - if (exitInfo.getReason() != ApplicationExitInfo.REASON_CRASH_NATIVE) { + if (exitInfo.getReason() != ApplicationExitInfo.REASON_CRASH_NATIVE + || exitInfo.getPid() != pid) { continue; } final long diff = Math.abs(exitInfo.getTimestamp() - nativeTimestamp.getTime()); diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt index abf6a8a336f..0234d1d74c1 100644 --- a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt @@ -1,21 +1,27 @@ package io.sentry.android.ndk +import android.app.ActivityManager +import android.app.ApplicationExitInfo import android.content.ComponentName +import android.content.Context import android.os.Binder import android.os.Parcel import com.google.common.truth.Truth.assertThat import io.sentry.android.core.SentryAndroidOptions import io.sentry.ndk.NdkOptions +import java.util.Date import kotlin.test.assertFailsWith import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito import org.mockito.kotlin.any import org.mockito.kotlin.never +import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder @RunWith(RobolectricTestRunner::class) @Config(sdk = [31], shadows = [SentryShadowParcelFileDescriptor::class]) @@ -92,6 +98,35 @@ class SentryNdkCrashServiceTest { } } + @Test + fun `matches the exit info of the app pid`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + addExitInfo(service, pid = 1, timestamp = 2000) + addExitInfo(service, pid = 2, timestamp = 1000) + + assertThat(service.findMatchingExitInfo(options, Date(2000), 2)?.pid).isEqualTo(2) + } + + @Test + fun `ignores exit infos of other pids`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + addExitInfo(service, pid = 1, timestamp = 1000) + + assertThat(service.findMatchingExitInfo(options, Date(1000), 2)).isNull() + } + + private fun addExitInfo(context: Context, pid: Int, timestamp: Long) { + val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + shadowOf(activityManager) + .addApplicationExitInfo( + ApplicationExitInfoBuilder.newBuilder() + .setPid(pid) + .setReason(ApplicationExitInfo.REASON_CRASH_NATIVE) + .setTimestamp(timestamp) + .build() + ) + } + private fun configure(configured: Boolean) { val binder = object : Binder() { From 1f126edf9265aa35f6e1f5c6ff723fdebb33ce3f Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 12:26:41 +0200 Subject: [PATCH 09/19] fix(android): Start an NDK crash daemon for each relaunch When the app relaunched while the previous daemon was still sending its crash, or before it noticed that its process was gone, the new config was stored but no worker started, and the old worker's cleanup then cleared it. The relaunched app's next crash waited 10 s in the handler and was lost, and its descriptors leaked. Keep the per-app daemon state in a config owned by its worker, and start a worker for each config right away. The old daemon is past crash processing or only waiting for its process to go away, so the two share only process-wide native state such as the logger. --- .../android/ndk/SentryNdkCrashService.java | 156 ++++++++++-------- .../android/ndk/SentryNdkCrashServiceTest.kt | 72 ++++++++ 2 files changed, 156 insertions(+), 72 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java index 1af756beb10..b2a6eede1a3 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -56,22 +56,6 @@ public final class SentryNdkCrashService extends Service { private static final long POLL_INTERVAL_MILLIS = 250; private static final long MATCH_TOLERANCE_MILLIS = 30000; - private final @NotNull Object lock = new Object(); - - private @Nullable Thread daemonWorker; - private @Nullable File nativeDatabaseDir; - private @Nullable String nativeLibraryDir; - private @Nullable String shmPath; - private int notifyReadFd = -1; - private int readyWriteFd = -1; - private int messageReadFd = -1; - private int appPid = -1; - private long appTid = 0; - private long shutdownTimeoutMillis = 0; - private @NotNull List inAppIncludes = new ArrayList<>(); - private @NotNull List inAppExcludes = new ArrayList<>(); - private boolean tombstoneEnabled = false; - private boolean debug = false; private final @NotNull IBinder binder = new Binder() { @Override @@ -352,7 +336,8 @@ private static boolean transactConfigure( } } - private boolean configureDaemon( + @VisibleForTesting + boolean configureDaemon( final @Nullable String nativeDatabasePath, final @Nullable String nativeLibraryDir, final @Nullable String shmPath, @@ -375,48 +360,44 @@ private boolean configureDaemon( return false; } - synchronized (lock) { - nativeDatabaseDir = new File(nativeDatabasePath); - this.nativeLibraryDir = nativeLibraryDir; - this.shmPath = shmPath; - this.appPid = appPid; - this.appTid = appTid; - this.tombstoneEnabled = tombstoneEnabled; - this.debug = debug; - this.shutdownTimeoutMillis = shutdownTimeoutMillis; - inAppIncludes = includes != null ? includes : new ArrayList<>(); - inAppExcludes = excludes != null ? excludes : new ArrayList<>(); - notifyReadFd = notifyRead.detachFd(); - readyWriteFd = readyWrite.detachFd(); - messageReadFd = messageRead.detachFd(); - startWorkersLocked(); - } + final DaemonConfig config = + new DaemonConfig( + nativeLibraryDir, + shmPath, + notifyRead.detachFd(), + readyWrite.detachFd(), + messageRead.detachFd(), + appPid, + appTid, + shutdownTimeoutMillis, + includes != null ? includes : new ArrayList<>(), + excludes != null ? excludes : new ArrayList<>(), + tombstoneEnabled, + debug); + // a relaunched app configures while the previous daemon may still be finishing + new Thread(() -> runDaemonWorker(config), "SentryNdkCrashService").start(); return true; } - private void startWorkersLocked() { - if (daemonWorker == null && isDaemonConfigured()) { - final @Nullable String path = shmPath; - final int pid = appPid; - daemonWorker = new Thread(() -> runDaemonWorker(path, pid), "SentryNdkCrashService"); - daemonWorker.start(); - } - } - - private void runDaemonWorker(final @Nullable String path, final int pid) { + private void runDaemonWorker(final @NotNull DaemonConfig config) { final SentryAndroidOptions options = new SentryAndroidOptions(); options.setLogger(new AndroidLogger("SentryNdkCrash")); - options.setDebug(debug); + options.setDebug(config.debug); try { - if (path == null || notifyReadFd < 0 || readyWriteFd < 0 || messageReadFd < 0 || pid < 0) { + if (config.notifyReadFd < 0 || config.readyWriteFd < 0 || config.messageReadFd < 0) { return; } // keeps going after onDestroy, a crashed app's binding goes away mid-crash try (final @Nullable CrashDaemon daemon = io.sentry.ndk.SentryNdk.runCrashDaemon( - pid, appTid, notifyReadFd, readyWriteFd, messageReadFd, path)) { + config.appPid, + config.appTid, + config.notifyReadFd, + config.readyWriteFd, + config.messageReadFd, + config.shmPath)) { if (daemon == null) { return; } @@ -425,11 +406,13 @@ private void runDaemonWorker(final @Nullable String path, final int pid) { return; } - final long deadlineMillis = System.currentTimeMillis() + shutdownTimeoutMillis; + final long deadlineMillis = System.currentTimeMillis() + config.shutdownTimeoutMillis; final File envelope = new File(envelopePath); final @Nullable MergedEnvelope mergedEnvelope = - tombstoneEnabled ? waitForMergedEnvelope(options, envelope, deadlineMillis, pid) : null; - if (tombstoneEnabled && mergedEnvelope == null) { + config.tombstoneEnabled + ? waitForMergedEnvelope(options, envelope, deadlineMillis, config) + : null; + if (config.tombstoneEnabled && mergedEnvelope == null) { options .getLogger() .log(SentryLevel.DEBUG, "Sending NDK crash without tombstone enrichment."); @@ -446,35 +429,17 @@ private void runDaemonWorker(final @Nullable String path, final int pid) { } } catch (InterruptedException | IOException e) { options.getLogger().log(SentryLevel.DEBUG, e, "Error running NDK crash service."); - } finally { - synchronized (lock) { - daemonWorker = null; - notifyReadFd = -1; - readyWriteFd = -1; - messageReadFd = -1; - appPid = -1; - appTid = 0; - shmPath = null; - } } } - private boolean isDaemonConfigured() { - return shmPath != null - && notifyReadFd >= 0 - && readyWriteFd >= 0 - && messageReadFd >= 0 - && appPid >= 0; - } - private @Nullable MergedEnvelope waitForMergedEnvelope( final @NotNull SentryAndroidOptions options, final @NotNull File envelope, final long deadlineMillis, - final int pid) + final @NotNull DaemonConfig config) throws InterruptedException { while (true) { - final @Nullable MergedEnvelope mergedEnvelope = processEnvelope(options, envelope, pid); + final @Nullable MergedEnvelope mergedEnvelope = processEnvelope(options, envelope, config); if (mergedEnvelope != null) { return mergedEnvelope; } @@ -489,7 +454,9 @@ private boolean isDaemonConfigured() { @SuppressLint("NewApi") private @Nullable MergedEnvelope processEnvelope( - final @NotNull SentryAndroidOptions options, final @NotNull File file, final int pid) { + final @NotNull SentryAndroidOptions options, + final @NotNull File file, + final @NotNull DaemonConfig config) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { return null; } @@ -505,7 +472,7 @@ private boolean isDaemonConfigured() { } final @Nullable ApplicationExitInfo exitInfo = - findMatchingExitInfo(options, nativeTimestamp, pid); + findMatchingExitInfo(options, nativeTimestamp, config.appPid); if (exitInfo == null) { return null; } @@ -518,7 +485,10 @@ private boolean isDaemonConfigured() { final SentryEvent tombstoneEvent; try (final TombstoneParser parser = new TombstoneParser( - tombstoneInputStream, inAppIncludes, inAppExcludes, nativeLibraryDir)) { + tombstoneInputStream, + config.inAppIncludes, + config.inAppExcludes, + config.nativeLibraryDir)) { tombstoneEvent = parser.parse(); } tombstoneEvent.setTimestamp(DateUtils.getDateTime(exitInfo.getTimestamp())); @@ -653,4 +623,46 @@ private MergedEnvelope(final @NotNull File file, final long tombstoneTimestamp) this.tombstoneTimestamp = tombstoneTimestamp; } } + + private static final class DaemonConfig { + private final @Nullable String nativeLibraryDir; + private final @NotNull String shmPath; + private final int notifyReadFd; + private final int readyWriteFd; + private final int messageReadFd; + private final int appPid; + private final long appTid; + private final long shutdownTimeoutMillis; + private final @NotNull List inAppIncludes; + private final @NotNull List inAppExcludes; + private final boolean tombstoneEnabled; + private final boolean debug; + + private DaemonConfig( + final @Nullable String nativeLibraryDir, + final @NotNull String shmPath, + final int notifyReadFd, + final int readyWriteFd, + final int messageReadFd, + final int appPid, + final long appTid, + final long shutdownTimeoutMillis, + final @NotNull List inAppIncludes, + final @NotNull List inAppExcludes, + final boolean tombstoneEnabled, + final boolean debug) { + this.nativeLibraryDir = nativeLibraryDir; + this.shmPath = shmPath; + this.notifyReadFd = notifyReadFd; + this.readyWriteFd = readyWriteFd; + this.messageReadFd = messageReadFd; + this.appPid = appPid; + this.appTid = appTid; + this.shutdownTimeoutMillis = shutdownTimeoutMillis; + this.inAppIncludes = inAppIncludes; + this.inAppExcludes = inAppExcludes; + this.tombstoneEnabled = tombstoneEnabled; + this.debug = debug; + } + } } diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt index 0234d1d74c1..3e3e295bc0a 100644 --- a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt @@ -6,21 +6,30 @@ import android.content.ComponentName import android.content.Context import android.os.Binder import android.os.Parcel +import android.os.ParcelFileDescriptor import com.google.common.truth.Truth.assertThat import io.sentry.android.core.SentryAndroidOptions import io.sentry.ndk.NdkOptions +import io.sentry.ndk.SentryNdk.CrashDaemon import java.util.Date +import java.util.concurrent.CountDownLatch +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.TimeUnit import kotlin.test.assertFailsWith import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.robolectric.Robolectric import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf import org.robolectric.annotation.Config +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder @RunWith(RobolectricTestRunner::class) @@ -115,6 +124,23 @@ class SentryNdkCrashServiceTest { assertThat(service.findMatchingExitInfo(options, Date(1000), 2)).isNull() } + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [BlockingCrashDaemonShadow::class]) + fun `starts a daemon for a relaunched app while the previous one finishes`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + try { + configureDaemon(service, pid = 1, fd = 10) + assertThat(BlockingCrashDaemonShadow.daemons.poll(5, TimeUnit.SECONDS)) + .isEqualTo(listOf(1, 10, 11, 12, "shm-1")) + + configureDaemon(service, pid = 2, fd = 20) + assertThat(BlockingCrashDaemonShadow.daemons.poll(5, TimeUnit.SECONDS)) + .isEqualTo(listOf(2, 20, 21, 22, "shm-2")) + } finally { + BlockingCrashDaemonShadow.release.countDown() + } + } + private fun addExitInfo(context: Context, pid: Int, timestamp: Long) { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager shadowOf(activityManager) @@ -127,6 +153,28 @@ class SentryNdkCrashServiceTest { ) } + private fun configureDaemon(service: SentryNdkCrashService, pid: Int, fd: Int) { + val configured = + service.configureDaemon( + "db", + null, + "shm-$pid", + pid, + 0L, + false, + false, + 0L, + null, + null, + descriptor(fd), + descriptor(fd + 1), + descriptor(fd + 2), + ) + assertThat(configured).isTrue() + } + + private fun descriptor(fd: Int): ParcelFileDescriptor = mock { on { detachFd() } doReturn fd } + private fun configure(configured: Boolean) { val binder = object : Binder() { @@ -139,3 +187,27 @@ class SentryNdkCrashServiceTest { shadowOf(app).setComponentNameAndServiceForBindService(component, binder) } } + +@Implements(io.sentry.ndk.SentryNdk::class) +class BlockingCrashDaemonShadow { + companion object { + val daemons = LinkedBlockingQueue>() + val release = CountDownLatch(1) + + @Suppress("unused") + @Implementation + @JvmStatic + fun runCrashDaemon( + appPid: Int, + appTid: Long, + notifyFd: Int, + readyFd: Int, + messageFd: Int, + shmPath: String, + ): CrashDaemon? { + daemons.add(listOf(appPid, notifyFd, readyFd, messageFd, shmPath)) + release.await(10, TimeUnit.SECONDS) + return null + } + } +} From 81997e9bc2b415fe47a42dc9f52878760f5d9886 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 12:39:01 +0200 Subject: [PATCH 10/19] ci(temp): Publish sentry-native-ndk-outproc to Maven Local `sentry-android-ndk-outproc` depends on `io.sentry:sentry-native-ndk-outproc`, which is not on Maven Central yet. Build it from the sentry-native feature branch and publish it to Maven Local so the Build job can resolve it. TODO: Revert. This is temporary until sentry-native publishes the artifact. --- .github/workflows/build.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b185217b4c..dd9af244ece 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -43,6 +43,20 @@ jobs: with: cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + # TODO: remove once sentry-native publishes sentry-native-ndk-outproc + - name: Checkout sentry-native + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: getsentry/sentry-native + ref: jpnurmi/feat/native-android + path: sentry-native + submodules: 'recursive' + + # TODO: remove once sentry-native publishes sentry-native-ndk-outproc + - name: Publish sentry-native-ndk-outproc to Maven Local + working-directory: sentry-native/ndk + run: ./gradlew :sentry-native-ndk-outproc:publishToMavenLocal + - name: Run Tests and Lint run: make preMerge From 05cc24e45f9bd0b006eb96d8dcbd359aa36a64f8 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 13:55:42 +0200 Subject: [PATCH 11/19] fix(android): Wait for the NDK tombstone outside the send budget The tombstone wait and the send shared `shutdownTimeoutMillis`, so a slow tombstone left the send no time at all. Wait for the app's exit info instead. The app exits right after debuggerd writes its tombstone, so the wait ends when the tombstone is there, capped at the 30 s match tolerance. Stop early when the app did not crash natively or the tombstone does not parse. Give the send the full `shutdownTimeoutMillis`, and write the marker for a merged envelope that was sent or kept for the next launch. --- .../android/ndk/SentryNdkCrashService.java | 79 +++++---- .../android/ndk/SentryNdkCrashServiceTest.kt | 167 +++++++++++++++++- 2 files changed, 203 insertions(+), 43 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java index b2a6eede1a3..76bc5fb97b5 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -406,22 +406,20 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { return; } - final long deadlineMillis = System.currentTimeMillis() + config.shutdownTimeoutMillis; final File envelope = new File(envelopePath); final @Nullable MergedEnvelope mergedEnvelope = - config.tombstoneEnabled - ? waitForMergedEnvelope(options, envelope, deadlineMillis, config) - : null; + config.tombstoneEnabled ? waitForMergedEnvelope(options, envelope, config) : null; if (config.tombstoneEnabled && mergedEnvelope == null) { options .getLogger() .log(SentryLevel.DEBUG, "Sending NDK crash without tombstone enrichment."); } - final long remainingMillis = Math.max(0, deadlineMillis - System.currentTimeMillis()); - if (!daemon.sendEnvelope( - mergedEnvelope != null ? mergedEnvelope.file.getPath() : envelope.getPath(), - remainingMillis)) { + final CrashDaemon.SendResult result = + daemon.sendEnvelope( + mergedEnvelope != null ? mergedEnvelope.file.getPath() : envelope.getPath(), + config.shutdownTimeoutMillis); + if (result == CrashDaemon.SendResult.FAILED) { options.getLogger().log(SentryLevel.DEBUG, "Failed to send NDK crash envelope."); } else if (mergedEnvelope != null) { writeLastTombstoneMarker(envelope, mergedEnvelope.tombstoneTimestamp); @@ -432,16 +430,42 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { } } + @SuppressLint("NewApi") private @Nullable MergedEnvelope waitForMergedEnvelope( final @NotNull SentryAndroidOptions options, final @NotNull File envelope, - final long deadlineMillis, final @NotNull DaemonConfig config) throws InterruptedException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { + return null; + } + + final @Nullable NativeEnvelope nativeEnvelope = readNativeEnvelope(options, envelope); + final @Nullable Date nativeTimestamp = + nativeEnvelope != null ? nativeEnvelope.event.getTimestamp() : null; + if (nativeTimestamp == null) { + return null; + } + + // the app exits once its tombstone is written, a later exit would not match + final long deadlineMillis = System.currentTimeMillis() + MATCH_TOLERANCE_MILLIS; while (true) { - final @Nullable MergedEnvelope mergedEnvelope = processEnvelope(options, envelope, config); - if (mergedEnvelope != null) { - return mergedEnvelope; + final @Nullable ApplicationExitInfo exitInfo = + findMatchingExitInfo(options, nativeTimestamp, config.appPid); + if (exitInfo != null) { + if (exitInfo.getReason() != ApplicationExitInfo.REASON_CRASH_NATIVE) { + return null; + } + try { + final @Nullable MergedEnvelope mergedEnvelope = + processEnvelope(options, envelope, nativeEnvelope, exitInfo, config); + if (mergedEnvelope != null) { + return mergedEnvelope; + } + } catch (Exception e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Failed to merge NDK tombstone envelope."); + return null; + } } final long remainingMillis = deadlineMillis - System.currentTimeMillis(); @@ -456,27 +480,10 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { private @Nullable MergedEnvelope processEnvelope( final @NotNull SentryAndroidOptions options, final @NotNull File file, - final @NotNull DaemonConfig config) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) { - return null; - } - - final @Nullable NativeEnvelope nativeEnvelope = readNativeEnvelope(options, file); - if (nativeEnvelope == null) { - return null; - } - - final @Nullable Date nativeTimestamp = nativeEnvelope.event.getTimestamp(); - if (nativeTimestamp == null) { - return null; - } - - final @Nullable ApplicationExitInfo exitInfo = - findMatchingExitInfo(options, nativeTimestamp, config.appPid); - if (exitInfo == null) { - return null; - } - + final @NotNull NativeEnvelope nativeEnvelope, + final @NotNull ApplicationExitInfo exitInfo, + final @NotNull DaemonConfig config) + throws Exception { try (final @Nullable InputStream tombstoneInputStream = exitInfo.getTraceInputStream()) { if (tombstoneInputStream == null) { return null; @@ -496,10 +503,7 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { TombstoneIntegration.TombstonePolicy.mergeNativeCrashes(nativeEnvelope.event, tombstoneEvent); final File mergedEnvelope = writeMergedEnvelope(options, file, nativeEnvelope); return new MergedEnvelope(mergedEnvelope, exitInfo.getTimestamp()); - } catch (Exception e) { - options.getLogger().log(SentryLevel.DEBUG, e, "Failed to merge NDK tombstone envelope."); } - return null; } @VisibleForTesting @@ -520,8 +524,7 @@ ApplicationExitInfo findMatchingExitInfo( ApplicationExitInfo bestMatch = null; long bestDiff = Long.MAX_VALUE; for (ApplicationExitInfo exitInfo : exitInfos) { - if (exitInfo.getReason() != ApplicationExitInfo.REASON_CRASH_NATIVE - || exitInfo.getPid() != pid) { + if (exitInfo.getPid() != pid) { continue; } final long diff = Math.abs(exitInfo.getTimestamp() - nativeTimestamp.getTime()); diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt index 3e3e295bc0a..6ce6832d434 100644 --- a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt @@ -8,13 +8,20 @@ import android.os.Binder import android.os.Parcel import android.os.ParcelFileDescriptor import com.google.common.truth.Truth.assertThat +import io.sentry.DateUtils +import io.sentry.SentryEnvelope +import io.sentry.SentryEvent import io.sentry.android.core.SentryAndroidOptions +import io.sentry.android.core.cache.AndroidEnvelopeCache import io.sentry.ndk.NdkOptions import io.sentry.ndk.SentryNdk.CrashDaemon +import java.io.File +import java.io.InputStream import java.util.Date import java.util.concurrent.CountDownLatch import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.TimeUnit +import java.util.zip.GZIPInputStream import kotlin.test.assertFailsWith import org.junit.Test import org.junit.runner.RunWith @@ -141,19 +148,105 @@ class SentryNdkCrashServiceTest { } } - private fun addExitInfo(context: Context, pid: Int, timestamp: Long) { + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `sends the merged envelope and writes the marker`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, trace = tombstone()) + crashDaemon(envelope, CrashDaemon.SendResult.SENT) + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(CrashDaemonShadow.sends).containsExactly(listOf("${envelope.path}.merged", 2000L)) + assertThat(marker().readText()).isEqualTo("2000") + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `writes the marker when the merged envelope is kept`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, trace = tombstone()) + crashDaemon(envelope, CrashDaemon.SendResult.KEPT) + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(marker().readText()).isEqualTo("2000") + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `writes no marker when the send fails`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, trace = tombstone()) + crashDaemon(envelope, CrashDaemon.SendResult.FAILED) + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(CrashDaemonShadow.sends).containsExactly(listOf("${envelope.path}.merged", 2000L)) + assertThat(marker().exists()).isFalse() + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `sends without a tombstone when the app did not crash natively`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, reason = ApplicationExitInfo.REASON_SIGNALED) + crashDaemon(envelope, CrashDaemon.SendResult.SENT) + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(CrashDaemonShadow.sends).containsExactly(listOf(envelope.path, 2000L)) + assertThat(marker().exists()).isFalse() + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `sends without a tombstone when it does not parse`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, trace = "invalid".byteInputStream()) + crashDaemon(envelope, CrashDaemon.SendResult.SENT) + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(CrashDaemonShadow.sends).containsExactly(listOf(envelope.path, 2000L)) + assertThat(marker().exists()).isFalse() + } + + private fun addExitInfo( + context: Context, + pid: Int, + timestamp: Long, + reason: Int = ApplicationExitInfo.REASON_CRASH_NATIVE, + trace: InputStream? = null, + ) { val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager shadowOf(activityManager) .addApplicationExitInfo( ApplicationExitInfoBuilder.newBuilder() .setPid(pid) - .setReason(ApplicationExitInfo.REASON_CRASH_NATIVE) + .setReason(reason) .setTimestamp(timestamp) + .setTraceInputStream(trace) .build() ) } - private fun configureDaemon(service: SentryNdkCrashService, pid: Int, fd: Int) { + private fun configureDaemon( + service: SentryNdkCrashService, + pid: Int, + fd: Int, + tombstoneEnabled: Boolean = false, + ) { val configured = service.configureDaemon( "db", @@ -161,9 +254,9 @@ class SentryNdkCrashServiceTest { "shm-$pid", pid, 0L, + tombstoneEnabled, false, - false, - 0L, + 2000L, null, null, descriptor(fd), @@ -173,6 +266,34 @@ class SentryNdkCrashServiceTest { assertThat(configured).isTrue() } + private fun writeNativeEnvelope(timestamp: Long): File { + val event = SentryEvent(DateUtils.getDateTime(timestamp)).apply { platform = "native" } + val file = File(app.cacheDir, ".sentry-native/test.run/${event.eventId}.envelope") + file.parentFile!!.mkdirs() + file.outputStream().use { + options.serializer.serialize(SentryEnvelope.from(options.serializer, event, null), it) + } + return file + } + + private fun tombstone(): InputStream = + GZIPInputStream(File("../sentry-android-core/src/test/resources/tombstone.pb.gz").inputStream()) + + private fun crashDaemon(envelope: File, result: CrashDaemon.SendResult) { + CrashDaemonShadow.envelopePath = envelope.path + CrashDaemonShadow.result = result + CrashDaemonShadow.sends.clear() + } + + private fun awaitWorker() { + Thread.getAllStackTraces() + .keys + .filter { it.name == "SentryNdkCrashService" } + .forEach { it.join(5000) } + } + + private fun marker(): File = File(app.cacheDir, AndroidEnvelopeCache.LAST_TOMBSTONE_REPORT) + private fun descriptor(fd: Int): ParcelFileDescriptor = mock { on { detachFd() } doReturn fd } private fun configure(configured: Boolean) { @@ -211,3 +332,39 @@ class BlockingCrashDaemonShadow { } } } + +@Implements(io.sentry.ndk.SentryNdk::class) +class CrashDaemonShadow { + companion object { + var envelopePath = "" + var result = CrashDaemon.SendResult.SENT + val sends = mutableListOf>() + + @Suppress("unused") @Implementation @JvmStatic fun loadNativeLibraries() {} + + @Suppress("unused", "UNUSED_PARAMETER") + @Implementation + @JvmStatic + fun nativeRunCrashDaemon( + appPid: Int, + appTid: Long, + notifyFd: Int, + readyFd: Int, + messageFd: Int, + shmPath: String, + ): Long = 1L + + @Suppress("unused", "UNUSED_PARAMETER") + @Implementation + @JvmStatic + fun nativeGetCrashEnvelopePath(handle: Long): String = envelopePath + + @Suppress("unused", "UNUSED_PARAMETER") + @Implementation + @JvmStatic + fun nativeSendEnvelope(handle: Long, path: String, timeout: Long): Int { + sends.add(listOf(path, timeout)) + return result.ordinal + } + } +} From b4dd86a145083fbb0a3fb2a0bd2d6b5321a8f9e7 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 14:16:22 +0200 Subject: [PATCH 12/19] fix(android): Remove stale NDK crash IPC files Name the shared-memory file after the app pid and delete leftovers whose process no longer runs when the crash service starts. A setup that fails before the daemon attaches would otherwise leave the file behind, while other processes of a multi-process app keep theirs. --- .../android/ndk/SentryNdkCrashService.java | 35 ++++++++++++++++++- .../android/ndk/SentryNdkCrashServiceTest.kt | 17 +++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java index 76bc5fb97b5..27c3a1a69dd 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -38,7 +38,9 @@ import java.io.OutputStream; import java.util.ArrayList; import java.util.Date; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; @@ -52,6 +54,7 @@ public final class SentryNdkCrashService extends Service { private static final @NotNull String BINDER_DESCRIPTOR = "io.sentry.android.ndk.CrashService"; private static final int TRANSACTION_CONFIGURE = IBinder.FIRST_CALL_TRANSACTION; + private static final @NotNull String SHM_PREFIX = ".sentry-shm-service-"; private static final @NotNull String MERGED_SUFFIX = ".merged"; private static final long POLL_INTERVAL_MILLIS = 250; private static final long MATCH_TOLERANCE_MILLIS = 30000; @@ -131,9 +134,11 @@ protected boolean onTransact( return null; } + deleteStaleShmFiles(context, nativeDatabaseDir); + final long ipcId = System.nanoTime(); final File shmFile = - new File(nativeDatabaseDir, ".sentry-shm-service-" + Long.toHexString(ipcId)); + new File(nativeDatabaseDir, SHM_PREFIX + Process.myPid() + "-" + Long.toHexString(ipcId)); ParcelFileDescriptor notifyRead = null; ParcelFileDescriptor notifyWrite = null; @@ -211,6 +216,34 @@ private static void closeQuietly(final @Nullable Closeable closeable) { } } + // a setup that failed before the daemon attached leaves its file behind + private static void deleteStaleShmFiles( + final @NotNull Context context, final @NotNull File nativeDatabaseDir) { + final @Nullable ActivityManager activityManager = + (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + final @Nullable List processes = + activityManager != null ? activityManager.getRunningAppProcesses() : null; + final @Nullable File[] files = nativeDatabaseDir.listFiles(); + if (processes == null || files == null) { + return; + } + + final Set pids = new HashSet<>(); + for (ActivityManager.RunningAppProcessInfo process : processes) { + pids.add(String.valueOf(process.pid)); + } + for (File file : files) { + final String name = file.getName(); + if (!name.startsWith(SHM_PREFIX)) { + continue; + } + final int end = name.indexOf('-', SHM_PREFIX.length()); + if (end < 0 || !pids.contains(name.substring(SHM_PREFIX.length(), end))) { + file.delete(); + } + } + } + private static @Nullable ServiceConnection configureService( final @NotNull Context context, final @NotNull Intent intent, diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt index 6ce6832d434..441d3864112 100644 --- a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt @@ -114,6 +114,23 @@ class SentryNdkCrashServiceTest { } } + @Test + fun `deletes the shared memory files of exited processes`() { + configure(true) + val dir = File(options.cacheDirPath, ".sentry-native").apply { mkdirs() } + val running = File(dir, ".sentry-shm-service-1-a").apply { createNewFile() } + val exited = File(dir, ".sentry-shm-service-2-b").apply { createNewFile() } + val activityManager = app.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager + shadowOf(activityManager) + .setProcesses(listOf(ActivityManager.RunningAppProcessInfo().apply { pid = 1 })) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { + SentryNdkCrashService.start(app, options) + } + + assertThat(running.exists()).isTrue() + assertThat(exited.exists()).isFalse() + } + @Test fun `matches the exit info of the app pid`() { val service = Robolectric.setupService(SentryNdkCrashService::class.java) From c8564cbfc0b2cd126229545587ac3d48a2e2c910 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 15:23:29 +0200 Subject: [PATCH 13/19] fix(android): Fall back to the 1-argument NDK init With a context, NdkIntegration only looked up `SentryNdk.init(Context, SentryAndroidOptions)`. An older sentry-android-ndk without it disabled NDK. Fall back to `SentryNdk.init(SentryAndroidOptions)` and log a warning. --- .../sentry/android/core/NdkIntegration.java | 20 ++++++++++++++----- .../sentry/android/core/NdkIntegrationTest.kt | 17 ++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java index 2ec1535efb7..b486bae64fa 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/NdkIntegration.java @@ -57,12 +57,22 @@ public final void register(final @NotNull IScopes scopes, final @NotNull SentryO } try { - final Method method; - final Object[] args; + Method method = null; + Object[] args = null; if (context != null) { - method = sentryNdkClass.getMethod("init", Context.class, SentryAndroidOptions.class); - args = new Object[] {context, this.options}; - } else { + try { + method = sentryNdkClass.getMethod("init", Context.class, SentryAndroidOptions.class); + args = new Object[] {context, this.options}; + } catch (NoSuchMethodException e) { + // an older sentry-android-ndk + this.options + .getLogger() + .log( + SentryLevel.WARNING, + "SentryNdk.init(Context, SentryAndroidOptions) not found, using SentryNdk.init(SentryAndroidOptions)."); + } + } + if (method == null) { method = sentryNdkClass.getMethod("init", SentryAndroidOptions.class); args = new Object[] {this.options}; } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt index ae73a394050..29bdf7ddd88 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/NdkIntegrationTest.kt @@ -1,5 +1,6 @@ package io.sentry.android.core +import android.content.Context import io.sentry.ILogger import io.sentry.IScopes import io.sentry.SentryLevel @@ -18,6 +19,8 @@ class NdkIntegrationTest { val logger = mock() fun getSut(clazz: Class<*>? = SentryNdk::class.java): NdkIntegration = NdkIntegration(clazz) + + fun getSut(context: Context, clazz: Class<*>): NdkIntegration = NdkIntegration(context, clazz) } private val fixture = Fixture() @@ -95,6 +98,20 @@ class NdkIntegrationTest { assertFalse(options.isEnableScopeSync) } + @Test + fun `NdkIntegration falls back to init without context`() { + val integration = fixture.getSut(mock(), SentryNdk::class.java) + + val options = getOptions() + + integration.register(fixture.scopes, options) + + verify(fixture.logger).log(eq(SentryLevel.WARNING), any()) + verify(fixture.logger, never()).log(eq(SentryLevel.ERROR), any(), any()) + assertTrue(options.isEnableNdk) + assertTrue(options.isEnableScopeSync) + } + @Test fun `NdkIntegration won't close if close method is not available`() { val integration = fixture.getSut(SentryNdkNoClose::class.java) From b8cc09da27df0fd20854d27d440adba351c9387c Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 15:31:42 +0200 Subject: [PATCH 14/19] ref(android): Stop passing the tombstone flag to NdkOptions sentry-native-ndk-outproc no longer reads it, because it always sends native crash envelopes without a minidump. The crash service still gets the tombstone flag through its configure call. --- .../java/io/sentry/android/ndk/SentryNdk.java | 3 --- .../io/sentry/android/ndk/NativeBackendTest.kt | 16 ---------------- 2 files changed, 19 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java index 9a09702912d..bfe400411d7 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -99,9 +99,6 @@ public static void init( io.sentry.ndk.NdkHandlerStrategy.SENTRY_HANDLER_STRATEGY_CHAIN_AT_START); } - ndkOptions.setTombstoneEnabled( - options.isTombstoneEnabled() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S); - final @Nullable Double tracesSampleRate = options.getTracesSampleRate(); if (tracesSampleRate == null) { ndkOptions.setTracesSampleRate(0.0f); diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt index f087253e5c6..47d39738649 100644 --- a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt @@ -18,22 +18,6 @@ import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) @Config(sdk = [31]) class NativeBackendTest { - @Test - fun `starts service with tombstones disabled`() { - initialize({ isTombstoneEnabled = false }) { assertThat(it.isTombstoneEnabled).isFalse() } - } - - @Test - fun `enables supported tombstone enrichment`() { - initialize({ isTombstoneEnabled = true }) { assertThat(it.isTombstoneEnabled).isTrue() } - } - - @Test - @Config(sdk = [30]) - fun `disables unavailable tombstone enrichment`() { - initialize({ isTombstoneEnabled = true }) { assertThat(it.isTombstoneEnabled).isFalse() } - } - @Test fun `defaults missing traces sample rate to zero`() { initialize({ tracesSampleRate = null }) { assertThat(it.tracesSampleRate).isEqualTo(0.0f) } From f10770fa5ebc1080402233801d85b4ab26a98fa0 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 16:02:20 +0200 Subject: [PATCH 15/19] fix(android): Skip SDK init in the NDK crash service process The crash service runs in its own `.sentry_ndk_crash` process, which runs the app's `Application.onCreate`. Apps that call `SentryAndroid.init` there started a second SDK on every launch, with the tombstone, ANR and outbox senders working on the same cache directory as the app process. Return early from `SentryAndroid.init` in that process. --- .../io/sentry/android/core/SentryAndroid.java | 20 +++++++++++++++++++ .../sentry/android/core/SentryAndroidTest.kt | 13 ++++++++++++ 2 files changed, 33 insertions(+) diff --git a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java index 82916a248e6..64fd0a042b1 100644 --- a/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java +++ b/sentry-android-core/src/main/java/io/sentry/android/core/SentryAndroid.java @@ -3,6 +3,7 @@ import android.annotation.SuppressLint; import android.app.Application; import android.content.Context; +import android.os.Build; import android.os.Process; import android.os.SystemClock; import android.os.Trace; @@ -48,6 +49,9 @@ public final class SentryAndroid { private static final String FRAGMENT_CLASS_NAME = "androidx.fragment.app.FragmentManager$FragmentLifecycleCallbacks"; + // the process of SentryNdkCrashService in sentry-android-ndk-outproc + private static final String NDK_CRASH_SERVICE_PROCESS_SUFFIX = ".sentry_ndk_crash"; + protected static final @NotNull AutoClosableReentrantLock staticLock = new AutoClosableReentrantLock(); @@ -96,6 +100,12 @@ public static void init( @NotNull final Context context, @NotNull ILogger logger, @NotNull Sentry.OptionsConfiguration configuration) { + if (isNdkCrashServiceProcess()) { + logger.log( + SentryLevel.DEBUG, "Skipping SentryAndroid.init in the NDK crash service process."); + return; + } + // Started before acquiring the lock so it stays balanced with the endSection() in the finally // even if acquire() throws. Trace.beginSection("SentryAndroid.init"); @@ -281,4 +291,14 @@ private static void deduplicateIntegrations( } } } + + // the crash service only runs on API 30+ + @SuppressLint("NewApi") + private static boolean isNdkCrashServiceProcess() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { + return false; + } + final @Nullable String processName = Application.getProcessName(); + return processName != null && processName.endsWith(NDK_CRASH_SERVICE_PROCESS_SUFFIX); + } } diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt index 725a5a61122..b5559582610 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/SentryAndroidTest.kt @@ -84,6 +84,7 @@ import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowActivityManager import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import org.robolectric.shadows.ShadowApplication @RunWith(AndroidJUnit4::class) @Config(sdk = [Build.VERSION_CODES.N], shadows = [SentryShadowProcess::class]) @@ -573,6 +574,18 @@ class SentryAndroidTest { assertTrue(optionsRef.eventProcessors.any { it is ApplicationExitInfoEventProcessor }) } + @Test + @Config(sdk = [30]) + fun `init does nothing in the NDK crash service process`() { + ShadowApplication.setProcessName("${context.packageName}.sentry_ndk_crash") + var configured = false + + fixture.initSut { configured = true } + + assertFalse(configured) + assertFalse(Sentry.isEnabled()) + } + private fun prefillScopeCache(options: SentryOptions, cacheDir: String) { val scopeDir = File(cacheDir, SCOPE_CACHE).also { it.mkdirs() } val queueFile = QueueFile.Builder(File(scopeDir, BREADCRUMBS_FILENAME)).build() From 5069fbde73ba1e34b323c3c071886f6c8f6f9140 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 16:05:00 +0200 Subject: [PATCH 16/19] ref(android): Remove the NDK crash service process check SentryAndroid.init now returns early in the `.sentry_ndk_crash` process, so SentryNdk.init is no longer called there. --- .../java/io/sentry/android/ndk/SentryNdk.java | 35 ------------------- 1 file changed, 35 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java index bfe400411d7..c245b7ff959 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java @@ -1,8 +1,6 @@ package io.sentry.android.ndk; import android.annotation.SuppressLint; -import android.app.ActivityManager; -import android.app.Application; import android.content.Context; import android.content.ServiceConnection; import android.os.Build; @@ -11,7 +9,6 @@ import io.sentry.ndk.NativeModuleListLoader; import io.sentry.ndk.NdkOptions; import io.sentry.util.Objects; -import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import org.jetbrains.annotations.ApiStatus; @@ -64,11 +61,6 @@ public static void init(@NotNull final SentryAndroidOptions options) { */ public static void init( @Nullable final Context context, @NotNull final SentryAndroidOptions options) { - if (context != null && isCrashServiceProcess(context)) { - options.getLogger().log(io.sentry.SentryLevel.DEBUG, "Skipping NDK init in crash service."); - return; - } - // no fork/exec fallback, the native crash daemon only runs in the crash service if (context == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { throw new IllegalStateException("The NDK crash service needs a context and API 30+"); @@ -166,31 +158,4 @@ private static void unbindCrashService() { context.unbindService(connection); } } - - private static boolean isCrashServiceProcess(final @NotNull Context context) { - final @NotNull String crashServiceProcess = context.getPackageName() + ".sentry_ndk_crash"; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - return crashServiceProcess.equals(Application.getProcessName()); - } - - final @Nullable ActivityManager activityManager = - (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); - if (activityManager == null) { - return false; - } - - final int pid = android.os.Process.myPid(); - final @Nullable List processes = - activityManager.getRunningAppProcesses(); - if (processes == null) { - return false; - } - - for (ActivityManager.RunningAppProcessInfo process : processes) { - if (process.pid == pid) { - return crashServiceProcess.equals(process.processName); - } - } - return false; - } } From a4942d082a087f5b23b92f7a32cf798284715b66 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 16:12:56 +0200 Subject: [PATCH 17/19] fix(android): Keep a newer NDK tombstone marker Crash service workers for processes that crash close together can finish out of order. A worker that wrote an older tombstone timestamp let `TombstoneIntegration` report the newer crash again on the next launch. Write the marker only when it is newer than the current one, and serialize the workers. --- .../android/ndk/SentryNdkCrashService.java | 14 ++++++++++++-- .../android/ndk/SentryNdkCrashServiceTest.kt | 16 ++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java index 27c3a1a69dd..2918070b8f6 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -28,6 +28,7 @@ import io.sentry.android.core.cache.AndroidEnvelopeCache; import io.sentry.android.core.internal.tombstone.TombstoneParser; import io.sentry.ndk.SentryNdk.CrashDaemon; +import io.sentry.util.FileUtils; import java.io.BufferedInputStream; import java.io.Closeable; import java.io.File; @@ -625,8 +626,9 @@ ApplicationExitInfo findMatchingExitInfo( return mergedFile; } - private void writeLastTombstoneMarker(final @NotNull File envelopeFile, final long timestamp) - throws IOException { + // workers for concurrent crashes must not move the marker back + private static synchronized void writeLastTombstoneMarker( + final @NotNull File envelopeFile, final long timestamp) throws IOException { final @Nullable File runDir = envelopeFile.getParentFile(); final @Nullable File nativeDir = runDir != null ? runDir.getParentFile() : null; final @Nullable File cacheDir = nativeDir != null ? nativeDir.getParentFile() : null; @@ -634,6 +636,14 @@ private void writeLastTombstoneMarker(final @NotNull File envelopeFile, final lo return; } final File marker = new File(cacheDir, AndroidEnvelopeCache.LAST_TOMBSTONE_REPORT); + final @Nullable String lastTimestamp = FileUtils.readText(marker); + try { + if (lastTimestamp != null && Long.parseLong(lastTimestamp.trim()) >= timestamp) { + return; + } + } catch (NumberFormatException ignored) { + // overwrite an invalid marker + } try (final OutputStream outputStream = new FileOutputStream(marker)) { outputStream.write(String.valueOf(timestamp).getBytes("UTF-8")); } diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt index 441d3864112..732d7cfce26 100644 --- a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt @@ -194,6 +194,22 @@ class SentryNdkCrashServiceTest { assertThat(marker().readText()).isEqualTo("2000") } + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `keeps a newer marker`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000, trace = tombstone()) + crashDaemon(envelope, CrashDaemon.SendResult.SENT) + marker().writeText("3000") + + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + awaitWorker() + + assertThat(CrashDaemonShadow.sends).containsExactly(listOf("${envelope.path}.merged", 2000L)) + assertThat(marker().readText()).isEqualTo("3000") + } + @Test @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) fun `writes no marker when the send fails`() { From 5955c4bc5223f2fe6df5f6d140d7f3e713c7acff Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 16:15:35 +0200 Subject: [PATCH 18/19] ci: Stop the sentry-native Gradle daemon after publishing The publish step left its 4 GB Gradle daemon running alongside the 8 GB daemon used by `make preMerge`. The job was killed about 12 minutes into the tests, most likely because the runner ran out of memory. --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dd9af244ece..759bf5843b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -55,7 +55,7 @@ jobs: # TODO: remove once sentry-native publishes sentry-native-ndk-outproc - name: Publish sentry-native-ndk-outproc to Maven Local working-directory: sentry-native/ndk - run: ./gradlew :sentry-native-ndk-outproc:publishToMavenLocal + run: ./gradlew --no-daemon :sentry-native-ndk-outproc:publishToMavenLocal - name: Run Tests and Lint run: make preMerge From a28a9968d640c5f975230fdde0c810abf9e1de84 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 23 Oct 2026 13:54:33 +0200 Subject: [PATCH 19/19] fix(android): Bind the NDK crash service as important Without `BIND_IMPORTANT`, a foreground app raises its bound crash service process only to the visible oom adj (100), so lmkd would kill the service before the app under memory pressure. The flag gives the service the app's own adj (0) while the app is on top, without lowering the app's priority the way `BIND_ABOVE_CLIENT` would. --- .../java/io/sentry/android/ndk/SentryNdkCrashService.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java index 2918070b8f6..89efcabd946 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java @@ -304,11 +304,13 @@ public void onBindingDied(final @NotNull ComponentName name) { } }; + // a foreground app raises the service process only to visible without BIND_IMPORTANT + final int flags = Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // deliver callbacks on the binder thread, the caller blocks on the latch - bound[0] = context.bindService(intent, Context.BIND_AUTO_CREATE, Runnable::run, connection); + bound[0] = context.bindService(intent, flags, Runnable::run, connection); } else { - bound[0] = context.bindService(intent, connection, Context.BIND_AUTO_CREATE); + bound[0] = context.bindService(intent, connection, flags); } if (!bound[0]) { return null;