fix(app, android): discard tasks posted to shut-down transactional executors - #9205
fix(app, android): discard tasks posted to shut-down transactional executors#9205ottob wants to merge 2 commits into
Conversation
…ecutors The transactional executor returned by TaskExecutorService is a bare Executors.newSingleThreadExecutor(), so when module invalidation calls shutdownNow() while a play-services Task is still in flight (App Check attestation, Auth network calls racing React instance teardown), the Task's completion listener posts to the dead executor and the default AbortPolicy crashes the process with an uncaught RejectedExecutionException thrown on a gms pool thread. d70520d guarded the database/firestore call sites, but the generic path through any module's transactional executor is still exposed. Give the transactional executor the same discard-after-shutdown behaviour the pooled executor already has via its RejectedExecutionHandler, keeping single-thread FIFO semantics identical. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
@ottob - This seems right to me. The transactional executor is the generic path A couple of things that are worth updating:
Providing CI goes green (I've just ran it) and @mikehardy doesn't have any further changes he'd like to see, this looks good to me. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #9205 +/- ##
============================================
+ Coverage 68.41% 69.89% +1.49%
============================================
Files 516 377 -139
Lines 37853 26996 -10857
Branches 5183 3632 -1551
============================================
- Hits 25892 18867 -7025
+ Misses 10176 7057 -3119
+ Partials 1785 1072 -713
Flags with carried forward coverage won't be shown. Click here to find out more. 🚀 New features to boost your workflow:
|
…ction guards Collapse both rejection handlers' guards to isShutdown() - it remains true through the terminating and terminated states, so the extra checks were redundant - and add Robolectric coverage: work submitted before shutdown runs, and work submitted after shutdown is discarded without throwing on both the transactional and pooled executors. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Thanks for the review @russellwheatley — both addressed in bb82650:
One honest caveat: I couldn't run the Gradle test suite locally (no JDK on this machine — we consume RNFB through Expo/EAS), so I'm relying on your CI run for execution. Happy to adjust if anything's red. |
|
I can pick up the testing work - the unit test infra is very new, I just put it in there, and I've got a JDK on the machine I'll be doing final review / qualification on already - so I'll shepherd this to merge - thanks for the fix! |
|
The CI android test problem is in fact related to the newness of the Java unit test infrastructure here - I have a fix coming for it in unrelated PR #9093 - I will merge that when ready, then rebase this PR and push it back out with the 9093 fix plus some small tweaks here The shape of your fix is correct though and if you are already using it locally it should work fine. We'll have it merged here and released before too long though Thanks for the PR! |
Description
TaskExecutorService.getNewExecutor()returns a bareExecutors.newSingleThreadExecutor()for transactional executors. WhenReactNativeFirebaseModule.invalidate()runsshutdownNow()while a play-servicesTaskis still in flight — an App Check Play Integrity attestation or an Auth network call racing React instance teardown (app swiped away, headless task ending, reload) — the Task's completion listener posts its continuation to the now-dead executor. The defaultAbortPolicythrows an uncaughtRejectedExecutionExceptionon a gms pool thread and crashes the process:d70520d ("catch RejectedExecutionException on executor-backed Tasks") guarded the database/firestore call sites, but the generic path through any module's transactional executor is still exposed — we see this in production via app-check/auth, where no
Tasks.callwrapper exists to catch it (the throw happens inside play-services internals, not in RNFB code).This PR replaces the bare single-thread executor with an equivalent
ThreadPoolExecutor(1, 1, 0ms, LinkedBlockingQueue)carrying aRejectedExecutionHandlerthat silently discards work arriving after shutdown — the same treatmentexecuteInFallbackalready gives the pooled executor. FIFO single-thread semantics are unchanged; a rejection on a live executor (unreachable with an unbounded queue) still throws to preserveAbortPolicybehaviour. Discarded continuations belong to a React instance that no longer exists, so nothing awaits them.Related issues
Related: #4047, #4118 (long-standing reports of this crash class). Follow-up to d70520d.
Release Summary
fix(app, android): discard tasks posted to shut-down transactional executors instead of crashing with RejectedExecutionException
Checklist
Androide2etests added or updated inpackages/\*\*/e2ejesttests added or updated inpackages/\*\*/__tests__Test Plan
We hit this crash in production (Google Play Console, Android vitals) in an app using only
app,app-check(Play Integrity provider) andauth— reproduced by killing the app immediately after launch while an App Check attestation is in flight. No automated test is included: the race needs a live play-services Task completing afterinvalidate(), which isn't reachable from jest, and e2e teardown timing is nondeterministic. The change is semantically equivalent to the existing pooled-executor rejection handling. We're shipping this same change as a local patch via patch-package/bun patch and will report back with before/after crash-rate data from Play Console.