From edc823746c1d799aad05647fc25edcab1c613cbf Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Thu, 20 Aug 2026 19:59:24 +0200 Subject: [PATCH 1/6] ci: add Android emulator job covering native crash capture --- .github/scripts/android-native-crash.sh | 82 +++++++++++++++++++ .github/scripts/verify-jni-symbols.sh | 40 +++++++++ .github/workflows/android.yml | 73 +++++++++++++++++ .../sdk/reactNative/android/app/build.gradle | 2 + 4 files changed, 197 insertions(+) create mode 100755 .github/scripts/android-native-crash.sh create mode 100755 .github/scripts/verify-jni-symbols.sh create mode 100644 .github/workflows/android.yml diff --git a/.github/scripts/android-native-crash.sh b/.github/scripts/android-native-crash.sh new file mode 100755 index 00000000..d5720392 --- /dev/null +++ b/.github/scripts/android-native-crash.sh @@ -0,0 +1,82 @@ +#!/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" + +tap_by_label() { + local label="$1" + adb shell uiautomator dump /sdcard/ui.xml >/dev/null + + # `|| true` so a missing button reports the labels it found instead of failing bare. + local bounds + bounds="$(adb shell cat /sdcard/ui.xml \ + | tr '>' '\n' \ + | grep "content-desc=\"$label\"" \ + | grep -oE 'bounds="\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]"' \ + | head -1 \ + | grep -oE '[0-9]+' \ + | tr '\n' ' ' || true)" + + if [ -z "$bounds" ]; then + echo "::error::could not find the '$label' button" + echo "labels present on screen:" + adb shell cat /sdcard/ui.xml | tr '>' '\n' | grep -oE 'content-desc="[^"]+"' | sort -u || true + return 1 + fi + + # shellcheck disable=SC2086 + set -- $bounds + adb shell input tap $(((${1} + ${3}) / 2)) $(((${2} + ${4}) / 2)) +} + +adb wait-for-device +adb install -r "$APK" + +adb shell pm clear "$PACKAGE" >/dev/null +adb logcat -c +adb shell am start -n "$ACTIVITY" >/dev/null +sleep 15 + +if ! adb logcat -d | grep -q "Initializing native crash reporter"; then + echo "::error::native crash reporter did not initialize" + adb logcat -d | tail -50 + exit 1 +fi + +tap_by_label "Update a time attribute" +sleep 5 + +ATTRIBUTE="$(adb logcat -d | grep -oE "Setting a time attribute to [0-9]+" | tail -1 | grep -oE "[0-9]+" || true)" +if [ -z "$ATTRIBUTE" ]; then + echo "::error::the app did not report setting a time attribute" + adb logcat -d | grep -i reactnativejs | tail -20 + exit 1 +fi +echo "attribute set after init: time=$ATTRIBUTE" + +tap_by_label "Crash application" +sleep 15 + +if adb shell pidof "$PACKAGE" >/dev/null 2>&1; then + echo "::error::app did not crash" + exit 1 +fi + +DUMP="$(adb shell run-as "$PACKAGE" find files/backtrace/native -name '*.dmp' 2>/dev/null | tr -d '\r' | head -1)" +if [ -z "$DUMP" ]; then + echo "::error::no minidump was written" + adb shell run-as "$PACKAGE" ls -R files/backtrace 2>&1 || true + adb logcat -d | grep -iE "backtrace|crashpad|SIGSEGV" | tail -30 + exit 1 +fi +echo "minidump written: $DUMP" + +adb shell run-as "$PACKAGE" cat "$DUMP" > /tmp/native-crash.dmp +if ! strings -a /tmp/native-crash.dmp | grep -qF "$ATTRIBUTE"; then + echo "::error::minidump does not carry the attribute set after init (time=$ATTRIBUTE)" + exit 1 +fi +echo "minidump carries the post-init attribute" 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..28b98a0b --- /dev/null +++ b/.github/workflows/android.yml @@ -0,0 +1,73 @@ +name: Android CI + +on: + push: + branches: [main, dev] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +jobs: + native_crash: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + # x86 has no crash backend upstream. + 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 + + - name: Use Java 17 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + + - run: npm ci + - run: npm run build + + - name: Install example dependencies + working-directory: examples/sdk/reactNative + run: npm install --no-audit --no-fund + + - 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: 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-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: bash .github/scripts/android-native-crash.sh 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" } From 55b73459fbfc350f604e0f1b84e01e7c4beee1d3 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Thu, 20 Aug 2026 20:51:48 +0200 Subject: [PATCH 2/6] ci: dismiss the example app startup dialog before driving it --- .github/scripts/android-native-crash.sh | 57 +++++++++++++++++-------- 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/.github/scripts/android-native-crash.sh b/.github/scripts/android-native-crash.sh index d5720392..fcb0b8be 100755 --- a/.github/scripts/android-native-crash.sh +++ b/.github/scripts/android-native-crash.sh @@ -5,31 +5,50 @@ set -euo pipefail PACKAGE="com.reactnative" ACTIVITY="$PACKAGE/.MainActivity" APK="examples/sdk/reactNative/android/app/build/outputs/apk/release/app-release.apk" +UI=/tmp/ui-hierarchy.txt -tap_by_label() { - local label="$1" - adb shell uiautomator dump /sdcard/ui.xml >/dev/null +dump_ui() { + for _ in 1 2 3; do + adb shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true + adb shell cat /sdcard/ui.xml 2>/dev/null | tr '>' '\n' > "$UI" || true + if [ -s "$UI" ]; then + return 0 + fi + sleep 5 + done - # `|| true` so a missing button reports the labels it found instead of failing bare. + echo "::error::could not read the UI hierarchy" + return 1 +} + +tap_node() { + local attribute="$1" value="$2" optional="${3:-required}" + dump_ui + + # `|| true` so a missing node reports what was on screen instead of failing bare. local bounds - bounds="$(adb shell cat /sdcard/ui.xml \ - | tr '>' '\n' \ - | grep "content-desc=\"$label\"" \ + bounds="$(grep "$attribute=\"$value\"" "$UI" \ | grep -oE 'bounds="\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]"' \ | head -1 \ | grep -oE '[0-9]+' \ | tr '\n' ' ' || true)" if [ -z "$bounds" ]; then - echo "::error::could not find the '$label' button" - echo "labels present on screen:" - adb shell cat /sdcard/ui.xml | tr '>' '\n' | grep -oE 'content-desc="[^"]+"' | sort -u || true + if [ "$optional" = "optional" ]; then + return 0 + fi + echo "::error::could not find $attribute=\"$value\"" + echo "content-desc on screen:" + grep -oE 'content-desc="[^"]+"' "$UI" | sort -u || true + echo "text on screen:" + grep -oE 'text="[^"]+"' "$UI" | sort -u | head -20 || true return 1 fi - # shellcheck disable=SC2086 - set -- $bounds - adb shell input tap $(((${1} + ${3}) / 2)) $(((${2} + ${4}) / 2)) + local x1 y1 x2 y2 + read -r x1 y1 x2 y2 <<<"$bounds" + adb shell input tap $(((x1 + x2) / 2)) $(((y1 + y2) / 2)) + sleep 2 } adb wait-for-device @@ -38,7 +57,7 @@ adb install -r "$APK" adb shell pm clear "$PACKAGE" >/dev/null adb logcat -c adb shell am start -n "$ACTIVITY" >/dev/null -sleep 15 +sleep 20 if ! adb logcat -d | grep -q "Initializing native crash reporter"; then echo "::error::native crash reporter did not initialize" @@ -46,8 +65,11 @@ if ! adb logcat -d | grep -q "Initializing native crash reporter"; then exit 1 fi -tap_by_label "Update a time attribute" -sleep 5 +# The example warns about an unset submission url on startup, which covers the buttons. +tap_node text OK optional + +tap_node content-desc "Update a time attribute" +sleep 3 ATTRIBUTE="$(adb logcat -d | grep -oE "Setting a time attribute to [0-9]+" | tail -1 | grep -oE "[0-9]+" || true)" if [ -z "$ATTRIBUTE" ]; then @@ -57,7 +79,8 @@ if [ -z "$ATTRIBUTE" ]; then fi echo "attribute set after init: time=$ATTRIBUTE" -tap_by_label "Crash application" +tap_node text OK optional +tap_node content-desc "Crash application" sleep 15 if adb shell pidof "$PACKAGE" >/dev/null 2>&1; then From c2c2fe697cf5da2913b9a2d79e887728471867e6 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Fri, 21 Aug 2026 14:20:57 +0200 Subject: [PATCH 3/6] ci: pull the minidump binary-safe, wait longer before crashing, report annotation keys on failure --- .github/scripts/android-native-crash.sh | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/.github/scripts/android-native-crash.sh b/.github/scripts/android-native-crash.sh index fcb0b8be..00298e7c 100755 --- a/.github/scripts/android-native-crash.sh +++ b/.github/scripts/android-native-crash.sh @@ -69,7 +69,8 @@ fi tap_node text OK optional tap_node content-desc "Update a time attribute" -sleep 3 +# useAttributes is an async bridge call, so give a slow emulator time to reach the JNI layer. +sleep 10 ATTRIBUTE="$(adb logcat -d | grep -oE "Setting a time attribute to [0-9]+" | tail -1 | grep -oE "[0-9]+" || true)" if [ -z "$ATTRIBUTE" ]; then @@ -97,9 +98,22 @@ if [ -z "$DUMP" ]; then fi echo "minidump written: $DUMP" -adb shell run-as "$PACKAGE" cat "$DUMP" > /tmp/native-crash.dmp +# exec-out, not shell: a pty mangles binary and would corrupt the minidump. +adb exec-out run-as "$PACKAGE" cat "$DUMP" > /tmp/native-crash.dmp +ON_DEVICE_SIZE="$(adb shell run-as "$PACKAGE" stat -c %s "$DUMP" 2>/dev/null | tr -d '\r')" +PULLED_SIZE="$(wc -c < /tmp/native-crash.dmp | tr -d ' ')" +if [ "$ON_DEVICE_SIZE" != "$PULLED_SIZE" ]; then + echo "::error::minidump transfer is incomplete: $PULLED_SIZE of $ON_DEVICE_SIZE bytes" + exit 1 +fi +echo "minidump pulled: $PULLED_SIZE bytes" + if ! strings -a /tmp/native-crash.dmp | grep -qF "$ATTRIBUTE"; then echo "::error::minidump does not carry the attribute set after init (time=$ATTRIBUTE)" + echo "annotation keys present in the minidump:" + strings -a /tmp/native-crash.dmp \ + | grep -xE "time|guid|application|application\.version|backtrace\.agent|backtrace\.version|error\.type|uname\.sysname" \ + | sort -u || true exit 1 fi echo "minidump carries the post-init attribute" From c701d3bd2ca29ace4652c5744dfc9e6922f94e38 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Fri, 21 Aug 2026 15:11:45 +0200 Subject: [PATCH 4/6] ci: assert minidump annotations with a self-validating checker, upload the dump on failure --- .github/scripts/android-native-crash.sh | 17 ++--- .github/scripts/check-minidump-annotations.py | 68 +++++++++++++++++++ .github/workflows/android.yml | 12 ++++ 3 files changed, 87 insertions(+), 10 deletions(-) create mode 100644 .github/scripts/check-minidump-annotations.py diff --git a/.github/scripts/android-native-crash.sh b/.github/scripts/android-native-crash.sh index 00298e7c..d347adf6 100755 --- a/.github/scripts/android-native-crash.sh +++ b/.github/scripts/android-native-crash.sh @@ -6,6 +6,7 @@ PACKAGE="com.reactnative" ACTIVITY="$PACKAGE/.MainActivity" APK="examples/sdk/reactNative/android/app/build/outputs/apk/release/app-release.apk" UI=/tmp/ui-hierarchy.txt +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" dump_ui() { for _ in 1 2 3; do @@ -65,11 +66,13 @@ if ! adb logcat -d | grep -q "Initializing native crash reporter"; then exit 1 fi +echo "device abis: $(adb shell getprop ro.product.cpu.abilist | tr -d '\r')" +echo "app abi:$(adb shell dumpsys package "$PACKAGE" | grep -m1 primaryCpuAbi | tr -d '\r' | cut -d= -f2)" + # The example warns about an unset submission url on startup, which covers the buttons. tap_node text OK optional tap_node content-desc "Update a time attribute" -# useAttributes is an async bridge call, so give a slow emulator time to reach the JNI layer. sleep 10 ATTRIBUTE="$(adb logcat -d | grep -oE "Setting a time attribute to [0-9]+" | tail -1 | grep -oE "[0-9]+" || true)" @@ -108,12 +111,6 @@ if [ "$ON_DEVICE_SIZE" != "$PULLED_SIZE" ]; then fi echo "minidump pulled: $PULLED_SIZE bytes" -if ! strings -a /tmp/native-crash.dmp | grep -qF "$ATTRIBUTE"; then - echo "::error::minidump does not carry the attribute set after init (time=$ATTRIBUTE)" - echo "annotation keys present in the minidump:" - strings -a /tmp/native-crash.dmp \ - | grep -xE "time|guid|application|application\.version|backtrace\.agent|backtrace\.version|error\.type|uname\.sysname" \ - | sort -u || true - exit 1 -fi -echo "minidump carries the post-init attribute" +adb logcat -d > /tmp/logcat.txt + +ATTRIBUTE="$ATTRIBUTE" 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..09d559be --- /dev/null +++ b/.github/scripts/check-minidump-annotations.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Asserts a minidump carries an expected crashpad annotation value.""" +import os +import re +import struct +import sys + +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("'}") + + if expected in strings: + print(f"minidump carries the post-init attribute (time={expected})") + return 0 + + print(f"::error::minidump does not carry the attribute set after init (time={expected})") + 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/workflows/android.yml b/.github/workflows/android.yml index 28b98a0b..28c7ac93 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -71,3 +71,15 @@ jobs: emulator-options: -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 + /tmp/ui-hierarchy.txt + if-no-files-found: warn + retention-days: 7 From b5d4fc3e2fdca399c84193d814c4b8579243b0e9 Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Tue, 25 Aug 2026 15:58:30 +0200 Subject: [PATCH 5/6] react-native: cache CI toolchain and AVD, arm crash driver by handshake, add re-init spec --- .github/scripts/android-native-crash.sh | 127 ++++++------------ .github/scripts/check-minidump-annotations.py | 24 ++-- .github/scripts/ci-crash-driver.js | 15 +++ .github/workflows/android.yml | 46 ++++++- .../tests/crashReporterReinitTests.spec.ts | 69 ++++++++++ 5 files changed, 187 insertions(+), 94 deletions(-) create mode 100644 .github/scripts/ci-crash-driver.js create mode 100644 packages/react-native/tests/crashReporterReinitTests.spec.ts diff --git a/.github/scripts/android-native-crash.sh b/.github/scripts/android-native-crash.sh index d347adf6..18a5701b 100755 --- a/.github/scripts/android-native-crash.sh +++ b/.github/scripts/android-native-crash.sh @@ -5,112 +5,73 @@ set -euo pipefail PACKAGE="com.reactnative" ACTIVITY="$PACKAGE/.MainActivity" APK="examples/sdk/reactNative/android/app/build/outputs/apk/release/app-release.apk" -UI=/tmp/ui-hierarchy.txt +MARKER="ci-marker-$(date +%s)" +TRIGGER_URL="backtrace-example://ci-native-crash?marker=$MARKER" HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -dump_ui() { - for _ in 1 2 3; do - adb shell uiautomator dump /sdcard/ui.xml >/dev/null 2>&1 || true - adb shell cat /sdcard/ui.xml 2>/dev/null | tr '>' '\n' > "$UI" || true - if [ -s "$UI" ]; then +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 5 + sleep 1 done - echo "::error::could not read the UI hierarchy" + echo "::error::timed out after ${deadline}s waiting for: $pattern" + adb logcat -d | tail -50 return 1 } -tap_node() { - local attribute="$1" value="$2" optional="${3:-required}" - dump_ui - - # `|| true` so a missing node reports what was on screen instead of failing bare. - local bounds - bounds="$(grep "$attribute=\"$value\"" "$UI" \ - | grep -oE 'bounds="\[[0-9]+,[0-9]+\]\[[0-9]+,[0-9]+\]"' \ - | head -1 \ - | grep -oE '[0-9]+' \ - | tr '\n' ' ' || true)" - - if [ -z "$bounds" ]; then - if [ "$optional" = "optional" ]; then - return 0 - fi - echo "::error::could not find $attribute=\"$value\"" - echo "content-desc on screen:" - grep -oE 'content-desc="[^"]+"' "$UI" | sort -u || true - echo "text on screen:" - grep -oE 'text="[^"]+"' "$UI" | sort -u | head -20 || true - return 1 - fi - - local x1 y1 x2 y2 - read -r x1 y1 x2 y2 <<<"$bounds" - adb shell input tap $(((x1 + x2) / 2)) $(((y1 + y2) / 2)) - sleep 2 -} - adb wait-for-device adb install -r "$APK" - adb shell pm clear "$PACKAGE" >/dev/null adb logcat -c -adb shell am start -n "$ACTIVITY" >/dev/null -sleep 20 - -if ! adb logcat -d | grep -q "Initializing native crash reporter"; then - echo "::error::native crash reporter did not initialize" - adb logcat -d | tail -50 - exit 1 -fi 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)" -# The example warns about an unset submission url on startup, which covers the buttons. -tap_node text OK optional - -tap_node content-desc "Update a time attribute" -sleep 10 - -ATTRIBUTE="$(adb logcat -d | grep -oE "Setting a time attribute to [0-9]+" | tail -1 | grep -oE "[0-9]+" || true)" -if [ -z "$ATTRIBUTE" ]; then - echo "::error::the app did not report setting a time attribute" - adb logcat -d | grep -i reactnativejs | tail -20 - exit 1 -fi -echo "attribute set after init: time=$ATTRIBUTE" - -tap_node text OK optional -tap_node content-desc "Crash application" -sleep 15 +wait_for_log "Initializing native crash reporter" 120 +wait_for_log "BT_CI_DRIVER_ARMED" 60 -if adb shell pidof "$PACKAGE" >/dev/null 2>&1; then - echo "::error::app did not crash" - exit 1 -fi - -DUMP="$(adb shell run-as "$PACKAGE" find files/backtrace/native -name '*.dmp' 2>/dev/null | tr -d '\r' | head -1)" -if [ -z "$DUMP" ]; then - echo "::error::no minidump was written" - adb shell run-as "$PACKAGE" ls -R files/backtrace 2>&1 || true - adb logcat -d | grep -iE "backtrace|crashpad|SIGSEGV" | tail -30 - exit 1 -fi -echo "minidump written: $DUMP" +# 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. -adb exec-out run-as "$PACKAGE" cat "$DUMP" > /tmp/native-crash.dmp -ON_DEVICE_SIZE="$(adb shell run-as "$PACKAGE" stat -c %s "$DUMP" 2>/dev/null | tr -d '\r')" -PULLED_SIZE="$(wc -c < /tmp/native-crash.dmp | tr -d ' ')" -if [ "$ON_DEVICE_SIZE" != "$PULLED_SIZE" ]; then - echo "::error::minidump transfer is incomplete: $PULLED_SIZE of $ON_DEVICE_SIZE bytes" +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 -ATTRIBUTE="$ATTRIBUTE" python3 "$HERE/check-minidump-annotations.py" /tmp/native-crash.dmp +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 index 09d559be..fc09bbd6 100644 --- a/.github/scripts/check-minidump-annotations.py +++ b/.github/scripts/check-minidump-annotations.py @@ -1,10 +1,11 @@ #!/usr/bin/env python3 -"""Asserts a minidump carries an expected crashpad annotation value.""" +"""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", @@ -39,26 +40,31 @@ def length_prefixed(data, decoder, width): def main(): path = sys.argv[1] - expected = os.environ["ATTRIBUTE"] + expected = os.environ["MARKER"] data = open(path, "rb").read() if data[:4] != b"MDMP": print(f"::error::{path} is not a minidump (magic {data[:4]!r}, {len(data)} bytes)") return 1 - utf8 = length_prefixed(data, "ascii", 1) - utf16 = length_prefixed(data, "utf-16-le", 2) - strings = utf8 | utf16 + strings = length_prefixed(data, "ascii", 1) | length_prefixed(data, "utf-16-le", 2) keys = sorted(k for k in KNOWN_KEYS if k in strings) - print(f"minidump ok: {len(data)} bytes, {len(utf8)} utf-8 and {len(utf16)} utf-16 strings") + print(f"minidump ok: {len(data)} bytes, {len(strings)} length-prefixed strings") print(f"annotation keys found: {', '.join(keys) if keys else ''}") - if expected in strings: - print(f"minidump carries the post-init attribute (time={expected})") + 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 does not carry the attribute set after init (time={expected})") + 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 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/workflows/android.yml b/.github/workflows/android.yml index 28c7ac93..27b1cae5 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -3,7 +3,19 @@ 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: @@ -13,6 +25,7 @@ concurrency: jobs: native_crash: runs-on: ubuntu-latest + timeout-minutes: 30 strategy: fail-fast: false @@ -34,20 +47,29 @@ jobs: 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 + # install, not ci: the lock pins the file: SDK's version, so npm ci breaks on every version bump. 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 @@ -61,7 +83,17 @@ jobs: sudo udevadm control --reload-rules sudo udevadm trigger --name-match=kvm - - name: Capture a native crash on the emulator + - 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 }} @@ -69,6 +101,17 @@ jobs: 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 @@ -80,6 +123,5 @@ jobs: path: | /tmp/native-crash.dmp /tmp/logcat.txt - /tmp/ui-hierarchy.txt if-no-files-found: warn retention-days: 7 diff --git a/packages/react-native/tests/crashReporterReinitTests.spec.ts b/packages/react-native/tests/crashReporterReinitTests.spec.ts new file mode 100644 index 00000000..c2d015b3 --- /dev/null +++ b/packages/react-native/tests/crashReporterReinitTests.spec.ts @@ -0,0 +1,69 @@ +import { NativeModules } from 'react-native'; +import { mockStreamFileSystem } from './_mocks/fileSystem'; + +// Proof for the dispose/re-init defect: CrashReporter.initialized is static and never reset, +// so a client created after dispose() silently loses native crash reporting. +// These tests document CURRENT behavior; they are evidence, not a regression suite. + +jest.mock('react-native', () => ({ + NativeModules: {}, + Platform: { + OS: 'ios', + select: (options: Record) => (options.ios !== undefined ? options.ios : options.default), + }, +})); + +jest.mock('../src/common/platformHelper', () => ({ + version: () => '0.81.6', +})); + +const nativeMock = { + initialize: jest.fn(), + useAttributes: jest.fn(), + useAttachments: jest.fn(), + crash: jest.fn(), +}; + +NativeModules.BacktraceReactNative = nativeMock; +NativeModules.BacktraceDirectoryProvider = { applicationDirectory: () => '/' }; +(globalThis as unknown as { RN$Bridgeless: boolean }).RN$Bridgeless = true; + +/* eslint-disable @typescript-eslint/no-var-requires */ +const { BacktraceClient } = require('../src/BacktraceClient'); +/* eslint-enable @typescript-eslint/no-var-requires */ + +function createClient() { + return new BacktraceClient({ + options: { + url: 'https://submit.backtrace.io/universe/token/json', + database: { enable: true, captureNativeCrashes: true, path: '/backtrace' }, + metrics: { enable: false }, + breadcrumbs: { enable: false }, + userAttributes: { application: 'reinitProof', 'application.version': '1.0.0' }, + }, + fileSystem: mockStreamFileSystem(), + }); +} + +describe('CrashReporter dispose and re-initialize (current behavior proof)', () => { + it('Should never reinitialize the native crash reporter after dispose, and updates stay dead', () => { + const first = createClient(); + first.initialize(); + expect(nativeMock.initialize).toHaveBeenCalledTimes(1); + + first.addAttribute({ 'first.client': 'works' }); + expect(nativeMock.useAttributes).toHaveBeenCalled(); + + first.dispose(); + + const second = createClient(); + second.initialize(); + // Static guard: the second client never reaches native init. + expect(nativeMock.initialize).toHaveBeenCalledTimes(1); + + nativeMock.useAttributes.mockClear(); + second.addAttribute({ 'second.client': 'lost' }); + // _enabled stayed false on the second reporter, so native never hears about this. + expect(nativeMock.useAttributes).not.toHaveBeenCalled(); + }); +}); From e9689c43bde92db7399a07076ce1a3a816e409bd Mon Sep 17 00:00:00 2001 From: Kishan P Rao Date: Tue, 25 Aug 2026 16:15:29 +0200 Subject: [PATCH 6/6] react-native: drop unnecessary spec, update comments --- .github/workflows/android.yml | 3 +- .../tests/crashReporterReinitTests.spec.ts | 69 ------------------- 2 files changed, 1 insertion(+), 71 deletions(-) delete mode 100644 packages/react-native/tests/crashReporterReinitTests.spec.ts diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 27b1cae5..a26ff514 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -30,7 +30,6 @@ jobs: strategy: fail-fast: false matrix: - # x86 has no crash backend upstream. api-level: [34] arch: [x86_64] new-arch: [true, false] @@ -64,7 +63,7 @@ jobs: - name: Install example dependencies working-directory: examples/sdk/reactNative - # install, not ci: the lock pins the file: SDK's version, so npm ci breaks on every version bump. + # 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 diff --git a/packages/react-native/tests/crashReporterReinitTests.spec.ts b/packages/react-native/tests/crashReporterReinitTests.spec.ts deleted file mode 100644 index c2d015b3..00000000 --- a/packages/react-native/tests/crashReporterReinitTests.spec.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { NativeModules } from 'react-native'; -import { mockStreamFileSystem } from './_mocks/fileSystem'; - -// Proof for the dispose/re-init defect: CrashReporter.initialized is static and never reset, -// so a client created after dispose() silently loses native crash reporting. -// These tests document CURRENT behavior; they are evidence, not a regression suite. - -jest.mock('react-native', () => ({ - NativeModules: {}, - Platform: { - OS: 'ios', - select: (options: Record) => (options.ios !== undefined ? options.ios : options.default), - }, -})); - -jest.mock('../src/common/platformHelper', () => ({ - version: () => '0.81.6', -})); - -const nativeMock = { - initialize: jest.fn(), - useAttributes: jest.fn(), - useAttachments: jest.fn(), - crash: jest.fn(), -}; - -NativeModules.BacktraceReactNative = nativeMock; -NativeModules.BacktraceDirectoryProvider = { applicationDirectory: () => '/' }; -(globalThis as unknown as { RN$Bridgeless: boolean }).RN$Bridgeless = true; - -/* eslint-disable @typescript-eslint/no-var-requires */ -const { BacktraceClient } = require('../src/BacktraceClient'); -/* eslint-enable @typescript-eslint/no-var-requires */ - -function createClient() { - return new BacktraceClient({ - options: { - url: 'https://submit.backtrace.io/universe/token/json', - database: { enable: true, captureNativeCrashes: true, path: '/backtrace' }, - metrics: { enable: false }, - breadcrumbs: { enable: false }, - userAttributes: { application: 'reinitProof', 'application.version': '1.0.0' }, - }, - fileSystem: mockStreamFileSystem(), - }); -} - -describe('CrashReporter dispose and re-initialize (current behavior proof)', () => { - it('Should never reinitialize the native crash reporter after dispose, and updates stay dead', () => { - const first = createClient(); - first.initialize(); - expect(nativeMock.initialize).toHaveBeenCalledTimes(1); - - first.addAttribute({ 'first.client': 'works' }); - expect(nativeMock.useAttributes).toHaveBeenCalled(); - - first.dispose(); - - const second = createClient(); - second.initialize(); - // Static guard: the second client never reaches native init. - expect(nativeMock.initialize).toHaveBeenCalledTimes(1); - - nativeMock.useAttributes.mockClear(); - second.addAttribute({ 'second.client': 'lost' }); - // _enabled stayed false on the second reporter, so native never hears about this. - expect(nativeMock.useAttributes).not.toHaveBeenCalled(); - }); -});