Repository navigation
handleNotification can drop the remote-notification completion handler, throttling background push delivery #25657
Description
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 calledWordPress-iOS/WordPress/Classes/Utility/Notifications/PushNotificationsManager.swift
Lines 208 to 224 in f039b67
| 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-resetguard), - is not a Zendesk/support type →
handleSupportNotificationreturnsfalse, - is not an auth notification →
handleAuthenticationNotificationreturnsfalse, - →
handleInactiveNotificationreturnsfalse(.background && !userInteraction), - carries no
note_id→handleBackgroundNotificationreturnsfalse(it requiresNotification.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 contractThe 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
returnare identical ontrunkand predate Adopt the UIScene life cycle #25643. Adopt the UIScene life cycle #25643's only change to this file (wideninghandleInactiveNotificationto accept.background && userInteraction) shrinks the set of dropped pushes — it neither introduces nor worsens this.
Activity
dangermattic commentedon Jun 15, 2026
Thanks for reporting! 👍