Skip to content

Fix race in UiThreadUtil main handler lazy initialization - #58963

Open
locnp-active wants to merge 1 commit into
react:mainfrom
locnp-active:fix-uithreadutil-lazy-race
Open

locnp-active wants to merge 1 commit into
react:mainfrom
locnp-active:fix-uithreadutil-lazy-race

Conversation

@locnp-active

Copy link
Copy Markdown

Summary:

UiThreadUtil keeps its main-thread Handler in a property delegated to lazy(LazyThreadSafetyMode.NONE):

private val mainHandler: Handler by
    lazy(LazyThreadSafetyMode.NONE) { Handler(Looper.getMainLooper()) }

NONE creates an UnsafeLazyImpl, whose getter is:

if (_value === UNINITIALIZED_VALUE) {
  _value = initializer!!()
  initializer = null
}
return _value as T

mainHandler is first read by whichever thread first calls runOnUiThread(), getUiThreadHandler() or removeOnUiThread(). isOnUiThread() and the assert* helpers only use Looper, so the main thread calling them does not initialize it. In practice the first reads usually come from background threads posting to the UI thread, such as the JS thread, the native modules thread, or a library's executor. When two of them make their first call at the same time:

  1. Thread A sees _value === UNINITIALIZED_VALUE and starts the initializer.
  2. Thread B also sees UNINITIALIZED_VALUE, because A has not written _value yet.
  3. A writes _value and sets initializer = null.
  4. B evaluates initializer!!() and throws NullPointerException.

The same NPE can happen without the two calls overlapping in time. _value and initializer are plain fields, so on a weakly ordered CPU (ARM) another thread can observe initializer == null before it observes the new _value.

The exception is thrown from the accessor Kotlin generates for the delegated property, so crash reports show the top frame as com.facebook.react.bridge.UiThreadUtil.getMainHandler. Only the first access in a process can race, so the crash is rare but fatal and usually happens during startup.

This was introduced in #50536 (1033584), which migrated UiThreadUtil to Kotlin. The Java implementation used double-checked locking on a volatile field. A reviewer suggested plain by lazy { }, which is synchronized by default, but the merged code uses LazyThreadSafetyMode.NONE. Every release from 0.80 to 0.88.0-rc.4 includes it.

This PR switches the property to LazyThreadSafetyMode.SYNCHRONIZED, restoring the guarantee of the Java implementation. The lock is only taken until the value is initialized. After that, reads are a single volatile read, so the runOnUiThread hot path is effectively unchanged.

Changelog:

[ANDROID] [FIXED] - Fix crash in UiThreadUtil when the main handler is first accessed from multiple threads at once

Test Plan:

The race only happens on the first access in a process and cannot be reproduced reliably in a unit test. It can be confirmed by:

  1. Kotlin stdlib: UnsafeLazyImpl (NONE) is documented as unsafe for multi-threaded access. SynchronizedLazyImpl (SYNCHRONIZED) guarantees a single initialization and safe publication.
  2. Bytecode: in a release APK built with React Native 0.86.0, UiThreadUtil.<clinit> creates the lazy with LazyThreadSafetyMode.NONE. With this change it uses SYNCHRONIZED.
  3. Behavior: the public API and the value returned are unchanged. The only difference is how the handler is initialized the first time.

@meta-cla

meta-cla Bot commented Oct 9, 2026

Copy link
Copy Markdown

Hi @locnp-active!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 9, 2026
@meta-cla

meta-cla Bot commented Oct 9, 2026

Copy link
Copy Markdown

Thank you for signing our Contributor License Agreement. We can now accept your code for this (and any) Meta Open Source project. Thanks!

@facebook-github-tools facebook-github-tools Bot added the Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team. label Oct 9, 2026

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. Shared with Meta Applied via automation to indicate that an Issue or Pull Request has been shared with the team.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant