@react-native-firebase/crashlytics 27.0.0 → 27.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/android/build.gradle +1 -0
  3. package/android/proguard-rules.pro +20 -0
  4. package/android/src/main/java/io/invertase/firebase/crashlytics/NativeRNFBTurboCrashlytics.java +11 -4
  5. package/android/src/main/java/io/invertase/firebase/crashlytics/ReactNativeFirebaseCrashlyticsFatalExit.java +117 -0
  6. package/android/src/main/java/io/invertase/firebase/crashlytics/ReactNativeFirebaseCrashlyticsInternal.java +80 -0
  7. package/android/src/test/java/io/invertase/firebase/crashlytics/ReactNativeFirebaseCrashlyticsFatalExitBarrierTest.java +122 -0
  8. package/android/src/test/java/io/invertase/firebase/crashlytics/ReactNativeFirebaseCrashlyticsFatalExitTest.java +189 -0
  9. package/android/src/test/java/io/invertase/firebase/crashlytics/ReactNativeFirebaseCrashlyticsInternalTest.java +199 -0
  10. package/dist/module/version.js +1 -1
  11. package/dist/typescript/lib/index.d.ts +1 -1
  12. package/dist/typescript/lib/version.d.ts +1 -1
  13. package/ios/RNFBCrashlytics/RNFBCrashlyticsHelper.h +40 -0
  14. package/ios/RNFBCrashlytics/RNFBCrashlyticsHelper.m +73 -0
  15. package/ios/RNFBCrashlytics/RNFBCrashlyticsModule.mm +21 -7
  16. package/ios/RNFBCrashlyticsUnitTests/HostStubs/Firebase/FIRCrashlyticsHostStub.m +66 -0
  17. package/ios/RNFBCrashlyticsUnitTests/HostStubs/Firebase/Firebase.h +9 -0
  18. package/ios/RNFBCrashlyticsUnitTests/RNFBCrashlyticsFatalExitTests.m +268 -0
  19. package/ios/RNFBCrashlyticsUnitTests/RNFBCrashlyticsUnitTests.xcodeproj/project.pbxproj +4 -0
  20. package/ios_config.sh +14 -4
  21. package/lib/version.ts +1 -1
  22. package/package.json +4 -4
