Skip to content

Fix HeartbeatStorage cache replacement race - #16759

Open
dajiaohuang wants to merge 2 commits into
firebase:mainfrom
dajiaohuang:fix/heartbeat-storage-cache-identity
Open

dajiaohuang wants to merge 2 commits into
firebase:mainfrom
dajiaohuang:fix/heartbeat-storage-cache-identity

Conversation

@dajiaohuang

@dajiaohuang dajiaohuang commented Oct 1, 2026 •

Copy link
Copy Markdown

Summary

Prevent a stale HeartbeatStorage instance from removing a newer cache entry with the same ID.

getInstance replaces a dead weak cache value while holding the cache lock. If the old instance's deinit is already waiting for that lock, its unconditional removal by ID can then evict the replacement. A later lookup can create another storage object with a separate serial queue for the same file.

Weak references can already be nil when deinit runs, so cleanup compares a per-instance generation token retained in the cache entry. A unit test models a replacement entry whose weak object is nil, then verifies that stale cleanup preserves it and matching cleanup removes it.

Validation

  • git diff --check passed.
  • Unit tests were not run because Xcode and Swift tooling are unavailable in this environment.

@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

paulb777 commented Oct 1, 2026

Copy link
Copy Markdown
Member

/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 prevents a deinitializing HeartbeatStorage instance from evicting a newer cached instance with the same identifier by introducing a stable cacheIdentity (UUID) to track instances. It wraps cached instances in a new HeartbeatStorageCacheEntry struct and only removes them during deinitialization if the identities match. A unit test was added to verify this behavior. Feedback suggests optimizing the removeCachedInstance method to use a single dictionary lookup instead of two to improve performance.

Comment on lines +102 to +103
guard cachedInstances[id]?.cacheIdentity == cacheIdentity else { return }
cachedInstances.removeValue(forKey: id)

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.

medium

We can optimize this method to perform only a single dictionary lookup instead of two. By retrieving the dictionary index first, we can check the identity and remove the entry using the index in O(1) time, avoiding the second hash lookup while holding the lock.

    if let index = cachedInstances.index(forKey: id),
       cachedInstances[index].value.cacheIdentity == cacheIdentity {
      cachedInstances.remove(at: index)
    }

@paulb777

paulb777 commented Oct 1, 2026

Copy link
Copy Markdown
Member

Thanks for the contribution! A few housekeeping items:

  • CHANGELOG: The entry in FirebaseCore/CHANGELOG.md is under # Firebase 13.0.0, which has already shipped. Please move it under a # Unreleased heading at the top of the file (add the heading if it isn't there yet).
  • PR number: end the entry with this PR's number, e.g. - [fixed] Description. (#16759).
  • Formatting: after updating, please run scripts/style.sh so the infra.check job passes. It needs clang-format 23 and swiftformat (see scripts/setup_check.sh). If you can't run it locally, the infra.check log lists the files that need formatting.

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

Thanks! The race is real in principle (a dying instance's deinit can remove a newer cache entry for the same ID), though hitting it requires deleteApp and configure for the same app ID racing on different threads, and the worst case is a lost or duplicated heartbeat. The fix itself looks correct.

Following up on my earlier comment: since there's no user-visible effect, I'd also be fine with dropping the CHANGELOG entry instead of moving it.

// Removes the instance if it was cached.
Self.cachedInstances.withLock { value in
value.removeValue(forKey: id)
Self.removeCachedInstance(id: id, cacheIdentity: cacheIdentity, from: &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.

Optional simplification: would it be enough to drop the cleanup in deinit entirely? getInstance already replaces an entry whose weak reference is nil, and leftover entries are bounded by the number of app IDs. That removes the race without the extra identity bookkeeping.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants