Closed donaldguy closed 1 month ago
This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contributions.
This issue has been closed due to inactivity.
First time contributor checklist
Contributor checklist
... to the best of my knowledge
I have tested my contribution on these devices:I have not tested changes because the situation is a hypothetical and/or difficult to reproduce data race.
That said, whereas the whole effect of the change is re-ordering two occurrences of 2 sequential lines of code to an order more logical, I would expect this to:
UNLESS a more experienced iOS programmer knows a specific reason for the extant order to be preferred
(and if that is the case, I would suggest that a
// XXX:
comment or similar is in order to explain the less-intuitive order)Description
It seems presently possible that a notification can fail-to-cancel & end up left [near-]duplicated instead, as a result of precise unfortunate timing wherein:
transits pending → delivered
removeDeliveredNotifications(withIdentifiers:)
removePendingNotificationRequests(withIdentifiers:)
which appear presently in that order, in error?
I have not been able to come up with any[^1] reasoning for this order to be preferred.
Context suggests that previous touching authors (in de61926ec8e8a0d1792c4a8dea2ba5b16b39879e and af6ff96975964fa483136e3a9625320abb19a710 ) errantly believed[^2] order to be irrelevant (owing to claims of synchronous action that are not met by the underlying iOS API)
[^1]: outside of code style preference for alphabetical order iff its equivalent
[^2]: They might also have known it to be irrelevant due to other features of previous versions of the code since removed. I didn't find those, but I didn't do an exhaustive search and commit messages perhaps hint so
My suspicion there could be a good, unspecified reason for this order obscure to me is further weakened by noticing that the analogous calls to
removeAll{Delivered,Pending}Notifications
happen in [this PR's / the intuitively-safer] pending-then-delivered order.That has been the case for the
removeAll*
calls since introduction to present, to wit:as introduced in 1bfe6918951c95158a74e8d0afd085d1b764b987
Because of the many files involved, and github's collapsing thereof, it seemed worth detailing my blame-findings that - ➕ these lines: https://github.com/signalapp/Signal-iOS/blob/1bfe6918951c95158a74e8d0afd085d1b764b987/Signal/src/UserInterface/Notifications/UserNotificationsAdaptee.swift#L174-L178) were swift introduced in this file [expanded greatly/replaced](https://github.com/donaldguy/Signal-iOS/commit/1bfe6918951c95158a74e8d0afd085d1b764b987#diff-72f0dd04e984d010bf233368689c94f4dd0f4500a7dc39f8e6b0e249dd6bb17a) in this commit - ➖ apparently ultimately replacing this Objective-C: https://github.com/signalapp/Signal-iOS/blob/312384201c3e52442b7b975843f3a4d596b383a5/Signal/src/environment/NotificationsManager.m#L498-L503 from files [removed](https://github.com/signalapp/Signal-iOS/commit/1bfe6918951c95158a74e8d0afd085d1b764b987#diff-f411e2400878764fbf7631ffd7f3179c155942647b64b8d14d402c2311b58525) (& where 312384201c3e52442b7b975843f3a4d596b383a5 is `1bfe691~1` [^3]) [^3]: though e.g. `github.com/blob/${fullsha}~1/Signal/…` _is_ a valid URL, it apparently fouls whatever regex or parser that grants inline-embed rendering to permalinks ( and as must be pretty strictly `/[0-9a-f]{n,m}/` as it would not be tricked by/tolerate urlencodes \[`^` = `%5e` or `~` = `%7e`\] even with tricksy commensurate reduction of sha prefix-length). Granted idk that the renderer wouldn't correct back to showing `3123842` even if it did work per these urls - ❓ that said I don't fully know what to make of the loop-y swift version: https://github.com/signalapp/Signal-iOS/blob/1bfe6918951c95158a74e8d0afd085d1b764b987/Signal/src/UserInterface/Notifications/LegacyNotificationsAdaptee.swift#L207-L212 introduced at the same time
(^this author hasn't touched the
withIdentifiers:
lines where they live now—and I thought that represented a preference matching my intuition w/o contradiction—but *sigh* in fact, I've belatedly realized they did introduced mismatched delivered-then-pending usage as
cancelNotification
only 23 lines up same file in same commit 🤔left this (inconsistent) way during aforementioned refactor of af6ff96975964fa483136e3a9625320abb19a710
```patch diff --git a/SignalMessaging/Notifications/UserNotificationsAdaptee.swift b/SignalMessaging/Notifications/UserNotificationsAdaptee.swift index 2aa55ea5129..eff43089277 100644 --- a/SignalMessaging/Notifications/UserNotificationsAdaptee.swift +++ b/SignalMessaging/Notifications/UserNotificationsAdaptee.swift [… snip …] @@ -391,14 +390,14 @@ class UserNotificationPresenterAdaptee: NSObject, NotificationPresenterAdaptee { return } - notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiersToCancel) - notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiersToCancel) + Self.notificationCenter.removeDeliveredNotifications(withIdentifiers: identifiersToCancel) + Self.notificationCenter.removePendingNotificationRequests(withIdentifiers: identifiersToCancel) } // This method is thread-safe. private func cancelNotificationSync(identifier: String) { - notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier]) - notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier]) + Self.notificationCenter.removeDeliveredNotifications(withIdentifiers: [identifier]) + Self.notificationCenter.removePendingNotificationRequests(withIdentifiers: [identifier]) } // This method is thread-safe. @@ -423,8 +422,8 @@ class UserNotificationPresenterAdaptee: NSObject, NotificationPresenterAdaptee { // This method is thread-safe. func clearAllNotifications() { pendingCancellations.removeAllValues() - notificationCenter.removeAllPendingNotificationRequests() - notificationCenter.removeAllDeliveredNotifications() + Self.notificationCenter.removeAllPendingNotificationRequests() + Self.notificationCenter.removeAllDeliveredNotifications() } ```In particular,
I believe this race may on occasion be actually occurring when e.g. multiple edits to the same message are being (belatedly) batch processed back-to-back
(after re-establishment of interrupted network connection;
or possibly mere sleep and resume of envelope processing per iOS Background App refresh)
(Except for) hoisting this out for emphasis —and also because apparently the callout parsing is suppressed inside a
<details>
even where other markdown is processed (and I don't think there is even effective html option to get it back?)I went ahead and squashed this into a
<details>
once I realized how already also long my (originally a ) TL;DR had become.But it should answer any questions of why I am proposing this (outside of ~mismatch to intuition)
## Oops I ***was*** push-spamming actually… I am an inveterate serial-editor. Both, as the feature is probably mainly intended, for fixing typos and such. **AND** as a matter of more dramatically rephrasing or expanding yet-unread messages before recipient's first read receipt fires This last practice was one undertaken intentionally under presumption that (as informed by experience with e.g. Discord, Slack, Facebook-comments, reddit comments, etc) by making my expansions thusly I was avoiding spamming recipient with additional push notifications (if they were not looking at Signal, but within earshot of device or had it in pocket, etc) Earlier today, I learned this was (sort of) not true! 😱 `Full Motivation & relevant code path
proceeds for a hefty-ish 66 lines,
https://github.com/signalapp/Signal-iOS/blob/e6ba17aa451b6fa66ccc492b078a0d5d41e6e8d6/SignalMessaging/Notifications/UserNotificationsPresenter.swift#L454-L520... but as best as I've noticed is up until the last 2 lines, reordered in this PR, simply staking out early returns for various edge cases, which reifying an edit is not
meanwhile the simpler function,
cancelNotificationSync
is proceeded with a reassuring comment
// This method is thread-safe.
...except for the lies (asynchronous system API)
So, I think it's clear that these functions basically claim and their callers expect that by return from
cancelSync
the notification has been canceled.But these are synchronous only so far as Signal has control of the situation.
Take a look at the docs for the functions and you'll find:
removeDeliveredNotifications(withIdentifiers:)
https://developer.apple.com/documentation/usernotifications/unusernotificationcenter/1649500-removedeliverednotificationssays (emphasis added by me):
Parameters
identifiers
An array of
NSString
objects, each of which corresponds to a value in theidentifier
property of aUNNotificationRequest
object. This method ignores the identifiers of requests whose notifications are not currently displayed in Notification Center.Discussion
Use this method to selectively remove notifications that you no longer want displayed in Notification Center. The method executes asynchronously, returning immediately and removing the specified notifications on a background thread.
whilst
removePendingNotificationRequests(withIdentifiers:)
https://developer.apple.com/documentation/usernotifications/unusernotificationcenter/1649517-removependingnotificationrequestssays (again, emphasis added by me):<> says:
Parameters
identifiers
An array of
NSString
objects, each of which corresponds to a value in theidentifier
property of aUNNotificationRequest
object. If the identifier belongs to a non repeating request, and the trigger condition for that request has already been met, this method ignores the identifier.Discussion
The method executes asynchronously, returning immediately and removing the specified notifications on a background thread.
It's thus clear these are very much not synchronous removals
...and the blind spot (data race)
While it would not shock me if in some cases Apple simply decides that a requested removal is not actually very important (to the end at least of letting any error stand without retry, and definitely no (facilitation for) notification of the caller; but perhaps also like - battery save state, CPU load etc),
if that is not the case (and apple will be very diligent) this mismatch of
cancelSync(
named code and async-proceeding reification shouldn't actually ever result in redundant notifications like my friend reportedbut with the current order of calls, it does seem plausible that you could slip through the situation where:
removeDeliveredNotifications
, that there "The method executes asynchronously, returning immediately and removing the specified notifications on a background thread." still-so-far accurately describes the situation (cause in fact the notification is still pending, never delivered)removePendingNotificationRequests
, its now the case that "the trigger condition for that request has already been met, [so] this method ignores the identifier" (that you are now talking about a delivered notification, never canceled)That seems... like a tight timing constraint to come out that way, so I'm kinda still inclined to think maybe sometimes Apple de-prioritizes such a retraction to filth, but ... as mentioned top level, the batch-processing scenarios (e.g. upon network reconnect with multiple pending edits to same message) seem at least plausible
but maybe only if you busywait?
If in fact it's all about the data race, then this (grossly over-explained) swapping of lines ought to suffice to eliminate it. And I don't think it could hurt regardless (though again, a more experienced iOS programmer may disagree for reasons yet obscure)
But if the "lazy Apple" scenario is on the table, or if indeed you just really want to deliver on the
Sync
promise of the cancel function names (and the past tense of thedid
indidReplaceNotification
), then I think you need to actually check that the notification is gone nowas a busy-wait loop over (ideally 1 but possibly multiple) calls to
getDeliveredNotifications(completionHandler: )
and an O(N) traversal of the present set ; ditto maybegetPendingNotificationRequests(completionHandler:)
(or you can perhaps wait for it to be first delivered, since I don't think Signal is in the habit of scheduling in the later-than-ASAP future?)This feels ... wasteful, so I understand why you don't already do it (beyond possibly incorrectly assuming its unneeded)
but maybe there are other options?
falling out of the bottom of the
<details>
a better way?
by the time you've reached
cancelSync
, you already didgetNotificationsRequests
which did exactly one turn each of those O(N) loops overgetDeliveredNotifications(completionHandler: )
andgetPendingNotificationRequests(completionHandler:)
you are nominally holding a
UNNotificationRequest
, which might be for a genuinely pending notification, or may have been.request
ed off of a bonafideUNNotification
briefly held in passingeither way, you proceed to (peek at
.content.userInfo
to differentiate edge cases, but by the time we reach an attempted cancel), reduce down to theNSString
that is.identifier
targeted observation?
you held a reference to the actual notification (request) object.
Idk if in modern iOS this is a pass-by-value clone or literally a pass-by-reference handle/delegate on the notification as it exists presented in Notification Center
broadly my impression of Apple's approach in the past is that it might be the latter? (though conformance to `NSCopying may say no?)
So is there something in that that is observable? Does anything happen to it if you had a handle and the async piece of the
remove*(withIdentifiers: )
have completed? Perhaps (but perhaps not):.date
ofUNNotification
gets blanked or set to epoch?These sorts of things could at least avoid repeated O(N) fetch for a "make sure its actually gone"; also could potentially duck any busy waiting if there's something there (or indeed up on the
UNUserNotificationCenter
forNSKeyValueObserving
to target more actively?)differentiated handling?
.trigger
The wording is confusing, but it would appear possibly that the
.trigger
of the request coming into cancelSync actually should tell you whether you need to callremoveDeliveredNotifications
ORremovePendingNotificationRequests
about it, yes?per that page's
Idk if that means it actually changes or just you know ... is still there
But also regardless the flattening between the two happening in
getNotificationsRequests
doesn't have to happen per se.content
As re my specific concern about excess, and indeed potentially unnecessary re-delivery for edits, there is definitely a chance to look into the
body
of the notification to see whether the edit actually changed it? (presuming either that there is actually any truncation of what's given there, or a windowing heuristic is applicable?)actual modification?
not for nothing, and I'm sure there are relevant limitations and/or overhead but I do notice that
UNMutableNotificationContent
is a thingas well as this whole system(s): https://developer.apple.com/documentation/usernotifications/modifying-content-in-newly-delivered-notifications / https://developer.apple.com/documentation/usernotifications/unnotificationcontent/updating(from:)
so it seems possible that in place modification is not out of the question?