diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7b482e737..f9ebcf395a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -543,6 +543,7 @@ jobs: pip install --upgrade --requirement tests/requirements.txt pytest --capture=no --verbose tests (cd ndk && ./gradlew :sentry-native-ndk:connectedDebugAndroidTest --stacktrace) + (cd ndk && ./gradlew :sentry-native-ndk-outproc:connectedDebugAndroidTest --stacktrace) - name: Retry emulator (Android) if: ${{ steps.android-test.outcome == 'failure' && env.ANDROID_BOOTED != 'true' }} @@ -637,4 +638,6 @@ jobs: sentry-native-*.zip sentry-native.zip ndk/lib/build/distributions/*.zip + ndk/native/build/distributions/*.zip ndk/lib/build/intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib/* + ndk/native/build/intermediates/merged_native_libs/release/mergeReleaseNativeLibs/out/lib/* diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index ff5a67ce10..4926b67b94 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -61,3 +61,71 @@ jobs: SENTRY_E2E_ORG: ${{ secrets.SENTRY_E2E_ORG }} SENTRY_E2E_PROJECT: ${{ secrets.SENTRY_E2E_PROJECT }} run: pytest --capture=no --verbose tests/test_e2e_sentry.py + + android: + name: ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: E2E Test (Android API 36, NDK 30) + ANDROID_API: 36 + ANDROID_NDK_VERSION: 30.0.16138531 + runs-on: ubuntu-latest + env: + ANDROID_API: ${{ matrix.ANDROID_API }} + ANDROID_ARCH: x86_64 + ANDROID_NDK_VERSION: ${{ matrix.ANDROID_NDK_VERSION }} + + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + submodules: recursive + + - name: Check out sentry-java + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: getsentry/sentry-java + ref: jpnurmi/feat/native-android + path: sentry-java + + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + + - name: Install test dependencies + run: pip install -r tests/requirements.txt + + - name: Set up Java + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + distribution: "temurin" + java-version: "21" + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # pin@v6.3.0 + + - name: Enable KVM group perms + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Set up and run Android E2E tests + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + env: + SENTRY_E2E_DSN: ${{ secrets.SENTRY_E2E_DSN }} + SENTRY_E2E_AUTH_TOKEN: ${{ secrets.SENTRY_E2E_AUTH_TOKEN }} + SENTRY_E2E_ORG: ${{ secrets.SENTRY_E2E_ORG }} + SENTRY_E2E_PROJECT: ${{ secrets.SENTRY_E2E_PROJECT }} + with: + api-level: ${{ env.ANDROID_API }} + ndk: ${{ env.ANDROID_NDK_VERSION }} + arch: ${{ env.ANDROID_ARCH }} + target: google_apis + emulator-boot-timeout: 1200 + script: | + export ANDROID_NDK="$ANDROID_HOME/ndk/$ANDROID_NDK_VERSION" + python scripts/setup-android-e2e.py sentry-java + pytest --capture=no --verbose tests/test_e2e_sentry.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 89a0aac09d..ff3707c1cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Add `sentry_scope_set_trace` for per-scope traces. ([#2236](https://github.com/getsentry/sentry-native/pull/2236)) - Add `sentry_continue_trace` to continue incoming traces without a transaction. ([#2237](https://github.com/getsentry/sentry-native/pull/2237)) - Add `sentry_iter_trace_propagation_headers` to propagate traces without a span. ([#2240](https://github.com/getsentry/sentry-native/pull/2240)) +- Native: add Android support. ([#1725](https://github.com/getsentry/sentry-native/pull/1725)) **Fixes**: diff --git a/CMakeLists.txt b/CMakeLists.txt index fa07e8e456..23d068311c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -274,6 +274,10 @@ elseif(SENTRY_BACKEND STREQUAL "inproc") elseif(SENTRY_BACKEND STREQUAL "breakpad") set(SENTRY_BACKEND_BREAKPAD TRUE) elseif(SENTRY_BACKEND STREQUAL "native") + if(ANDROID) + message(WARNING "The 'native' backend on Android is experimental. " + "It may have incomplete features or unexpected behavior.") + endif() set(SENTRY_BACKEND_NATIVE TRUE) elseif(SENTRY_BACKEND STREQUAL "none") set(SENTRY_BACKEND_NONE TRUE) diff --git a/README.md b/README.md index 3ad98fa43a..3449d1a60e 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,7 @@ using `cmake -D BUILD_SHARED_LIBS=OFF ..`. - **native**: An out-of-process crash handler that uses a lightweight daemon to monitor the application, generate minidumps, and send crash reports. Supports Linux, macOS, and Windows, where it is the default. Compatible with TSAN and ASAN - sanitizers. + sanitizers. Support on Android is **experimental**. - **none**: This builds `sentry-native` without a backend, so it does not handle crashes. It is primarily used for tests. @@ -364,7 +364,7 @@ using `cmake -D BUILD_SHARED_LIBS=OFF ..`. | | | | | | | | Backends | | | | | | | - crashpad | ✓ | ✓ | ✓ | | | -| - native | ☑ | ☑ | ☑ | | | +| - native | ☑ | ☑ | ☑ | (✓)*** | | | - breakpad | ✓ | ✓ | ✓ | (✓)* | (✓)* | | - inproc | ✓ | ✓ | ✓ | ☑ | | | - none | ✓ | ✓ | ✓ | ✓ | | @@ -376,6 +376,7 @@ Legend: - (✓) supported with limitations - `*`: `breakpad` on Android and iOS builds and should work according to upstream but is untested. - `**`: `curl` as a transport works on Android but isn't used in any supported configuration to reduce the size of our artifacts. +- `***`: The `native` backend on Android is experimental. In addition to platform support, the "Advanced Usage" section of the SDK docs now [describes the tradeoffs](https://docs.sentry.io/platforms/native/advanced-usage/backend-tradeoffs/) involved in choosing a suitable backend for a particular use case. diff --git a/ndk/Makefile b/ndk/Makefile index fbb03eb1aa..aac0403eb8 100644 --- a/ndk/Makefile +++ b/ndk/Makefile @@ -18,4 +18,4 @@ api: # Run tests and lint check: - ./gradlew :sentry-native-ndk:check + ./gradlew :sentry-native-ndk:check :sentry-native-ndk-outproc:check diff --git a/ndk/README.md b/ndk/README.md index 90b8abcfd2..8908ee054c 100644 --- a/ndk/README.md +++ b/ndk/README.md @@ -73,3 +73,17 @@ The `ndk` project uses the Gradle build system in combination with CMake. You ca ## Development Please see the [contribution guide](../CONTRIBUTING.md). + +## Crash backends + +`sentry-native-ndk` uses the inproc backend. The opt-in +`sentry-native-ndk-outproc` package includes the **experimental** native backend +and crash daemon. Both provide `libsentry.so` and must not be used together. + +Android SDK apps select `sentry-android-ndk-outproc` through the Sentry Gradle plugin: + +```kotlin +sentry { + ndkBackend.set("native") +} +``` diff --git a/ndk/build.gradle.kts b/ndk/build.gradle.kts index be4a8ce147..21f93c6b9f 100644 --- a/ndk/build.gradle.kts +++ b/ndk/build.gradle.kts @@ -1,3 +1,4 @@ +import com.android.build.api.dsl.LibraryExtension import com.diffplug.gradle.spotless.SpotlessPlugin import com.diffplug.spotless.LineEnding import com.vanniktech.maven.publish.MavenPublishBaseExtension @@ -73,6 +74,11 @@ subprojects { } } } + if (name == "sentry-native-ndk-outproc") { + plugins.withId("com.android.library") { + configureNdkLibrary() + } + } plugins.withId("io.gitlab.arturbosch.detekt") { configure { @@ -224,5 +230,207 @@ fun MavenPublishBaseExtension.assignAarTypes() { } apiValidation { - ignoredProjects.addAll(listOf("sample")) + ignoredProjects.addAll(listOf("sample", "sentry-native-ndk-outproc")) +} + +fun Project.configureNdkLibrary() { + var sentryNativeSrc: String = "${project.projectDir}/../.." + val nativeBackend = project.name == "sentry-native-ndk-outproc" + val sentryBackend = if (nativeBackend) "native" else providers.gradleProperty("sentryBackend").orElse("inproc").get() + val libDir = rootProject.file("lib") + val sanitizer = + System.getenv("RUN_ANALYZER").orEmpty().split(',') + .firstOrNull { it == "asan" || it == "tsan" } + + configure { + compileSdk = 37 + // retain AGP 8.7.3's default NDK to avoid changing the compiler and libc++ in a hotfix + ndkVersion = System.getenv("ANDROID_NDK")?.let { File(it).name } ?: "27.0.12077973" + namespace = "io.sentry.ndk" + + testBuildType = "debug" + + defaultConfig { + minSdk = 21 + + aarMetadata { + // avoid requiring consumers to compile against API 37 + minCompileSdk = 1 + } + + externalNativeBuild { + cmake { + arguments.add(0, "-DSENTRY_BACKEND=$sentryBackend") + if (sentryBackend == "inproc") { + arguments.add(0, "-DSENTRY_TRANSPORT=none") + } + arguments.add(0, "-DANDROID_STL=${if (sanitizer != null) "c++_shared" else "c++_static"}") + arguments.add(0, "-DSENTRY_NATIVE_SRC=$sentryNativeSrc") + if (sanitizer == "asan") { + arguments.add(0, "-DWITH_ASAN_OPTION=ON") + } + if (sanitizer == "tsan") { + arguments.add(0, "-DWITH_TSAN_OPTION=ON") + } + } + } + + ndk { + abiFilters.addAll( + if (sanitizer == "tsan") { + listOf("x86_64", "arm64-v8a") + } else { + listOf("x86", "armeabi-v7a", "x86_64", "arm64-v8a") + }, + ) + } + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + + if (nativeBackend) { + sourceSets.all { setRoot("$libDir/src/$name") } + } + + if (sanitizer != null) { + sourceSets.getByName("androidTest") { + jniLibs.srcDir("$libDir/build/$sanitizer/jniLibs") + resources.srcDir("$libDir/build/$sanitizer/resources") + } + } + + // we use the default NDK and CMake versions based on the AGP's version + // https://developer.android.com/studio/projects/install-ndk#apply-specific-version + externalNativeBuild { + cmake { + path("$libDir/CMakeLists.txt") + } + } + + buildTypes { + getByName("debug") { + externalNativeBuild { + cmake { + arguments.add(0, "-DENABLE_TESTS=ON") + } + } + } + getByName("release") { + consumerProguardFiles("$libDir/proguard-rules.pro") + } + } + + buildFeatures { + prefab = true + prefabPublishing = true + buildConfig = true + } + + // creates + // lib.aar/prefab/modules/sentry-android/libs//.so + // lib.aar/prefab/modules/sentry-android/include/sentry.h + prefab { + create("sentry-android") {} + create("sentry") { + headers = "../../include" + } + } + + testOptions { + animationsDisabled = true + unitTests.apply { + isReturnDefaultValues = true + isIncludeAndroidResources = true + } + } + + lint { + warningsAsErrors = true + checkDependencies = true + checkReleaseBuilds = true + disable.add("NewerVersionAvailable") + } + + packaging { + jniLibs { + useLegacyPackaging = true + } + } + } + + // legacy pre-prefab support + // creates lib.aar/jni/include/sentry.h alongside AGP's lib.aar/jni//.so + tasks.withType().configureEach { + if (name.startsWith("bundle") && name.endsWith("Aar")) { + from("../../include") { + into("jni/include") + } + } + } + + dependencies { + "compileOnly"("org.jetbrains:annotations:23.0.0") + + "testImplementation"("androidx.test.ext:junit:1.3.0") + + "androidTestImplementation"("androidx.test:runner:1.7.0") + "androidTestImplementation"("androidx.test.ext:junit:1.3.0") + "androidTestImplementation"("androidx.test:rules:1.7.0") + } + + /* + * Prefab doesn't support c++_static, so we need to change it to none. + * This should be fine, as we don't expose any conflicting symbols. + * Based on: https://github.com/bugsnag/bugsnag-android/blob/59460018551750dfcce4fd4e9f612eae7826559e/bugsnag-plugin-android-ndk/build.gradle.kts + * + * Copyright (c) 2012 Bugsnag + + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION + * OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + * + */ + afterEvaluate { + configurations.matching { + it.name.startsWith("releaseVariant") && it.name.endsWith("Publication") + }.configureEach { + outgoing.capability("$group:${project.name}:$version") + outgoing.capability("$group:sentry-ndk-backend:$version") + } + + tasks.matching { it.name.startsWith("prefab") && it.name.endsWith("Package") }.configureEach { + doLast { + project.fileTree("build/intermediates/") { + include("**/abi.json") + include("**/prefab.json") + include("**/prefab_publication.json/*") + }.forEach { file -> + val contents = file.readText().replace("c++_static", "none") + file.writeText( + if (nativeBackend) { + contents.replace("\"name\": \"${project.name}\"", "\"name\": \"sentry-native-ndk\"") + .replace("\"packageName\": \"${project.name}\"", "\"packageName\": \"sentry-native-ndk\"") + } else { + contents + }, + ) + } + } + } + } } diff --git a/ndk/lib/CMakeLists.txt b/ndk/lib/CMakeLists.txt index 211acf1771..064921103f 100644 --- a/ndk/lib/CMakeLists.txt +++ b/ndk/lib/CMakeLists.txt @@ -2,7 +2,10 @@ cmake_minimum_required(VERSION 3.10) project(Sentry-Android LANGUAGES C CXX) # Add sentry-android shared library -add_library(sentry-android SHARED src/main/jni/sentry.c) +add_library(sentry-android SHARED + src/main/jni/sentry.c + src/main/jni/sentry_http.c +) # Conditionally add test library if ENABLE_TESTS is set if(ENABLE_TESTS) @@ -13,9 +16,24 @@ endif() set(BUILD_SHARED_LIBS ON) set(SENTRY_BUILD_SHARED_LIBS ON) +if(SENTRY_BACKEND STREQUAL "native") + set(SENTRY_TRANSPORT "none" CACHE STRING "" FORCE) +endif() + # Adding sentry-native project add_subdirectory(${SENTRY_NATIVE_SRC} sentry_build) +# Package the daemon executable only for the instrumented tests, which fork/exec +# it. Apps run the daemon from libsentry.so in the sentry-java crash service. +if(SENTRY_BACKEND STREQUAL "native" AND ENABLE_TESTS) + set_target_properties(sentry-crash PROPERTIES + PREFIX "lib" + OUTPUT_NAME "sentry-crash" + SUFFIX ".so" + ) + target_link_options(sentry-crash PRIVATE "-Wl,-z,max-page-size=16384") +endif() + if(WITH_ASAN_OPTION) set(SENTRY_ANDROID_SANITIZER address) elseif(WITH_TSAN_OPTION) diff --git a/ndk/lib/api/sentry-native-ndk.api b/ndk/lib/api/sentry-native-ndk.api index 35780f414e..d5c6d550b5 100644 --- a/ndk/lib/api/sentry-native-ndk.api +++ b/ndk/lib/api/sentry-native-ndk.api @@ -110,8 +110,24 @@ public final class io/sentry/ndk/NdkOptions { public final class io/sentry/ndk/SentryNdk { public static fun close ()V public static fun init (Lio/sentry/ndk/NdkOptions;)V + public static fun initCrashDaemon (Ljava/lang/String;III)V public static fun loadNativeLibraries ()V public static fun preload ()V + public static fun runCrashDaemon (IJIIILjava/lang/String;)Lio/sentry/ndk/SentryNdk$CrashDaemon; +} + +public final class io/sentry/ndk/SentryNdk$CrashDaemon : java/lang/AutoCloseable { + public fun close ()V + public fun getEnvelopePath ()Ljava/lang/String; + public fun sendEnvelope (Ljava/lang/String;J)Lio/sentry/ndk/SentryNdk$CrashDaemon$SendResult; +} + +public final class io/sentry/ndk/SentryNdk$CrashDaemon$SendResult : java/lang/Enum { + public static final field FAILED Lio/sentry/ndk/SentryNdk$CrashDaemon$SendResult; + public static final field KEPT Lio/sentry/ndk/SentryNdk$CrashDaemon$SendResult; + public static final field SENT Lio/sentry/ndk/SentryNdk$CrashDaemon$SendResult; + public static fun valueOf (Ljava/lang/String;)Lio/sentry/ndk/SentryNdk$CrashDaemon$SendResult; + public static fun values ()[Lio/sentry/ndk/SentryNdk$CrashDaemon$SendResult; } public final class io/sentry/ndk/SentryNdkPreloadProvider : android/content/ContentProvider { diff --git a/ndk/lib/proguard-rules.pro b/ndk/lib/proguard-rules.pro index b4b1a0152c..6f5f7b4975 100644 --- a/ndk/lib/proguard-rules.pro +++ b/ndk/lib/proguard-rules.pro @@ -6,6 +6,8 @@ # The JNI layer uses this class through reflection -keep class io.sentry.ndk.NdkOptions { *; } -keep class io.sentry.ndk.DebugImage { *; } +-keep class io.sentry.ndk.SentryHttpClient { *; } +-keep class io.sentry.ndk.SentryHttpClient$Response { *; } # For native methods, see http://proguard.sourceforge.net/manual/examples.html#native -keepclasseswithmembernames,includedescriptorclasses class io.sentry.ndk.** { diff --git a/ndk/lib/src/main/java/io/sentry/ndk/SentryHttpClient.java b/ndk/lib/src/main/java/io/sentry/ndk/SentryHttpClient.java new file mode 100644 index 0000000000..730e326dca --- /dev/null +++ b/ndk/lib/src/main/java/io/sentry/ndk/SentryHttpClient.java @@ -0,0 +1,216 @@ +package io.sentry.ndk; + +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.SocketTimeoutException; +import java.net.URL; +import java.nio.ByteBuffer; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import javax.net.ssl.TrustManagerFactory; +import org.jetbrains.annotations.Nullable; + +class SentryHttpClient { + private final Object lock = new Object(); + private final ScheduledExecutorService timer = + Executors.newSingleThreadScheduledExecutor( + task -> { + final Thread thread = new Thread(task, "sentry-http-timeout"); + thread.setDaemon(true); + return thread; + }); + private @Nullable HttpURLConnection connection; + private boolean stopped; + private long timeout; + private String userAgent = ""; + private @Nullable Proxy proxy; + private @Nullable SSLSocketFactory socketFactory; + + void configure(long timeout, @Nullable String proxy, @Nullable String caCerts, String userAgent) + throws IOException, GeneralSecurityException { + this.timeout = timeout; + this.userAgent = userAgent; + if (proxy != null) { + final URL url = new URL(proxy.contains("://") ? proxy : "http://" + proxy); + if (!"http".equals(url.getProtocol()) || url.getUserInfo() != null) { + throw new IOException("Only unauthenticated HTTP proxies are supported"); + } + this.proxy = + new Proxy( + Proxy.Type.HTTP, + new InetSocketAddress(url.getHost(), url.getPort() < 0 ? 80 : url.getPort())); + } + if (caCerts != null) { + final KeyStore store = KeyStore.getInstance(KeyStore.getDefaultType()); + store.load(null, null); + try (InputStream input = new FileInputStream(caCerts)) { + int index = 0; + for (Certificate certificate : + CertificateFactory.getInstance("X.509").generateCertificates(input)) { + store.setCertificateEntry(Integer.toString(index++), certificate); + } + } + final TrustManagerFactory factory = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); + factory.init(store); + final SSLContext context = SSLContext.getInstance("TLS"); + context.init(null, factory.getTrustManagers(), null); + socketFactory = context.getSocketFactory(); + } + } + + HttpURLConnection openConnection(URL url) throws IOException { + return (HttpURLConnection) (proxy == null ? url.openConnection() : url.openConnection(proxy)); + } + + Response send( + String method, + String url, + String[] headers, + @Nullable ByteBuffer body, + @Nullable String bodyPath, + long bodyLength) + throws IOException { + final HttpURLConnection connection = openConnection(new URL(url)); + final AtomicBoolean expired = new AtomicBoolean(); + final @Nullable ScheduledFuture deadline; + synchronized (lock) { + if (stopped) { + connection.disconnect(); + throw new IOException("HTTP client is shut down"); + } + this.connection = connection; + deadline = + timeout > 0 + ? timer.schedule( + () -> { + expired.set(true); + connection.disconnect(); + }, + timeout, + TimeUnit.MILLISECONDS) + : null; + } + try { + connection.setConnectTimeout((int) Math.min(timeout > 0 ? timeout : 15000, 15000)); + connection.setReadTimeout((int) Math.min(timeout, Integer.MAX_VALUE)); + connection.setUseCaches(false); + connection.setInstanceFollowRedirects(false); + connection.setRequestMethod(method); + connection.setRequestProperty("User-Agent", userAgent); + if (socketFactory != null && connection instanceof HttpsURLConnection) { + ((HttpsURLConnection) connection).setSSLSocketFactory(socketFactory); + } + for (int i = 0; i < headers.length; i += 2) { + connection.setRequestProperty(headers[i], headers[i + 1]); + } + checkCancelled(expired); + final byte[] buffer = new byte[8192]; + if (body != null || bodyPath != null) { + connection.setDoOutput(true); + connection.setFixedLengthStreamingMode(bodyLength); + try (OutputStream output = connection.getOutputStream()) { + if (bodyPath != null) { + try (InputStream input = new FileInputStream(bodyPath)) { + int count; + while ((count = input.read(buffer)) != -1) { + checkCancelled(expired); + output.write(buffer, 0, count); + } + } + } else { + while (body.hasRemaining()) { + checkCancelled(expired); + final int count = Math.min(body.remaining(), buffer.length); + body.get(buffer, 0, count); + output.write(buffer, 0, count); + } + } + } + } + checkCancelled(expired); + final int statusCode = connection.getResponseCode(); + if (statusCode < 0) { + throw new IOException("Invalid HTTP response"); + } + final List responseHeaders = new ArrayList<>(); + for (Map.Entry> header : connection.getHeaderFields().entrySet()) { + if (header.getKey() != null) { + for (String value : header.getValue()) { + responseHeaders.add(header.getKey()); + responseHeaders.add(value); + } + } + } + try (InputStream input = + statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream()) { + if (input != null) { + while (input.read(buffer) != -1) { + checkCancelled(expired); + } + } + } + checkCancelled(expired); + return new Response(statusCode, responseHeaders.toArray(new String[0])); + } finally { + if (deadline != null) { + deadline.cancel(false); + } + synchronized (lock) { + this.connection = null; + } + connection.disconnect(); + } + } + + private void checkCancelled(AtomicBoolean expired) throws IOException { + if (expired.get()) { + throw new SocketTimeoutException("HTTP request timed out"); + } + synchronized (lock) { + if (stopped) { + throw new IOException("HTTP client is shut down"); + } + } + } + + void shutdown() { + final @Nullable HttpURLConnection connection; + synchronized (lock) { + stopped = true; + connection = this.connection; + } + timer.shutdownNow(); + if (connection != null) { + connection.disconnect(); + } + } + + static final class Response { + final int statusCode; + final String[] headers; + + Response(int statusCode, String[] headers) { + this.statusCode = statusCode; + this.headers = headers; + } + } +} diff --git a/ndk/lib/src/main/java/io/sentry/ndk/SentryNdk.java b/ndk/lib/src/main/java/io/sentry/ndk/SentryNdk.java index d583275c7d..ebc9faae63 100644 --- a/ndk/lib/src/main/java/io/sentry/ndk/SentryNdk.java +++ b/ndk/lib/src/main/java/io/sentry/ndk/SentryNdk.java @@ -2,6 +2,7 @@ import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; @ApiStatus.Internal public final class SentryNdk { @@ -20,6 +21,18 @@ private SentryNdk() {} */ private static native int initSentryNative(@NotNull final NdkOptions options); + private static native void nativeInitCrashDaemon( + @NotNull String shmPath, int notifyFd, int readyFd, int messageFd); + + private static native long nativeRunCrashDaemon( + int appPid, long appTid, int notifyFd, int readyFd, int messageFd, @NotNull String shmPath); + + private static native @NotNull String nativeGetCrashEnvelopePath(long handle); + + private static native int nativeSendEnvelope(long handle, @NotNull String path, long timeout); + + private static native void nativeCloseCrashDaemon(long handle); + private static native void shutdown(); /** @@ -55,6 +68,62 @@ public static void init(@NotNull final NdkOptions options) { } } + public static void initCrashDaemon( + @NotNull String shmPath, int notifyFd, int readyFd, int messageFd) { + loadNativeLibraries(); + nativeInitCrashDaemon(shmPath, notifyFd, readyFd, messageFd); + } + + public static @Nullable CrashDaemon runCrashDaemon( + int appPid, long appTid, int notifyFd, int readyFd, int messageFd, @NotNull String shmPath) { + loadNativeLibraries(); + final long handle = nativeRunCrashDaemon(appPid, appTid, notifyFd, readyFd, messageFd, shmPath); + return handle == 0 ? null : new CrashDaemon(handle); + } + + @ApiStatus.Internal + public static final class CrashDaemon implements AutoCloseable { + private final @NotNull String envelopePath; + private long handle; + + private CrashDaemon(long handle) { + this.handle = handle; + envelopePath = nativeGetCrashEnvelopePath(handle); + } + + public @NotNull String getEnvelopePath() { + return envelopePath; + } + + /** Sends the merged envelope and closes the daemon. */ + public synchronized @NotNull SendResult sendEnvelope(@NotNull String path, long timeout) { + final long handle = this.handle; + this.handle = 0; + return handle != 0 + ? SendResult.values()[nativeSendEnvelope(handle, path, timeout)] + : SendResult.FAILED; + } + + @Override + public synchronized void close() { + final long handle = this.handle; + this.handle = 0; + if (handle != 0) { + nativeCloseCrashDaemon(handle); + } + } + + /** The outcome of {@link #sendEnvelope(String, long)}. */ + public enum SendResult { + /** The envelope was neither sent nor kept. */ + FAILED, + /** The envelope was sent. */ + SENT, + /** The envelope was not sent and is kept for the next launch. */ + KEPT + } + } + /** Closes the NDK integration */ public static void close() { loadNativeLibraries(); diff --git a/ndk/lib/src/main/jni/sentry.c b/ndk/lib/src/main/jni/sentry.c index e31b2cc702..e092edda3b 100644 --- a/ndk/lib/src/main/jni/sentry.c +++ b/ndk/lib/src/main/jni/sentry.c @@ -4,9 +4,26 @@ #include #include #include +#include #include #include +#include "sentry_http.h" + +extern void sentry_android_crash_daemon_init(const char *shm_path, + int notify_fd, int ready_fd, int message_fd) __attribute__((weak)); +typedef struct sentry_crash_daemon_s sentry_crash_daemon_t; +extern sentry_crash_daemon_t *sentry_android_crash_daemon_run(int app_pid, + uint64_t app_tid, int notify_fd, int ready_fd, int message_fd, + const char *shm_path, sentry_transport_t *transport) __attribute__((weak)); +extern const char *sentry_android_crash_daemon_get_envelope_path( + const sentry_crash_daemon_t *daemon) __attribute__((weak)); +extern int sentry_android_crash_daemon_send( + sentry_crash_daemon_t *daemon, const char *path, + uint64_t timeout) __attribute__((weak)); +extern void sentry_android_crash_daemon_close( + sentry_crash_daemon_t *daemon) __attribute__((weak)); + #define ENSURE(Expr) \ if (!(Expr)) \ return @@ -433,6 +450,11 @@ Java_io_sentry_ndk_SentryNdk_initSentryNative( options = sentry_options_new(); ENSURE_OR_FAIL(options); + // Minidumps take precedence over native envelopes during processing and + // make crash envelopes large. Send native envelopes only, like inproc. + sentry_options_set_crash_reporting_mode( + options, SENTRY_CRASH_REPORTING_MODE_NATIVE); + // Android SDK needs to retain the marker timestamp for session finalization // and cleans it up when appropriate sentry__retain_crash_marker(options); @@ -540,6 +562,94 @@ Java_io_sentry_ndk_SentryNdk_initSentryNative( return (jint)-1; } +JNIEXPORT void JNICALL +Java_io_sentry_ndk_SentryNdk_nativeInitCrashDaemon( + JNIEnv *env, + jclass cls, + jstring shm_path, + jint notify_fd, + jint ready_fd, + jint message_fd) { + if (!shm_path) { + return; + } + + const char *shm_path_chars = (*env)->GetStringUTFChars(env, shm_path, 0); + if (!shm_path_chars) { + return; + } + + if (sentry_android_crash_daemon_init) { + sentry_android_crash_daemon_init( + shm_path_chars, (int) notify_fd, (int) ready_fd, (int) message_fd); + } + + (*env)->ReleaseStringUTFChars(env, shm_path, shm_path_chars); +} + +JNIEXPORT jlong JNICALL +Java_io_sentry_ndk_SentryNdk_nativeRunCrashDaemon( + JNIEnv *env, + jclass cls, + jint app_pid, + jlong app_tid, + jint notify_fd, + jint ready_fd, + jint message_fd, + jstring shm_path) { + if (!shm_path) { + return 0; + } + + const char *shm_path_chars = (*env)->GetStringUTFChars(env, shm_path, 0); + if (!shm_path_chars) { + return 0; + } + + sentry_crash_daemon_t *daemon = NULL; + if (sentry_android_crash_daemon_run) { + daemon = sentry_android_crash_daemon_run((int) app_pid, + (uint64_t) app_tid, (int) notify_fd, (int) ready_fd, + (int) message_fd, shm_path_chars, + sentry_android_http_transport_new()); + } + + (*env)->ReleaseStringUTFChars(env, shm_path, shm_path_chars); + return (jlong)(uintptr_t)daemon; +} + +JNIEXPORT jstring JNICALL +Java_io_sentry_ndk_SentryNdk_nativeGetCrashEnvelopePath( + JNIEnv *env, jclass cls, jlong handle) { + return (*env)->NewStringUTF(env, + sentry_android_crash_daemon_get_envelope_path( + (sentry_crash_daemon_t *)(uintptr_t)handle)); +} + +JNIEXPORT jint JNICALL +Java_io_sentry_ndk_SentryNdk_nativeSendEnvelope( + JNIEnv *env, + jclass cls, + jlong handle, + jstring path, + jlong timeout) { + const char *path_chars = (*env)->GetStringUTFChars(env, path, 0); + int result = sentry_android_crash_daemon_send( + (sentry_crash_daemon_t *)(uintptr_t)handle, path_chars, + (uint64_t) timeout); + if (path_chars) { + (*env)->ReleaseStringUTFChars(env, path, path_chars); + } + return (jint) result; +} + +JNIEXPORT void JNICALL +Java_io_sentry_ndk_SentryNdk_nativeCloseCrashDaemon( + JNIEnv *env, jclass cls, jlong handle) { + sentry_android_crash_daemon_close( + (sentry_crash_daemon_t *)(uintptr_t)handle); +} + JNIEXPORT void JNICALL Java_io_sentry_ndk_NativeModuleListLoader_nativeClearModuleList( JNIEnv *env, jclass cls) diff --git a/ndk/lib/src/main/jni/sentry_http.c b/ndk/lib/src/main/jni/sentry_http.c new file mode 100644 index 0000000000..aa24145aba --- /dev/null +++ b/ndk/lib/src/main/jni/sentry_http.c @@ -0,0 +1,314 @@ +#include "sentry_http.h" + +#include +#include +#include +#include +#include + +static JavaVM *g_vm; +static jclass g_client_class; +static jmethodID g_constructor; +static jmethodID g_configure; +static jmethodID g_send; +static jmethodID g_shutdown; +static jfieldID g_status_code; +static jfieldID g_headers; + +JNIEXPORT jint JNICALL +JNI_OnLoad(JavaVM *vm, void *reserved) +{ + (void)reserved; + JNIEnv *env; + if ((*vm)->GetEnv(vm, (void **)&env, JNI_VERSION_1_6) != JNI_OK) { + return JNI_ERR; + } + jclass cls = (*env)->FindClass(env, "io/sentry/ndk/SentryHttpClient"); + if (!cls) { + return JNI_ERR; + } + g_client_class = (*env)->NewGlobalRef(env, cls); + (*env)->DeleteLocalRef(env, cls); + if (!g_client_class) { + return JNI_ERR; + } + jclass response + = (*env)->FindClass(env, "io/sentry/ndk/SentryHttpClient$Response"); + if (!response + || !(g_constructor + = (*env)->GetMethodID(env, g_client_class, "", "()V")) + || !(g_configure = (*env)->GetMethodID(env, g_client_class, "configure", + "(JLjava/lang/String;Ljava/lang/String;Ljava/lang/String;)V")) + || !(g_send = (*env)->GetMethodID(env, g_client_class, "send", + "(Ljava/lang/String;Ljava/lang/String;[Ljava/lang/String;" + "Ljava/nio/ByteBuffer;Ljava/lang/String;J)" + "Lio/sentry/ndk/SentryHttpClient$Response;")) + || !(g_shutdown + = (*env)->GetMethodID(env, g_client_class, "shutdown", "()V")) + || !(g_status_code + = (*env)->GetFieldID(env, response, "statusCode", "I")) + || !(g_headers = (*env)->GetFieldID( + env, response, "headers", "[Ljava/lang/String;"))) { + (*env)->DeleteGlobalRef(env, g_client_class); + g_client_class = NULL; + return JNI_ERR; + } + (*env)->DeleteLocalRef(env, response); + g_vm = vm; + return JNI_VERSION_1_6; +} + +static JNIEnv * +get_env(bool *attached) +{ + JNIEnv *env = NULL; + *attached = false; + jint rv = (*g_vm)->GetEnv(g_vm, (void **)&env, JNI_VERSION_1_6); + if (rv == JNI_EDETACHED) { + if ((*g_vm)->AttachCurrentThread(g_vm, &env, NULL) != JNI_OK) { + return NULL; + } + *attached = true; + } else if (rv != JNI_OK) { + return NULL; + } + return env; +} + +static bool +clear_exception(JNIEnv *env) +{ + if (!(*env)->ExceptionCheck(env)) { + return false; + } + (*env)->ExceptionDescribe(env); + (*env)->ExceptionClear(env); + __android_log_write( + ANDROID_LOG_WARN, "sentry-native", "Android HTTP client failed"); + return true; +} + +static void +release_env(JNIEnv *env, bool attached) +{ + clear_exception(env); + if (attached) { + (*g_vm)->DetachCurrentThread(g_vm); + } +} + +static sentry_http_client_t * +client_new(void *data) +{ + (void)data; + bool attached; + JNIEnv *env = get_env(&attached); + if (!env) { + return NULL; + } + jobject client = (*env)->NewObject(env, g_client_class, g_constructor); + jobject ref = client ? (*env)->NewGlobalRef(env, client) : NULL; + if (client) { + (*env)->DeleteLocalRef(env, client); + } + release_env(env, attached); + return ref; +} + +static int +client_start(sentry_http_client_t *client, const sentry_options_t *options) +{ + bool attached; + JNIEnv *env = get_env(&attached); + if (!env) { + return 1; + } + int rv = 1; + if ((*env)->PushLocalFrame(env, 4) != JNI_OK) { + release_env(env, attached); + return rv; + } + const char *proxy = sentry_options_get_proxy(options); + const char *ca_certs = sentry_options_get_ca_certs(options); + const char *user_agent = sentry_options_get_user_agent(options); + jstring jproxy = proxy ? (*env)->NewStringUTF(env, proxy) : NULL; + if (proxy && !jproxy) { + goto done; + } + jstring jca_certs = ca_certs ? (*env)->NewStringUTF(env, ca_certs) : NULL; + if (ca_certs && !jca_certs) { + goto done; + } + jstring juser_agent = (*env)->NewStringUTF( + env, user_agent ? user_agent : SENTRY_SDK_USER_AGENT); + if (!juser_agent) { + goto done; + } + uint64_t timeout + = sentry_options_get_transfer_timeout((sentry_options_t *)options); + (*env)->CallVoidMethod(env, client, g_configure, + (jlong)(timeout > INT64_MAX ? INT64_MAX : timeout), jproxy, jca_certs, + juser_agent); + rv = clear_exception(env) ? 1 : 0; + +done: + clear_exception(env); + (*env)->PopLocalFrame(env, NULL); + release_env(env, attached); + return rv; +} + +static int +client_send(sentry_http_client_t *client, sentry_http_request_t *req, + sentry_http_response_t *resp) +{ + bool attached; + JNIEnv *env = get_env(&attached); + if (!env) { + return 0; + } + int rv = 0; + if ((*env)->PushLocalFrame(env, 16) != JNI_OK) { + release_env(env, attached); + return 0; + } + + jstring method + = (*env)->NewStringUTF(env, sentry_http_request_get_method(req)); + if (!method) { + goto done; + } + jstring url = (*env)->NewStringUTF(env, sentry_http_request_get_url(req)); + if (!url) { + goto done; + } + size_t count = sentry_http_request_get_header_count(req); + if (count > INT_MAX / 2) { + goto done; + } + jclass string_class = (*env)->FindClass(env, "java/lang/String"); + if (!string_class) { + goto done; + } + jobjectArray headers + = (*env)->NewObjectArray(env, (jsize)(count * 2), string_class, NULL); + if (!headers) { + goto done; + } + for (size_t i = 0; i < count; i++) { + const char *key, *value; + sentry_http_request_get_header(req, i, &key, &value); + jstring jkey = (*env)->NewStringUTF(env, key); + if (!jkey) { + goto done; + } + jstring jvalue = (*env)->NewStringUTF(env, value); + if (!jvalue) { + goto done; + } + (*env)->SetObjectArrayElement(env, headers, (jsize)(i * 2), jkey); + (*env)->SetObjectArrayElement(env, headers, (jsize)(i * 2 + 1), jvalue); + (*env)->DeleteLocalRef(env, jkey); + (*env)->DeleteLocalRef(env, jvalue); + if ((*env)->ExceptionCheck(env)) { + goto done; + } + } + + size_t body_len; + const char *body = sentry_http_request_get_body(req, &body_len); + jobject buffer = NULL; + jstring path = NULL; + if (body) { + if (body_len > INT_MAX) { + goto done; + } + buffer + = (*env)->NewDirectByteBuffer(env, (void *)body, (jlong)body_len); + if (!buffer) { + goto done; + } + } else { + const char *body_path + = sentry_http_request_get_body_file_path(req, &body_len); + if (body_path) { + path = (*env)->NewStringUTF(env, body_path); + if (!path) { + goto done; + } + } + } + jobject response = (*env)->CallObjectMethod(env, client, g_send, method, + url, headers, buffer, path, (jlong)body_len); + if (clear_exception(env) || !response) { + goto done; + } + sentry_http_response_set_status_code( + resp, (*env)->GetIntField(env, response, g_status_code)); + jobjectArray response_headers + = (*env)->GetObjectField(env, response, g_headers); + jsize len = (*env)->GetArrayLength(env, response_headers); + for (jsize i = 0; i < len; i += 2) { + jstring key = (*env)->GetObjectArrayElement(env, response_headers, i); + jstring value + = (*env)->GetObjectArrayElement(env, response_headers, i + 1); + const char *ckey = (*env)->GetStringUTFChars(env, key, NULL); + if (!ckey) { + goto done; + } + const char *cvalue = (*env)->GetStringUTFChars(env, value, NULL); + if (cvalue) { + sentry_http_response_set_header(resp, ckey, cvalue); + (*env)->ReleaseStringUTFChars(env, value, cvalue); + } + (*env)->ReleaseStringUTFChars(env, key, ckey); + (*env)->DeleteLocalRef(env, key); + (*env)->DeleteLocalRef(env, value); + if (!cvalue) { + goto done; + } + } + rv = 1; + +done: + clear_exception(env); + (*env)->PopLocalFrame(env, NULL); + release_env(env, attached); + return rv; +} + +static void +client_shutdown(sentry_http_client_t *client) +{ + bool attached; + JNIEnv *env = get_env(&attached); + if (env) { + (*env)->CallVoidMethod(env, client, g_shutdown); + release_env(env, attached); + } +} + +static void +client_free(sentry_http_client_t *client) +{ + client_shutdown(client); + bool attached; + JNIEnv *env = get_env(&attached); + if (env) { + (*env)->DeleteGlobalRef(env, client); + release_env(env, attached); + } +} + +sentry_transport_t * +sentry_android_http_transport_new(void) +{ + sentry_transport_t *transport + = sentry_http_transport_new(client_new, NULL, client_send, client_free); + if (transport) { + sentry_http_transport_set_client_start_func(transport, client_start); + sentry_http_transport_set_client_shutdown_func( + transport, client_shutdown); + } + return transport; +} diff --git a/ndk/lib/src/main/jni/sentry_http.h b/ndk/lib/src/main/jni/sentry_http.h new file mode 100644 index 0000000000..36dcc4e950 --- /dev/null +++ b/ndk/lib/src/main/jni/sentry_http.h @@ -0,0 +1,8 @@ +#ifndef SENTRY_ANDROID_HTTP_H_INCLUDED +#define SENTRY_ANDROID_HTTP_H_INCLUDED + +#include + +sentry_transport_t *sentry_android_http_transport_new(void); + +#endif diff --git a/ndk/lib/src/test/java/io/sentry/ndk/SentryHttpClientTest.java b/ndk/lib/src/test/java/io/sentry/ndk/SentryHttpClientTest.java new file mode 100644 index 0000000000..1bb17c49e7 --- /dev/null +++ b/ndk/lib/src/test/java/io/sentry/ndk/SentryHttpClientTest.java @@ -0,0 +1,310 @@ +package io.sentry.ndk; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.ByteBuffer; +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class SentryHttpClientTest { + private Connection connection; + private SentryHttpClient client; + + @Before + public void setUp() throws Exception { + connection = new Connection(); + client = + new SentryHttpClient() { + @Override + HttpURLConnection openConnection(URL url) { + return connection; + } + }; + client.configure(5000, null, null, "sentry-native/test"); + } + + @After + public void tearDown() { + client.shutdown(); + } + + @Test + public void sendsBinaryEnvelope() throws Exception { + final byte[] body = {0, 1, (byte) 0xff, 0, 42}; + final SentryHttpClient.Response response = + client.send( + "POST", + connection.getURL().toString(), + new String[] {"Content-Type", "application/x-sentry-envelope", "X-Sentry-Auth", "auth"}, + ByteBuffer.wrap(body), + null, + body.length); + + assertEquals(200, response.statusCode); + assertEquals("POST", connection.getRequestMethod()); + assertEquals("auth", connection.getRequestProperty("X-Sentry-Auth")); + assertEquals("sentry-native/test", connection.getRequestProperty("User-Agent")); + assertEquals("application/x-sentry-envelope", connection.getRequestProperty("Content-Type")); + assertEquals(body.length, connection.bodyLength()); + assertArrayEquals(body, connection.output.toByteArray()); + assertFalse(connection.getUseCaches()); + assertFalse(connection.getInstanceFollowRedirects()); + assertTrue(connection.disconnected); + } + + @Test + public void streamsAttachment() throws Exception { + final File file = File.createTempFile("sentry-http", ".attachment"); + try { + final byte[] body = new byte[20000]; + Arrays.fill(body, (byte) 0xff); + try (OutputStream output = new FileOutputStream(file)) { + output.write(body); + } + client.send( + "PATCH", + connection.getURL().toString(), + new String[] {"Tus-Resumable", "1.0.0"}, + null, + file.getPath(), + body.length); + + assertEquals("PATCH", connection.getRequestMethod()); + assertEquals(body.length, connection.bodyLength()); + assertArrayEquals(body, connection.output.toByteArray()); + } finally { + assertTrue(file.delete()); + } + } + + @Test + public void sendsBodylessRequest() throws Exception { + client.send("POST", connection.getURL().toString(), new String[0], null, null, 0); + + assertEquals("POST", connection.getRequestMethod()); + assertFalse(connection.getDoOutput()); + assertEquals(0, connection.output.size()); + } + + @Test + public void returnsErrorStatusAndHeaders() throws Exception { + connection.statusCode = 429; + connection.headers.put(null, Collections.singletonList("HTTP/1.1 429 Too Many Requests")); + connection.headers.put("Retry-After", Collections.singletonList("60")); + connection.headers.put("X-Sentry-Rate-Limits", Collections.singletonList("60:error:key")); + connection.headers.put("Location", Collections.singletonList("/upload/1")); + connection.headers.put("Other", Arrays.asList("one", "two")); + + final SentryHttpClient.Response response = + client.send("POST", connection.getURL().toString(), new String[0], null, null, 0); + + assertEquals(429, response.statusCode); + assertArrayEquals( + new String[] { + "Retry-After", + "60", + "X-Sentry-Rate-Limits", + "60:error:key", + "Location", + "/upload/1", + "Other", + "one", + "Other", + "two" + }, + response.headers); + } + + @Test + public void disconnectsOnFailure() { + connection.failure = new IOException("network unavailable"); + + assertThrows( + IOException.class, + () -> client.send("POST", connection.getURL().toString(), new String[0], null, null, 0)); + + assertTrue(connection.disconnected); + } + + @Test + public void cancelsTimedOutRequest() throws Exception { + client.configure(20, null, null, "test"); + connection.block = true; + + assertThrows( + IOException.class, + () -> client.send("POST", connection.getURL().toString(), new String[0], null, null, 0)); + + assertTrue(connection.disconnected); + } + + @Test + public void shutdownCancelsRequest() throws Exception { + connection.block = true; + final ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + final Future failure = + worker.submit( + () -> + assertThrows( + IOException.class, + () -> + client.send( + "POST", + connection.getURL().toString(), + new String[0], + null, + null, + 0))); + assertTrue(connection.entered.await(2, TimeUnit.SECONDS)); + client.shutdown(); + + assertNotNull(failure.get(2, TimeUnit.SECONDS)); + assertTrue(connection.disconnected); + } finally { + worker.shutdownNow(); + } + } + + @Test + public void rejectsRequestsAfterShutdown() { + client.shutdown(); + + assertThrows( + IOException.class, + () -> client.send("POST", connection.getURL().toString(), new String[0], null, null, 0)); + } + + @Test + public void rejectsInvalidResponse() { + connection.statusCode = -1; + + assertThrows( + IOException.class, + () -> client.send("POST", connection.getURL().toString(), new String[0], null, null, 0)); + + assertTrue(connection.disconnected); + } + + @Test + public void shutdownBeforeConnectPreventsUpload() { + connection.beforeRequest = client::shutdown; + + assertThrows( + IOException.class, + () -> + client.send( + "POST", + connection.getURL().toString(), + new String[0], + ByteBuffer.wrap(new byte[] {1}), + null, + 1)); + + assertEquals(0, connection.output.size()); + } + + private static final class Connection extends HttpURLConnection { + final ByteArrayOutputStream output = new ByteArrayOutputStream(); + final Map> headers = new LinkedHashMap<>(); + final CountDownLatch entered = new CountDownLatch(1); + final CountDownLatch cancelled = new CountDownLatch(1); + volatile boolean disconnected; + boolean block; + int statusCode = 200; + IOException failure; + Runnable beforeRequest; + + Connection() throws Exception { + super(new URL("http://localhost/envelope/")); + } + + long bodyLength() { + return fixedContentLengthLong; + } + + @Override + public void setRequestMethod(String method) { + this.method = method; + if (beforeRequest != null) { + beforeRequest.run(); + } + } + + @Override + public OutputStream getOutputStream() { + return output; + } + + @Override + public int getResponseCode() throws IOException { + if (failure != null) { + throw failure; + } + if (block) { + entered.countDown(); + try { + assertTrue(cancelled.await(2, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + throw new IOException("cancelled"); + } + return statusCode; + } + + @Override + public Map> getHeaderFields() { + return headers; + } + + @Override + public InputStream getInputStream() { + return new ByteArrayInputStream(new byte[0]); + } + + @Override + public InputStream getErrorStream() { + return null; + } + + @Override + public void disconnect() { + disconnected = true; + cancelled.countDown(); + } + + @Override + public boolean usingProxy() { + return false; + } + + @Override + public void connect() {} + } +} diff --git a/ndk/native/build.gradle.kts b/ndk/native/build.gradle.kts new file mode 100644 index 0000000000..48ba031565 --- /dev/null +++ b/ndk/native/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.library") + kotlin("android") +} diff --git a/ndk/native/gradle.properties b/ndk/native/gradle.properties new file mode 100644 index 0000000000..46b4aaa4ee --- /dev/null +++ b/ndk/native/gradle.properties @@ -0,0 +1 @@ +POM_ARTIFACT_ID=sentry-native-ndk-outproc diff --git a/ndk/sample/src/main/AndroidManifest.xml b/ndk/sample/src/main/AndroidManifest.xml index 882d9c1e80..b7eab55e03 100644 --- a/ndk/sample/src/main/AndroidManifest.xml +++ b/ndk/sample/src/main/AndroidManifest.xml @@ -2,6 +2,8 @@ + + + settings.dependencyResolutionManagement.repositories {{ + maven {{ url = uri('{repo.as_uri()}') }} + }} +}} +allprojects {{ + configurations.configureEach {{ + resolutionStrategy.force('io.sentry:sentry-native-ndk-outproc:{version}') + }} +}} +""") + + def gradle(project, *args): + command = [ + str(project / "gradlew"), + "--console=plain", + f"-PversionName={version}", + f"-Dmaven.repo.local={repo}", + *args, + ] + print("+ " + " ".join(command), flush=True) + subprocess.run(command, cwd=project, check=True) + + gradle(PROJECT_DIR / "ndk", ":sentry-native-ndk-outproc:publishToMavenLocal") + gradle( + java, + "--init-script", + str(init), + ":sentry:publishToMavenLocal", + ":sentry-android-core:publishToMavenLocal", + ":sentry-android-ndk-outproc:publishToMavenLocal", + ) + + versions = tomllib.loads((java / "gradle/libs.versions.toml").read_text())[ + "versions" + ] + agp = re.search( + r'id\("com.android.application"\) version "([^"]+)"', + (PROJECT_DIR / "ndk/build.gradle.kts").read_text(), + ).group(1) + subprocess.run( + [ + str(java / "gradlew"), + "--console=plain", + f"-PagpVersion={agp}", + f"-PcompileSdk={versions['compileSdk']}", + f"-PcompileSdkMinor={versions.get('compileSdkMinor', '0')}", + f"-PtargetSdk={versions['targetSdk']}", + f"-PsentryVersion={version}", + f"-PlocalRepo={repo}", + "assembleDebug", + "assembleNondebuggable", + ], + cwd=app, + check=True, + ) + print(f"Prepared APKs: {app / 'build/outputs/apk'}") + + +if __name__ == "__main__": + main() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 2aa4739b79..cbef6c30b4 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -178,6 +178,14 @@ elseif(SENTRY_BACKEND_NATIVE) backends/native/sentry_crash_ipc.c backends/native/sentry_crash_handler.c ) + if(ANDROID) + sentry_target_sources_cwd(sentry + backends/native/sentry_crash_daemon.c + backends/native/minidump/sentry_minidump_common.c + backends/native/minidump/sentry_minidump_indirect.c + backends/native/minidump/sentry_minidump_linux.c + ) + endif() if(SENTRY_WITH_LIBUNWINDSTACK) target_compile_definitions(sentry PRIVATE SENTRY_WITH_UNWINDER_LIBUNWINDSTACK_REMOTE) sentry_target_sources_cwd(sentry @@ -189,7 +197,7 @@ elseif(SENTRY_BACKEND_NATIVE) target_include_directories(sentry PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/backends/native) # Platform-specific libraries for native backend - if(LINUX OR ANDROID) + if(LINUX AND NOT ANDROID) # Linux needs pthread and rt for shared memory target_link_libraries(sentry PRIVATE pthread rt) elseif(APPLE) diff --git a/src/backends/native/sentry_crash_daemon.c b/src/backends/native/sentry_crash_daemon.c index be7317a899..0ed15ad724 100644 --- a/src/backends/native/sentry_crash_daemon.c +++ b/src/backends/native/sentry_crash_daemon.c @@ -205,6 +205,82 @@ static size_t walk_stack_with_dbghelp(HANDLE hProcess, DWORD crashed_tid, sentry_frame_info_t *frames, size_t max_frames); #endif +#if defined(SENTRY_PLATFORM_ANDROID) +// matches io.sentry.ndk.SentryNdk.CrashDaemon.SendResult +typedef enum { + SENTRY_ANDROID_SEND_FAILED, + SENTRY_ANDROID_SEND_SENT, + SENTRY_ANDROID_SEND_KEPT, +} sentry_android_send_result_t; + +struct sentry_crash_daemon_s { + sentry_options_t *options; + sentry_crash_ipc_t *ipc; + sentry_transport_t *transport; + FILE *log_file; + char envelope_path[SENTRY_CRASH_MAX_PATH]; + bool transport_shutdown; +}; + +static void android_cleanup_pending_crash_daemon( + sentry_crash_daemon_t *daemon, bool discard_envelope); + +__attribute__((visibility("default"))) sentry_crash_daemon_t * +sentry_android_crash_daemon_run(pid_t app_pid, uint64_t app_tid, int notify_fd, + int ready_fd, int message_fd, const char *shm_path, + sentry_transport_t *transport) +{ + if (!shm_path || !shm_path[0]) { + goto fail; + } + + // app configuration must be complete before attaching to shm + sentry_crash_ipc_t ipc = { .notify_fd = notify_fd }; + if (!sentry__crash_ipc_wait(&ipc, SENTRY_CRASH_DAEMON_READY_TIMEOUT_MS)) { + goto fail; + } + + int shm_fd = open(shm_path, O_RDWR | O_CLOEXEC); + if (shm_fd < 0) { + goto fail; + } + // the app maps the file before it notifies, neither side needs the name + unlink(shm_path); + + sentry_crash_daemon_t *daemon = SENTRY_MAKE(sentry_crash_daemon_t); + if (!daemon) { + close(shm_fd); + goto fail; + } + daemon->transport = transport; + + int rv = sentry__crash_daemon_main( + app_pid, app_tid, notify_fd, ready_fd, shm_fd, message_fd, daemon); + + if (rv != 0 || !daemon->envelope_path[0]) { + android_cleanup_pending_crash_daemon(daemon, true); + return NULL; + } + + return daemon; + +fail: + // the daemon owns the fds, even when it does not start + close(notify_fd); + close(ready_fd); + close(message_fd); + sentry_transport_free(transport); + return NULL; +} + +__attribute__((visibility("default"))) const char * +sentry_android_crash_daemon_get_envelope_path( + const sentry_crash_daemon_t *daemon) +{ + return daemon->envelope_path; +} +#endif + // Provide default ASAN options for sentry-crash daemon executable // This suppresses false positives from fork() which ASAN doesn't handle well #if defined(__has_feature) @@ -468,6 +544,76 @@ read_crash_event(const sentry_path_t *run_folder) } return event; } +#if defined(SENTRY_PLATFORM_ANDROID) +static sentry_path_t * +make_run_envelope_path( + const sentry_run_t *run, const sentry_envelope_t *envelope) +{ + if (!run || !envelope || !run->run_path) { + return NULL; + } + + sentry_uuid_t event_id = sentry__envelope_get_event_id(envelope); + if (sentry_uuid_is_nil(&event_id)) { + return NULL; + } + + char *filename = sentry__uuid_as_filename(&event_id, ".envelope"); + if (!filename) { + return NULL; + } + + sentry_path_t *path = sentry__path_join_str(run->run_path, filename); + sentry_free(filename); + return path; +} + +static sentry_envelope_t * +wait_for_android_tombstone_envelope( + const sentry_path_t *native_envelope_path, sentry_path_t **merged_path_out) +{ + if (merged_path_out) { + *merged_path_out = NULL; + } + if (!native_envelope_path) { + return NULL; + } + + sentry_path_t *merged_path + = sentry__path_append_str(native_envelope_path, ".merged"); + if (!merged_path) { + return NULL; + } + + const int timeout_ms = 10000; + const int interval_ms = 100; + int elapsed_ms = 0; + while (elapsed_ms < timeout_ms) { + if (sentry__path_is_file(merged_path)) { + SENTRY_DEBUGF("Found Android tombstone merged envelope: %s", + merged_path->path); + sentry_envelope_t *envelope + = sentry__envelope_from_path(merged_path); + if (envelope) { + if (merged_path_out) { + *merged_path_out = merged_path; + } else { + sentry__path_free(merged_path); + } + return envelope; + } + SENTRY_WARN("Failed to read Android tombstone merged envelope"); + break; + } + usleep(interval_ms * 1000); + elapsed_ms += interval_ms; + } + + SENTRY_DEBUG("Timed out waiting for Android tombstone merged envelope"); + sentry__path_free(merged_path); + return NULL; +} +#endif // cache large staged attachments as attachment-ref items static void @@ -4045,8 +4191,10 @@ ensure_device_arch(sentry_value_t event) #endif bool -sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) +sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc, + sentry_crash_daemon_t *daemon) { + (void)daemon; SENTRY_DEBUG("Processing crash - START"); bool crash_captured = false; @@ -4333,23 +4481,68 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) add_attachment_refs(envelope, options, attachments); +#if defined(SENTRY_PLATFORM_ANDROID) + sentry_path_t *android_native_envelope_path + = make_run_envelope_path(options ? options->run : NULL, envelope); +#endif + SENTRY_DEBUG("Envelope loaded, capturing"); // Launch reporter, or fall back to transport (consent/caching in core). if (!sentry__launch_external_crash_reporter(options, envelope)) { - if (options && options->transport && options->run) { - SENTRY_DEBUG("Capturing crash envelope"); - sentry__capture_envelope(options->transport, envelope, options); - crash_captured = true; - SENTRY_DEBUG("Crash envelope captured (queued)"); +#if defined(SENTRY_PLATFORM_ANDROID) + if (!options || !options->run + || !sentry__run_write_envelope(options->run, envelope)) { + SENTRY_WARN("Failed to dump crash envelope to run folder"); } else { - SENTRY_WARN("No transport available for sending envelope"); - sentry_envelope_free(envelope); + SENTRY_DEBUG( + "Crash envelope written for Android tombstone merging"); + sentry__atomic_store(&ctx->state, SENTRY_CRASH_STATE_DONE); + SENTRY_DEBUG("Released crashed process for Android tombstone"); + if (daemon && android_native_envelope_path) { + strncpy(daemon->envelope_path, + android_native_envelope_path->path, + sizeof(daemon->envelope_path) - 1); + daemon->envelope_path[sizeof(daemon->envelope_path) - 1] = '\0'; + sentry_envelope_free(envelope); + envelope = NULL; + } else { + sentry_path_t *merged_path = NULL; + sentry_envelope_t *merged_envelope + = wait_for_android_tombstone_envelope( + android_native_envelope_path, &merged_path); + if (merged_envelope) { + sentry_envelope_free(envelope); + envelope = merged_envelope; + sentry__path_remove(android_native_envelope_path); + sentry__path_remove(merged_path); + sentry__path_free(merged_path); + } else { + sentry_envelope_free(envelope); + envelope = NULL; + } + } + } +#endif + if (envelope) { + if (options && options->transport && options->run) { + SENTRY_DEBUG("Capturing crash envelope"); + sentry__capture_envelope(options->transport, envelope, options); + crash_captured = true; + SENTRY_DEBUG("Crash envelope captured (queued)"); + } else { + SENTRY_WARN("No transport available for sending envelope"); + sentry_envelope_free(envelope); + } } } else { crash_captured = true; } +#if defined(SENTRY_PLATFORM_ANDROID) + sentry__path_free(android_native_envelope_path); +#endif + // Keep the original crash envelope for the callback on the next launch. if (!ctx->has_on_crashed_last_run) { #if defined(SENTRY_PLATFORM_UNIX) @@ -4382,6 +4575,116 @@ sentry__process_crash(const sentry_options_t *options, sentry_crash_ipc_t *ipc) return crash_captured; } +#if defined(SENTRY_PLATFORM_ANDROID) +__attribute__((visibility("default"))) int +sentry_android_crash_daemon_send( + sentry_crash_daemon_t *daemon, const char *path, uint64_t timeout) +{ + if (!path || !path[0]) { + android_cleanup_pending_crash_daemon(daemon, false); + return SENTRY_ANDROID_SEND_FAILED; + } + + sentry_path_t *envelope_path = sentry__path_from_str(path); + sentry_envelope_t *envelope + = envelope_path ? sentry__envelope_from_path(envelope_path) : NULL; + if (envelope_path) { + sentry__path_free(envelope_path); + } + if (!envelope) { + SENTRY_WARN("Failed to read merged Android crash envelope"); + android_cleanup_pending_crash_daemon(daemon, false); + return SENTRY_ANDROID_SEND_FAILED; + } + + sentry_android_send_result_t result = SENTRY_ANDROID_SEND_FAILED; + if (daemon->options->transport && daemon->options->run) { + SENTRY_DEBUG("Capturing merged Android crash envelope"); + // keep a failed upload in the run folder for the next launch + sentry__http_transport_set_dump_failed( + daemon->options->transport, true); + sentry__capture_envelope( + daemon->options->transport, envelope, daemon->options); + + sentry_path_t *raw_path = sentry__path_from_str(daemon->envelope_path); + if (raw_path) { + sentry__path_remove(raw_path); + } + if (strcmp(path, daemon->envelope_path) != 0) { + sentry_path_t *merged_path = sentry__path_from_str(path); + if (merged_path) { + sentry__path_remove(merged_path); + sentry__path_free(merged_path); + } + } + + daemon->options->shutdown_timeout = timeout; + int rv = sentry__transport_shutdown( + daemon->options->transport, daemon->options->shutdown_timeout); + daemon->transport_shutdown = true; + if (rv != 0) { + SENTRY_WARN("transport did not shut down cleanly"); + } + sentry__transport_dump_queue( + daemon->options->transport, daemon->options->run); + sentry__http_transport_set_retry_enabled( + daemon->options->transport, false); + sentry__http_transport_set_dump_failed( + daemon->options->transport, false); + // the dump and a failed upload both write the raw envelope path + if (raw_path && sentry__path_is_file(raw_path)) { + result = SENTRY_ANDROID_SEND_KEPT; + } else if (rv == 0) { + result = SENTRY_ANDROID_SEND_SENT; + } + sentry__path_free(raw_path); + } else { + SENTRY_WARN( + "No transport available for sending Android crash envelope"); + sentry_envelope_free(envelope); + } + + android_cleanup_pending_crash_daemon(daemon, false); + return result; +} + +__attribute__((visibility("default"))) void +sentry_android_crash_daemon_close(sentry_crash_daemon_t *daemon) +{ + android_cleanup_pending_crash_daemon(daemon, true); +} + +static void +android_cleanup_pending_crash_daemon( + sentry_crash_daemon_t *daemon, bool discard_envelope) +{ + if (discard_envelope && daemon->envelope_path[0]) { + sentry_path_t *path = sentry__path_from_str(daemon->envelope_path); + if (path) { + sentry__path_remove(path); + sentry__path_free(path); + } + } + if (daemon->options) { + if (daemon->options->transport && !daemon->transport_shutdown) { + sentry__transport_shutdown(daemon->options->transport, 0); + } + sentry_options_free(daemon->options); + } + sentry_transport_free(daemon->transport); + if (daemon->ipc) { + sentry__crash_ipc_unlink(daemon->ipc); + sentry__crash_ipc_free(daemon->ipc); + } + if (daemon->log_file) { + sentry__logger_disable(); + fclose(daemon->log_file); + } + + sentry_free(daemon); +} +#endif + /** * Check if parent process is still alive */ @@ -4461,7 +4764,12 @@ dump_crash_queue(void) #endif } -#if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) +#if defined(SENTRY_PLATFORM_ANDROID) +int +sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, int notify_eventfd, + int ready_eventfd, int shm_fd, int message_fd, + sentry_crash_daemon_t *daemon) +#elif defined(SENTRY_PLATFORM_LINUX) int sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, int notify_eventfd, int ready_eventfd, int message_fd) @@ -4477,7 +4785,10 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, { // Initialize IPC first (attach to shared memory created by parent) // We need this to get the database path for logging -#if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) +#if defined(SENTRY_PLATFORM_ANDROID) + sentry_crash_ipc_t *ipc = sentry__crash_ipc_init_daemon( + app_pid, app_tid, notify_eventfd, ready_eventfd, shm_fd, message_fd); +#elif defined(SENTRY_PLATFORM_LINUX) sentry_crash_ipc_t *ipc = sentry__crash_ipc_init_daemon( app_pid, app_tid, notify_eventfd, ready_eventfd, message_fd); #elif defined(SENTRY_PLATFORM_MACOS) @@ -4488,6 +4799,11 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, event_handle, ready_event_handle, message_read_handle); #endif if (!ipc) { +#if defined(SENTRY_PLATFORM_ANDROID) + close(notify_eventfd); + close(ready_eventfd); + close(message_fd); +#endif return 1; } @@ -4566,11 +4882,19 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, sentry_options_t *options = sentry_options_new(); if (!options) { SENTRY_ERROR("sentry_options_new() failed"); + sentry__crash_ipc_free(ipc); if (log_file) { + sentry__logger_disable(); fclose(log_file); } return 1; } +#if defined(SENTRY_PLATFORM_ANDROID) + if (daemon) { + sentry_options_set_transport(options, daemon->transport); + daemon->transport = NULL; + } +#endif sentry_options_set_database_path(options, ipc->shmem->database_path); @@ -4729,7 +5053,11 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, } if (state == SENTRY_CRASH_STATE_CRASHED) { SENTRY_DEBUG("Crash notification received, processing"); - bool crash_captured = sentry__process_crash(options, ipc); +#if defined(SENTRY_PLATFORM_ANDROID) + bool crash_captured = sentry__process_crash(options, ipc, daemon); +#else + bool crash_captured = sentry__process_crash(options, ipc, NULL); +#endif crash_processed = true; if (crash_captured @@ -4788,6 +5116,15 @@ sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, SENTRY_DEBUG("Daemon exiting"); +#if defined(SENTRY_PLATFORM_ANDROID) + if (daemon && daemon->envelope_path[0]) { + daemon->options = options; + daemon->ipc = ipc; + daemon->log_file = log_file; + return 0; + } +#endif + // Cleanup if (options) { if (options->transport) { @@ -4862,13 +5199,13 @@ main(int argc, char **argv) // Expected arguments: // Linux: // - // macOS: - // -# if defined(SENTRY_PLATFORM_MACOS) + // macOS/Android: + // +# if defined(SENTRY_PLATFORM_MACOS) || defined(SENTRY_PLATFORM_ANDROID) if (argc < 7) { fprintf(stderr, - "Usage: sentry-crash " - " \n"); + "Usage: sentry-crash " + " \n"); return 1; } # else @@ -4884,7 +5221,14 @@ main(int argc, char **argv) pid_t app_pid = (pid_t)strtoul(argv[1], NULL, 10); uint64_t app_tid = strtoull(argv[2], NULL, 16); -# if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) +# if defined(SENTRY_PLATFORM_ANDROID) + int notify_eventfd = atoi(argv[3]); + int ready_eventfd = atoi(argv[4]); + int shm_fd_arg = atoi(argv[5]); + int message_fd = atoi(argv[6]); + return sentry__crash_daemon_main(app_pid, app_tid, notify_eventfd, + ready_eventfd, shm_fd_arg, message_fd, NULL); +# elif defined(SENTRY_PLATFORM_LINUX) int notify_eventfd = atoi(argv[3]); int ready_eventfd = atoi(argv[4]); int message_fd = atoi(argv[5]); diff --git a/src/backends/native/sentry_crash_daemon.h b/src/backends/native/sentry_crash_daemon.h index e7a6473d5d..4d5b38e2e2 100644 --- a/src/backends/native/sentry_crash_daemon.h +++ b/src/backends/native/sentry_crash_daemon.h @@ -12,6 +12,7 @@ // Forward declaration struct sentry_options_s; +typedef struct sentry_crash_daemon_s sentry_crash_daemon_t; /** * Daemon main loop (runs in forked child on Unix, or separate process on @@ -21,7 +22,11 @@ struct sentry_options_s; * @param notify_handle Notification handle for crash signals * @param ready_handle Ready signal handle to signal parent */ -#if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) +#if defined(SENTRY_PLATFORM_ANDROID) +int sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, + int notify_eventfd, int ready_eventfd, int shm_fd, int message_fd, + sentry_crash_daemon_t *daemon); +#elif defined(SENTRY_PLATFORM_LINUX) int sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, int notify_eventfd, int ready_eventfd, int message_fd); #elif defined(SENTRY_PLATFORM_MACOS) @@ -42,9 +47,10 @@ int sentry__crash_daemon_main(pid_t app_pid, uint64_t app_tid, * * @param options Sentry options (DSN, transport, etc.) * @param ipc Crash IPC with crash context in shared memory + * @param daemon Android service-hosted daemon to defer the upload to, or NULL * @return true if the crash envelope was captured for upload or reporting */ -bool sentry__process_crash( - const struct sentry_options_s *options, sentry_crash_ipc_t *ipc); +bool sentry__process_crash(const struct sentry_options_s *options, + sentry_crash_ipc_t *ipc, sentry_crash_daemon_t *daemon); #endif diff --git a/src/backends/native/sentry_crash_handler.c b/src/backends/native/sentry_crash_handler.c index 43b5e16656..e7376f5c6b 100644 --- a/src/backends/native/sentry_crash_handler.c +++ b/src/backends/native/sentry_crash_handler.c @@ -27,6 +27,9 @@ # if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) # include # endif +# if defined(SENTRY_PLATFORM_ANDROID) +# include +# endif # include #elif defined(SENTRY_PLATFORM_WINDOWS) # include @@ -471,6 +474,14 @@ crash_signal_handler(int signum, siginfo_t *info, void *context) sentry_crash_context_t *ctx = ipc->shmem; +# if defined(SENTRY_PLATFORM_ANDROID) + // Let the daemon ptrace and read this process. Non-debuggable apps are not + // dumpable, and Yama requires a declared ptracer (EINVAL without Yama). + // Not restored, since the process terminates after the crash. + prctl(PR_SET_DUMPABLE, 1, 0, 0, 0); + prctl(PR_SET_PTRACER, PR_SET_PTRACER_ANY, 0, 0, 0); +# endif + // Fill crash context ctx->crashed_pid = getpid(); ctx->crashed_tid = get_tid(); diff --git a/src/backends/native/sentry_crash_ipc.c b/src/backends/native/sentry_crash_ipc.c index fb123ffd21..8b8a10b369 100644 --- a/src/backends/native/sentry_crash_ipc.c +++ b/src/backends/native/sentry_crash_ipc.c @@ -164,29 +164,93 @@ sentry__crash_ipc_message_decode( # include # include -sentry_crash_ipc_t * -sentry__crash_ipc_init_app(sem_t *init_sem) +# if defined(SENTRY_PLATFORM_ANDROID) +// Bionic implements neither shm_open() nor sem_open(). Map a file in the +// database directory instead, and lock initialization with a mutex. +static int +shm_create(sentry_crash_ipc_t *ipc) { - sentry_crash_ipc_t *ipc = SENTRY_MAKE(sentry_crash_ipc_t); - if (!ipc) { - return NULL; + return open(ipc->shm_name, O_CREAT | O_RDWR | O_EXCL | O_CLOEXEC, 0600); +} + +static int +shm_reopen(sentry_crash_ipc_t *ipc) +{ + return open(ipc->shm_name, O_RDWR | O_CLOEXEC); +} + +static void +shm_remove(sentry_crash_ipc_t *ipc) +{ + unlink(ipc->shm_name); +} + +static bool +init_lock(sentry_crash_ipc_t *ipc) +{ + if (ipc->init_mutex) { + sentry__mutex_lock(ipc->init_mutex); + } + return true; +} + +static void +init_unlock(sentry_crash_ipc_t *ipc) +{ + if (ipc->init_mutex) { + sentry__mutex_unlock(ipc->init_mutex); + } +} +# else +static int +shm_create(sentry_crash_ipc_t *ipc) +{ + return shm_open(ipc->shm_name, O_CREAT | O_RDWR | O_EXCL, 0600); +} + +static int +shm_reopen(sentry_crash_ipc_t *ipc) +{ + return shm_open(ipc->shm_name, O_RDWR, 0600); +} + +static void +shm_remove(sentry_crash_ipc_t *ipc) +{ + shm_unlink(ipc->shm_name); +} + +static bool +init_lock(sentry_crash_ipc_t *ipc) +{ + return !ipc->init_sem || sem_wait(ipc->init_sem) == 0; +} + +static void +init_unlock(sentry_crash_ipc_t *ipc) +{ + if (ipc->init_sem) { + sem_post(ipc->init_sem); } +} +# endif + +/** + * Creates or reuses the shared memory named by `ipc->shm_name`, and the + * notification and message fds that were not handed over (-1). + */ +static sentry_crash_ipc_t * +init_app(sentry_crash_ipc_t *ipc, int notify_fd, int ready_fd, int message_fd) +{ ipc->is_daemon = false; - ipc->init_sem = init_sem; // Use provided semaphore (managed by backend) - ipc->message_fd[0] = -1; + ipc->shm_fd = -1; + ipc->notify_fd = notify_fd; + ipc->ready_fd = ready_fd; + ipc->message_fd[0] = message_fd; ipc->message_fd[1] = -1; - // Create shared memory with unique name based on PID and thread ID - // macOS has a 31-character limit for POSIX shared memory names (PSEMNAMLEN) - // Format: /s-{8_hex_chars} = 11 chars total (well under 31 limit) - // We mix PID and TID to create a unique 32-bit identifier, allowing - // multiple sentry_init() calls from different threads in the same process. - uint64_t tid = (uint64_t)pthread_self(); - uint32_t id = (uint32_t)((getpid() ^ (tid & 0xFFFFFFFF)) & 0xFFFFFFFF); - snprintf(ipc->shm_name, sizeof(ipc->shm_name), "/s-%08x", id); - - // Acquire semaphore for exclusive access during initialization - if (ipc->init_sem && sem_wait(ipc->init_sem) < 0) { + // Acquire exclusive access during initialization + if (!init_lock(ipc)) { SENTRY_WARNF( "failed to acquire initialization semaphore: %s", strerror(errno)); sentry_free(ipc); @@ -195,20 +259,16 @@ sentry__crash_ipc_init_app(sem_t *init_sem) // Try to create or open shared memory bool shm_exists = false; - ipc->shm_fd = shm_open(ipc->shm_name, O_CREAT | O_RDWR | O_EXCL, 0600); + ipc->shm_fd = shm_create(ipc); if (ipc->shm_fd < 0 && errno == EEXIST) { // Shared memory already exists - reuse it shm_exists = true; - ipc->shm_fd = shm_open(ipc->shm_name, O_RDWR, 0600); + ipc->shm_fd = shm_reopen(ipc); } if (ipc->shm_fd < 0) { SENTRY_WARNF("failed to open shared memory: %s", strerror(errno)); - if (ipc->init_sem) { - sem_post(ipc->init_sem); - } - sentry_free(ipc); - return NULL; + goto fail; } // Verify and resize shared memory (both new and existing) @@ -217,37 +277,21 @@ sentry__crash_ipc_init_app(sem_t *init_sem) struct stat st; if (fstat(ipc->shm_fd, &st) < 0) { SENTRY_WARNF("failed to stat shared memory: %s", strerror(errno)); - close(ipc->shm_fd); - if (ipc->init_sem) { - sem_post(ipc->init_sem); - } - sentry_free(ipc); - return NULL; + goto fail; } if (st.st_size != SENTRY_CRASH_SHM_SIZE) { // Existing shm has wrong size, resize it if (ftruncate(ipc->shm_fd, SENTRY_CRASH_SHM_SIZE) < 0) { SENTRY_WARNF("failed to resize existing shared memory: %s", strerror(errno)); - close(ipc->shm_fd); - if (ipc->init_sem) { - sem_post(ipc->init_sem); - } - sentry_free(ipc); - return NULL; + goto fail; } } } else { // New shared memory, set size if (ftruncate(ipc->shm_fd, SENTRY_CRASH_SHM_SIZE) < 0) { SENTRY_WARNF("failed to resize shared memory: %s", strerror(errno)); - close(ipc->shm_fd); - shm_unlink(ipc->shm_name); - if (ipc->init_sem) { - sem_post(ipc->init_sem); - } - sentry_free(ipc); - return NULL; + goto fail; } } @@ -256,66 +300,34 @@ sentry__crash_ipc_init_app(sem_t *init_sem) MAP_SHARED, ipc->shm_fd, 0); if (ipc->shmem == MAP_FAILED) { SENTRY_WARNF("failed to map shared memory: %s", strerror(errno)); - close(ipc->shm_fd); - if (!shm_exists) { - shm_unlink(ipc->shm_name); - } - if (ipc->init_sem) { - sem_post(ipc->init_sem); - } - sentry_free(ipc); - return NULL; + ipc->shmem = NULL; + goto fail; } // Create eventfd for crash notifications - ipc->notify_fd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); if (ipc->notify_fd < 0) { - SENTRY_WARNF("failed to create eventfd: %s", strerror(errno)); - munmap(ipc->shmem, SENTRY_CRASH_SHM_SIZE); - close(ipc->shm_fd); - if (!shm_exists) { - shm_unlink(ipc->shm_name); - } - if (ipc->init_sem) { - sem_post(ipc->init_sem); + ipc->notify_fd = eventfd(0, EFD_CLOEXEC | EFD_NONBLOCK); + if (ipc->notify_fd < 0) { + SENTRY_WARNF("failed to create eventfd: %s", strerror(errno)); + goto fail; } - sentry_free(ipc); - return NULL; } // Create eventfd for daemon ready signal - ipc->ready_fd = eventfd(0, EFD_CLOEXEC); if (ipc->ready_fd < 0) { - SENTRY_WARNF("failed to create ready eventfd: %s", strerror(errno)); - close(ipc->notify_fd); - munmap(ipc->shmem, SENTRY_CRASH_SHM_SIZE); - close(ipc->shm_fd); - if (!shm_exists) { - shm_unlink(ipc->shm_name); + ipc->ready_fd = eventfd(0, EFD_CLOEXEC); + if (ipc->ready_fd < 0) { + SENTRY_WARNF("failed to create ready eventfd: %s", strerror(errno)); + goto fail; } - if (ipc->init_sem) { - sem_post(ipc->init_sem); - } - sentry_free(ipc); - return NULL; } - if (socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, ipc->message_fd) - < 0) { + if (ipc->message_fd[0] < 0 + && socketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0, ipc->message_fd) + < 0) { SENTRY_WARNF( "failed to create message socketpair: %s", strerror(errno)); - close(ipc->ready_fd); - close(ipc->notify_fd); - munmap(ipc->shmem, SENTRY_CRASH_SHM_SIZE); - close(ipc->shm_fd); - if (!shm_exists) { - shm_unlink(ipc->shm_name); - } - if (ipc->init_sem) { - sem_post(ipc->init_sem); - } - sentry_free(ipc); - return NULL; + goto fail; } struct timeval send_timeout = { 1, 0 }; @@ -327,23 +339,121 @@ sentry__crash_ipc_init_app(sem_t *init_sem) sentry__crash_context_init(ipc->shmem); } - // Release semaphore after initialization - if (ipc->init_sem) { - sem_post(ipc->init_sem); - } + // Release exclusive access after initialization + init_unlock(ipc); SENTRY_DEBUGF("initialized crash IPC (shm=%s, notify_fd=%d)", ipc->shm_name, ipc->notify_fd); return ipc; + +fail: + if (ipc->message_fd[0] >= 0) { + close(ipc->message_fd[0]); + } + if (ipc->message_fd[1] >= 0) { + close(ipc->message_fd[1]); + } + if (ipc->ready_fd >= 0) { + close(ipc->ready_fd); + } + if (ipc->notify_fd >= 0) { + close(ipc->notify_fd); + } + if (ipc->shmem) { + munmap(ipc->shmem, SENTRY_CRASH_SHM_SIZE); + } + if (ipc->shm_fd >= 0) { + close(ipc->shm_fd); + if (!shm_exists) { + shm_remove(ipc); + } + } + init_unlock(ipc); + sentry_free(ipc); + return NULL; } +# if defined(SENTRY_PLATFORM_ANDROID) +sentry_crash_ipc_t * +sentry__crash_ipc_init_app( + const char *database_path, sentry_mutex_t *init_mutex) +{ + if (!database_path || !database_path[0]) { + SENTRY_WARN("Android crash IPC requires a database path"); + return NULL; + } + + sentry_crash_ipc_t *ipc = SENTRY_MAKE(sentry_crash_ipc_t); + if (!ipc) { + return NULL; + } + ipc->init_mutex = init_mutex; + + uint64_t tid = (uint64_t)pthread_self(); + uint32_t id = (uint32_t)((getpid() ^ (tid & 0xFFFFFFFF)) & 0xFFFFFFFF); + snprintf(ipc->shm_name, sizeof(ipc->shm_name), "%s/.sentry-shm-%08x", + database_path, id); + + return init_app(ipc, -1, -1, -1); +} + +sentry_crash_ipc_t * +sentry__crash_ipc_init_app_with_fds(const char *shm_path, int notify_fd, + int ready_fd, int message_fd, sentry_mutex_t *init_mutex) +{ + if (!shm_path || !shm_path[0]) { + SENTRY_WARN("Android crash IPC requires a shared memory path"); + return NULL; + } + + sentry_crash_ipc_t *ipc = SENTRY_MAKE(sentry_crash_ipc_t); + if (!ipc) { + return NULL; + } + ipc->init_mutex = init_mutex; + strncpy(ipc->shm_name, shm_path, sizeof(ipc->shm_name) - 1); + + return init_app(ipc, notify_fd, ready_fd, message_fd); +} +# else +sentry_crash_ipc_t * +sentry__crash_ipc_init_app(sem_t *init_sem) +{ + sentry_crash_ipc_t *ipc = SENTRY_MAKE(sentry_crash_ipc_t); + if (!ipc) { + return NULL; + } + ipc->init_sem = init_sem; // Use provided semaphore (managed by backend) + + // Create shared memory with unique name based on PID and thread ID + // macOS has a 31-character limit for POSIX shared memory names (PSEMNAMLEN) + // Format: /s-{8_hex_chars} = 11 chars total (well under 31 limit) + // We mix PID and TID to create a unique 32-bit identifier, allowing + // multiple sentry_init() calls from different threads in the same process. + uint64_t tid = (uint64_t)pthread_self(); + uint32_t id = (uint32_t)((getpid() ^ (tid & 0xFFFFFFFF)) & 0xFFFFFFFF); + snprintf(ipc->shm_name, sizeof(ipc->shm_name), "/s-%08x", id); + + return init_app(ipc, -1, -1, -1); +} +# endif + +# if defined(SENTRY_PLATFORM_ANDROID) +sentry_crash_ipc_t * +sentry__crash_ipc_init_daemon(pid_t app_pid, uint64_t app_tid, + int notify_eventfd, int ready_eventfd, int shm_fd, int message_fd) +# else sentry_crash_ipc_t * sentry__crash_ipc_init_daemon(pid_t app_pid, uint64_t app_tid, int notify_eventfd, int ready_eventfd, int message_fd) +# endif { sentry_crash_ipc_t *ipc = SENTRY_MAKE(sentry_crash_ipc_t); if (!ipc) { +# if defined(SENTRY_PLATFORM_ANDROID) + close(shm_fd); +# endif return NULL; } ipc->is_daemon = true; @@ -354,6 +464,10 @@ sentry__crash_ipc_init_daemon(pid_t app_pid, uint64_t app_tid, // Open existing shared memory created by app (using PID and thread ID) // Must match the format in sentry__crash_ipc_init_app uint32_t id = (uint32_t)((app_pid ^ (app_tid & 0xFFFFFFFF)) & 0xFFFFFFFF); +# if defined(SENTRY_PLATFORM_ANDROID) + // The file is handed over open, its path is only needed to unlink it + ipc->shm_fd = shm_fd; +# else snprintf(ipc->shm_name, sizeof(ipc->shm_name), "/s-%08x", id); ipc->shm_fd = shm_open(ipc->shm_name, O_RDWR, 0600); @@ -363,6 +477,7 @@ sentry__crash_ipc_init_daemon(pid_t app_pid, uint64_t app_tid, sentry_free(ipc); return NULL; } +# endif // Map shared memory ipc->shmem = mmap(NULL, SENTRY_CRASH_SHM_SIZE, PROT_READ | PROT_WRITE, @@ -384,6 +499,13 @@ sentry__crash_ipc_init_daemon(pid_t app_pid, uint64_t app_tid, return NULL; } +# if defined(SENTRY_PLATFORM_ANDROID) + if (ipc->shmem->database_path[0]) { + snprintf(ipc->shm_name, sizeof(ipc->shm_name), "%s/.sentry-shm-%08x", + ipc->shmem->database_path, id); + } +# endif + // Eventfds are inherited from parent after fork - assign them ipc->notify_fd = notify_eventfd; ipc->ready_fd = ready_eventfd; @@ -443,7 +565,7 @@ void sentry__crash_ipc_unlink(sentry_crash_ipc_t *ipc) { if (ipc && ipc->shm_name[0]) { - shm_unlink(ipc->shm_name); + shm_remove(ipc); } } diff --git a/src/backends/native/sentry_crash_ipc.h b/src/backends/native/sentry_crash_ipc.h index 6704dbc600..506e532f4f 100644 --- a/src/backends/native/sentry_crash_ipc.h +++ b/src/backends/native/sentry_crash_ipc.h @@ -9,7 +9,11 @@ #include #if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) -# include +# if defined(SENTRY_PLATFORM_ANDROID) +# include "sentry_sync.h" +# else +# include +# endif # include # include #elif defined(SENTRY_PLATFORM_MACOS) @@ -125,9 +129,14 @@ typedef struct { int ready_fd; // Eventfd for daemon ready signal // Anonymous app-to-daemon message stream: app writes, daemon reads. int message_fd[2]; +# if defined(SENTRY_PLATFORM_ANDROID) + char shm_name[SENTRY_CRASH_MAX_PATH]; // File path, bionic has no shm_open() + sentry_mutex_t *init_mutex; // Process-wide initialization mutex +# else char shm_name[SENTRY_CRASH_IPC_NAME_SIZE]; sem_t *init_sem; // Named semaphore for initialization synchronization char sem_name[SENTRY_CRASH_IPC_NAME_SIZE]; +# endif #elif defined(SENTRY_PLATFORM_MACOS) int shm_fd; int notify_pipe[2]; // Pipe for crash notifications (fork-safe) @@ -158,11 +167,17 @@ typedef struct { /** * Initialize IPC for application process. * Creates shared memory and notification mechanism. + * @param database_path Android-only directory for the file-backed mapping * @param init_sem Optional semaphore for synchronizing init (can be NULL) * @param init_mutex Optional mutex for synchronizing init on Windows (can be * NULL) */ -#if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) +#if defined(SENTRY_PLATFORM_ANDROID) +sentry_crash_ipc_t *sentry__crash_ipc_init_app( + const char *database_path, sentry_mutex_t *init_mutex); +sentry_crash_ipc_t *sentry__crash_ipc_init_app_with_fds(const char *shm_path, + int notify_fd, int ready_fd, int message_fd, sentry_mutex_t *init_mutex); +#elif defined(SENTRY_PLATFORM_LINUX) sentry_crash_ipc_t *sentry__crash_ipc_init_app(sem_t *init_sem); #elif defined(SENTRY_PLATFORM_MACOS) sentry_crash_ipc_t *sentry__crash_ipc_init_app(sentry_mutex_t *init_mutex); @@ -182,7 +197,11 @@ sentry_crash_ipc_t *sentry__crash_ipc_init_app(void); * @param ready_handle Ready signal handle inherited from parent (eventfd on * Linux, pipe fd on macOS, event on Windows) */ -#if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) +#if defined(SENTRY_PLATFORM_ANDROID) +sentry_crash_ipc_t *sentry__crash_ipc_init_daemon(pid_t app_pid, + uint64_t app_tid, int notify_eventfd, int ready_eventfd, int shm_fd, + int message_fd); +#elif defined(SENTRY_PLATFORM_LINUX) sentry_crash_ipc_t *sentry__crash_ipc_init_daemon(pid_t app_pid, uint64_t app_tid, int notify_eventfd, int ready_eventfd, int message_fd); #elif defined(SENTRY_PLATFORM_MACOS) diff --git a/src/backends/sentry_backend_native.c b/src/backends/sentry_backend_native.c index 0479dbc54e..f12e0e15ff 100644 --- a/src/backends/sentry_backend_native.c +++ b/src/backends/sentry_backend_native.c @@ -17,6 +17,7 @@ # endif # if defined(SENTRY_PLATFORM_ANDROID) # include +# include # endif #endif @@ -52,9 +53,10 @@ // This lives for the entire backend lifetime and is shared across all threads #if defined(SENTRY_PLATFORM_WINDOWS) static HANDLE g_ipc_mutex = NULL; -#elif defined(SENTRY_PLATFORM_MACOS) -// macOS uses a plain pthread mutex instead of named semaphores (sem_open) -// because App Sandbox blocks POSIX named semaphores. +#elif defined(SENTRY_PLATFORM_MACOS) || defined(SENTRY_PLATFORM_ANDROID) +// macOS/Android use a plain pthread mutex instead of named semaphores +// (sem_open) because App Sandbox blocks POSIX named semaphores and Android has +// no shm_open. static sentry_mutex_t g_ipc_sync_mutex = SENTRY__MUTEX_INIT; #else # include @@ -62,10 +64,123 @@ static sem_t *g_ipc_init_sem = SEM_FAILED; static char g_ipc_sem_name[64] = { 0 }; #endif -// Mutex to protect IPC initialization (Windows and Linux only, not macOS/iOS) -// macOS uses g_ipc_sync_mutex directly; iOS has no out-of-process daemon. +#if defined(SENTRY_PLATFORM_ANDROID) +typedef struct { + bool configured; + char shm_path[SENTRY_CRASH_MAX_PATH]; + int notify_fd; + int ready_fd; + int message_fd; +} android_crash_daemon_config_t; + +static android_crash_daemon_config_t g_android_crash_daemon_config + = { false, { 0 }, -1, -1, -1 }; + +__attribute__((visibility("default"))) void +sentry_android_crash_daemon_init( + const char *shm_path, int notify_fd, int ready_fd, int message_fd) +{ + if (!shm_path || !shm_path[0] || notify_fd < 0 || ready_fd < 0 + || message_fd < 0) { + return; + } + + sentry__mutex_lock(&g_ipc_sync_mutex); + // replaces a configuration that no backend startup took + if (g_android_crash_daemon_config.configured) { + close(g_android_crash_daemon_config.notify_fd); + close(g_android_crash_daemon_config.ready_fd); + close(g_android_crash_daemon_config.message_fd); + } + strncpy(g_android_crash_daemon_config.shm_path, shm_path, + sizeof(g_android_crash_daemon_config.shm_path) - 1); + g_android_crash_daemon_config + .shm_path[sizeof(g_android_crash_daemon_config.shm_path) - 1] + = '\0'; + g_android_crash_daemon_config.notify_fd = notify_fd; + g_android_crash_daemon_config.ready_fd = ready_fd; + g_android_crash_daemon_config.message_fd = message_fd; + g_android_crash_daemon_config.configured = true; + sentry__mutex_unlock(&g_ipc_sync_mutex); +} + +static bool +take_android_crash_daemon_config(char *shm_path, size_t shm_path_len, + int *notify_fd, int *ready_fd, int *message_fd) +{ + bool configured = false; + sentry__mutex_lock(&g_ipc_sync_mutex); + if (g_android_crash_daemon_config.configured) { + strncpy( + shm_path, g_android_crash_daemon_config.shm_path, shm_path_len - 1); + shm_path[shm_path_len - 1] = '\0'; + *notify_fd = g_android_crash_daemon_config.notify_fd; + *ready_fd = g_android_crash_daemon_config.ready_fd; + *message_fd = g_android_crash_daemon_config.message_fd; + g_android_crash_daemon_config.notify_fd = -1; + g_android_crash_daemon_config.ready_fd = -1; + g_android_crash_daemon_config.message_fd = -1; + g_android_crash_daemon_config.configured = false; + configured = true; + } + sentry__mutex_unlock(&g_ipc_sync_mutex); + return configured; +} +#endif + +static char * +resolve_handler_path(const sentry_options_t *options) +{ + if (options->handler_path) { + return sentry__string_clone(options->handler_path->path); + } + +#if defined(SENTRY_PLATFORM_ANDROID) + Dl_info info; + if (dladdr((void *)resolve_handler_path, &info) == 0 || !info.dli_fname) { + SENTRY_WARN("unable to resolve sentry library path for crash daemon"); + return NULL; + } + + sentry_path_t *library_path = sentry__path_from_str(info.dli_fname); + if (!library_path) { + return NULL; + } + + sentry_path_t *library_dir = sentry__path_dir(library_path); + sentry__path_free(library_path); + if (!library_dir) { + return NULL; + } + + sentry_path_t *handler_path + = sentry__path_join_str(library_dir, "libsentry-crash.so"); + sentry__path_free(library_dir); + if (!handler_path) { + return NULL; + } + + if (!sentry__path_is_file(handler_path)) { + SENTRY_WARNF("Android crash daemon is missing: %s", handler_path->path); + sentry__path_free(handler_path); + return NULL; + } + + SENTRY_DEBUGF("resolved Android crash daemon path: %s", handler_path->path); + char *handler_path_str = sentry__string_clone(handler_path->path); + sentry__path_free(handler_path); + return handler_path_str; +#else + return NULL; +#endif +} + +// Mutex to protect IPC initialization (Windows and Linux only, not macOS/iOS or +// Android) macOS/Android use g_ipc_sync_mutex directly; iOS has no +// out-of-process daemon. #if defined(SENTRY_PLATFORM_WINDOWS) \ - || (!defined(SENTRY_PLATFORM_MACOS) && !defined(SENTRY_PLATFORM_IOS)) + || (!defined(SENTRY_PLATFORM_MACOS) && !defined(SENTRY_PLATFORM_ANDROID) \ + && !defined(SENTRY_PLATFORM_IOS)) # ifdef SENTRY__MUTEX_INIT_DYN SENTRY__MUTEX_INIT_DYN(g_ipc_init_mutex) # else @@ -242,6 +357,7 @@ typedef struct { sentry_path_t *run_path; sentry_path_t *envelope_path; const sentry_options_t *options; + bool android_service_daemon; sentry_scope_observer_t *scope_observer; volatile long crashed; } native_backend_state_t; @@ -336,7 +452,11 @@ native_backend_process_old_run(sentry_backend_t *backend, * @param ready_handle Ready signal handle * @return Daemon PID on success, -1 on failure */ -#if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) +#if defined(SENTRY_PLATFORM_ANDROID) +static pid_t +daemon_start(pid_t app_pid, uint64_t app_tid, int notify_eventfd, + int ready_eventfd, int shm_fd, int message_fd, const char *handler_path) +#elif defined(SENTRY_PLATFORM_LINUX) static pid_t daemon_start(pid_t app_pid, uint64_t app_tid, int notify_eventfd, int ready_eventfd, int message_fd, const char *handler_path) @@ -466,6 +586,13 @@ daemon_start(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, fcntl(message_fd, F_SETFD, message_flags & ~FD_CLOEXEC); } +# if defined(SENTRY_PLATFORM_ANDROID) + int shm_flags = fcntl(shm_fd, F_GETFD); + if (shm_flags != -1) { + fcntl(shm_fd, F_SETFD, shm_flags & ~FD_CLOEXEC); + } +# endif + // Convert arguments to strings for exec char pid_str[32], tid_str[32], notify_str[32], ready_str[32], message_str[32]; @@ -475,8 +602,15 @@ daemon_start(pid_t app_pid, uint64_t app_tid, HANDLE event_handle, snprintf(ready_str, sizeof(ready_str), "%d", ready_eventfd); snprintf(message_str, sizeof(message_str), "%d", message_fd); +# if defined(SENTRY_PLATFORM_ANDROID) + char shm_str[32]; + snprintf(shm_str, sizeof(shm_str), "%d", shm_fd); + char *argv[] = { "sentry-crash", pid_str, tid_str, notify_str, + ready_str, shm_str, message_str, NULL }; +# else char *argv[] = { "sentry-crash", pid_str, tid_str, notify_str, ready_str, message_str, NULL }; +# endif if (!sentry__string_empty(handler_path)) { execv(handler_path, argv); @@ -674,9 +808,10 @@ native_backend_startup( } sentry__mutex_unlock(&g_ipc_init_mutex); -#elif defined(SENTRY_PLATFORM_MACOS) - // macOS uses a plain pthread mutex (no sem_open which is blocked by App - // Sandbox). The mutex is statically initialized - no setup needed. +#elif defined(SENTRY_PLATFORM_MACOS) || defined(SENTRY_PLATFORM_ANDROID) + // macOS/Android use a plain pthread mutex (no sem_open/shm_open which is + // blocked by App Sandbox, or missing on Android). The mutex is statically + // initialized - no setup needed. (void)0; #elif !defined(SENTRY_PLATFORM_IOS) // Create process-wide IPC initialization semaphore (singleton pattern) @@ -715,6 +850,23 @@ native_backend_startup( state->ipc = sentry__crash_ipc_init_app(g_ipc_mutex); #elif defined(SENTRY_PLATFORM_IOS) state->ipc = sentry__crash_ipc_init_app(NULL); +#elif defined(SENTRY_PLATFORM_ANDROID) + char service_shm_path[SENTRY_CRASH_MAX_PATH] = { 0 }; + int service_notify_fd = -1; + int service_ready_fd = -1; + int service_message_fd = -1; + state->android_service_daemon = take_android_crash_daemon_config( + service_shm_path, sizeof(service_shm_path), &service_notify_fd, + &service_ready_fd, &service_message_fd); + if (state->android_service_daemon) { + state->ipc = sentry__crash_ipc_init_app_with_fds(service_shm_path, + service_notify_fd, service_ready_fd, service_message_fd, + &g_ipc_sync_mutex); + } else { + state->ipc = sentry__crash_ipc_init_app( + options->database_path ? options->database_path->path : NULL, + &g_ipc_sync_mutex); + } #elif defined(SENTRY_PLATFORM_MACOS) state->ipc = sentry__crash_ipc_init_app(&g_ipc_sync_mutex); #else @@ -743,7 +895,7 @@ native_backend_startup( return 1; } } -#elif defined(SENTRY_PLATFORM_MACOS) +#elif defined(SENTRY_PLATFORM_MACOS) || defined(SENTRY_PLATFORM_ANDROID) sentry__mutex_lock(&g_ipc_sync_mutex); #elif !defined(SENTRY_PLATFORM_IOS) if (g_ipc_init_sem && sem_wait(g_ipc_init_sem) < 0) { @@ -892,7 +1044,7 @@ native_backend_startup( if (g_ipc_mutex) { ReleaseMutex(g_ipc_mutex); } -#elif defined(SENTRY_PLATFORM_MACOS) +#elif defined(SENTRY_PLATFORM_MACOS) || defined(SENTRY_PLATFORM_ANDROID) sentry__mutex_unlock(&g_ipc_sync_mutex); #elif !defined(SENTRY_PLATFORM_IOS) // Release semaphore after context configuration @@ -917,30 +1069,47 @@ native_backend_startup( #else // Other platforms: Use out-of-process daemon // Pass the notification handles (eventfd/pipe on Unix, events on Windows) - const char *daemon_handler_path - = options->handler_path ? options->handler_path->path : NULL; -# if defined(SENTRY_PLATFORM_LINUX) || defined(SENTRY_PLATFORM_ANDROID) + char *daemon_handler_path = NULL; +# if defined(SENTRY_PLATFORM_ANDROID) + if (state->android_service_daemon) { + state->daemon_pid = 0; + SENTRY_DEBUG("using Android service-hosted crash daemon"); + uint64_t ready = 1; + ssize_t written = write(state->ipc->notify_fd, &ready, sizeof(ready)); + (void)written; + } else { + daemon_handler_path = resolve_handler_path(options); + uint64_t tid = (uint64_t)pthread_self(); + state->daemon_pid = daemon_start(getpid(), tid, state->ipc->notify_fd, + state->ipc->ready_fd, state->ipc->shm_fd, state->ipc->message_fd[1], + daemon_handler_path); + } +# elif defined(SENTRY_PLATFORM_LINUX) + daemon_handler_path = resolve_handler_path(options); uint64_t tid = (uint64_t)pthread_self(); state->daemon_pid = daemon_start(getpid(), tid, state->ipc->notify_fd, state->ipc->ready_fd, state->ipc->message_fd[1], daemon_handler_path); # elif defined(SENTRY_PLATFORM_MACOS) + daemon_handler_path = resolve_handler_path(options); uint64_t tid = (uint64_t)pthread_self(); state->daemon_pid = daemon_start(getpid(), tid, state->ipc->notify_pipe[0], state->ipc->ready_pipe[1], state->ipc->shm_fd, state->ipc->message_fd[1], daemon_handler_path); # elif defined(SENTRY_PLATFORM_WINDOWS) + daemon_handler_path = resolve_handler_path(options); uint64_t tid = (uint64_t)GetCurrentThreadId(); state->daemon_pid = daemon_start(GetCurrentProcessId(), tid, state->ipc->event_handle, state->ipc->ready_event_handle, state->ipc->message_read_handle, daemon_handler_path); # endif + sentry_free(daemon_handler_path); // On Windows, pid_t is DWORD (unsigned), so (pid_t)-1 == 0xFFFFFFFF. // On Unix, pid_t is signed and fork returns -1 on failure. # if defined(SENTRY_PLATFORM_WINDOWS) if (state->daemon_pid == (pid_t)-1) { # else - if (state->daemon_pid < 0) { + if (!state->android_service_daemon && state->daemon_pid < 0) { # endif SENTRY_WARN("failed to start crash daemon"); sentry__crash_ipc_free(state->ipc); @@ -950,7 +1119,9 @@ native_backend_startup( return 1; } - SENTRY_DEBUGF("crash daemon started with PID %d", state->daemon_pid); + if (!state->android_service_daemon) { + SENTRY_DEBUGF("crash daemon started with PID %d", state->daemon_pid); + } # if defined(SENTRY_PLATFORM_MACOS) // Close unused pipe ends in parent process @@ -975,12 +1146,13 @@ native_backend_startup( // On Linux, allow the daemon to ptrace this process // This is required when Yama LSM ptrace_scope is enabled - if (prctl(PR_SET_PTRACER, state->daemon_pid, 0, 0, 0) != 0) { + if (!state->android_service_daemon + && prctl(PR_SET_PTRACER, state->daemon_pid, 0, 0, 0) != 0) { SENTRY_WARNF( "prctl(PR_SET_PTRACER) failed: %s - daemon may not be able to " "read process memory", strerror(errno)); - } else { + } else if (!state->android_service_daemon) { SENTRY_DEBUGF("Set daemon PID %d as ptracer", state->daemon_pid); } # endif @@ -1026,7 +1198,9 @@ native_backend_startup( if (sentry__crash_handler_init(state->ipc, strategy, NULL) < 0) { SENTRY_WARN("failed to initialize crash handler"); # if defined(SENTRY_PLATFORM_UNIX) - kill(state->daemon_pid, SIGTERM); + if (!state->android_service_daemon && state->daemon_pid > 0) { + kill(state->daemon_pid, SIGTERM); + } # elif defined(SENTRY_PLATFORM_WINDOWS) wer_unregister_module(); // On Windows, terminate the daemon process diff --git a/src/transports/sentry_http_transport.c b/src/transports/sentry_http_transport.c index 99ab08d1fa..cb4c18b22b 100644 --- a/src/transports/sentry_http_transport.c +++ b/src/transports/sentry_http_transport.c @@ -43,6 +43,7 @@ typedef struct { volatile long shutting_down; sentry_retry_t *retry; volatile long retry_enabled; + volatile long dump_failed; sentry_cache_keep_t cache_keep; sentry_run_t *run; bool send_client_reports; @@ -865,6 +866,10 @@ http_send_task(void *_envelope, void *_state) && sentry__run_write_envelope(state->run, envelope)) { // Keep attachment-ref cache siblings for the persisted envelope. ref_owner = envelope; + } else if (sentry__atomic_fetch(&state->dump_failed) + && sentry__run_write_envelope(state->run, envelope)) { + // Keep attachment-ref cache siblings for the dumped envelope. + ref_owner = envelope; } else if (state->retry && sentry__retry_enqueue(state->retry, envelope)) { // Keep attachment-ref cache siblings for the queued retry envelope. @@ -1115,6 +1120,16 @@ sentry__http_transport_set_retry_enabled( } } +void +sentry__http_transport_set_dump_failed( + sentry_transport_t *transport, bool enabled) +{ + if (sentry__transport_get_state(transport)) { + sentry__atomic_store( + &http_transport_get_state(transport)->dump_failed, enabled); + } +} + sentry_transport_t * sentry_http_transport_new(sentry_http_client_factory_func_t factory, void *factory_data, sentry_http_client_send_func_t send_func, diff --git a/src/transports/sentry_http_transport.h b/src/transports/sentry_http_transport.h index 0f2d11c66a..5e5b647b7d 100644 --- a/src/transports/sentry_http_transport.h +++ b/src/transports/sentry_http_transport.h @@ -62,6 +62,8 @@ void sentry__http_transport_set_shutdown_client( sentry_transport_t *transport, void (*shutdown_client)(void *)); void sentry__http_transport_set_retry_enabled( sentry_transport_t *transport, bool enabled); +void sentry__http_transport_set_dump_failed( + sentry_transport_t *transport, bool enabled); #ifdef SENTRY_UNITTEST void *sentry__http_transport_get_bgworker(sentry_transport_t *transport); diff --git a/tests/conditions.py b/tests/conditions.py index 2c0bd567ee..e4dccc157a 100644 --- a/tests/conditions.py +++ b/tests/conditions.py @@ -3,7 +3,7 @@ import shutil is_aix = sys.platform == "aix" or sys.platform == "os400" -is_android = os.environ.get("ANDROID_API") +is_android = int(os.environ.get("ANDROID_API") or "0") is_wine = bool(os.environ.get("TEST_WINE")) is_x86 = os.environ.get("TEST_X86") is_arm32 = bool(os.environ.get("TEST_ARM32")) @@ -55,6 +55,8 @@ # Native backend works on all platforms (lightweight, no external dependencies) # It's always available - tests explicitly set SENTRY_BACKEND: native in cmake # On macOS ASAN, the signal handling conflicts with ASAN's memory interception -has_native = has_http and not (is_asan and sys.platform == "darwin") +has_native = not (is_asan and sys.platform == "darwin") and ( + not is_android or is_android >= 23 +) has_sccache = os.environ.get("USE_SCCACHE") and shutil.which("sccache") diff --git a/tests/fixtures/android_e2e/CMakeLists.txt b/tests/fixtures/android_e2e/CMakeLists.txt new file mode 100644 index 0000000000..e0e7ee399b --- /dev/null +++ b/tests/fixtures/android_e2e/CMakeLists.txt @@ -0,0 +1,4 @@ +cmake_minimum_required(VERSION 3.10) +project(sentry_android_e2e LANGUAGES C) + +add_library(e2e SHARED crash.c) diff --git a/tests/fixtures/android_e2e/README.md b/tests/fixtures/android_e2e/README.md new file mode 100644 index 0000000000..1ad9045d8e --- /dev/null +++ b/tests/fixtures/android_e2e/README.md @@ -0,0 +1,20 @@ +# Android native E2E fixture + +Prepare the APK separately from pytest, using a sentry-java checkout with the +native crash service (currently `feat/native-crash-service`). Set `ANDROID_HOME`, +`ANDROID_NDK` (the NDK directory), and `ANDROID_ARCH` for the build: + +```sh +python scripts/setup-android-e2e.py ../sentry-java +pytest -s -v tests/test_e2e_sentry.py +``` + +Run these commands from the sentry-native root. Testing needs a running emulator +or device, `ANDROID_API`, and the `SENTRY_E2E_*` credentials documented in the test +module. The crash service needs API 30+; tombstone merging needs API 31+. + +The setup script builds this checkout's native NDK package and the Java SDK +into an isolated Maven repository, overriding the Java SDK's pinned NDK dependency. +It then builds debuggable and non-debuggable fixture APKs under +`build/android-e2e`. Use `--build-dir` to choose another output directory and +set `SENTRY_E2E_ANDROID_BUILD_DIR` to it when running pytest. diff --git a/tests/fixtures/android_e2e/build.gradle b/tests/fixtures/android_e2e/build.gradle new file mode 100644 index 0000000000..17b59b228a --- /dev/null +++ b/tests/fixtures/android_e2e/build.gradle @@ -0,0 +1,45 @@ +buildscript { + repositories { + google() + mavenCentral() + } + dependencies { + classpath "com.android.tools.build:gradle:${agpVersion}" + } +} + +apply plugin: "com.android.application" + +android { + namespace = "io.sentry.e2e" + compileSdk = project.property("compileSdk").toInteger() + compileSdkMinor = project.property("compileSdkMinor").toInteger() + ndkVersion = new File(System.getenv("ANDROID_NDK")).name + + defaultConfig { + applicationId = "io.sentry.e2e" + minSdk = 30 + targetSdk = project.property("targetSdk").toInteger() + ndk { abiFilters.add(System.getenv("ANDROID_ARCH")) } + } + + buildTypes { + // non-debuggable apps are not dumpable + nondebuggable { + initWith(debug) + debuggable = false + } + } + + externalNativeBuild { + cmake { path = file("CMakeLists.txt") } + } +} + +dependencies { + implementation "io.sentry:sentry-android-ndk-outproc:${sentryVersion}" +} + +configurations.configureEach { + resolutionStrategy.force("io.sentry:sentry-native-ndk-outproc:${sentryVersion}") +} diff --git a/tests/fixtures/android_e2e/crash.c b/tests/fixtures/android_e2e/crash.c new file mode 100644 index 0000000000..ae49ea41e8 --- /dev/null +++ b/tests/fixtures/android_e2e/crash.c @@ -0,0 +1,19 @@ +#include + +__attribute__((noinline, optnone)) void +e2e_segfault(void) +{ + *(volatile int *)0 = 42; +} + +__attribute__((noinline, optnone)) void +e2e_native_crash(void) +{ + e2e_segfault(); +} + +JNIEXPORT void JNICALL +Java_io_sentry_e2e_MainActivity_crash(JNIEnv *env, jclass cls) +{ + e2e_native_crash(); +} diff --git a/tests/fixtures/android_e2e/gradle.properties b/tests/fixtures/android_e2e/gradle.properties new file mode 100644 index 0000000000..5bac8ac504 --- /dev/null +++ b/tests/fixtures/android_e2e/gradle.properties @@ -0,0 +1 @@ +android.useAndroidX=true diff --git a/tests/fixtures/android_e2e/settings.gradle b/tests/fixtures/android_e2e/settings.gradle new file mode 100644 index 0000000000..43a5fd4de7 --- /dev/null +++ b/tests/fixtures/android_e2e/settings.gradle @@ -0,0 +1,12 @@ +dependencyResolutionManagement { + repositories { + google() + mavenCentral() + exclusiveContent { + forRepository { maven { url = uri(localRepo) } } + filter { includeGroup("io.sentry") } + } + } +} + +rootProject.name = "app" diff --git a/tests/fixtures/android_e2e/src/main/AndroidManifest.xml b/tests/fixtures/android_e2e/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..cd00810a98 --- /dev/null +++ b/tests/fixtures/android_e2e/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/tests/fixtures/android_e2e/src/main/java/io/sentry/e2e/CrashReceiver.java b/tests/fixtures/android_e2e/src/main/java/io/sentry/e2e/CrashReceiver.java new file mode 100644 index 0000000000..1be9fb5722 --- /dev/null +++ b/tests/fixtures/android_e2e/src/main/java/io/sentry/e2e/CrashReceiver.java @@ -0,0 +1,24 @@ +package io.sentry.e2e; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.Looper; +import io.sentry.Sentry; + +public class CrashReceiver extends BroadcastReceiver { + @Override + public void onReceive(Context context, Intent intent) { + if (intent.hasExtra("dsn")) { + MainActivity.init(context.getApplicationContext(), intent); + } + // a restarted process lost its crash service, so it must not crash + if (!Sentry.isEnabled()) { + return; + } + // keep the process active until the crash + goAsync(); + new Handler(Looper.getMainLooper()).postDelayed(MainActivity::triggerNativeCrash, 1000); + } +} diff --git a/tests/fixtures/android_e2e/src/main/java/io/sentry/e2e/MainActivity.java b/tests/fixtures/android_e2e/src/main/java/io/sentry/e2e/MainActivity.java new file mode 100644 index 0000000000..a9f0bd6ff6 --- /dev/null +++ b/tests/fixtures/android_e2e/src/main/java/io/sentry/e2e/MainActivity.java @@ -0,0 +1,53 @@ +package io.sentry.e2e; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.os.Handler; +import io.sentry.Sentry; +import io.sentry.android.core.SentryAndroid; +import io.sentry.protocol.User; + +public class MainActivity extends Activity { + private static native void crash(); + + @Override + public void onCreate(Bundle state) { + super.onCreate(state); + init(getApplicationContext(), getIntent()); + if (getIntent().getBooleanExtra("crash", true)) { + new Handler(getMainLooper()).postDelayed(MainActivity::triggerNativeCrash, 1000); + } + } + + static void init(Context context, Intent intent) { + System.loadLibrary("e2e"); + SentryAndroid.init( + context, + options -> { + options.setDsn(intent.getStringExtra("dsn")); + options.setDebug(true); + options.setRelease("sentry-native-e2e"); + options.setTombstoneEnabled(intent.getBooleanExtra("tombstone", false)); + options.setEnableAutoSessionTracking(false); + options.setShutdownTimeoutMillis(30000); + }); + String testId = intent.getStringExtra("test_id"); + Sentry.configureScope( + scope -> { + scope.setTag("test.id", testId); + User user = new User(); + user.setId(testId); + scope.setUser(user); + }); + } + + private static void callNativeCrash() { + crash(); + } + + static void triggerNativeCrash() { + callNativeCrash(); + } +} diff --git a/tests/test_e2e_sentry.py b/tests/test_e2e_sentry.py index 5925a8ca86..c6c070f4a1 100644 --- a/tests/test_e2e_sentry.py +++ b/tests/test_e2e_sentry.py @@ -9,6 +9,8 @@ - SENTRY_E2E_AUTH_TOKEN: Sentry API token with project:read scope - SENTRY_E2E_ORG: Sentry organization slug - SENTRY_E2E_PROJECT: Sentry project slug +- SENTRY_E2E_ANDROID_BUILD_DIR: Optional build directory of the prepared + Android fixture APKs (defaults to build/android-e2e). Skip these tests if env vars not set (for regular CI runs). """ @@ -18,18 +20,21 @@ import subprocess import sys import time +import uuid +from pathlib import Path from urllib.parse import urlsplit, urlunsplit import pytest import requests -from . import run, check_output +from . import adb, run, check_output, sourcedir from .conditions import ( has_breakpad, has_crashpad, has_http, has_native, is_asan, + is_android, ) # Skip all tests if E2E env vars not configured @@ -39,7 +44,7 @@ reason="E2E tests require SENTRY_E2E_DSN environment variable", ), pytest.mark.skipif( - not has_http, + not has_http and not is_android, reason="E2E tests require http transport", ), ] @@ -384,6 +389,7 @@ def run_crash_e2e(tmp_path, exe, args, env, wait_for_daemon=False): not has_native, reason="E2E crash mode tests require native backend", ) +@pytest.mark.skipif(is_android, reason="Standalone E2E tests require http transport") class TestE2ENative: """E2E tests for all 3 crash reporting modes against real Sentry.""" @@ -691,6 +697,7 @@ def test_default_mode_is_native_with_minidump_e2e(self): ) +@pytest.mark.skipif(is_android, reason="Standalone E2E tests require http transport") def test_e2e_inproc(cmake): """Verify that inproc can send a signal-handler crash event to Sentry.""" tmp_path = cmake(["sentry_test_integration"], {"SENTRY_BACKEND": "inproc"}) @@ -829,6 +836,7 @@ def test_e2e_crashpad(cmake): verify_no_thread_duplication(threads_data, "test_e2e_crashpad") +@pytest.mark.skipif(is_android, reason="Standalone E2E tests require http transport") def test_e2e_trace_propagation(cmake): """Verify that Sentry links the events of an upstream and a downstream app when the downstream app continues the trace from the upstream headers.""" @@ -863,3 +871,195 @@ def test_e2e_trace_propagation(cmake): assert downstream_trace["trace_id"] == upstream_trace["trace_id"] assert downstream_trace["parent_span_id"] == upstream_trace["span_id"] + + +@pytest.fixture(scope="module") +def android_e2e_app(request): + get_sentry_headers() + get_sentry_org_project() + build_dir = ( + Path( + os.environ.get( + "SENTRY_E2E_ANDROID_BUILD_DIR", Path(sourcedir) / "build/android-e2e" + ) + ) + .expanduser() + .resolve() + ) + build_type = getattr(request, "param", "debug") + apk = build_dir / f"app/build/outputs/apk/{build_type}/app-{build_type}.apk" + assert apk.is_file(), "Prepare the APK with scripts/setup-android-e2e.py first" + package = "io.sentry.e2e" + adb("install", "-r", str(apk), check=True) + yield package + adb("uninstall", package, check=False) + + +def assert_android_native_event(event, test_id, tombstone, debuggable=True): + assert event["platform"] == "native" + assert event["release"]["version"] == "sentry-native-e2e" + assert event["user"]["id"] == test_id + + exception = get_exception_from_event(event)["values"][0] + mechanism = exception["mechanism"] + assert mechanism["handled"] is False + if tombstone: + assert mechanism["type"] == "TombstoneMerged" + else: + assert mechanism["type"] == "signalhandler" + frames = exception["stacktrace"]["frames"] + # the frame-pointer walk stops before libart in non-debuggable apps + assert len(frames) >= (3 if debuggable else 2) + if tombstone: + functions = [frame.get("function") or "" for frame in frames] + assert {"e2e_segfault", "e2e_native_crash"}.issubset(functions) + assert any( + "io.sentry.e2e.MainActivity.triggerNativeCrash" in function + for function in functions + ) + assert any( + "io.sentry.e2e.MainActivity.callNativeCrash" in function + for function in functions + ) + + threads = get_threads_from_event(event) + # a single thread means the daemon could not read the app's /proc + assert len(threads["values"]) >= 2 + assert any(thread["crashed"] for thread in threads["values"]) + verify_no_thread_duplication(threads, "test_e2e_android_native") + + images = get_debug_meta_from_event(event)["images"] + # default packaging maps the libraries straight from the APK + assert any( + image["code_file"].endswith(("libe2e.so", "/base.apk")) for image in images + ) + + +@pytest.mark.skipif( + not is_android or not has_native, reason="Tests need Android + native" +) +@pytest.mark.skipif( + is_android < 30, reason="The native crash service needs Android API 30+" +) +@pytest.mark.parametrize( + "android_e2e_app, debuggable", + [("debug", True), ("nondebuggable", False)], + ids=["debuggable", "nondebuggable"], + indirect=["android_e2e_app"], + scope="module", +) +@pytest.mark.parametrize("tombstone", [False, True], ids=["native", "tombstone"]) +def test_e2e_android_native(android_e2e_app, debuggable, tombstone): + """The crash service sends native crashes, optionally merged with a tombstone.""" + if tombstone and is_android < 31: + pytest.skip("Tombstone merging needs Android API 31+") + test_id = str(uuid.uuid4()) + package = android_e2e_app + adb("shell", "pm", "clear", package, check=True, capture_output=True) + try: + adb( + "shell", + "am", + "start", + "-n", + f"{package}/.MainActivity", + "--es", + "dsn", + os.environ["SENTRY_E2E_DSN"], + "--es", + "test_id", + test_id, + "--ez", + "tombstone", + str(tombstone).lower(), + check=True, + capture_output=True, + ) + # no restart: delivery must happen from the surviving crash service + event = poll_sentry_for_event(test_id) + assert_android_native_event(event, test_id, tombstone, debuggable) + finally: + adb("shell", "am", "force-stop", package, check=False) + + +@pytest.mark.skipif( + not is_android or not has_native, reason="Tests need Android + native" +) +@pytest.mark.skipif( + is_android < 30, reason="The native crash service needs Android API 30+" +) +def test_e2e_android_native_broadcast(android_e2e_app): + """A process that a broadcast starts in the background gets the crash service.""" + test_id = str(uuid.uuid4()) + package = android_e2e_app + adb("shell", "pm", "clear", package, check=True, capture_output=True) + try: + adb( + "shell", + "am", + "broadcast", + "--include-stopped-packages", + "-n", + f"{package}/.CrashReceiver", + "--es", + "dsn", + os.environ["SENTRY_E2E_DSN"], + "--es", + "test_id", + test_id, + check=True, + capture_output=True, + ) + event = poll_sentry_for_event(test_id) + assert_android_native_event(event, test_id, False) + finally: + adb("shell", "am", "force-stop", package, check=False) + + +@pytest.mark.skipif( + not is_android or not has_native, reason="Tests need Android + native" +) +@pytest.mark.skipif( + is_android < 30, reason="The native crash service needs Android API 30+" +) +def test_e2e_android_native_background(android_e2e_app): + """The crash service outlives the idle timeout of background services.""" + test_id = str(uuid.uuid4()) + package = android_e2e_app + adb("shell", "pm", "clear", package, check=True, capture_output=True) + try: + adb( + "shell", + "am", + "start", + "-W", + "-n", + f"{package}/.MainActivity", + "--es", + "dsn", + os.environ["SENTRY_E2E_DSN"], + "--es", + "test_id", + test_id, + "--ez", + "crash", + "false", + check=True, + capture_output=True, + ) + adb("shell", "input", "keyevent", "KEYCODE_HOME", check=True) + # Android stops idle background services after about a minute + time.sleep(90) + adb( + "shell", + "am", + "broadcast", + "-n", + f"{package}/.CrashReceiver", + check=True, + capture_output=True, + ) + event = poll_sentry_for_event(test_id) + assert_android_native_event(event, test_id, False) + finally: + adb("shell", "am", "force-stop", package, check=False) diff --git a/tests/test_integration_android.py b/tests/test_integration_android.py new file mode 100644 index 0000000000..535fc85193 --- /dev/null +++ b/tests/test_integration_android.py @@ -0,0 +1,66 @@ +import time + +import pytest + +from . import adb, run +from .conditions import has_native, is_android + +pytestmark = pytest.mark.skipif( + not is_android, + reason="Tests need Android", +) + + +@pytest.mark.skipif( + not has_native, + reason="Tests need the native backend enabled", +) +@pytest.mark.skipif( + 0 < is_android < 23, + reason="Android native backend test needs API 23+ (process_vm_readv)", +) +def test_native_android(cmake): + database_path = "/data/local/tmp/.sentry-native" + + def find_minidumps(timeout=10): + deadline = time.time() + timeout + last_result = None + while last_result is None or time.time() < deadline: + last_result = adb( + "shell", + f'for f in {database_path}/*.run/__sentry-crash.dmp; do [ -f "$f" ] && echo "$f"; done', + capture_output=True, + text=True, + check=False, + ) + if last_result.returncode == 0 and last_result.stdout.strip(): + return [ + line.strip() + for line in last_result.stdout.splitlines() + if line.strip() + ] + time.sleep(0.5) + if last_result is not None: + print(last_result.stdout) + print(last_result.stderr) + return [] + + tmp_path = cmake( + ["sentry_test_integration"], + {"SENTRY_BACKEND": "native", "SENTRY_TRANSPORT": "none"}, + ) + + adb("shell", f"rm -rf {database_path}", check=True) + assert find_minidumps(timeout=0) == [] + + try: + run(tmp_path, "sentry_test_integration", ["log", "crash"], expect_failure=True) + + minidumps = find_minidumps() + assert minidumps, "native backend should create a minidump on Android" + + local_minidump = tmp_path / "android.dmp" + adb("pull", minidumps[0], str(local_minidump), check=True, capture_output=True) + assert local_minidump.stat().st_size > 0 + finally: + adb("shell", f"rm -rf {database_path}", check=False) diff --git a/tests/test_integration_logger.py b/tests/test_integration_logger.py index e752c4a732..6105019c49 100644 --- a/tests/test_integration_logger.py +++ b/tests/test_integration_logger.py @@ -129,7 +129,8 @@ def parse_logger_output(output): "native", marks=[ pytest.mark.skipif( - not has_native or is_qemu, reason="native backend not available" + not has_native or is_qemu or is_android, + reason="native backend not available", ), ], ), @@ -177,7 +178,8 @@ def test_logger_enabled_when_crashed(backend, cmake): "native", marks=[ pytest.mark.skipif( - not has_native or is_qemu, reason="native backend not available" + not has_native or is_qemu or is_android, + reason="native backend not available", ), ], ), diff --git a/tests/test_integration_macos_sandbox.py b/tests/test_integration_macos_sandbox.py index 90a3bab838..7d3013f615 100644 --- a/tests/test_integration_macos_sandbox.py +++ b/tests/test_integration_macos_sandbox.py @@ -18,10 +18,10 @@ from . import make_dsn, Envelope from .assertions import wait_for_file -from .conditions import has_native +from .conditions import has_native, is_android pytestmark = pytest.mark.skipif( - not (has_native and sys.platform == "darwin"), + not (has_native and sys.platform == "darwin" and not is_android), reason="macOS App Sandbox tests require native backend on macOS", ) diff --git a/tests/test_integration_native.py b/tests/test_integration_native.py index 2aa36c8ab0..a739f1025c 100644 --- a/tests/test_integration_native.py +++ b/tests/test_integration_native.py @@ -44,13 +44,13 @@ assert_user_feedback, assert_crash_stack, ) -from .conditions import has_files, has_system_proxy, is_arm64e, is_kcov +from .conditions import has_files, has_http, has_system_proxy, is_arm64e, is_kcov from .conditions import has_native, has_oom, is_asan, is_tsan, is_qemu, is_wine from .proxy import proxy_test_finally, start_proxy, system_proxy pytestmark = pytest.mark.skipif( - not has_native or is_qemu, - reason="Tests need the native backend enabled", + not has_native or not has_http or is_qemu, + reason="Tests need the native backend and HTTP support", ) # Sanitizer builds are slower, so selected native crash tests use the same 10s diff --git a/tests/unit/test_native_backend.c b/tests/unit/test_native_backend.c index ee586a711c..88f5dc7a65 100644 --- a/tests/unit/test_native_backend.c +++ b/tests/unit/test_native_backend.c @@ -1337,7 +1337,11 @@ SENTRY_TEST(native_scope_updates) static sentry_crash_ipc_t * new_test_ipc(void) { +# if defined(SENTRY_PLATFORM_ANDROID) + sentry_crash_ipc_t *ipc = sentry__crash_ipc_init_app(".", NULL); +# else sentry_crash_ipc_t *ipc = sentry__crash_ipc_init_app(NULL); +# endif TEST_ASSERT(ipc != NULL); TEST_ASSERT(sentry__crash_scope_init(&ipc->scope, 2)); sentry_value_t snapshot = scope_snapshot(); diff --git a/tests/unit/test_retry.c b/tests/unit/test_retry.c index f5a6e8155f..3804e777a9 100644 --- a/tests/unit/test_retry.c +++ b/tests/unit/test_retry.c @@ -672,3 +672,29 @@ SENTRY_TEST(retry_consent) sentry__retry_free(retry); sentry_close(); } + +SENTRY_TEST(transport_dump_failed) +{ + SENTRY_TEST_OPTIONS_NEW(options); + sentry_options_set_dsn(options, "https://foo@sentry.invalid/42"); + sentry_transport_t *transport + = sentry__http_transport_new(NULL, test_http_send_fails); + TEST_ASSERT(!!transport); + sentry_options_set_transport(options, transport); + sentry_init(options); + sentry__http_transport_set_dump_failed(transport, true); + + sentry_path_t *run_path = sentry__path_clone(options->run->run_path); + TEST_ASSERT(!!run_path); + TEST_CHECK_INT_EQUAL(count_envelope_files(run_path), 0); + + sentry_capture_event( + sentry_value_new_message_event(SENTRY_LEVEL_INFO, NULL, "test")); + sentry_flush(5000); + + TEST_CHECK_INT_EQUAL(count_envelope_files(run_path), 1); + + sentry_close(); + sentry__path_remove_all(run_path); + sentry__path_free(run_path); +} diff --git a/tests/unit/tests.inc b/tests/unit/tests.inc index 3dbb8d3e31..4615f74c83 100644 --- a/tests/unit/tests.inc +++ b/tests/unit/tests.inc @@ -559,6 +559,7 @@ XX(traceparent_header_disabled_by_default) XX(traceparent_header_generation) XX(transaction_name_backfill_on_finish) XX(transactions_skip_before_send) +XX(transport_dump_failed) XX(transport_retry) XX(transport_sampling_transactions) XX(transport_sampling_transactions_set_trace)