Skip to content

Prevent duplicate Storage requests on resume - #16771

Open
dajiaohuang wants to merge 2 commits into
firebase:mainfrom
dajiaohuang:fix/storage-task-enqueue-idempotency
Open

dajiaohuang wants to merge 2 commits into
firebase:mainfrom
dajiaohuang:fix/storage-task-enqueue-idempotency

Conversation

@dajiaohuang

Copy link
Copy Markdown

Summary

  • Give upload and download setup attempts a generation so a resumed attempt invalidates an earlier suspended setup.
  • Ignore callbacks and completion from stale setup generations.
  • Add deterministic pause/resume regression tests for both task types using a continuation-gated fetcher-service setup.

Testing

  • Added StorageTaskEnqueueTests for upload and download setup races.
  • Not run locally: Xcode and Swift are unavailable in this environment.
  • git diff --check passed.

@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

Thanks for the contribution! A few housekeeping items:

  • CHANGELOG: The entry in FirebaseStorage/CHANGELOG.md is under # 12.19.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. (#16771).
  • 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 added this to the 13.1.0 - M188 milestone Oct 1, 2026
@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 duplicate upload and download requests when resuming a task before its initial setup completes by introducing generation tracking (enqueueGeneration) in StorageDownloadTask and StorageUploadTask. Unit tests have been added to verify that earlier setups are discarded upon resumption. The review feedback highlights a potential data race in both task implementations where setupDiscardedHandlerForTesting is accessed outside of the lock, and suggests synchronizing this access by retrieving the handler under the lock before executing it.

Comment on lines +185 to +188
guard stateLock.withLock({ generation == enqueueGeneration }) else {
setupDiscardedHandlerForTesting?()
return
}

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

Accessing setupDiscardedHandlerForTesting outside of the lock while checking generation == enqueueGeneration introduces a potential data race. Since StorageTask is marked @unchecked Sendable and uses manual locking, all shared mutable state accesses must be synchronized. We should retrieve the handler under the lock and then execute it outside the lock to ensure thread safety.

      let discardedHandler = stateLock.withLock { () -> (@Sendable () -> Void)? in
        guard generation == enqueueGeneration else {
          return setupDiscardedHandlerForTesting
        }
        return nil
      }
      if let discardedHandler {
        discardedHandler()
        return
      }

Comment on lines +88 to +91
guard stateLock.withLock({ generation == enqueueGeneration }) else {
setupDiscardedHandlerForTesting?()
return
}

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

Accessing setupDiscardedHandlerForTesting outside of the lock while checking generation == enqueueGeneration introduces a potential data race. Since StorageTask is marked @unchecked Sendable and uses manual locking, all shared mutable state accesses must be synchronized. We should retrieve the handler under the lock and then execute it outside the lock to ensure thread safety.

        let discardedHandler = stateLock.withLock { () -> (@Sendable () -> Void)? in
          guard generation == enqueueGeneration else {
            return setupDiscardedHandlerForTesting
          }
          return nil
        }
        if let discardedHandler {
          discardedHandler()
          return
        }

@paulb777 paulb777 self-assigned this Oct 1, 2026

@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, this is easy to hit: putData(...) already enqueues, so an immediate resume() (or a quick pause()/resume()) runs setup twice and starts two fetchers, orphaning the first. The generation approach looks sound to me.

CI confirms one blocker: testUploadResumeDiscardsEarlierSetup crashes with Fatal error: Internal error enqueueing a Storage task in the SPM unit tests (e.g. spm (xcode-27, catalyst)). The cause is inline.

Coverage: please add tests for the most likely real-world trigger (putData(...).resume() and getData(...).resume() right after creation, with no pause()), and for download resume() during .progress.

await StorageFetcherService.shared.updateServiceWaiterForTesting(nil)
}

let task = rootReference().putData(Data("upload".utf8))

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.

rootReference() has no object path, so uploadMetadata.path stays nil and the resumed setup hits the fatalError at StorageUploadTask.swift:107. That's the crash in CI. rootReference().child("object") should fix it.

XCTAssertTrue(task.hasFetcherForTesting)

await serviceGate.releaseFirstCall()
await discardedSetup.wait()

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.

These waits (also L62, L117 and L125) have no timeout, so if this regresses the test hangs instead of failing. Could you use an XCTestExpectation with await fulfillment(of:timeout:)?

let baseRequest: URLRequest

// A narrow synchronization point for tests of task setup cancellation.
var setupDiscardedHandlerForTesting: (@Sendable () -> Void)?

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 is an unsynchronized var on an @unchecked Sendable class, and it only fires at one of the three discard points. Could you guard it with stateLock (or #if DEBUG), and call it at every discard point?

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants