Skip to content

Fix GDTStorage background task not being ended on storeEvent: failure paths - #179

Open
tilltue wants to merge 1 commit into
google:mainfrom
tilltue:fix/gdtstorage-background-task-leak
Open

Fix GDTStorage background task not being ended on storeEvent: failure paths#179
tilltue wants to merge 1 commit into
google:mainfrom
tilltue:fix/gdtstorage-background-task-leak

Conversation

@tilltue

@tilltue tilltue commented Sep 2, 2026

Copy link
Copy Markdown

Summary

-[GDTCORFlatFileStorage storeEvent:onComplete:] begins a GDTStorage background task,
but the three failure paths inside the dispatched block (encode failure, storage size
limit reached, write failure) return without ending it. While the tracked storage size
is at or over kGDTCORFlatFileStorageSizeLimit, every successfully encoded event takes
the size-limit branch, so no store attempt releases its assertion until the system
expires it.

__block GDTCORBackgroundIdentifier bgID = GDTCORBackgroundIdentifierInvalid;
bgID = [[GDTCORApplication sharedApplication]
beginBackgroundTaskWithName:@"GDTStorage"
expirationHandler:^{
// End the background task if it's still valid.
[[GDTCORApplication sharedApplication] endBackgroundTask:bgID];
bgID = GDTCORBackgroundIdentifierInvalid;
}];
dispatch_async(_storageQueue, ^{
// Check that a backend implementation is available for this target.
GDTCORTarget target = event.target;
NSString *filePath = [GDTCORFlatFileStorage pathForTarget:target
eventID:event.eventID
qosTier:@(event.qosTier)
expirationDate:event.expirationDate
mappingID:event.mappingID];
NSError *error;
NSData *encodedEvent = GDTCOREncodeArchive(event, nil, &error);
if (error) {
completion(NO, error);
return;
}
// Check storage size limit before storing the event.
uint64_t resultingStorageSize = self.sizeTracker.directoryContentSize + encodedEvent.length;
if (resultingStorageSize > kGDTCORFlatFileStorageSizeLimit) {
NSError *error = [NSError
errorWithDomain:GDTCORFlatFileStorageErrorDomain
code:GDTCORFlatFileStorageErrorSizeLimitReached
userInfo:@{
NSLocalizedFailureReasonErrorKey : @"Storage size limit has been reached."
}];
if (self.delegate != nil) {
GDTCORLogDebug(@"Delegate notified that event with mapping ID %@ was dropped.",
event.mappingID);
[self.delegate storage:self didDropEvent:event];
}
completion(NO, error);
return;
}
// Write the encoded event to the file.
BOOL writeResult = GDTCORWriteDataToFile(encodedEvent, filePath, &error);
if (writeResult == NO || error) {
GDTCORLogDebug(@"Attempt to write archive failed: path:%@ error:%@", filePath, error);
completion(NO, error);
return;
} else {
GDTCORLogDebug(@"Writing archive succeeded: %@", filePath);
completion(YES, nil);
}
// Notify size tracker.
[self.sizeTracker fileWasAddedAtPath:filePath withSize:encodedEvent.length];
// Check the QoS, if it's high priority, notify the target that it has a high priority event.
if (event.qosTier == GDTCOREventQoSFast) {
// TODO: Remove a direct dependency on the upload coordinator.
[self.uploadCoordinator forceUploadForTarget:target];
}
// Cancel or end the associated background task if it's still valid.
[[GDTCORApplication sharedApplication] endBackgroundTask:bgID];
bgID = GDTCORBackgroundIdentifierInvalid;

This change ends the task on every exit path. The success-path teardown point is
unchanged (still after the size tracker update and the high-QoS upload trigger).

Related: firebase/firebase-ios-sdk#15129 (currently closed, needs-info). Filling the
storage past the limit gives a deterministic way to exercise the leaking path (steps
below).

Testing

  • Locally, existing unit suite GoogleDataTransport-Unit-Tests-Unit on the iOS
    simulator: 110 tests, 0 failures.
  • Locally, simulator reproduction with the storage over the limit: before this change
    every GDTStorage task created during the run was left un-ended and produced the
    "was created over 30 seconds ago" warning; after this change all were ended.
Reproduction steps and measurements
  1. Run any app that uses GDT on the simulator, then terminate it.
  2. Place a >20 MB file in the app container under Library/Caches/google-sdks-events/
    (a sparse file is enough — GDTCORDirectorySizeTracker sums NSURLFileSizeKey;
    e.g. mkfile -n 21m filler).
  3. Relaunch and watch log stream --predicate 'eventMessage CONTAINS "GDTStorage"'.

My counts of Created background task / Ending task with identifier over 150 s
foreground runs:

storage created ended ">30 seconds ago" warnings
main @ 5d20ca19, baseline 8 KB 75 75 0
main @ 5d20ca19, over limit 22 MB 67 0 67
main @ 5d20ca19, filler removed 8 KB 67 67 0
this change, over limit 22 MB 161 161 0
Notes
  • No unit test added: a clean unit test would likely require making the application
    injectable, as GDTCORTransformer does, which I did not want to bundle with the fix.
    Happy to add it if preferred.
  • bgID is accessed from the expiration handler and _storageQueue without
    synchronisation. This is pre-existing (the success path already had it, and
    GDTCORTransformer has the same pattern); the failure paths now share it. I can
    follow up separately if you would like that addressed.
  • Unit suite run with -testLanguage en -testRegion US because
    testFetchAndUpdateMetrics_WhenDecodeError compares an English
    localizedFailureReason and fails on a non-English host regardless of this change.
    Not run locally: tvOS/macOS/Catalyst/watchOS, Thread Sanitizer.
  • Environment: GoogleDataTransport 10.1.0 (storeEvent:onComplete: unchanged in 10.1.1
    and on main at 5d20ca19); iPhone 17 simulator, iOS 26.0, Xcode 26.0 (17A324).

… paths

-[GDTCORFlatFileStorage storeEvent:onComplete:] begins a "GDTStorage"
background task before dispatching to the storage queue. Outside the
expiration handler, only the success path ended it: the three early
returns inside the dispatched block (encode failure, storage size limit
reached, write failure) returned without ending it, leaving the
assertion outstanding until the system expired it. Once the storage
directory reaches kGDTCORFlatFileStorageSizeLimit, every subsequent
store attempt takes the size-limit branch, so no attempt releases its
assertion.

Extract the teardown into an endBackgroundTaskIfNeeded block and call it
on each exit path. The success-path teardown point is unchanged: it still
runs after the size tracker update and the high-QoS upload trigger.

Related: firebase/firebase-ios-sdk#15129
@google-cla

google-cla Bot commented Sep 2, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

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.

1 participant