Skip to content

refactor: introduce thread-safe RNFBHandleMap and migrate native listener registries repo-wide - #9093

Open
mikehardy wants to merge 18 commits into
mainfrom
functions-threadsafety
Open

refactor: introduce thread-safe RNFBHandleMap and migrate native listener registries repo-wide#9093
mikehardy wants to merge 18 commits into
mainfrom
functions-threadsafety

Conversation

@mikehardy

@mikehardy mikehardy commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

Description

Native listener and handle maps were being read and written from more than one thread (JavaScript/TurboModule entry and Firebase SDK callbacks). That race can crash or drop events when maps such as HashMap, SparseArray, or NSMutableDictionary are updated without synchronization.

A gap analysis identified the same class of problem in ~32 places (functions streaming listeners, firestore snapshot/transaction maps, database transaction/query maps, storage pending tasks, auth listeners and credential/MFA caches, app-check listeners, remote-config update handlers, perf Android trace/metric maps, messaging notification map).

The fix introduces a minimal shared RNFBHandleMap helper (Android + iOS): register handles under a lock with put / get / take / takeAll, then perform SDK cancel/remove and JS emit after the handle is taken out of the map. Package-specific registries wrap HandleMap where lifecycle needs extra rules (for example Functions StreamingHolder, Storage take-then-cancel, Auth putReplacing for caches). One atomic commit per package.

While hardening tests, we found Android unit tests were pinning Robolectric sdk because of a subtle Gradle plugin interaction (DefaultSdkPicker vs compile SDK). This PR switches pure JVM tests to JUnit where Robolectric is not required and drops unnecessary @Config(sdk=…) pins (see hygiene commits).

This PR also starts in-package iOS XCTest infrastructure (RNFBAppUnitTests and per-package unit targets) so we can cover RNFBHandleMap and package registries at 100% on reachable native lines, including cases that full e2e does not exercise reliably.

Related issues

Related PRs

Release Summary

Fixes thread-safety crashes and listener races by routing native handle maps through a shared locked RNFBHandleMap across affected Firebase modules.

Checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
    • Yes
  • My change supports the following platforms;
    • Android
    • iOS
    • Other (macOS, web)
  • My change includes tests;
    • e2e tests added or updated in packages/**/e2e
    • jest / JVM / XCTest unit tests added or updated where native lines are touched
  • I have updated TypeScript types that are affected by my change.
  • This is a breaking change;
    • Yes
    • No

Test Plan

  • Android JVM unit tests (yarn tests:android:unit) + Jacoco on touched registries/HandleMap
  • iOS in-package XCTest (yarn tests:ios:unit) + LCOV merge for HandleMap and package registries
  • Full pre-merge e2e with coverage: iOS 1463/0, Android 1487/0, macOS 1283/0
  • Functions e2e refactored to use shared getE2eEmulatorHost()

Internal note (maintainers only)

Linear tracking for merge automation only — not a public GitHub issue closure.

Fixes CPRN-365.

@mikehardy
mikehardy requested a review from russellwheatley July 8, 2026 23:50
@cursor

cursor Bot commented Jul 8, 2026

Copy link
Copy Markdown

Bugbot is not enabled for this team, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request improves the thread-safety of Android functions streaming by synchronizing access to listener management and adjusting the cleanup logic to prevent lost events. Additionally, it enforces stricter quality standards by integrating a mandatory coverage evidence gate for native bridge and library changes, supported by updated documentation and new e2e test cases.