package/CHANGELOG.md CHANGED
@@ -3,6 +3,14 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [27.1.0](https://github.com/invertase/react-native-firebase/compare/v27.0.0...v27.1.0) (2026-10-23)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **crashlytics, ios:** time out SPM upload-symbols so a stalled upload cannot hang the build ([1cdb7de](https://github.com/invertase/react-native-firebase/commit/1cdb7de24ef5ca12a24cf599cce11d3d8797cbb2))
11
+ - **crashlytics:** reach Android Crashlytics internals by reflection only ([c3f15e3](https://github.com/invertase/react-native-firebase/commit/c3f15e37b1f53e0a81469183e896003ea6e80141))
12
+ - **crashlytics:** save JS fatal report before exiting when chaining is disabled ([cfd00b6](https://github.com/invertase/react-native-firebase/commit/cfd00b65754186aad0f765f61a5db3f19e84b716))
13
+
6
14
  ## [27.0.0](https://github.com/invertase/react-native-firebase/compare/v26.4.0...v27.0.0) (2026-10-19)
7
15
 
8
16
  ### ⚠ BREAKING CHANGES
@@ -68,6 +68,7 @@ android {
68
68
 
69
69
  defaultConfig {
70
70
  multiDexEnabled = true
71
+ consumerProguardFiles 'proguard-rules.pro'
71
72
  }
72
73
 
73
74
  buildFeatures {
@@ -0,0 +1,20 @@
1
+ # Consumer R8/ProGuard rules for @react-native-firebase/crashlytics.
2
+ #
3
+ # io.invertase.firebase.crashlytics.ReactNativeFirebaseCrashlyticsInternal reaches these Crashlytics
4
+ # SDK internals by reflection to wait for a JS fatal's event write before the process exits. Keep
5
+ # this list in sync with the names there. A missing rule (or an SDK without these members) only
6
+ # makes the barrier fail, which falls back to a bounded wait; it never crashes.
7
+ -keepclassmembers class com.google.firebase.crashlytics.FirebaseCrashlytics {
8
+ com.google.firebase.crashlytics.internal.common.CrashlyticsCore core;
9
+ }
10
+ -keepclassmembers class com.google.firebase.crashlytics.internal.common.CrashlyticsCore {
11
+ com.google.firebase.crashlytics.internal.concurrency.CrashlyticsWorkers crashlyticsWorkers;
12
+ }
13
+ -keepclassmembers class com.google.firebase.crashlytics.internal.concurrency.CrashlyticsWorkers {
14
+ com.google.firebase.crashlytics.internal.concurrency.CrashlyticsWorker common;
15
+ com.google.firebase.crashlytics.internal.concurrency.CrashlyticsWorker diskWrite;
16
+ }
17
+ -keepclassmembers class com.google.firebase.crashlytics.internal.concurrency.CrashlyticsWorker {
18
+ com.google.android.gms.tasks.Task submit(java.lang.Runnable);
19
+ com.google.android.gms.tasks.Task submitTask(java.util.concurrent.Callable);
20
+ }
@@ -35,6 +35,9 @@ import java.util.Objects;
35
35
 
36
36
  public class NativeRNFBTurboCrashlytics extends NativeRNFBTurboCrashlyticsSpec {
37
37
  private static final String TAG = "Crashlytics";
38
+ // Process-wide so a second JS fatal during the bounded wait neither records nor exits again.
39
+ private static final ReactNativeFirebaseCrashlyticsFatalExit FATAL_EXIT =
40
+ ReactNativeFirebaseCrashlyticsFatalExit.create();
38
41
 
39
42
  public NativeRNFBTurboCrashlytics(ReactApplicationContext reactContext) {
40
43
  super(reactContext);
@@ -83,10 +86,14 @@ public class NativeRNFBTurboCrashlytics extends NativeRNFBTurboCrashlyticsSpec {
83
86
  @Override
84
87
  public void crashWithStackPromise(ReadableMap jsErrorMap, Promise promise) {
85
88
  if (ReactNativeFirebaseCrashlyticsInitProvider.isCrashlyticsCollectionEnabled()) {
86
- Exception e = recordJavaScriptError(jsErrorMap);
87
- e.printStackTrace(System.err);
88
- Log.e(TAG, "Crash logged. Terminating app.");
89
- System.exit(0);
89
+ // Coverage exception: this branch ends the process, so neither JVM tests nor e2e can
90
+ // observe it. Keep it a thin delegate; barrier, timeout, fallback and the second-fatal guard
91
+ // live in ReactNativeFirebaseCrashlyticsFatalExit (unit-tested). The promise is
92
+ // intentionally never resolved: that keeps the JS global handler suspended (no chaining to
93
+ // the previous handler, no fall-through) until termination.
94
+ Log.e(TAG, "Logging crash and terminating app.");
95
+ FATAL_EXIT.recordThenTerminate(() -> recordJavaScriptError(jsErrorMap).printStackTrace());
96
+ return;
90
97
  } else {
91
98
  Log.i(TAG, "crashlytics collection is not enabled, not crashing.");
92
99
  }
@@ -0,0 +1,117 @@
1
+ package io.invertase.firebase.crashlytics;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ *
18
+ */
19
+
20
+ import com.google.android.gms.tasks.Tasks;
21
+ import com.google.firebase.crashlytics.FirebaseCrashlytics;
22
+ import java.util.concurrent.TimeUnit;
23
+ import java.util.concurrent.atomic.AtomicBoolean;
24
+
25
+ /**
26
+ * Records a JS fatal, then terminates the process once the record has been written.
27
+ *
28
+ * <p>{@code FirebaseCrashlytics.recordException} writes asynchronously on SDK worker threads, so
29
+ * exiting right after it usually loses the event. This waits on a barrier that completes after the
30
+ * queued event write, bounded by {@link #TIMEOUT_MS}. On any barrier failure (SDK internals
31
+ * changed, timeout, interruption) it waits out the rest of the bound, then terminates anyway.
32
+ *
33
+ * <p>Must run on a background thread (the TurboModule's native-modules thread), never main.
34
+ */
35
+ final class ReactNativeFirebaseCrashlyticsFatalExit {
36
+ static final long TIMEOUT_MS = 2000;
37
+
38
+ interface Barrier {
39
+ void await(long timeoutMs) throws Exception;
40
+ }
41
+
42
+ interface Sleeper {
43
+ void sleep(long millis) throws InterruptedException;
44
+ }
45
+
46
+ interface Clock {
47
+ long nowMillis();
48
+ }
49
+
50
+ final Barrier barrier;
51
+ final Sleeper sleeper;
52
+ final Clock clock;
53
+ final Runnable terminator;
54
+ final long timeoutMs;
55
+ private final AtomicBoolean exiting = new AtomicBoolean(false);
56
+
57
+ ReactNativeFirebaseCrashlyticsFatalExit(
58
+ Barrier barrier, Sleeper sleeper, Clock clock, Runnable terminator, long timeoutMs) {
59
+ this.barrier = barrier;
60
+ this.sleeper = sleeper;
61
+ this.clock = clock;
62
+ this.terminator = terminator;
63
+ this.timeoutMs = timeoutMs;
64
+ }
65
+
66
+ static ReactNativeFirebaseCrashlyticsFatalExit create() {
67
+ return new ReactNativeFirebaseCrashlyticsFatalExit(
68
+ timeoutMs ->
69
+ Tasks.await(
70
+ ReactNativeFirebaseCrashlyticsInternal.pendingEventWritesBarrier(
71
+ FirebaseCrashlytics.getInstance()),
72
+ timeoutMs,
73
+ TimeUnit.MILLISECONDS),
74
+ Thread::sleep,
75
+ () -> TimeUnit.NANOSECONDS.toMillis(System.nanoTime()),
76
+ // Coverage exception: ends the process, so no test can run it.
77
+ () -> System.exit(0),
78
+ TIMEOUT_MS);
79
+ }
80
+
81
+ /**
82
+ * Runs {@code record}, waits (bounded) for it to be written, then terminates, even when {@code
83
+ * record} throws. Returns false without running {@code record} when an earlier call already
84
+ * started terminating.
85
+ */
86
+ boolean recordThenTerminate(Runnable record) {
87
+ if (!exiting.compareAndSet(false, true)) {
88
+ return false;
89
+ }
90
+ try {
91
+ record.run();
92
+ } catch (Throwable recordFailed) {
93
+ // The guard is already set, so terminate anyway rather than leave the app running with
94
+ // every later fatal ignored.
95
+ recordFailed.printStackTrace();
96
+ }
97
+ long deadline = clock.nowMillis() + timeoutMs;
98
+ try {
99
+ barrier.await(timeoutMs);
100
+ } catch (Throwable barrierUnavailable) {
101
+ waitUntil(deadline);
102
+ }
103
+ terminator.run();
104
+ return true;
105
+ }
106
+
107
+ private void waitUntil(long deadline) {
108
+ long remaining;
109
+ while ((remaining = deadline - clock.nowMillis()) > 0) {
110
+ try {
111
+ sleeper.sleep(remaining);
112
+ } catch (InterruptedException ignored) {
113
+ // Keep waiting out the bound; the process terminates right after.
114
+ }
115
+ }
116
+ }
117
+ }
@@ -0,0 +1,80 @@
1
+ package io.invertase.firebase.crashlytics;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ *
18
+ */
19
+
20
+ import com.google.android.gms.tasks.Task;
21
+ import com.google.firebase.crashlytics.FirebaseCrashlytics;
22
+ import java.lang.reflect.Field;
23
+ import java.lang.reflect.Method;
24
+ import java.util.Objects;
25
+ import java.util.concurrent.Callable;
26
+
27
+ /**
28
+ * Reaches Firebase Crashlytics SDK internals that have no public API.
29
+ *
30
+ * <p>{@code recordException} queues its event on the {@code common} worker, which then queues the
31
+ * event write on the {@code diskWrite} worker. Both workers are serial, so a {@code common} task
32
+ * that itself queues a no-op on {@code diskWrite} completes only after every event write already
33
+ * queued by earlier {@code recordException} calls.
34
+ *
35
+ * <p>Apps can override the Firebase BoM, so every internal member is reached by reflection: an SDK
36
+ * without them still builds, and any mismatch throws at runtime. Callers must treat every failure
37
+ * as "barrier unavailable". The reflected members are kept by the package's consumer R8/ProGuard
38
+ * rules ({@code proguard-rules.pro}); keep both lists in sync.
39
+ */
40
+ final class ReactNativeFirebaseCrashlyticsInternal {
41
+ static final String CORE_FIELD = "core";
42
+ static final String WORKERS_FIELD = "crashlyticsWorkers";
43
+ static final String COMMON_FIELD = "common";
44
+ static final String DISK_WRITE_FIELD = "diskWrite";
45
+ static final String SUBMIT_TASK_METHOD = "submitTask";
46
+ static final String SUBMIT_METHOD = "submit";
47
+
48
+ private ReactNativeFirebaseCrashlyticsInternal() {}
49
+
50
+ /** Returns a task that completes after all event writes already queued by the SDK. */
51
+ static Task<?> pendingEventWritesBarrier(FirebaseCrashlytics crashlytics)
52
+ throws ReflectiveOperationException {
53
+ return barrierOn(read(read(crashlytics, CORE_FIELD), WORKERS_FIELD));
54
+ }
55
+
56
+ /** {@code workers.common.submitTask(() -> workers.diskWrite.submit(() -> {}))}, reflectively. */
57
+ static Task<?> barrierOn(Object workers) throws ReflectiveOperationException {
58
+ Object common = read(workers, COMMON_FIELD);
59
+ Object diskWrite = read(workers, DISK_WRITE_FIELD);
60
+ Method submit = diskWrite.getClass().getMethod(SUBMIT_METHOD, Runnable.class);
61
+ Method submitTask = common.getClass().getMethod(SUBMIT_TASK_METHOD, Callable.class);
62
+ Runnable noop = () -> {};
63
+ Callable<Object> queueNoopOnDiskWrite = () -> submit.invoke(diskWrite, noop);
64
+ return (Task<?>) submitTask.invoke(common, queueNoopOnDiskWrite);
65
+ }
66
+
67
+ /** Reads field {@code name} declared on {@code owner}'s class or a superclass; never null. */
68
+ static Object read(Object owner, String name) throws ReflectiveOperationException {
69
+ for (Class<?> type = owner.getClass(); type != null; type = type.getSuperclass()) {
70
+ try {
71
+ Field field = type.getDeclaredField(name);
72
+ field.setAccessible(true);
73
+ return Objects.requireNonNull(field.get(owner), name);
74
+ } catch (NoSuchFieldException notDeclaredHere) {
75
+ // Keep looking up the hierarchy (e.g. a subclass of the SDK type).
76
+ }
77
+ }
78
+ throw new NoSuchFieldException(name);
79
+ }
80
+ }
@@ -0,0 +1,122 @@
1
+ package io.invertase.firebase.crashlytics;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ *
18
+ */
19
+
20
+ import static org.junit.Assert.assertEquals;
21
+ import static org.junit.Assert.assertTrue;
22
+ import static org.mockito.Mockito.mock;
23
+ import static org.mockito.Mockito.mockStatic;
24
+
25
+ import com.google.firebase.crashlytics.FirebaseCrashlytics;
26
+ import com.google.firebase.crashlytics.internal.common.CrashlyticsCore;
27
+ import com.google.firebase.crashlytics.internal.concurrency.CrashlyticsWorkers;
28
+ import java.util.ArrayList;
29
+ import java.util.Arrays;
30
+ import java.util.List;
31
+ import java.util.concurrent.ExecutorService;
32
+ import java.util.concurrent.Executors;
33
+ import java.util.concurrent.TimeUnit;
34
+ import java.util.concurrent.atomic.AtomicBoolean;
35
+ import org.junit.After;
36
+ import org.junit.Test;
37
+ import org.junit.runner.RunWith;
38
+ import org.mockito.MockedStatic;
39
+ import org.robolectric.RobolectricTestRunner;
40
+
41
+ /**
42
+ * Covers the production barrier wired by {@link ReactNativeFirebaseCrashlyticsFatalExit#create()}
43
+ * against real Crashlytics workers. Robolectric for the SDK classes; AndroidTest-AD-1. The barrier
44
+ * runs on a pool thread (as on the native-modules thread in production), and {@code mockStatic} is
45
+ * thread-bound, so it is opened on that thread.
46
+ */
47
+ @RunWith(RobolectricTestRunner.class)
48
+ public class ReactNativeFirebaseCrashlyticsFatalExitBarrierTest {
49
+ private final ExecutorService pool = Executors.newCachedThreadPool();
50
+
51
+ @After
52
+ public void tearDown() {
53
+ pool.shutdownNow();
54
+ }
55
+
56
+ @Test
57
+ public void productionBarrier_waitsForTheQueuedEventWrite() throws Exception {
58
+ CrashlyticsWorkers workers = new CrashlyticsWorkers(pool, pool);
59
+ FirebaseCrashlytics crashlytics =
60
+ ReactNativeFirebaseCrashlyticsInternalTest.crashlyticsWith(workers);
61
+ AtomicBoolean eventWritten = new AtomicBoolean(false);
62
+ workers.diskWrite.submit(
63
+ () -> {
64
+ try {
65
+ Thread.sleep(150);
66
+ } catch (InterruptedException e) {
67
+ throw new IllegalStateException(e);
68
+ }
69
+ eventWritten.set(true);
70
+ });
71
+
72
+ ReactNativeFirebaseCrashlyticsFatalExit fatalExit =
73
+ ReactNativeFirebaseCrashlyticsFatalExit.create();
74
+ pool.submit(
75
+ () -> {
76
+ try (MockedStatic<FirebaseCrashlytics> firebaseCrashlytics =
77
+ mockStatic(FirebaseCrashlytics.class)) {
78
+ firebaseCrashlytics.when(FirebaseCrashlytics::getInstance).thenReturn(crashlytics);
79
+ fatalExit.barrier.await(fatalExit.timeoutMs);
80
+ }
81
+ return null;
82
+ })
83
+ .get(10, TimeUnit.SECONDS);
84
+
85
+ assertTrue(eventWritten.get());
86
+ }
87
+
88
+ /**
89
+ * When the barrier is unavailable (here: an unset {@code crashlyticsWorkers}), the production
90
+ * barrier fails and the fatal exit waits out the bound, then terminates.
91
+ */
92
+ @Test
93
+ public void productionBarrier_barrierUnavailable_fallsBackToTheBoundedWait() throws Exception {
94
+ FirebaseCrashlytics crashlytics =
95
+ ReactNativeFirebaseCrashlyticsInternalTest.crashlyticsWithCore(mock(CrashlyticsCore.class));
96
+ List<String> events = new ArrayList<>();
97
+ long[] now = {0};
98
+ ReactNativeFirebaseCrashlyticsFatalExit fatalExit =
99
+ new ReactNativeFirebaseCrashlyticsFatalExit(
100
+ ReactNativeFirebaseCrashlyticsFatalExit.create().barrier,
101
+ millis -> {
102
+ events.add("sleep:" + millis);
103
+ now[0] += millis;
104
+ },
105
+ () -> now[0],
106
+ () -> events.add("exit"),
107
+ ReactNativeFirebaseCrashlyticsFatalExit.TIMEOUT_MS);
108
+
109
+ pool.submit(
110
+ () -> {
111
+ try (MockedStatic<FirebaseCrashlytics> firebaseCrashlytics =
112
+ mockStatic(FirebaseCrashlytics.class)) {
113
+ firebaseCrashlytics.when(FirebaseCrashlytics::getInstance).thenReturn(crashlytics);
114
+ assertTrue(fatalExit.recordThenTerminate(() -> events.add("record")));
115
+ }
116
+ return null;
117
+ })
118
+ .get(10, TimeUnit.SECONDS);
119
+
120
+ assertEquals(Arrays.asList("record", "sleep:2000", "exit"), events);
121
+ }
122
+ }
@@ -0,0 +1,189 @@
1
+ package io.invertase.firebase.crashlytics;
2
+
3
+ /*
4
+ * Copyright (c) 2016-present Invertase Limited & Contributors
5
+ *
6
+ * Licensed under the Apache License, Version 2.0 (the "License");
7
+ * you may not use this library except in compliance with the License.
8
+ * You may obtain a copy of the License at
9
+ *
10
+ * http://www.apache.org/licenses/LICENSE-2.0
11
+ *
12
+ * Unless required by applicable law or agreed to in writing, software
13
+ * distributed under the License is distributed on an "AS IS" BASIS,
14
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15
+ * See the License for the specific language governing permissions and
16
+ * limitations under the License.
17
+ *
18
+ */
19
+
20
+ import static org.junit.Assert.assertEquals;
21
+ import static org.junit.Assert.assertFalse;
22
+ import static org.junit.Assert.assertTrue;
23
+
24
+ import java.util.ArrayList;
25
+ import java.util.Arrays;
26
+ import java.util.Collections;
27
+ import java.util.List;
28
+ import java.util.concurrent.TimeoutException;
29
+ import org.junit.Test;
30
+
31
+ /**
32
+ * Plain JUnit (AndroidTest-AD-1) for the JS-fatal record → barrier → terminate ordering. A fake
33
+ * clock/sleeper/terminator stand in for time and {@code System.exit}, so no test ends the JVM.
34
+ */
35
+ public class ReactNativeFirebaseCrashlyticsFatalExitTest {
36
+ private static final long TIMEOUT = 2000;
37
+
38
+ private final List<String> events = Collections.synchronizedList(new ArrayList<>());
39
+ private final List<Long> sleeps = new ArrayList<>();
40
+ private long now = 10_000;
41
+
42
+ private ReactNativeFirebaseCrashlyticsFatalExit fatalExit(
43
+ ReactNativeFirebaseCrashlyticsFatalExit.Barrier barrier,
44
+ ReactNativeFirebaseCrashlyticsFatalExit.Sleeper sleeper) {
45
+ return new ReactNativeFirebaseCrashlyticsFatalExit(
46
+ barrier, sleeper, () -> now, () -> events.add("exit"), TIMEOUT);
47
+ }
48
+
49
+ private ReactNativeFirebaseCrashlyticsFatalExit.Sleeper advancingSleeper() {
50
+ return millis -> {
51
+ sleeps.add(millis);
52
+ now += millis;
53
+ };
54
+ }
55
+
56
+ @Test
57
+ public void barrierSuccess_terminatesAfterRecordAndBarrierWithoutWaiting() {
58
+ ReactNativeFirebaseCrashlyticsFatalExit fatalExit =
59
+ fatalExit(
60
+ timeoutMs -> {
61
+ assertEquals(TIMEOUT, timeoutMs);
62
+ events.add("barrier");
63
+ },
64
+ advancingSleeper());
65
+
66
+ assertTrue(fatalExit.recordThenTerminate(() -> events.add("record")));
67
+
68
+ assertEquals(Arrays.asList("record", "barrier", "exit"), events);
69
+ assertTrue(sleeps.isEmpty());
70
+ }
71
+
72
+ @Test
73
+ public void recordThrows_stillWaitsOnBarrierAndTerminates() {
74
+ ReactNativeFirebaseCrashlyticsFatalExit fatalExit =
75
+ fatalExit(timeoutMs -> events.add("barrier"), advancingSleeper());
76
+
77
+ assertTrue(
78
+ fatalExit.recordThenTerminate(
79
+ () -> {
80
+ events.add("record");
81
+ throw new IllegalStateException("record failed");
82
+ }));
83
+
84
+ assertEquals(Arrays.asList("record", "barrier", "exit"), events);
85
+ assertFalse(fatalExit.recordThenTerminate(() -> events.add("second")));
86
+ assertEquals(Arrays.asList("record", "barrier", "exit"), events);
87
+ }
88
+
89
+ @Test
90
+ public void barrierTimeout_terminatesOnceTheBoundHasElapsed() {
91
+ ReactNativeFirebaseCrashlyticsFatalExit fatalExit =
92
+ fatalExit(
93
+ timeoutMs -> {
94
+ now += timeoutMs;
95
+ throw new TimeoutException("barrier timed out");
96
+ },
97
+ advancingSleeper());
98
+
99
+ assertTrue(fatalExit.recordThenTerminate(() -> events.add("record")));
100
+
101
+ assertEquals(Arrays.asList("record", "exit"), events);
102
+ assertTrue("bound already used up by the barrier", sleeps.isEmpty());
103
+ }
104
+
105
+ @Test
106
+ public void reflectionFailure_waitsOutTheRemainingBoundThenTerminates() {
107
+ ReactNativeFirebaseCrashlyticsFatalExit fatalExit =
108
+ fatalExit(
109
+ timeoutMs -> {
110
+ now += 300;
111
+ throw new NoSuchFieldException("crashlyticsWorkers");
112
+ },
113
+ advancingSleeper());
114
+
115
+ assertTrue(fatalExit.recordThenTerminate(() -> events.add("record")));
116
+
117
+ assertEquals(Collections.singletonList(TIMEOUT - 300), sleeps);
118
+ assertEquals(Arrays.asList("record", "exit"), events);
119
+ }
120
+
121
+ @Test
122
+ public void linkageError_fallsBackToTheBoundedWait() {
123
+ ReactNativeFirebaseCrashlyticsFatalExit fatalExit =
124
+ fatalExit(
125
+ timeoutMs -> {
126
+ throw new NoSuchFieldError("core");
127
+ },
128
+ advancingSleeper());
129
+
130
+ assertTrue(fatalExit.recordThenTerminate(() -> events.add("record")));
131
+
132
+ assertEquals(Collections.singletonList(TIMEOUT), sleeps);
133
+ assertEquals(Arrays.asList("record", "exit"), events);
134
+ }
135
+
136
+ @Test
137
+ public void interruptedFallbackWait_keepsWaitingUntilTheBound() {
138
+ ReactNativeFirebaseCrashlyticsFatalExit fatalExit =
139
+ fatalExit(
140
+ timeoutMs -> {
141
+ throw new InterruptedException("barrier interrupted");
142
+ },
143
+ millis -> {
144
+ sleeps.add(millis);
145
+ if (sleeps.size() == 1) {
146
+ now += 500;
147
+ throw new InterruptedException("sleep interrupted");
148
+ }
149
+ now += millis;
150
+ });
151
+
152
+ assertTrue(fatalExit.recordThenTerminate(() -> events.add("record")));
153
+
154
+ assertEquals(Arrays.asList(TIMEOUT, TIMEOUT - 500), sleeps);
155
+ assertEquals(Arrays.asList("record", "exit"), events);
156
+ }
157
+
158
+ @Test
159
+ public void secondFatalWhileTerminating_neitherRecordsNorTerminatesAgain() {
160
+ ReactNativeFirebaseCrashlyticsFatalExit[] holder =
161
+ new ReactNativeFirebaseCrashlyticsFatalExit[1];
162
+ List<Boolean> nestedResults = new ArrayList<>();
163
+ holder[0] =
164
+ fatalExit(
165
+ timeoutMs -> {
166
+ // A second JS fatal arrives while the first one is waiting on the barrier.
167
+ nestedResults.add(holder[0].recordThenTerminate(() -> events.add("record-2")));
168
+ },
169
+ advancingSleeper());
170
+
171
+ assertTrue(holder[0].recordThenTerminate(() -> events.add("record-1")));
172
+ assertFalse(holder[0].recordThenTerminate(() -> events.add("record-3")));
173
+
174
+ assertEquals(Collections.singletonList(false), nestedResults);
175
+ assertEquals(Arrays.asList("record-1", "exit"), events);
176
+ }
177
+
178
+ @Test
179
+ public void create_wiresTheProductionTimeoutClockAndSleeper() throws Exception {
180
+ ReactNativeFirebaseCrashlyticsFatalExit fatalExit =
181
+ ReactNativeFirebaseCrashlyticsFatalExit.create();
182
+
183
+ assertEquals(2000, ReactNativeFirebaseCrashlyticsFatalExit.TIMEOUT_MS);
184
+ assertEquals(ReactNativeFirebaseCrashlyticsFatalExit.TIMEOUT_MS, fatalExit.timeoutMs);
185
+ long before = fatalExit.clock.nowMillis();
186
+ fatalExit.sleeper.sleep(5);
187
+ assertTrue(fatalExit.clock.nowMillis() >= before + 5);
188
+ }
189
+ }