diff --git a/.github/scripts/android-native-crash.sh b/.github/scripts/android-native-crash.sh new file mode 100755 index 00000000..18a5701b --- /dev/null +++ b/.github/scripts/android-native-crash.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Asserts a native crash produces a minidump carrying an attribute set after init. +set -euo pipefail + +PACKAGE="com.reactnative" +ACTIVITY="$PACKAGE/.MainActivity" +APK="examples/sdk/reactNative/android/app/build/outputs/apk/release/app-release.apk" +MARKER="ci-marker-$(date +%s)" +TRIGGER_URL="backtrace-example://ci-native-crash?marker=$MARKER" +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +wait_for_log() { + local pattern="$1" deadline="$2" + for _ in $(seq 1 "$deadline"); do + if adb logcat -d | grep -qE "$pattern"; then + return 0 + fi + sleep 1 + done + + echo "::error::timed out after ${deadline}s waiting for: $pattern" + adb logcat -d | tail -50 + return 1 +} + +adb wait-for-device +adb install -r "$APK" +adb shell pm clear "$PACKAGE" >/dev/null +adb logcat -c + +echo "device abis: $(adb shell getprop ro.product.cpu.abilist | tr -d '\r')" +adb shell am start -n "$ACTIVITY" >/dev/null +echo "app abi:$(adb shell dumpsys package "$PACKAGE" | grep -m1 primaryCpuAbi | tr -d '\r' | cut -d= -f2)" + +wait_for_log "Initializing native crash reporter" 120 +wait_for_log "BT_CI_DRIVER_ARMED" 60 + +# Inner quotes survive to the device shell, which would otherwise glob the ? in the URL. +adb shell am start -n "$ACTIVITY" -a android.intent.action.VIEW -d "'$TRIGGER_URL'" >/dev/null +echo "marker set after init: ci.marker=$MARKER" + +DUMP="" +PULLED="" +# The uploader can move a dump out of pending/ between finding and reading it, so re-find on every try. +# exec-out, not shell: a pty mangles binary and would corrupt the minidump. +for _ in $(seq 1 180); do + DUMP="$(adb shell run-as "$PACKAGE" find files/backtrace/native -name '*.dmp' 2>/dev/null | tr -d '\r' | head -1 || true)" + if [ -n "$DUMP" ] && adb exec-out run-as "$PACKAGE" cat "$DUMP" > /tmp/native-crash.dmp 2>/dev/null; then + ON_DEVICE_SIZE="$(adb shell run-as "$PACKAGE" stat -c %s "$DUMP" 2>/dev/null | tr -d '\r' || true)" + PULLED_SIZE="$(wc -c < /tmp/native-crash.dmp | tr -d ' ')" + if [ -n "$ON_DEVICE_SIZE" ] && [ "$ON_DEVICE_SIZE" = "$PULLED_SIZE" ]; then + PULLED=1 + break + fi + fi + sleep 1 +done + +if [ -z "$PULLED" ]; then + if [ -z "$DUMP" ]; then + echo "::error::no minidump was written" + if adb shell pidof "$PACKAGE" >/dev/null 2>&1; then + echo "::error::the app is still running, so the crash never fired" + fi + adb shell run-as "$PACKAGE" ls -R files/backtrace 2>&1 || true + adb logcat -d | grep -iE "backtrace|crashpad|SIGSEGV|BT_CI_DRIVER" | tail -30 + else + echo "::error::could not pull a stable copy of $DUMP" + fi + exit 1 +fi +echo "minidump: $DUMP" +echo "minidump pulled: $PULLED_SIZE bytes" + +adb logcat -d > /tmp/logcat.txt + +MARKER="$MARKER" python3 "$HERE/check-minidump-annotations.py" /tmp/native-crash.dmp diff --git a/.github/scripts/check-minidump-annotations.py b/.github/scripts/check-minidump-annotations.py new file mode 100644 index 00000000..fc09bbd6 --- /dev/null +++ b/.github/scripts/check-minidump-annotations.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Asserts a minidump carries the per-run marker annotation, key and value.""" +import os +import re +import struct +import sys + +EXPECTED_KEY = "ci.marker" +KNOWN_KEYS = ( + "application", + "application.version", + "backtrace.agent", + "backtrace.version", + "device.model", + "error.type", + "guid", + "uname.sysname", +) + + +def length_prefixed(data, decoder, width): + """Crashpad writes annotations as uint32 byte-length followed by the string.""" + out = set() + for match in re.finditer(rb"(?=(....))", data, re.S): + (declared,) = struct.unpack("'}") + + checks = ( + (f"post-init key {EXPECTED_KEY}", EXPECTED_KEY in strings), + (f"post-init value {expected}", expected in strings), + # Set at init through userAttributes in the example, so it asserts init-time propagation. + ("init-time key custom-attribute", "custom-attribute" in strings), + ) + missing = [what for what, present in checks if not present] + if not missing: + print(f"minidump carries the init-time and post-init attributes ({EXPECTED_KEY}={expected})") + return 0 + + print(f"::error::minidump is missing: {', '.join(missing)}") + if not keys: + print("::error::no known annotation keys either, so the dump carries no attributes at all") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/ci-crash-driver.js b/.github/scripts/ci-crash-driver.js new file mode 100644 index 00000000..99b3d8b1 --- /dev/null +++ b/.github/scripts/ci-crash-driver.js @@ -0,0 +1,15 @@ +// Appended to the example's index.js by the Android CI workflow. Never committed to the example. +import { Linking } from 'react-native'; + +Linking.addEventListener('url', ({ url }) => { + const match = /^backtrace-example:\/\/ci-native-crash\?marker=([A-Za-z0-9-]+)$/.exec(url ?? ''); + if (!match) { + return; + } + console.log(`BT_CI_DRIVER firing: ${url}`); + const client = BacktraceClient.instance; + client.addAttribute({ 'ci.marker': match[1] }); + client.crash(); +}); + +console.log('BT_CI_DRIVER_ARMED'); diff --git a/.github/scripts/verify-jni-symbols.sh b/.github/scripts/verify-jni-symbols.sh new file mode 100755 index 00000000..6732b1a0 --- /dev/null +++ b/.github/scripts/verify-jni-symbols.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Fails when an ABI is missing from the APK, or its library lacks the JNI entry points. +set -euo pipefail + +APK="${1:?usage: $0 }" +ABIS=${ABIS:-"arm64-v8a armeabi-v7a x86 x86_64"} +SYMBOLS_BOUND_AT_RUNTIME="Java_backtraceio_library_nativeCalls_BacktraceCrashHandler_initializeJavaCrashHandler Java_backtraceio_library_nativeCalls_BacktraceCrashHandler_handleCrash Java_backtraceio_library_BacktraceDatabase_addAttribute Java_backtraceio_library_base_BacktraceBase_crash" +SYMBOLS_PROVING_LIBRARY_VERSION="Java_backtraceio_library_BacktraceDatabase_addAttachment" +SYMBOLS=${SYMBOLS:-"$SYMBOLS_BOUND_AT_RUNTIME $SYMBOLS_PROVING_LIBRARY_VERSION"} + +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +status=0 +for abi in $ABIS; do + lib="lib/$abi/libbacktrace-native.so" + if ! unzip -o -q "$APK" "$lib" -d "$WORK" 2>/dev/null; then + echo "::error::$abi: libbacktrace-native.so missing from the APK" + status=1 + continue + fi + + # Extract once: piping into `grep -q` under pipefail fails on SIGPIPE. + strings -a "$WORK/$lib" > "$WORK/$abi.strings" + + missing=0 + for symbol in $SYMBOLS; do + if ! grep -qF "$symbol" "$WORK/$abi.strings"; then + echo "::error::$abi: missing JNI symbol $symbol" + missing=1 + status=1 + fi + done + + if [ "$missing" -eq 0 ]; then + echo "$abi ok ($(wc -c < "$WORK/$lib") bytes)" + fi +done + +exit $status diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml new file mode 100644 index 00000000..a26ff514 --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,126 @@ +name: Android CI + +on: + push: + branches: [main, dev] + paths: + - packages/react-native/** + - packages/sdk-core/** + - examples/sdk/reactNative/** + - .github/workflows/android.yml + - .github/scripts/** + pull_request: + paths: + - packages/react-native/** + - packages/sdk-core/** + - examples/sdk/reactNative/** + - .github/workflows/android.yml + - .github/scripts/** + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + native_crash: + runs-on: ubuntu-latest + timeout-minutes: 30 + + strategy: + fail-fast: false + matrix: + api-level: [34] + arch: [x86_64] + new-arch: [true, false] + + steps: + - uses: actions/checkout@v4 + + - name: Verify 16KB alignment + run: | + sudo apt-get install -y binutils + bash scripts/verify-elf-16k.sh + + - name: Use Node.js 20.x + uses: actions/setup-node@v4 + with: + node-version: 20.x + cache: npm + cache-dependency-path: | + package-lock.json + examples/sdk/reactNative/package-lock.json + + - name: Use Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: gradle + + - run: npm ci + - run: npm run build + + - name: Install example dependencies + working-directory: examples/sdk/reactNative + # npm ci fails after an SDK version bump, the lock records the linked package's version. + run: npm install --no-audit --no-fund + + - name: Inject the CI crash driver into the example entry point + run: cat .github/scripts/ci-crash-driver.js >> examples/sdk/reactNative/index.js + + - name: Build example app + working-directory: examples/sdk/reactNative/android + run: ./gradlew assembleRelease -PbtCiDebuggable -PnewArchEnabled=${{ matrix.new-arch }} -x uploadSourceMapsToBacktrace --console=plain + + - name: Verify native libraries and JNI symbols + run: bash .github/scripts/verify-jni-symbols.sh examples/sdk/reactNative/android/app/build/outputs/apk/release/app-release.apk + + - name: Enable KVM + 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: AVD cache + uses: actions/cache@v4 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }}-${{ matrix.arch }} + + - name: Create AVD snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: ${{ matrix.arch }} + target: google_apis + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: false + script: echo "Generated AVD snapshot for caching." + + - name: Capture a native crash on the emulator + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: ${{ matrix.arch }} + target: google_apis + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: bash .github/scripts/android-native-crash.sh + + - name: Upload crash evidence + if: failure() + uses: actions/upload-artifact@v4 + with: + name: native-crash-api${{ matrix.api-level }}-${{ matrix.arch }}-newarch-${{ matrix.new-arch }} + path: | + /tmp/native-crash.dmp + /tmp/logcat.txt + if-no-files-found: warn + retention-days: 7 diff --git a/examples/sdk/reactNative/android/app/build.gradle b/examples/sdk/reactNative/android/app/build.gradle index bd843f4a..55a7af09 100644 --- a/examples/sdk/reactNative/android/app/build.gradle +++ b/examples/sdk/reactNative/android/app/build.gradle @@ -115,6 +115,8 @@ android { // Caution! In production, you need to generate your own keystore file. // see https://reactnative.dev/docs/signed-apk-android. signingConfig signingConfigs.debug + // CI-only, so run-as can read the crashpad database. + debuggable project.hasProperty("btCiDebuggable") minifyEnabled enableProguardInReleaseBuilds proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" }