Highlights

  • Thread-Safety Hardening: Synchronized access to the Android functionsStreamingListeners SparseArray to prevent race conditions during stream lifecycle events.
  • Lifecycle Management: Deferred listener removal until explicit cancellation or module invalidation, ensuring final stream events are correctly emitted even if a race condition occurs.
  • Coverage Evidence Gate: Introduced a mandatory coverage_evidence_gate requiring documented proof of coverage for native bridge and library changes before review completion.
  • E2E Testing: Added new end-to-end tests covering native streaming error paths and early stream cancellation cleanup.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a new coverage_evidence gate to the validation and documentation workflow, detailing evidence requirements and anti-patterns. In the Android codebase, thread synchronization was added around functionsStreamingListeners to prevent race conditions. However, the review feedback correctly identifies that removing the listener cleanup from the success, error, and exception paths in NativeRNFBTurboFunctions introduces memory leaks, as completed or failed listeners are never removed from the static map. The reviewer provides actionable suggestions to safely remove these listeners from the map without triggering a cancellation.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

@mikehardy
mikehardy force-pushed the functions-threadsafety branch from c5187ce to b04fb15 Compare July 9, 2026 00:04
@cursor

cursor Bot commented Jul 9, 2026

Copy link
Copy Markdown

Bugbot is not enabled for this team, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.06383% with 135 lines in your changes missing coverage. Please review.
✅ Project coverage is 68.78%. Comparing base (4ef21d0) to head (1229270).

Additional details and impacted files
@@             Coverage Diff              @@
##               main    #9093      +/-   ##
============================================
+ Coverage     68.36%   68.78%   +0.42%     
- Complexity     1914     2066     +152     
============================================
  Files           516      434      -82     
  Lines         37853    25115   -12738     
  Branches       5183     4217     -966     
============================================
- Hits          25875    17273    -8602     
+ Misses        10187     6493    -3694     
+ Partials       1791     1349     -442     
Flag Coverage Δ
android-native 65.15% <76.07%> (+1.40%) ⬆️
e2e-ts-android 54.11% <ø> (+0.02%) ⬆️
e2e-ts-ios 53.51% <ø> (ø)
e2e-ts-macos 49.74% <ø> (+0.06%) ⬆️
ios-native ?
ios-ruby 100.00% <ø> (ø)
jest 45.48% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Comment thread okf-bundle/testing/coverage-design.md
@github-actions

Copy link
Copy Markdown

Hello 👋, this PR has been opened for more than 14 days with no activity on it.

If you think this is a mistake please comment and ping a maintainer to get this merged ASAP! Thanks for contributing!

You have 7 days until this gets closed automatically

@github-actions github-actions Bot added the Stale label Jul 23, 2026
@mikehardy

Copy link
Copy Markdown
Collaborator Author

will come back to this shortly

Synchronize access to the streaming listeners SparseArray and defer
removal until explicit cancel or invalidate so final stream events are
not dropped under concurrent unsubscribe.

Add e2e coverage for native streaming errors and early stream cancel.
Add coverage_evidence_gate, mandatory Jacoco/lcov evidence package,
NYC anti-pattern, and subagent return fields so native/lib bridge
changes cannot close review without per-file coverage proof.
Replace unlocked SparseArray/NSMutableDictionary listener and transaction maps with HandleMap registries so snapshot and begin-transaction ids cannot race on JS vs SDK threads.
Replace unlocked SparseArray/NSMutableDictionary PENDING_TASKS with HandleMap so pause, resume, cancel, and complete cannot race on the same task id.
The H6 HandleMap wiring dropped a bracket so getQueryNamed never received its completion block and iOS builds failed.
Replace unlocked SparseArray/HashMap/NSMutableDictionary transaction and query listener maps with HandleMap so JS and SDK threads cannot race on the same id.
@mikehardy
mikehardy force-pushed the functions-threadsafety branch from b04fb15 to 1229270 Compare August 22, 2026 12:23
@mikehardy mikehardy changed the title fix(functions): thread-safe Android streaming listener lifecycle refactor: introduce thread-safe RNFBHandleMap and migrate native listener registries repo-wide Aug 22, 2026
@mikehardy

Copy link
Copy Markdown
Collaborator Author

Updated - finally ready to go on this odyssey

please note - patch coverage is lower because I specifically accepted uncovered lines in the native code. I explored how to cover them but it required pulling in Firebase and other stuff during unit tests which wouldn't work well, or having bridge/helper intermediary objects which lowered cohesion just for some coverage. In the end, I chose to make sure the new handler object had 100% coverage but I accepted gaps in some areas of it's usage in all the packages.

@russellwheatley russellwheatley left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes on the storage cancel regression and the non-atomic replace pattern. The RNFBHandleMap approach is right, and cancelling outside the lock from the last round is resolved cleanly.

Blocking:

  • RNFBStorageTaskRegistry.takeAndCancel changes what StorageTask.cancel() returns to JS and orphans paused uploads.
  • putReplacing / putOrSkip / putOrDiscard are all take then put outside the lock, so replace is still a read-modify-write race. Two atomic primitives on RNFBHandleMap fix every call site at once.
  • Perf putOrDiscard(id, trace, Trace::stop) reports the discarded trace instead of dropping it, and stopTrace takes the handle before the work that can throw.

Worth a decision, not blocking:

  • Several call sites turned a silent overwrite into an exception reachable from JS input. Storage task ids come from a JS module counter that resets on reload while PENDING_TASKS is static, so it is reachable in dev.
  • Messaging duplicate ids flipped to first-wins while the message store still takes the new message.

Comment on lines +46 to +53
public void put(K id, V handle) throws RNFBHandleCollisionException {
synchronized (lock) {
if (map.containsKey(id)) {
throw new RNFBHandleCollisionException(id);
}
map.put(id, handle);
}
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Making put unique with no upsert pushes every replace call site into a non-atomic take then put: RNFBAuthCacheRegistry.putReplacing, RNFBFirestoreTransactionRegistry.putOrSkip, RNFBPerfHandleRegistry.putOrDiscard, RNFBStorageTaskRegistry.putOrDiscard, RNFBMessagingNotificationRegistry.putOrDiscard. Each of those is a read-modify-write outside the lock, which is the bug class this PR exists to remove.

Two more primitives here collapse all of them into one call:

Suggested change
public void put(K id, V handle) throws RNFBHandleCollisionException {
synchronized (lock) {
if (map.containsKey(id)) {
throw new RNFBHandleCollisionException(id);
}
map.put(id, handle);
}
}
public void put(K id, V handle) throws RNFBHandleCollisionException {
synchronized (lock) {
if (map.containsKey(id)) {
throw new RNFBHandleCollisionException(id);
}
map.put(id, handle);
}
}
/** Stores {@code handle} only when {@code id} is free. Returns the existing handle, or null. */
public V putIfAbsent(K id, V handle) {
synchronized (lock) {
V existing = map.get(id);
if (existing != null) {
return existing;
}
map.put(id, handle);
return null;
}
}
/** Stores {@code handle} unconditionally. Returns the displaced handle, or null. */
public V putReplacing(K id, V handle) {
synchronized (lock) {
return map.put(id, handle);
}
}

Callers still cancel or stop the returned handle after the call, so "the lock only moves pointers" still holds. Same on the iOS side.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This upsert decision was one I struggled with - I like the idea of extending the API even though I was trying to keep it very small - these additional primitives allow upsert to work more cleanly for those that need it

👍

Comment on lines +60 to +68
/** Take then cancel outside the HandleMap lock. Returns {@code false} when no mapping existed. */
boolean takeAndCancel(int taskId) {
StoragePendingHandle handle = map.take(taskId);
if (handle == null) {
return false;
}
handle.cancel();
return true;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This changes what JS gets back from StorageTask.cancel(). The old cancelTaskById returned storageTask.cancel(), and ReactNativeFirebaseStorageTask.cancel() only removes itself from the map inside the isInProgress() branch. Here the entry is taken unconditionally and the handle.cancel() result is thrown away, so:

  • cancel() resolves true for a task that was paused or not yet in progress, where it used to resolve false.
  • a paused upload is dropped from PENDING_TASKS even though nothing was cancelled, so no later pause, resume or cancel can reach it. Same in the window where storageTask is still null (the task registers itself in the constructor, before the StorageTask is assigned), which leaves an upload running and untracked.
Suggested change
/** Take then cancel outside the HandleMap lock. Returns {@code false} when no mapping existed. */
boolean takeAndCancel(int taskId) {
StoragePendingHandle handle = map.take(taskId);
if (handle == null) {
return false;
}
handle.cancel();
return true;
}
/** Cancel outside the HandleMap lock. Returns the underlying cancel result. */
boolean takeAndCancel(int taskId) {
StoragePendingHandle handle = map.get(taskId);
if (handle == null) {
return false;
}
// A successful cancel already removes the mapping via ReactNativeFirebaseStorageTask.destroyTask().
boolean cancelled = handle.cancel();
if (cancelled) {
map.take(taskId);
}
return cancelled;
}

Worth an e2e for cancelling a paused upload, there is none today.

Comment on lines +53 to +56
try {
PENDING_TASKS.put(taskId, this);
} catch (RNFBHandleCollisionException collision) {
throw new IllegalStateException(collision.getMessage());

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicate task ids used to overwrite silently, now they throw out of a constructor. TASK_ID in packages/storage/lib/StorageTask.ts is a module-level counter that resets to 0 on every JS reload, while PENDING_TASKS is static and outlives the JS context. Reload with an upload in flight and the next putFile throws from native.

With an atomic putReplacing on RNFBHandleMap this becomes one call: replace, then cancel the displaced task outside the lock. That keeps the old last-wins behavior without a JS-reachable throw.

Comment on lines +58 to +61
void putReplacing(String key, V value) {
map.take(key);
putOrDiscard(key, value);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

take then putOrDiscard is not atomic. Two threads replacing the same key interleave as take, take, put, put, and the second value is silently dropped while the caller believes it stored. getSession and getJSError still hand the sessionId back to JS, so a later resolveMultiFactorSignIn looks up a resolver that was never cached.

Suggested change
void putReplacing(String key, V value) {
map.take(key);
putOrDiscard(key, value);
}
void putReplacing(String key, V value) {
map.putReplacing(key, value);
}

Needs the atomic putReplacing on RNFBHandleMap, see the comment there.

Comment on lines +77 to +79
if (!traces.putOrDiscard(id, trace, Trace::stop)) {
throw new IllegalStateException("perf trace id already registered: " + id);
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Trace::stop is not a discard, it is how a trace gets reported to Firebase. On collision this sends a bogus near-zero-duration trace under the same name and then throws. Drop the handle instead. Same shape at lines 117, 143, 195, 226 and 243.

The throw is also reachable from JS input, startTrace used to overwrite and now rejects. With an atomic putReplacing you can keep the old behavior and stop the displaced handle outside the lock if you do want it recorded.

return Tasks.call(
() -> {
Trace trace = traces.get(id);
Trace trace = traces.take(id);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The handle is taken before the attributes and metrics are applied. putAttribute throws on an invalid name, and if it does the trace is already out of the map so trace.stop() is never reached and nothing can stop or retry it. Before, the entry survived the failure. Use get for the work and take immediately before stop(). Same at 153, 201 and 249.

// Unique put: duplicate message ids keep the first (no HashMap upsert).
if (remoteMessage.getNotification() != null) {
notifications.put(remoteMessage.getMessageId(), remoteMessage);
notifications.putOrDiscard(remoteMessage.getMessageId(), remoteMessage);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This flips duplicate message ids from last-wins to first-wins, and the storeFirebaseMessage call below always stores the new message, so the in-memory map and the store can now disagree for the same id. The map is static and only drained when a notification is opened, so a redelivered FCM message keeps the stale copy. putReplacing keeps the previous behavior.

}

- (BOOL)put:(id)key value:(id)value error:(NSError **)error {
@synchronized(self) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking. The header tells callers never to @synchronized a RNFBHandleMap instance, but nothing enforces it and the Android side already uses a private lock object for exactly this reason. Locking on a private ivar makes the contract unbreakable rather than documented.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants