From 507da9a360f211dea9e84f7c1e7eafe63fd950a5 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 20 May 2026 18:20:53 +0200 Subject: [PATCH 01/29] 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 ff6e1e20dc1..76b99a0086a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -188,6 +188,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.5" } +sentry-native-ndk-outproc = { module = "io.sentry:sentry-native-ndk-outproc", version = "0.17.5" } 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 0c2ae5c90f8..3afe2a8987c 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 } @@ -29,6 +30,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 c61678be0cb..3bf4617f739 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -21,6 +21,7 @@ android { // for AGP 4.1 buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") + buildConfigField("String", "SENTRY_PACKAGE_NAME", "\"${project.name}\"") } buildTypes { @@ -79,3 +80,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 ec94cc6ccdf..f43a36d8df3 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 54dea34b271def2dc58107e21ce68884bef60492 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Wed, 21 Oct 2026 15:19:02 +0200 Subject: [PATCH 02/29] Update CHANGELOG.md --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 90ddcf1f068..4a9c337ac8e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - The scope `Environment` is also applied to the trace envelope header (DSC), logs, metrics and profiles, so all telemetry of a trace reports the same environment - Add an `attachTracingHeaders` flag to `SentryApolloInterceptor` to stop attaching `sentry-trace` and `baggage` headers to Apollo v2 requests ([#6414](https://github.com/getsentry/sentry-java/pull/6414)) - Apollo v2 has no request URL in the interceptor, so these headers are attached without checking `tracePropagationTargets`. Pass `attachTracingHeaders = false` to opt out. +- Add experimental, opt-in out-of-process crash reporting on Android ([#6347](https://github.com/getsentry/sentry-java/pull/6347)) ### Improvements From d34d408f3d069815aef8750a83ed938e9d898c95 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 09:19:12 +0200 Subject: [PATCH 03/29] 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 c47c86c58f7e2191fa8d18e8361f596ca15bd9f6 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 09:28:01 +0200 Subject: [PATCH 04/29] 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 a60a8e95064c14b1e3358e10d74fe9cfa3acf4ad Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 09:44:00 +0200 Subject: [PATCH 05/29] 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 f5dc791a64997faa02b37c6493016b9ffcd3a4ca Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 09:57:18 +0200 Subject: [PATCH 06/29] 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 919eb5aac981ebb3cc576af407bad52c48f06643 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 11:15:44 +0200 Subject: [PATCH 07/29] 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 3bf4617f739..65e8061bc79 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -83,4 +83,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 ae47d7bab0c344d6d70339b57ab1e534ff80756e Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 12:04:27 +0200 Subject: [PATCH 08/29] 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 05e75eb896c7cb66e3d5c88ee5f7d5c84b522496 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 12:26:41 +0200 Subject: [PATCH 09/29] 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 e84e3c67ba03078084ecb026872a1dac6245e278 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 12:39:01 +0200 Subject: [PATCH 10/29] 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 36c62fbe188..70bfa5bb9ff 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -30,6 +30,20 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@8da46b539cd8fe6ab27ca75128919a18d757604c # v6.4.1 + # 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 0fe3673f2aa28867cbcd8ae5da567c7579f051a6 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 13:55:42 +0200 Subject: [PATCH 11/29] 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 4a7c12d64c80256945fe5659c3d68b0f091a562e Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 14:16:22 +0200 Subject: [PATCH 12/29] 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 094728d73fcabab06dc2b5e128804cd4109f1683 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 15:23:29 +0200 Subject: [PATCH 13/29] 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 20d45472fc94d94abea7c5ef83a3d397fa76a962 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 15:31:42 +0200 Subject: [PATCH 14/29] 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 71a3cda6bc3601331092edae5338222f0c550bc9 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 16:02:20 +0200 Subject: [PATCH 15/29] 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 c779feb70adb73fab95c1e0563da8172773b1ac9 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 16:05:00 +0200 Subject: [PATCH 16/29] 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 1d66db7fd07245d9be585dfd83d58080c5fd9d56 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 16:12:56 +0200 Subject: [PATCH 17/29] 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 390f348f3d7ccca1fb2cd21192ec4dfe576cb6d4 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Thu, 22 Oct 2026 16:15:35 +0200 Subject: [PATCH 18/29] 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 70bfa5bb9ff..ba6dd6c3099 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,7 +42,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 a36c1e5f8094635c0b5920e82378401c23625af1 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Fri, 23 Oct 2026 13:54:33 +0200 Subject: [PATCH 19/29] 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; From 7d92170c4e467b6f503842fa07b96facb3bede78 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 3 Nov 2026 11:50:51 +0100 Subject: [PATCH 20/29] Apply batched suggestions from code review Co-authored-by: Roman Zavarnitsyn --- sentry-android-ndk-outproc/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/sentry-android-ndk-outproc/build.gradle.kts b/sentry-android-ndk-outproc/build.gradle.kts index 75c7a2e75d5..d740e5516ab 100644 --- a/sentry-android-ndk-outproc/build.gradle.kts +++ b/sentry-android-ndk-outproc/build.gradle.kts @@ -4,10 +4,12 @@ plugins { id("com.android.library") alias(libs.plugins.kotlin.android) alias(libs.plugins.gradle.versions) + id("io.sentry.api-validation") } android { compileSdk = libs.versions.compileSdk.get().toInt() + compileSdkMinor = libs.versions.compileSdkMinor.get().toInt() namespace = "io.sentry.android.ndk" defaultConfig { From 5324909c69433abc9f6c421928f9182fa79b8353 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 3 Nov 2026 12:03:19 +0100 Subject: [PATCH 21/29] chore(android): Update the outproc API dump The outproc module shares NdkScopeObserver with the inproc module, which gained setEnvironment on main (#5772). --- sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api | 1 + 1 file changed, 1 insertion(+) 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 ea482e03037..df8a0b1744a 100644 --- a/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api +++ b/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api @@ -21,6 +21,7 @@ public final class io/sentry/android/ndk/NdkScopeObserver : io/sentry/ScopeObser public fun clearAttachments ()V public fun removeExtra (Ljava/lang/String;)V public fun removeTag (Ljava/lang/String;)V + public fun setEnvironment (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 From df1000dbfb5a2e29be1cbcf65aca5e92e7eb8512 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 3 Nov 2026 14:59:55 +0100 Subject: [PATCH 22/29] fix(android): Reattach the NDK crash daemon after a service restart When the crash service process died while the app was bound, Android restarted it and called `onServiceConnected` again, but the connection had already closed the descriptors it configured with, so the app ran without a crash daemon. Configure the restarted service with new pipes and the app's shared memory file, then reattach the native crash IPC. Bind again when the binding dies, and log a lost connection at ERROR. --- .../android/ndk/SentryNdkCrashService.java | 152 ++++++++++++++++-- .../android/ndk/SentryNdkCrashServiceTest.kt | 69 +++++++- .../ndk/SentryShadowParcelFileDescriptor.kt | 10 ++ 3 files changed, 206 insertions(+), 25 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 89efcabd946..6d3f4387e59 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 @@ -87,12 +87,15 @@ protected boolean onTransact( final @Nullable ParcelFileDescriptor notifyRead = data.readFileDescriptor(); final @Nullable ParcelFileDescriptor readyWrite = data.readFileDescriptor(); final @Nullable ParcelFileDescriptor messageRead = data.readFileDescriptor(); + final @Nullable ParcelFileDescriptor shm = + data.readInt() == 1 ? data.readFileDescriptor() : null; final boolean configured = configureDaemon( nativeDatabasePath, nativeLibraryDir, shmPath, + shm, appPid, appTid, tombstoneEnabled, @@ -265,19 +268,40 @@ private static void deleteStaleShmFiles( throws InterruptedException { final CountDownLatch latch = new CountDownLatch(1); final boolean[] configured = new boolean[] {false}; - final boolean[] bound = new boolean[] {false}; + // a foreground app raises the service process only to visible without BIND_IMPORTANT + final int flags = Context.BIND_AUTO_CREATE | Context.BIND_IMPORTANT; final ServiceConnection connection = new ServiceConnection() { + private boolean connected; + @Override public void onServiceConnected( final @NotNull ComponentName name, final @NotNull IBinder service) { + if (connected) { + // a restarted service process has no daemon for this app + reattach( + service, + logger, + nativeDatabasePath, + nativeLibraryDir, + appPid, + appTid, + tombstoneEnabled, + debug, + shutdownTimeoutMillis, + includes, + excludes); + return; + } + connected = true; configured[0] = transactConfigure( service, nativeDatabasePath, nativeLibraryDir, shmPath, + null, appPid, appTid, tombstoneEnabled, @@ -293,26 +317,23 @@ public void onServiceConnected( @Override public void onServiceDisconnected(final @NotNull ComponentName name) { - logger.log(SentryLevel.WARNING, "NDK crash service disconnected."); + logger.log(SentryLevel.ERROR, "NDK crash service disconnected."); latch.countDown(); } @Override public void onBindingDied(final @NotNull ComponentName name) { - logger.log(SentryLevel.WARNING, "NDK crash service binding died."); + logger.log(SentryLevel.ERROR, "NDK crash service binding died."); latch.countDown(); + // a dead binding does not reconnect, it must be bound again + if (configured[0]) { + context.unbindService(this); + bind(context, intent, flags, this); + } } }; - // 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, flags, Runnable::run, connection); - } else { - bound[0] = context.bindService(intent, connection, flags); - } - if (!bound[0]) { + if (!bind(context, intent, flags, connection)) { return null; } boolean ready = false; @@ -327,11 +348,98 @@ public void onBindingDied(final @NotNull ComponentName name) { } } + private static boolean bind( + final @NotNull Context context, + final @NotNull Intent intent, + final int flags, + final @NotNull ServiceConnection connection) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + // deliver callbacks on the binder thread, the caller blocks on the latch + return context.bindService(intent, flags, Runnable::run, connection); + } + return context.bindService(intent, connection, flags); + } + + private static void reattach( + final @NotNull IBinder service, + final @NotNull ILogger logger, + final @NotNull String nativeDatabasePath, + final @Nullable String nativeLibraryDir, + final int appPid, + final long appTid, + final boolean tombstoneEnabled, + final boolean debug, + final long shutdownTimeoutMillis, + final @NotNull ArrayList includes, + final @NotNull ArrayList excludes) { + // the daemon removed the file's name once attached, so the new one gets the file itself + final int shmFd = io.sentry.ndk.SentryNdk.dupCrashShmFd(); + if (shmFd < 0) { + logger.log(SentryLevel.ERROR, "NDK crash service restarted, but the app can't reattach."); + return; + } + + final ParcelFileDescriptor shm = ParcelFileDescriptor.adoptFd(shmFd); + 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 boolean reattached = + transactConfigure( + service, + nativeDatabasePath, + nativeLibraryDir, + null, + shm, + appPid, + appTid, + tombstoneEnabled, + debug, + shutdownTimeoutMillis, + includes, + excludes, + notifyRead, + readyWrite, + messageRead) + && io.sentry.ndk.SentryNdk.reattachCrashDaemon( + notifyWrite.detachFd(), readyRead.detachFd(), messageWrite.detachFd()); + if (reattached) { + logger.log(SentryLevel.WARNING, "NDK crash service restarted, reattached the app."); + } else { + logger.log(SentryLevel.ERROR, "NDK crash service restarted, failed to reattach the app."); + } + } catch (IOException | SecurityException e) { + logger.log(SentryLevel.ERROR, e, "Failed to reattach to the NDK crash service."); + } finally { + closeQuietly(shm); + closeQuietly(notifyRead); + closeQuietly(notifyWrite); + closeQuietly(readyRead); + closeQuietly(readyWrite); + closeQuietly(messageWrite); + closeQuietly(messageRead); + } + } + private static boolean transactConfigure( final @NotNull IBinder service, final @NotNull String nativeDatabasePath, final @Nullable String nativeLibraryDir, - final @NotNull String shmPath, + final @Nullable String shmPath, + final @Nullable ParcelFileDescriptor shm, final int appPid, final long appTid, final boolean tombstoneEnabled, @@ -359,6 +467,10 @@ private static boolean transactConfigure( data.writeFileDescriptor(notifyRead.getFileDescriptor()); data.writeFileDescriptor(readyWrite.getFileDescriptor()); data.writeFileDescriptor(messageRead.getFileDescriptor()); + data.writeInt(shm != null ? 1 : 0); + if (shm != null) { + data.writeFileDescriptor(shm.getFileDescriptor()); + } if (!service.transact(TRANSACTION_CONFIGURE, data, reply, 0)) { return false; } @@ -377,6 +489,7 @@ boolean configureDaemon( final @Nullable String nativeDatabasePath, final @Nullable String nativeLibraryDir, final @Nullable String shmPath, + final @Nullable ParcelFileDescriptor shm, final int appPid, final long appTid, final boolean tombstoneEnabled, @@ -388,7 +501,7 @@ boolean configureDaemon( final @Nullable ParcelFileDescriptor readyWrite, final @Nullable ParcelFileDescriptor messageRead) { if (nativeDatabasePath == null - || shmPath == null + || (shmPath == null && shm == null) || notifyRead == null || readyWrite == null || messageRead == null @@ -400,6 +513,7 @@ boolean configureDaemon( new DaemonConfig( nativeLibraryDir, shmPath, + shm != null ? shm.detachFd() : -1, notifyRead.detachFd(), readyWrite.detachFd(), messageRead.detachFd(), @@ -433,7 +547,8 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { config.notifyReadFd, config.readyWriteFd, config.messageReadFd, - config.shmPath)) { + config.shmPath, + config.shmFd)) { if (daemon == null) { return; } @@ -674,7 +789,8 @@ private MergedEnvelope(final @NotNull File file, final long tombstoneTimestamp) private static final class DaemonConfig { private final @Nullable String nativeLibraryDir; - private final @NotNull String shmPath; + private final @Nullable String shmPath; + private final int shmFd; private final int notifyReadFd; private final int readyWriteFd; private final int messageReadFd; @@ -688,7 +804,8 @@ private static final class DaemonConfig { private DaemonConfig( final @Nullable String nativeLibraryDir, - final @NotNull String shmPath, + final @Nullable String shmPath, + final int shmFd, final int notifyReadFd, final int readyWriteFd, final int messageReadFd, @@ -701,6 +818,7 @@ private DaemonConfig( final boolean debug) { this.nativeLibraryDir = nativeLibraryDir; this.shmPath = shmPath; + this.shmFd = shmFd; this.notifyReadFd = notifyReadFd; this.readyWriteFd = readyWriteFd; this.messageReadFd = messageReadFd; 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 732d7cfce26..87fb9408fd3 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,55 @@ class SentryNdkCrashServiceTest { } } + @Test + fun `reattaches when the restarted service connects again`() { + val binder = configure(true) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { native -> + native.`when` { io.sentry.ndk.SentryNdk.dupCrashShmFd() }.thenReturn(42) + native + .`when` { io.sentry.ndk.SentryNdk.reattachCrashDaemon(any(), any(), any()) } + .thenReturn(true) + val connection = SentryNdkCrashService.start(app, options)!! + + connection.onServiceDisconnected(component) + connection.onServiceConnected(component, binder) + + assertThat(binder.transactions).isEqualTo(2) + native.verify { io.sentry.ndk.SentryNdk.reattachCrashDaemon(any(), any(), any()) } + } + } + + @Test + fun `does not reattach without a crash IPC`() { + val binder = configure(true) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { native -> + native.`when` { io.sentry.ndk.SentryNdk.dupCrashShmFd() }.thenReturn(-1) + val connection = SentryNdkCrashService.start(app, options)!! + + connection.onServiceConnected(component, binder) + + assertThat(binder.transactions).isEqualTo(1) + native.verify( + { io.sentry.ndk.SentryNdk.reattachCrashDaemon(any(), any(), any()) }, + never(), + ) + } + } + + @Test + fun `binds again when the binding died`() { + configure(true) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { + val connection = SentryNdkCrashService.start(app, options)!! + + connection.onBindingDied(component) + + assertThat(shadowOf(app).boundServiceConnections).containsExactly(connection) + // robolectric records each bind intent as started + assertThat(shadowOf(app).allStartedServices).hasSize(2) + } + } + @Test fun `deletes the shared memory files of exited processes`() { configure(true) @@ -285,6 +334,7 @@ class SentryNdkCrashServiceTest { "db", null, "shm-$pid", + null, pid, 0L, tombstoneEnabled, @@ -329,17 +379,18 @@ class SentryNdkCrashServiceTest { private fun descriptor(fd: Int): ParcelFileDescriptor = mock { on { detachFd() } doReturn fd } - private fun configure(configured: Boolean) { - val binder = - object : Binder() { + private fun configure(configured: Boolean) = + object : Binder() { + var transactions = 0 + override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean { + transactions++ reply?.writeNoException() reply?.writeInt(if (configured) 1 else 0) return true } } - shadowOf(app).setComponentNameAndServiceForBindService(component, binder) - } + .also { shadowOf(app).setComponentNameAndServiceForBindService(component, it) } } @Implements(io.sentry.ndk.SentryNdk::class) @@ -357,9 +408,10 @@ class BlockingCrashDaemonShadow { notifyFd: Int, readyFd: Int, messageFd: Int, - shmPath: String, + shmPath: String?, + shmFd: Int, ): CrashDaemon? { - daemons.add(listOf(appPid, notifyFd, readyFd, messageFd, shmPath)) + daemons.add(listOf(appPid, notifyFd, readyFd, messageFd, shmPath ?: shmFd)) release.await(10, TimeUnit.SECONDS) return null } @@ -384,7 +436,8 @@ class CrashDaemonShadow { notifyFd: Int, readyFd: Int, messageFd: Int, - shmPath: String, + shmPath: String?, + shmFd: Int, ): Long = 1L @Suppress("unused", "UNUSED_PARAMETER") 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 index 229a4e70031..dde34120d0c 100644 --- 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 @@ -1,6 +1,7 @@ package io.sentry.android.ndk import android.os.ParcelFileDescriptor +import java.io.File import org.robolectric.annotation.Implementation import org.robolectric.annotation.Implements import org.robolectric.shadows.ShadowParcelFileDescriptor @@ -9,4 +10,13 @@ import org.robolectric.shadows.ShadowParcelFileDescriptor @Implements(ParcelFileDescriptor::class) class SentryShadowParcelFileDescriptor : ShadowParcelFileDescriptor() { @Suppress("unused") @Implementation fun detachFd(): Int = -1 + + companion object { + // the real adoptFd needs the same API + @Suppress("unused", "UNUSED_PARAMETER") + @Implementation + @JvmStatic + fun adoptFd(fd: Int): ParcelFileDescriptor = + ParcelFileDescriptor.open(File("/dev/null"), ParcelFileDescriptor.MODE_READ_ONLY) + } } From 150ab4c09069d958877bcd0a758cb727b277d9f5 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 3 Nov 2026 15:43:24 +0100 Subject: [PATCH 23/29] ref(android): Build the NDK outproc package on top of sentry-android-ndk sentry-android-ndk-outproc compiled sentry-android-ndk's sources through extra source folders, shared its namespace and borrowed its ProGuard rules, so the same files belonged to both modules, and the deprecated `BaseExtension` wired it up. Depend on sentry-android-ndk instead, without its `sentry-native-ndk`, and keep only the crash service and the `SentryNdkOutproc` entry point in the `io.sentry.android.ndk.outproc` namespace. `NdkIntegration` now loads that entry point first and falls back to `SentryNdk`. --- .../api/sentry-android-core.api | 1 + .../core/AndroidOptionsInitializer.java | 7 +++- .../sentry/android/core/NdkIntegration.java | 3 ++ .../core/AndroidOptionsInitializerTest.kt | 16 ++++++++++ sentry-android-ndk-outproc/README.md | 18 +++++++++-- .../api/sentry-android-ndk-outproc.api | 32 +++---------------- sentry-android-ndk-outproc/build.gradle.kts | 27 +++------------- sentry-android-ndk-outproc/proguard-rules.pro | 6 ++++ .../{ => outproc}/SentryNdkCrashService.java | 2 +- .../SentryNdkOutproc.java} | 22 ++++++++++--- .../ndk/{ => outproc}/NativeBackendTest.kt | 8 ++--- .../SentryNdkCrashServiceTest.kt | 10 +++--- .../SentryShadowParcelFileDescriptor.kt | 2 +- sentry-android-ndk/api/sentry-android-ndk.api | 1 - sentry-android-ndk/build.gradle.kts | 6 ---- .../java/io/sentry/android/ndk/SentryNdk.java | 0 .../io/sentry/android/ndk/SentryNdkUtil.java | 5 ++- .../io/sentry/android/ndk/SentryNdkTest.kt | 0 .../sentry/android/ndk/SentryNdkUtilTest.kt | 3 +- 19 files changed, 89 insertions(+), 80 deletions(-) create mode 100644 sentry-android-ndk-outproc/proguard-rules.pro rename sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/{ => outproc}/SentryNdkCrashService.java (99%) rename sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/{SentryNdk.java => outproc/SentryNdkOutproc.java} (88%) rename sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/{ => outproc}/NativeBackendTest.kt (90%) rename sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/{ => outproc}/SentryNdkCrashServiceTest.kt (98%) rename sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/{ => outproc}/SentryShadowParcelFileDescriptor.kt (95%) rename sentry-android-ndk/src/{inproc => main}/java/io/sentry/android/ndk/SentryNdk.java (100%) rename sentry-android-ndk/src/{inprocTest => test}/java/io/sentry/android/ndk/SentryNdkTest.kt (100%) diff --git a/sentry-android-core/api/sentry-android-core.api b/sentry-android-core/api/sentry-android-core.api index e6b08aeae4d..dc4fc89bf54 100644 --- a/sentry-android-core/api/sentry-android-core.api +++ b/sentry-android-core/api/sentry-android-core.api @@ -372,6 +372,7 @@ public final class io/sentry/android/core/NdkHandlerStrategy : java/lang/Enum { public final class io/sentry/android/core/NdkIntegration : io/sentry/Integration, java/io/Closeable { public static final field SENTRY_NDK_CLASS_NAME Ljava/lang/String; + public static final field SENTRY_NDK_OUTPROC_CLASS_NAME Ljava/lang/String; public fun (Ljava/lang/Class;)V public fun close ()V public final fun register (Lio/sentry/IScopes;Lio/sentry/SentryOptions;)V 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 18735929337..6981b3ea08f 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 @@ -1,6 +1,7 @@ package io.sentry.android.core; import static io.sentry.android.core.NdkIntegration.SENTRY_NDK_CLASS_NAME; +import static io.sentry.android.core.NdkIntegration.SENTRY_NDK_OUTPROC_CLASS_NAME; import static io.sentry.util.IntegrationUtils.addIntegrationToSdkVersion; import android.annotation.SuppressLint; @@ -435,7 +436,11 @@ 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()); + // the outproc backend builds on the inproc one, so it goes first, and quietly + Class sentryNdkClass = loadClass.loadClass(SENTRY_NDK_OUTPROC_CLASS_NAME, null); + if (sentryNdkClass == null) { + sentryNdkClass = loadClass.loadClass(SENTRY_NDK_CLASS_NAME, options.getLogger()); + } options.addIntegration(new NdkIntegration(context, sentryNdkClass)); if (buildInfoProvider.getSdkInfoVersion() >= Build.VERSION_CODES.S) { 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 b486bae64fa..f8c96b71459 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 @@ -20,6 +20,9 @@ public final class NdkIntegration implements Integration, Closeable { public static final String SENTRY_NDK_CLASS_NAME = "io.sentry.android.ndk.SentryNdk"; + public static final String SENTRY_NDK_OUTPROC_CLASS_NAME = + "io.sentry.android.ndk.outproc.SentryNdkOutproc"; + private final @Nullable Context context; private final @Nullable Class sentryNdkClass; diff --git a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt index 99b99b81120..251a9d8d7e1 100644 --- a/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt +++ b/sentry-android-core/src/test/java/io/sentry/android/core/AndroidOptionsInitializerTest.kt @@ -52,6 +52,7 @@ import kotlin.test.assertNull import kotlin.test.assertTrue import org.junit.runner.RunWith import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never @@ -125,6 +126,7 @@ class AndroidOptionsInitializerTest { fun initSutWithClassLoader( classesToLoad: List = emptyList(), + loadedClasses: Map> = emptyMap(), isFragmentAvailable: Boolean = false, isTimberAvailable: Boolean = false, isReplayAvailable: Boolean = false, @@ -138,6 +140,9 @@ class AndroidOptionsInitializerTest { sentryOptions.isDebug = true val buildInfo = createBuildInfo() val loadClass = createClassMock(classesToLoad) + loadedClasses.forEach { (name, clazz) -> + whenever(loadClass.loadClass(eq(name), anyOrNull())).thenReturn(clazz) + } val activityFramesTracker = ActivityFramesTracker(loadClass, sentryOptions) AndroidOptionsInitializer.loadDefaultAndMetadataOptions( @@ -640,6 +645,17 @@ class AndroidOptionsInitializerTest { assertNotNull((actual as NdkIntegration).sentryNdkClass) } + @Test + fun `NdkIntegration prefers the outproc backend class`() { + fixture.initSutWithClassLoader( + classesToLoad = listOfNotNull(NdkIntegration.SENTRY_NDK_CLASS_NAME), + loadedClasses = mapOf(NdkIntegration.SENTRY_NDK_OUTPROC_CLASS_NAME to Any::class.java), + ) + + val actual = fixture.sentryOptions.integrations.firstOrNull { it is NdkIntegration } + assertEquals(Any::class.java, (actual as NdkIntegration).sentryNdkClass) + } + @Test fun `NdkIntegration won't be enabled, if class not found`() { fixture.initSutWithClassLoader(classesToLoad = emptyList()) diff --git a/sentry-android-ndk-outproc/README.md b/sentry-android-ndk-outproc/README.md index 9a21f46dc7f..582f86da1f5 100644 --- a/sentry-android-ndk-outproc/README.md +++ b/sentry-android-ndk-outproc/README.md @@ -1,8 +1,9 @@ # 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: +Experimental opt-in alternative to the inproc native backend, with an Android +service that runs the native crash daemon. Builds on `sentry-android-ndk` and +replaces its `sentry-native-ndk` with `sentry-native-ndk-outproc`. Select it +through the Sentry Gradle plugin: ```kotlin sentry { @@ -12,5 +13,16 @@ sentry { The default is the inproc backend. +Without the plugin, add `sentry-android-ndk-outproc`. With the `sentry-android` +bundle, which brings `sentry-native-ndk`, also exclude it: + +```kotlin +configurations.configureEach { + exclude(group = "io.sentry", module = "sentry-native-ndk") +} +``` + +Otherwise, the build fails because it contains both native backends. + 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 df8a0b1744a..45ea4895289 100644 --- a/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api +++ b/sentry-android-ndk-outproc/api/sentry-android-ndk-outproc.api @@ -1,41 +1,19 @@ -public final class io/sentry/android/ndk/BuildConfig { +public final class io/sentry/android/ndk/outproc/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 setEnvironment (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/outproc/SentryNdkCrashService : android/app/Service { + public fun ()V + public fun onBind (Landroid/content/Intent;)Landroid/os/IBinder; } -public final class io/sentry/android/ndk/SentryNdk { +public final class io/sentry/android/ndk/outproc/SentryNdkOutproc { 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; -} - diff --git a/sentry-android-ndk-outproc/build.gradle.kts b/sentry-android-ndk-outproc/build.gradle.kts index d740e5516ab..bcd9a6d2ab5 100644 --- a/sentry-android-ndk-outproc/build.gradle.kts +++ b/sentry-android-ndk-outproc/build.gradle.kts @@ -10,7 +10,7 @@ plugins { android { compileSdk = libs.versions.compileSdk.get().toInt() compileSdkMinor = libs.versions.compileSdkMinor.get().toInt() - namespace = "io.sentry.android.ndk" + namespace = "io.sentry.android.ndk.outproc" defaultConfig { minSdk = libs.versions.minSdk.get().toInt() @@ -21,12 +21,11 @@ android { // 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") } + getByName("debug") { consumerProguardFiles("proguard-rules.pro") } + getByName("release") { consumerProguardFiles("proguard-rules.pro") } } // AGP 9 only generates unit tests for the testBuildType. The debug variant is @@ -70,6 +69,8 @@ android { dependencies { api(projects.sentry) api(projects.sentryAndroidCore) + // the outproc backend replaces the inproc one + api(projects.sentryAndroidNdk) { exclude(group = "io.sentry", module = "sentry-native-ndk") } compileOnly(libs.jetbrains.annotations) @@ -82,21 +83,3 @@ dependencies { 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/proguard-rules.pro b/sentry-android-ndk-outproc/proguard-rules.pro new file mode 100644 index 00000000000..022d5bb3a1e --- /dev/null +++ b/sentry-android-ndk-outproc/proguard-rules.pro @@ -0,0 +1,6 @@ +##---------------Begin: proguard configuration for NDK outproc ---------- + +# The Android SDK checks at runtime if this class is available via Class.forName +-keep class io.sentry.android.ndk.outproc.SentryNdkOutproc { *; } + +##---------------End: proguard configuration for NDK outproc ---------- 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/outproc/SentryNdkCrashService.java similarity index 99% rename from sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdkCrashService.java rename to sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java index 6d3f4387e59..56d5ab205f5 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/outproc/SentryNdkCrashService.java @@ -1,4 +1,4 @@ -package io.sentry.android.ndk; +package io.sentry.android.ndk.outproc; import android.annotation.SuppressLint; import android.app.ActivityManager; 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/outproc/SentryNdkOutproc.java similarity index 88% rename from sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/SentryNdk.java rename to sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkOutproc.java index c245b7ff959..ecc3768efd7 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/outproc/SentryNdkOutproc.java @@ -1,13 +1,17 @@ -package io.sentry.android.ndk; +package io.sentry.android.ndk.outproc; import android.annotation.SuppressLint; import android.content.Context; import android.content.ServiceConnection; import android.os.Build; +import io.sentry.SentryIntegrationPackageStorage; import io.sentry.android.core.NdkHandlerStrategy; import io.sentry.android.core.SentryAndroidOptions; +import io.sentry.android.ndk.DebugImagesLoader; +import io.sentry.android.ndk.NdkScopeObserver; import io.sentry.ndk.NativeModuleListLoader; import io.sentry.ndk.NdkOptions; +import io.sentry.protocol.SdkVersion; import io.sentry.util.Objects; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -16,7 +20,9 @@ import org.jetbrains.annotations.Nullable; @ApiStatus.Internal -public final class SentryNdk { +public final class SentryNdkOutproc { + + private static final @NotNull String PACKAGE_NAME = "maven:io.sentry:sentry-android-ndk-outproc"; private static final @NotNull CountDownLatch loadLibraryLatch = new CountDownLatch(1); @@ -25,7 +31,7 @@ public final class SentryNdk { private static @Nullable ServiceConnection serviceConnection; - private SentryNdk() {} + private SentryNdkOutproc() {} static { new Thread( @@ -66,7 +72,7 @@ public static void init( throw new IllegalStateException("The NDK crash service needs a context and API 30+"); } - SentryNdkUtil.addPackage(options.getSdkVersion()); + addPackage(options.getSdkVersion()); try { if (loadLibraryLatch.await(2000, TimeUnit.MILLISECONDS)) { final @NotNull NdkOptions ndkOptions = @@ -149,6 +155,14 @@ public static void close() { } } + private static void addPackage(final @Nullable SdkVersion sdkVersion) { + SentryIntegrationPackageStorage.getInstance() + .addPackage(PACKAGE_NAME, BuildConfig.VERSION_NAME); + if (sdkVersion != null) { + sdkVersion.addPackage(PACKAGE_NAME, BuildConfig.VERSION_NAME); + } + } + private static void unbindCrashService() { final @Nullable Context context = serviceContext; final @Nullable ServiceConnection connection = serviceConnection; 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/outproc/NativeBackendTest.kt similarity index 90% rename from sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/NativeBackendTest.kt rename to sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/NativeBackendTest.kt index 47d39738649..96a68c1960f 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/outproc/NativeBackendTest.kt @@ -1,4 +1,4 @@ -package io.sentry.android.ndk +package io.sentry.android.ndk.outproc import android.content.Context import android.content.ServiceConnection @@ -49,13 +49,13 @@ class NativeBackendTest { @Test fun `fails without a context`() { - assertFailsWith { SentryNdk.init(options()) } + assertFailsWith { SentryNdkOutproc.init(options()) } } @Test @Config(sdk = [29]) fun `fails below API 30`() { - assertFailsWith { SentryNdk.init(mock(), options()) } + assertFailsWith { SentryNdkOutproc.init(mock(), options()) } } private fun options() = @@ -80,7 +80,7 @@ class NativeBackendTest { .`when` { SentryNdkCrashService.start(context, options) } .thenReturn(mock()) - SentryNdk.init(context, options) + SentryNdkOutproc.init(context, options) service.verify { SentryNdkCrashService.start(context, options) } assertThat(captured).isNotNull() 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/outproc/SentryNdkCrashServiceTest.kt similarity index 98% rename from sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryNdkCrashServiceTest.kt rename to sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt index 87fb9408fd3..f93cb6c8608 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/outproc/SentryNdkCrashServiceTest.kt @@ -1,4 +1,4 @@ -package io.sentry.android.ndk +package io.sentry.android.ndk.outproc import android.app.ActivityManager import android.app.ApplicationExitInfo @@ -79,10 +79,10 @@ class SentryNdkCrashServiceTest { fun `close unbinds the service`() { configure(true) Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { - SentryNdk.init(app, options) + SentryNdkOutproc.init(app, options) val connection = shadowOf(app).boundServiceConnections.single() - SentryNdk.close() + SentryNdkOutproc.close() assertThat(shadowOf(app).boundServiceConnections).isEmpty() assertThat(shadowOf(app).unboundServiceConnections).containsExactly(connection) @@ -97,7 +97,7 @@ class SentryNdkCrashServiceTest { .`when` { io.sentry.ndk.SentryNdk.init(any()) } .thenThrow(IllegalStateException()) - assertFailsWith { SentryNdk.init(app, options) } + assertFailsWith { SentryNdkOutproc.init(app, options) } assertThat(shadowOf(app).boundServiceConnections).isEmpty() assertThat(shadowOf(app).unboundServiceConnections).hasSize(1) @@ -108,7 +108,7 @@ class SentryNdkCrashServiceTest { 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) } + assertFailsWith { SentryNdkOutproc.init(app, options) } native.verify({ io.sentry.ndk.SentryNdk.init(any()) }, never()) } 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/outproc/SentryShadowParcelFileDescriptor.kt similarity index 95% rename from sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/SentryShadowParcelFileDescriptor.kt rename to sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryShadowParcelFileDescriptor.kt index dde34120d0c..0035c13a2ed 100644 --- 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/outproc/SentryShadowParcelFileDescriptor.kt @@ -1,4 +1,4 @@ -package io.sentry.android.ndk +package io.sentry.android.ndk.outproc import android.os.ParcelFileDescriptor import java.io.File diff --git a/sentry-android-ndk/api/sentry-android-ndk.api b/sentry-android-ndk/api/sentry-android-ndk.api index 3afe2a8987c..397e4b2bfd4 100644 --- a/sentry-android-ndk/api/sentry-android-ndk.api +++ b/sentry-android-ndk/api/sentry-android-ndk.api @@ -2,7 +2,6 @@ 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 } diff --git a/sentry-android-ndk/build.gradle.kts b/sentry-android-ndk/build.gradle.kts index 65e8061bc79..c61678be0cb 100644 --- a/sentry-android-ndk/build.gradle.kts +++ b/sentry-android-ndk/build.gradle.kts @@ -21,7 +21,6 @@ android { // for AGP 4.1 buildConfigField("String", "VERSION_NAME", "\"${project.version}\"") - buildConfigField("String", "SENTRY_PACKAGE_NAME", "\"${project.name}\"") } buildTypes { @@ -80,8 +79,3 @@ dependencies { testImplementation(libs.mockito.kotlin) testImplementation(projects.sentryTestSupport) } - -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/inproc/java/io/sentry/android/ndk/SentryNdk.java b/sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java similarity index 100% rename from sentry-android-ndk/src/inproc/java/io/sentry/android/ndk/SentryNdk.java rename to sentry-android-ndk/src/main/java/io/sentry/android/ndk/SentryNdk.java 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 182c5ceb867..9f92d2d4d79 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:" + BuildConfig.SENTRY_PACKAGE_NAME, BuildConfig.VERSION_NAME); + .addPackage("maven:io.sentry:sentry-android-ndk", BuildConfig.VERSION_NAME); } private SentryNdkUtil() {} @@ -25,7 +25,6 @@ static void addPackage(@Nullable final SdkVersion sdkVersion) { if (sdkVersion == null) { return; } - sdkVersion.addPackage( - "maven:io.sentry:" + BuildConfig.SENTRY_PACKAGE_NAME, BuildConfig.VERSION_NAME); + sdkVersion.addPackage("maven:io.sentry:sentry-android-ndk", BuildConfig.VERSION_NAME); } } diff --git a/sentry-android-ndk/src/inprocTest/java/io/sentry/android/ndk/SentryNdkTest.kt b/sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt similarity index 100% rename from sentry-android-ndk/src/inprocTest/java/io/sentry/android/ndk/SentryNdkTest.kt rename to sentry-android-ndk/src/test/java/io/sentry/android/ndk/SentryNdkTest.kt 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 c4ca0203a67..943e4fb6df7 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,7 @@ class SentryNdkUtilTest { SentryNdkUtil.addPackage(options.sdkVersion) assertTrue( options.sdkVersion!!.packageSet.any { - it.name == "maven:io.sentry:${BuildConfig.SENTRY_PACKAGE_NAME}" && - it.version == BuildConfig.VERSION_NAME + it.name == "maven:io.sentry:sentry-android-ndk" && it.version == BuildConfig.VERSION_NAME } ) } From ccbe2ea91452389df22be4616664f6ee6f3a3497 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 3 Nov 2026 17:09:55 +0100 Subject: [PATCH 24/29] fix(android): Write the NDK tombstone marker before the send The native side keeps the envelope for the next launch until it is sent, so the crash is delivered whatever the send result. Write the marker before the send, so that a send that a frozen service finishes late no longer races TombstoneIntegration on the next launch. --- .../android/ndk/outproc/SentryNdkCrashService.java | 13 +++++++++---- .../ndk/outproc/SentryNdkCrashServiceTest.kt | 4 ++-- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java index 56d5ab205f5..b9de795315d 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java @@ -560,7 +560,14 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { final File envelope = new File(envelopePath); final @Nullable MergedEnvelope mergedEnvelope = config.tombstoneEnabled ? waitForMergedEnvelope(options, envelope, config) : null; - if (config.tombstoneEnabled && mergedEnvelope == null) { + if (mergedEnvelope != null) { + // native keeps the envelope for the next launch until it is sent + try { + writeLastTombstoneMarker(envelope, mergedEnvelope.tombstoneTimestamp); + } catch (IOException e) { + options.getLogger().log(SentryLevel.DEBUG, e, "Failed to write the tombstone marker."); + } + } else if (config.tombstoneEnabled) { options .getLogger() .log(SentryLevel.DEBUG, "Sending NDK crash without tombstone enrichment."); @@ -572,11 +579,9 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { 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); } } - } catch (InterruptedException | IOException e) { + } catch (InterruptedException e) { options.getLogger().log(SentryLevel.DEBUG, e, "Error running NDK crash service."); } } diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt index f93cb6c8608..670a3455da4 100644 --- a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt @@ -261,7 +261,7 @@ class SentryNdkCrashServiceTest { @Test @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) - fun `writes no marker when the send fails`() { + fun `writes the 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()) @@ -271,7 +271,7 @@ class SentryNdkCrashServiceTest { awaitWorker() assertThat(CrashDaemonShadow.sends).containsExactly(listOf("${envelope.path}.merged", 2000L)) - assertThat(marker().exists()).isFalse() + assertThat(marker().readText()).isEqualTo("2000") } @Test From 8dccc5ced1b3d9276e44d7550d8b980d8bef8044 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 3 Nov 2026 17:19:59 +0100 Subject: [PATCH 25/29] fix(android): Close rejected NDK crash service fds `configureDaemon` left the file descriptors of a binder transaction open until garbage collection when it rejected the configuration. Close them before returning. --- .../ndk/outproc/SentryNdkCrashService.java | 4 +++ .../ndk/outproc/SentryNdkCrashServiceTest.kt | 27 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java index b9de795315d..12e97e2f376 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java @@ -506,6 +506,10 @@ boolean configureDaemon( || readyWrite == null || messageRead == null || appPid < 0) { + closeQuietly(shm); + closeQuietly(notifyRead); + closeQuietly(readyWrite); + closeQuietly(messageRead); return false; } diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt index 670a3455da4..a3884b4c977 100644 --- a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt @@ -304,6 +304,33 @@ class SentryNdkCrashServiceTest { assertThat(marker().exists()).isFalse() } + @Test + fun `closes the fds when it does not configure`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val fds = listOf(descriptor(10), descriptor(11), descriptor(12), descriptor(13)) + + val configured = + service.configureDaemon( + "db", + null, + null, + fds[0], + -1, + 0L, + false, + false, + 2000L, + null, + null, + fds[1], + fds[2], + fds[3], + ) + + assertThat(configured).isFalse() + fds.forEach { Mockito.verify(it).close() } + } + private fun addExitInfo( context: Context, pid: Int, From e9046ba182e682ad35e598458fbb1f1a6907486f Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 3 Nov 2026 17:33:07 +0100 Subject: [PATCH 26/29] fix(android): Time the NDK tombstone poll with a monotonic clock The crash service polled for a matching tombstone until a wall clock deadline, so a clock step backward delayed the crash send by the size of the step. Use `SystemClock.uptimeMillis()`, which also drives `Thread.sleep()`. --- .../io/sentry/android/ndk/outproc/SentryNdkCrashService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java index 12e97e2f376..3bc2ed0ca12 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java @@ -15,6 +15,7 @@ import android.os.ParcelFileDescriptor; import android.os.Process; import android.os.RemoteException; +import android.os.SystemClock; import io.sentry.DateUtils; import io.sentry.ILogger; import io.sentry.SentryEnvelope; @@ -608,7 +609,7 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { } // the app exits once its tombstone is written, a later exit would not match - final long deadlineMillis = System.currentTimeMillis() + MATCH_TOLERANCE_MILLIS; + final long deadlineMillis = SystemClock.uptimeMillis() + MATCH_TOLERANCE_MILLIS; while (true) { final @Nullable ApplicationExitInfo exitInfo = findMatchingExitInfo(options, nativeTimestamp, config.appPid); @@ -628,7 +629,7 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { } } - final long remainingMillis = deadlineMillis - System.currentTimeMillis(); + final long remainingMillis = deadlineMillis - SystemClock.uptimeMillis(); if (remainingMillis <= 0) { return null; } From ef9285c9eb1bcbf08047bc6bca4e2fa5a8fb2b48 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 3 Nov 2026 17:34:21 +0100 Subject: [PATCH 27/29] fix(android): Narrow the NDK outproc library load catch The library loader thread swallowed every `Throwable`, including fatal errors. Catch only the `UnsatisfiedLinkError` of a missing or broken library, which `init()` reports later. `ExceptionUtils.rethrowIfFatal` does not fit, because it rethrows `LinkageError` and would turn a missing library into an app crash. --- .../java/io/sentry/android/ndk/outproc/SentryNdkOutproc.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkOutproc.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkOutproc.java index ecc3768efd7..ad9a4844444 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkOutproc.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkOutproc.java @@ -39,7 +39,7 @@ private SentryNdkOutproc() {} try { //noinspection UnstableApiUsage io.sentry.ndk.SentryNdk.loadNativeLibraries(); - } catch (Throwable t) { + } catch (UnsatisfiedLinkError e) { // ignored // if loadLibrary() fails, the later init() will throw an exception anyway } finally { From 8393197e1833776c4d5702a55d7ff78fdb2eb5f5 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 3 Nov 2026 17:36:02 +0100 Subject: [PATCH 28/29] fix(android): Load the NDK outproc libraries before binding the service A missing or broken native library surfaced only in `initCrashDaemon`, after the crash service was bound and the IPC fds were detached. The `UnsatisfiedLinkError` left the service bound with `BIND_IMPORTANT` and the fds open for the app's lifetime. Load the libraries first, so the failure leaves nothing behind. --- .../ndk/outproc/SentryNdkCrashService.java | 3 +++ .../ndk/outproc/SentryNdkCrashServiceTest.kt | 15 +++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java index 3bc2ed0ca12..cd75a65a1df 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java @@ -117,6 +117,9 @@ protected boolean onTransact( static @Nullable ServiceConnection start( final @NotNull Context context, final @NotNull SentryAndroidOptions options) { + // fail before binding, the crash daemon can't attach without the native libraries + io.sentry.ndk.SentryNdk.loadNativeLibraries(); + final @Nullable String outboxPath = options.getOutboxPath(); if (outboxPath == null) { options diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt index a3884b4c977..a5af595d461 100644 --- a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt @@ -75,6 +75,21 @@ class SentryNdkCrashServiceTest { } } + @Test + fun `does not bind without the native libraries`() { + configure(true) + Mockito.mockStatic(io.sentry.ndk.SentryNdk::class.java).use { native -> + native + .`when` { io.sentry.ndk.SentryNdk.loadNativeLibraries() } + .thenThrow(UnsatisfiedLinkError()) + + assertFailsWith { SentryNdkCrashService.start(app, options) } + + assertThat(shadowOf(app).boundServiceConnections).isEmpty() + assertThat(shadowOf(app).unboundServiceConnections).isEmpty() + } + } + @Test fun `close unbinds the service`() { configure(true) From 77bc81da68bba3c49739baa718f2b0a6844440b5 Mon Sep 17 00:00:00 2001 From: J-P Nurmi Date: Tue, 3 Nov 2026 17:51:19 +0100 Subject: [PATCH 29/29] fix(android): Write the NDK tombstone marker when the merge fails When the crash service matched the app's native crash exit but could not merge its tombstone, because it stayed unavailable until the deadline or the merge threw, it sent the native event without writing the marker. On the next launch, `TombstoneIntegration` then reported the same crash again as a standalone tombstone event. Write the marker for a matched tombstone, merged or not. --- .../ndk/outproc/SentryNdkCrashService.java | 15 ++++++----- .../ndk/outproc/SentryNdkCrashServiceTest.kt | 26 ++++++++++++++++++- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java index cd75a65a1df..1de78b43489 100644 --- a/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java +++ b/sentry-android-ndk-outproc/src/main/java/io/sentry/android/ndk/outproc/SentryNdkCrashService.java @@ -575,7 +575,9 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { } catch (IOException e) { options.getLogger().log(SentryLevel.DEBUG, e, "Failed to write the tombstone marker."); } - } else if (config.tombstoneEnabled) { + } + final @Nullable File mergedFile = mergedEnvelope != null ? mergedEnvelope.file : null; + if (mergedFile == null && config.tombstoneEnabled) { options .getLogger() .log(SentryLevel.DEBUG, "Sending NDK crash without tombstone enrichment."); @@ -583,7 +585,7 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { final CrashDaemon.SendResult result = daemon.sendEnvelope( - mergedEnvelope != null ? mergedEnvelope.file.getPath() : envelope.getPath(), + mergedFile != null ? mergedFile.getPath() : envelope.getPath(), config.shutdownTimeoutMillis); if (result == CrashDaemon.SendResult.FAILED) { options.getLogger().log(SentryLevel.DEBUG, "Failed to send NDK crash envelope."); @@ -628,13 +630,13 @@ private void runDaemonWorker(final @NotNull DaemonConfig config) { } } catch (Exception e) { options.getLogger().log(SentryLevel.DEBUG, e, "Failed to merge NDK tombstone envelope."); - return null; + return new MergedEnvelope(null, exitInfo.getTimestamp()); } } final long remainingMillis = deadlineMillis - SystemClock.uptimeMillis(); if (remainingMillis <= 0) { - return null; + return exitInfo != null ? new MergedEnvelope(null, exitInfo.getTimestamp()) : null; } Thread.sleep(Math.min(POLL_INTERVAL_MILLIS, remainingMillis)); } @@ -791,10 +793,11 @@ private NativeEnvelope( } private static final class MergedEnvelope { - private final @NotNull File file; + // null when the matched tombstone did not merge, the native event still reports its crash + private final @Nullable File file; private final long tombstoneTimestamp; - private MergedEnvelope(final @NotNull File file, final long tombstoneTimestamp) { + private MergedEnvelope(final @Nullable File file, final long tombstoneTimestamp) { this.file = file; this.tombstoneTimestamp = tombstoneTimestamp; } diff --git a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt index a5af595d461..db42d805ba4 100644 --- a/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt +++ b/sentry-android-ndk-outproc/src/test/java/io/sentry/android/ndk/outproc/SentryNdkCrashServiceTest.kt @@ -38,6 +38,7 @@ import org.robolectric.annotation.Config import org.robolectric.annotation.Implementation import org.robolectric.annotation.Implements import org.robolectric.shadows.ShadowActivityManager.ApplicationExitInfoBuilder +import org.robolectric.shadows.ShadowSystemClock @RunWith(RobolectricTestRunner::class) @Config(sdk = [31], shadows = [SentryShadowParcelFileDescriptor::class]) @@ -316,7 +317,30 @@ class SentryNdkCrashServiceTest { awaitWorker() assertThat(CrashDaemonShadow.sends).containsExactly(listOf(envelope.path, 2000L)) - assertThat(marker().exists()).isFalse() + assertThat(marker().readText()).isEqualTo("2000") + } + + @Test + @Config(instrumentedPackages = ["io.sentry.ndk"], shadows = [CrashDaemonShadow::class]) + fun `writes the marker when the tombstone stays unavailable`() { + val service = Robolectric.setupService(SentryNdkCrashService::class.java) + val envelope = writeNativeEnvelope(timestamp = 1000) + addExitInfo(service, pid = 1, timestamp = 2000) + crashDaemon(envelope, CrashDaemon.SendResult.SENT) + + val threads = Thread.getAllStackTraces().keys + configureDaemon(service, pid = 1, fd = 10, tombstoneEnabled = true) + val worker = + (Thread.getAllStackTraces().keys - threads).single { it.name == "SentryNdkCrashService" } + // pass the deadline once the worker polls for the tombstone + while (worker.isAlive && worker.state != Thread.State.TIMED_WAITING) { + Thread.yield() + } + ShadowSystemClock.advanceBy(30, TimeUnit.SECONDS) + worker.join(5000) + + assertThat(CrashDaemonShadow.sends).containsExactly(listOf(envelope.path, 2000L)) + assertThat(marker().readText()).isEqualTo("2000") } @Test