Skip to content

iOS: roll back stuck pending updates before JS starts, fix crash - #27

Merged
ofalvai merged 1 commit into
masterfrom
push-pzqxyqkskoyo
Aug 11, 2026
Merged

iOS: roll back stuck pending updates before JS starts, fix crash#27
ofalvai merged 1 commit into
masterfrom
push-pzqxyqkskoyo

Conversation

@ofalvai

@ofalvai ofalvai commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Why

At the moment, the decision to roll back a bad update happens on the JS side, therefore, Hermes and JS code are already running at the point when the rollback happens. A rollback involves RCTTriggerReloadCommandListeners and a teardown of the JS runtime instance. This teardown could race with the initialization of the new instance, leading to a concrete crash. We can reproduce this crash even in CI today.

Much more detailed LLM summary

Captured from a real iOS Simulator .ips report (TestCodePush-2026-08-06-113918.ips) generated during localPackage.install.revert.dorevert.

  • Exception: EXC_BAD_ACCESS (SIGSEGV), KERN_INVALID_ADDRESS at 0x0000000000000008.
  • Triggering thread (com.apple.root.user-interactive-qos):
    pthread_mutex_lock
    std::__1::mutex::lock()
    facebook::react::MountingCoordinator::setMountingOverrideDelegate(...)
    -[RCTScheduler setupAnimationDriver:]
    -[RCTSurfacePresenter setupAnimationDriverWithSurfaceHandler:]
    __25-[RCTFabricSurface start]_block_invoke_2
    
  • Concurrently, the JS thread (com.facebook.react.runtime.JavaScript) was mid-teardown of the previous instance:
    -[RCTInstance invalidate]
    facebook::react::ReactInstance::~ReactInstance()
    facebook::react::HermesJSRuntime::~HermesJSRuntime()
    hermes::vm::Runtime::~Runtime()
    hermes::vm::HadesGC::finalizeAll() / finalizeYoungGenObjects()
    

A new Fabric surface was starting up while the old ReactInstance was concurrently being destructed — a teardown/recreate race in Fabric's mounting layer, hit exactly during the reload CodePush triggers to apply an update.

CodePush.js accesses NativeModules.CodePush at import time, so the native module's -init runs during initial bundle evaluation:

-init → initializeUpdateAfterRestart → (if isLoading==YES) rollbackPackage → loadBundle → dispatch_async(main) → reload

On the rollback path specifically, this schedules the reload before the new surface's start() has even been queued (surface start happens via the buffered runtime executor, which only runs once bundle evaluation finishes) — so the race isn't bad luck on this path, it's structurally near-guaranteed to attempt an overlap. localPackage.install.revert.dorevert's third launch (test/template/scenarios/scenarioInstallWithRevert.js installs a bundle that never calls notifyApplicationReady, forcing a rollback on the next launch — see test/test.ts:963-994) exercises exactly this path.

Android's own CodePush.java calls initializeUpdateAfterRestart() from the module's constructor, invoked from MainApplication.getPackages() — i.e. before any JS exists. The bundle-file decision is made and settled before React ever starts, so there's no analogous "reload to correct a decision already made mid-bundle-eval." iOS is the outlier here.

What

Align the iOS rollback mechanism with Android: the rollback decision is made on the native side before any JS is initialized.

Concrete changes:

  • CodePush.bundleURLForResource() (the main entry point that decides which bundle to load) is now performing the rollback and returns the rolled back bundle URL.
  • The native module's init path (initializeUpdateAfterRestart) no longer performs rollbacks, it is now only responsible for "bookkeeping" of pending update state.
  • Give rollbackPackage() a more precise name (discardStuckPendingUpdate()) as it no longer hot-reloads a new bundle, it just "swaps" the active package so that the next launch loads the right one.
  • saveFailedUpdate() can be a class method as it doesn't touch instance state (this whole class is already messy enough, let's prevent future mistakes by limiting who has access to what)

Decisions

IMMEDIATE/restartApp() usage: not covered by this PR, that codepath also has a race condition and leads to a different runtime crash.

notifyApplicationReady() JS API: for users who manually mark an update as working, nothing changes. The rollback handling that this PR touches is based on PendingUpdateIsLoadingKey, which is written by the previous app session (before a crash). So the rollback decision and notifyApplicationReady() call happen in different launches. Also, the Android side already handles rollbacks this way, we are just aligning the iOS side to that.

Bigger refactors of CodePush.m: I resisted the urge to pull apart that large file or introduce Swift or anything like that. I think we should still do these, and I have a few draft prototypes, but those should land as different PRs.

@ofalvai
ofalvai force-pushed the push-pzqxyqkskoyo branch from 1cb6d90 to 15ded75 Compare August 10, 2026 14:03
Moves the rollback decision for a stuck (isLoading==YES) pending update from
-initializeUpdateAfterRestart (runs mid bundle-eval, after JS/Fabric already
exist) into +bundleURLForResource:..., the bridge's source-URL provider,
which runs before any JS or Fabric surface exists. This removes the
in-process reload that raced RN 0.86's Fabric surface teardown/recreate
internals and crashed CI intermittently on localPackage.install.revert.dorevert
(EXC_BAD_ACCESS / SIGSEGV).

The rollback itself (+[CodePushPackage rollbackPackage] + bookkeeping) is
extracted into a new +[CodePush rollbackPendingUpdate] class method, callable
with no bridge/instance around. The old -rollbackPackage instance method
(which called -loadBundle to trigger the racy reload) is now dead and
removed; -saveFailedUpdate: became a class method since it is shared by both
the download-failure path and the new rollback path.

Note: the IMMEDIATE-install/restartApp() reload path has the same underlying
Fabric race and is not addressed here; that needs a separate follow-up.
@ofalvai
ofalvai force-pushed the push-pzqxyqkskoyo branch from 15ded75 to b159320 Compare August 10, 2026 15:59
@ofalvai ofalvai changed the title iOS: roll back stuck pending updates before JS/Fabric starts, fix crash iOS: roll back stuck pending updates before JSstarts, fix crash Aug 11, 2026
@ofalvai
ofalvai marked this pull request as ready for review August 11, 2026 10:48
@ofalvai ofalvai changed the title iOS: roll back stuck pending updates before JSstarts, fix crash iOS: roll back stuck pending updates before JS starts, fix crash Aug 11, 2026
@ofalvai
ofalvai merged commit 07ef4d5 into master Aug 11, 2026
5 checks passed
@ofalvai
ofalvai deleted the push-pzqxyqkskoyo branch August 11, 2026 18:25
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