Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 77 additions & 0 deletions .github/scripts/android-native-crash.sh
Original file line number Diff line number Diff line change
@@ -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
74 changes: 74 additions & 0 deletions .github/scripts/check-minidump-annotations.py
Original file line number Diff line number Diff line change
@@ -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("<I", match.group(1))
if not 1 <= declared <= 512 or declared % width:
continue
start = match.start() + 4
raw = data[start : start + declared]
if len(raw) < declared:
continue
try:
text = raw.decode(decoder)
except UnicodeDecodeError:
continue
if text.isprintable():
out.add(text)
return out


def main():
path = sys.argv[1]
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

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(strings)} length-prefixed strings")
print(f"annotation keys found: {', '.join(keys) if keys else '<none>'}")

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())
15 changes: 15 additions & 0 deletions .github/scripts/ci-crash-driver.js
Original file line number Diff line number Diff line change
@@ -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');
40 changes: 40 additions & 0 deletions .github/scripts/verify-jni-symbols.sh
Original file line number Diff line number Diff line change
@@ -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 <apk>}"
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
126 changes: 126 additions & 0 deletions .github/workflows/android.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions examples/sdk/reactNative/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
Loading