fix: stop a replaced React Native instance from reporting downloads and restarting - #196
Merged
Merged
Conversation
floydkim
marked this pull request as draft
September 6, 2026 15:03
floyd-soomgo
marked this pull request as ready for review
September 7, 2026 05:50
## Summary
- Add a standalone E2E scenario that taps "Check for updates" and then
"Restart app" while the update archive is still downloading.
- Throttle archive responses in the mock server when
`E2E_SLOW_DOWNLOAD_MS` is set, so the reload lands in the middle of a
download instead of after it. Without this the archive arrives over
localhost in one chunk and the scenario proves nothing.
- Run it from `e2e/repro-reload-race.ts`, which releases one optional
update and runs that one flow rather than the whole suite. `--build`
rebuilds the app for a native change, and `--skip-release` reuses the
update already being served.
- Report what the device recorded either way: the matching iOS crash
report, or the logcat lines around a crash on Android.
## Verification
On iOS (RN0840, new architecture) the scenario crashes the app in two
runs out of two:
EXC_CRASH (SIGABRT)
-[CodePushDownloadHandler connection:didReceiveData:]
-[CodePush downloadUpdate:...]_block_invoke
-[CodePush dispatchDownloadProgressEvent]
-[NativeCodePushSpecBase emitOnDownloadProgress:]
The download that outlives the reload reaches the event emitter of a
runtime that has already been torn down.
Android is unverified: no emulator was available.
## Summary - Adopt `RCTInvalidating`, so React Native tells the module when the instance it belongs to is being replaced. - Track that in `_generationAlive` and return early from `dispatchDownloadProgressEvent`, `loadBundle` and `restartAppInternal:` once it is false. - Invalidate the suspend timer and remove the resume observers at the same point, so neither can restart the instance that replaced this one. A reload starts the next instance before the one being replaced has finished being torn down. A download in flight keeps this module alive across that boundary, and its progress callback then reaches an event emitter whose runtime is gone. The suspend timer and the resume notifications reach the module the same way, and the restart they ask for belongs to an instance that is no longer on screen. ## Verification - `npx tsx e2e/repro-reload-race.ts --app RN0840 --platform ios`: the scenario that crashed in two runs out of two now passes, with no crash report written. - `npm run jest`: 11 suites, 102 tests. Android carries the same exposure through its own progress path and is not covered here.
…ated
## Summary
- Track whether the React Native instance this module belongs to is
still running in `mGenerationAlive`, cleared in `invalidate()`.
- Return early from `emitDownloadProgressEvent` once it is false. Every
progress event routes through there, including the ones a frame
callback delivers after teardown.
- Return early from `restartAppInternal` too, so a module that has been
replaced cannot restart the instance that replaced it.
`invalidate()` shuts the background executor down, but a frame callback
already posted to `ReactChoreographer` belongs to a process-wide
singleton and runs regardless. It then emits through a context that has
been destroyed.
## Verification
`e2e/repro-reload-race.ts --app RN0840 --platform android`, against a
Pixel emulator on API 36 with the new architecture:
- Without this change the app dies on the frame callback:
FATAL EXCEPTION: main
java.lang.RuntimeException: __next_prime overflow
NativeCodePushSpec.emitOnDownloadProgress
CodePushNativeModule.emitDownloadProgressEvent
...doFrame
android.view.Choreographer$CallbackRecord.run
This is the signature the iOS crash carried as well.
- With it the scenario passes. The download the reload interrupts ends
in the `InterruptedIOException` that shutting the executor down is
meant to raise, which is logged and rejects its promise. It reaches
the general catch rather than the invalid-update one, so no package
hash is recorded as failed.
- `:bravemobile_react-native-code-push:compileReleaseJavaWithJavac`
floyd-soomgo
force-pushed
the
fix/reload-race-generation-guard
branch
from
September 7, 2026 05:51
4ea1357 to
dc9972e
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Reloading the app while an update is still downloading crashes it, on both platforms.
A reload starts the next React Native instance before the one it replaces has finished
being torn down. A download in flight keeps the CodePush module of the outgoing instance
alive across that boundary, and its progress callback then reaches an event emitter whose
runtime is gone.
This adds a per-instance liveness flag, cleared when the instance is invalidated, and
returns early from every path that would report to or restart a runtime that is no longer
there.
RCTInvalidating, guarddispatchDownloadProgressEvent,loadBundleand
restartAppInternal:, and invalidate the suspend timer and the resume observers ininvalidate, so neither can restart the instance that replaced this one.emitDownloadProgressEvent, which every progress event routesthrough, including the ones a frame callback delivers after teardown.
invalidate()shuts the background executor down, but a callback already posted to the process-wide
ReactChoreographerruns regardless.restartAppInternalis guarded the same way.mid-download, and throttle archive responses in the mock server so the reload lands
while the download is still running.
The library holds no JSI objects of its own, so the crash is not the one a pure JSI module
would hit. The condition behind it is the same, and CodePush is more exposed than most
because it is what triggers the reload.
The crash
Both platforms abort with the same signature, a destroyed
unordered_mapwhose bucketcount no longer makes sense:
iOS —
EXC_CRASH (SIGABRT)Android —
FATAL EXCEPTION: mainHow it is reached in practice
The window is narrow: the reload has to land before the download finishes, which the
ordinary sync flow never does because it restarts after the download completes. These do
reach it:
restartApp()on its own, from a "restart now" control, while abackground sync downloads
disallow()thenallow()releasing a queued restart during a downloadON_NEXT_RESUMEorON_NEXT_SUSPENDupdate from an earlier session restarting theapp while a later sync is downloading
DevSettings.reload()or a reload on a locale orauth change
Verification
Reproduction,
npx tsx e2e/repro-reload-race.ts --app RN0840 --platform <ios|android>:SIGABRT, 2 runs out of 2FATAL EXCEPTIONRegression,
npm run e2e -- --app RN0840 --platform both: all 7 phases pass on bothplatforms, 0 failures. Phase 4 covers the resume and suspend install modes that depend on
the observers and timer
invalidatenow releases, and phase 6 covers answering taps whilea patch downloads and applies.
Also
npm run jest(11 suites, 102 tests) and:bravemobile_react-native-code-push:compileReleaseJavaWithJavac.Notes
With the guard in place, a reload during a download ends that download in an
InterruptedIOExceptionon Android, which is what shutting the executor down is meant toraise. It is logged and rejects its promise, and reaches the general catch rather than the
invalid-update one, so no package hash is recorded as failed.
Not addressed here:
CodePush.mCurrentInstanceon Android is a process-wide singleton thatloadBundleLegacy()clears throughinvalidateCurrentInstance(), which an outgoinginstance could use to clear the reference its replacement owns. That path only runs when
the reflection-based reload fails, and it is unrelated to this crash.