Skip to content

fix: stop a replaced React Native instance from reporting downloads and restarting - #196

Merged
floyd-soomgo merged 3 commits into
masterfrom
fix/reload-race-generation-guard
Sep 7, 2026
Merged

fix: stop a replaced React Native instance from reporting downloads and restarting#196
floyd-soomgo merged 3 commits into
masterfrom
fix/reload-race-generation-guard

Conversation

@floyd-soomgo

Copy link
Copy Markdown
Member

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.

  • iOS — adopt RCTInvalidating, guard dispatchDownloadProgressEvent, loadBundle
    and restartAppInternal:, and invalidate the suspend timer and the resume observers in
    invalidate, so neither can restart the instance that replaced this one.
  • Android — guard emitDownloadProgressEvent, which every progress event routes
    through, including the ones a frame callback delivers after teardown. invalidate()
    shuts the background executor down, but a callback already posted to the process-wide
    ReactChoreographer runs regardless. restartAppInternal is guarded the same way.
  • E2E — add a standalone scenario that taps "Check for updates" and then "Restart app"
    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_map whose bucket
count no longer makes sense:

iOSEXC_CRASH (SIGABRT)

-[CodePushDownloadHandler connection:didReceiveData:]
-[CodePush downloadUpdate:...]_block_invoke
-[CodePush dispatchDownloadProgressEvent]
-[NativeCodePushSpecBase emitOnDownloadProgress:]
  unordered_map<string, shared_ptr<IAsyncEventEmitter>>::operator[]
  __hash_table::__rehash_unique -> std::__next_prime
  std::__throw_overflow_error -> abort

AndroidFATAL EXCEPTION: main

java.lang.RuntimeException: __next_prime overflow
  CxxCallbackImpl.nativeInvoke
  NativeCodePushSpec.emitOnDownloadProgress
  CodePushNativeModule.emitDownloadProgressEvent
  ...doFrame
  ReactChoreographer
  android.view.Choreographer$CallbackRecord.run

How 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:

  • an app calling restartApp() on its own, from a "restart now" control, while a
    background sync downloads
  • disallow() then allow() releasing a queued restart during a download
  • an ON_NEXT_RESUME or ON_NEXT_SUSPEND update from an earlier session restarting the
    app while a later sync is downloading
  • any other reload in the app, such as DevSettings.reload() or a reload on a locale or
    auth change
  • Fast Refresh in a development build

Verification

Reproduction, npx tsx e2e/repro-reload-race.ts --app RN0840 --platform <ios|android>:

before after
iOS, simulator, new architecture SIGABRT, 2 runs out of 2 passes, no crash report written
Android, Pixel emulator API 36, new architecture FATAL EXCEPTION passes, no fatal exception

Regression, npm run e2e -- --app RN0840 --platform both: all 7 phases pass on both
platforms, 0 failures. Phase 4 covers the resume and suspend install modes that depend on
the observers and timer invalidate now releases, and phase 6 covers answering taps while
a 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
InterruptedIOException on Android, which is what shutting the executor down is meant to
raise. 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.mCurrentInstance on Android is a process-wide singleton that
loadBundleLegacy() clears through invalidateCurrentInstance(), which an outgoing
instance 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.

@floydkim
floydkim marked this pull request as draft September 6, 2026 15:03
@floyd-soomgo
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
floyd-soomgo force-pushed the fix/reload-race-generation-guard branch from 4ea1357 to dc9972e Compare September 7, 2026 05:51
@floyd-soomgo
floyd-soomgo merged commit b23ee96 into master Sep 7, 2026
1 check passed
@floyd-soomgo
floyd-soomgo deleted the fix/reload-race-generation-guard branch September 7, 2026 06:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants