Skip to content

[RC] Fix data race in RCNConfigExperiment (#16303)#16326

Open
paulb777 wants to merge 18 commits into
mainfrom
fix-remoteconfig-race-16303
Open

[RC] Fix data race in RCNConfigExperiment (#16303)#16326
paulb777 wants to merge 18 commits into
mainfrom
fix-remoteconfig-race-16303

Conversation

@paulb777

@paulb777 paulb777 commented Jun 27, 2026

Copy link
Copy Markdown
Member

Description

Fixes a data race condition in RCNConfigExperiment ivars (_experimentPayloads, _experimentMetadata, _activeExperimentPayloads) when activate() is called shortly after initialization, concurrently racing with the asynchronous experiment DB load completion block.

This update refactors and narrows the synchronization locks (@synchronized(self)) to cover only quick in-memory variable accesses and mutations. Expensive operations (such as JSON serialization, database operations, and calls to external controller methods) have been moved completely outside critical sections to prevent deadlocks and lock contention.

Changes

  1. Narrowed Thread Synchronization: Wrapped state changes and in-memory cache accesses in narrow @synchronized(self) locks inside RCNConfigExperiment.m.
  2. Lockless External Invocations:
    • In updateExperimentsWithResponse:, performed JSON payload serialization and _DBManager operations outside the lock.
    • In updateExperimentsWithHandler:, resolved nested locking by retrieving state parameters inside short independent locks, calling updateExperimentStartTime and the external experimentController outside the lock.
    • In updateExperimentStartTime, performed JSON metadata serialization and DB writes outside the lock.
    • In updateActiveExperimentsInDBWithPayloads:, performed _DBManager delete/insert calls outside the lock.
    • In latestStartTimeWithExistingLastStartTime:, copied payloads under a short lock and queried the external controller outside the lock.
  3. Backing Properties Safeguard: Synthesized and implemented custom thread-safe getters and setters for the experiment properties. Changed property types in class extension/tests to immutable NSArray and NSDictionary to prevent unintended caller modifications, returning immutable copies from the getters to resolve races.
  4. Defensive Programming: Handles nil values in setters defensively by fallback-initializing backing mutable ivars to empty collections.
  5. Robust Thread-safe Unit Testing: Added testConcurrentAccess in RCNConfigExperimentTest.m which verifies correctness under high concurrent stress. Introduced a custom thread-safe test double (RCNConfigDBManagerFake) which overrides database setup and operation methods, entirely bypassing OCMock for concurrent calls to avoid OCMock internal race conditions. Added a 10-second timeout to prevent test hangs.

Fixes #16303

@gemini-code-assist

Copy link
Copy Markdown
Contributor
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.

@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@paulb777 paulb777 changed the title Fix data race in RCNConfigExperiment (#16303) [RC] Fix data race in RCNConfigExperiment (#16303) Jun 27, 2026

@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 addresses a data race condition in RCNConfigExperiment by introducing @synchronized blocks to synchronize access to internal properties (experimentPayloads, experimentMetadata, and activeExperimentPayloads) and their corresponding database update methods. It also adds a concurrent access unit test. The review feedback highlights several critical thread-safety issues: first, a race condition exists in updateExperimentsWithHandler: because updateActiveExperimentsInDB is called outside the synchronized block, meaning it could persist modified payloads instead of the ones that were actually activated. Second, the custom getters for mutable properties return the internal mutable collections directly, which is not thread-safe for external callers; they should return mutable copies instead.

Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@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 addresses a data race condition in RCNConfigExperiment by adding @synchronized blocks to synchronize access to experiment payloads, metadata, and active payloads, and introduces a concurrent access unit test. The review feedback highlights that OCMock is not thread-safe and should not be used concurrently in the new test. Additionally, the custom setters should defensively handle nil inputs to prevent silent failures, and the custom getters should return immutable copies to avoid misleading callers and reduce unnecessary memory allocations.

Comment thread FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@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 addresses a data race condition in RCNConfigExperiment by changing its experiment payload and metadata properties to immutable types and adding thread-safe getters, setters, and synchronization blocks around direct instance variable accesses. It also introduces a concurrent access unit test. The review feedback highlights two important improvements: first, _experimentController is uninitialized (nil) in the new concurrent test, which should be resolved by instantiating it locally; second, the raw _experimentPayloads instance variable is passed to the experiment controller in latestStartTimeWithExistingLastStartTime:, which should be replaced with the thread-safe self.experimentPayloads property to prevent potential thread-safety issues.

Comment thread FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@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 addresses a data race condition in RCNConfigExperiment by converting experiment payloads and metadata properties to immutable types and wrapping their accessors and modification methods in @synchronized blocks. A concurrent access unit test was also introduced to verify thread safety. The review feedback recommends avoiding DISPATCH_TIME_FOREVER in the new unit test to prevent potential indefinite hangs in CI/CD pipelines, and optimizing latestStartTimeWithExistingLastStartTime: by directly accessing the backing instance variable _experimentPayloads to avoid a redundant reentrant lock and array copy.

Comment thread FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m
@paulb777 paulb777 marked this pull request as ready for review June 27, 2026 23:38
@paulb777 paulb777 added this to the 12.16.0 - M183 milestone Jun 28, 2026
@ncooke3 ncooke3 self-requested a review June 29, 2026 18:16

@ncooke3 ncooke3 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.

Is the @synchronized(self) prone to deadlock? See bot's review below.

bot review
### Critical Concerns / Blocking Issues

* Holding instance-level locks (`@synchronized(self)`) during database interactions and calls to external controllers introduces severe risks of lock contention, priority inversion, and deadlocks. State locks should only cover the mutation/access of the in-memory variables.

### Concurrency & Memory

Status: Block

* `FirebaseRemoteConfig/Sources/RCNConfigExperiment.m` / Lines 164-185: Holding `@synchronized(self)` across an entire loop that performs JSON serialization and makes `_DBManager` insertion calls is too broad. This blocks any thread attempting to read experiment state while these expensive operations occur. Prepare the `JSONPayload` objects and update the internal array inside a short lock, then execute the DB manager calls outside of the lock.
* `FirebaseRemoteConfig/Sources/RCNConfigExperiment.m` / Lines 215-236: The `updateExperimentStartTime` method calls `[_DBManager insertExperimentTableWithKey:...]` while holding the `self` lock. If the DB manager shares a queue that calls back into this class, it will deadlock. Copy the `_experimentMetadata`, perform the JSON serialization, and invoke the DB manager outside the critical section.
* `FirebaseRemoteConfig/Sources/RCNConfigExperiment.m` / Lines 241-249: `updateActiveExperimentsInDBWithPayloads:` delegates to `_DBManager` inside the lock. Extract the DB deletion and insertion logic outside of `@synchronized(self)`.
* `FirebaseRemoteConfig/Sources/RCNConfigExperiment.m` / Lines 255-259: `latestStartTimeWithExistingLastStartTime:` invokes an external object (`self.experimentController`) while holding the lock. Calling external components from within a lock is a known deadlock risk. Copy `_experimentPayloads` locally within the lock, then pass the copy to the controller outside the lock.
* `FirebaseRemoteConfig/Sources/RCNConfigExperiment.m` / Lines 219-220: `updateExperimentStartTime` calls `latestStartTimeWithExistingLastStartTime:`. Both methods synchronize on `self`. While Objective-C `@synchronized` blocks are re-entrant and will not self-deadlock, this indicates a flawed locking hierarchy and adds unnecessary overhead. Refactor to use a private, unlocked helper method for internal calls where the lock is already acquired.

Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
Comment on lines +31 to +34
@property(nonatomic, copy) NSArray<NSData *> *experimentPayloads; ///< Experiment payloads.
@property(nonatomic, copy)
NSDictionary<NSString *, id> *experimentMetadata; ///< Experiment metadata
@property(nonatomic, copy)

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.

Are these properties ever actually accessed as properties? The methods below access the instance variables directly, and the synchronized properties aren't referenced in areas where the critical section can ever only be the property access itself. Since these are private properties I think it would be better to get rid of them and just use the instance variable consistently throughout or make the property get/set methods nonlocking and use those instead of directly accessing instance variables.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

They're used as extension properties for unit testing. See irebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m below.

@paulb777

Copy link
Copy Markdown
Member Author

Is the @synchronized(self) prone to deadlock? See bot's review below.

Good catch! Every @synchronized block in RCNConfigExperiment.m has been strictly narrowed to perform quick, thread-safe in-memory reads, writes, or collection copying

Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m

@ncooke3 ncooke3 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.

review feedback (drop-down)
### Critical Concerns / Blocking Issues

* Database delete and insert operations have been moved outside of critical sections, creating a data race where concurrent operations can interleave and corrupt the persistent database state.
* Disjoint locks across `updateExperimentsWithHandler:` and its helper methods lead to time-of-check to time-of-use (TOCTOU) inconsistencies, resulting in mismatched state being passed to the external experiment controller.

### Concurrency & Thread Safety

Status: Block

* [RCNConfigExperiment.m/Lines 199-204] `updateExperimentsWithResponse:` executes `deleteExperimentTableForKey:` and subsequent `insertExperimentTableWithKey:` calls sequentially without synchronization. If called concurrently, one thread's insertions could be overwritten or duplicated by another thread. These operations must be atomic (e.g., serialized via a dedicated database dispatch queue).
* [RCNConfigExperiment.m/Lines 286-291] The database delete and insert sequence in `updateActiveExperimentsInDBWithPayloads:` is completely unprotected. This creates an identical interleaving vulnerability. Ensure database atomicity.
* [RCNConfigExperiment.m/Lines 211-222] `updateExperimentsWithHandler:` retrieves `lastStartTime`, calls `updateExperimentStartTime`, and captures `payloadsCopy` in separate, disjoint locks. If another thread mutates `_experimentPayloads` or `_experimentMetadata` between these locks, the state provided to the `experimentController` will be fundamentally inconsistent. Capture a single consistent snapshot of all required state at the beginning of the method.

### Test Coverage & Platform Support

Status: Comment

* [RCNConfigExperimentTest.m/Lines 39-41] The property redeclarations in the test class extension omit the lightweight generics (e.g., `<NSData *>`) present in the main source file's implementation. Add the generics to match the source file to maintain strict type consistency.

Comment thread FirebaseRemoteConfig/CHANGELOG.md Outdated
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@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 addresses a data race condition in RCNConfigExperiment by converting internal collections to immutable types, implementing thread-safe getters and setters using @synchronized, and offloading database operations to a new serial dispatch queue (experimentDBQueue). Additionally, a concurrent access unit test has been added to verify these thread-safety improvements. Feedback was provided regarding a potential issue where a nil experimentController could prevent the completion handler from being executed, which could cause callers to hang indefinitely.

Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@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 addresses a data race condition in RCNConfigExperiment by converting internal properties to immutable types with thread-safe accessors, introducing a serial dispatch queue for database operations, and synchronizing access to state variables. Feedback suggests copying the payloads parameter in updateActiveExperimentsInDBWithPayloads: to prevent potential crashes from nil arguments or concurrent mutations.

Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@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 addresses a data race condition in RCNConfigExperiment by converting internal state properties to immutable types, implementing thread-safe getters and setters using @synchronized, and introducing a serial dispatch queue (experimentDBQueue) for database operations. Additionally, a concurrent access unit test has been added to verify thread safety. The review feedback suggests moving a nil check for self.experimentController to the beginning of updateExperimentsWithHandler: to prevent logical side effects, correcting a mismatched method signature for updateExperimentStartTime in the test file, and improving error logging when parsing activated experiment payloads.

Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
Comment thread FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m Outdated
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m Outdated
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@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 addresses a data race condition in RCNConfigExperiment by converting properties to immutable types, adding synchronized accessors, and introducing a serial dispatch queue for database operations. The review feedback points out a potential race condition where database loads could overwrite newer in-memory state, and suggests validating JSON payloads before serialization to prevent crashes. Additionally, for the concurrent unit test, it is recommended to expose and flush the serial database queue to prevent background thread leaks and test flakiness.

Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m
Comment thread FirebaseRemoteConfig/Sources/RCNConfigExperiment.m
Comment thread FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m
Comment thread FirebaseRemoteConfig/Tests/Unit/RCNConfigExperimentTest.m
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

1 similar comment
@paulb777

Copy link
Copy Markdown
Member Author

/gemini review

@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 addresses a data race condition in RCNConfigExperiment by introducing thread-safe property accessors, synchronized blocks, and a serial dispatch queue for database operations. The review feedback identifies two critical concurrency issues: first, a potential loss of metadata and active payloads if a concurrent fetch completes before the database load, which can be resolved by tracking updates to individual components with separate flags; second, a race condition in updateExperimentsWithHandler: where an inconsistent payload snapshot could cause new experiments to be silently skipped, which can be fixed by passing a consistent payload snapshot to updateExperimentStartTime.

Comment on lines +43 to +48
@implementation RCNConfigExperiment {
NSMutableArray<NSData *> *_experimentPayloads;
NSMutableDictionary<NSString *, id> *_experimentMetadata;
NSMutableArray<NSData *> *_activeExperimentPayloads;
BOOL _databaseLoadCompleted;
}

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.

high

Issue: Potential Loss of Metadata and Active Payloads on Concurrent Fetch

If updateExperimentsWithResponse: is called before the database load completes, it sets _databaseLoadCompleted = YES. When the database load completion block eventually runs, the entire block inside @synchronized(strongSelf) is skipped because !strongSelf->_databaseLoadCompleted evaluates to false.

This results in:

  1. _experimentMetadata remaining empty (losing the stored last_experiment_start_time).
  2. _activeExperimentPayloads remaining empty (losing the active experiments).

Additionally, if updateExperimentsWithHandler: runs before the database load completes, it will update the metadata and active payloads. When the database load subsequently completes, it will overwrite these newly updated values with the stale ones from the database because _databaseLoadCompleted is still NO.

Recommendation

Instead of a single _databaseLoadCompleted flag, track whether each component (payloads, metadata, activePayloads) has been updated by an external/user action before the database load completes. This ensures that we only skip loading a specific component from the database if it has already been updated with newer data, while still loading the other components safely.

Let's add three new boolean flags to track this.

Suggested change
@implementation RCNConfigExperiment {
NSMutableArray<NSData *> *_experimentPayloads;
NSMutableDictionary<NSString *, id> *_experimentMetadata;
NSMutableArray<NSData *> *_activeExperimentPayloads;
BOOL _databaseLoadCompleted;
}
@implementation RCNConfigExperiment {
NSMutableArray<NSData *> *_experimentPayloads;
NSMutableDictionary<NSString *, id> *_experimentMetadata;
NSMutableArray<NSData *> *_activeExperimentPayloads;
BOOL _databaseLoadCompleted;
BOOL _payloadsUpdated;
BOOL _metadataUpdated;
BOOL _activePayloadsUpdated;
}

Comment on lines +167 to +182
@synchronized(strongSelf) {
if (!strongSelf->_databaseLoadCompleted) {
if (parsedPayloads) {
[strongSelf->_experimentPayloads removeAllObjects];
[strongSelf->_experimentPayloads addObjectsFromArray:parsedPayloads];
}
if (parsedMetadata) {
strongSelf->_experimentMetadata = parsedMetadata;
}
if (parsedActivePayloads) {
[strongSelf->_activeExperimentPayloads removeAllObjects];
[strongSelf->_activeExperimentPayloads addObjectsFromArray:parsedActivePayloads];
}
strongSelf->_databaseLoadCompleted = YES;
}
}

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.

high

Update the database load completion block to selectively load each component only if it has not been updated by a concurrent operation yet.

    @synchronized(strongSelf) {
      if (!strongSelf->_payloadsUpdated && parsedPayloads) {
        [strongSelf->_experimentPayloads removeAllObjects];
        [strongSelf->_experimentPayloads addObjectsFromArray:parsedPayloads];
      }
      if (!strongSelf->_metadataUpdated && parsedMetadata) {
        strongSelf->_experimentMetadata = parsedMetadata;
      }
      if (!strongSelf->_activePayloadsUpdated && parsedActivePayloads) {
        [strongSelf->_activeExperimentPayloads removeAllObjects];
        [strongSelf->_activeExperimentPayloads addObjectsFromArray:parsedActivePayloads];
      }
      strongSelf->_databaseLoadCompleted = YES;
    }

Comment on lines +207 to +212
@synchronized(self) {
_databaseLoadCompleted = YES;
// cache fetched experiment payloads.
[_experimentPayloads removeAllObjects];
[_experimentPayloads addObjectsFromArray:serializedPayloads];
}

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.

high

Set the _payloadsUpdated flag to YES when the payloads are updated concurrently.

Suggested change
@synchronized(self) {
_databaseLoadCompleted = YES;
// cache fetched experiment payloads.
[_experimentPayloads removeAllObjects];
[_experimentPayloads addObjectsFromArray:serializedPayloads];
}
@synchronized(self) {
_payloadsUpdated = YES;
_databaseLoadCompleted = YES;
// cache fetched experiment payloads.
[_experimentPayloads removeAllObjects];
[_experimentPayloads addObjectsFromArray:serializedPayloads];
}

Comment on lines +275 to +285
@synchronized(self) {
// Check if the calculated start time is newer or equal to what is currently stored,
// avoiding out-of-order overwrites from logical races during concurrent operations.
NSTimeInterval currentStoredTime =
[_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
if (latestStartTime >= currentStoredTime) {
_experimentMetadata[kExperimentMetadataKeyLastStartTime] = @(latestStartTime);
metadataCopy = [_experimentMetadata copy];
shouldUpdate = YES;
}
}

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.

high

Set the _metadataUpdated flag to YES when the metadata is updated concurrently.

Suggested change
@synchronized(self) {
// Check if the calculated start time is newer or equal to what is currently stored,
// avoiding out-of-order overwrites from logical races during concurrent operations.
NSTimeInterval currentStoredTime =
[_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
if (latestStartTime >= currentStoredTime) {
_experimentMetadata[kExperimentMetadataKeyLastStartTime] = @(latestStartTime);
metadataCopy = [_experimentMetadata copy];
shouldUpdate = YES;
}
}
@synchronized(self) {
_metadataUpdated = YES;
// Check if the calculated start time is newer or equal to what is currently stored,
// avoiding out-of-order overwrites from logical races during concurrent operations.
NSTimeInterval currentStoredTime =
[_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
if (latestStartTime >= currentStoredTime) {
_experimentMetadata[kExperimentMetadataKeyLastStartTime] = @(latestStartTime);
metadataCopy = [_experimentMetadata copy];
shouldUpdate = YES;
}
}

Comment on lines +315 to 319
@synchronized(self) {
/// Put current fetched experiment payloads into activated experiment DB.
[_activeExperimentPayloads removeAllObjects];
[_activeExperimentPayloads addObjectsFromArray:payloadsCopy];
}

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.

high

Set the _activePayloadsUpdated flag to YES when the active payloads are updated concurrently.

Suggested change
@synchronized(self) {
/// Put current fetched experiment payloads into activated experiment DB.
[_activeExperimentPayloads removeAllObjects];
[_activeExperimentPayloads addObjectsFromArray:payloadsCopy];
}
@synchronized(self) {
_activePayloadsUpdated = YES;
/// Put current fetched experiment payloads into activated experiment DB.
[_activeExperimentPayloads removeAllObjects];
[_activeExperimentPayloads addObjectsFromArray:payloadsCopy];
}

}

// Update the last experiment start time with the latest payload.
[self updateExperimentStartTime];

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.

high

Issue: Race Condition / Inconsistent Payload Snapshot in Experiment Activation

In updateExperimentsWithHandler:, a consistent snapshot of the payloads is captured at the beginning (payloadsCopy). However, [self updateExperimentStartTime] is then called, which internally takes a new snapshot of _experimentPayloads via latestStartTimeWithExistingLastStartTime:.

If updateExperimentsWithResponse: runs concurrently between these two steps, updateExperimentStartTime will calculate the new start time based on the newer payloads, updating _experimentMetadata[kExperimentMetadataKeyLastStartTime] to that newer timestamp.
But updateExperimentsWithHandler: will proceed to call updateExperimentsWithServiceOrigin:... using the older payloadsCopy.

When the newer payloads are eventually activated later, FIRExperimentController will see that the stored lastStartTime is already equal to or greater than their start time, causing those new experiments to be silently skipped and never set to Firebase Analytics.

Recommendation

Refactor updateExperimentStartTime to accept the payloads array as a parameter (e.g., updateExperimentStartTimeWithPayloads:), and pass the consistent payloadsCopy snapshot to it.

Suggested change
[self updateExperimentStartTime];
[self updateExperimentStartTimeWithPayloads:payloadsCopy];

Comment on lines 264 to 311
- (void)updateExperimentStartTime {
NSTimeInterval existingLastStartTime =
[_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
NSTimeInterval existingLastStartTime;
@synchronized(self) {
existingLastStartTime = [_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
}

NSTimeInterval latestStartTime =
[self latestStartTimeWithExistingLastStartTime:existingLastStartTime];

_experimentMetadata[kExperimentMetadataKeyLastStartTime] = @(latestStartTime);
NSDictionary *metadataCopy = nil;
BOOL shouldUpdate = NO;
@synchronized(self) {
// Check if the calculated start time is newer or equal to what is currently stored,
// avoiding out-of-order overwrites from logical races during concurrent operations.
NSTimeInterval currentStoredTime =
[_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
if (latestStartTime >= currentStoredTime) {
_experimentMetadata[kExperimentMetadataKeyLastStartTime] = @(latestStartTime);
metadataCopy = [_experimentMetadata copy];
shouldUpdate = YES;
}
}

if (![NSJSONSerialization isValidJSONObject:_experimentMetadata]) {
if (!shouldUpdate) {
return;
}

if (![NSJSONSerialization isValidJSONObject:metadataCopy]) {
FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000028",
@"Invalid fetched experiment metadata to be serialized.");
return;
}
NSError *error;
NSData *serializedExperimentMetadata =
[NSJSONSerialization dataWithJSONObject:_experimentMetadata
[NSJSONSerialization dataWithJSONObject:metadataCopy
options:NSJSONWritingPrettyPrinted
error:&error];
[_DBManager insertExperimentTableWithKey:@RCNExperimentTableKeyMetadata
value:serializedExperimentMetadata
completionHandler:nil];
if (serializedExperimentMetadata) {
__weak __typeof__(self) weakSelf = self;
dispatch_async(self.experimentDBQueue, ^{
__typeof__(self) strongSelf = weakSelf;
if (!strongSelf) return;
[strongSelf->_DBManager insertExperimentTableWithKey:@RCNExperimentTableKeyMetadata
value:serializedExperimentMetadata
completionHandler:nil];
});
}
}

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.

high

Refactor updateExperimentStartTime to support passing a consistent snapshot of payloads, while keeping the parameterless version as a wrapper for backward compatibility and tests.

- (void)updateExperimentStartTime {
  [self updateExperimentStartTimeWithPayloads:self.experimentPayloads];
}

- (void)updateExperimentStartTimeWithPayloads:(NSArray<NSData *> *)payloads {
  NSTimeInterval existingLastStartTime;
  @synchronized(self) {
    existingLastStartTime = [_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
  }

  NSTimeInterval latestStartTime = [self.experimentController
      latestExperimentStartTimestampBetweenTimestamp:existingLastStartTime
                                         andPayloads:payloads];

  NSDictionary *metadataCopy = nil;
  BOOL shouldUpdate = NO;
  @synchronized(self) {
    _metadataUpdated = YES;
    // Check if the calculated start time is newer or equal to what is currently stored,
    // avoiding out-of-order overwrites from logical races during concurrent operations.
    NSTimeInterval currentStoredTime =
        [_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
    if (latestStartTime >= currentStoredTime) {
      _experimentMetadata[kExperimentMetadataKeyLastStartTime] = @(latestStartTime);
      metadataCopy = [_experimentMetadata copy];
      shouldUpdate = YES;
    }
  }

  if (!shouldUpdate) {
    return;
  }

  if (![NSJSONSerialization isValidJSONObject:metadataCopy]) {
    FIRLogError(kFIRLoggerRemoteConfig, @

@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 addresses a data race condition in RCNConfigExperiment by introducing thread-safe property accessors, using @synchronized blocks for state synchronization, and offloading database operations to a serial dispatch queue. Additionally, a concurrent access unit test has been added to verify thread safety. The review feedback identifies a remaining race condition in updateExperimentsWithHandler: where updateExperimentStartTime queries the live _experimentPayloads instead of using the consistently captured payloadsCopy snapshot, and suggests refactoring the method to accept the payloads array directly to prevent inconsistent metadata updates.

}

// Update the last experiment start time with the latest payload.
[self updateExperimentStartTime];

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.

high

There is a potential race condition/inconsistency here. payloadsCopy is captured under a lock to ensure consistency during processing. However, calling [self updateExperimentStartTime] queries the current _experimentPayloads again (which might have been updated to a newer set of payloads by a concurrent call to updateExperimentsWithResponse:).

If _experimentPayloads was updated concurrently, updateExperimentStartTime will calculate and persist a newer last_experiment_start_time (e.g., 200) to the database. But updateExperimentsWithHandler: will proceed to call updateExperimentsWithServiceOrigin:... using the older payloadsCopy (e.g., with start time 100).

This results in an inconsistent state where the database metadata says the last start time is 200, but the active experiments are actually the older ones. On subsequent runs, the newer experiments (with start time 200) will be skipped because their start time is not strictly greater than the stored last_experiment_start_time (which is already 200), causing them to never be activated in Firebase Analytics.

To fix this, we should pass the captured payloadsCopy to a helper method updateExperimentStartTimeWithPayloads:.

Suggested change
[self updateExperimentStartTime];
[self updateExperimentStartTimeWithPayloads:payloadsCopy];

Comment on lines 264 to 311
- (void)updateExperimentStartTime {
NSTimeInterval existingLastStartTime =
[_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
NSTimeInterval existingLastStartTime;
@synchronized(self) {
existingLastStartTime = [_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
}

NSTimeInterval latestStartTime =
[self latestStartTimeWithExistingLastStartTime:existingLastStartTime];

_experimentMetadata[kExperimentMetadataKeyLastStartTime] = @(latestStartTime);
NSDictionary *metadataCopy = nil;
BOOL shouldUpdate = NO;
@synchronized(self) {
// Check if the calculated start time is newer or equal to what is currently stored,
// avoiding out-of-order overwrites from logical races during concurrent operations.
NSTimeInterval currentStoredTime =
[_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
if (latestStartTime >= currentStoredTime) {
_experimentMetadata[kExperimentMetadataKeyLastStartTime] = @(latestStartTime);
metadataCopy = [_experimentMetadata copy];
shouldUpdate = YES;
}
}

if (![NSJSONSerialization isValidJSONObject:_experimentMetadata]) {
if (!shouldUpdate) {
return;
}

if (![NSJSONSerialization isValidJSONObject:metadataCopy]) {
FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000028",
@"Invalid fetched experiment metadata to be serialized.");
return;
}
NSError *error;
NSData *serializedExperimentMetadata =
[NSJSONSerialization dataWithJSONObject:_experimentMetadata
[NSJSONSerialization dataWithJSONObject:metadataCopy
options:NSJSONWritingPrettyPrinted
error:&error];
[_DBManager insertExperimentTableWithKey:@RCNExperimentTableKeyMetadata
value:serializedExperimentMetadata
completionHandler:nil];
if (serializedExperimentMetadata) {
__weak __typeof__(self) weakSelf = self;
dispatch_async(self.experimentDBQueue, ^{
__typeof__(self) strongSelf = weakSelf;
if (!strongSelf) return;
[strongSelf->_DBManager insertExperimentTableWithKey:@RCNExperimentTableKeyMetadata
value:serializedExperimentMetadata
completionHandler:nil];
});
}
}

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.

high

Refactor updateExperimentStartTime to use a helper method updateExperimentStartTimeWithPayloads: that accepts the payloads array. This allows updateExperimentsWithHandler: to pass its consistently captured snapshot of payloads, preventing race conditions and inconsistent metadata updates.

- (void)updateExperimentStartTime {
  [self updateExperimentStartTimeWithPayloads:self.experimentPayloads];
}

- (void)updateExperimentStartTimeWithPayloads:(NSArray<NSData *> *)payloads {
  NSTimeInterval existingLastStartTime;
  @synchronized(self) {
    existingLastStartTime = [_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
  }

  NSTimeInterval latestStartTime = [self.experimentController
      latestExperimentStartTimestampBetweenTimestamp:existingLastStartTime
                                         andPayloads:payloads];

  NSDictionary *metadataCopy = nil;
  BOOL shouldUpdate = NO;
  @synchronized(self) {
    // Check if the calculated start time is newer or equal to what is currently stored,
    // avoiding out-of-order overwrites from logical races during concurrent operations.
    NSTimeInterval currentStoredTime =
        [_experimentMetadata[kExperimentMetadataKeyLastStartTime] doubleValue];
    if (latestStartTime >= currentStoredTime) {
      _experimentMetadata[kExperimentMetadataKeyLastStartTime] = @(latestStartTime);
      metadataCopy = [_experimentMetadata copy];
      shouldUpdate = YES;
    }
  }

  if (!shouldUpdate) {
    return;
  }

  if (![NSJSONSerialization isValidJSONObject:metadataCopy]) {
    FIRLogError(kFIRLoggerRemoteConfig, @"I-RCN000028",
                @"Invalid fetched experiment metadata to be serialized.");
    return;
  }
  NSError *error;
  NSData *serializedExperimentMetadata =
      [NSJSONSerialization dataWithJSONObject:metadataCopy
                                      options:NSJSONWritingPrettyPrinted
                                        error:&error];
  if (serializedExperimentMetadata) {
    __weak __typeof__(self) weakSelf = self;
    dispatch_async(self.experimentDBQueue, ^{
      __typeof__(self) strongSelf = weakSelf;
      if (!strongSelf) return;
      [strongSelf->_DBManager insertExperimentTableWithKey:@RCNExperimentTableKeyMetadata
                                                     value:serializedExperimentMetadata
                                         completionHandler:nil];
    });
  }
}

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.

RemoteConfig: activate() crashes in RCNConfigExperiment due to a data race with the experiment DB load

4 participants