Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 113 additions & 4 deletions templates/apple/Sources/Services/Push.swift.twig
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,7 @@ open class Push: Service {
Push.apnsPrepare(self)
}

Push.scheduleReconcile(self)
let generation = sync.sync { closeGeneration }
let mqtt: MQTTClient
do {
Expand Down Expand Up @@ -1326,6 +1327,10 @@ extension Push {
private static var apnsProxyInstalled = false
private static var apnsRegistrationRequested = false
static var apnsDeliveries = Deliveries(defaults: Shared.defaults)
static var apnsLinkedTopics = LinkedTopics(defaults: Shared.defaults)
private static var apnsReconciled: Set<String> = []
/// How long a launch has to subscribe again before the topics it doesn't claim are unlinked.
static var reconcileDelay: TimeInterval = 30

private final class WeakPush {
weak var push: Push?
Expand Down Expand Up @@ -1417,6 +1422,41 @@ extension Push {
// Gone already, or the session was ended first.
} catch {
push.surface(error)
return
}
apnsLinkedTopics.removeTarget(targetId)
}

/// Once per launch, after the app has had time to subscribe again, unlink the topics an earlier
/// launch linked that no background subscription claims now, so the server stops waking the
/// device for them. A topic subscribed later is linked again.
static func scheduleReconcile(_ push: Push) {
guard !Shared.inServiceExtension, let userId = push.remoteUserId() else {
return
}
let targetId = Ids.target(installation: Installation.id, userId: userId)
guard apnsLocked({ apnsReconciled.insert(targetId).inserted }) else {
return
}
let scheduler = WeakPush(push)
Task {
try? await Task.sleep(nanoseconds: UInt64(reconcileDelay * 1_000_000_000))
await reconcile(userId: userId, targetId: targetId, fallback: scheduler.push)
}
}

static func reconcile(userId: String, targetId: String, fallback: Push?) async {
let pushes = apnsLocked { apnsPushes.values.compactMap { $0.push } }.filter { $0.remoteUserId() == userId }
guard let push = pushes.first(where: { !$0.backgroundTopics().isEmpty }) else {
// No background subscription this launch: the target and its subscribers go, as on close().
if apnsLinkedTopics.isRegistered(targetId), let push = fallback ?? pushes.first {
await deleteTarget(push, targetId: targetId)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A new background claim can be lost during target deletion

This branch decides there are no claims before awaiting deleteTarget, but a background subscribe can begin while that DELETE is in flight. If its target upsert completes first, the DELETE can remove the target after register has already finished, leaving the new subscription without APNs delivery until another registration occurs.

Prompt To Fix With AI
This is a comment left during a code review.
Path: templates/apple/Sources/Services/Push.swift.twig
Line: 1453

Comment:
**A new background claim can be lost during target deletion**

This branch decides there are no claims before awaiting `deleteTarget`, but a background subscribe can begin while that DELETE is in flight. If its target upsert completes first, the DELETE can remove the target after `register` has already finished, leaving the new subscription without APNs delivery until another registration occurs.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

🟡 Minor · concurrency · Reply if this doesn't apply.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed on the base branch, since this PR is merged into #2000: 966125e. After the DELETE, deleteTarget registers the device again and links the topics for any Push of the user that has background subscriptions by then.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don’t see that fix in the revision available here: reconcile checks for claims before awaiting deletion, and deleteTarget then performs the DELETE without re-registering any claims that appeared in the meantime. If registration’s upsert finishes before the DELETE, the target can still be removed. (reconcile and deleteTarget)

Please make sure commit 966125e16 is included in the reviewed stack; I’ll recheck once it’s visible here.

}
return
}
let claimed = Set(pushes.flatMap { $0.backgroundTopics() })
for topic in LinkedTopics.stale(saved: apnsLinkedTopics.topics(targetId), claimed: claimed) {
await unlink(push, targetId: targetId, topic: topic)
Comment thread
hansi-codes[bot] marked this conversation as resolved.
}
}

Expand Down Expand Up @@ -1467,6 +1507,8 @@ extension Push {
return
}
apnsLocked { apnsLinked[targetId] = apnsLinked[targetId] ?? [] }
apnsLinkedTopics.register(targetId)
scheduleReconcile(push)
// A topic unsubscribed while the target request was in flight is not linked.
let wanted = Set(push.backgroundTopics())
for topic in topics where Ids.isMessagingTopic(topic) && wanted.contains(topic) {
Expand Down Expand Up @@ -1531,6 +1573,7 @@ extension Push {
return
}
apnsLocked { _ = apnsLinked[targetId, default: []].insert(topic) }
apnsLinkedTopics.add(topic, to: targetId)
// Unsubscribed while the subscriber was being created: `apnsDisable` found nothing to
// unlink then, so undo it here.
if !push.backgroundTopics().contains(topic) {
Expand All @@ -1539,6 +1582,7 @@ extension Push {
}

private static func unlink(_ push: Push, targetId: String, topic: String) async {
var unlinked = true
do {
let _: Any = try await push.client.call(
method: "DELETE",
Expand All @@ -1550,12 +1594,19 @@ extension Push {
// Gone already.
} catch {
push.surface(error)
unlinked = false
}
apnsLocked { _ = apnsLinked[targetId]?.remove(topic) }
// Back in a background subscription while the delete was in flight: `link` saw the topic
// still linked then and skipped it, so link it again now.
if push.backgroundTopics().contains(topic) {
await link(push, targetId: targetId, topic: topic)
if unlinked {
apnsLinkedTopics.remove(topic, from: targetId)
}
// Back in a background subscription of any Push of the user while the delete was in flight:
// `link` saw the topic still linked then and skipped it, so link it again now.
let userId = push.remoteUserId()
let claimer = apnsLocked { apnsPushes.values.compactMap { $0.push } }
.first { $0.remoteUserId() == userId && $0.backgroundTopics().contains(topic) }
if let claimer {
await link(claimer, targetId: targetId, topic: topic)
}
}

Expand Down Expand Up @@ -1755,6 +1806,64 @@ extension Push {

/// Message ids already delivered, by MQTT or APNs, so each message reaches the app once.
/// Bounded, and kept across launches.
/// The targets this install registered and the topics linked to each, kept across launches and
/// shared with the extension.
final class LinkedTopics {
static let key = "{{ spec.info.title | caseLower }}.push.linked"

private let defaults: UserDefaults

init(defaults: UserDefaults) {
self.defaults = defaults
}

func topics(_ targetId: String) -> Set<String> {
Shared.locked { Set(read()[targetId] ?? []) }
}

func add(_ topic: String, to targetId: String) {
change { $0[targetId] = Array(Set($0[targetId] ?? []).union([topic])).sorted() }
}

func remove(_ topic: String, from targetId: String) {
change { $0[targetId] = $0[targetId]?.filter { $0 != topic } }
}

func removeTarget(_ targetId: String) {
change { $0[targetId] = nil }
}

/// Record a target this install registered, linked topics or not.
func register(_ targetId: String) {
change { $0[targetId] = $0[targetId] ?? [] }
}

func isRegistered(_ targetId: String) -> Bool {
Shared.locked { read()[targetId] != nil }
}

/// Saved topics no background subscription claims.
static func stale(saved: Set<String>, claimed: Set<String>) -> [String] {
saved.subtracting(claimed).sorted()
}

private func read() -> [String: [String]] {
defaults.dictionary(forKey: LinkedTopics.key) as? [String: [String]] ?? [:]
}

private func change(_ body: (inout [String: [String]]) -> Void) {
Shared.locked {
var links = read()
body(&links)
if links.isEmpty {
defaults.removeObject(forKey: LinkedTopics.key)
} else {
defaults.set(links, forKey: LinkedTopics.key)
}
}
}
}

final class Deliveries {
static let capacity = 500
static let key = "{{ spec.info.title | caseLower }}.push.delivered"
Expand Down
31 changes: 31 additions & 0 deletions templates/apple/Tests/PushTests.swift.twig
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,37 @@ final class PushTests {
PushMessage(topic: topic, payload: Array(text.utf8), qos: 1)
}

@Test func linkedTopicsAreKeptAcrossLaunches() {
let shared = defaults()
let launch = Push.LinkedTopics(defaults: shared)
launch.add("news", to: "t1")
launch.add("sales", to: "t1")
launch.add("news", to: "t2")
launch.remove("sales", from: "t1")

let next = Push.LinkedTopics(defaults: shared)
#expect(next.topics("t1") == ["news"])
#expect(next.topics("t2") == ["news"])
next.removeTarget("t1")
#expect(Push.LinkedTopics(defaults: shared).topics("t1").isEmpty)
}

@Test func aRegisteredTargetIsKeptUntilItIsDeleted() {
let links = Push.LinkedTopics(defaults: defaults())
#expect(!links.isRegistered("t1"))
links.register("t1")
links.add("news", to: "t1")
links.remove("news", from: "t1")
#expect(links.isRegistered("t1"))
links.removeTarget("t1")
#expect(!links.isRegistered("t1"))
}

@Test func topicsNoSubscriptionClaimsAreStale() {
#expect(Push.LinkedTopics.stale(saved: ["news", "sales", "users-1"], claimed: ["news"]) == ["sales", "users-1"])
#expect(Push.LinkedTopics.stale(saved: ["news"], claimed: ["news", "deals"]).isEmpty)
}

@Test func onlyRawPayloadsAndEnvelopesWithTextAreShown() {
let shown: [(String, Bool, Bool)] = [
("plain text", true, true),
Expand Down
Loading