Skip to content

fix(firestore,ios): guard the shared transactions map against concurrent access - #9209

Open
Matador829 wants to merge 1 commit into
invertase:mainfrom
Matador829:fix-firestore-ios-transaction-map-race
Open

fix(firestore,ios): guard the shared transactions map against concurrent access#9209
Matador829 wants to merge 1 commit into
invertase:mainfrom
Matador829:fix-firestore-ios-transaction-map-race

Conversation

@Matador829

@Matador829 Matador829 commented Aug 20, 2026

Copy link
Copy Markdown

Description

transactions in RNFBFirestoreTransactionModule is a file-scope static NSMutableDictionary
shared by every in-flight Firestore transaction, but every @synchronized in the file locks the
per-transaction transactionState object — never the container. Two transactions beginning at
once therefore hold two different locks while mutating the same dictionary from different
threads. That corrupts its internal hash table, and the next lookup dereferences a null bucket
pointer.

This surfaced as a production crash: EXC_BAD_ACCESS (SIGSEGV), null deref, ~2.4s after launch on
iOS 26.6 / iPhone 15 Pro, from an app whose first-run migration fires several runTransaction()
calls in a loop.

Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
Exception Subtype: KERN_INVALID_ADDRESS at 0x0000000000000000
esr: 0x92000006 (Data Abort) byte read Translation fault

Thread 10 Crashed:
0 CoreFoundation  mdict_index_for_key
1 CoreFoundation  mdict_setObjectForKey
2 CoreFoundation  -[__NSDictionaryM setObject:forKeyedSubscript:]
3 <app>           __53-[RNFBFirestoreTransactionModule transactionBegin:::]_block_invoke
                  (RNFBFirestoreTransactionModule.m:157)
4 <app>           -[FIRFirestore runTransactionWithOptions:block:dispatchQueue:completion:]
                  (FIRFirestore.mm:329)

Frame 3 is the container insert. The crash log also shows the concurrency directly rather than by
inference — four threads were inside transactionBegin's block simultaneously:

Thread Frame
3 the dispatch_async event emit
5 dispatch_semaphore_wait
10 the container insert — crashed
32 dispatch_semaphore_wait

A second, quieter bug in the same file, fixed here too: the three lookup methods open with

@synchronized(transactions[[transactionIdNumber stringValue]]) {
  NSMutableDictionary *transactionState = transactions[[transactionIdNumber stringValue]];
  if (!transactionState) { ...; return; }

When the key is absent that expression is nil, and @synchronized(nil) is a silent no-op — the
lock protects nothing. It also reads the shared dictionary outside any lock in order to acquire the
lock.

The change

A dedicated transactionsLock now guards every access to the container and nothing else: the insert
in transactionBegin, the remove in the completion block, invalidate, and the three lookups. The
lookups resolve the state under the container lock, release it, and only then lock the state.

  • Lock ordering is state → container everywhere. The lookups never hold the container lock while
    acquiring a state lock, so there is no reverse-order hold-and-wait and no new deadlock.
  • The container lock is never held across the semaphore wait or the event dispatch.
  • No public API, behavior, or type changes — purely internal locking.

Related issues

I could not find an existing issue for this (#8715 is a different transactionBegin crash — nil
transactionId). Happy to file one to link against if you'd prefer that for changelog/triage.

Release Summary

Fix an iOS crash (EXC_BAD_ACCESS) when two or more Firestore transactions run concurrently.

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 tests added or updated in packages/**/__tests__
  • I have updated TypeScript types that are affected by my change.
  • This is a breaking change;
    • Yes
    • No

Test Plan

Being a data race in native locking, this resists a deterministic regression test, and I want to be
precise about what I did and did not verify:

  • Verified — the equivalent change, in production. I first applied the structurally identical
    fix to RNFBFirestoreTransactionModule.m on 25.1.0 via patch-package in the app that produced
    the crash log above. That patched file compiles: clang -fsyntax-only -fobjc-arc -fmodules
    against the app's installed Pods headers passes, and the same harness fails on a deliberately
    typo'd copy, so the check isn't vacuous.
  • Verified — the app-side race is gone. In that app I also serialised its transaction call site
    and unit-tested it: 6 transactions fired synchronously in a burst go from 6 concurrent to 1, and
    the test fails without the gate.
  • Verified — formatting. clang-format --style=Google -n -Werror is clean on the changed file
    (and flags a deliberately mangled copy).
  • Not verified — this exact .mm has not been compiled. It imports
    RNFBFirestoreTurboModules.h, which is codegen output I can't resolve without building the
    monorepo, so I have not compiled this file locally. I originally expected CI to cover it, but all
    the substantive workflows on this PR (Code Quality Checks, Testing, Testing E2E iOS, …) are
    sitting at action_required pending maintainer approval, so nothing has compiled it yet
    I'd rather say that plainly than let the checklist imply otherwise. What I can say is that the
    edits here are the same transformation, line for line, as the .m version I did compile, and
    they touch only locking — no TurboModule scaffolding. Approving the workflows should settle it;
    happy to fix anything they surface.
  • Not verified — no on-device before/after repro. The crash is probabilistic; I have the crash
    log as evidence of the failure, not a red/green run.

Note on Android

ReactNativeFirebaseFirestoreTransactionModule.java keeps its handlers in a plain SparseArray,
which is likewise not thread-safe, with no visible synchronization around put/get/delete/
clear. That looks like an analogous issue, though the failure mode differs (JVM data corruption or
a lost/duplicated handler rather than a segfault). I've deliberately left it out of scope — I have
no crash evidence for it and didn't want to widen an unverifiable diff. Happy to follow up if you'd
like it addressed.


🤖 Generated with Claude Code

https://claude.ai/code/session_01GhxoXDyQvDTCxak1GSmVpr

…ent access

`transactions` is a file-scope static NSMutableDictionary shared by every
in-flight Firestore transaction, but every @synchronized in this file locks the
per-transaction `transactionState` object instead of the container. Two
transactions beginning at once therefore hold two different locks while mutating
the same dictionary from different threads, corrupting its internal hash table.
The next lookup dereferences a null bucket pointer and the app dies with
EXC_BAD_ACCESS in mdict_index_for_key.

Add a dedicated `transactionsLock` covering every access to the container and
nothing else: the insert in transactionBegin, the remove in the completion
block, invalidate, and the three lookup methods.

The lookups previously opened with `@synchronized(transactions[key])`, which is
`@synchronized(nil)` -- a silent no-op -- whenever the key is absent, and which
read the shared dictionary outside any lock in order to acquire that lock. They
now resolve the state under the container lock, release it, and only then lock
the state.

Lock ordering is state -> container everywhere, and the lookups never hold the
container lock while acquiring a state lock, so there is no reverse-order
hold-and-wait. The container lock is never held across the semaphore wait or the
event dispatch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GhxoXDyQvDTCxak1GSmVpr
@CLAassistant

Copy link
Copy Markdown

CLA assistant check
Thank you for your submission! We really appreciate it. Like many open source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution.
You have signed the CLA already but the status is still pending? Let us recheck it.

@mikehardy

mikehardy commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Oh hey 👋 - believe it or not this problem is endemic throughout the repository, there are approximately 11 spots where this same pattern happens, including in functions. I've got a PR where I'm working through all of them including this one

Sorry this bit you!

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.

3 participants