Skip to content

handleNotification can drop the remote-notification completion handler, throttling background push delivery #25657

Description

@jkmassel

Summary

PushNotificationsManager.handleNotification(_:userInteraction:completionHandler:) can return without ever invoking the OS-supplied fetchCompletionHandler. For a content-available (silent/background) push, the app then never signals iOS that it finished its background work — wasting the background assertion and, over repeated occurrences, leading iOS to throttle the app's future background/silent-push wakeups and background-fetch budget.

Pre-existing — not introduced by any recent change. Surfaced while reviewing #25643 (UIScene adoption), which makes windowless background launches a routinely-exercised path but does not touch this dispatch loop.

Root Cause

The OS entry point hands handleNotification a non-optional escaping handler (application(_:didReceiveRemoteNotification:fetchCompletionHandler:)). But handleNotification only calls it inside the four sub-handlers' success paths, with no terminal call when nothing handles the push — plus an early return on the badge path:

// Badge: Reset
guard let type = userInfo.string(forKey: Notification.typeKey), type != Notification.badgeResetValue else {
    return                       // ← completionHandler never called
}

let handlers = [
    handleSupportNotification,
    handleAuthenticationNotification,
    handleInactiveNotification,
    handleBackgroundNotification
]
for handler in handlers {
    if handler(userInfo, userInteraction, completionHandler) {
        break
    }
}
// ← if every handler returned false, completionHandler is never called

guard let type = userInfo.string(forKey: Notification.typeKey), type != Notification.badgeResetValue else {
return
}
// Handling!
let handlers = [
handleSupportNotification,
handleAuthenticationNotification,
handleInactiveNotification,
handleBackgroundNotification
]
for handler in handlers {
if handler(userInfo, userInteraction, completionHandler) {
break
}
}

Trigger

A remote push delivered in the .background state with userInteraction == false that:

  • has a type (so it survives the badge-reset guard),
  • is not a Zendesk/support type → handleSupportNotification returns false,
  • is not an auth notification → handleAuthenticationNotification returns false,
  • handleInactiveNotification returns false (.background && !userInteraction),
  • carries no note_idhandleBackgroundNotification returns false (it requires Notification.identifierKey).

All four decline; completionHandler is never called. The badge-reset early-return at L208 is the same drop, though a badge-reset push isn't usually a content-available push the OS waits on.

In practice, real WP.com notification pushes (comment / like / follow / …) all carry note_id, so they land in handleBackgroundNotification and do call the handler. The drop therefore needs a malformed or future/unknown payload shape — latent and low-frequency, but unbounded by anything in the client.

Impact

  • A wasted ~30s background assertion per occurrence (the OS waits out the watchdog, then suspends the app).
  • iOS meters completion-handler reliability; repeated drops contribute to throttling of future silent-push delivery and background-fetch budget — i.e. degraded background responsiveness (share-extension upload completion, silent syncs).

No crash, no data loss — a robustness gap.

Fix

Guarantee the handler is called exactly once on every path:

guard let type = userInfo.string(forKey: Notification.typeKey), type != Notification.badgeResetValue else {
    completionHandler?(.noData)
    return
}

for handler in handlers {
    if handler(userInfo, userInteraction, completionHandler) {
        return                       // a handler took it (and already called completionHandler)
    }
}
completionHandler?(.noData)          // nothing handled it — still fulfil the OS contract

The return-on-handled keeps it single-call (no double invocation when a handler already called it).

Notes

  • Confirmed pre-existing: the dispatch loop and badge-reset return are identical on trunk and predate Adopt the UIScene life cycle #25643. Adopt the UIScene life cycle #25643's only change to this file (widening handleInactiveNotification to accept .background && userInteraction) shrinks the set of dropped pushes — it neither introduces nor worsens this.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions