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
97 changes: 97 additions & 0 deletions .github/scripts/android-js-fatal.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
# Asserts a fatal JS error produces one report, no duplicate from the rethrown JavascriptException, and no 5 s wait.
set -euo pipefail

PACKAGE="com.reactnative"
ACTIVITY="$PACKAGE/.MainActivity"
APK="examples/sdk/reactNative/android/app/build/outputs/apk/release/app-release.apk"
MARKER="ci-js-fatal-$(date +%s)"
TRIGGER_URL="backtrace-example://ci-js-fatal?marker=$MARKER"
LOGCAT_OUT="/tmp/js-fatal-logcat.txt"
FORWARD_BOUND_S=7
EXIT_BOUND_S=2

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
}

seconds_between() {
python3 -c "print(round(float('$2') - float('$1'), 2))"
}

at_most() {
python3 -c "import sys; sys.exit(0 if float('$1') <= float('$2') else 1)"
}

trap 'adb logcat -d -v epoch > "$LOGCAT_OUT" 2>/dev/null || true' EXIT

adb wait-for-device
adb uninstall "$PACKAGE" >/dev/null 2>&1 || true
adb install -r "$APK"
adb shell pm clear "$PACKAGE" >/dev/null
adb logcat -c

adb shell am start -n "$ACTIVITY" >/dev/null
wait_for_log "BT_CI_DRIVER_ARMED" 120
PID="$(adb shell pidof "$PACKAGE" | tr -d '\r')"

adb shell am start -n "$ACTIVITY" -a android.intent.action.VIEW -d "'$TRIGGER_URL'" >/dev/null
echo "fatal JS error fired: $MARKER, pid $PID"

for _ in $(seq 1 30); do
if ! adb shell pidof "$PACKAGE" >/dev/null 2>&1; then
break
fi
sleep 1
done
if adb shell pidof "$PACKAGE" >/dev/null 2>&1; then
echo "::error::the app is still running 30 s after the fatal JS error"
exit 1
fi
sleep 1
adb logcat -d -v epoch > "$LOGCAT_OUT"

FIRED_AT="$(awk '/BT_CI_DRIVER firing: backtrace-example:\/\/ci-js-fatal/ { print $1; exit }' "$LOGCAT_OUT")"
RETHROWN_AT="$(awk -v pid="$PID" '$2 == pid && /AndroidRuntime: FATAL EXCEPTION/ { print $1; exit }' "$LOGCAT_OUT")"
DIED_AT="$(grep -F "Process $PACKAGE (pid $PID) has died" "$LOGCAT_OUT" | head -1 | awk '{ print $1 }' || true)"
if [ -z "$FIRED_AT" ] || [ -z "$RETHROWN_AT" ] || [ -z "$DIED_AT" ]; then
echo "::error::missing log lines: fired '$FIRED_AT', rethrown '$RETHROWN_AT', died '$DIED_AT'"
grep -E "BT_CI|AndroidRuntime|has died" "$LOGCAT_OUT" | tail -30
exit 1
fi

FORWARD_S="$(seconds_between "$FIRED_AT" "$RETHROWN_AT")"
EXIT_S="$(seconds_between "$RETHROWN_AT" "$DIED_AT")"
echo "React Native rethrew ${FORWARD_S}s after the fatal JS error, the process died ${EXIT_S}s later"
if ! at_most "$FORWARD_S" "$FORWARD_BOUND_S"; then
echo "::error::the JS handler held the fatal error ${FORWARD_S}s, expected at most ${FORWARD_BOUND_S}s"
exit 1
fi
if ! at_most "$EXIT_S" "$EXIT_BOUND_S"; then
echo "::error::the process died ${EXIT_S}s after the rethrow, expected at most ${EXIT_BOUND_S}s"
exit 1
fi

RECORDS="$(awk -v since="$FIRED_AT" '$1 >= since && /BT_CI_RECORD/ { sub(/.*BT_CI_RECORD /, ""); print }' "$LOGCAT_OUT")"
COUNT="$(printf '%s\n' "$RECORDS" | grep -c . || true)"
echo "reports stored: $COUNT"
printf '%s\n' "$RECORDS"
if [ "$COUNT" != "1" ]; then
echo "::error::expected exactly one stored report, found $COUNT"
exit 1
fi
if ! printf '%s\n' "$RECORDS" | grep -qF "Error \"BT_CI_JS_FATAL $MARKER\""; then
echo "::error::the stored report is not the fatal JS error"
exit 1
fi
echo "one report with the JS error, no JavascriptException duplicate, process died ${EXIT_S}s after the rethrow"
16 changes: 16 additions & 0 deletions .github/scripts/ci-crash-driver.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@
import { Linking } from 'react-native';

Linking.addEventListener('url', ({ url }) => {
const fatal = /^backtrace-example:\/\/ci-js-fatal\?marker=([A-Za-z0-9-]+)$/.exec(url ?? '');
if (fatal) {
console.log(`BT_CI_DRIVER firing: ${url}`);
setTimeout(() => {
throw new Error(`BT_CI_JS_FATAL ${fatal[1]}`);
}, 0);
return;
}
const match = /^backtrace-example:\/\/ci-native-crash\?marker=([A-Za-z0-9-]+)$/.exec(url ?? '');
if (!match) {
return;
Expand All @@ -12,4 +20,12 @@ Linking.addEventListener('url', ({ url }) => {
client.crash();
});

BacktraceClient.instance?.database?.on('added', (record) => {
if (record.type === 'report') {
console.log(
`BT_CI_RECORD ${record.data.classifiers?.[0]} ${JSON.stringify(record.data.attributes?.['error.message'] ?? '')}`,
);
}
});

console.log('BT_CI_DRIVER_ARMED');
4 changes: 3 additions & 1 deletion .github/workflows/android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ jobs:
disable-animations: false
script: echo "Generated AVD snapshot for caching."

- name: Capture a native crash on the emulator, single APK then Play-style split install
- name: Capture a native crash, single APK then Play-style split install, and a fatal JS error on the emulator
uses: reactivecircus/android-emulator-runner@v2
env:
BUNDLETOOL_JAR: ${{ runner.temp }}/bundletool.jar
Expand All @@ -127,6 +127,7 @@ jobs:
script: |
INSTALL=apk bash .github/scripts/android-native-crash.sh
INSTALL=split bash .github/scripts/android-native-crash.sh
bash .github/scripts/android-js-fatal.sh

- name: Upload crash evidence
if: failure()
Expand All @@ -136,5 +137,6 @@ jobs:
path: |
/tmp/native-crash-*.dmp
/tmp/logcat-*.txt
/tmp/js-fatal-logcat.txt
if-no-files-found: warn
retention-days: 7
3 changes: 3 additions & 0 deletions examples/sdk/reactNative/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,16 @@ android {
buildTypes {
debug {
signingConfig signingConfigs.debug
buildConfigField "boolean", "USE_DEVELOPER_SUPPORT", "true"
}
release {
// 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")
// BuildConfig.DEBUG is true in the debuggable CI build.
buildConfigField "boolean", "USE_DEVELOPER_SUPPORT", "false"
minifyEnabled enableProguardInReleaseBuilds || project.hasProperty("btCiMinify")
proguardFiles getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro"
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ public String getJSMainModuleName() {

@Override
public boolean getUseDeveloperSupport() {
return BuildConfig.DEBUG;
return BuildConfig.USE_DEVELOPER_SUPPORT;
}

@Override
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,14 @@
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.common.JavascriptException;
import com.facebook.react.module.annotations.ReactModule;

import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference;
Expand All @@ -37,6 +41,12 @@ public class BacktraceAndroidBackgroundUnhandledExceptionHandler extends ReactCo

private static final long REPORT_PROCESSED_TIMEOUT_MS = 5000;

private static final long FATAL_ERROR_MARK_TIMEOUT_MS = 5000;

private static final int MAX_FATAL_ERROR_MARKS = 8;

private final List<FatalErrorMark> _fatalErrorMarks = new ArrayList<>();

public static final String NAME = "BacktraceAndroidBackgroundUnhandledExceptionHandler";

public BacktraceAndroidBackgroundUnhandledExceptionHandler(ReactApplicationContext reactContext) {
Expand Down Expand Up @@ -66,7 +76,11 @@ public void start(Callback callback) {
@Override
public synchronized void uncaughtException(final Thread thread, final Throwable throwable) {
try {
report(throwable);
if (isReportedFatalError(throwable)) {
Log.d(LOG_TAG, "Skipping the JavascriptException of a fatal error already reported from JavaScript.");
} else {
report(throwable);
}
} catch (RuntimeException | Error ex) {
Log.w(LOG_TAG, "Failed to report the unhandled exception.", ex);
} finally {
Expand All @@ -77,6 +91,54 @@ public synchronized void uncaughtException(final Thread thread, final Throwable
}
}

// The interop layer rejects a void synchronous method.
@ReactMethod(isBlockingSynchronousMethod = true)
public boolean markFatalError(String message) {
synchronized (_fatalErrorMarks) {
removeExpiredMarks();
if (_fatalErrorMarks.size() == MAX_FATAL_ERROR_MARKS) {
_fatalErrorMarks.remove(0);
}
_fatalErrorMarks.add(new FatalErrorMark(message, System.nanoTime()));
}
return true;
}

private boolean isReportedFatalError(Throwable throwable) {
if (!(throwable instanceof JavascriptException)) {
return false;
}
String errorMessage = errorMessageOf((JavascriptException) throwable);
synchronized (_fatalErrorMarks) {
removeExpiredMarks();
for (Iterator<FatalErrorMark> marks = _fatalErrorMarks.iterator(); marks.hasNext(); ) {
if (marks.next().matches(errorMessage)) {
marks.remove();
return true;
}
}
}
return false;
}

private void removeExpiredMarks() {
for (Iterator<FatalErrorMark> marks = _fatalErrorMarks.iterator(); marks.hasNext(); ) {
if (!marks.next().isRecent()) {
marks.remove();
}
}
}

// React Native appends ", stack:" and the JS frames to the error message.
private static String errorMessageOf(JavascriptException exception) {
String text = exception.getMessage();
if (text == null) {
return "";
}
int stackStart = text.indexOf(", stack:");
return stackStart >= 0 ? text.substring(0, stackStart) : text;
}

private void report(Throwable throwable) {
Callback callback = _callback.getAndSet(null);
if (callback == null) {
Expand All @@ -100,10 +162,33 @@ private void waitForReportProcessing(CountDownLatch reportProcessed) {
}

// not synchronized: the crashing thread holds this monitor while it waits
@ReactMethod
public void reportProcessed() {
@ReactMethod(isBlockingSynchronousMethod = true)
public boolean reportProcessed() {
Log.d(LOG_TAG, "Unhandled exception report processed by the JavaScript side.");
_reportProcessed.countDown();
return true;
}

private static final class FatalErrorMark {
private final String message;
private final long markedAtNanos;

FatalErrorMark(String message, long markedAtNanos) {
this.message = message == null ? "" : message;
this.markedAtNanos = markedAtNanos;
}

boolean matches(String errorMessage) {
if (message.isEmpty()) {
String trimmed = errorMessage.trim();
return trimmed.isEmpty() || trimmed.endsWith(":");
}
return errorMessage.contains(message);
}

boolean isRecent() {
return System.nanoTime() - markedAtNanos <= TimeUnit.MILLISECONDS.toNanos(FATAL_ERROR_MARK_TIMEOUT_MS);
}
}

private static String stackTraceToString(StackTraceElement[] stackTrace) {
Expand Down
Loading
Loading