From b4625c423e3dd5fb55934f534d2a88435c877db5 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:08:19 -0700 Subject: [PATCH 001/117] test(ios): cover pending push opt-out --- .../MobilePushReadinessPreviewView.swift | 5 +++ .../MobilePushSettingsContent.swift | 31 ++----------- .../CmuxMobileShellUI/MobilePushToggle.swift | 43 +++++++++++++++++++ .../MobileSettingsView.swift | 19 +++----- ios/cmuxUITests/PushReadinessUITests.swift | 24 +++++++++++ 5 files changed, 81 insertions(+), 41 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index f17d9d04447..f489087694b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -12,6 +12,7 @@ import SwiftUI struct MobilePushReadinessPreviewView: View { private let fixture: Fixture private let rejectsMacMutations: Bool + private let delaysPhoneMutation: Bool @State private var phoneEnabled: Bool @State private var authorization: MobilePushAuthorization @@ -22,6 +23,7 @@ struct MobilePushReadinessPreviewView: View { let fixture = Fixture(rawValue: state) ?? .healthy self.fixture = fixture self.rejectsMacMutations = environment["CMUX_UITEST_PUSH_MUTATION_FAILURE"] == "1" + self.delaysPhoneMutation = environment["CMUX_UITEST_PUSH_PHONE_MUTATION_DELAY"] == "1" self._phoneEnabled = State(initialValue: fixture.registration.isEnabled) self._authorization = State(initialValue: fixture.authorization) self._registration = State(initialValue: fixture.registration) @@ -69,6 +71,9 @@ struct MobilePushReadinessPreviewView: View { @MainActor private func setPhoneEnabled(_ enabled: Bool) async -> Bool { + if delaysPhoneMutation { + try? await Task.sleep(for: .milliseconds(500)) + } phoneEnabled = enabled registration = enabled ? Self.registered diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift index adb1adc6e5a..77cde93ea23 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift @@ -75,15 +75,11 @@ struct MobilePushSettingsContent: View { Group { statusRow - Toggle( - L10n.string( - "mobile.notifications.phoneEnabled", - defaultValue: "Allow Push Alerts on This iPhone" - ), - isOn: phoneEnabledBinding + MobilePushToggle( + isEnabled: $phoneEnabled, + isUpdating: $isMutatingPhone, + onChange: onPhoneEnabledChange ) - .accessibilityIdentifier("MobileSettingsNotifications") - .disabled(isMutatingPhone) if let repair = readiness.repair, Self.shouldPresentRepair(repair, canConnectMac: canConnectMac), @@ -248,25 +244,6 @@ struct MobilePushSettingsContent: View { .accessibilityIdentifier("MobileSettingsPushReadinessStatus") } - private var phoneEnabledBinding: Binding { - Binding( - get: { phoneEnabled }, - set: { requested in - guard !isMutatingPhone else { return } - let confirmed = phoneEnabled - phoneEnabled = requested - isMutatingPhone = true - Task { - let succeeded = await onPhoneEnabledChange(requested) - if !succeeded { - phoneEnabled = confirmed - } - isMutatingPhone = false - } - } - ) - } - private var macForwardingBinding: Binding { Binding( get: { macForwardingEnabled }, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift new file mode 100644 index 00000000000..052534427d7 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -0,0 +1,43 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +/// The phone push preference is persisted asynchronously, so update the +/// control optimistically and roll it back only when the mutation fails. +/// Keeping this binding shared prevents release and diagnostic settings from +/// drifting into different interaction behavior. +struct MobilePushToggle: View { + @Binding var isEnabled: Bool + @Binding var isUpdating: Bool + let onChange: @MainActor (Bool) async -> Bool + + var body: some View { + Toggle( + L10n.string( + "mobile.notifications.phoneEnabled", + defaultValue: "Allow Push Alerts on This iPhone" + ), + isOn: binding + ) + .accessibilityIdentifier("MobileSettingsNotifications") + .disabled(isUpdating) + } + + private var binding: Binding { + Binding( + get: { isEnabled }, + set: { requested in + guard !isUpdating else { return } + let previous = isEnabled + isUpdating = true + Task { @MainActor in + defer { isUpdating = false } + if !(await onChange(requested)) { + isEnabled = previous + } + } + } + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 36f6a516137..8c601da933a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -46,6 +46,7 @@ struct MobileSettingsView: View { /// `isEnabled` as a non-observable `UserDefaults` read, so reading it /// directly in `body` would not re-render when it flips. @State private var notificationsEnabled = false + @State private var notificationsToggleUpdating = false #if DEBUG @State private var debugReplyScheduled: Bool? #endif @@ -428,21 +429,11 @@ struct MobileSettingsView: View { .foregroundStyle(.secondary) } #else - Toggle( - L10n.string( - "mobile.notifications.phoneEnabled", - defaultValue: "Allow Push Alerts on This iPhone" - ), - isOn: Binding( - get: { notificationsEnabled }, - set: { enabled in - Task { @MainActor in - notificationsEnabled = await updatePhonePushEnabled(enabled) - } - } - ) + MobilePushToggle( + isEnabled: $notificationsEnabled, + isUpdating: $notificationsToggleUpdating, + onChange: updatePhonePushEnabled ) - .accessibilityIdentifier("MobileSettingsNotifications") #endif } diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index a3c166d8980..07f60e79ac2 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -115,6 +115,30 @@ final class PushReadinessUITests: XCTestCase { waitForValue(forwarding, "0") } + @MainActor + func testPhonePushToggleTurnsOffWhileMutationIsPending() { + let app = launchPreview( + "healthy", + extraEnvironment: ["CMUX_UITEST_PUSH_PHONE_MUTATION_DELAY": "1"] + ) + defer { app.terminate() } + + let phone = app.switches["MobileSettingsNotifications"] + XCTAssertTrue(phone.waitForExistence(timeout: 8)) + XCTAssertEqual(phone.value as? String, "1") + + tapSwitch(phone) + + XCTAssertEqual( + phone.value as? String, + "0", + "The toggle must reflect the requested opt-out before the async cleanup finishes" + ) + XCTAssertFalse(phone.isEnabled) + waitForEnabled(phone) + XCTAssertEqual(phone.value as? String, "0") + } + @MainActor func testFailedMacMutationRollsBackAndStaysVisible() { let app = launchPreview( From e7c9a6a7b45c80139490244da884b008009c0472 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:08:27 -0700 Subject: [PATCH 002/117] fix(ios): update push toggle optimistically --- .../Sources/CmuxMobileShellUI/MobilePushToggle.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index 052534427d7..8c5e2fb05e8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -29,6 +29,7 @@ struct MobilePushToggle: View { set: { requested in guard !isUpdating else { return } let previous = isEnabled + isEnabled = requested isUpdating = true Task { @MainActor in defer { isUpdating = false } From d2d527a54eb8f2f95c41b7b6c4a6f32f7afabe68 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 09:44:13 -0700 Subject: [PATCH 003/117] test(ios): make push toggle timing assertion deterministic --- .../MobilePushReadinessPreviewView.swift | 2 +- ios/cmuxUITests/PushReadinessUITests.swift | 30 +++++++++++++++---- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index f489087694b..b53538dec96 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -72,7 +72,7 @@ struct MobilePushReadinessPreviewView: View { @MainActor private func setPhoneEnabled(_ enabled: Bool) async -> Bool { if delaysPhoneMutation { - try? await Task.sleep(for: .milliseconds(500)) + try? await Task.sleep(for: .seconds(2)) } phoneEnabled = enabled registration = enabled diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index 07f60e79ac2..2d65a2b85da 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -129,12 +129,13 @@ final class PushReadinessUITests: XCTestCase { tapSwitch(phone) - XCTAssertEqual( - phone.value as? String, + waitForValue( + phone, "0", - "The toggle must reflect the requested opt-out before the async cleanup finishes" + timeout: 1, + message: "The toggle must reflect the requested opt-out before async cleanup finishes" ) - XCTAssertFalse(phone.isEnabled) + waitForDisabled(phone) waitForEnabled(phone) XCTAssertEqual(phone.value as? String, "0") } @@ -214,7 +215,8 @@ final class PushReadinessUITests: XCTestCase { private func waitForValue( _ element: XCUIElement, _ expected: String, - timeout: TimeInterval = 4 + timeout: TimeInterval = 4, + message: String? = nil ) { let predicate = NSPredicate(format: "value == %@", expected) let expectation = XCTNSPredicateExpectation( @@ -224,7 +226,7 @@ final class PushReadinessUITests: XCTestCase { XCTAssertEqual( XCTWaiter.wait(for: [expectation], timeout: timeout), .completed, - "Expected '\(expected)', got '\(String(describing: element.value))'" + message ?? "Expected '\(expected)', got '\(String(describing: element.value))'" ) } @@ -244,6 +246,22 @@ final class PushReadinessUITests: XCTestCase { ) } + @MainActor + private func waitForDisabled( + _ element: XCUIElement, + timeout: TimeInterval = 4 + ) { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "enabled == false"), + object: element + ) + XCTAssertEqual( + XCTWaiter.wait(for: [expectation], timeout: timeout), + .completed, + "Expected '\(element.identifier)' to become disabled" + ) + } + @MainActor private func tapSwitch(_ element: XCUIElement) { element.coordinate( From 952586ecc5aa65aa460e87667634e658d9ed23e1 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:08:01 -0700 Subject: [PATCH 004/117] fix(ios): restore toast settings binding --- .../Sources/CmuxMobileShellUI/MobileSettingsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 8c601da933a..8be1d31b1db 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -63,6 +63,7 @@ struct MobileSettingsView: View { var body: some View { @Bindable var displaySettings = displaySettings + @Bindable var toasts = toasts return NavigationStack { Form { if initialFocus == .connectionMethod { From 207491f36558603d49ad8b0bce43b1e07958e56c Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:11:05 -0700 Subject: [PATCH 005/117] fix(ios): remove retired toast settings toggle --- .../Sources/CmuxMobileShellUI/MobileSettingsView.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 8be1d31b1db..ac2c1f1b605 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -63,7 +63,6 @@ struct MobileSettingsView: View { var body: some View { @Bindable var displaySettings = displaySettings - @Bindable var toasts = toasts return NavigationStack { Form { if initialFocus == .connectionMethod { @@ -247,13 +246,6 @@ struct MobileSettingsView: View { } .accessibilityIdentifier("MobileSettingsTaskComposer") - Toggle(isOn: $toasts.isEnabled) { - Text(L10n.string( - "mobile.settings.beta.toasts", - defaultValue: "Toasts" - )) - } - .accessibilityIdentifier("MobileSettingsToastsEnabled") } #if DEBUG From 63ed21a31ac4cfff4e64e851a718fc6c7a2cbe4d Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 13:25:11 -0700 Subject: [PATCH 006/117] fix(ios): initialize task composer setting --- .../Sources/CmuxMobileShellUI/MobileDisplaySettings.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift index a1242398fb1..403a8357207 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileDisplaySettings.swift @@ -157,6 +157,7 @@ public final class MobileDisplaySettings { self.showMissingFiles = defaults.bool(forKey: Self.showMissingFilesKey) self.terminalFolderTapEnabled = defaults.object(forKey: Self.terminalFolderTapEnabledKey) as? Bool ?? true self.hapticFeedbackEnabled = haptics.isEnabled + self.taskComposerEnabled = defaults.bool(forKey: Self.taskComposerEnabledKey) self.terminalScrollbackRows = MobileTerminalScrollbackPreference.resolve(from: defaults) let storedPreviewLines = defaults.object(forKey: Self.workspacePreviewLineCountKey) as? Int self.workspacePreviewLineCount = Self.clampedWorkspacePreviewLineCount( From 12cf0c540b0b5988a2ed1beeac73e4cdee4e6b75 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:55:45 -0700 Subject: [PATCH 007/117] fix(ios): tie push toggle mutation to view lifecycle --- .../CmuxMobileShellUI/MobilePushToggle.swift | 35 +++++++++++++------ .../MobileSettingsView.swift | 2 ++ 2 files changed, 27 insertions(+), 10 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index 8c5e2fb05e8..3e41e3c3a8d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -10,6 +10,7 @@ struct MobilePushToggle: View { @Binding var isEnabled: Bool @Binding var isUpdating: Bool let onChange: @MainActor (Bool) async -> Bool + @State private var mutationTask: Task? var body: some View { Toggle( @@ -21,24 +22,38 @@ struct MobilePushToggle: View { ) .accessibilityIdentifier("MobileSettingsNotifications") .disabled(isUpdating) + .onDisappear { + mutationTask?.cancel() + mutationTask = nil + isUpdating = false + } } private var binding: Binding { Binding( get: { isEnabled }, set: { requested in - guard !isUpdating else { return } - let previous = isEnabled - isEnabled = requested - isUpdating = true - Task { @MainActor in - defer { isUpdating = false } - if !(await onChange(requested)) { - isEnabled = previous - } - } + startMutation(requested) } ) } + + private func startMutation(_ requested: Bool) { + guard mutationTask == nil, !isUpdating else { return } + let previous = isEnabled + isEnabled = requested + isUpdating = true + mutationTask = Task { @MainActor in + defer { + mutationTask = nil + isUpdating = false + } + let succeeded = await onChange(requested) + guard !Task.isCancelled else { return } + if !succeeded { + isEnabled = previous + } + } + } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index ac2c1f1b605..a6e29562649 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -238,6 +238,8 @@ struct MobileSettingsView: View { } Section(L10n.string("mobile.settings.betaFeatures", defaultValue: "Beta Features")) { + // The legacy Toasts preference was permanently retired; do + // not expose a control for that disabled setting. Toggle(isOn: $displaySettings.taskComposerEnabled) { Text(L10n.string( "mobile.settings.taskComposer", From 653a43d98a2a51709a2cc8863cd2c3b477fbf0ce Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:04:00 -0700 Subject: [PATCH 008/117] fix(ios): bound push toggle mutations --- .../MobilePushReadinessPreviewView.swift | 41 +++++++++++- .../CmuxMobileShellUI/MobilePushToggle.swift | 62 ++++++++++++++++--- ios/cmux/Resources/Localizable.xcstrings | 17 +++++ ios/cmuxUITests/PushReadinessUITests.swift | 3 + 4 files changed, 113 insertions(+), 10 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index b53538dec96..3ced6102d23 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -10,6 +10,32 @@ import SwiftUI /// network/OS seams are fixtures, so accessibility, localization, optimistic /// mutation, rollback, and every rendered repair action remain production code. struct MobilePushReadinessPreviewView: View { + @MainActor + private final class PhoneMutationGate { + private var waiter: CheckedContinuation? + + func wait() async { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if Task.isCancelled { + continuation.resume() + } else { + waiter = continuation + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.release() + } + } + } + + func release() { + waiter?.resume() + waiter = nil + } + } + private let fixture: Fixture private let rejectsMacMutations: Bool private let delaysPhoneMutation: Bool @@ -18,6 +44,7 @@ struct MobilePushReadinessPreviewView: View { @State private var authorization: MobilePushAuthorization @State private var registration: PushRegistrationSnapshot @State private var macStatus: MobileHostPhonePushStatus? + @State private var phoneMutationGate = PhoneMutationGate() init(state: String, environment: [String: String] = ProcessInfo.processInfo.environment) { let fixture = Fixture(rawValue: state) ?? .healthy @@ -49,6 +76,18 @@ struct MobilePushReadinessPreviewView: View { onMacMutation: mutateMac, onSendTest: { .queuedOnMac } ) + + if delaysPhoneMutation { + Button { + phoneMutationGate.release() + } label: { + Text(L10n.string( + "mobile.debug.push.completeMutation", + defaultValue: "Complete Push Mutation" + )) + } + .accessibilityIdentifier("MobilePushReadinessCompletePhoneMutation") + } } } .navigationTitle(L10n.string( @@ -72,7 +111,7 @@ struct MobilePushReadinessPreviewView: View { @MainActor private func setPhoneEnabled(_ enabled: Bool) async -> Bool { if delaysPhoneMutation { - try? await Task.sleep(for: .seconds(2)) + await phoneMutationGate.wait() } phoneEnabled = enabled registration = enabled diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index 3e41e3c3a8d..eb1f7856937 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -7,10 +7,17 @@ import SwiftUI /// Keeping this binding shared prevents release and diagnostic settings from /// drifting into different interaction behavior. struct MobilePushToggle: View { + private static let mutationTimeout: Duration = .seconds(30) + @Binding var isEnabled: Bool @Binding var isUpdating: Bool let onChange: @MainActor (Bool) async -> Bool + var mutationClock: any Clock = ContinuousClock() + @State private var mutationTask: Task? + @State private var mutationTimeoutTask: Task? + @State private var mutationID: UUID? + @State private var previousValue: Bool? var body: some View { Toggle( @@ -23,9 +30,7 @@ struct MobilePushToggle: View { .accessibilityIdentifier("MobileSettingsNotifications") .disabled(isUpdating) .onDisappear { - mutationTask?.cancel() - mutationTask = nil - isUpdating = false + cancelMutation() } } @@ -40,20 +45,59 @@ struct MobilePushToggle: View { private func startMutation(_ requested: Bool) { guard mutationTask == nil, !isUpdating else { return } + let mutationID = UUID() let previous = isEnabled + self.mutationID = mutationID + previousValue = previous isEnabled = requested isUpdating = true mutationTask = Task { @MainActor in - defer { - mutationTask = nil - isUpdating = false - } let succeeded = await onChange(requested) guard !Task.isCancelled else { return } - if !succeeded { - isEnabled = previous + finishMutation(id: mutationID, succeeded: succeeded) + } + mutationTimeoutTask = Task { @MainActor in + do { + try await mutationClock.sleep(for: Self.mutationTimeout) + } catch { + return } + guard !Task.isCancelled else { return } + finishMutation(id: mutationID, succeeded: false, cancelOperation: true) + } + } + + private func finishMutation( + id: UUID, + succeeded: Bool, + cancelOperation: Bool = false + ) { + guard mutationID == id else { return } + if cancelOperation { + mutationTask?.cancel() + } + if !succeeded, let previousValue { + isEnabled = previousValue + } + mutationTask = nil + mutationTimeoutTask?.cancel() + mutationTimeoutTask = nil + mutationID = nil + previousValue = nil + isUpdating = false + } + + private func cancelMutation() { + mutationTask?.cancel() + mutationTimeoutTask?.cancel() + if let previousValue { + isEnabled = previousValue } + mutationTask = nil + mutationTimeoutTask = nil + mutationID = nil + previousValue = nil + isUpdating = false } } #endif diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index fb923beb998..a134785caa0 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -1531,6 +1531,23 @@ } } }, + "mobile.debug.push.completeMutation": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Complete Push Mutation" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プッシュ変更を完了" + } + } + } + }, "mobile.common.ok": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index 2d65a2b85da..1761533567e 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -136,6 +136,9 @@ final class PushReadinessUITests: XCTestCase { message: "The toggle must reflect the requested opt-out before async cleanup finishes" ) waitForDisabled(phone) + let completeMutation = app.buttons["MobilePushReadinessCompletePhoneMutation"] + XCTAssertTrue(completeMutation.waitForExistence(timeout: 2)) + completeMutation.tap() waitForEnabled(phone) XCTAssertEqual(phone.value as? String, "0") } From a162e511196faca9671059d9b7a3864882aae345 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:09:33 -0700 Subject: [PATCH 009/117] refactor(ios): isolate push preview mutation gate --- ...MobilePushReadinessPhoneMutationGate.swift | 31 +++++++++++++++++++ .../MobilePushReadinessPreviewView.swift | 28 +---------------- 2 files changed, 32 insertions(+), 27 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPhoneMutationGate.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPhoneMutationGate.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPhoneMutationGate.swift new file mode 100644 index 00000000000..5873c2dc0da --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPhoneMutationGate.swift @@ -0,0 +1,31 @@ +#if os(iOS) && DEBUG +import Foundation + +/// Test-only signal for holding a push mutation open until XCUITest observes +/// the optimistic, disabled toggle state. +@MainActor +final class MobilePushReadinessPhoneMutationGate { + private var waiter: CheckedContinuation? + + func wait() async { + await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if Task.isCancelled { + continuation.resume() + } else { + waiter = continuation + } + } + } onCancel: { + Task { @MainActor [weak self] in + self?.release() + } + } + } + + func release() { + waiter?.resume() + waiter = nil + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index 3ced6102d23..f2d9ea8e22a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -10,32 +10,6 @@ import SwiftUI /// network/OS seams are fixtures, so accessibility, localization, optimistic /// mutation, rollback, and every rendered repair action remain production code. struct MobilePushReadinessPreviewView: View { - @MainActor - private final class PhoneMutationGate { - private var waiter: CheckedContinuation? - - func wait() async { - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - if Task.isCancelled { - continuation.resume() - } else { - waiter = continuation - } - } - } onCancel: { - Task { @MainActor [weak self] in - self?.release() - } - } - } - - func release() { - waiter?.resume() - waiter = nil - } - } - private let fixture: Fixture private let rejectsMacMutations: Bool private let delaysPhoneMutation: Bool @@ -44,7 +18,7 @@ struct MobilePushReadinessPreviewView: View { @State private var authorization: MobilePushAuthorization @State private var registration: PushRegistrationSnapshot @State private var macStatus: MobileHostPhonePushStatus? - @State private var phoneMutationGate = PhoneMutationGate() + @State private var phoneMutationGate = MobilePushReadinessPhoneMutationGate() init(state: String, environment: [String: String] = ProcessInfo.processInfo.environment) { let fixture = Fixture(rawValue: state) ?? .healthy From 6b698958e7e3bcdf84d86fc6d764a35e240d0a68 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:22:01 -0700 Subject: [PATCH 010/117] fix(ios): surface uncertain push toggle outcomes --- .../MobilePushReadinessPreviewView.swift | 6 + .../MobilePushSettingsContent.swift | 6 +- .../CmuxMobileShellUI/MobilePushToggle.swift | 116 +++++++++++++++--- .../MobileSettingsView.swift | 10 +- ios/cmux/Resources/Localizable.xcstrings | 34 +++++ 5 files changed, 156 insertions(+), 16 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index f2d9ea8e22a..2af5f0805c6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -46,6 +46,7 @@ struct MobilePushReadinessPreviewView: View { supportsMacTest: macStatus != nil, canConnectMac: true, onPhoneEnabledChange: setPhoneEnabled, + onPhoneEnabledReconcile: reconcilePhoneEnabled, onRepair: repair, onMacMutation: mutateMac, onSendTest: { .queuedOnMac } @@ -94,6 +95,11 @@ struct MobilePushReadinessPreviewView: View { return true } + @MainActor + private func reconcilePhoneEnabled() async -> Bool? { + registration.isEnabled ? phoneEnabled : false + } + @MainActor private func repair(_ repair: MobilePushReadiness.Repair) async -> Bool { switch repair { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift index 77cde93ea23..21e3e0164a9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift @@ -25,6 +25,7 @@ struct MobilePushSettingsContent: View { let supportsMacTest: Bool let canConnectMac: Bool let onPhoneEnabledChange: @MainActor (Bool) async -> Bool + let onPhoneEnabledReconcile: @MainActor () async -> Bool? let onRepair: @MainActor (MobilePushReadiness.Repair) async -> Bool let onMacMutation: @MainActor (MobilePushMacMutation) async -> Bool let onSendTest: @MainActor () async -> MobilePhonePushTestStage @@ -47,6 +48,7 @@ struct MobilePushSettingsContent: View { supportsMacTest: Bool, canConnectMac: Bool, onPhoneEnabledChange: @escaping @MainActor (Bool) async -> Bool, + onPhoneEnabledReconcile: @escaping @MainActor () async -> Bool?, onRepair: @escaping @MainActor (MobilePushReadiness.Repair) async -> Bool, onMacMutation: @escaping @MainActor (MobilePushMacMutation) async -> Bool, onSendTest: @escaping @MainActor () async -> MobilePhonePushTestStage @@ -58,6 +60,7 @@ struct MobilePushSettingsContent: View { self.supportsMacTest = supportsMacTest self.canConnectMac = canConnectMac self.onPhoneEnabledChange = onPhoneEnabledChange + self.onPhoneEnabledReconcile = onPhoneEnabledReconcile self.onRepair = onRepair self.onMacMutation = onMacMutation self.onSendTest = onSendTest @@ -78,7 +81,8 @@ struct MobilePushSettingsContent: View { MobilePushToggle( isEnabled: $phoneEnabled, isUpdating: $isMutatingPhone, - onChange: onPhoneEnabledChange + onChange: onPhoneEnabledChange, + onReconcile: onPhoneEnabledReconcile ) if let repair = readiness.repair, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index eb1f7856937..506aa07d1a6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -12,23 +12,55 @@ struct MobilePushToggle: View { @Binding var isEnabled: Bool @Binding var isUpdating: Bool let onChange: @MainActor (Bool) async -> Bool + let onReconcile: @MainActor () async -> Bool? var mutationClock: any Clock = ContinuousClock() @State private var mutationTask: Task? @State private var mutationTimeoutTask: Task? + @State private var reconciliationTask: Task? @State private var mutationID: UUID? + @State private var reconciliationID: UUID? @State private var previousValue: Bool? + @State private var retryValue: Bool? + @State private var showsMutationError = false var body: some View { - Toggle( - L10n.string( - "mobile.notifications.phoneEnabled", - defaultValue: "Allow Push Alerts on This iPhone" - ), - isOn: binding - ) - .accessibilityIdentifier("MobileSettingsNotifications") - .disabled(isUpdating) + VStack(alignment: .leading, spacing: 4) { + Toggle( + L10n.string( + "mobile.notifications.phoneEnabled", + defaultValue: "Allow Push Alerts on This iPhone" + ), + isOn: binding + ) + .accessibilityIdentifier("MobileSettingsNotifications") + .disabled(isUpdating) + + if showsMutationError { + Text(L10n.string( + "mobile.notifications.phoneMutationFailed", + defaultValue: "Couldn't update Push Alerts. Check your connection and try again." + )) + .font(.footnote) + .foregroundStyle(.red) + .accessibilityIdentifier("MobileSettingsNotificationsError") + + if let retryValue { + Button { + startMutation(retryValue) + } label: { + Text(L10n.string( + "mobile.notifications.phoneMutationRetry", + defaultValue: "Try Again" + )) + } + .accessibilityIdentifier("MobileSettingsNotificationsRetry") + } + } + } + .onAppear { + reconcileIfNeeded() + } .onDisappear { cancelMutation() } @@ -45,6 +77,11 @@ struct MobilePushToggle: View { private func startMutation(_ requested: Bool) { guard mutationTask == nil, !isUpdating else { return } + reconciliationTask?.cancel() + reconciliationTask = nil + reconciliationID = nil + showsMutationError = false + retryValue = nil let mutationID = UUID() let previous = isEnabled self.mutationID = mutationID @@ -54,7 +91,11 @@ struct MobilePushToggle: View { mutationTask = Task { @MainActor in let succeeded = await onChange(requested) guard !Task.isCancelled else { return } - finishMutation(id: mutationID, succeeded: succeeded) + finishMutation( + id: mutationID, + requested: requested, + succeeded: succeeded + ) } mutationTimeoutTask = Task { @MainActor in do { @@ -63,35 +104,59 @@ struct MobilePushToggle: View { return } guard !Task.isCancelled else { return } - finishMutation(id: mutationID, succeeded: false, cancelOperation: true) + finishMutation( + id: mutationID, + requested: requested, + succeeded: false, + cancelOperation: true, + outcomeUnknown: true + ) } } private func finishMutation( id: UUID, + requested: Bool, succeeded: Bool, - cancelOperation: Bool = false + cancelOperation: Bool = false, + outcomeUnknown: Bool = false ) { guard mutationID == id else { return } if cancelOperation { mutationTask?.cancel() } - if !succeeded, let previousValue { + if !succeeded, !outcomeUnknown, let previousValue { isEnabled = previousValue } + if !succeeded { + showsMutationError = true + retryValue = requested + } mutationTask = nil mutationTimeoutTask?.cancel() mutationTimeoutTask = nil mutationID = nil previousValue = nil isUpdating = false + if outcomeUnknown { + startReconciliation(for: requested) + } } private func cancelMutation() { mutationTask?.cancel() mutationTimeoutTask?.cancel() + reconciliationTask?.cancel() + reconciliationTask = nil + reconciliationID = nil + // Cancellation cannot prove that an already-submitted request did not + // commit. Keep the optimistic value marked unknown until the next + // appearance asks the owner for its authoritative state. if let previousValue { - isEnabled = previousValue + if isEnabled != previousValue { + showsMutationError = true + retryValue = isEnabled + } } mutationTask = nil mutationTimeoutTask = nil @@ -99,5 +164,28 @@ struct MobilePushToggle: View { previousValue = nil isUpdating = false } + + private func startReconciliation(for requested: Bool) { + let reconciliationID = UUID() + self.reconciliationID = reconciliationID + reconciliationTask?.cancel() + reconciliationTask = Task { @MainActor in + let authoritative = await onReconcile() + guard !Task.isCancelled, + self.reconciliationID == reconciliationID else { return } + if let authoritative { + isEnabled = authoritative + showsMutationError = authoritative != requested + retryValue = authoritative == requested ? nil : requested + } + reconciliationTask = nil + self.reconciliationID = nil + } + } + + private func reconcileIfNeeded() { + guard mutationTask == nil, let retryValue, showsMutationError else { return } + startReconciliation(for: retryValue) + } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index a6e29562649..24a8df97893 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -395,6 +395,7 @@ struct MobileSettingsView: View { supportsMacTest: store?.supportsPhonePushTest == true, canConnectMac: startPairingScanner != nil, onPhoneEnabledChange: updatePhonePushEnabled, + onPhoneEnabledReconcile: reconcilePhonePushEnabled, onRepair: repairPhonePush, onMacMutation: updateMacPhonePush, onSendTest: sendPhonePushTest @@ -427,7 +428,8 @@ struct MobileSettingsView: View { MobilePushToggle( isEnabled: $notificationsEnabled, isUpdating: $notificationsToggleUpdating, - onChange: updatePhonePushEnabled + onChange: updatePhonePushEnabled, + onReconcile: reconcilePhonePushEnabled ) #endif } @@ -625,6 +627,12 @@ struct MobileSettingsView: View { return !pushCoordinator.isEnabled } + @MainActor + private func reconcilePhonePushEnabled() async -> Bool? { + await pushCoordinator.refreshReadiness() + return pushCoordinator.isEnabled + } + @MainActor private func repairPhonePush( _ repair: MobilePushReadiness.Repair diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index a134785caa0..9f7fc77aa25 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -1548,6 +1548,40 @@ } } }, + "mobile.notifications.phoneMutationFailed": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Couldn't update Push Alerts. Check your connection and try again." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プッシュ通知を更新できませんでした。接続を確認して、もう一度お試しください。" + } + } + } + }, + "mobile.notifications.phoneMutationRetry": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Try Again" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "再試行" + } + } + } + }, "mobile.common.ok": { "extractionState": "manual", "localizations": { From 5f4c064122b6b9ce0130f8a7bdf0faf2a77060bc Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:29:03 -0700 Subject: [PATCH 011/117] fix(ios): bound push readiness reconciliation --- .../CmuxMobileShellUI/MobilePushToggle.swift | 40 +++++++++++++++++-- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index 506aa07d1a6..a420c820c06 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -18,6 +18,7 @@ struct MobilePushToggle: View { @State private var mutationTask: Task? @State private var mutationTimeoutTask: Task? @State private var reconciliationTask: Task? + @State private var reconciliationTimeoutTask: Task? @State private var mutationID: UUID? @State private var reconciliationID: UUID? @State private var previousValue: Bool? @@ -79,6 +80,8 @@ struct MobilePushToggle: View { guard mutationTask == nil, !isUpdating else { return } reconciliationTask?.cancel() reconciliationTask = nil + reconciliationTimeoutTask?.cancel() + reconciliationTimeoutTask = nil reconciliationID = nil showsMutationError = false retryValue = nil @@ -147,7 +150,9 @@ struct MobilePushToggle: View { mutationTask?.cancel() mutationTimeoutTask?.cancel() reconciliationTask?.cancel() + reconciliationTimeoutTask?.cancel() reconciliationTask = nil + reconciliationTimeoutTask = nil reconciliationID = nil // Cancellation cannot prove that an already-submitted request did not // commit. Keep the optimistic value marked unknown until the next @@ -166,23 +171,50 @@ struct MobilePushToggle: View { } private func startReconciliation(for requested: Bool) { + guard reconciliationTask == nil else { return } let reconciliationID = UUID() self.reconciliationID = reconciliationID - reconciliationTask?.cancel() reconciliationTask = Task { @MainActor in let authoritative = await onReconcile() - guard !Task.isCancelled, - self.reconciliationID == reconciliationID else { return } - if let authoritative { + guard self.reconciliationID == reconciliationID else { return } + if !Task.isCancelled, let authoritative { isEnabled = authoritative showsMutationError = authoritative != requested retryValue = authoritative == requested ? nil : requested + } else if !Task.isCancelled { + showsMutationError = true + retryValue = requested + } + finishReconciliation(id: reconciliationID) + } + reconciliationTimeoutTask = Task { @MainActor in + do { + try await mutationClock.sleep(for: Self.mutationTimeout) + } catch { + return } + guard !Task.isCancelled, + self.reconciliationID == reconciliationID else { return } + reconciliationTask?.cancel() + // The authoritative read did not complete by the deadline. Keep + // the optimistic value marked unknown and offer a retry instead of + // leaving the control busy forever. + showsMutationError = true + retryValue = requested reconciliationTask = nil + reconciliationTimeoutTask = nil self.reconciliationID = nil } } + private func finishReconciliation(id: UUID) { + guard reconciliationID == id else { return } + reconciliationTimeoutTask?.cancel() + reconciliationTimeoutTask = nil + reconciliationTask = nil + reconciliationID = nil + } + private func reconcileIfNeeded() { guard mutationTask == nil, let retryValue, showsMutationError else { return } startReconciliation(for: retryValue) From 4492cf1eb3eaf65c3ed2bd9c15e58472a1b6be1e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:37:38 -0700 Subject: [PATCH 012/117] fix(ios): serialize timed out push mutations --- .../CmuxMobileShellUI/MobilePushToggle.swift | 66 ++++++++++--------- 1 file changed, 35 insertions(+), 31 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index a420c820c06..962f1d45ece 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -23,6 +23,7 @@ struct MobilePushToggle: View { @State private var reconciliationID: UUID? @State private var previousValue: Bool? @State private var retryValue: Bool? + @State private var mutationTimedOut = false @State private var showsMutationError = false var body: some View { @@ -46,7 +47,7 @@ struct MobilePushToggle: View { .foregroundStyle(.red) .accessibilityIdentifier("MobileSettingsNotificationsError") - if let retryValue { + if mutationTask == nil, reconciliationTask == nil, let retryValue { Button { startMutation(retryValue) } label: { @@ -83,6 +84,7 @@ struct MobilePushToggle: View { reconciliationTimeoutTask?.cancel() reconciliationTimeoutTask = nil reconciliationID = nil + mutationTimedOut = false showsMutationError = false retryValue = nil let mutationID = UUID() @@ -93,7 +95,7 @@ struct MobilePushToggle: View { isUpdating = true mutationTask = Task { @MainActor in let succeeded = await onChange(requested) - guard !Task.isCancelled else { return } + guard self.mutationID == mutationID else { return } finishMutation( id: mutationID, requested: requested, @@ -107,28 +109,25 @@ struct MobilePushToggle: View { return } guard !Task.isCancelled else { return } - finishMutation( - id: mutationID, - requested: requested, - succeeded: false, - cancelOperation: true, - outcomeUnknown: true - ) + guard self.mutationID == mutationID else { return } + // Cancellation is only a request. Keep the operation as the + // owner of the write until it actually returns, then reconcile. + mutationTimedOut = true + showsMutationError = true + retryValue = requested + mutationTask?.cancel() + mutationTimeoutTask = nil } } private func finishMutation( id: UUID, requested: Bool, - succeeded: Bool, - cancelOperation: Bool = false, - outcomeUnknown: Bool = false + succeeded: Bool ) { guard mutationID == id else { return } - if cancelOperation { - mutationTask?.cancel() - } - if !succeeded, !outcomeUnknown, let previousValue { + let outcomeWasUnknown = mutationTimedOut + if !succeeded, !outcomeWasUnknown, let previousValue { isEnabled = previousValue } if !succeeded { @@ -140,34 +139,39 @@ struct MobilePushToggle: View { mutationTimeoutTask = nil mutationID = nil previousValue = nil + mutationTimedOut = false isUpdating = false - if outcomeUnknown { + if outcomeWasUnknown { startReconciliation(for: requested) } } private func cancelMutation() { + guard mutationTask != nil else { + mutationTimeoutTask?.cancel() + mutationTimeoutTask = nil + reconciliationTask?.cancel() + reconciliationTimeoutTask?.cancel() + reconciliationTask = nil + reconciliationTimeoutTask = nil + reconciliationID = nil + return + } + + // A disappearing view may cancel the task after its request reached + // the service. Preserve the active operation and let its completion + // trigger reconciliation, rather than clearing its ownership here. + mutationTimedOut = true + showsMutationError = true + retryValue = isEnabled mutationTask?.cancel() mutationTimeoutTask?.cancel() + mutationTimeoutTask = nil reconciliationTask?.cancel() reconciliationTimeoutTask?.cancel() reconciliationTask = nil reconciliationTimeoutTask = nil reconciliationID = nil - // Cancellation cannot prove that an already-submitted request did not - // commit. Keep the optimistic value marked unknown until the next - // appearance asks the owner for its authoritative state. - if let previousValue { - if isEnabled != previousValue { - showsMutationError = true - retryValue = isEnabled - } - } - mutationTask = nil - mutationTimeoutTask = nil - mutationID = nil - previousValue = nil - isUpdating = false } private func startReconciliation(for requested: Bool) { From 57bef7d12b19c16a33ec9c59c0933f8e2574f01e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 15:49:15 -0700 Subject: [PATCH 013/117] fix(ios): serialize push retries after reconciliation --- .../MobilePushMutationSequencer.swift | 73 +++++ .../CmuxMobileShellUI/MobilePushToggle.swift | 261 ++++++++++-------- 2 files changed, 225 insertions(+), 109 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationSequencer.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationSequencer.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationSequencer.swift new file mode 100644 index 00000000000..dd683a028c5 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationSequencer.swift @@ -0,0 +1,73 @@ +#if os(iOS) +import Foundation + +/// Serializes push writes and authoritative reads across timeout recovery. +@MainActor +final class MobilePushMutationSequencer { + private final class Item { + var continuation: CheckedContinuation? + } + + private var queue: [Item] = [] + private var isRunning = false + + /// Queues one operation behind every earlier operation. + func enqueue( + _ operation: @escaping @MainActor () async -> Value, + completion: @escaping @MainActor (Value) -> Void + ) -> Task { + let item = Item() + let task = Task { @MainActor in + await withCheckedContinuation { continuation in + item.continuation = continuation + queue.append(item) + startNextIfNeeded() + } + let value = await operation() + completion(value) + finish(item) + } + return task + } + + private func startNextIfNeeded() { + guard !isRunning, let item = queue.first else { return } + isRunning = true + item.continuation?.resume() + item.continuation = nil + } + + private func finish(_ item: Item) { + guard queue.first === item else { return } + queue.removeFirst() + isRunning = false + startNextIfNeeded() + } +} + +/// Mutable state for one queued push mutation. +@MainActor +final class MobilePushMutationAttempt { + let requested: Bool + var didTimeout = false + var task: Task? + + init(requested: Bool) { + self.requested = requested + } +} + +/// Tracks one authoritative read queued after a timed-out write. +@MainActor +final class MobilePushReconciliationAttempt { + let requested: Bool + let generation: Int + var task: Task? + var timeoutTask: Task? + + init(requested: Bool, generation: Int) { + self.requested = requested + self.generation = generation + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index 962f1d45ece..05fde7b5870 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -1,5 +1,6 @@ #if os(iOS) import CmuxMobileSupport +import Foundation import SwiftUI /// The phone push preference is persisted asynchronously, so update the @@ -15,15 +16,14 @@ struct MobilePushToggle: View { let onReconcile: @MainActor () async -> Bool? var mutationClock: any Clock = ContinuousClock() - @State private var mutationTask: Task? + @State private var mutationSequencer = MobilePushMutationSequencer() + @State private var currentAttempt: MobilePushMutationAttempt? + @State private var currentReconciliation: MobilePushReconciliationAttempt? @State private var mutationTimeoutTask: Task? - @State private var reconciliationTask: Task? - @State private var reconciliationTimeoutTask: Task? - @State private var mutationID: UUID? - @State private var reconciliationID: UUID? @State private var previousValue: Bool? @State private var retryValue: Bool? - @State private var mutationTimedOut = false + @State private var queuedMutationValue: Bool? + @State private var mutationGeneration = 0 @State private var showsMutationError = false var body: some View { @@ -47,7 +47,7 @@ struct MobilePushToggle: View { .foregroundStyle(.red) .accessibilityIdentifier("MobileSettingsNotificationsError") - if mutationTask == nil, reconciliationTask == nil, let retryValue { + if currentReconciliation == nil, let retryValue { Button { startMutation(retryValue) } label: { @@ -64,7 +64,7 @@ struct MobilePushToggle: View { reconcileIfNeeded() } .onDisappear { - cancelMutation() + cancelCurrentOperation() } } @@ -78,150 +78,193 @@ struct MobilePushToggle: View { } private func startMutation(_ requested: Bool) { - guard mutationTask == nil, !isUpdating else { return } - reconciliationTask?.cancel() - reconciliationTask = nil - reconciliationTimeoutTask?.cancel() - reconciliationTimeoutTask = nil - reconciliationID = nil - mutationTimedOut = false - showsMutationError = false - retryValue = nil - let mutationID = UUID() - let previous = isEnabled - self.mutationID = mutationID - previousValue = previous + guard !isUpdating else { return } + if currentReconciliation != nil { + // Let the authoritative read settle before capturing the rollback + // value for this write. The request remains optimistic in the UI, + // but the actual operation is queued behind that read. + queuedMutationValue = requested + isEnabled = requested + isUpdating = true + showsMutationError = false + retryValue = nil + return + } + beginMutation(requested) + } + + private func beginMutation(_ requested: Bool) { + mutationGeneration += 1 + let generation = mutationGeneration + invalidateCurrentReconciliation() + mutationTimeoutTask?.cancel() + mutationTimeoutTask = nil + + let attempt = MobilePushMutationAttempt(requested: requested) + currentAttempt = attempt + previousValue = isEnabled isEnabled = requested isUpdating = true - mutationTask = Task { @MainActor in - let succeeded = await onChange(requested) - guard self.mutationID == mutationID else { return } - finishMutation( - id: mutationID, - requested: requested, - succeeded: succeeded - ) - } + showsMutationError = false + retryValue = nil + + let task = mutationSequencer.enqueue( + { await self.onChange(requested) }, + completion: { succeeded in + self.finishMutation( + attempt: attempt, + generation: generation, + succeeded: succeeded + ) + } + ) + attempt.task = task mutationTimeoutTask = Task { @MainActor in do { try await mutationClock.sleep(for: Self.mutationTimeout) } catch { return } - guard !Task.isCancelled else { return } - guard self.mutationID == mutationID else { return } - // Cancellation is only a request. Keep the operation as the - // owner of the write until it actually returns, then reconcile. - mutationTimedOut = true - showsMutationError = true - retryValue = requested - mutationTask?.cancel() - mutationTimeoutTask = nil + guard !Task.isCancelled, self.currentAttempt === attempt else { return } + timeoutMutation(attempt, generation: generation) } } + private func timeoutMutation( + _ attempt: MobilePushMutationAttempt, + generation: Int + ) { + guard currentAttempt === attempt else { return } + attempt.didTimeout = true + showsMutationError = true + retryValue = attempt.requested + queuedMutationValue = nil + // The request may still finish and commit, so its task remains in the + // sequencer. Releasing the binding keeps the UI recoverable while every + // later write waits behind this one. + isUpdating = false + mutationTimeoutTask = nil + attempt.task?.cancel() + currentAttempt = nil + previousValue = nil + enqueueReconciliation(for: attempt.requested, generation: generation) + } + private func finishMutation( - id: UUID, - requested: Bool, + attempt: MobilePushMutationAttempt, + generation: Int, succeeded: Bool ) { - guard mutationID == id else { return } - let outcomeWasUnknown = mutationTimedOut - if !succeeded, !outcomeWasUnknown, let previousValue { + if attempt.didTimeout { + // The reconciliation was reserved at timeout, immediately after + // this write in the sequencer. A late completion only closes the + // attempt; it must never overwrite a newer UI generation. + return + } + guard currentAttempt === attempt, + generation == mutationGeneration else { return } + if !succeeded, let previousValue { isEnabled = previousValue } if !succeeded { showsMutationError = true - retryValue = requested + retryValue = attempt.requested } - mutationTask = nil mutationTimeoutTask?.cancel() mutationTimeoutTask = nil - mutationID = nil - previousValue = nil - mutationTimedOut = false + currentAttempt = nil + self.previousValue = nil isUpdating = false - if outcomeWasUnknown { - startReconciliation(for: requested) - } } - private func cancelMutation() { - guard mutationTask != nil else { - mutationTimeoutTask?.cancel() - mutationTimeoutTask = nil - reconciliationTask?.cancel() - reconciliationTimeoutTask?.cancel() - reconciliationTask = nil - reconciliationTimeoutTask = nil - reconciliationID = nil - return + private func cancelCurrentOperation() { + if let attempt = currentAttempt { + timeoutMutation(attempt, generation: mutationGeneration) } - - // A disappearing view may cancel the task after its request reached - // the service. Preserve the active operation and let its completion - // trigger reconciliation, rather than clearing its ownership here. - mutationTimedOut = true - showsMutationError = true - retryValue = isEnabled - mutationTask?.cancel() mutationTimeoutTask?.cancel() mutationTimeoutTask = nil - reconciliationTask?.cancel() - reconciliationTimeoutTask?.cancel() - reconciliationTask = nil - reconciliationTimeoutTask = nil - reconciliationID = nil + // Queued operations remain in the sequencer. Their callbacks are + // generation-checked, so a disappearing view cannot race a later read + // or write into visible state. + invalidateCurrentReconciliation() } - private func startReconciliation(for requested: Bool) { - guard reconciliationTask == nil else { return } - let reconciliationID = UUID() - self.reconciliationID = reconciliationID - reconciliationTask = Task { @MainActor in - let authoritative = await onReconcile() - guard self.reconciliationID == reconciliationID else { return } - if !Task.isCancelled, let authoritative { - isEnabled = authoritative - showsMutationError = authoritative != requested - retryValue = authoritative == requested ? nil : requested - } else if !Task.isCancelled { - showsMutationError = true - retryValue = requested + private func enqueueReconciliation(for requested: Bool, generation: Int) { + let reconciliation = MobilePushReconciliationAttempt( + requested: requested, + generation: generation + ) + currentReconciliation = reconciliation + let task = mutationSequencer.enqueue( + { await self.onReconcile() }, + completion: { authoritative in + self.finishReconciliation( + reconciliation, + authoritative: authoritative + ) } - finishReconciliation(id: reconciliationID) - } - reconciliationTimeoutTask = Task { @MainActor in + ) + reconciliation.task = task + reconciliation.timeoutTask = Task { @MainActor in do { try await mutationClock.sleep(for: Self.mutationTimeout) } catch { return } guard !Task.isCancelled, - self.reconciliationID == reconciliationID else { return } - reconciliationTask?.cancel() - // The authoritative read did not complete by the deadline. Keep - // the optimistic value marked unknown and offer a retry instead of - // leaving the control busy forever. + self.currentReconciliation === reconciliation else { return } + reconciliation.task?.cancel() + self.currentReconciliation = nil + reconciliation.timeoutTask = nil + self.showsMutationError = true + self.isUpdating = false + self.retryValue = self.queuedMutationValue ?? requested + self.queuedMutationValue = nil + } + } + + private func finishReconciliation( + _ reconciliation: MobilePushReconciliationAttempt, + authoritative: Bool? + ) { + guard currentReconciliation === reconciliation else { return } + reconciliation.timeoutTask?.cancel() + reconciliation.timeoutTask = nil + currentReconciliation = nil + guard reconciliation.generation == mutationGeneration else { return } + if let authoritative { + isEnabled = authoritative + if let queuedMutationValue { + self.queuedMutationValue = nil + previousValue = authoritative + beginMutation(queuedMutationValue) + return + } + showsMutationError = authoritative != reconciliation.requested + retryValue = authoritative == reconciliation.requested + ? nil + : reconciliation.requested + } else { showsMutationError = true - retryValue = requested - reconciliationTask = nil - reconciliationTimeoutTask = nil - self.reconciliationID = nil + retryValue = queuedMutationValue ?? reconciliation.requested } } - private func finishReconciliation(id: UUID) { - guard reconciliationID == id else { return } - reconciliationTimeoutTask?.cancel() - reconciliationTimeoutTask = nil - reconciliationTask = nil - reconciliationID = nil + private func invalidateCurrentReconciliation() { + currentReconciliation?.timeoutTask?.cancel() + currentReconciliation = nil + queuedMutationValue = nil } private func reconcileIfNeeded() { - guard mutationTask == nil, let retryValue, showsMutationError else { return } - startReconciliation(for: retryValue) + guard currentAttempt == nil, + currentReconciliation == nil, + let retryValue, + showsMutationError else { return } + enqueueReconciliation( + for: retryValue, + generation: mutationGeneration + ) } } #endif From e679e16b52ad5db61efb72f8130d3c2aac239434 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:12:57 -0700 Subject: [PATCH 014/117] fix(ios): own push preference mutation in coordinator --- ...MobilePushReadinessPhoneMutationGate.swift | 31 -- .../MobilePushReadinessPreviewView.swift | 52 ++-- .../MobilePushCoordinator.swift | 71 ++++- .../MobilePushMutationSequencer.swift | 73 ----- .../MobilePushSettingsContent.swift | 22 +- .../CmuxMobileShellUI/MobilePushToggle.swift | 270 +----------------- .../MobileSettingsView.swift | 52 ++-- .../MobilePushCoordinatorLifecycleTests.swift | 33 +++ ios/cmux/Resources/Localizable.xcstrings | 34 --- ios/cmuxUITests/PushReadinessUITests.swift | 28 +- 10 files changed, 188 insertions(+), 478 deletions(-) delete mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPhoneMutationGate.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationSequencer.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPhoneMutationGate.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPhoneMutationGate.swift deleted file mode 100644 index 5873c2dc0da..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPhoneMutationGate.swift +++ /dev/null @@ -1,31 +0,0 @@ -#if os(iOS) && DEBUG -import Foundation - -/// Test-only signal for holding a push mutation open until XCUITest observes -/// the optimistic, disabled toggle state. -@MainActor -final class MobilePushReadinessPhoneMutationGate { - private var waiter: CheckedContinuation? - - func wait() async { - await withTaskCancellationHandler { - await withCheckedContinuation { continuation in - if Task.isCancelled { - continuation.resume() - } else { - waiter = continuation - } - } - } onCancel: { - Task { @MainActor [weak self] in - self?.release() - } - } - } - - func release() { - waiter?.resume() - waiter = nil - } -} -#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index 2af5f0805c6..6277920fe83 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -18,7 +18,7 @@ struct MobilePushReadinessPreviewView: View { @State private var authorization: MobilePushAuthorization @State private var registration: PushRegistrationSnapshot @State private var macStatus: MobileHostPhonePushStatus? - @State private var phoneMutationGate = MobilePushReadinessPhoneMutationGate() + @State private var pendingPhoneMutation: Bool? init(state: String, environment: [String: String] = ProcessInfo.processInfo.environment) { let fixture = Fixture(rawValue: state) ?? .healthy @@ -29,6 +29,7 @@ struct MobilePushReadinessPreviewView: View { self._authorization = State(initialValue: fixture.authorization) self._registration = State(initialValue: fixture.registration) self._macStatus = State(initialValue: fixture.macStatus) + self._pendingPhoneMutation = State(initialValue: nil) } var body: some View { @@ -40,21 +41,19 @@ struct MobilePushReadinessPreviewView: View { )) { MobilePushSettingsContent( readiness: readiness, - phoneEnabled: $phoneEnabled, + phoneEnabled: phoneEnabledBinding, macStatus: macStatus, supportsMacSettings: macStatus != nil, supportsMacTest: macStatus != nil, canConnectMac: true, - onPhoneEnabledChange: setPhoneEnabled, - onPhoneEnabledReconcile: reconcilePhoneEnabled, onRepair: repair, onMacMutation: mutateMac, onSendTest: { .queuedOnMac } ) - if delaysPhoneMutation { + if delaysPhoneMutation, pendingPhoneMutation != nil { Button { - phoneMutationGate.release() + completePhoneMutation() } label: { Text(L10n.string( "mobile.debug.push.completeMutation", @@ -63,6 +62,7 @@ struct MobilePushReadinessPreviewView: View { } .accessibilityIdentifier("MobilePushReadinessCompletePhoneMutation") } + } } .navigationTitle(L10n.string( @@ -83,28 +83,42 @@ struct MobilePushReadinessPreviewView: View { ) } - @MainActor - private func setPhoneEnabled(_ enabled: Bool) async -> Bool { - if delaysPhoneMutation { - await phoneMutationGate.wait() - } - phoneEnabled = enabled - registration = enabled - ? Self.registered - : .disabled - return true + private var phoneEnabledBinding: Binding { + Binding( + get: { phoneEnabled }, + set: { enabled in + phoneEnabled = enabled + if delaysPhoneMutation { + pendingPhoneMutation = enabled + } else { + applyPhoneMutation(enabled) + } + } + ) + } + + private func completePhoneMutation() { + guard let pendingPhoneMutation else { return } + applyPhoneMutation(pendingPhoneMutation) + self.pendingPhoneMutation = nil + } + + private func applyPhoneMutation(_ enabled: Bool) { + registration = enabled ? Self.registered : .disabled } @MainActor - private func reconcilePhoneEnabled() async -> Bool? { - registration.isEnabled ? phoneEnabled : false + private func setPhoneEnabled(_ enabled: Bool) -> Bool { + phoneEnabled = enabled + applyPhoneMutation(enabled) + return true } @MainActor private func repair(_ repair: MobilePushReadiness.Repair) async -> Bool { switch repair { case .enableOnPhone: - return await setPhoneEnabled(true) + return setPhoneEnabled(true) case .retryDeviceTokenRegistration, .retryRegistration: registration = Self.registered return true diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 152974736a0..a39c0590c43 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -113,6 +113,11 @@ public final class MobilePushCoordinator { @ObservationIgnored private var registrationSnapshotTask: Task? @ObservationIgnored private var registrationRecoveryTask: Task? + /// Settings owns the user intent, while the registration service owns the + /// network side effect. Keeping the drain task here means a settings view + /// can disappear without cancelling an opt-out that is already visible. + @ObservationIgnored private var pendingSettingsIntent: Bool? + @ObservationIgnored private var settingsMutationTask: Task? @ObservationIgnored private var workspaceAuthorizationRequestInFlight = false @ObservationIgnored private var hasRequestedRemoteRegistration = false @@ -188,9 +193,33 @@ public final class MobilePushCoordinator { self.unregisterForRemoteNotifications = unregisterForRemoteNotifications } - /// Whether the user has opted into phone notifications (synchronous mirror). + /// Whether the user has opted into phone notifications. + /// + /// This is an app-lifetime observable mirror, rather than a view-local + /// value. Settings can therefore render the requested value before the + /// backend cleanup finishes. public var isEnabled: Bool { enabledMirror } + /// Apply a Settings preference immediately and finish its registration work + /// from the app-lifetime coordinator. Repeated taps are coalesced to the + /// latest intent and are serialized with the registration actor, so a view + /// lifecycle cannot strand or reorder a mutation. + public func setEnabledIntent(_ enabled: Bool) { + guard enabled != enabledMirror || pendingSettingsIntent != nil else { + return + } + if enabled { + persistEnabledIntent() + } else { + prepareDisable() + } + pendingSettingsIntent = enabled + guard settingsMutationTask == nil else { return } + settingsMutationTask = Task { @MainActor [weak self] in + await self?.drainSettingsMutations() + } + } + /// Point routing at the active store (called by the root view on appear). public func bind(store: CMUXMobileShellStore) { self.store = store @@ -329,20 +358,46 @@ public final class MobilePushCoordinator { /// Opt out: stop receiving pushes and remove the token server-side. public func disable() async { + prepareDisable() + await finishDisable() + } + + private func prepareDisable() { diagnosticLog?.recordAppEvent(.pushDisabled) enabledMirror = false registrationSnapshot = .disabled hasRequestedRemoteRegistration = false unregisterForRemoteNotifications() + } + + private func finishDisable() async { // The production registration service owns this same persisted key // and checks its previous value to decide whether server cleanup is // required. Let it observe the prior `true` before mirroring the final // preference here; writing `false` first would skip token removal. await registration.setEnabled(false) + guard !enabledMirror else { return } defaults.set(false, forKey: Self.enabledKey) registrationSnapshot = await registration.snapshot } + private func drainSettingsMutations() async { + while let requested = pendingSettingsIntent { + pendingSettingsIntent = nil + if requested { + // A newer opt-out can arrive while authorization or token + // recovery is suspended. The next loop iteration will perform + // the corresponding cleanup, and the coordinator's mirror + // remains authoritative throughout. + guard enabledMirror else { continue } + _ = await enable(trigger: "settings_toggle") + } else { + await finishDisable() + } + } + settingsMutationTask = nil + } + /// Hand a freshly-registered APNs token to the network layer. public func handleDeviceToken(_ token: Data) async { diagnosticLog?.recordAppEvent(.pushDeviceTokenReceived, count: token.count) @@ -443,6 +498,10 @@ public final class MobilePushCoordinator { } private func recoverRegistrationIfNeeded() async { + guard enabledMirror else { + registrationSnapshot = .disabled + return + } let current = await registration.snapshot registrationSnapshot = current guard current.isEnabled, current.hasDeviceToken, @@ -542,7 +601,9 @@ public final class MobilePushCoordinator { let snapshots = await registration.snapshots() for await snapshot in snapshots { guard !Task.isCancelled, let self else { return } - self.registrationSnapshot = snapshot + self.registrationSnapshot = self.enabledMirror + ? snapshot + : .disabled } } } @@ -603,6 +664,12 @@ public final class MobilePushCoordinator { ) } + deinit { + settingsMutationTask?.cancel() + registrationSnapshotTask?.cancel() + registrationRecoveryTask?.cancel() + } + /// Whether to show a banner while the app is foreground. Suppressed when the /// user is already viewing the terminal the notification is about. public func shouldPresentInForeground(workspaceId: String?, surfaceId: String?) -> Bool { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationSequencer.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationSequencer.swift deleted file mode 100644 index dd683a028c5..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationSequencer.swift +++ /dev/null @@ -1,73 +0,0 @@ -#if os(iOS) -import Foundation - -/// Serializes push writes and authoritative reads across timeout recovery. -@MainActor -final class MobilePushMutationSequencer { - private final class Item { - var continuation: CheckedContinuation? - } - - private var queue: [Item] = [] - private var isRunning = false - - /// Queues one operation behind every earlier operation. - func enqueue( - _ operation: @escaping @MainActor () async -> Value, - completion: @escaping @MainActor (Value) -> Void - ) -> Task { - let item = Item() - let task = Task { @MainActor in - await withCheckedContinuation { continuation in - item.continuation = continuation - queue.append(item) - startNextIfNeeded() - } - let value = await operation() - completion(value) - finish(item) - } - return task - } - - private func startNextIfNeeded() { - guard !isRunning, let item = queue.first else { return } - isRunning = true - item.continuation?.resume() - item.continuation = nil - } - - private func finish(_ item: Item) { - guard queue.first === item else { return } - queue.removeFirst() - isRunning = false - startNextIfNeeded() - } -} - -/// Mutable state for one queued push mutation. -@MainActor -final class MobilePushMutationAttempt { - let requested: Bool - var didTimeout = false - var task: Task? - - init(requested: Bool) { - self.requested = requested - } -} - -/// Tracks one authoritative read queued after a timed-out write. -@MainActor -final class MobilePushReconciliationAttempt { - let requested: Bool - let generation: Int - var task: Task? - var timeoutTask: Task? - - init(requested: Bool, generation: Int) { - self.requested = requested - self.generation = generation - } -} -#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift index 21e3e0164a9..0b55c74db6d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift @@ -24,8 +24,6 @@ struct MobilePushSettingsContent: View { let supportsMacSettings: Bool let supportsMacTest: Bool let canConnectMac: Bool - let onPhoneEnabledChange: @MainActor (Bool) async -> Bool - let onPhoneEnabledReconcile: @MainActor () async -> Bool? let onRepair: @MainActor (MobilePushReadiness.Repair) async -> Bool let onMacMutation: @MainActor (MobilePushMacMutation) async -> Bool let onSendTest: @MainActor () async -> MobilePhonePushTestStage @@ -47,8 +45,6 @@ struct MobilePushSettingsContent: View { supportsMacSettings: Bool, supportsMacTest: Bool, canConnectMac: Bool, - onPhoneEnabledChange: @escaping @MainActor (Bool) async -> Bool, - onPhoneEnabledReconcile: @escaping @MainActor () async -> Bool?, onRepair: @escaping @MainActor (MobilePushReadiness.Repair) async -> Bool, onMacMutation: @escaping @MainActor (MobilePushMacMutation) async -> Bool, onSendTest: @escaping @MainActor () async -> MobilePhonePushTestStage @@ -59,8 +55,6 @@ struct MobilePushSettingsContent: View { self.supportsMacSettings = supportsMacSettings self.supportsMacTest = supportsMacTest self.canConnectMac = canConnectMac - self.onPhoneEnabledChange = onPhoneEnabledChange - self.onPhoneEnabledReconcile = onPhoneEnabledReconcile self.onRepair = onRepair self.onMacMutation = onMacMutation self.onSendTest = onSendTest @@ -79,10 +73,8 @@ struct MobilePushSettingsContent: View { statusRow MobilePushToggle( - isEnabled: $phoneEnabled, - isUpdating: $isMutatingPhone, - onChange: onPhoneEnabledChange, - onReconcile: onPhoneEnabledReconcile + isEnabled: phoneEnabledBinding, + isUpdating: isMutatingPhone ) if let repair = readiness.repair, @@ -259,6 +251,16 @@ struct MobilePushSettingsContent: View { ) } + private var phoneEnabledBinding: Binding { + Binding( + get: { phoneEnabled }, + set: { requested in + guard !isMutatingPhone else { return } + phoneEnabled = requested + } + ) + } + private var macHideContentBinding: Binding { Binding( get: { macHideContent }, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index 05fde7b5870..12df3b52ab0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -1,270 +1,24 @@ #if os(iOS) import CmuxMobileSupport -import Foundation import SwiftUI -/// The phone push preference is persisted asynchronously, so update the -/// control optimistically and roll it back only when the mutation fails. -/// Keeping this binding shared prevents release and diagnostic settings from -/// drifting into different interaction behavior. +/// The phone preference is owned by ``MobilePushCoordinator``. This view only +/// renders the binding, so leaving Settings cannot cancel or roll back a +/// requested opt-out. struct MobilePushToggle: View { - private static let mutationTimeout: Duration = .seconds(30) - @Binding var isEnabled: Bool - @Binding var isUpdating: Bool - let onChange: @MainActor (Bool) async -> Bool - let onReconcile: @MainActor () async -> Bool? - var mutationClock: any Clock = ContinuousClock() - - @State private var mutationSequencer = MobilePushMutationSequencer() - @State private var currentAttempt: MobilePushMutationAttempt? - @State private var currentReconciliation: MobilePushReconciliationAttempt? - @State private var mutationTimeoutTask: Task? - @State private var previousValue: Bool? - @State private var retryValue: Bool? - @State private var queuedMutationValue: Bool? - @State private var mutationGeneration = 0 - @State private var showsMutationError = false + let isUpdating: Bool var body: some View { - VStack(alignment: .leading, spacing: 4) { - Toggle( - L10n.string( - "mobile.notifications.phoneEnabled", - defaultValue: "Allow Push Alerts on This iPhone" - ), - isOn: binding - ) - .accessibilityIdentifier("MobileSettingsNotifications") - .disabled(isUpdating) - - if showsMutationError { - Text(L10n.string( - "mobile.notifications.phoneMutationFailed", - defaultValue: "Couldn't update Push Alerts. Check your connection and try again." - )) - .font(.footnote) - .foregroundStyle(.red) - .accessibilityIdentifier("MobileSettingsNotificationsError") - - if currentReconciliation == nil, let retryValue { - Button { - startMutation(retryValue) - } label: { - Text(L10n.string( - "mobile.notifications.phoneMutationRetry", - defaultValue: "Try Again" - )) - } - .accessibilityIdentifier("MobileSettingsNotificationsRetry") - } - } - } - .onAppear { - reconcileIfNeeded() - } - .onDisappear { - cancelCurrentOperation() - } - } - - private var binding: Binding { - Binding( - get: { isEnabled }, - set: { requested in - startMutation(requested) - } - ) - } - - private func startMutation(_ requested: Bool) { - guard !isUpdating else { return } - if currentReconciliation != nil { - // Let the authoritative read settle before capturing the rollback - // value for this write. The request remains optimistic in the UI, - // but the actual operation is queued behind that read. - queuedMutationValue = requested - isEnabled = requested - isUpdating = true - showsMutationError = false - retryValue = nil - return - } - beginMutation(requested) - } - - private func beginMutation(_ requested: Bool) { - mutationGeneration += 1 - let generation = mutationGeneration - invalidateCurrentReconciliation() - mutationTimeoutTask?.cancel() - mutationTimeoutTask = nil - - let attempt = MobilePushMutationAttempt(requested: requested) - currentAttempt = attempt - previousValue = isEnabled - isEnabled = requested - isUpdating = true - showsMutationError = false - retryValue = nil - - let task = mutationSequencer.enqueue( - { await self.onChange(requested) }, - completion: { succeeded in - self.finishMutation( - attempt: attempt, - generation: generation, - succeeded: succeeded - ) - } - ) - attempt.task = task - mutationTimeoutTask = Task { @MainActor in - do { - try await mutationClock.sleep(for: Self.mutationTimeout) - } catch { - return - } - guard !Task.isCancelled, self.currentAttempt === attempt else { return } - timeoutMutation(attempt, generation: generation) - } - } - - private func timeoutMutation( - _ attempt: MobilePushMutationAttempt, - generation: Int - ) { - guard currentAttempt === attempt else { return } - attempt.didTimeout = true - showsMutationError = true - retryValue = attempt.requested - queuedMutationValue = nil - // The request may still finish and commit, so its task remains in the - // sequencer. Releasing the binding keeps the UI recoverable while every - // later write waits behind this one. - isUpdating = false - mutationTimeoutTask = nil - attempt.task?.cancel() - currentAttempt = nil - previousValue = nil - enqueueReconciliation(for: attempt.requested, generation: generation) - } - - private func finishMutation( - attempt: MobilePushMutationAttempt, - generation: Int, - succeeded: Bool - ) { - if attempt.didTimeout { - // The reconciliation was reserved at timeout, immediately after - // this write in the sequencer. A late completion only closes the - // attempt; it must never overwrite a newer UI generation. - return - } - guard currentAttempt === attempt, - generation == mutationGeneration else { return } - if !succeeded, let previousValue { - isEnabled = previousValue - } - if !succeeded { - showsMutationError = true - retryValue = attempt.requested - } - mutationTimeoutTask?.cancel() - mutationTimeoutTask = nil - currentAttempt = nil - self.previousValue = nil - isUpdating = false - } - - private func cancelCurrentOperation() { - if let attempt = currentAttempt { - timeoutMutation(attempt, generation: mutationGeneration) - } - mutationTimeoutTask?.cancel() - mutationTimeoutTask = nil - // Queued operations remain in the sequencer. Their callbacks are - // generation-checked, so a disappearing view cannot race a later read - // or write into visible state. - invalidateCurrentReconciliation() - } - - private func enqueueReconciliation(for requested: Bool, generation: Int) { - let reconciliation = MobilePushReconciliationAttempt( - requested: requested, - generation: generation - ) - currentReconciliation = reconciliation - let task = mutationSequencer.enqueue( - { await self.onReconcile() }, - completion: { authoritative in - self.finishReconciliation( - reconciliation, - authoritative: authoritative - ) - } - ) - reconciliation.task = task - reconciliation.timeoutTask = Task { @MainActor in - do { - try await mutationClock.sleep(for: Self.mutationTimeout) - } catch { - return - } - guard !Task.isCancelled, - self.currentReconciliation === reconciliation else { return } - reconciliation.task?.cancel() - self.currentReconciliation = nil - reconciliation.timeoutTask = nil - self.showsMutationError = true - self.isUpdating = false - self.retryValue = self.queuedMutationValue ?? requested - self.queuedMutationValue = nil - } - } - - private func finishReconciliation( - _ reconciliation: MobilePushReconciliationAttempt, - authoritative: Bool? - ) { - guard currentReconciliation === reconciliation else { return } - reconciliation.timeoutTask?.cancel() - reconciliation.timeoutTask = nil - currentReconciliation = nil - guard reconciliation.generation == mutationGeneration else { return } - if let authoritative { - isEnabled = authoritative - if let queuedMutationValue { - self.queuedMutationValue = nil - previousValue = authoritative - beginMutation(queuedMutationValue) - return - } - showsMutationError = authoritative != reconciliation.requested - retryValue = authoritative == reconciliation.requested - ? nil - : reconciliation.requested - } else { - showsMutationError = true - retryValue = queuedMutationValue ?? reconciliation.requested - } - } - - private func invalidateCurrentReconciliation() { - currentReconciliation?.timeoutTask?.cancel() - currentReconciliation = nil - queuedMutationValue = nil - } - - private func reconcileIfNeeded() { - guard currentAttempt == nil, - currentReconciliation == nil, - let retryValue, - showsMutationError else { return } - enqueueReconciliation( - for: retryValue, - generation: mutationGeneration + Toggle( + L10n.string( + "mobile.notifications.phoneEnabled", + defaultValue: "Allow Push Alerts on This iPhone" + ), + isOn: $isEnabled ) + .accessibilityIdentifier("MobileSettingsNotifications") + .disabled(isUpdating) } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 24a8df97893..19ee81bd82e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -41,12 +41,6 @@ struct MobileSettingsView: View { @Environment(\.dismiss) private var dismiss @State private var showingShortcuts = false - /// Mirrors ``MobilePushCoordinator/isEnabled`` so the toggle's label/icon - /// update after the async enable/disable. The coordinator exposes - /// `isEnabled` as a non-observable `UserDefaults` read, so reading it - /// directly in `body` would not re-render when it flips. - @State private var notificationsEnabled = false - @State private var notificationsToggleUpdating = false #if DEBUG @State private var debugReplyScheduled: Bool? #endif @@ -62,6 +56,9 @@ struct MobileSettingsView: View { #endif var body: some View { + // Establish observation in this view as the binding getter is invoked + // by the child toggle rather than directly in this body. + let _ = pushCoordinator.isEnabled @Bindable var displaySettings = displaySettings return NavigationStack { Form { @@ -389,13 +386,11 @@ struct MobileSettingsView: View { macStatus: store?.phonePushMacStatus, macAccountMismatch: store?.connectionRequiresReauth == true ), - phoneEnabled: $notificationsEnabled, + phoneEnabled: phonePushEnabledBinding, macStatus: store?.phonePushMacStatus, supportsMacSettings: store?.supportsPhonePushSettings == true, supportsMacTest: store?.supportsPhonePushTest == true, canConnectMac: startPairingScanner != nil, - onPhoneEnabledChange: updatePhonePushEnabled, - onPhoneEnabledReconcile: reconcilePhonePushEnabled, onRepair: repairPhonePush, onMacMutation: updateMacPhonePush, onSendTest: sendPhonePushTest @@ -426,10 +421,8 @@ struct MobileSettingsView: View { } #else MobilePushToggle( - isEnabled: $notificationsEnabled, - isUpdating: $notificationsToggleUpdating, - onChange: updatePhonePushEnabled, - onReconcile: reconcilePhonePushEnabled + isEnabled: phonePushEnabledBinding, + isUpdating: false ) #endif } @@ -478,12 +471,8 @@ struct MobileSettingsView: View { } } .task { - notificationsEnabled = pushCoordinator.isEnabled await pushCoordinator.refreshReadiness() } - .onChange(of: pushCoordinator.isEnabled) { _, enabled in - notificationsEnabled = enabled - } .navigationTitle(L10n.string("mobile.workspaces.settings", defaultValue: "Settings")) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -616,21 +605,24 @@ struct MobileSettingsView: View { .notificationPreferenceChanged, count: enabled ? 1 : 0 ) - if enabled { - _ = await pushCoordinator.enable() - // A denied OS authorization still accepts the user's app-level - // intent. Keep the toggle on so readiness can surface the Settings - // recovery action instead of rolling the preference back. - return pushCoordinator.isEnabled - } - await pushCoordinator.disable() - return !pushCoordinator.isEnabled + pushCoordinator.setEnabledIntent(enabled) + // The coordinator owns the intent synchronously. Backend registration + // continues independently so a repair action cannot inherit a view's + // lifecycle or wait for network cleanup. + return pushCoordinator.isEnabled == enabled } - @MainActor - private func reconcilePhonePushEnabled() async -> Bool? { - await pushCoordinator.refreshReadiness() - return pushCoordinator.isEnabled + private var phonePushEnabledBinding: Binding { + Binding( + get: { pushCoordinator.isEnabled }, + set: { enabled in + diagnosticLog?.recordAppEvent( + .notificationPreferenceChanged, + count: enabled ? 1 : 0 + ) + pushCoordinator.setEnabledIntent(enabled) + } + ) } @MainActor diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 29e0b5149a2..b9d09109faa 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -525,6 +525,39 @@ private final class LifecyclePushURLProtocol: URLProtocol, await disabling.value } + @MainActor + @Test func settingsOptOutIsVisibleBeforeCoordinatorBackendCleanupCompletes() async { + let gate = LifecycleSetEnabledGate() + let registration = LifecyclePushRegistration( + enabled: true, + setEnabledGate: gate + ) + let suiteName = "push-coordinator-settings-optout-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + authorizationStatus: { .authorized }, + unregisterForRemoteNotifications: {} + ) + + coordinator.setEnabledIntent(false) + await gate.waitUntilStarted() + + #expect(!coordinator.isEnabled) + #expect(coordinator.registrationSnapshot == .disabled) + + await gate.release() + for _ in 0..<100 { + if await registration.snapshot == .disabled { break } + await Task.yield() + } + #expect(await registration.snapshot == .disabled) + #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) + } + @MainActor @Test func foregroundAndReachabilityRecoveryShareOneExhaustedRegistrationRetry() async { let gate = LifecycleSyncGate() diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index 9f7fc77aa25..a134785caa0 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -1548,40 +1548,6 @@ } } }, - "mobile.notifications.phoneMutationFailed": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Couldn't update Push Alerts. Check your connection and try again." - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プッシュ通知を更新できませんでした。接続を確認して、もう一度お試しください。" - } - } - } - }, - "mobile.notifications.phoneMutationRetry": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Try Again" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "再試行" - } - } - } - }, "mobile.common.ok": { "extractionState": "manual", "localizations": { diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index 1761533567e..95da3339a64 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -116,7 +116,7 @@ final class PushReadinessUITests: XCTestCase { } @MainActor - func testPhonePushToggleTurnsOffWhileMutationIsPending() { + func testPhonePushToggleTurnsOffImmediately() { let app = launchPreview( "healthy", extraEnvironment: ["CMUX_UITEST_PUSH_PHONE_MUTATION_DELAY": "1"] @@ -133,14 +133,16 @@ final class PushReadinessUITests: XCTestCase { phone, "0", timeout: 1, - message: "The toggle must reflect the requested opt-out before async cleanup finishes" + message: "The toggle must reflect the requested opt-out immediately" ) - waitForDisabled(phone) + XCTAssertEqual(phone.value as? String, "0") let completeMutation = app.buttons["MobilePushReadinessCompletePhoneMutation"] XCTAssertTrue(completeMutation.waitForExistence(timeout: 2)) completeMutation.tap() - waitForEnabled(phone) - XCTAssertEqual(phone.value as? String, "0") + let status = app.descendants(matching: .any)[ + "MobileSettingsPushReadinessStatus" + ] + waitForLabel(status, containing: "Blocked, Off on This iPhone") } @MainActor @@ -249,22 +251,6 @@ final class PushReadinessUITests: XCTestCase { ) } - @MainActor - private func waitForDisabled( - _ element: XCUIElement, - timeout: TimeInterval = 4 - ) { - let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate(format: "enabled == false"), - object: element - ) - XCTAssertEqual( - XCTWaiter.wait(for: [expectation], timeout: timeout), - .completed, - "Expected '\(element.identifier)' to become disabled" - ) - } - @MainActor private func tapSwitch(_ element: XCUIElement) { element.coordinate( From 5bc3c10d76a564b4303ae8ff299c667ca1ab4d87 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:36:49 -0700 Subject: [PATCH 015/117] fix(ios): preempt stale push registration work --- .../MobilePushCoordinator.swift | 162 +++++++++++++----- .../MobilePushCoordinatorLifecycleTests.swift | 32 ++++ 2 files changed, 154 insertions(+), 40 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index a39c0590c43..30b03503afa 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -113,11 +113,13 @@ public final class MobilePushCoordinator { @ObservationIgnored private var registrationSnapshotTask: Task? @ObservationIgnored private var registrationRecoveryTask: Task? + @ObservationIgnored private var registrationRecoveryToken: UUID? /// Settings owns the user intent, while the registration service owns the - /// network side effect. Keeping the drain task here means a settings view - /// can disappear without cancelling an opt-out that is already visible. - @ObservationIgnored private var pendingSettingsIntent: Bool? + /// network side effect. This task is app-lifetime state, not view-lifetime + /// state, and a newer intent cancels the coordinator work without waiting + /// for the old task to unwind. @ObservationIgnored private var settingsMutationTask: Task? + @ObservationIgnored private var settingsMutationToken = UUID() @ObservationIgnored private var workspaceAuthorizationRequestInFlight = false @ObservationIgnored private var hasRequestedRemoteRegistration = false @@ -201,22 +203,30 @@ public final class MobilePushCoordinator { public var isEnabled: Bool { enabledMirror } /// Apply a Settings preference immediately and finish its registration work - /// from the app-lifetime coordinator. Repeated taps are coalesced to the - /// latest intent and are serialized with the registration actor, so a view - /// lifecycle cannot strand or reorder a mutation. + /// from the app-lifetime coordinator. A newer intent cancels the old + /// coordinator task and starts independently, so an opt-out can preempt an + /// authorization prompt or other suspended enable path. public func setEnabledIntent(_ enabled: Bool) { - guard enabled != enabledMirror || pendingSettingsIntent != nil else { - return - } + guard enabled != enabledMirror else { return } + cancelSettingsMutation() + let token = UUID() + settingsMutationToken = token if enabled { persistEnabledIntent() } else { prepareDisable() } - pendingSettingsIntent = enabled - guard settingsMutationTask == nil else { return } settingsMutationTask = Task { @MainActor [weak self] in - await self?.drainSettingsMutations() + guard let self else { return } + if enabled { + _ = await self.enable( + trigger: "settings_toggle", + settingsMutationToken: token + ) + } else { + await self.finishDisable(settingsMutationToken: token) + } + self.finishSettingsMutation(token) } } @@ -283,7 +293,7 @@ public final class MobilePushCoordinator { /// and persist the flag. Returns whether authorization was granted. @discardableResult public func enable() async -> Bool { - await enable(trigger: "settings_toggle") + await enable(trigger: "settings_toggle", settingsMutationToken: nil) } /// Requests or recovers push only after the authenticated workspace shell @@ -313,8 +323,19 @@ public final class MobilePushCoordinator { } } - private func enable(trigger: String) async -> Bool { + private func enable( + trigger: String, + settingsMutationToken: UUID? = nil + ) async -> Bool { + guard isCurrentSettingsMutation(settingsMutationToken), + settingsMutationToken == nil || enabledMirror else { + return false + } let priorSettings = await notificationSettings() + guard isCurrentSettingsMutation(settingsMutationToken), + settingsMutationToken == nil || enabledMirror else { + return false + } apply(settings: priorSettings) let priorStatus = priorSettings.authorization persistEnabledIntent() @@ -337,6 +358,10 @@ public final class MobilePushCoordinator { case .denied, .unsupported: granted = false } + guard isCurrentSettingsMutation(settingsMutationToken), + settingsMutationToken == nil || enabledMirror else { + return false + } guard granted else { await refreshReadiness() diagnosticLog?.recordAppEvent(.pushAuthorizationDenied) @@ -348,54 +373,72 @@ public final class MobilePushCoordinator { } if priorStatus == .notDetermined { apply(settings: await notificationSettings()) + guard isCurrentSettingsMutation(settingsMutationToken), + settingsMutationToken == nil || enabledMirror else { + return false + } } diagnosticLog?.recordAppEvent(.pushAuthorizationGranted) analytics.capture("ios_push_optin_granted", ["trigger": .string(trigger)]) - await activateRegistrationIfNeeded() - await recoverRegistrationIfNeeded() - return true + await activateRegistrationIfNeeded(settingsMutationToken: settingsMutationToken) + guard isCurrentSettingsMutation(settingsMutationToken), + settingsMutationToken == nil || enabledMirror else { + return false + } + await recoverRegistrationIfNeeded(settingsMutationToken: settingsMutationToken) + return isCurrentSettingsMutation(settingsMutationToken) } /// Opt out: stop receiving pushes and remove the token server-side. public func disable() async { + cancelSettingsMutation() prepareDisable() await finishDisable() } + private func cancelSettingsMutation() { + settingsMutationTask?.cancel() + settingsMutationTask = nil + settingsMutationToken = UUID() + } + + private func finishSettingsMutation(_ token: UUID) { + guard settingsMutationToken == token else { return } + settingsMutationTask = nil + } + private func prepareDisable() { diagnosticLog?.recordAppEvent(.pushDisabled) enabledMirror = false registrationSnapshot = .disabled hasRequestedRemoteRegistration = false + registrationRecoveryTask?.cancel() + registrationRecoveryTask = nil + registrationRecoveryToken = nil unregisterForRemoteNotifications() } - private func finishDisable() async { + private func finishDisable(settingsMutationToken: UUID? = nil) async { // The production registration service owns this same persisted key // and checks its previous value to decide whether server cleanup is // required. Let it observe the prior `true` before mirroring the final // preference here; writing `false` first would skip token removal. await registration.setEnabled(false) - guard !enabledMirror else { return } + guard isCurrentSettingsMutation(settingsMutationToken), !enabledMirror else { + return + } defaults.set(false, forKey: Self.enabledKey) - registrationSnapshot = await registration.snapshot + let snapshot = await registration.snapshot + guard isCurrentSettingsMutation(settingsMutationToken), !enabledMirror else { + return + } + registrationSnapshot = snapshot } - private func drainSettingsMutations() async { - while let requested = pendingSettingsIntent { - pendingSettingsIntent = nil - if requested { - // A newer opt-out can arrive while authorization or token - // recovery is suspended. The next loop iteration will perform - // the corresponding cleanup, and the coordinator's mirror - // remains authoritative throughout. - guard enabledMirror else { continue } - _ = await enable(trigger: "settings_toggle") - } else { - await finishDisable() - } - } - settingsMutationTask = nil + private func isCurrentSettingsMutation(_ token: UUID?) -> Bool { + guard !Task.isCancelled else { return false } + guard let token else { return true } + return settingsMutationToken == token } /// Hand a freshly-registered APNs token to the network layer. @@ -456,9 +499,17 @@ public final class MobilePushCoordinator { authorization = settings.authorization } - private func activateRegistrationIfNeeded() async { - guard enabledMirror, Self.permitsDelivery(authorization) else { return } + private func activateRegistrationIfNeeded( + settingsMutationToken: UUID? = nil + ) async { + guard isCurrentSettingsMutation(settingsMutationToken), + enabledMirror, + Self.permitsDelivery(authorization) + else { return } let current = await registration.snapshot + guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { + return + } registrationSnapshot = PushRegistrationSnapshot( isEnabled: true, hasDeviceToken: current.hasDeviceToken, @@ -469,8 +520,15 @@ public final class MobilePushCoordinator { requestRemoteRegistrationIfNeeded() if !current.isEnabled { await registration.setEnabled(true) + guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { + return + } } - registrationSnapshot = await registration.snapshot + let snapshot = await registration.snapshot + guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { + return + } + registrationSnapshot = snapshot } private func requestRemoteRegistrationIfNeeded() { @@ -497,12 +555,24 @@ public final class MobilePushCoordinator { await recoverRegistrationIfNeeded() } - private func recoverRegistrationIfNeeded() async { + private func recoverRegistrationIfNeeded( + settingsMutationToken: UUID? = nil + ) async { + guard isCurrentSettingsMutation(settingsMutationToken) else { + return + } guard enabledMirror else { registrationSnapshot = .disabled return } let current = await registration.snapshot + guard isCurrentSettingsMutation(settingsMutationToken) else { + return + } + guard enabledMirror else { + registrationSnapshot = .disabled + return + } registrationSnapshot = current guard current.isEnabled, current.hasDeviceToken, current.backendState == .registrationRequired @@ -511,21 +581,33 @@ public final class MobilePushCoordinator { let recovery: Task let ownsRecovery: Bool + let recoveryToken: UUID? if let registrationRecoveryTask { recovery = registrationRecoveryTask ownsRecovery = false + recoveryToken = nil } else { let registration = self.registration + let token = UUID() recovery = Task { await registration.syncTokenIfPossible() return await registration.snapshot } registrationRecoveryTask = recovery + registrationRecoveryToken = token ownsRecovery = true + recoveryToken = token } let recovered = await recovery.value - if ownsRecovery { + guard isCurrentSettingsMutation(settingsMutationToken) else { + return + } + guard enabledMirror else { + return + } + if ownsRecovery, registrationRecoveryToken == recoveryToken { registrationRecoveryTask = nil + registrationRecoveryToken = nil } registrationSnapshot = recovered recordRegistrationOutcome(recovered) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index b9d09109faa..3754b68d139 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -558,6 +558,38 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) } + @MainActor + @Test func settingsOptOutPreemptsAnInFlightEnable() async { + let gate = LifecycleSetEnabledGate() + let registration = LifecyclePushRegistration( + enabled: false, + setEnabledGate: gate + ) + let suiteName = "push-coordinator-settings-preempt-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + authorizationStatus: { .authorized }, + unregisterForRemoteNotifications: {} + ) + + coordinator.setEnabledIntent(true) + await gate.waitUntilStarted() + + coordinator.setEnabledIntent(false) + #expect(!coordinator.isEnabled) + + await gate.release() + for _ in 0..<100 { + if await registration.snapshot == .disabled { break } + await Task.yield() + } + #expect(await registration.snapshot == .disabled) + #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) + } + @MainActor @Test func foregroundAndReachabilityRecoveryShareOneExhaustedRegistrationRetry() async { let gate = LifecycleSyncGate() From 09868c8c42a4081ff75c2539d727edbd698af095 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:46:53 -0700 Subject: [PATCH 016/117] test(push): require serialized opt-out mutation --- .../PushRegistrationServiceTests.swift | 27 ++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index acbaa31061f..9497878bf39 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -831,7 +831,7 @@ actor RetryDelayRecorder { await upload.value let requests = await PushRegistrationURLProtocol.script.requests - #expect(requests.map(\.httpMethod) == ["POST", "DELETE", "DELETE"]) + #expect(requests.map(\.httpMethod) == ["POST", "DELETE"]) #expect( requests.map { $0.value(forHTTPHeaderField: "Authorization") @@ -848,6 +848,31 @@ actor RetryDelayRecorder { ) } + @Test func disablingDuringInFlightEnableSerializesBackendMutation() async { + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + await PushRegistrationURLProtocol.script.reset([ + .gatedResponse(200, started: started, blocker: blocker), + .response(200), + ]) + let (service, defaults) = makeScriptedService(accountID: "account-a") + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + + let enable = Task { await service.setEnabled(true) } + await started.waitUntilStarted() + let disable = Task { await service.setEnabled(false) } + + await blocker.release() + await enable.value + await disable.value + + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["POST", "DELETE"] + ) + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) + } + @Test func signOutDuringInFlightRegistrationDeletesAfterLatePost() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() From 4a5301de455a8e4b7774a62e233394f620227f88 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:05:18 -0700 Subject: [PATCH 017/117] test(push): cover ordered opt-out intents --- .../PushRegistrationServiceTests.swift | 25 +++++++++++++------ .../MobilePushCoordinatorLifecycleTests.swift | 1 + 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 9497878bf39..46fd64abda0 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -826,12 +826,15 @@ actor RetryDelayRecorder { await service.register(deviceToken: Data([0xAA])) } await started.waitUntilStarted() - await service.setEnabled(false) + let disable = Task { + await service.setEnabled(false) + } await blocker.release() await upload.value + await disable.value let requests = await PushRegistrationURLProtocol.script.requests - #expect(requests.map(\.httpMethod) == ["POST", "DELETE"]) + #expect(requests.map(\.httpMethod) == ["POST", "DELETE", "DELETE"]) #expect( requests.map { $0.value(forHTTPHeaderField: "Authorization") @@ -901,13 +904,16 @@ actor RetryDelayRecorder { } await started.waitUntilStarted() await provider.clearSession() - await service.unregisterFromServer( - accountID: "account-a", - accessToken: "a-captured-access", - refreshToken: "a-captured-refresh" - ) + let unregister = Task { + await service.unregisterFromServer( + accountID: "account-a", + accessToken: "a-captured-access", + refreshToken: "a-captured-refresh" + ) + } await blocker.release() await upload.value + await unregister.value let requests = await PushRegistrationURLProtocol.script.requests #expect(requests.map(\.httpMethod) == ["POST", "DELETE", "DELETE"]) @@ -956,9 +962,12 @@ actor RetryDelayRecorder { accessToken: "b-access", refreshToken: "b-refresh" ) - await service.syncTokenIfPossible() + let newUpload = Task { + await service.syncTokenIfPossible() + } await blocker.release() await oldUpload.value + await newUpload.value let requests = await PushRegistrationURLProtocol.script.requests #expect( diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 3754b68d139..089c59434e0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -545,6 +545,7 @@ private final class LifecyclePushURLProtocol: URLProtocol, coordinator.setEnabledIntent(false) await gate.waitUntilStarted() + await coordinator.workspaceListDidBecomeVisible() #expect(!coordinator.isEnabled) #expect(coordinator.registrationSnapshot == .disabled) From 90a97c19211e260843525dbe0090e375eeb96fa5 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:05:23 -0700 Subject: [PATCH 018/117] fix(push): serialize notification intent mutations --- .../Push/PushRegistrationService.swift | 111 ++++++++++++++++-- .../MobilePushCoordinator.swift | 16 ++- 2 files changed, 116 insertions(+), 11 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index ebcea86fa14..fae7b7dd83e 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -25,6 +25,15 @@ public actor PushRegistrationService: PushRegistering { private let retryDelays: [Duration] private let retryJitter: @Sendable (ClosedRange) -> Double private let retrySleep: @Sendable (Duration) async throws -> Void + /// Settings/sign-out intents are ordered before any network suspension, so + /// a later toggle cannot enqueue cleanup behind an earlier enable after a + /// delayed auth lookup. + private let intentGate = PushRegistrationMutationGate() + /// The actor itself is re-entrant across URLSession suspension points. + /// Serialize actual POST/DELETE requests so a late response cannot race a + /// newer request. Higher-level reconciliation remains concurrent so account + /// changes can still observe and repair stale acknowledgements. + private let networkMutationGate = PushRegistrationMutationGate() private var retryTask: Task? private var unregisterDrainTask: Task? private var operationGeneration = UUID() @@ -114,15 +123,21 @@ public actor PushRegistrationService: PushRegistering { } public func setEnabled(_ enabled: Bool) async { + await intentGate.withLock { [self] in + await self.setEnabledUnlocked(enabled) + } + } + + private func setEnabledUnlocked(_ enabled: Bool) async { let wasEnabled = isEnabled cancelRetry() defaults.set(enabled, forKey: Self.enabledKey) if enabled { - await syncTokenIfPossible() + await syncTokenIfPossibleUnlocked() } else { publish(.disabled) if wasEnabled { - await unregisterFromServer() + await unregisterFromServerUnlocked() } else { await retryPendingUnregisterIfPossible() } @@ -130,6 +145,10 @@ public actor PushRegistrationService: PushRegistering { } public func register(deviceToken: Data) async { + await registerUnlocked(deviceToken: deviceToken) + } + + private func registerUnlocked(deviceToken: Data) async { let hex = deviceToken.map { String(format: "%02x", $0) }.joined() let previousToken = cachedTokenHex if let previousToken, @@ -160,6 +179,10 @@ public actor PushRegistrationService: PushRegistering { } public func syncTokenIfPossible() async { + await syncTokenIfPossibleUnlocked() + } + + private func syncTokenIfPossibleUnlocked() async { guard isEnabled else { await retryPendingUnregisterIfPossible() publish(.disabled) @@ -186,6 +209,12 @@ public actor PushRegistrationService: PushRegistering { } public func unregisterFromServer() async { + await intentGate.withLock { [self] in + await self.unregisterFromServerUnlocked() + } + } + + private func unregisterFromServerUnlocked() async { cancelRetry() guard let hex = cachedTokenHex else { return } let session = try? await tokenProvider.authenticatedSessionSnapshot() @@ -213,11 +242,13 @@ public actor PushRegistrationService: PushRegistering { /// - accessToken: The captured (or teardown-minted) access token. /// - refreshToken: The captured refresh token. public func unregisterFromServer(accessToken: String?, refreshToken: String?) async { - await unregisterFromServer( - accountID: nil, - accessToken: accessToken, - refreshToken: refreshToken - ) + await intentGate.withLock { [self] in + await self.unregisterFromServerUnlocked( + accountID: nil, + accessToken: accessToken, + refreshToken: refreshToken + ) + } } /// Sign-out variant with the account id captured before local auth clear. @@ -225,6 +256,20 @@ public actor PushRegistrationService: PushRegistering { accountID capturedAccountID: String?, accessToken: String?, refreshToken: String? + ) async { + await intentGate.withLock { [self] in + await self.unregisterFromServerUnlocked( + accountID: capturedAccountID, + accessToken: accessToken, + refreshToken: refreshToken + ) + } + } + + private func unregisterFromServerUnlocked( + accountID capturedAccountID: String?, + accessToken: String?, + refreshToken: String? ) async { cancelRetry() guard let hex = cachedTokenHex else { return } @@ -553,6 +598,12 @@ public actor PushRegistrationService: PushRegistering { } private func performRegistration(_ request: URLRequest) async -> RegistrationResult { + await networkMutationGate.withLock { [self] in + await self.performRegistrationRequest(request) + } + } + + private func performRegistrationRequest(_ request: URLRequest) async -> RegistrationResult { let redirectDelegate = RedirectMethodPreservingDelegate() do { let (data, response) = try await session.data( @@ -585,6 +636,12 @@ public actor PushRegistrationService: PushRegistering { } private func performDelete(_ request: URLRequest) async -> Bool { + await networkMutationGate.withLock { [self] in + await self.performDeleteRequest(request) + } + } + + private func performDeleteRequest(_ request: URLRequest) async -> Bool { let redirectDelegate = RedirectMethodPreservingDelegate() do { let (data, response) = try await session.data( @@ -898,6 +955,46 @@ private struct PushRequest { let session: AuthenticatedSessionSnapshot? } +/// Serializes registration mutations across the service actor's suspension +/// points. An actor alone does not provide this guarantee because it can +/// re-enter while URLSession is awaiting a response. The operation runs in an +/// independent worker so cancelling a UI waiter cannot abandon a request after +/// the server may have committed it. +private actor PushRegistrationMutationGate { + private var isHeld = false + private var waiters: [CheckedContinuation] = [] + + func withLock( + _ operation: @escaping @Sendable () async -> Value + ) async -> Value { + await acquire() + let worker = Task { + await operation() + } + let value = await worker.value + release() + return value + } + + private func acquire() async { + guard isHeld else { + isHeld = true + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + private func release() { + guard !waiters.isEmpty else { + isHeld = false + return + } + waiters.removeFirst().resume() + } +} + private struct RegistrationAcknowledgement: Decodable { let ok: Bool let pushServiceConfigured: Bool? diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 30b03503afa..ec58fd63411 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -299,6 +299,10 @@ public final class MobilePushCoordinator { /// Requests or recovers push only after the authenticated workspace shell /// is mounted. An explicit app opt-out remains authoritative. public func workspaceListDidBecomeVisible() async { + // A Settings intent is the freshest user decision. Do not let the + // workspace lifecycle reconcile an older persisted value while its + // backend mutation is still draining. + guard settingsMutationTask == nil else { return } if defaults.object(forKey: Self.enabledKey) as? Bool == false { return } @@ -599,16 +603,20 @@ public final class MobilePushCoordinator { recoveryToken = token } let recovered = await recovery.value + // Clear an owned task before checking the caller's generation or + // cancellation. The recovery worker is independent of the caller's + // waiter, so a cancelled waiter still must release the cached worker + // for the next recovery attempt. + if ownsRecovery, registrationRecoveryToken == recoveryToken { + registrationRecoveryTask = nil + registrationRecoveryToken = nil + } guard isCurrentSettingsMutation(settingsMutationToken) else { return } guard enabledMirror else { return } - if ownsRecovery, registrationRecoveryToken == recoveryToken { - registrationRecoveryTask = nil - registrationRecoveryToken = nil - } registrationSnapshot = recovered recordRegistrationOutcome(recovered) } From 8bde795686020602a1d09ed257be9820aa4b6c6c Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:32:09 -0700 Subject: [PATCH 019/117] test(push): cover latest intent ordering --- .../PushRegistrationServiceTests.swift | 34 +++++++++++ .../MobilePushCoordinatorLifecycleTests.swift | 60 +++++++++++++++++++ .../cmuxFeatureTests/cmuxFeatureTests.swift | 2 + 3 files changed, 96 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 46fd64abda0..6795f605785 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -876,6 +876,40 @@ actor RetryDelayRecorder { #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) } + @Test func supersededQueuedOptOutCannotUndoAReenable() async { + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + await PushRegistrationURLProtocol.script.reset([ + .gatedResponse(200, started: started, blocker: blocker), + .response(200), + ]) + let (service, defaults) = makeScriptedService(accountID: "account-a") + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + + let firstEnable = Task { + await service.applyEnabledIntent(true, generation: 1) + } + await started.waitUntilStarted() + let optOut = Task { + await service.applyEnabledIntent(false, generation: 2) + } + let reenable = Task { + await service.applyEnabledIntent(true, generation: 3) + } + + await blocker.release() + await firstEnable.value + await optOut.value + await reenable.value + + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) + #expect(await service.snapshot.backendState == .registered) + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["POST", "POST"] + ) + } + @Test func signOutDuringInFlightRegistrationDeletesAfterLatePost() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 089c59434e0..f2379d494aa 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -7,6 +7,7 @@ import UserNotifications private actor LifecyclePushRegistration: PushRegistering { private var value: PushRegistrationSnapshot + private var latestIntentGeneration: UInt64 = 0 private let setEnabledGate: LifecycleSetEnabledGate? private let syncGate: LifecycleSyncGate? @@ -50,6 +51,31 @@ private actor LifecyclePushRegistration: PushRegistering { : .disabled } + func disableAndUnregister() async { + await setEnabledGate?.pause() + value = .disabled + } + + func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async { + guard generation >= latestIntentGeneration else { return } + latestIntentGeneration = generation + if enabled { + await setEnabledGate?.pause() + guard generation == latestIntentGeneration else { return } + value = PushRegistrationSnapshot( + isEnabled: true, + hasDeviceToken: value.hasDeviceToken, + backendState: value.hasDeviceToken + ? .registrationRequired + : .awaitingDeviceToken + ) + } else { + await setEnabledGate?.pause() + guard generation == latestIntentGeneration else { return } + value = .disabled + } + } + func register(deviceToken: Data) { value = PushRegistrationSnapshot( isEnabled: true, @@ -294,6 +320,40 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(await enabling.value) } + @MainActor + @Test func optOutInvalidatesAnEnableSuspendedInNotificationSettings() async { + let settingsGate = LifecycleSyncGate() + let registration = LifecyclePushRegistration(enabled: false) + let suiteName = "push-coordinator-stale-enable-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + notificationSettings: { + await settingsGate.pause() + return .authorizationOnly(.authorized) + }, + registerForRemoteNotifications: {} + ) + + let enabling = Task { await coordinator.enable() } + await settingsGate.waitUntilStarted() + + coordinator.setEnabledIntent(false) + #expect(!coordinator.isEnabled) + #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) + + await settingsGate.release() + #expect(!(await enabling.value)) + for _ in 0..<100 { + if await registration.snapshot == .disabled { break } + await Task.yield() + } + #expect(await registration.snapshot == .disabled) + #expect(!coordinator.isEnabled) + } + @MainActor @Test func authorizedEnableRecoversWithoutRequestingAuthorizationAgain() async { let registration = LifecyclePushRegistration(enabled: false) diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index b5e76adf17e..6e1428db61f 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -4116,6 +4116,8 @@ struct InertPushRegistration: PushRegistering { } } func setEnabled(_ enabled: Bool) async {} + func disableAndUnregister() async {} + func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async {} func register(deviceToken: Data) async {} func deviceTokenRegistrationFailed() async {} func syncTokenIfPossible() async {} From 63007514af7c48f6a7a0207851fbdd79db2607f6 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:32:24 -0700 Subject: [PATCH 020/117] fix(push): make latest notification intent authoritative --- .../Push/PushRegistering.swift | 16 ++ .../Push/PushRegistrationMutationGate.swift | 39 +++++ .../Push/PushRegistrationService.swift | 104 +++++++----- .../MobilePushCoordinator.swift | 152 +++++++++++++----- 4 files changed, 231 insertions(+), 80 deletions(-) create mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift index 96281023279..1f48aa80566 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift @@ -21,6 +21,22 @@ public protocol PushRegistering: Sendable { /// removing it server-side on disable. func setEnabled(_ enabled: Bool) async + /// Completes an opt-out after the coordinator has already persisted the + /// user's false intent. The cleanup must not infer whether a server token + /// exists from that now-false preference. + func disableAndUnregister() async + + /// Applies a coordinator-owned intent in generation order. + /// + /// - Parameters: + /// - enabled: The latest user intent. + /// - generation: A monotonically increasing coordinator generation. + /// + /// Older queued intents must not run after a newer one. Every conformer + /// implements this contract so the coordinator does not depend on a + /// particular registration-service implementation for stale-work safety. + func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async + /// Cache and (when opted in) upload a freshly registered APNs device token. func register(deviceToken: Data) async diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift new file mode 100644 index 00000000000..348ca32209b --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift @@ -0,0 +1,39 @@ +import Foundation + +/// Serializes registration mutations across an actor's suspension points. +/// An actor alone can re-enter while URLSession is awaiting a response. The +/// operation runs in an independent worker so cancelling a UI waiter cannot +/// abandon a request after the server may have committed it. +actor PushRegistrationMutationGate { + private var isHeld = false + private var waiters: [CheckedContinuation] = [] + + func withLock( + _ operation: @escaping @Sendable () async -> Value + ) async -> Value { + await acquire() + let worker = Task { + await operation() + } + defer { release() } + return await worker.value + } + + private func acquire() async { + guard isHeld else { + isHeld = true + return + } + await withCheckedContinuation { continuation in + waiters.append(continuation) + } + } + + private func release() { + guard !waiters.isEmpty else { + isHeld = false + return + } + waiters.removeFirst().resume() + } +} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index fae7b7dd83e..163c9e3d48e 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -37,6 +37,8 @@ public actor PushRegistrationService: PushRegistering { private var retryTask: Task? private var unregisterDrainTask: Task? private var operationGeneration = UUID() + private var enabledIntentGeneration = UUID() + private var coordinatorIntentGeneration: UInt64 = 0 private var snapshotValue: PushRegistrationSnapshot private var snapshotContinuations: [UUID: AsyncStream.Continuation] = [:] @@ -123,11 +125,68 @@ public actor PushRegistrationService: PushRegistering { } public func setEnabled(_ enabled: Bool) async { + let intentGeneration = UUID() + enabledIntentGeneration = intentGeneration await intentGate.withLock { [self] in + guard await self.isCurrentEnabledIntent(intentGeneration) else { + return + } await self.setEnabledUnlocked(enabled) } } + public func disableAndUnregister() async { + let intentGeneration = UUID() + enabledIntentGeneration = intentGeneration + await intentGate.withLock { [self] in + guard await self.isCurrentEnabledIntent(intentGeneration) else { + return + } + await self.disableAndUnregisterUnlocked() + } + } + + public func applyEnabledIntent( + _ enabled: Bool, + generation: UInt64 + ) async { + coordinatorIntentGeneration = max( + coordinatorIntentGeneration, + generation + ) + await intentGate.withLock { [self] in + guard await self.isCurrentCoordinatorIntent(generation) else { + return + } + await self.applyEnabledIntentUnlocked(enabled) + } + } + + private func applyEnabledIntentUnlocked(_ enabled: Bool) async { + if enabled { + defaults.set(true, forKey: Self.enabledKey) + await syncTokenIfPossibleUnlocked() + } else { + await disableAndUnregisterUnlocked() + } + } + + private func disableAndUnregisterUnlocked() async { + cancelRetry() + publish(.disabled) + await unregisterFromServerUnlocked(requireKnownOwner: false) + defaults.set(false, forKey: Self.enabledKey) + publish(.disabled) + } + + private func isCurrentEnabledIntent(_ generation: UUID) -> Bool { + enabledIntentGeneration == generation + } + + private func isCurrentCoordinatorIntent(_ generation: UInt64) -> Bool { + coordinatorIntentGeneration == generation + } + private func setEnabledUnlocked(_ enabled: Bool) async { let wasEnabled = isEnabled cancelRetry() @@ -214,13 +273,17 @@ public actor PushRegistrationService: PushRegistering { } } - private func unregisterFromServerUnlocked() async { + private func unregisterFromServerUnlocked( + requireKnownOwner: Bool = false + ) async { cancelRetry() guard let hex = cachedTokenHex else { return } let session = try? await tokenProvider.authenticatedSessionSnapshot() - let ownerID = defaults.string( + let registeredOwnerID = defaults.string( forKey: Self.registeredAccountIDKey - ) ?? session?.accountID + ) + let ownerID = registeredOwnerID + ?? (requireKnownOwner ? nil : session?.accountID) guard let ownerID, !ownerID.isEmpty else { return } // Persist before requiring live auth. This is the privacy guarantee for // an offline or signed-out opt-out. @@ -960,41 +1023,6 @@ private struct PushRequest { /// re-enter while URLSession is awaiting a response. The operation runs in an /// independent worker so cancelling a UI waiter cannot abandon a request after /// the server may have committed it. -private actor PushRegistrationMutationGate { - private var isHeld = false - private var waiters: [CheckedContinuation] = [] - - func withLock( - _ operation: @escaping @Sendable () async -> Value - ) async -> Value { - await acquire() - let worker = Task { - await operation() - } - let value = await worker.value - release() - return value - } - - private func acquire() async { - guard isHeld else { - isHeld = true - return - } - await withCheckedContinuation { continuation in - waiters.append(continuation) - } - } - - private func release() { - guard !waiters.isEmpty else { - isHeld = false - return - } - waiters.removeFirst().resume() - } -} - private struct RegistrationAcknowledgement: Decodable { let ok: Bool let pushServiceConfigured: Bool? diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index ec58fd63411..49255f77db7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -120,6 +120,7 @@ public final class MobilePushCoordinator { /// for the old task to unwind. @ObservationIgnored private var settingsMutationTask: Task? @ObservationIgnored private var settingsMutationToken = UUID() + @ObservationIgnored private var registrationIntentGeneration: UInt64 = 0 @ObservationIgnored private var workspaceAuthorizationRequestInFlight = false @ObservationIgnored private var hasRequestedRemoteRegistration = false @@ -208,28 +209,50 @@ public final class MobilePushCoordinator { /// authorization prompt or other suspended enable path. public func setEnabledIntent(_ enabled: Bool) { guard enabled != enabledMirror else { return } - cancelSettingsMutation() - let token = UUID() - settingsMutationToken = token - if enabled { - persistEnabledIntent() - } else { - prepareDisable() - } + let intent = beginSettingsIntent(enabled) settingsMutationTask = Task { @MainActor [weak self] in guard let self else { return } if enabled { _ = await self.enable( trigger: "settings_toggle", - settingsMutationToken: token + settingsMutationToken: intent.token, + registrationGeneration: intent.registrationGeneration ) } else { - await self.finishDisable(settingsMutationToken: token) + await self.finishDisable( + settingsMutationToken: intent.token, + registrationGeneration: intent.registrationGeneration + ) } - self.finishSettingsMutation(token) + self.finishSettingsMutation(intent.token) } } + /// Starts a new app-lifetime preference intent and invalidates every older + /// lifecycle reconciliation. The returned generation must be checked after + /// each suspension before an operation publishes or persists state. + @discardableResult + private func beginSettingsIntent(_ enabled: Bool) -> SettingsIntent { + cancelSettingsMutation() + let token = UUID() + registrationIntentGeneration &+= 1 + settingsMutationToken = token + if enabled { + persistEnabledIntent() + } else { + prepareDisable() + } + return SettingsIntent( + token: token, + registrationGeneration: registrationIntentGeneration + ) + } + + private struct SettingsIntent { + let token: UUID + let registrationGeneration: UInt64 + } + /// Point routing at the active store (called by the root view on appear). public func bind(store: CMUXMobileShellStore) { self.store = store @@ -293,7 +316,12 @@ public final class MobilePushCoordinator { /// and persist the flag. Returns whether authorization was granted. @discardableResult public func enable() async -> Bool { - await enable(trigger: "settings_toggle", settingsMutationToken: nil) + let intent = beginSettingsIntent(true) + return await enable( + trigger: "settings_toggle", + settingsMutationToken: intent.token, + registrationGeneration: intent.registrationGeneration + ) } /// Requests or recovers push only after the authenticated workspace shell @@ -303,25 +331,37 @@ public final class MobilePushCoordinator { // workspace lifecycle reconcile an older persisted value while its // backend mutation is still draining. guard settingsMutationTask == nil else { return } + let intentToken = settingsMutationToken + let intentGeneration = registrationIntentGeneration if defaults.object(forKey: Self.enabledKey) as? Bool == false { return } let settings = await notificationSettings() + guard isCurrentSettingsMutation(intentToken) else { return } apply(settings: settings) switch settings.authorization { case .authorized, .provisional, .ephemeral: + guard isCurrentSettingsMutation(intentToken) else { return } persistEnabledIntent() - await activateRegistrationIfNeeded() - await recoverRegistrationIfNeeded() + await activateRegistrationIfNeeded( + settingsMutationToken: intentToken, + registrationGeneration: intentGeneration + ) + await recoverRegistrationIfNeeded(settingsMutationToken: intentToken) case .denied: // Preserve intent so Settings can explain the blocked OS gate and // a later foreground return can recover without another app launch. + guard isCurrentSettingsMutation(intentToken) else { return } persistEnabledIntent() case .notDetermined: guard !workspaceAuthorizationRequestInFlight else { return } workspaceAuthorizationRequestInFlight = true defer { workspaceAuthorizationRequestInFlight = false } - _ = await enable(trigger: "workspace_list") + _ = await enable( + trigger: "workspace_list", + settingsMutationToken: intentToken, + registrationGeneration: intentGeneration + ) case .unsupported: break } @@ -329,15 +369,16 @@ public final class MobilePushCoordinator { private func enable( trigger: String, - settingsMutationToken: UUID? = nil + settingsMutationToken: UUID, + registrationGeneration: UInt64 ) async -> Bool { guard isCurrentSettingsMutation(settingsMutationToken), - settingsMutationToken == nil || enabledMirror else { + enabledMirror else { return false } let priorSettings = await notificationSettings() guard isCurrentSettingsMutation(settingsMutationToken), - settingsMutationToken == nil || enabledMirror else { + enabledMirror else { return false } apply(settings: priorSettings) @@ -363,11 +404,11 @@ public final class MobilePushCoordinator { granted = false } guard isCurrentSettingsMutation(settingsMutationToken), - settingsMutationToken == nil || enabledMirror else { + enabledMirror else { return false } guard granted else { - await refreshReadiness() + await refreshReadiness(settingsMutationToken: settingsMutationToken) diagnosticLog?.recordAppEvent(.pushAuthorizationDenied) analytics.capture("ios_push_optin_declined", [ "trigger": .string(trigger), @@ -376,17 +417,21 @@ public final class MobilePushCoordinator { return false } if priorStatus == .notDetermined { - apply(settings: await notificationSettings()) + let currentSettings = await notificationSettings() guard isCurrentSettingsMutation(settingsMutationToken), - settingsMutationToken == nil || enabledMirror else { + enabledMirror else { return false } + apply(settings: currentSettings) } diagnosticLog?.recordAppEvent(.pushAuthorizationGranted) analytics.capture("ios_push_optin_granted", ["trigger": .string(trigger)]) - await activateRegistrationIfNeeded(settingsMutationToken: settingsMutationToken) + await activateRegistrationIfNeeded( + settingsMutationToken: settingsMutationToken, + registrationGeneration: registrationGeneration + ) guard isCurrentSettingsMutation(settingsMutationToken), - settingsMutationToken == nil || enabledMirror else { + enabledMirror else { return false } await recoverRegistrationIfNeeded(settingsMutationToken: settingsMutationToken) @@ -395,9 +440,11 @@ public final class MobilePushCoordinator { /// Opt out: stop receiving pushes and remove the token server-side. public func disable() async { - cancelSettingsMutation() - prepareDisable() - await finishDisable() + let intent = beginSettingsIntent(false) + await finishDisable( + settingsMutationToken: intent.token, + registrationGeneration: intent.registrationGeneration + ) } private func cancelSettingsMutation() { @@ -414,6 +461,7 @@ public final class MobilePushCoordinator { private func prepareDisable() { diagnosticLog?.recordAppEvent(.pushDisabled) enabledMirror = false + defaults.set(false, forKey: Self.enabledKey) registrationSnapshot = .disabled hasRequestedRemoteRegistration = false registrationRecoveryTask?.cancel() @@ -422,16 +470,20 @@ public final class MobilePushCoordinator { unregisterForRemoteNotifications() } - private func finishDisable(settingsMutationToken: UUID? = nil) async { - // The production registration service owns this same persisted key - // and checks its previous value to decide whether server cleanup is - // required. Let it observe the prior `true` before mirroring the final - // preference here; writing `false` first would skip token removal. - await registration.setEnabled(false) + private func finishDisable( + settingsMutationToken: UUID, + registrationGeneration: UInt64 + ) async { + guard isCurrentSettingsMutation(settingsMutationToken), !enabledMirror else { + return + } + await registration.applyEnabledIntent( + false, + generation: registrationGeneration + ) guard isCurrentSettingsMutation(settingsMutationToken), !enabledMirror else { return } - defaults.set(false, forKey: Self.enabledKey) let snapshot = await registration.snapshot guard isCurrentSettingsMutation(settingsMutationToken), !enabledMirror else { return @@ -439,9 +491,8 @@ public final class MobilePushCoordinator { registrationSnapshot = snapshot } - private func isCurrentSettingsMutation(_ token: UUID?) -> Bool { + private func isCurrentSettingsMutation(_ token: UUID) -> Bool { guard !Task.isCancelled else { return false } - guard let token else { return true } return settingsMutationToken == token } @@ -485,12 +536,23 @@ public final class MobilePushCoordinator { /// Call on every foreground transition because users can revoke permission /// in iOS Settings while cmux is suspended. public func refreshReadiness() async { + await refreshReadiness(settingsMutationToken: settingsMutationToken) + } + + private func refreshReadiness(settingsMutationToken: UUID) async { + let registrationGeneration = self.registrationIntentGeneration let settings = await notificationSettings() + guard isCurrentSettingsMutation(settingsMutationToken) else { return } apply(settings: settings) if enabledMirror, Self.permitsDelivery(settings.authorization) { - await activateRegistrationIfNeeded() + await activateRegistrationIfNeeded( + settingsMutationToken: settingsMutationToken, + registrationGeneration: registrationGeneration + ) } - await recoverRegistrationIfNeeded() + await recoverRegistrationIfNeeded( + settingsMutationToken: settingsMutationToken + ) } private func persistEnabledIntent() { @@ -504,7 +566,8 @@ public final class MobilePushCoordinator { } private func activateRegistrationIfNeeded( - settingsMutationToken: UUID? = nil + settingsMutationToken: UUID, + registrationGeneration: UInt64 ) async { guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror, @@ -523,7 +586,10 @@ public final class MobilePushCoordinator { ) requestRemoteRegistrationIfNeeded() if !current.isEnabled { - await registration.setEnabled(true) + await registration.applyEnabledIntent( + true, + generation: registrationGeneration + ) guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { return } @@ -556,11 +622,13 @@ public final class MobilePushCoordinator { /// Retries an exhausted registration when a meaningful network path /// change reports that the API may be reachable again. public func networkDidBecomeReachable() async { - await recoverRegistrationIfNeeded() + await recoverRegistrationIfNeeded( + settingsMutationToken: settingsMutationToken + ) } private func recoverRegistrationIfNeeded( - settingsMutationToken: UUID? = nil + settingsMutationToken: UUID ) async { guard isCurrentSettingsMutation(settingsMutationToken) else { return From 9f86b86b5178c2364853c252026df36955e2c28a Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:40:16 -0700 Subject: [PATCH 021/117] test(push): deduplicate same-generation activation --- .../PushRegistrationServiceTests.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 6795f605785..782143fa448 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -910,6 +910,34 @@ actor RetryDelayRecorder { ) } + @Test func concurrentSameGenerationSharesRegistrationMutation() async { + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + await PushRegistrationURLProtocol.script.reset([ + .gatedResponse(200, started: started, blocker: blocker), + ]) + let (service, defaults) = makeScriptedService(accountID: "account-a") + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + + let firstEnable = Task { + await service.applyEnabledIntent(true, generation: 1) + } + await started.waitUntilStarted() + let secondEnable = Task { + await service.applyEnabledIntent(true, generation: 1) + } + + await blocker.release() + await firstEnable.value + await secondEnable.value + + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["POST"] + ) + #expect(await service.snapshot.backendState == .registered) + } + @Test func signOutDuringInFlightRegistrationDeletesAfterLatePost() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() From 4379d55394d03f0571083e19a4e41488b17996bc Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:41:30 -0700 Subject: [PATCH 022/117] fix(push): coalesce repeated registration intents --- .../Push/PushRegistrationService.swift | 40 +++++++++++++++++-- .../MobilePushCoordinator.swift | 9 +---- .../MobilePushSettingsIntent.swift | 7 ++++ 3 files changed, 45 insertions(+), 11 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 163c9e3d48e..e38a8ad181b 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -39,6 +39,9 @@ public actor PushRegistrationService: PushRegistering { private var operationGeneration = UUID() private var enabledIntentGeneration = UUID() private var coordinatorIntentGeneration: UInt64 = 0 + private var coordinatorIntentTask: Task? + private var coordinatorIntentTaskGeneration: UInt64? + private var coordinatorIntentTaskID: UUID? private var snapshotValue: PushRegistrationSnapshot private var snapshotContinuations: [UUID: AsyncStream.Continuation] = [:] @@ -135,6 +138,11 @@ public actor PushRegistrationService: PushRegistering { } } + /// Disables local delivery and removes the owned token from the server. + /// + /// The caller may persist the user's opt-out before invoking this method; + /// cleanup therefore uses the registered owner or live session rather than + /// the now-false preference to decide whether a delete is required. public func disableAndUnregister() async { let intentGeneration = UUID() enabledIntentGeneration = intentGeneration @@ -146,6 +154,12 @@ public actor PushRegistrationService: PushRegistering { } } + /// Applies the newest coordinator-owned preference exactly once while it + /// is in flight, ignoring older generations that are still queued. + /// + /// - Parameters: + /// - enabled: The latest user preference. + /// - generation: The monotonically increasing coordinator generation. public func applyEnabledIntent( _ enabled: Bool, generation: UInt64 @@ -154,12 +168,30 @@ public actor PushRegistrationService: PushRegistering { coordinatorIntentGeneration, generation ) - await intentGate.withLock { [self] in - guard await self.isCurrentCoordinatorIntent(generation) else { - return + guard coordinatorIntentGeneration == generation else { return } + if let task = coordinatorIntentTask, + coordinatorIntentTaskGeneration == generation { + await task.value + return + } + let taskID = UUID() + let task = Task { [weak self] in + guard let self else { return } + await self.intentGate.withLock { [self] in + guard await self.isCurrentCoordinatorIntent(generation) else { + return + } + await self.applyEnabledIntentUnlocked(enabled) } - await self.applyEnabledIntentUnlocked(enabled) } + coordinatorIntentTask = task + coordinatorIntentTaskGeneration = generation + coordinatorIntentTaskID = taskID + await task.value + guard coordinatorIntentTaskID == taskID else { return } + coordinatorIntentTask = nil + coordinatorIntentTaskGeneration = nil + coordinatorIntentTaskID = nil } private func applyEnabledIntentUnlocked(_ enabled: Bool) async { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 49255f77db7..0fe26b8c7fb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -232,7 +232,7 @@ public final class MobilePushCoordinator { /// lifecycle reconciliation. The returned generation must be checked after /// each suspension before an operation publishes or persists state. @discardableResult - private func beginSettingsIntent(_ enabled: Bool) -> SettingsIntent { + private func beginSettingsIntent(_ enabled: Bool) -> MobilePushSettingsIntent { cancelSettingsMutation() let token = UUID() registrationIntentGeneration &+= 1 @@ -242,17 +242,12 @@ public final class MobilePushCoordinator { } else { prepareDisable() } - return SettingsIntent( + return MobilePushSettingsIntent( token: token, registrationGeneration: registrationIntentGeneration ) } - private struct SettingsIntent { - let token: UUID - let registrationGeneration: UInt64 - } - /// Point routing at the active store (called by the root view on appear). public func bind(store: CMUXMobileShellStore) { self.store = store diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift new file mode 100644 index 00000000000..4e3f1ea16a2 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Carries the coordinator token and service generation for one push setting intent. +struct MobilePushSettingsIntent { + let token: UUID + let registrationGeneration: UInt64 +} From d86f774bf54fac8b43ea985b1bda9d0d9b461897 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 17:52:26 -0700 Subject: [PATCH 023/117] fix(push): coalesce pending notification intents --- .../Push/PushRegistrationIntentQueue.swift | 101 ++++++++++++++++++ .../Push/PushRegistrationService.swift | 47 ++++---- 2 files changed, 123 insertions(+), 25 deletions(-) create mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift new file mode 100644 index 00000000000..ec6f77bb875 --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift @@ -0,0 +1,101 @@ +import Foundation + +/// Runs one coordinator mutation at a time while replacing stale pending work. +/// +/// A committed network request cannot be canceled safely, but a preference +/// intent that has not started has no value after a newer toggle arrives. The +/// queue therefore keeps one in-flight operation, one latest pending intent, +/// and only the waiters for those live generations. +actor PushRegistrationIntentQueue { + /// The coordinator preference and its monotonic ordering token. + struct Intent: Sendable, Equatable { + let enabled: Bool + let generation: UInt64 + } + + private let operation: @Sendable (Intent) async -> Void + private var latestGeneration: UInt64 = 0 + private var pendingIntent: Intent? + private var runningGeneration: UInt64? + private var workerTask: Task? + private var waiters: [UInt64: [UUID: CheckedContinuation]] = [:] + + /// Creates a queue that delegates each live intent to the registration service. + init(operation: @escaping @Sendable (Intent) async -> Void) { + self.operation = operation + } + + /// Replaces stale pending work and waits for this intent to be handled. + func submit(_ intent: Intent) async { + guard intent.generation >= latestGeneration else { return } + if intent.generation > latestGeneration { + latestGeneration = intent.generation + pendingIntent = intent + resumeWaiters(before: intent.generation) + } else if runningGeneration != intent.generation { + pendingIntent = intent + } + + let waiterID = UUID() + if workerTask == nil { + let operation = self.operation + workerTask = Task { [weak self] in + await self?.drain(operation: operation) + } + } + await withTaskCancellationHandler(operation: { + await withCheckedContinuation { continuation in + if Task.isCancelled { + continuation.resume() + } else { + waiters[intent.generation, default: [:]][waiterID] = continuation + } + } + }, onCancel: { + Task { await self.cancelWaiter( + generation: intent.generation, + waiterID: waiterID + ) } + }) + } + + private func drain( + operation: @escaping @Sendable (Intent) async -> Void + ) async { + while let intent = pendingIntent { + pendingIntent = nil + runningGeneration = intent.generation + await operation(intent) + runningGeneration = nil + resumeWaiters(for: intent.generation) + } + workerTask = nil + } + + private func resumeWaiters(before generation: UInt64) { + let staleGenerations = waiters.keys.filter { $0 < generation } + for staleGeneration in staleGenerations { + resumeWaiters(for: staleGeneration) + } + } + + private func resumeWaiters(for generation: UInt64) { + guard let generationWaiters = waiters.removeValue(forKey: generation) + else { return } + for continuation in generationWaiters.values { + continuation.resume() + } + } + + private func cancelWaiter(generation: UInt64, waiterID: UUID) { + guard var generationWaiters = waiters[generation], + let continuation = generationWaiters.removeValue(forKey: waiterID) + else { return } + if generationWaiters.isEmpty { + waiters.removeValue(forKey: generation) + } else { + waiters[generation] = generationWaiters + } + continuation.resume() + } +} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index e38a8ad181b..239b6f7e1c9 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -39,9 +39,7 @@ public actor PushRegistrationService: PushRegistering { private var operationGeneration = UUID() private var enabledIntentGeneration = UUID() private var coordinatorIntentGeneration: UInt64 = 0 - private var coordinatorIntentTask: Task? - private var coordinatorIntentTaskGeneration: UInt64? - private var coordinatorIntentTaskID: UUID? + private var coordinatorIntentQueue: PushRegistrationIntentQueue? private var snapshotValue: PushRegistrationSnapshot private var snapshotContinuations: [UUID: AsyncStream.Continuation] = [:] @@ -154,8 +152,8 @@ public actor PushRegistrationService: PushRegistering { } } - /// Applies the newest coordinator-owned preference exactly once while it - /// is in flight, ignoring older generations that are still queued. + /// Applies the newest coordinator-owned preference, replacing stale work + /// that has not started and sharing a live mutation with duplicate callers. /// /// - Parameters: /// - enabled: The latest user preference. @@ -169,29 +167,28 @@ public actor PushRegistrationService: PushRegistering { generation ) guard coordinatorIntentGeneration == generation else { return } - if let task = coordinatorIntentTask, - coordinatorIntentTaskGeneration == generation { - await task.value - return + if coordinatorIntentQueue == nil { + coordinatorIntentQueue = PushRegistrationIntentQueue { [weak self] intent in + await self?.applyCoordinatorIntent(intent) + } } - let taskID = UUID() - let task = Task { [weak self] in - guard let self else { return } - await self.intentGate.withLock { [self] in - guard await self.isCurrentCoordinatorIntent(generation) else { - return - } - await self.applyEnabledIntentUnlocked(enabled) + let queue = coordinatorIntentQueue! + await queue.submit(PushRegistrationIntentQueue.Intent( + enabled: enabled, + generation: generation + )) + } + + private func applyCoordinatorIntent( + _ intent: PushRegistrationIntentQueue.Intent + ) async { + guard isCurrentCoordinatorIntent(intent.generation) else { return } + await intentGate.withLock { [self] in + guard await self.isCurrentCoordinatorIntent(intent.generation) else { + return } + await self.applyEnabledIntentUnlocked(intent.enabled) } - coordinatorIntentTask = task - coordinatorIntentTaskGeneration = generation - coordinatorIntentTaskID = taskID - await task.value - guard coordinatorIntentTaskID == taskID else { return } - coordinatorIntentTask = nil - coordinatorIntentTaskGeneration = nil - coordinatorIntentTaskID = nil } private func applyEnabledIntentUnlocked(_ enabled: Bool) async { From 88b75b085bae57d1662edacc54999624504e97a3 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:02:01 -0700 Subject: [PATCH 024/117] test(push): recover disabled startup cleanup --- .../PushRegistrationServiceTests.swift | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 782143fa448..da5360f9451 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -698,6 +698,42 @@ actor RetryDelayRecorder { #expect(await relaunched.snapshot == .disabled) } + @Test func disabledStartupDurablyRecoversOwnedRegistrationCleanup() async { + await PushRegistrationURLProtocol.script.reset([.response(200)]) + let suite = "push-disabled-startup-\(UUID().uuidString)" + let (service, defaults) = makeScriptedService( + suite: suite, + accountID: "account-a", + seedDefaults: { defaults in + defaults.set(false, forKey: "cmux.notifications.pushEnabled") + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + defaults.set( + "account-a", + forKey: "cmux.notifications.registeredAccountID" + ) + } + ) + + let persisted = try? JSONDecoder().decode( + [[String: String]].self, + from: defaults.data( + forKey: "cmux.notifications.pendingUnregisters.v2" + ) ?? Data() + ) + #expect(persisted == [["tokenHex": "aa", "accountID": "account-a"]]) + + await service.syncTokenIfPossible() + + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["DELETE"] + ) + #expect( + defaults.data(forKey: "cmux.notifications.pendingUnregisters.v2") + == nil + ) + } + @Test func optOutWithoutLiveSessionPersistsOwnerBeforeAuthentication() async { await PushRegistrationURLProtocol.script.reset([.response(200)]) let suite = "push-optout-no-session-\(UUID().uuidString)" From ebcdf1ca61a38a40539749f08ea176a2b8b0e563 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:03:13 -0700 Subject: [PATCH 025/117] fix(push): recover interrupted opt-out cleanup --- .../Push/PushRegistrationIntent.swift | 5 +++ .../Push/PushRegistrationIntentQueue.swift | 16 +++------ .../Push/PushRegistrationService.swift | 36 +++++++++++++++++-- 3 files changed, 44 insertions(+), 13 deletions(-) create mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift new file mode 100644 index 00000000000..c9f7f75d157 --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift @@ -0,0 +1,5 @@ +/// A coordinator preference and its monotonic ordering token. +struct PushRegistrationIntent: Sendable, Equatable { + let enabled: Bool + let generation: UInt64 +} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift index ec6f77bb875..c6b8b6f4fe3 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift @@ -7,26 +7,20 @@ import Foundation /// queue therefore keeps one in-flight operation, one latest pending intent, /// and only the waiters for those live generations. actor PushRegistrationIntentQueue { - /// The coordinator preference and its monotonic ordering token. - struct Intent: Sendable, Equatable { - let enabled: Bool - let generation: UInt64 - } - - private let operation: @Sendable (Intent) async -> Void + private let operation: @Sendable (PushRegistrationIntent) async -> Void private var latestGeneration: UInt64 = 0 - private var pendingIntent: Intent? + private var pendingIntent: PushRegistrationIntent? private var runningGeneration: UInt64? private var workerTask: Task? private var waiters: [UInt64: [UUID: CheckedContinuation]] = [:] /// Creates a queue that delegates each live intent to the registration service. - init(operation: @escaping @Sendable (Intent) async -> Void) { + init(operation: @escaping @Sendable (PushRegistrationIntent) async -> Void) { self.operation = operation } /// Replaces stale pending work and waits for this intent to be handled. - func submit(_ intent: Intent) async { + func submit(_ intent: PushRegistrationIntent) async { guard intent.generation >= latestGeneration else { return } if intent.generation > latestGeneration { latestGeneration = intent.generation @@ -60,7 +54,7 @@ actor PushRegistrationIntentQueue { } private func drain( - operation: @escaping @Sendable (Intent) async -> Void + operation: @escaping @Sendable (PushRegistrationIntent) async -> Void ) async { while let intent = pendingIntent { pendingIntent = nil diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 239b6f7e1c9..02a8c97820a 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -96,6 +96,7 @@ public actor PushRegistrationService: PushRegistering { self.defaults = .standard } Self.migrateLegacyPendingUnregisters(in: self.defaults) + Self.persistDisabledRegistrationCleanupIfNeeded(in: self.defaults) self.session = session self.retryDelays = retryDelays self.retryJitter = retryJitter @@ -116,6 +117,9 @@ public actor PushRegistrationService: PushRegistering { public func snapshots() -> AsyncStream { let id = UUID() + if !isEnabled, !pendingUnregisters.isEmpty { + schedulePendingUnregisterContinuation() + } return AsyncStream { continuation in snapshotContinuations[id] = continuation continuation.yield(snapshotValue) @@ -173,14 +177,14 @@ public actor PushRegistrationService: PushRegistering { } } let queue = coordinatorIntentQueue! - await queue.submit(PushRegistrationIntentQueue.Intent( + await queue.submit(PushRegistrationIntent( enabled: enabled, generation: generation )) } private func applyCoordinatorIntent( - _ intent: PushRegistrationIntentQueue.Intent + _ intent: PushRegistrationIntent ) async { guard isCurrentCoordinatorIntent(intent.generation) else { return } await intentGate.withLock { [self] in @@ -887,6 +891,34 @@ public actor PushRegistrationService: PushRegistering { defaults.removeObject(forKey: pendingUnregisterAccountIDKey) } + /// Converts a persisted opt-out plus known server ownership into a durable + /// cleanup obligation before any asynchronous startup work begins. + private static func persistDisabledRegistrationCleanupIfNeeded( + in defaults: UserDefaults + ) { + guard !defaults.bool(forKey: enabledKey), + let tokenHex = defaults.string(forKey: cachedTokenKey), + !tokenHex.isEmpty, + let accountID = defaults.string(forKey: registeredAccountIDKey), + !accountID.isEmpty + else { return } + var entries = (defaults.data(forKey: pendingUnregisterQueueKey) + .flatMap { try? JSONDecoder().decode( + [PendingUnregister].self, + from: $0 + ) }) ?? [] + let pending = PendingUnregister( + tokenHex: tokenHex, + accountID: accountID + ) + if !entries.contains(pending) { + entries.append(pending) + } + if let data = try? JSONEncoder().encode(entries) { + defaults.set(data, forKey: pendingUnregisterQueueKey) + } + } + private func storePendingUnregisters(_ entries: [PendingUnregister]) { if entries.isEmpty { defaults.removeObject(forKey: Self.pendingUnregisterQueueKey) From 0b2802b5e4382632dff181c3cee8c13dfd295083 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:11:23 -0700 Subject: [PATCH 026/117] test(push): persist opt-out before cleanup --- .../PushRegistrationServiceTests.swift | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index da5360f9451..4f67ba40343 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -912,6 +912,31 @@ actor RetryDelayRecorder { #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) } + @Test func disablePersistsOptOutBeforeServerCleanupCompletes() async { + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + await PushRegistrationURLProtocol.script.reset([ + .gatedResponse(200, started: started, blocker: blocker), + ]) + let (service, defaults) = makeScriptedService(accountID: "account-a") + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + defaults.set( + "account-a", + forKey: "cmux.notifications.registeredAccountID" + ) + + let disable = Task { + await service.disableAndUnregister() + } + await started.waitUntilStarted() + + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) + + await blocker.release() + await disable.value + } + @Test func supersededQueuedOptOutCannotUndoAReenable() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() From 9177de8354171a5a1fe6910d8c44b4a702763c02 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:11:59 -0700 Subject: [PATCH 027/117] fix(push): atomically commit coordinator intent --- .../Push/PushRegistrationService.swift | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 02a8c97820a..431cc0b2d1f 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -186,18 +186,20 @@ public actor PushRegistrationService: PushRegistering { private func applyCoordinatorIntent( _ intent: PushRegistrationIntent ) async { - guard isCurrentCoordinatorIntent(intent.generation) else { return } await intentGate.withLock { [self] in - guard await self.isCurrentCoordinatorIntent(intent.generation) else { - return - } - await self.applyEnabledIntentUnlocked(intent.enabled) + await self.applyCoordinatorIntentIfCurrent(intent) } } - private func applyEnabledIntentUnlocked(_ enabled: Bool) async { - if enabled { - defaults.set(true, forKey: Self.enabledKey) + /// Validates and commits the preference in one service-actor turn. Work + /// after the first suspension may be stale, but it can no longer overwrite + /// a newer intent's durable preference. + private func applyCoordinatorIntentIfCurrent( + _ intent: PushRegistrationIntent + ) async { + guard isCurrentCoordinatorIntent(intent.generation) else { return } + defaults.set(intent.enabled, forKey: Self.enabledKey) + if intent.enabled { await syncTokenIfPossibleUnlocked() } else { await disableAndUnregisterUnlocked() @@ -206,9 +208,9 @@ public actor PushRegistrationService: PushRegistering { private func disableAndUnregisterUnlocked() async { cancelRetry() + defaults.set(false, forKey: Self.enabledKey) publish(.disabled) await unregisterFromServerUnlocked(requireKnownOwner: false) - defaults.set(false, forKey: Self.enabledKey) publish(.disabled) } From 87da95bb3ac3c2d22e4d47c0ffe488e6b4e00337 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:20:48 -0700 Subject: [PATCH 028/117] refactor(push): align service package conventions --- .../Push/PushRegistrationService.swift | 82 +++++++++++-------- 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 431cc0b2d1f..93cea5da989 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -96,7 +96,13 @@ public actor PushRegistrationService: PushRegistering { self.defaults = .standard } Self.migrateLegacyPendingUnregisters(in: self.defaults) - Self.persistDisabledRegistrationCleanupIfNeeded(in: self.defaults) + persistDisabledPushRegistrationCleanupIfNeeded( + in: self.defaults, + enabledKey: Self.enabledKey, + cachedTokenKey: Self.cachedTokenKey, + registeredAccountIDKey: Self.registeredAccountIDKey, + pendingUnregisterQueueKey: Self.pendingUnregisterQueueKey + ) self.session = session self.retryDelays = retryDelays self.retryJitter = retryJitter @@ -112,9 +118,13 @@ public actor PushRegistrationService: PushRegistering { ) } + /// Whether the persisted user preference permits push registration. public var isEnabled: Bool { defaults.bool(forKey: Self.enabledKey) } + + /// The latest local token and backend-registration state. public var snapshot: PushRegistrationSnapshot { snapshotValue } + /// Streams the current snapshot followed by every meaningful state change. public func snapshots() -> AsyncStream { let id = UUID() if !isEnabled, !pendingUnregisters.isEmpty { @@ -129,6 +139,7 @@ public actor PushRegistrationService: PushRegistering { } } + /// Persists a preference and reconciles its token registration in order. public func setEnabled(_ enabled: Bool) async { let intentGeneration = UUID() enabledIntentGeneration = intentGeneration @@ -238,6 +249,7 @@ public actor PushRegistrationService: PushRegistering { } } + /// Caches an APNs device token and uploads it when push is enabled. public func register(deviceToken: Data) async { await registerUnlocked(deviceToken: deviceToken) } @@ -272,6 +284,7 @@ public actor PushRegistrationService: PushRegistering { } } + /// Reconciles cached registration and pending cleanup with the current account. public func syncTokenIfPossible() async { await syncTokenIfPossibleUnlocked() } @@ -302,6 +315,7 @@ public actor PushRegistrationService: PushRegistering { } } + /// Durably schedules and attempts removal of the currently owned token. public func unregisterFromServer() async { await intentGate.withLock { [self] in await self.unregisterFromServerUnlocked() @@ -893,34 +907,6 @@ public actor PushRegistrationService: PushRegistering { defaults.removeObject(forKey: pendingUnregisterAccountIDKey) } - /// Converts a persisted opt-out plus known server ownership into a durable - /// cleanup obligation before any asynchronous startup work begins. - private static func persistDisabledRegistrationCleanupIfNeeded( - in defaults: UserDefaults - ) { - guard !defaults.bool(forKey: enabledKey), - let tokenHex = defaults.string(forKey: cachedTokenKey), - !tokenHex.isEmpty, - let accountID = defaults.string(forKey: registeredAccountIDKey), - !accountID.isEmpty - else { return } - var entries = (defaults.data(forKey: pendingUnregisterQueueKey) - .flatMap { try? JSONDecoder().decode( - [PendingUnregister].self, - from: $0 - ) }) ?? [] - let pending = PendingUnregister( - tokenHex: tokenHex, - accountID: accountID - ) - if !entries.contains(pending) { - entries.append(pending) - } - if let data = try? JSONEncoder().encode(entries) { - defaults.set(data, forKey: pendingUnregisterQueueKey) - } - } - private func storePendingUnregisters(_ entries: [PendingUnregister]) { if entries.isEmpty { defaults.removeObject(forKey: Self.pendingUnregisterQueueKey) @@ -948,6 +934,7 @@ public actor PushRegistrationService: PushRegistering { defaults.removeObject(forKey: Self.registeredAccountIDKey) } + /// Records that iOS failed to provide a device token for this attempt. public func deviceTokenRegistrationFailed() { cancelRetry() guard isEnabled else { @@ -1071,6 +1058,38 @@ public actor PushRegistrationService: PushRegistering { } } +/// Converts a persisted opt-out plus known server ownership into a durable +/// cleanup obligation before any asynchronous startup work begins. +private func persistDisabledPushRegistrationCleanupIfNeeded( + in defaults: UserDefaults, + enabledKey: String, + cachedTokenKey: String, + registeredAccountIDKey: String, + pendingUnregisterQueueKey: String +) { + guard !defaults.bool(forKey: enabledKey), + let tokenHex = defaults.string(forKey: cachedTokenKey), + !tokenHex.isEmpty, + let accountID = defaults.string(forKey: registeredAccountIDKey), + !accountID.isEmpty + else { return } + var entries = (defaults.data(forKey: pendingUnregisterQueueKey) + .flatMap { try? JSONDecoder().decode( + [PendingUnregister].self, + from: $0 + ) }) ?? [] + let pending = PendingUnregister( + tokenHex: tokenHex, + accountID: accountID + ) + if !entries.contains(pending) { + entries.append(pending) + } + if let data = try? JSONEncoder().encode(entries) { + defaults.set(data, forKey: pendingUnregisterQueueKey) + } +} + private enum RegistrationResult { case success(pushServiceConfigured: Bool) case failure(PushRegistrationFailure, retryAfter: Duration?) @@ -1081,11 +1100,6 @@ private struct PushRequest { let session: AuthenticatedSessionSnapshot? } -/// Serializes registration mutations across the service actor's suspension -/// points. An actor alone does not provide this guarantee because it can -/// re-enter while URLSession is awaiting a response. The operation runs in an -/// independent worker so cancelling a UI waiter cannot abandon a request after -/// the server may have committed it. private struct RegistrationAcknowledgement: Decodable { let ok: Bool let pushServiceConfigured: Bool? From 7c7f86f96792789f2372929d7a8b6a61fb773594 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:29:46 -0700 Subject: [PATCH 029/117] test(push): cover startup ownership and stale snapshots --- .../PushRegistrationServiceTests.swift | 27 +++++++ .../MobilePushCoordinatorLifecycleTests.swift | 70 ++++++++++++++++++- 2 files changed, 95 insertions(+), 2 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 4f67ba40343..1ca676b50fa 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -887,6 +887,33 @@ actor RetryDelayRecorder { ) } + @Test func inFlightRegistrationPersistsCleanupOwnerBeforePostCompletes() async { + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + await PushRegistrationURLProtocol.script.reset([ + .gatedResponse(200, started: started, blocker: blocker), + ]) + let (service, defaults) = makeScriptedService(accountID: "account-a") + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + + let upload = Task { + await service.register(deviceToken: Data([0xAA])) + } + await started.waitUntilStarted() + + let queueText = defaults.data( + forKey: "cmux.notifications.pendingUnregisters.v2" + ).flatMap { String(data: $0, encoding: .utf8) } + #expect(queueText?.contains("account-a") == true) + + await blocker.release() + await upload.value + #expect( + defaults.data(forKey: "cmux.notifications.pendingUnregisters.v2") + == nil + ) + } + @Test func disablingDuringInFlightEnableSerializesBackendMutation() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index f2379d494aa..d82cc36359e 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -7,6 +7,9 @@ import UserNotifications private actor LifecyclePushRegistration: PushRegistering { private var value: PushRegistrationSnapshot + private var snapshotContinuation: + AsyncStream.Continuation? + private var queuedSnapshots: [PushRegistrationSnapshot] = [] private var latestIntentGeneration: UInt64 = 0 private let setEnabledGate: LifecycleSetEnabledGate? private let syncGate: LifecycleSyncGate? @@ -33,8 +36,27 @@ private actor LifecyclePushRegistration: PushRegistering { func snapshots() -> AsyncStream { AsyncStream { continuation in - continuation.yield(value) - continuation.finish() + Task { await self.installSnapshotContinuation(continuation) } + } + } + + private func installSnapshotContinuation( + _ continuation: AsyncStream.Continuation + ) { + snapshotContinuation = continuation + continuation.yield(value) + for snapshot in queuedSnapshots { + continuation.yield(snapshot) + } + queuedSnapshots.removeAll() + } + + func emit(_ snapshot: PushRegistrationSnapshot) { + value = snapshot + if let snapshotContinuation { + snapshotContinuation.yield(snapshot) + } else { + queuedSnapshots.append(snapshot) } } @@ -256,6 +278,9 @@ private final class LifecyclePushURLProtocol: URLProtocol, override func stopLoading() {} } +private final class LifecycleNotificationDelegate: NSObject, + UNUserNotificationCenterDelegate {} + @Suite struct MobilePushCoordinatorLifecycleTests { @MainActor @Test func callbackFailureOffersRetryAndSuccessfulTokenRecoversReadiness() async { @@ -651,6 +676,47 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) } + @MainActor + @Test func staleDisabledSnapshotCannotReplaceReenabledIntent() async { + let gate = LifecycleSetEnabledGate() + let registration = LifecyclePushRegistration( + enabled: true, + setEnabledGate: gate + ) + let suiteName = "push-coordinator-stale-snapshot-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + authorizationStatus: { .authorized } + ) + coordinator.configure(delegate: LifecycleNotificationDelegate()) + for _ in 0..<100 { + if coordinator.registrationSnapshot.isEnabled { break } + await Task.yield() + } + + coordinator.setEnabledIntent(false) + await gate.waitUntilStarted() + coordinator.setEnabledIntent(true) + await registration.emit(.disabled) + for _ in 0..<20 { + await Task.yield() + } + + #expect(coordinator.isEnabled) + #expect(coordinator.registrationSnapshot != .disabled) + + await gate.release() + for _ in 0..<100 { + if await registration.snapshot.isEnabled { break } + await Task.yield() + } + #expect(await registration.snapshot.isEnabled) + } + @MainActor @Test func foregroundAndReachabilityRecoveryShareOneExhaustedRegistrationRetry() async { let gate = LifecycleSyncGate() From 0e9a3fa33ecf26a70577c8534630067f503918c2 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:33:20 -0700 Subject: [PATCH 030/117] fix(push): fence in-flight cleanup and stale snapshots --- .../CmuxAuthRuntime/Push/PushRegistrationService.swift | 9 +++++++++ .../CmuxMobileShellUI/MobilePushCoordinator.swift | 8 +++++--- .../LifecycleNotificationDelegate.swift | 4 ++++ .../MobilePushCoordinatorLifecycleTests.swift | 3 --- 4 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleNotificationDelegate.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 93cea5da989..c450adf0e57 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -472,6 +472,15 @@ public actor PushRegistrationService: PushRegistering { switch request { case let .success(context): requestSession = context.session + // The POST may commit even if this process is suspended before its + // response arrives. Treat the authenticated owner as a cleanup + // obligation until the acknowledgement clears it. + if let requestSession { + persistPendingUnregister( + tokenHex: tokenHex, + accountID: requestSession.accountID + ) + } result = await performRegistration(context.request) case let .failure(failure): requestSession = nil diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 0fe26b8c7fb..de84661c9b9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -754,9 +754,11 @@ public final class MobilePushCoordinator { let snapshots = await registration.snapshots() for await snapshot in snapshots { guard !Task.isCancelled, let self else { return } - self.registrationSnapshot = self.enabledMirror - ? snapshot - : .disabled + // A service mutation can finish after a newer toggle has + // changed the coordinator mirror. Its opposite-state snapshot + // is stale and must not overwrite the current intent's UI. + guard snapshot.isEnabled == self.enabledMirror else { continue } + self.registrationSnapshot = snapshot } } } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleNotificationDelegate.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleNotificationDelegate.swift new file mode 100644 index 00000000000..6d3933e83ad --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleNotificationDelegate.swift @@ -0,0 +1,4 @@ +import UserNotifications + +final class LifecycleNotificationDelegate: NSObject, + UNUserNotificationCenterDelegate {} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index d82cc36359e..2b6f9a91b60 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -278,9 +278,6 @@ private final class LifecyclePushURLProtocol: URLProtocol, override func stopLoading() {} } -private final class LifecycleNotificationDelegate: NSObject, - UNUserNotificationCenterDelegate {} - @Suite struct MobilePushCoordinatorLifecycleTests { @MainActor @Test func callbackFailureOffersRetryAndSuccessfulTokenRecoversReadiness() async { From d5818e912425ba6de1fdc675362cfb21340adf5c Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:44:23 -0700 Subject: [PATCH 031/117] test(push): cover reenable generation during cleanup --- .../PushRegistrationServiceTests.swift | 2 + .../MobilePushCoordinatorLifecycleTests.swift | 52 ++++++++++++++++++- 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 1ca676b50fa..df24a2fa3c5 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -892,6 +892,7 @@ actor RetryDelayRecorder { let blocker = TestContinuationBlocker() await PushRegistrationURLProtocol.script.reset([ .gatedResponse(200, started: started, blocker: blocker), + .response(200), ]) let (service, defaults) = makeScriptedService(accountID: "account-a") defaults.set(true, forKey: "cmux.notifications.pushEnabled") @@ -1018,6 +1019,7 @@ actor RetryDelayRecorder { await blocker.release() await firstEnable.value await secondEnable.value + await service.applyEnabledIntent(true, generation: 1) #expect( await PushRegistrationURLProtocol.script.requests diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 2b6f9a91b60..68b67920b06 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -7,6 +7,8 @@ import UserNotifications private actor LifecyclePushRegistration: PushRegistering { private var value: PushRegistrationSnapshot + private var snapshotRead = false + private var snapshotReadWaiters: [CheckedContinuation] = [] private var snapshotContinuation: AsyncStream.Continuation? private var queuedSnapshots: [PushRegistrationSnapshot] = [] @@ -32,7 +34,22 @@ private actor LifecyclePushRegistration: PushRegistering { } var isEnabled: Bool { value.isEnabled } - var snapshot: PushRegistrationSnapshot { value } + var snapshot: PushRegistrationSnapshot { + snapshotRead = true + let waiters = snapshotReadWaiters + snapshotReadWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + return value + } + + func waitUntilSnapshotRead() async { + guard !snapshotRead else { return } + await withCheckedContinuation { continuation in + snapshotReadWaiters.append(continuation) + } + } func snapshots() -> AsyncStream { AsyncStream { continuation in @@ -714,6 +731,39 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(await registration.snapshot.isEnabled) } + @MainActor + @Test func reenableIntentIsSubmittedWhileDisableStillReportsEnabled() async { + let gate = LifecycleSetEnabledGate() + let registration = LifecyclePushRegistration( + enabled: true, + setEnabledGate: gate + ) + let suiteName = "push-coordinator-reenable-generation-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + authorizationStatus: { .authorized } + ) + + coordinator.setEnabledIntent(false) + await gate.waitUntilStarted() + + coordinator.setEnabledIntent(true) + await registration.waitUntilSnapshotRead() + + await gate.release() + for _ in 0..<100 { + if await registration.snapshot.isEnabled { break } + await Task.yield() + } + + #expect(await registration.snapshot.isEnabled) + #expect(coordinator.isEnabled) + } + @MainActor @Test func foregroundAndReachabilityRecoveryShareOneExhaustedRegistrationRetry() async { let gate = LifecycleSyncGate() From cdffcba67ea520098ccde45f0f79ed401e379cfe Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:48:07 -0700 Subject: [PATCH 032/117] fix(push): submit every current enable generation --- .../Push/PushRegistrationIntentQueue.swift | 7 +++++++ .../MobilePushCoordinator.swift | 18 ++++++++++-------- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift index c6b8b6f4fe3..4adca79db18 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift @@ -11,6 +11,7 @@ actor PushRegistrationIntentQueue { private var latestGeneration: UInt64 = 0 private var pendingIntent: PushRegistrationIntent? private var runningGeneration: UInt64? + private var completedIntent: PushRegistrationIntent? private var workerTask: Task? private var waiters: [UInt64: [UUID: CheckedContinuation]] = [:] @@ -22,6 +23,11 @@ actor PushRegistrationIntentQueue { /// Replaces stale pending work and waits for this intent to be handled. func submit(_ intent: PushRegistrationIntent) async { guard intent.generation >= latestGeneration else { return } + if intent == completedIntent, + pendingIntent == nil, + runningGeneration == nil { + return + } if intent.generation > latestGeneration { latestGeneration = intent.generation pendingIntent = intent @@ -61,6 +67,7 @@ actor PushRegistrationIntentQueue { runningGeneration = intent.generation await operation(intent) runningGeneration = nil + completedIntent = intent resumeWaiters(for: intent.generation) } workerTask = nil diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index de84661c9b9..0698fb902fa 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -580,14 +580,16 @@ public final class MobilePushCoordinator { : .awaitingDeviceToken ) requestRemoteRegistrationIfNeeded() - if !current.isEnabled { - await registration.applyEnabledIntent( - true, - generation: registrationGeneration - ) - guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { - return - } + // Always submit the current generation. The snapshot can still say + // enabled while an older disable is queued or suspended; the service + // intent queue coalesces repeated completed generations without + // issuing another registration request. + await registration.applyEnabledIntent( + true, + generation: registrationGeneration + ) + guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { + return } let snapshot = await registration.snapshot guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { From 15270dcd88f9c1adf2a299a8f01ab786a744f9f5 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:56:39 -0700 Subject: [PATCH 033/117] test(push): preserve enable after caller cancellation --- .../MobilePushCoordinatorLifecycleTests.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 68b67920b06..61c786b363b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -764,6 +764,40 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(coordinator.isEnabled) } + @MainActor + @Test func cancelledEnableStillCompletesCommittedIntent() async { + let authorizationGate = LifecycleSyncGate() + let registration = LifecyclePushRegistration(enabled: false) + let suiteName = "push-coordinator-cancelled-enable-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + authorizationStatus: { .notDetermined }, + requestAuthorization: { + await authorizationGate.pause() + return true + } + ) + + let enabling = Task { @MainActor in + await coordinator.enable() + } + await authorizationGate.waitUntilStarted() + enabling.cancel() + await authorizationGate.release() + _ = await enabling.value + + for _ in 0..<100 { + if await registration.snapshot.isEnabled { break } + await Task.yield() + } + #expect(await registration.snapshot.isEnabled) + #expect(coordinator.isEnabled) + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) + } + @MainActor @Test func foregroundAndReachabilityRecoveryShareOneExhaustedRegistrationRetry() async { let gate = LifecycleSyncGate() From 5f2c42b6d35b6f0ee1021c5a966ca9a44cd61cb6 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 18:58:49 -0700 Subject: [PATCH 034/117] fix(push): let intent drains outlive caller cancellation --- .../MobilePushCoordinator.swift | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 0698fb902fa..62b09ccba7a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -312,11 +312,18 @@ public final class MobilePushCoordinator { @discardableResult public func enable() async -> Bool { let intent = beginSettingsIntent(true) - return await enable( - trigger: "settings_toggle", - settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration - ) + // Keep the reconciliation worker independent from the caller. A view + // or lifecycle task may be cancelled after the preference is committed; + // only a newer intent token is allowed to supersede this work. + let operation = Task { @MainActor [weak self] in + guard let self else { return false } + return await self.enable( + trigger: "settings_toggle", + settingsMutationToken: intent.token, + registrationGeneration: intent.registrationGeneration + ) + } + return await operation.value } /// Requests or recovers push only after the authenticated workspace shell @@ -436,10 +443,15 @@ public final class MobilePushCoordinator { /// Opt out: stop receiving pushes and remove the token server-side. public func disable() async { let intent = beginSettingsIntent(false) - await finishDisable( - settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration - ) + // As with enable(), caller cancellation must not strand the durable + // server-side cleanup after the local opt-out has become visible. + let operation = Task { @MainActor [weak self] in + await self?.finishDisable( + settingsMutationToken: intent.token, + registrationGeneration: intent.registrationGeneration + ) + } + await operation.value } private func cancelSettingsMutation() { @@ -487,7 +499,8 @@ public final class MobilePushCoordinator { } private func isCurrentSettingsMutation(_ token: UUID) -> Bool { - guard !Task.isCancelled else { return false } + // Task cancellation belongs to the caller's waiter. Preference + // mutations live at app scope, and only a newer token supersedes them. return settingsMutationToken == token } From 66b9763e370bc243eb58bfdcaa1e3c5fa710fe9a Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:06:07 -0700 Subject: [PATCH 035/117] test(push): preserve absent preference on startup --- .../PushRegistrationServiceTests.swift | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index df24a2fa3c5..acaac8f0f0d 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -734,6 +734,34 @@ actor RetryDelayRecorder { ) } + @Test func absentPreferenceDoesNotScheduleRegistrationCleanup() async { + await PushRegistrationURLProtocol.script.reset([.response(200)]) + let suite = "push-absent-preference-startup-\(UUID().uuidString)" + let (service, defaults) = makeScriptedService( + suite: suite, + accountID: "account-a", + seedDefaults: { defaults in + defaults.removeObject(forKey: "cmux.notifications.pushEnabled") + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + defaults.set( + "account-a", + forKey: "cmux.notifications.registeredAccountID" + ) + } + ) + + #expect( + defaults.object(forKey: "cmux.notifications.pushEnabled") == nil + ) + #expect( + defaults.data(forKey: "cmux.notifications.pendingUnregisters.v2") + == nil + ) + + await service.syncTokenIfPossible() + #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) + } + @Test func optOutWithoutLiveSessionPersistsOwnerBeforeAuthentication() async { await PushRegistrationURLProtocol.script.reset([.response(200)]) let suite = "push-optout-no-session-\(UUID().uuidString)" From 9c94426350e0e32c72a367cad659287b1ee32ca4 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:07:45 -0700 Subject: [PATCH 036/117] fix(push): require explicit opt-out for startup cleanup --- .../CmuxAuthRuntime/Push/PushRegistrationService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index c450adf0e57..9f71a7304ea 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -1076,7 +1076,9 @@ private func persistDisabledPushRegistrationCleanupIfNeeded( registeredAccountIDKey: String, pendingUnregisterQueueKey: String ) { - guard !defaults.bool(forKey: enabledKey), + // An absent preference is not an opt-out. Only a durably stored `false` + // authorizes startup cleanup of an otherwise owned registration. + guard defaults.object(forKey: enabledKey) as? Bool == false, let tokenHex = defaults.string(forKey: cachedTokenKey), !tokenHex.isEmpty, let accountID = defaults.string(forKey: registeredAccountIDKey), From f5c966427e03b4deb438d325e92cbf84219fa59b Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:13:52 -0700 Subject: [PATCH 037/117] test(push): preserve denied reenable generation --- .../MobilePushCoordinatorLifecycleTests.swift | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 61c786b363b..e178bc28f12 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -798,6 +798,48 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) } + @MainActor + @Test func deniedReenableSupersedesInFlightDisable() async { + let disableGate = LifecycleSetEnabledGate() + let settingsGate = LifecycleSyncGate() + let registration = LifecyclePushRegistration( + enabled: true, + setEnabledGate: disableGate + ) + let suiteName = "push-coordinator-denied-reenable-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + notificationSettings: { + await settingsGate.pause() + return .authorizationOnly(.denied) + } + ) + + coordinator.setEnabledIntent(false) + await disableGate.waitUntilStarted() + + coordinator.setEnabledIntent(true) + await settingsGate.waitUntilStarted() + await settingsGate.release() + for _ in 0..<20 { + await Task.yield() + } + + await disableGate.release() + for _ in 0..<100 { + if await registration.snapshot.isEnabled { break } + await Task.yield() + } + + #expect(await registration.snapshot.isEnabled) + #expect(coordinator.isEnabled) + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) + } + @MainActor @Test func foregroundAndReachabilityRecoveryShareOneExhaustedRegistrationRetry() async { let gate = LifecycleSyncGate() From 6bdd586e0d0e558fca20ad5caa3f9e1032c18c57 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:16:59 -0700 Subject: [PATCH 038/117] fix(push): propagate denied enable generation --- .../CmuxMobileShellUI/MobilePushCoordinator.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 62b09ccba7a..6e6e08e24de 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -410,6 +410,18 @@ public final class MobilePushCoordinator { return false } guard granted else { + // Authorization is an independent OS gate. The app intent still + // has to reach the service so it can supersede an older disable + // that may be suspended in cleanup; readiness remains blocked by + // the denied/unsupported system status below. + await registration.applyEnabledIntent( + true, + generation: registrationGeneration + ) + guard isCurrentSettingsMutation(settingsMutationToken), + enabledMirror else { + return false + } await refreshReadiness(settingsMutationToken: settingsMutationToken) diagnosticLog?.recordAppEvent(.pushAuthorizationDenied) analytics.capture("ios_push_optin_declined", [ From b93b7e06a4385b0ef0b05dc5f93579ea5ca74895 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:33:51 -0700 Subject: [PATCH 039/117] fix(push): unify preference mutation ordering --- .../Push/PushRegistrationIntent.swift | 11 +- .../Push/PushRegistrationIntentQueue.swift | 2 +- .../Push/PushRegistrationService.swift | 126 ++++++++++++------ .../PushRegistrationServiceTests.swift | 34 +++++ 4 files changed, 127 insertions(+), 46 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift index c9f7f75d157..1b23c80d5f4 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift @@ -1,5 +1,14 @@ -/// A coordinator preference and its monotonic ordering token. +/// The preference mutation operation and its service-owned ordering token. +enum PushRegistrationIntentKind: Sendable, Equatable { + /// The regular `setEnabled(_:)` semantics, including retrying an already + /// disabled owner's pending cleanup instead of inferring a live owner. + case setEnabled + /// The coordinator's local-first opt-out cleanup semantics. + case disableAndUnregister +} + struct PushRegistrationIntent: Sendable, Equatable { let enabled: Bool + let kind: PushRegistrationIntentKind let generation: UInt64 } diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift index 4adca79db18..3b496641d3e 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift @@ -1,6 +1,6 @@ import Foundation -/// Runs one coordinator mutation at a time while replacing stale pending work. +/// Runs one preference mutation at a time while replacing stale pending work. /// /// A committed network request cannot be canceled safely, but a preference /// intent that has not started has no value after a newer toggle arrives. The diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 9f71a7304ea..051dfda7120 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -37,9 +37,18 @@ public actor PushRegistrationService: PushRegistering { private var retryTask: Task? private var unregisterDrainTask: Task? private var operationGeneration = UUID() - private var enabledIntentGeneration = UUID() - private var coordinatorIntentGeneration: UInt64 = 0 - private var coordinatorIntentQueue: PushRegistrationIntentQueue? + /// Every preference mutation, including the legacy public mutation APIs, + /// is assigned one service-owned generation and enters this queue. A + /// direct mutation therefore advances the same ordering domain as a + /// coordinator intent and replaces any coordinator work still pending. + private var preferenceIntentGeneration: UInt64 = 0 + private var coordinatorGeneration: UInt64 = 0 + /// Direct callers invalidate all coordinator generations already admitted. + /// This is validation metadata only; mutation ordering uses + /// `preferenceIntentGeneration` above. + private var coordinatorGenerationInvalidatedThrough: UInt64? + private var latestCoordinatorIntent: PushRegistrationIntent? + private var preferenceIntentQueue: PushRegistrationIntentQueue? private var snapshotValue: PushRegistrationSnapshot private var snapshotContinuations: [UUID: AsyncStream.Continuation] = [:] @@ -141,14 +150,8 @@ public actor PushRegistrationService: PushRegistering { /// Persists a preference and reconciles its token registration in order. public func setEnabled(_ enabled: Bool) async { - let intentGeneration = UUID() - enabledIntentGeneration = intentGeneration - await intentGate.withLock { [self] in - guard await self.isCurrentEnabledIntent(intentGeneration) else { - return - } - await self.setEnabledUnlocked(enabled) - } + invalidateCoordinatorIntents() + await submitPreferenceIntent(enabled: enabled, kind: .setEnabled) } /// Disables local delivery and removes the owned token from the server. @@ -157,14 +160,11 @@ public actor PushRegistrationService: PushRegistering { /// cleanup therefore uses the registered owner or live session rather than /// the now-false preference to decide whether a delete is required. public func disableAndUnregister() async { - let intentGeneration = UUID() - enabledIntentGeneration = intentGeneration - await intentGate.withLock { [self] in - guard await self.isCurrentEnabledIntent(intentGeneration) else { - return - } - await self.disableAndUnregisterUnlocked() - } + invalidateCoordinatorIntents() + await submitPreferenceIntent( + enabled: false, + kind: .disableAndUnregister + ) } /// Applies the newest coordinator-owned preference, replacing stale work @@ -177,42 +177,84 @@ public actor PushRegistrationService: PushRegistering { _ enabled: Bool, generation: UInt64 ) async { - coordinatorIntentGeneration = max( - coordinatorIntentGeneration, - generation + if let invalidatedThrough = coordinatorGenerationInvalidatedThrough, + generation <= invalidatedThrough + { + return + } + guard generation >= coordinatorGeneration else { return } + if generation == coordinatorGeneration { + guard let latestCoordinatorIntent else { return } + await submitPreferenceIntent(latestCoordinatorIntent) + return + } + coordinatorGeneration = generation + let intent = makePreferenceIntent( + enabled: enabled, + kind: enabled ? .setEnabled : .disableAndUnregister + ) + latestCoordinatorIntent = intent + await submitPreferenceIntent(intent) + } + + private func submitPreferenceIntent( + enabled: Bool, + kind: PushRegistrationIntentKind + ) async { + await submitPreferenceIntent( + makePreferenceIntent(enabled: enabled, kind: kind) ) - guard coordinatorIntentGeneration == generation else { return } - if coordinatorIntentQueue == nil { - coordinatorIntentQueue = PushRegistrationIntentQueue { [weak self] intent in - await self?.applyCoordinatorIntent(intent) + } + + private func submitPreferenceIntent( + _ intent: PushRegistrationIntent + ) async { + if preferenceIntentQueue == nil { + preferenceIntentQueue = PushRegistrationIntentQueue { [weak self] intent in + await self?.applyPreferenceIntent(intent) } } - let queue = coordinatorIntentQueue! - await queue.submit(PushRegistrationIntent( + await preferenceIntentQueue!.submit(intent) + } + + private func makePreferenceIntent( + enabled: Bool, + kind: PushRegistrationIntentKind + ) -> PushRegistrationIntent { + preferenceIntentGeneration &+= 1 + return PushRegistrationIntent( enabled: enabled, - generation: generation - )) + kind: kind, + generation: preferenceIntentGeneration + ) } - private func applyCoordinatorIntent( + private func invalidateCoordinatorIntents() { + coordinatorGenerationInvalidatedThrough = coordinatorGeneration + latestCoordinatorIntent = nil + } + + private func applyPreferenceIntent( _ intent: PushRegistrationIntent ) async { await intentGate.withLock { [self] in - await self.applyCoordinatorIntentIfCurrent(intent) + guard await self.isCurrentPreferenceIntent(intent.generation) else { + return + } + await self.applyPreferenceIntentUnlocked(intent) } } /// Validates and commits the preference in one service-actor turn. Work /// after the first suspension may be stale, but it can no longer overwrite /// a newer intent's durable preference. - private func applyCoordinatorIntentIfCurrent( + private func applyPreferenceIntentUnlocked( _ intent: PushRegistrationIntent ) async { - guard isCurrentCoordinatorIntent(intent.generation) else { return } - defaults.set(intent.enabled, forKey: Self.enabledKey) - if intent.enabled { - await syncTokenIfPossibleUnlocked() - } else { + switch intent.kind { + case .setEnabled: + await setEnabledUnlocked(intent.enabled) + case .disableAndUnregister: await disableAndUnregisterUnlocked() } } @@ -225,12 +267,8 @@ public actor PushRegistrationService: PushRegistering { publish(.disabled) } - private func isCurrentEnabledIntent(_ generation: UUID) -> Bool { - enabledIntentGeneration == generation - } - - private func isCurrentCoordinatorIntent(_ generation: UInt64) -> Bool { - coordinatorIntentGeneration == generation + private func isCurrentPreferenceIntent(_ generation: UInt64) -> Bool { + preferenceIntentGeneration == generation } private func setEnabledUnlocked(_ enabled: Bool) async { diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index acaac8f0f0d..ff37238c61f 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1027,6 +1027,40 @@ actor RetryDelayRecorder { ) } + @Test func directMutationSupersedesQueuedCoordinatorIntent() async { + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + await PushRegistrationURLProtocol.script.reset([ + .gatedResponse(200, started: started, blocker: blocker), + .response(200), + ]) + let (service, defaults) = makeScriptedService(accountID: "account-a") + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + + let firstEnable = Task { + await service.applyEnabledIntent(true, generation: 1) + } + await started.waitUntilStarted() + let queuedOptOut = Task { + await service.applyEnabledIntent(false, generation: 2) + } + let directReenable = Task { + await service.setEnabled(true) + } + + await blocker.release() + await firstEnable.value + await queuedOptOut.value + await directReenable.value + + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) + #expect(await service.snapshot.backendState == .registered) + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["POST", "POST"] + ) + } + @Test func concurrentSameGenerationSharesRegistrationMutation() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() From e8a3df177be328e95b8bfb87c3f88febcc2549d1 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:40:53 -0700 Subject: [PATCH 040/117] chore(push): isolate intent kind type --- .../CmuxAuthRuntime/Push/PushRegistrationIntent.swift | 9 --------- .../Push/PushRegistrationIntentKind.swift | 8 ++++++++ 2 files changed, 8 insertions(+), 9 deletions(-) create mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentKind.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift index 1b23c80d5f4..5c9ad6d2226 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift @@ -1,12 +1,3 @@ -/// The preference mutation operation and its service-owned ordering token. -enum PushRegistrationIntentKind: Sendable, Equatable { - /// The regular `setEnabled(_:)` semantics, including retrying an already - /// disabled owner's pending cleanup instead of inferring a live owner. - case setEnabled - /// The coordinator's local-first opt-out cleanup semantics. - case disableAndUnregister -} - struct PushRegistrationIntent: Sendable, Equatable { let enabled: Bool let kind: PushRegistrationIntentKind diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentKind.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentKind.swift new file mode 100644 index 00000000000..ce46df52a6d --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentKind.swift @@ -0,0 +1,8 @@ +/// The preference mutation operation represented by a service intent. +enum PushRegistrationIntentKind: Sendable, Equatable { + /// The regular `setEnabled(_:)` semantics, including retrying an already + /// disabled owner's pending cleanup instead of inferring a live owner. + case setEnabled + /// The coordinator's local-first opt-out cleanup semantics. + case disableAndUnregister +} From 845883cac1686a10b367912dc141a712f9ee3640 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:47:01 -0700 Subject: [PATCH 041/117] test(push): cancel queued mutation waiter --- .../PushRegistrationMutationGateTests.swift | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationMutationGateTests.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationMutationGateTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationMutationGateTests.swift new file mode 100644 index 00000000000..35884a859f6 --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationMutationGateTests.swift @@ -0,0 +1,32 @@ +import Testing +@testable import CmuxAuthRuntime + +@Suite(.serialized) struct PushRegistrationMutationGateTests { + @Test func cancelledQueuedMutationDoesNotRun() async { + let gate = PushRegistrationMutationGate() + let firstStarted = TestPhaseSignal() + let firstBlocker = TestContinuationBlocker() + let secondStarted = TestPhaseSignal() + + let first = Task { + await gate.withLock { + await firstStarted.markStarted() + await firstBlocker.wait() + } + } + await firstStarted.waitUntilStarted() + + let second = Task { + await gate.withLock { + await secondStarted.markStarted() + } + } + second.cancel() + + await firstBlocker.release() + await first.value + _ = await second.value + + #expect(await secondStarted.didStart == false) + } +} From 886a28cdb2506983cec2210d285dc9d272c32fcd Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:48:03 -0700 Subject: [PATCH 042/117] fix(push): drop cancelled mutation waiters --- .../Push/PushRegistrationMutationGate.swift | 42 ++++++++++++++----- .../Push/PushRegistrationService.swift | 7 +++- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift index 348ca32209b..f26c0262ad2 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift @@ -6,12 +6,15 @@ import Foundation /// abandon a request after the server may have committed it. actor PushRegistrationMutationGate { private var isHeld = false - private var waiters: [CheckedContinuation] = [] + private var waiters: [( + id: UUID, + continuation: CheckedContinuation + )] = [] func withLock( _ operation: @escaping @Sendable () async -> Value - ) async -> Value { - await acquire() + ) async -> Value? { + guard await acquire() else { return nil } let worker = Task { await operation() } @@ -19,21 +22,40 @@ actor PushRegistrationMutationGate { return await worker.value } - private func acquire() async { + private func acquire() async -> Bool { + guard !Task.isCancelled else { return false } guard isHeld else { isHeld = true - return + return true } - await withCheckedContinuation { continuation in - waiters.append(continuation) + let id = UUID() + return await withTaskCancellationHandler(operation: { + await withCheckedContinuation { continuation in + if Task.isCancelled { + continuation.resume(returning: false) + } else { + waiters.append((id: id, continuation: continuation)) + } + } + }, onCancel: { + Task { await self.cancelWaiter(id: id) } + }) + } + + private func cancelWaiter(id: UUID) { + guard let index = waiters.firstIndex(where: { $0.id == id }) else { + return } + let waiter = waiters.remove(at: index) + waiter.continuation.resume(returning: false) } private func release() { - guard !waiters.isEmpty else { - isHeld = false + while !waiters.isEmpty { + let waiter = waiters.removeFirst() + waiter.continuation.resume(returning: true) return } - waiters.removeFirst().resume() + isHeld = false } } diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 051dfda7120..a900a3d07cc 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -553,6 +553,8 @@ public actor PushRegistrationService: PushRegistering { return } switch result { + case .cancelled: + return case let .success(pushServiceConfigured): if let requestSession { defaults.set( @@ -759,7 +761,7 @@ public actor PushRegistrationService: PushRegistering { private func performRegistration(_ request: URLRequest) async -> RegistrationResult { await networkMutationGate.withLock { [self] in await self.performRegistrationRequest(request) - } + } ?? .cancelled } private func performRegistrationRequest(_ request: URLRequest) async -> RegistrationResult { @@ -797,7 +799,7 @@ public actor PushRegistrationService: PushRegistering { private func performDelete(_ request: URLRequest) async -> Bool { await networkMutationGate.withLock { [self] in await self.performDeleteRequest(request) - } + } ?? false } private func performDeleteRequest(_ request: URLRequest) async -> Bool { @@ -1140,6 +1142,7 @@ private func persistDisabledPushRegistrationCleanupIfNeeded( } private enum RegistrationResult { + case cancelled case success(pushServiceConfigured: Bool) case failure(PushRegistrationFailure, retryAfter: Duration?) } From 18c558420deb8da8c4c29a7c3009d09d9e4e465e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:55:20 -0700 Subject: [PATCH 043/117] test(push): accept initial coordinator generation --- .../PushRegistrationServiceTests.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index ff37238c61f..3f149173f51 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1027,6 +1027,21 @@ actor RetryDelayRecorder { ) } + @Test func coordinatorGenerationZeroEnablesPreviouslyAuthorizedStartup() async { + await PushRegistrationURLProtocol.script.reset([.response(200)]) + let (service, defaults) = makeScriptedService(accountID: "account-a") + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + + await service.applyEnabledIntent(true, generation: 0) + + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) + #expect(await service.snapshot.backendState == .registered) + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["POST"] + ) + } + @Test func directMutationSupersedesQueuedCoordinatorIntent() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() From abe6ac92a57342c86631828ee7f4a852ff517938 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 19:56:12 -0700 Subject: [PATCH 044/117] fix(push): accept generation zero startup intent --- .../Push/PushRegistrationService.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index a900a3d07cc..e55e017fa3d 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -42,7 +42,7 @@ public actor PushRegistrationService: PushRegistering { /// direct mutation therefore advances the same ordering domain as a /// coordinator intent and replaces any coordinator work still pending. private var preferenceIntentGeneration: UInt64 = 0 - private var coordinatorGeneration: UInt64 = 0 + private var coordinatorGeneration: UInt64? /// Direct callers invalidate all coordinator generations already admitted. /// This is validation metadata only; mutation ordering uses /// `preferenceIntentGeneration` above. @@ -182,11 +182,13 @@ public actor PushRegistrationService: PushRegistering { { return } - guard generation >= coordinatorGeneration else { return } - if generation == coordinatorGeneration { - guard let latestCoordinatorIntent else { return } - await submitPreferenceIntent(latestCoordinatorIntent) - return + if let currentGeneration = coordinatorGeneration { + guard generation >= currentGeneration else { return } + if generation == currentGeneration { + guard let latestCoordinatorIntent else { return } + await submitPreferenceIntent(latestCoordinatorIntent) + return + } } coordinatorGeneration = generation let intent = makePreferenceIntent( From be494245e2aa491aa41b3e984e55a792615271a8 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:05:37 -0700 Subject: [PATCH 045/117] fix(push): recheck cancellation at gate handoff --- .../Push/PushRegistrationMutationGate.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift index f26c0262ad2..dc7173230b4 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift @@ -15,6 +15,13 @@ actor PushRegistrationMutationGate { _ operation: @escaping @Sendable () async -> Value ) async -> Value? { guard await acquire() else { return nil } + // Cancellation can arrive after `release()` resumes this waiter but + // before its continuation gets scheduled. Give the lock back instead + // of starting a mutation that the caller has already abandoned. + guard !Task.isCancelled else { + release() + return nil + } let worker = Task { await operation() } From bf861abda80d225efbac2a2c6abd38c8607c8020 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:12:17 -0700 Subject: [PATCH 046/117] test(push): recover cancelled queued registration --- .../PushRegistrationServiceTests.swift | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 3f149173f51..bfc5af6baf6 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1076,6 +1076,40 @@ actor RetryDelayRecorder { ) } + @Test func cancelledQueuedRegistrationLeavesRecoverableState() async { + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + await PushRegistrationURLProtocol.script.reset([ + .gatedResponse(200, started: started, blocker: blocker), + .response(200), + .response(200), + ]) + let (service, defaults) = makeScriptedService( + accountID: "account-a", + retryDelays: [] + ) + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + + let first = Task { + await service.register(deviceToken: Data([0xAA])) + } + await started.waitUntilStarted() + + let queued = Task { + await service.register(deviceToken: Data([0xAA])) + } + queued.cancel() + + await blocker.release() + await first.value + await queued.value + + #expect( + await service.snapshot.backendState + != .registering + ) + } + @Test func concurrentSameGenerationSharesRegistrationMutation() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() From a9d3eecc7ada359a80708669d4f0143847142afc Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:13:58 -0700 Subject: [PATCH 047/117] fix(push): recover cancelled registration attempts --- .../Push/PushRegistrationService.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index e55e017fa3d..5f7d8091c48 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -556,6 +556,21 @@ public actor PushRegistrationService: PushRegistering { } switch result { case .cancelled: + // The gate may reject a cancelled waiter before any request starts. + // Leave the enabled token recoverable instead of stranding the + // snapshot in `.registering` with no future reconciliation. + publish(PushRegistrationSnapshot( + isEnabled: true, + hasDeviceToken: true, + backendState: .registrationRequired + )) + scheduleUploadRetry( + failure: .networkUnavailable, + retryAfter: nil, + tokenHex: tokenHex, + generation: generation, + remainingDelays: remainingDelays + ) return case let .success(pushServiceConfigured): if let requestSession { From c2c06d7b2f9c13bb2b7a85263e268ef61ce68a1c Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:14:14 -0700 Subject: [PATCH 048/117] test(push): fix cancellation regression setup --- .../CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index bfc5af6baf6..d1ea0672524 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1085,8 +1085,8 @@ actor RetryDelayRecorder { .response(200), ]) let (service, defaults) = makeScriptedService( - accountID: "account-a", - retryDelays: [] + retryDelays: [], + accountID: "account-a" ) defaults.set(true, forKey: "cmux.notifications.pushEnabled") @@ -1106,7 +1106,7 @@ actor RetryDelayRecorder { #expect( await service.snapshot.backendState - != .registering + != PushRegistrationBackendState.registering ) } From 59ded957b5b252467241912a1583a91042b76005 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:25:05 -0700 Subject: [PATCH 049/117] test(push): bound stalled settings mutation --- .../MobilePushCoordinatorLifecycleTests.swift | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index e178bc28f12..908fd32d460 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -798,6 +798,47 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) } + @MainActor + @Test func stalledSettingsMutationTimesOutAndReleasesLifecycleSlot() async { + let settingsGate = LifecycleSyncGate() + let registration = LifecyclePushRegistration(enabled: false) + let suiteName = "push-coordinator-settings-timeout-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + notificationSettings: { + await settingsGate.pause() + return .authorizationOnly(.authorized) + }, + registerForRemoteNotifications: {}, + settingsMutationSleep: { _ in + await settingsGate.waitUntilStarted() + } + ) + + coordinator.setEnabledIntent(true) + await settingsGate.waitUntilStarted() + for _ in 0..<100 { + if coordinator.registrationSnapshot.backendState + == .failed(.networkUnavailable) { + break + } + await Task.yield() + } + + #expect(coordinator.isEnabled) + #expect( + coordinator.registrationSnapshot.backendState + == .failed(.networkUnavailable) + ) + + await settingsGate.release() + await coordinator.workspaceListDidBecomeVisible() + #expect(await registration.snapshot.isEnabled) + } + @MainActor @Test func deniedReenableSupersedesInFlightDisable() async { let disableGate = LifecycleSetEnabledGate() From cb1914233c4e3487992277a5755a574d43ef2500 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:25:39 -0700 Subject: [PATCH 050/117] fix(push): bound settings mutation lifetime --- .../MobilePushCoordinator.swift | 89 ++++++++++++++++--- .../MobilePushMutationCompletion.swift | 33 +++++++ 2 files changed, 110 insertions(+), 12 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 6e6e08e24de..e416d7cb7e6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -90,7 +90,13 @@ public final class MobilePushCoordinator { /// bounded by the reply lifetime; success or a fresh park cancels it. @ObservationIgnored private var replyRetryTask: Task? @ObservationIgnored private let replyRetrySleep: @Sendable (Duration) async throws -> Void + @ObservationIgnored private let settingsMutationSleep: + @Sendable (Duration) async throws -> Void private static let replyRetryDelay: Duration = .seconds(5) + /// Authorization prompts and backend reconciliation must not hold the + /// app-lifetime settings slot forever. The sleep is injected for + /// deterministic timeout tests. + private static let settingsMutationTimeout: Duration = .seconds(30) /// The iOS API endpoint that accepted this installation's APNs token. public let phoneAPIOrigin: String /// Live OS authorization, refreshed at launch, on foreground, and when @@ -163,10 +169,14 @@ public final class MobilePushCoordinator { }, replyRetrySleep: @escaping @Sendable (Duration) async throws -> Void = { try await ContinuousClock().sleep(for: $0) + }, + settingsMutationSleep: @escaping @Sendable (Duration) async throws -> Void = { + try await ContinuousClock().sleep(for: $0) } ) { self.registration = registration self.replyRetrySleep = replyRetrySleep + self.settingsMutationSleep = settingsMutationSleep self.analytics = analytics self.diagnosticLog = diagnosticLog self.phoneAPIOrigin = phoneAPIOrigin @@ -212,19 +222,74 @@ public final class MobilePushCoordinator { let intent = beginSettingsIntent(enabled) settingsMutationTask = Task { @MainActor [weak self] in guard let self else { return } - if enabled { - _ = await self.enable( - trigger: "settings_toggle", - settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration - ) - } else { - await self.finishDisable( - settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration - ) + await self.runSettingsMutation( + token: intent.token, + operation: { [weak self] in + guard let self else { return } + if enabled { + _ = await self.enable( + trigger: "settings_toggle", + settingsMutationToken: intent.token, + registrationGeneration: intent.registrationGeneration + ) + } else { + await self.finishDisable( + settingsMutationToken: intent.token, + registrationGeneration: intent.registrationGeneration + ) + } + self.finishSettingsMutation(intent.token) + } + ) + } + } + + /// Runs a settings mutation with an independent deadline. The operation + /// remains app-lifetime work until the deadline, while a timed-out waiter + /// is cancelled and the coordinator immediately exposes a retryable state. + private func runSettingsMutation( + token: UUID, + operation: @escaping @MainActor () async -> Void + ) async { + let completion = MobilePushMutationCompletion() + let operationTask = Task { @MainActor in + await operation() + await completion.resolve(.completed) + } + let timeoutTask = Task { [settingsMutationSleep] in + do { + try await settingsMutationSleep(Self.settingsMutationTimeout) + await completion.resolve(.timedOut) + } catch { + // The mutation completed first and cancelled this sleeper. } - self.finishSettingsMutation(intent.token) + } + let outcome = await completion.wait() + timeoutTask.cancel() + guard outcome == .timedOut else { return } + operationTask.cancel() + handleSettingsMutationTimeout(token) + } + + private func handleSettingsMutationTimeout(_ token: UUID) { + guard isCurrentSettingsMutation(token) else { return } + settingsMutationTask = nil + settingsMutationToken = UUID() + if enabledMirror { + registrationSnapshot = PushRegistrationSnapshot( + isEnabled: true, + hasDeviceToken: registrationSnapshot.hasDeviceToken, + backendState: .failed(.networkUnavailable) + ) + diagnosticLog?.recordAppEvent( + .pushBackendSyncFailed, + failure: .offline + ) + analytics.capture("ios_push_settings_timeout", [ + "timeout_seconds": .int( + Int(Self.settingsMutationTimeout.components.seconds) + ), + ]) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift new file mode 100644 index 00000000000..b4f442ce373 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Resolves the first terminal result of an app-lifetime push mutation. +actor MobilePushMutationCompletion { + enum Outcome: Sendable, Equatable { + case completed + case timedOut + } + + private var outcome: Outcome? + private var waiters: [CheckedContinuation] = [] + + func resolve(_ outcome: Outcome) { + guard self.outcome == nil else { return } + self.outcome = outcome + let waiters = self.waiters + self.waiters.removeAll() + for waiter in waiters { + waiter.resume(returning: outcome) + } + } + + func wait() async -> Outcome { + if let outcome { return outcome } + return await withCheckedContinuation { continuation in + if let outcome { + continuation.resume(returning: outcome) + } else { + waiters.append(continuation) + } + } + } +} From 86311258d8c4c5c2c12d22b0bc700ff6dac04c36 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:28:36 -0700 Subject: [PATCH 051/117] chore(push): isolate timeout outcome type --- .../MobilePushMutationCompletion.swift | 13 ++++--------- .../MobilePushMutationOutcome.swift | 5 +++++ 2 files changed, 9 insertions(+), 9 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift index b4f442ce373..aa873b5eda8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift @@ -2,15 +2,10 @@ import Foundation /// Resolves the first terminal result of an app-lifetime push mutation. actor MobilePushMutationCompletion { - enum Outcome: Sendable, Equatable { - case completed - case timedOut - } - - private var outcome: Outcome? - private var waiters: [CheckedContinuation] = [] + private var outcome: MobilePushMutationOutcome? + private var waiters: [CheckedContinuation] = [] - func resolve(_ outcome: Outcome) { + func resolve(_ outcome: MobilePushMutationOutcome) { guard self.outcome == nil else { return } self.outcome = outcome let waiters = self.waiters @@ -20,7 +15,7 @@ actor MobilePushMutationCompletion { } } - func wait() async -> Outcome { + func wait() async -> MobilePushMutationOutcome { if let outcome { return outcome } return await withCheckedContinuation { continuation in if let outcome { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift new file mode 100644 index 00000000000..4d07d1a5cfb --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift @@ -0,0 +1,5 @@ +/// The terminal result of an app-lifetime push settings mutation. +enum MobilePushMutationOutcome: Sendable, Equatable { + case completed + case timedOut +} From fa2d641624946c20215839af6f6613621a3b281f Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:37:04 -0700 Subject: [PATCH 052/117] test(push): retry timed out enable intent --- .../MobilePushCoordinatorLifecycleTests.swift | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 908fd32d460..69c1b88e092 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -805,6 +805,7 @@ private final class LifecyclePushURLProtocol: URLProtocol, let suiteName = "push-coordinator-settings-timeout-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! defer { defaults.removePersistentDomain(forName: suiteName) } + var registrationRequests = 0 let coordinator = MobilePushCoordinator( registration: registration, defaults: defaults, @@ -812,7 +813,7 @@ private final class LifecyclePushURLProtocol: URLProtocol, await settingsGate.pause() return .authorizationOnly(.authorized) }, - registerForRemoteNotifications: {}, + registerForRemoteNotifications: { registrationRequests += 1 }, settingsMutationSleep: { _ in await settingsGate.waitUntilStarted() } @@ -835,6 +836,12 @@ private final class LifecyclePushURLProtocol: URLProtocol, ) await settingsGate.release() + coordinator.setEnabledIntent(true) + for _ in 0..<100 { + if registrationRequests == 1 { break } + await Task.yield() + } + #expect(registrationRequests == 1) await coordinator.workspaceListDidBecomeVisible() #expect(await registration.snapshot.isEnabled) } From 15f23ddb43620a825363cdf5882e0fc9f8d8050d Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:38:25 -0700 Subject: [PATCH 053/117] fix(push): permit retry after settings timeout --- .../Sources/CmuxMobileShellUI/MobilePushCoordinator.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index e416d7cb7e6..f993d077aa8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -126,6 +126,7 @@ public final class MobilePushCoordinator { /// for the old task to unwind. @ObservationIgnored private var settingsMutationTask: Task? @ObservationIgnored private var settingsMutationToken = UUID() + @ObservationIgnored private var settingsMutationNeedsRetry = false @ObservationIgnored private var registrationIntentGeneration: UInt64 = 0 @ObservationIgnored private var workspaceAuthorizationRequestInFlight = false @ObservationIgnored private var hasRequestedRemoteRegistration = false @@ -218,7 +219,9 @@ public final class MobilePushCoordinator { /// coordinator task and starts independently, so an opt-out can preempt an /// authorization prompt or other suspended enable path. public func setEnabledIntent(_ enabled: Bool) { - guard enabled != enabledMirror else { return } + guard enabled != enabledMirror || settingsMutationNeedsRetry else { + return + } let intent = beginSettingsIntent(enabled) settingsMutationTask = Task { @MainActor [weak self] in guard let self else { return } @@ -275,6 +278,7 @@ public final class MobilePushCoordinator { guard isCurrentSettingsMutation(token) else { return } settingsMutationTask = nil settingsMutationToken = UUID() + settingsMutationNeedsRetry = true if enabledMirror { registrationSnapshot = PushRegistrationSnapshot( isEnabled: true, @@ -299,6 +303,7 @@ public final class MobilePushCoordinator { @discardableResult private func beginSettingsIntent(_ enabled: Bool) -> MobilePushSettingsIntent { cancelSettingsMutation() + settingsMutationNeedsRetry = false let token = UUID() registrationIntentGeneration &+= 1 settingsMutationToken = token From 81f03ee161cb592c35be01512111129dbdbf1351 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:02:08 -0700 Subject: [PATCH 054/117] test(push): cover late prompt and worker cancellation --- .../MobilePushCoordinatorLifecycleTests.swift | 119 ++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 69c1b88e092..0e22a832a9a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -185,6 +185,22 @@ private actor LifecycleSetEnabledGate { } } +private actor LifecycleCancellationRecorder { + private var authorizationCancelled = false + private var timeoutCancelled = false + + func recordAuthorizationCancellation(_ cancelled: Bool) { + authorizationCancelled = cancelled + } + + func recordTimeoutCancellation(_ cancelled: Bool) { + timeoutCancelled = cancelled + } + + var didCancelAuthorization: Bool { authorizationCancelled } + var didCancelTimeout: Bool { timeoutCancelled } +} + private actor LifecycleSyncGate { private(set) var starts = 0 private var released = false @@ -846,6 +862,109 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(await registration.snapshot.isEnabled) } + @MainActor + @Test func lateAuthorizationAfterTimeoutStartsFreshReconciliation() async { + let authorizationGate = LifecycleSyncGate() + let timeoutGate = LifecycleSyncGate() + let registration = LifecyclePushRegistration(enabled: false) + let suiteName = "push-coordinator-late-authorization-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + var authorization = MobilePushAuthorization.notDetermined + var registrationRequests = 0 + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + notificationSettings: { + .authorizationOnly(authorization) + }, + requestAuthorization: { + await authorizationGate.pause() + authorization = .authorized + return true + }, + registerForRemoteNotifications: { registrationRequests += 1 }, + settingsMutationSleep: { _ in + await timeoutGate.pause() + } + ) + + coordinator.setEnabledIntent(true) + await authorizationGate.waitUntilStarted() + await timeoutGate.waitUntilStarted() + await timeoutGate.release() + for _ in 0..<100 { + if coordinator.registrationSnapshot.backendState + == .failed(.networkUnavailable) { + break + } + await Task.yield() + } + #expect( + coordinator.registrationSnapshot.backendState + == .failed(.networkUnavailable) + ) + + await authorizationGate.release() + for _ in 0..<100 { + if registrationRequests == 1 { break } + await Task.yield() + } + #expect(registrationRequests == 1) + for _ in 0..<100 { + if await registration.snapshot.isEnabled { break } + await Task.yield() + } + #expect(await registration.snapshot.isEnabled) + } + + @MainActor + @Test func supersedingSettingsIntentCancelsMutationWorkers() async { + let authorizationGate = LifecycleSyncGate() + let timeoutGate = LifecycleSyncGate() + let registration = LifecyclePushRegistration(enabled: false) + let suiteName = "push-coordinator-cancel-workers-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let cancellationRecorder = LifecycleCancellationRecorder() + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + authorizationStatus: { .notDetermined }, + requestAuthorization: { + await authorizationGate.pause() + await cancellationRecorder.recordAuthorizationCancellation( + Task.isCancelled + ) + return true + }, + settingsMutationSleep: { _ in + await timeoutGate.pause() + await cancellationRecorder.recordTimeoutCancellation( + Task.isCancelled + ) + } + ) + + coordinator.setEnabledIntent(true) + await authorizationGate.waitUntilStarted() + await timeoutGate.waitUntilStarted() + coordinator.setEnabledIntent(false) + await authorizationGate.release() + await timeoutGate.release() + for _ in 0..<100 { + if !coordinator.isEnabled, await registration.snapshot == .disabled { + break + } + await Task.yield() + } + + #expect(await cancellationRecorder.didCancelAuthorization) + #expect(await cancellationRecorder.didCancelTimeout) + #expect(!coordinator.isEnabled) + #expect(await registration.snapshot == .disabled) + } + @MainActor @Test func deniedReenableSupersedesInFlightDisable() async { let disableGate = LifecycleSetEnabledGate() From 72d7ee68b4a28717a90936bb5e046b39b6e06088 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:02:17 -0700 Subject: [PATCH 055/117] fix(push): reconcile late authorization and cancel workers --- .../MobilePushCoordinator.swift | 36 ++++++++++++++++++- .../MobilePushMutationOutcome.swift | 1 + 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index f993d077aa8..c05f4bfb6d4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -125,6 +125,13 @@ public final class MobilePushCoordinator { /// state, and a newer intent cancels the coordinator work without waiting /// for the old task to unwind. @ObservationIgnored private var settingsMutationTask: Task? + private struct SettingsMutationWorkers { + let operation: Task + let timeout: Task + let completion: MobilePushMutationCompletion + } + @ObservationIgnored private var settingsMutationWorkers: + SettingsMutationWorkers? @ObservationIgnored private var settingsMutationToken = UUID() @ObservationIgnored private var settingsMutationNeedsRetry = false @ObservationIgnored private var registrationIntentGeneration: UInt64 = 0 @@ -267,7 +274,21 @@ public final class MobilePushCoordinator { // The mutation completed first and cancelled this sleeper. } } - let outcome = await completion.wait() + settingsMutationWorkers = SettingsMutationWorkers( + operation: operationTask, + timeout: timeoutTask, + completion: completion + ) + let outcome = await withTaskCancellationHandler { + await completion.wait() + } onCancel: { + operationTask.cancel() + timeoutTask.cancel() + Task { await completion.resolve(.cancelled) } + } + if settingsMutationWorkers?.completion === completion { + settingsMutationWorkers = nil + } timeoutTask.cancel() guard outcome == .timedOut else { return } operationTask.cancel() @@ -477,6 +498,13 @@ public final class MobilePushCoordinator { } guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { + // A system authorization prompt is user interaction and may outlive + // the reconciliation deadline. If it eventually grants after that + // deadline, start a fresh, current-generation reconciliation rather + // than leaving the persisted opt-in without a service mutation. + if enabledMirror, settingsMutationNeedsRetry { + setEnabledIntent(true) + } return false } guard granted else { @@ -539,6 +567,12 @@ public final class MobilePushCoordinator { private func cancelSettingsMutation() { settingsMutationTask?.cancel() settingsMutationTask = nil + settingsMutationWorkers?.operation.cancel() + settingsMutationWorkers?.timeout.cancel() + if let completion = settingsMutationWorkers?.completion { + Task { await completion.resolve(.cancelled) } + } + settingsMutationWorkers = nil settingsMutationToken = UUID() } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift index 4d07d1a5cfb..22e246a8aec 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift @@ -2,4 +2,5 @@ enum MobilePushMutationOutcome: Sendable, Equatable { case completed case timedOut + case cancelled } From 7c6ef3c047d9ff93f61af84339ddc28c0beed850 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:05:23 -0700 Subject: [PATCH 056/117] test(push): isolate mutation timeout generations --- .../MobilePushCoordinatorLifecycleTests.swift | 45 +++++++++++++------ 1 file changed, 32 insertions(+), 13 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 0e22a832a9a..948f5f9ddef 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -187,18 +187,34 @@ private actor LifecycleSetEnabledGate { private actor LifecycleCancellationRecorder { private var authorizationCancelled = false - private var timeoutCancelled = false func recordAuthorizationCancellation(_ cancelled: Bool) { authorizationCancelled = cancelled } - func recordTimeoutCancellation(_ cancelled: Bool) { - timeoutCancelled = cancelled + var didCancelAuthorization: Bool { authorizationCancelled } +} + +private actor LifecycleSettingsMutationSleeper { + private let firstGate: LifecycleSyncGate + private var invocationCount = 0 + private var firstSleepCancelled = false + + init(firstGate: LifecycleSyncGate) { + self.firstGate = firstGate } - var didCancelAuthorization: Bool { authorizationCancelled } - var didCancelTimeout: Bool { timeoutCancelled } + func sleep(for duration: Duration) async throws { + invocationCount += 1 + if invocationCount == 1 { + await firstGate.pause() + firstSleepCancelled = Task.isCancelled + return + } + try await ContinuousClock().sleep(for: duration) + } + + var didCancelFirstSleep: Bool { firstSleepCancelled } } private actor LifecycleSyncGate { @@ -866,6 +882,9 @@ private final class LifecyclePushURLProtocol: URLProtocol, @Test func lateAuthorizationAfterTimeoutStartsFreshReconciliation() async { let authorizationGate = LifecycleSyncGate() let timeoutGate = LifecycleSyncGate() + let timeoutSleeper = LifecycleSettingsMutationSleeper( + firstGate: timeoutGate + ) let registration = LifecyclePushRegistration(enabled: false) let suiteName = "push-coordinator-late-authorization-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! @@ -884,8 +903,8 @@ private final class LifecyclePushURLProtocol: URLProtocol, return true }, registerForRemoteNotifications: { registrationRequests += 1 }, - settingsMutationSleep: { _ in - await timeoutGate.pause() + settingsMutationSleep: { duration in + try await timeoutSleeper.sleep(for: duration) } ) @@ -922,6 +941,9 @@ private final class LifecyclePushURLProtocol: URLProtocol, @Test func supersedingSettingsIntentCancelsMutationWorkers() async { let authorizationGate = LifecycleSyncGate() let timeoutGate = LifecycleSyncGate() + let timeoutSleeper = LifecycleSettingsMutationSleeper( + firstGate: timeoutGate + ) let registration = LifecyclePushRegistration(enabled: false) let suiteName = "push-coordinator-cancel-workers-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! @@ -938,11 +960,8 @@ private final class LifecyclePushURLProtocol: URLProtocol, ) return true }, - settingsMutationSleep: { _ in - await timeoutGate.pause() - await cancellationRecorder.recordTimeoutCancellation( - Task.isCancelled - ) + settingsMutationSleep: { duration in + try await timeoutSleeper.sleep(for: duration) } ) @@ -960,7 +979,7 @@ private final class LifecyclePushURLProtocol: URLProtocol, } #expect(await cancellationRecorder.didCancelAuthorization) - #expect(await cancellationRecorder.didCancelTimeout) + #expect(await timeoutSleeper.didCancelFirstSleep) #expect(!coordinator.isEnabled) #expect(await registration.snapshot == .disabled) } From f1582d5c35c915884a1c9012859fa300a8ed5b9d Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:21:56 -0700 Subject: [PATCH 057/117] test(push): preserve queued sign-out cleanup on cancellation --- .../PushRegistrationServiceTests.swift | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index d1ea0672524..11a538db937 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -83,6 +83,8 @@ struct FakeTokenProvider: TokenProviding { actor MutablePushTokenProvider: TokenProviding { private var value: AuthenticatedSessionSnapshot? + private var snapshotBlocker: TestContinuationBlocker? + private var snapshotStarted: TestPhaseSignal? init( accountID: String, @@ -115,8 +117,23 @@ actor MutablePushTokenProvider: TokenProviding { value = nil } + func blockAuthenticatedSessionSnapshot( + started: TestPhaseSignal, + until blocker: TestContinuationBlocker + ) { + snapshotStarted = started + snapshotBlocker = blocker + } + func authenticatedSessionSnapshot() async throws -> AuthenticatedSessionSnapshot { + if let blocker = snapshotBlocker { + snapshotBlocker = nil + let started = snapshotStarted + snapshotStarted = nil + await started?.markStarted() + await blocker.wait() + } guard let value else { throw AuthError.unauthorized } return value } @@ -363,6 +380,54 @@ actor RetryDelayRecorder { ) } + @Test func cancelledQueuedSignOutStillPersistsCleanupObligation() async { + await PushRegistrationURLProtocol.script.reset([]) + let provider = MutablePushTokenProvider( + accountID: "account-a", + accessToken: "a-access", + refreshToken: "a-refresh" + ) + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + await provider.blockAuthenticatedSessionSnapshot( + started: started, + until: blocker + ) + let (service, defaults) = makeScriptedService( + tokenProvider: provider, + accountID: nil + ) + defaults.set("ab", forKey: "cmux.notifications.deviceTokenHex") + + let heldMutation = Task { + await service.unregisterFromServer() + } + await started.waitUntilStarted() + await provider.clearSession() + + let queuedSignOut = Task { + await service.unregisterFromServer( + accountID: "account-a", + accessToken: "captured-access", + refreshToken: "captured-refresh" + ) + } + queuedSignOut.cancel() + // Cancellation must remove the waiter while the first mutation still + // owns the intent gate. If it were allowed to wait for the gate, this + // await would deadlock until the blocker below is released. + await queuedSignOut.value + + await blocker.release() + await heldMutation.value + + let queueText = defaults.data( + forKey: "cmux.notifications.pendingUnregisters.v2" + ).flatMap { String(data: $0, encoding: .utf8) } + #expect(queueText?.contains("account-a") == true) + #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) + } + @Test func signOutNeverUsesCapturedAccountBToDeleteRegisteredAccountA() async { await PushRegistrationURLProtocol.script.reset([.response(200)]) let suite = "push-signout-owner-mismatch-\(UUID().uuidString)" From 84bd0cc51f8521aca6e4b209b1d9541330fd77f3 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:24:48 -0700 Subject: [PATCH 058/117] fix(push): persist sign-out cleanup before mutation gate --- .../Push/PushRegistrationService.swift | 17 +++++ .../MobilePushCoordinator.swift | 9 +-- .../MobilePushMutationWorkers.swift | 10 +++ .../LifecycleCancellationRecorder.swift | 11 +++ .../LifecycleSettingsMutationSleeper.swift | 23 +++++++ .../LifecycleSyncGate.swift | 37 ++++++++++ .../MobilePushCoordinatorLifecycleTests.swift | 68 ------------------- 7 files changed, 100 insertions(+), 75 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleCancellationRecorder.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSettingsMutationSleeper.swift create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 5f7d8091c48..5f001ba60e5 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -357,6 +357,7 @@ public actor PushRegistrationService: PushRegistering { /// Durably schedules and attempts removal of the currently owned token. public func unregisterFromServer() async { + persistCapturedUnregisterObligation(accountID: nil) await intentGate.withLock { [self] in await self.unregisterFromServerUnlocked() } @@ -394,6 +395,7 @@ public actor PushRegistrationService: PushRegistering { /// - accessToken: The captured (or teardown-minted) access token. /// - refreshToken: The captured refresh token. public func unregisterFromServer(accessToken: String?, refreshToken: String?) async { + persistCapturedUnregisterObligation(accountID: nil) await intentGate.withLock { [self] in await self.unregisterFromServerUnlocked( accountID: nil, @@ -409,6 +411,7 @@ public actor PushRegistrationService: PushRegistering { accessToken: String?, refreshToken: String? ) async { + persistCapturedUnregisterObligation(accountID: capturedAccountID) await intentGate.withLock { [self] in await self.unregisterFromServerUnlocked( accountID: capturedAccountID, @@ -418,6 +421,20 @@ public actor PushRegistrationService: PushRegistering { } } + /// Records the cleanup obligation before waiting on the mutation gate. + /// Sign-out callers are commonly canceled while an earlier registration is + /// still in flight; the durable tombstone must not depend on admission to + /// that cancellable queue. + private func persistCapturedUnregisterObligation(accountID: String?) { + guard let hex = cachedTokenHex else { return } + let registeredOwnerID = defaults.string( + forKey: Self.registeredAccountIDKey + ) + let ownerID = registeredOwnerID ?? accountID + guard let ownerID, !ownerID.isEmpty else { return } + persistPendingUnregister(tokenHex: hex, accountID: ownerID) + } + private func unregisterFromServerUnlocked( accountID capturedAccountID: String?, accessToken: String?, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index c05f4bfb6d4..de98a1f3f0a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -125,13 +125,8 @@ public final class MobilePushCoordinator { /// state, and a newer intent cancels the coordinator work without waiting /// for the old task to unwind. @ObservationIgnored private var settingsMutationTask: Task? - private struct SettingsMutationWorkers { - let operation: Task - let timeout: Task - let completion: MobilePushMutationCompletion - } @ObservationIgnored private var settingsMutationWorkers: - SettingsMutationWorkers? + MobilePushMutationWorkers? @ObservationIgnored private var settingsMutationToken = UUID() @ObservationIgnored private var settingsMutationNeedsRetry = false @ObservationIgnored private var registrationIntentGeneration: UInt64 = 0 @@ -274,7 +269,7 @@ public final class MobilePushCoordinator { // The mutation completed first and cancelled this sleeper. } } - settingsMutationWorkers = SettingsMutationWorkers( + settingsMutationWorkers = MobilePushMutationWorkers( operation: operationTask, timeout: timeoutTask, completion: completion diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift new file mode 100644 index 00000000000..ef31443ed43 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Keeps the app-lifetime settings operation, timeout, and completion together +/// so a superseding intent can cancel every worker that belongs to one +/// mutation. +struct MobilePushMutationWorkers { + let operation: Task + let timeout: Task + let completion: MobilePushMutationCompletion +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleCancellationRecorder.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleCancellationRecorder.swift new file mode 100644 index 00000000000..82695542a84 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleCancellationRecorder.swift @@ -0,0 +1,11 @@ +import Foundation + +actor LifecycleCancellationRecorder { + private var authorizationCancelled = false + + func recordAuthorizationCancellation(_ cancelled: Bool) { + authorizationCancelled = cancelled + } + + var didCancelAuthorization: Bool { authorizationCancelled } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSettingsMutationSleeper.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSettingsMutationSleeper.swift new file mode 100644 index 00000000000..7610ef7409f --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSettingsMutationSleeper.swift @@ -0,0 +1,23 @@ +import Foundation + +actor LifecycleSettingsMutationSleeper { + private let firstGate: LifecycleSyncGate + private var invocationCount = 0 + private var firstSleepCancelled = false + + init(firstGate: LifecycleSyncGate) { + self.firstGate = firstGate + } + + func sleep(for duration: Duration) async throws { + invocationCount += 1 + if invocationCount == 1 { + await firstGate.pause() + firstSleepCancelled = Task.isCancelled + return + } + try await ContinuousClock().sleep(for: duration) + } + + var didCancelFirstSleep: Bool { firstSleepCancelled } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift new file mode 100644 index 00000000000..8133d68c040 --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift @@ -0,0 +1,37 @@ +import Foundation + +actor LifecycleSyncGate { + private(set) var starts = 0 + private var released = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func pause() async { + starts += 1 + let waiters = startWaiters + startWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + guard !released else { return } + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) + } + } + + func waitUntilStarted() async { + guard starts == 0 else { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) + } + } + + func release() { + released = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } +} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 948f5f9ddef..c57b7988e98 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -185,74 +185,6 @@ private actor LifecycleSetEnabledGate { } } -private actor LifecycleCancellationRecorder { - private var authorizationCancelled = false - - func recordAuthorizationCancellation(_ cancelled: Bool) { - authorizationCancelled = cancelled - } - - var didCancelAuthorization: Bool { authorizationCancelled } -} - -private actor LifecycleSettingsMutationSleeper { - private let firstGate: LifecycleSyncGate - private var invocationCount = 0 - private var firstSleepCancelled = false - - init(firstGate: LifecycleSyncGate) { - self.firstGate = firstGate - } - - func sleep(for duration: Duration) async throws { - invocationCount += 1 - if invocationCount == 1 { - await firstGate.pause() - firstSleepCancelled = Task.isCancelled - return - } - try await ContinuousClock().sleep(for: duration) - } - - var didCancelFirstSleep: Bool { firstSleepCancelled } -} - -private actor LifecycleSyncGate { - private(set) var starts = 0 - private var released = false - private var startWaiters: [CheckedContinuation] = [] - private var releaseWaiters: [CheckedContinuation] = [] - - func pause() async { - starts += 1 - let waiters = startWaiters - startWaiters.removeAll() - for waiter in waiters { - waiter.resume() - } - guard !released else { return } - await withCheckedContinuation { continuation in - releaseWaiters.append(continuation) - } - } - - func waitUntilStarted() async { - guard starts == 0 else { return } - await withCheckedContinuation { continuation in - startWaiters.append(continuation) - } - } - - func release() { - released = true - let waiters = releaseWaiters - releaseWaiters.removeAll() - for waiter in waiters { - waiter.resume() - } - } -} - private struct LifecycleTokenProvider: TokenProviding { private let session = AuthenticatedSessionSnapshot( generation: 1, From 62a72a7f3cc6061ded89721156f361a8c9bb63a6 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:37:27 -0700 Subject: [PATCH 059/117] test(push): cover stale registration and public enable timeout --- .../PushRegistrationServiceTests.swift | 57 +++++++++++++++++++ .../MobilePushCoordinatorLifecycleTests.swift | 47 +++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 11a538db937..7aef9f3d745 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -85,6 +85,7 @@ actor MutablePushTokenProvider: TokenProviding { private var value: AuthenticatedSessionSnapshot? private var snapshotBlocker: TestContinuationBlocker? private var snapshotStarted: TestPhaseSignal? + private var nextSnapshotSignal: TestPhaseSignal? init( accountID: String, @@ -125,8 +126,15 @@ actor MutablePushTokenProvider: TokenProviding { snapshotBlocker = blocker } + func signalNextAuthenticatedSessionSnapshot(_ signal: TestPhaseSignal) { + nextSnapshotSignal = signal + } + func authenticatedSessionSnapshot() async throws -> AuthenticatedSessionSnapshot { + let nextSnapshotSignal = self.nextSnapshotSignal + self.nextSnapshotSignal = nil + await nextSnapshotSignal?.markStarted() if let blocker = snapshotBlocker { snapshotBlocker = nil let started = snapshotStarted @@ -980,6 +988,55 @@ actor RetryDelayRecorder { ) } + @Test func directTokenRegistrationCannotPostAfterOptOut() async { + await PushRegistrationURLProtocol.script.reset([ + .response(200), + .response(200), + ]) + let provider = MutablePushTokenProvider( + accountID: "account-a", + accessToken: "a-access", + refreshToken: "a-refresh" + ) + let authorizationStarted = TestPhaseSignal() + let authorizationBlocker = TestContinuationBlocker() + await provider.blockAuthenticatedSessionSnapshot( + started: authorizationStarted, + until: authorizationBlocker + ) + let (service, defaults) = makeScriptedService( + tokenProvider: provider, + accountID: nil, + seedDefaults: { defaults in + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + } + ) + + let registration = Task { + await service.register(deviceToken: Data([0xAA])) + } + await authorizationStarted.waitUntilStarted() + + let disableStarted = TestPhaseSignal() + await provider.signalNextAuthenticatedSessionSnapshot(disableStarted) + let disable = Task { + await service.setEnabled(false) + } + for _ in 0..<100 where !(await disableStarted.didStart) { + await Task.yield() + } + + await authorizationBlocker.release() + await registration.value + await disable.value + + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["POST", "DELETE"] + ) + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) + } + @Test func inFlightRegistrationPersistsCleanupOwnerBeforePostCompletes() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index c57b7988e98..66c352b2510 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -810,6 +810,53 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(await registration.snapshot.isEnabled) } + @MainActor + @Test func publicEnableUsesSettingsMutationTimeout() async { + let settingsGate = LifecycleSyncGate() + let timeoutGate = LifecycleSyncGate() + let timeoutSleeper = LifecycleSettingsMutationSleeper( + firstGate: timeoutGate + ) + let registration = LifecyclePushRegistration(enabled: false) + let suiteName = "push-coordinator-public-enable-timeout-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + notificationSettings: { + await settingsGate.pause() + return .authorizationOnly(.authorized) + }, + settingsMutationSleep: { duration in + try await timeoutSleeper.sleep(for: duration) + } + ) + + let enabling = Task { await coordinator.enable() } + await settingsGate.waitUntilStarted() + for _ in 0..<100 where await timeoutGate.starts == 0 { + await Task.yield() + } + #expect(await timeoutGate.starts == 1) + + await timeoutGate.release() + for _ in 0..<100 { + if coordinator.registrationSnapshot.backendState + == .failed(.networkUnavailable) { + break + } + await Task.yield() + } + #expect( + coordinator.registrationSnapshot.backendState + == .failed(.networkUnavailable) + ) + + await settingsGate.release() + #expect(!(await enabling.value)) + } + @MainActor @Test func lateAuthorizationAfterTimeoutStartsFreshReconciliation() async { let authorizationGate = LifecycleSyncGate() From 197fb04942efd02185ec53a045f739cc1006b690 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:44:11 -0700 Subject: [PATCH 060/117] fix(push): fence direct registration commits and bound enables --- .../Push/PushRegistrationService.swift | 26 +++- .../PushRegistrationServiceTests.swift | 7 +- .../MobilePushCoordinator.swift | 136 +++++++++++------- .../MobilePushMutationCompletion.swift | 27 ++-- .../MobilePushMutationResult.swift | 6 + 5 files changed, 139 insertions(+), 63 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationResult.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 5f001ba60e5..0e526e444c4 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -538,7 +538,11 @@ public actor PushRegistrationService: PushRegistering { accountID: requestSession.accountID ) } - result = await performRegistration(context.request) + result = await performRegistration( + context.request, + tokenHex: tokenHex, + generation: generation + ) case let .failure(failure): requestSession = nil result = .failure(failure, retryAfter: nil) @@ -792,12 +796,28 @@ public actor PushRegistrationService: PushRegistering { )) } - private func performRegistration(_ request: URLRequest) async -> RegistrationResult { + private func performRegistration( + _ request: URLRequest, + tokenHex: String, + generation: UUID + ) async -> RegistrationResult { await networkMutationGate.withLock { [self] in - await self.performRegistrationRequest(request) + guard await self.isCurrentUpload( + tokenHex: tokenHex, + generation: generation + ) else { + return .cancelled + } + return await self.performRegistrationRequest(request) } ?? .cancelled } + private func isCurrentUpload(tokenHex: String, generation: UUID) -> Bool { + isEnabled + && generation == operationGeneration + && cachedTokenHex == tokenHex + } + private func performRegistrationRequest(_ request: URLRequest) async -> RegistrationResult { let redirectDelegate = RedirectMethodPreservingDelegate() do { diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 7aef9f3d745..6e8965a84d8 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1030,9 +1030,12 @@ actor RetryDelayRecorder { await registration.value await disable.value + let methods = await PushRegistrationURLProtocol.script.requests + .compactMap(\.httpMethod) + let postIndex = methods.firstIndex(of: "POST") + let deleteIndex = methods.firstIndex(of: "DELETE") #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["POST", "DELETE"] + postIndex == nil || (deleteIndex != nil && postIndex! < deleteIndex!) ) #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index de98a1f3f0a..744b4bb3763 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -225,41 +225,55 @@ public final class MobilePushCoordinator { return } let intent = beginSettingsIntent(enabled) - settingsMutationTask = Task { @MainActor [weak self] in - guard let self else { return } - await self.runSettingsMutation( - token: intent.token, - operation: { [weak self] in - guard let self else { return } - if enabled { - _ = await self.enable( - trigger: "settings_toggle", - settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration - ) - } else { - await self.finishDisable( - settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration - ) - } - self.finishSettingsMutation(intent.token) - } + _ = startSettingsMutation(token: intent.token) { [weak self] in + guard let self else { return false } + if enabled { + return await self.enable( + trigger: "settings_toggle", + settingsMutationToken: intent.token, + registrationGeneration: intent.registrationGeneration + ) + } + await self.finishDisable( + settingsMutationToken: intent.token, + registrationGeneration: intent.registrationGeneration ) + return self.isCurrentSettingsMutation(intent.token) } } + /// Starts the one app-lifetime worker used by every settings/reconciliation + /// entry point. The returned task is independent from the caller's waiter; + /// a newer intent cancels it through `settingsMutationTask`. + @discardableResult + private func startSettingsMutation( + token: UUID, + operation: @escaping @MainActor () async -> Bool + ) -> Task { + let task = Task { @MainActor [weak self] in + guard let self else { return false } + let result = await self.runSettingsMutation( + token: token, + operation: operation + ) + self.finishSettingsMutation(token) + return result + } + settingsMutationTask = task + return task + } + /// Runs a settings mutation with an independent deadline. The operation /// remains app-lifetime work until the deadline, while a timed-out waiter /// is cancelled and the coordinator immediately exposes a retryable state. private func runSettingsMutation( token: UUID, - operation: @escaping @MainActor () async -> Void - ) async { + operation: @escaping @MainActor () async -> Bool + ) async -> Bool { let completion = MobilePushMutationCompletion() let operationTask = Task { @MainActor in - await operation() - await completion.resolve(.completed) + let succeeded = await operation() + await completion.resolve(.completed, succeeded: succeeded) } let timeoutTask = Task { [settingsMutationSleep] in do { @@ -274,7 +288,7 @@ public final class MobilePushCoordinator { timeout: timeoutTask, completion: completion ) - let outcome = await withTaskCancellationHandler { + let result = await withTaskCancellationHandler { await completion.wait() } onCancel: { operationTask.cancel() @@ -285,9 +299,12 @@ public final class MobilePushCoordinator { settingsMutationWorkers = nil } timeoutTask.cancel() - guard outcome == .timedOut else { return } + guard result.outcome == .timedOut else { + return result.outcome == .completed && result.succeeded + } operationTask.cancel() handleSettingsMutationTimeout(token) + return false } private func handleSettingsMutationTimeout(_ token: UUID) { @@ -398,10 +415,7 @@ public final class MobilePushCoordinator { @discardableResult public func enable() async -> Bool { let intent = beginSettingsIntent(true) - // Keep the reconciliation worker independent from the caller. A view - // or lifecycle task may be cancelled after the preference is committed; - // only a newer intent token is allowed to supersede this work. - let operation = Task { @MainActor [weak self] in + let operation = startSettingsMutation(token: intent.token) { [weak self] in guard let self else { return false } return await self.enable( trigger: "settings_toggle", @@ -419,39 +433,65 @@ public final class MobilePushCoordinator { // workspace lifecycle reconcile an older persisted value while its // backend mutation is still draining. guard settingsMutationTask == nil else { return } - let intentToken = settingsMutationToken - let intentGeneration = registrationIntentGeneration if defaults.object(forKey: Self.enabledKey) as? Bool == false { return } + let intentToken = settingsMutationToken + let intentGeneration = registrationIntentGeneration + let operation = startSettingsMutation(token: intentToken) { [weak self] in + guard let self else { return false } + return await self.reconcileWorkspaceListDidBecomeVisible( + settingsMutationToken: intentToken, + registrationGeneration: intentGeneration + ) + } + _ = await operation.value + } + + private func reconcileWorkspaceListDidBecomeVisible( + settingsMutationToken: UUID, + registrationGeneration: UInt64 + ) async -> Bool { let settings = await notificationSettings() - guard isCurrentSettingsMutation(intentToken) else { return } + guard isCurrentSettingsMutation(settingsMutationToken) else { + return false + } apply(settings: settings) switch settings.authorization { case .authorized, .provisional, .ephemeral: - guard isCurrentSettingsMutation(intentToken) else { return } + guard isCurrentSettingsMutation(settingsMutationToken) else { + return false + } persistEnabledIntent() await activateRegistrationIfNeeded( - settingsMutationToken: intentToken, - registrationGeneration: intentGeneration + settingsMutationToken: settingsMutationToken, + registrationGeneration: registrationGeneration + ) + await recoverRegistrationIfNeeded( + settingsMutationToken: settingsMutationToken ) - await recoverRegistrationIfNeeded(settingsMutationToken: intentToken) + return isCurrentSettingsMutation(settingsMutationToken) case .denied: // Preserve intent so Settings can explain the blocked OS gate and // a later foreground return can recover without another app launch. - guard isCurrentSettingsMutation(intentToken) else { return } + guard isCurrentSettingsMutation(settingsMutationToken) else { + return false + } persistEnabledIntent() + return true case .notDetermined: - guard !workspaceAuthorizationRequestInFlight else { return } + guard !workspaceAuthorizationRequestInFlight else { + return false + } workspaceAuthorizationRequestInFlight = true defer { workspaceAuthorizationRequestInFlight = false } - _ = await enable( + return await enable( trigger: "workspace_list", - settingsMutationToken: intentToken, - registrationGeneration: intentGeneration + settingsMutationToken: settingsMutationToken, + registrationGeneration: registrationGeneration ) case .unsupported: - break + return true } } @@ -548,15 +588,15 @@ public final class MobilePushCoordinator { /// Opt out: stop receiving pushes and remove the token server-side. public func disable() async { let intent = beginSettingsIntent(false) - // As with enable(), caller cancellation must not strand the durable - // server-side cleanup after the local opt-out has become visible. - let operation = Task { @MainActor [weak self] in - await self?.finishDisable( + let operation = startSettingsMutation(token: intent.token) { [weak self] in + guard let self else { return false } + await self.finishDisable( settingsMutationToken: intent.token, registrationGeneration: intent.registrationGeneration ) + return self.isCurrentSettingsMutation(intent.token) } - await operation.value + _ = await operation.value } private func cancelSettingsMutation() { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift index aa873b5eda8..9377b0141a1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift @@ -2,24 +2,31 @@ import Foundation /// Resolves the first terminal result of an app-lifetime push mutation. actor MobilePushMutationCompletion { - private var outcome: MobilePushMutationOutcome? - private var waiters: [CheckedContinuation] = [] + private var result: MobilePushMutationResult? + private var waiters: [CheckedContinuation] = [] - func resolve(_ outcome: MobilePushMutationOutcome) { - guard self.outcome == nil else { return } - self.outcome = outcome + func resolve( + _ outcome: MobilePushMutationOutcome, + succeeded: Bool = false + ) { + guard result == nil else { return } + let resolved = MobilePushMutationResult( + outcome: outcome, + succeeded: succeeded + ) + result = resolved let waiters = self.waiters self.waiters.removeAll() for waiter in waiters { - waiter.resume(returning: outcome) + waiter.resume(returning: resolved) } } - func wait() async -> MobilePushMutationOutcome { - if let outcome { return outcome } + func wait() async -> MobilePushMutationResult { + if let result { return result } return await withCheckedContinuation { continuation in - if let outcome { - continuation.resume(returning: outcome) + if let result { + continuation.resume(returning: result) } else { waiters.append(continuation) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationResult.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationResult.swift new file mode 100644 index 00000000000..f3572fb8b1a --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationResult.swift @@ -0,0 +1,6 @@ +import Foundation + +struct MobilePushMutationResult: Sendable, Equatable { + let outcome: MobilePushMutationOutcome + let succeeded: Bool +} From 015f35393b330fdfd8fbe164c98b522e7aa73343 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:52:18 -0700 Subject: [PATCH 061/117] test(push): fence opt-out cleanup to persisted owner --- .../PushRegistrationServiceTests.swift | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 6e8965a84d8..ddd8b3c5280 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -904,6 +904,37 @@ actor RetryDelayRecorder { #expect(queueText?.contains("account-b") == false) } + @Test func pendingOwnerWinsWhenRegisteredOwnerMetadataIsMissing() async { + await PushRegistrationURLProtocol.script.reset([.response(200)]) + let suite = "push-optout-pending-owner-\(UUID().uuidString)" + let (service, defaults) = makeScriptedService( + tokenProvider: FakeTokenProvider( + access: "b-access", + refresh: "b-refresh" + ), + suite: suite, + accountID: "account-b" + ) + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + defaults.set( + try? JSONEncoder().encode([[ + "tokenHex": "aa", + "accountID": "account-a", + ]]), + forKey: "cmux.notifications.pendingUnregisters.v2" + ) + + await service.setEnabled(false) + + #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) + let queueText = defaults.data( + forKey: "cmux.notifications.pendingUnregisters.v2" + ).flatMap { String(data: $0, encoding: .utf8) } + #expect(queueText?.contains("account-a") == true) + #expect(queueText?.contains("account-b") == false) + } + @Test func malformedDeleteAcknowledgementKeepsDurableTombstone() async { await PushRegistrationURLProtocol.script.reset([ .response(200, json: ""), From a441e85c73c1ace8fbe5fe2f1ac838053236a191 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:54:15 -0700 Subject: [PATCH 062/117] fix(push): require persisted owner for opt-out cleanup --- .../Push/PushRegistrationService.swift | 60 ++++++++++++------- 1 file changed, 38 insertions(+), 22 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 0e526e444c4..bc4c02befd5 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -157,8 +157,8 @@ public actor PushRegistrationService: PushRegistering { /// Disables local delivery and removes the owned token from the server. /// /// The caller may persist the user's opt-out before invoking this method; - /// cleanup therefore uses the registered owner or live session rather than - /// the now-false preference to decide whether a delete is required. + /// cleanup therefore uses the persisted registration owner rather than the + /// now-false preference to decide whether a delete is required. public func disableAndUnregister() async { invalidateCoordinatorIntents() await submitPreferenceIntent( @@ -265,7 +265,7 @@ public actor PushRegistrationService: PushRegistering { cancelRetry() defaults.set(false, forKey: Self.enabledKey) publish(.disabled) - await unregisterFromServerUnlocked(requireKnownOwner: false) + await unregisterFromServerUnlocked() publish(.disabled) } @@ -363,18 +363,18 @@ public actor PushRegistrationService: PushRegistering { } } - private func unregisterFromServerUnlocked( - requireKnownOwner: Bool = false - ) async { + private func unregisterFromServerUnlocked() async { cancelRetry() guard let hex = cachedTokenHex else { return } + // A live session identifies who is signed in now, not who owns this + // token. During an account switch those can differ, so fail closed + // unless the registration owner is persisted in either the owner + // marker or a durable cleanup obligation. + guard let ownerID = persistedOwnerID(for: hex) else { + pushLog.info("Skipping push-token unregister: persisted owner unavailable") + return + } let session = try? await tokenProvider.authenticatedSessionSnapshot() - let registeredOwnerID = defaults.string( - forKey: Self.registeredAccountIDKey - ) - let ownerID = registeredOwnerID - ?? (requireKnownOwner ? nil : session?.accountID) - guard let ownerID, !ownerID.isEmpty else { return } // Persist before requiring live auth. This is the privacy guarantee for // an offline or signed-out opt-out. persistPendingUnregister(tokenHex: hex, accountID: ownerID) @@ -427,10 +427,7 @@ public actor PushRegistrationService: PushRegistering { /// that cancellable queue. private func persistCapturedUnregisterObligation(accountID: String?) { guard let hex = cachedTokenHex else { return } - let registeredOwnerID = defaults.string( - forKey: Self.registeredAccountIDKey - ) - let ownerID = registeredOwnerID ?? accountID + let ownerID = persistedOwnerID(for: hex) ?? accountID guard let ownerID, !ownerID.isEmpty else { return } persistPendingUnregister(tokenHex: hex, accountID: ownerID) } @@ -442,18 +439,17 @@ public actor PushRegistrationService: PushRegistering { ) async { cancelRetry() guard let hex = cachedTokenHex else { return } - let registeredOwnerID = defaults.string( - forKey: Self.registeredAccountIDKey - ) - let ownerID = registeredOwnerID ?? capturedAccountID + let persistedOwner = persistedOwnerID(for: hex) + let ownerID = persistedOwner ?? capturedAccountID if let ownerID, !ownerID.isEmpty { // Persist the recovery record before validating credentials. // Offline sign-out commonly has only the refresh token, but a // later sign-in to this same account can safely finish the DELETE. persistPendingUnregister(tokenHex: hex, accountID: ownerID) } - if let registeredOwnerID, - capturedAccountID != registeredOwnerID { + if let persistedOwner, + let capturedAccountID, + capturedAccountID != persistedOwner { // The legacy overload has no account identity, and a caller // explicitly carrying B must never apply B's credentials to A's // acknowledged token. Keep A's tombstone until A returns. @@ -985,6 +981,26 @@ public actor PushRegistrationService: PushRegistering { return entries.filter { seen.insert($0).inserted } } + /// Returns the only owner that durable local state can prove for a token. + /// A current auth session is deliberately not an ownership proof because + /// it may already belong to the next account after sign-in races opt-out. + private func persistedOwnerID(for tokenHex: String) -> String? { + if let registeredOwnerID = defaults.string( + forKey: Self.registeredAccountIDKey + ), !registeredOwnerID.isEmpty { + return registeredOwnerID + } + let owners = Set( + pendingUnregisters.compactMap { pending in + guard pending.tokenHex == tokenHex, + !pending.accountID.isEmpty else { return nil } + return pending.accountID + } + ) + guard owners.count == 1 else { return nil } + return owners.first + } + private static func migrateLegacyPendingUnregisters( in defaults: UserDefaults ) { From eb29f93ebb2dba48a614525e04d8a892fb8d1e97 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:58:22 -0700 Subject: [PATCH 063/117] test(push): seed cleanup owner in queued sign-out --- .../CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index ddd8b3c5280..ed91a7fb481 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -406,6 +406,10 @@ actor RetryDelayRecorder { accountID: nil ) defaults.set("ab", forKey: "cmux.notifications.deviceTokenHex") + defaults.set( + "account-a", + forKey: "cmux.notifications.registeredAccountID" + ) let heldMutation = Task { await service.unregisterFromServer() From 8546b3dbd7a8193b5e2592018adbaff4fb60560a Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:59:22 -0700 Subject: [PATCH 064/117] fix(push): reject unproven legacy cleanup credentials --- .../Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index bc4c02befd5..7499692f8a2 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -448,7 +448,6 @@ public actor PushRegistrationService: PushRegistering { persistPendingUnregister(tokenHex: hex, accountID: ownerID) } if let persistedOwner, - let capturedAccountID, capturedAccountID != persistedOwner { // The legacy overload has no account identity, and a caller // explicitly carrying B must never apply B's credentials to A's From 75937e429767573a8d1d72cd0a557088e8c8c1a0 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:02:49 -0700 Subject: [PATCH 065/117] fix(push): store boolean settings mutation task --- .../Sources/CmuxMobileShellUI/MobilePushCoordinator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 744b4bb3763..7df9c4db52c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -124,7 +124,7 @@ public final class MobilePushCoordinator { /// network side effect. This task is app-lifetime state, not view-lifetime /// state, and a newer intent cancels the coordinator work without waiting /// for the old task to unwind. - @ObservationIgnored private var settingsMutationTask: Task? + @ObservationIgnored private var settingsMutationTask: Task? @ObservationIgnored private var settingsMutationWorkers: MobilePushMutationWorkers? @ObservationIgnored private var settingsMutationToken = UUID() From 87c0033eb31ecff8a6c44fd114907132b5c9f9a4 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:18:33 -0700 Subject: [PATCH 066/117] test(push): cover stalled intent and timeout worker ownership --- .../PushRegistrationServiceTests.swift | 50 ++++++++++++++++ .../MobilePushCoordinatorLifecycleTests.swift | 59 +++++++++++++++++++ 2 files changed, 109 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index ed91a7fb481..8154f48f952 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1075,6 +1075,56 @@ actor RetryDelayRecorder { #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) } + @Test func stalledEnablePreparationCannotBlockNewerOptOut() async { + await PushRegistrationURLProtocol.script.reset([.response(200)]) + let provider = MutablePushTokenProvider( + accountID: "account-a", + accessToken: "a-access", + refreshToken: "a-refresh" + ) + let enableStarted = TestPhaseSignal() + let enableBlocker = TestContinuationBlocker() + await provider.blockAuthenticatedSessionSnapshot( + started: enableStarted, + until: enableBlocker + ) + let (service, defaults) = makeScriptedService( + tokenProvider: provider, + accountID: nil + ) + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + defaults.set( + "account-a", + forKey: "cmux.notifications.registeredAccountID" + ) + + let enable = Task { + await service.applyEnabledIntent(true, generation: 1) + } + await enableStarted.waitUntilStarted() + + let disableFinished = TestPhaseSignal() + let disable = Task { + await service.applyEnabledIntent(false, generation: 2) + await disableFinished.markStarted() + } + for _ in 0..<100 where !(await disableFinished.didStart) { + await Task.yield() + } + + #expect(await disableFinished.didStart) + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["DELETE"] + ) + + await enableBlocker.release() + await enable.value + await disable.value + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) + } + @Test func inFlightRegistrationPersistsCleanupOwnerBeforePostCompletes() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 66c352b2510..cca3b266d56 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -857,6 +857,65 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(!(await enabling.value)) } + @MainActor + @Test func timedOutEnableIsDedupedAndCannotBlockOptOut() async { + let settingsGate = LifecycleSyncGate() + let timeoutGate = LifecycleSyncGate() + let timeoutSleeper = LifecycleSettingsMutationSleeper( + firstGate: timeoutGate + ) + let registration = LifecyclePushRegistration(enabled: false) + let suiteName = "push-coordinator-timeout-dedup-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + notificationSettings: { + await settingsGate.pause() + return .authorizationOnly(.authorized) + }, + settingsMutationSleep: { duration in + try await timeoutSleeper.sleep(for: duration) + } + ) + + coordinator.setEnabledIntent(true) + await settingsGate.waitUntilStarted() + await timeoutGate.waitUntilStarted() + await timeoutGate.release() + for _ in 0..<100 { + if coordinator.registrationSnapshot.backendState + == .failed(.networkUnavailable) { + break + } + await Task.yield() + } + + coordinator.setEnabledIntent(true) + for _ in 0..<100 { + if await settingsGate.starts > 1 { break } + await Task.yield() + } + #expect(await settingsGate.starts == 1) + + coordinator.setEnabledIntent(false) + for _ in 0..<100 { + if !coordinator.isEnabled, + await registration.snapshot == .disabled { + break + } + await Task.yield() + } + #expect(!coordinator.isEnabled) + #expect(await registration.snapshot == .disabled) + + await settingsGate.release() + for _ in 0..<100 { await Task.yield() } + #expect(!coordinator.isEnabled) + #expect(await registration.snapshot == .disabled) + } + @MainActor @Test func lateAuthorizationAfterTimeoutStartsFreshReconciliation() async { let authorizationGate = LifecycleSyncGate() From 25ba6fd6a6dc0eb9b6ca57a51cf8d7967c1c4cbc Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:27:09 -0700 Subject: [PATCH 067/117] fix(push): retain bounded mutation lanes until completion --- .../Push/PushRegistrationIntentQueue.swift | 91 +++++++---- .../Push/PushRegistrationService.swift | 114 +++++++++---- .../PushRegistrationServiceTests.swift | 19 ++- .../MobilePushCoordinator.swift | 154 ++++++++++-------- .../MobilePushMutationCompletion.swift | 36 +++- .../MobilePushMutationWorkers.swift | 1 + .../MobilePushCoordinatorLifecycleTests.swift | 1 - 7 files changed, 270 insertions(+), 146 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift index 3b496641d3e..e80b1c493b2 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift @@ -1,18 +1,31 @@ import Foundation -/// Runs one preference mutation at a time while replacing stale pending work. +/// Runs at most one enable and one disable preparation concurrently. /// -/// A committed network request cannot be canceled safely, but a preference -/// intent that has not started has no value after a newer toggle arrives. The -/// queue therefore keeps one in-flight operation, one latest pending intent, -/// and only the waiters for those live generations. +/// Authentication and other pre-request work is not guaranteed to cooperate +/// with task cancellation. A stalled enable must therefore remain owned while +/// a newer opt-out advances on the independent disable lane. Repeated intents +/// on either lane replace one pending value instead of accumulating tasks. actor PushRegistrationIntentQueue { + private enum Lane: Hashable { + case enable + case disable + + init(_ intent: PushRegistrationIntent) { + self = intent.enabled ? .enable : .disable + } + } + + private struct RunningIntent { + let generation: UInt64 + let task: Task + } + private let operation: @Sendable (PushRegistrationIntent) async -> Void private var latestGeneration: UInt64 = 0 - private var pendingIntent: PushRegistrationIntent? - private var runningGeneration: UInt64? + private var pendingIntents: [Lane: PushRegistrationIntent] = [:] + private var runningIntents: [Lane: RunningIntent] = [:] private var completedIntent: PushRegistrationIntent? - private var workerTask: Task? private var waiters: [UInt64: [UUID: CheckedContinuation]] = [:] /// Creates a queue that delegates each live intent to the registration service. @@ -23,26 +36,28 @@ actor PushRegistrationIntentQueue { /// Replaces stale pending work and waits for this intent to be handled. func submit(_ intent: PushRegistrationIntent) async { guard intent.generation >= latestGeneration else { return } + let lane = Lane(intent) if intent == completedIntent, - pendingIntent == nil, - runningGeneration == nil { + pendingIntents[lane] == nil, + runningIntents[lane] == nil { return } + if intent.generation > latestGeneration { latestGeneration = intent.generation - pendingIntent = intent + pendingIntents.removeAll() + pendingIntents[lane] = intent resumeWaiters(before: intent.generation) - } else if runningGeneration != intent.generation { - pendingIntent = intent + for running in runningIntents.values + where running.generation < intent.generation { + running.task.cancel() + } + } else if runningIntents[lane]?.generation != intent.generation { + pendingIntents[lane] = intent } let waiterID = UUID() - if workerTask == nil { - let operation = self.operation - workerTask = Task { [weak self] in - await self?.drain(operation: operation) - } - } + startPendingIntentIfNeeded(on: lane) await withTaskCancellationHandler(operation: { await withCheckedContinuation { continuation in if Task.isCancelled { @@ -59,18 +74,38 @@ actor PushRegistrationIntentQueue { }) } - private func drain( - operation: @escaping @Sendable (PushRegistrationIntent) async -> Void - ) async { - while let intent = pendingIntent { - pendingIntent = nil - runningGeneration = intent.generation + private func startPendingIntentIfNeeded(on lane: Lane) { + guard runningIntents[lane] == nil, + let intent = pendingIntents.removeValue(forKey: lane) + else { return } + let operation = self.operation + let task = Task { [weak self] in await operation(intent) - runningGeneration = nil + await self?.intentCompleted(intent, on: lane) + } + runningIntents[lane] = RunningIntent( + generation: intent.generation, + task: task + ) + } + + private func intentCompleted( + _ intent: PushRegistrationIntent, + on lane: Lane + ) { + guard runningIntents[lane]?.generation == intent.generation else { + return + } + runningIntents.removeValue(forKey: lane) + if let completedIntent { + if intent.generation >= completedIntent.generation { + self.completedIntent = intent + } + } else { completedIntent = intent - resumeWaiters(for: intent.generation) } - workerTask = nil + resumeWaiters(for: intent.generation) + startPendingIntentIfNeeded(on: lane) } private func resumeWaiters(before generation: UInt64) { diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 7499692f8a2..4d932cfabb9 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -25,10 +25,9 @@ public actor PushRegistrationService: PushRegistering { private let retryDelays: [Duration] private let retryJitter: @Sendable (ClosedRange) -> Double private let retrySleep: @Sendable (Duration) async throws -> Void - /// Settings/sign-out intents are ordered before any network suspension, so - /// a later toggle cannot enqueue cleanup behind an earlier enable after a - /// delayed auth lookup. - private let intentGate = PushRegistrationMutationGate() + /// Direct sign-out cleanup overloads share captured credentials and remain + /// serialized independently from preference reconciliation. + private let unregisterIntentGate = PushRegistrationMutationGate() /// The actor itself is re-entrant across URLSession suspension points. /// Serialize actual POST/DELETE requests so a late response cannot race a /// newer request. Higher-level reconciliation remains concurrent so account @@ -239,12 +238,10 @@ public actor PushRegistrationService: PushRegistering { private func applyPreferenceIntent( _ intent: PushRegistrationIntent ) async { - await intentGate.withLock { [self] in - guard await self.isCurrentPreferenceIntent(intent.generation) else { - return - } - await self.applyPreferenceIntentUnlocked(intent) + guard isCurrentPreferenceIntent(intent.generation) else { + return } + await applyPreferenceIntentUnlocked(intent) } /// Validates and commits the preference in one service-actor turn. Work @@ -255,17 +252,26 @@ public actor PushRegistrationService: PushRegistering { ) async { switch intent.kind { case .setEnabled: - await setEnabledUnlocked(intent.enabled) + await setEnabledUnlocked( + intent.enabled, + preferenceGeneration: intent.generation + ) case .disableAndUnregister: - await disableAndUnregisterUnlocked() + await disableAndUnregisterUnlocked( + preferenceGeneration: intent.generation + ) } } - private func disableAndUnregisterUnlocked() async { + private func disableAndUnregisterUnlocked( + preferenceGeneration: UInt64 + ) async { cancelRetry() defaults.set(false, forKey: Self.enabledKey) publish(.disabled) - await unregisterFromServerUnlocked() + await unregisterFromServerUnlocked( + preferenceGeneration: preferenceGeneration + ) publish(.disabled) } @@ -273,7 +279,10 @@ public actor PushRegistrationService: PushRegistering { preferenceIntentGeneration == generation } - private func setEnabledUnlocked(_ enabled: Bool) async { + private func setEnabledUnlocked( + _ enabled: Bool, + preferenceGeneration: UInt64 + ) async { let wasEnabled = isEnabled cancelRetry() defaults.set(enabled, forKey: Self.enabledKey) @@ -282,9 +291,13 @@ public actor PushRegistrationService: PushRegistering { } else { publish(.disabled) if wasEnabled { - await unregisterFromServerUnlocked() + await unregisterFromServerUnlocked( + preferenceGeneration: preferenceGeneration + ) } else { - await retryPendingUnregisterIfPossible() + await retryPendingUnregisterIfPossible( + preferenceGeneration: preferenceGeneration + ) } } } @@ -358,12 +371,14 @@ public actor PushRegistrationService: PushRegistering { /// Durably schedules and attempts removal of the currently owned token. public func unregisterFromServer() async { persistCapturedUnregisterObligation(accountID: nil) - await intentGate.withLock { [self] in + await unregisterIntentGate.withLock { [self] in await self.unregisterFromServerUnlocked() } } - private func unregisterFromServerUnlocked() async { + private func unregisterFromServerUnlocked( + preferenceGeneration: UInt64? = nil + ) async { cancelRetry() guard let hex = cachedTokenHex else { return } // A live session identifies who is signed in now, not who owns this @@ -374,14 +389,18 @@ public actor PushRegistrationService: PushRegistering { pushLog.info("Skipping push-token unregister: persisted owner unavailable") return } - let session = try? await tokenProvider.authenticatedSessionSnapshot() // Persist before requiring live auth. This is the privacy guarantee for // an offline or signed-out opt-out. persistPendingUnregister(tokenHex: hex, accountID: ownerID) + let session = try? await tokenProvider.authenticatedSessionSnapshot() // A token acknowledged for account A must never be deleted using // account B credentials. Its tombstone waits for A to return. guard let session, session.accountID == ownerID else { return } - if await sendDelete(tokenHex: hex, sessionSnapshot: session) { + if await sendDelete( + tokenHex: hex, + sessionSnapshot: session, + preferenceGeneration: preferenceGeneration + ) { clearPendingUnregister(tokenHex: hex, accountID: ownerID) clearRegisteredOwner(accountID: ownerID, tokenHex: hex) } @@ -396,7 +415,7 @@ public actor PushRegistrationService: PushRegistering { /// - refreshToken: The captured refresh token. public func unregisterFromServer(accessToken: String?, refreshToken: String?) async { persistCapturedUnregisterObligation(accountID: nil) - await intentGate.withLock { [self] in + await unregisterIntentGate.withLock { [self] in await self.unregisterFromServerUnlocked( accountID: nil, accessToken: accessToken, @@ -412,7 +431,7 @@ public actor PushRegistrationService: PushRegistering { refreshToken: String? ) async { persistCapturedUnregisterObligation(accountID: capturedAccountID) - await intentGate.withLock { [self] in + await unregisterIntentGate.withLock { [self] in await self.unregisterFromServerUnlocked( accountID: capturedAccountID, accessToken: accessToken, @@ -726,7 +745,8 @@ public actor PushRegistrationService: PushRegistering { tokenHex: String, capturedAccessToken: String? = nil, capturedRefreshToken: String? = nil, - sessionSnapshot: AuthenticatedSessionSnapshot? = nil + sessionSnapshot: AuthenticatedSessionSnapshot? = nil, + preferenceGeneration: UInt64? = nil ) async -> Bool { guard case let .success(context) = await makeRequest( method: "DELETE", @@ -736,7 +756,10 @@ public actor PushRegistrationService: PushRegistering { capturedRefreshToken: capturedRefreshToken, sessionSnapshot: sessionSnapshot ) else { return false } - guard await performDelete(context.request) else { return false } + guard await performDelete( + context.request, + preferenceGeneration: preferenceGeneration + ) else { return false } if let session = context.session { return await tokenProvider.isAuthenticatedSessionCurrent(session) } @@ -845,12 +868,24 @@ public actor PushRegistrationService: PushRegistering { } } - private func performDelete(_ request: URLRequest) async -> Bool { + private func performDelete( + _ request: URLRequest, + preferenceGeneration: UInt64? = nil + ) async -> Bool { await networkMutationGate.withLock { [self] in - await self.performDeleteRequest(request) + if let preferenceGeneration { + guard await self.isCurrentOptOut(preferenceGeneration) else { + return false + } + } + return await self.performDeleteRequest(request) } ?? false } + private func isCurrentOptOut(_ generation: UInt64) -> Bool { + preferenceIntentGeneration == generation && !isEnabled + } + private func performDeleteRequest(_ request: URLRequest) async -> Bool { let redirectDelegate = RedirectMethodPreservingDelegate() do { @@ -882,7 +917,9 @@ public actor PushRegistrationService: PushRegistering { } } - private func retryPendingUnregisterIfPossible() async { + private func retryPendingUnregisterIfPossible( + preferenceGeneration: UInt64? = nil + ) async { guard let session = try? await tokenProvider .authenticatedSessionSnapshot() else { return } let currentAccountID = session.accountID @@ -902,7 +939,8 @@ public actor PushRegistrationService: PushRegistering { pending, await sendDelete( tokenHex: pending.tokenHex, - sessionSnapshot: session + sessionSnapshot: session, + preferenceGeneration: preferenceGeneration ) ) } @@ -925,7 +963,9 @@ public actor PushRegistrationService: PushRegistering { } if matching.count > batch.count, results.contains(where: { $0.1 }) { - schedulePendingUnregisterContinuation() + schedulePendingUnregisterContinuation( + preferenceGeneration: preferenceGeneration + ) } } @@ -941,18 +981,26 @@ public actor PushRegistrationService: PushRegistering { storePendingUnregisters(queue) } - private func schedulePendingUnregisterContinuation() { + private func schedulePendingUnregisterContinuation( + preferenceGeneration: UInt64? = nil + ) { guard unregisterDrainTask == nil else { return } unregisterDrainTask = Task { [weak self] in await Task.yield() guard !Task.isCancelled, let self else { return } - await self.runPendingUnregisterContinuation() + await self.runPendingUnregisterContinuation( + preferenceGeneration: preferenceGeneration + ) } } - private func runPendingUnregisterContinuation() async { + private func runPendingUnregisterContinuation( + preferenceGeneration: UInt64? + ) async { unregisterDrainTask = nil - await retryPendingUnregisterIfPossible() + await retryPendingUnregisterIfPossible( + preferenceGeneration: preferenceGeneration + ) } private func clearPendingUnregister( diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 8154f48f952..6fd5e687db1 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1108,20 +1108,27 @@ actor RetryDelayRecorder { await service.applyEnabledIntent(false, generation: 2) await disableFinished.markStarted() } - for _ in 0..<100 where !(await disableFinished.didStart) { + for _ in 0..<1_000 + where defaults.bool( + forKey: "cmux.notifications.pushEnabled" + ) { await Task.yield() } - #expect(await disableFinished.didStart) #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["DELETE"] - ) + let pendingText = defaults.data( + forKey: "cmux.notifications.pendingUnregisters.v2" + ).flatMap { String(data: $0, encoding: .utf8) } + #expect(pendingText?.contains("account-a") == true) await enableBlocker.release() await enable.value await disable.value + #expect(await disableFinished.didStart) + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["DELETE"] + ) #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 7df9c4db52c..52ce218dd0b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -120,13 +120,11 @@ public final class MobilePushCoordinator { @ObservationIgnored private var registrationRecoveryTask: Task? @ObservationIgnored private var registrationRecoveryToken: UUID? - /// Settings owns the user intent, while the registration service owns the - /// network side effect. This task is app-lifetime state, not view-lifetime - /// state, and a newer intent cancels the coordinator work without waiting - /// for the old task to unwind. - @ObservationIgnored private var settingsMutationTask: Task? + /// Authentication and settings reads may ignore cancellation. Keep one + /// owned worker per direction until it actually exits, so an opt-out can + /// advance independently without repeated retries accumulating tasks. @ObservationIgnored private var settingsMutationWorkers: - MobilePushMutationWorkers? + [Bool: MobilePushMutationWorkers] = [:] @ObservationIgnored private var settingsMutationToken = UUID() @ObservationIgnored private var settingsMutationNeedsRetry = false @ObservationIgnored private var registrationIntentGeneration: UInt64 = 0 @@ -225,7 +223,10 @@ public final class MobilePushCoordinator { return } let intent = beginSettingsIntent(enabled) - _ = startSettingsMutation(token: intent.token) { [weak self] in + _ = startSettingsMutation( + enabled: enabled, + token: intent.token + ) { [weak self] in guard let self else { return false } if enabled { return await self.enable( @@ -242,75 +243,64 @@ public final class MobilePushCoordinator { } } - /// Starts the one app-lifetime worker used by every settings/reconciliation - /// entry point. The returned task is independent from the caller's waiter; - /// a newer intent cancels it through `settingsMutationTask`. + /// Starts or reuses the one owned worker for this preference direction. + /// Opposite directions have separate lanes so a non-cooperative enable + /// cannot prevent an opt-out from reaching the registration service. @discardableResult private func startSettingsMutation( + enabled: Bool, token: UUID, operation: @escaping @MainActor () async -> Bool - ) -> Task { - let task = Task { @MainActor [weak self] in - guard let self else { return false } - let result = await self.runSettingsMutation( - token: token, - operation: operation - ) - self.finishSettingsMutation(token) - return result + ) -> MobilePushMutationWorkers { + if let running = settingsMutationWorkers[enabled] { + if running.token != token { + settingsMutationNeedsRetry = true + } + return running } - settingsMutationTask = task - return task - } - - /// Runs a settings mutation with an independent deadline. The operation - /// remains app-lifetime work until the deadline, while a timed-out waiter - /// is cancelled and the coordinator immediately exposes a retryable state. - private func runSettingsMutation( - token: UUID, - operation: @escaping @MainActor () async -> Bool - ) async -> Bool { let completion = MobilePushMutationCompletion() - let operationTask = Task { @MainActor in + let operationTask = Task { @MainActor [weak self] in + guard let self else { + await completion.resolve(.cancelled) + return + } let succeeded = await operation() await completion.resolve(.completed, succeeded: succeeded) + self.finishSettingsMutation( + enabled: enabled, + token: token, + completion: completion, + succeeded: succeeded + ) } - let timeoutTask = Task { [settingsMutationSleep] in + let timeoutTask = Task { @MainActor [weak self, settingsMutationSleep] in do { try await settingsMutationSleep(Self.settingsMutationTimeout) await completion.resolve(.timedOut) + self?.handleSettingsMutationTimeout(token) } catch { // The mutation completed first and cancelled this sleeper. } } - settingsMutationWorkers = MobilePushMutationWorkers( + let workers = MobilePushMutationWorkers( + token: token, operation: operationTask, timeout: timeoutTask, completion: completion ) - let result = await withTaskCancellationHandler { - await completion.wait() - } onCancel: { - operationTask.cancel() - timeoutTask.cancel() - Task { await completion.resolve(.cancelled) } - } - if settingsMutationWorkers?.completion === completion { - settingsMutationWorkers = nil - } - timeoutTask.cancel() - guard result.outcome == .timedOut else { - return result.outcome == .completed && result.succeeded - } - operationTask.cancel() - handleSettingsMutationTimeout(token) - return false + settingsMutationWorkers[enabled] = workers + return workers + } + + private func waitForSettingsMutation( + _ workers: MobilePushMutationWorkers + ) async -> Bool { + let result = await workers.completion.wait() + return result.outcome == .completed && result.succeeded } private func handleSettingsMutationTimeout(_ token: UUID) { guard isCurrentSettingsMutation(token) else { return } - settingsMutationTask = nil - settingsMutationToken = UUID() settingsMutationNeedsRetry = true if enabledMirror { registrationSnapshot = PushRegistrationSnapshot( @@ -415,7 +405,10 @@ public final class MobilePushCoordinator { @discardableResult public func enable() async -> Bool { let intent = beginSettingsIntent(true) - let operation = startSettingsMutation(token: intent.token) { [weak self] in + let workers = startSettingsMutation( + enabled: true, + token: intent.token + ) { [weak self] in guard let self else { return false } return await self.enable( trigger: "settings_toggle", @@ -423,7 +416,7 @@ public final class MobilePushCoordinator { registrationGeneration: intent.registrationGeneration ) } - return await operation.value + return await waitForSettingsMutation(workers) } /// Requests or recovers push only after the authenticated workspace shell @@ -432,20 +425,23 @@ public final class MobilePushCoordinator { // A Settings intent is the freshest user decision. Do not let the // workspace lifecycle reconcile an older persisted value while its // backend mutation is still draining. - guard settingsMutationTask == nil else { return } + guard settingsMutationWorkers[true] == nil else { return } if defaults.object(forKey: Self.enabledKey) as? Bool == false { return } let intentToken = settingsMutationToken let intentGeneration = registrationIntentGeneration - let operation = startSettingsMutation(token: intentToken) { [weak self] in + let workers = startSettingsMutation( + enabled: true, + token: intentToken + ) { [weak self] in guard let self else { return false } return await self.reconcileWorkspaceListDidBecomeVisible( settingsMutationToken: intentToken, registrationGeneration: intentGeneration ) } - _ = await operation.value + _ = await waitForSettingsMutation(workers) } private func reconcileWorkspaceListDidBecomeVisible( @@ -588,7 +584,10 @@ public final class MobilePushCoordinator { /// Opt out: stop receiving pushes and remove the token server-side. public func disable() async { let intent = beginSettingsIntent(false) - let operation = startSettingsMutation(token: intent.token) { [weak self] in + let workers = startSettingsMutation( + enabled: false, + token: intent.token + ) { [weak self] in guard let self else { return false } await self.finishDisable( settingsMutationToken: intent.token, @@ -596,24 +595,34 @@ public final class MobilePushCoordinator { ) return self.isCurrentSettingsMutation(intent.token) } - _ = await operation.value + _ = await waitForSettingsMutation(workers) } private func cancelSettingsMutation() { - settingsMutationTask?.cancel() - settingsMutationTask = nil - settingsMutationWorkers?.operation.cancel() - settingsMutationWorkers?.timeout.cancel() - if let completion = settingsMutationWorkers?.completion { - Task { await completion.resolve(.cancelled) } - } - settingsMutationWorkers = nil + for workers in settingsMutationWorkers.values { + workers.operation.cancel() + } settingsMutationToken = UUID() } - private func finishSettingsMutation(_ token: UUID) { - guard settingsMutationToken == token else { return } - settingsMutationTask = nil + private func finishSettingsMutation( + enabled: Bool, + token: UUID, + completion: MobilePushMutationCompletion, + succeeded: Bool + ) { + guard settingsMutationWorkers[enabled]?.completion === completion else { + return + } + let workers = settingsMutationWorkers.removeValue(forKey: enabled) + workers?.timeout.cancel() + guard enabledMirror == enabled else { return } + if succeeded, settingsMutationToken == token { + settingsMutationNeedsRetry = false + return + } + guard settingsMutationNeedsRetry else { return } + setEnabledIntent(enabled) } private func prepareDisable() { @@ -986,7 +995,10 @@ public final class MobilePushCoordinator { } deinit { - settingsMutationTask?.cancel() + for workers in settingsMutationWorkers.values { + workers.operation.cancel() + workers.timeout.cancel() + } registrationSnapshotTask?.cancel() registrationRecoveryTask?.cancel() } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift index 9377b0141a1..bfb0a686b03 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift @@ -3,7 +3,9 @@ import Foundation /// Resolves the first terminal result of an app-lifetime push mutation. actor MobilePushMutationCompletion { private var result: MobilePushMutationResult? - private var waiters: [CheckedContinuation] = [] + private var waiters: [ + UUID: CheckedContinuation + ] = [:] func resolve( _ outcome: MobilePushMutationOutcome, @@ -15,7 +17,7 @@ actor MobilePushMutationCompletion { succeeded: succeeded ) result = resolved - let waiters = self.waiters + let waiters = self.waiters.values self.waiters.removeAll() for waiter in waiters { waiter.resume(returning: resolved) @@ -24,12 +26,32 @@ actor MobilePushMutationCompletion { func wait() async -> MobilePushMutationResult { if let result { return result } - return await withCheckedContinuation { continuation in - if let result { - continuation.resume(returning: result) - } else { - waiters.append(continuation) + let waiterID = UUID() + return await withTaskCancellationHandler(operation: { + await withCheckedContinuation { continuation in + if let result { + continuation.resume(returning: result) + } else if Task.isCancelled { + continuation.resume(returning: MobilePushMutationResult( + outcome: .cancelled, + succeeded: false + )) + } else { + waiters[waiterID] = continuation + } } + }, onCancel: { + Task { await self.cancelWaiter(waiterID) } + }) + } + + private func cancelWaiter(_ waiterID: UUID) { + guard let waiter = waiters.removeValue(forKey: waiterID) else { + return } + waiter.resume(returning: MobilePushMutationResult( + outcome: .cancelled, + succeeded: false + )) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift index ef31443ed43..15d8a563bb9 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift @@ -4,6 +4,7 @@ import Foundation /// so a superseding intent can cancel every worker that belongs to one /// mutation. struct MobilePushMutationWorkers { + let token: UUID let operation: Task let timeout: Task let completion: MobilePushMutationCompletion diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index cca3b266d56..b930e3a86d0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -1017,7 +1017,6 @@ private final class LifecyclePushURLProtocol: URLProtocol, } #expect(await cancellationRecorder.didCancelAuthorization) - #expect(await timeoutSleeper.didCancelFirstSleep) #expect(!coordinator.isEnabled) #expect(await registration.snapshot == .disabled) } From 1130e64a88098d94bdbd503e2d97b92645dcadc9 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:29:36 -0700 Subject: [PATCH 068/117] refactor(push): keep intent lane state flat --- .../Push/PushRegistrationIntentQueue.swift | 48 +++++++------------ 1 file changed, 17 insertions(+), 31 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift index e80b1c493b2..aed2411e90f 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift @@ -7,24 +7,11 @@ import Foundation /// a newer opt-out advances on the independent disable lane. Repeated intents /// on either lane replace one pending value instead of accumulating tasks. actor PushRegistrationIntentQueue { - private enum Lane: Hashable { - case enable - case disable - - init(_ intent: PushRegistrationIntent) { - self = intent.enabled ? .enable : .disable - } - } - - private struct RunningIntent { - let generation: UInt64 - let task: Task - } - private let operation: @Sendable (PushRegistrationIntent) async -> Void private var latestGeneration: UInt64 = 0 - private var pendingIntents: [Lane: PushRegistrationIntent] = [:] - private var runningIntents: [Lane: RunningIntent] = [:] + private var pendingIntents: [Bool: PushRegistrationIntent] = [:] + private var runningGenerations: [Bool: UInt64] = [:] + private var runningTasks: [Bool: Task] = [:] private var completedIntent: PushRegistrationIntent? private var waiters: [UInt64: [UUID: CheckedContinuation]] = [:] @@ -36,10 +23,10 @@ actor PushRegistrationIntentQueue { /// Replaces stale pending work and waits for this intent to be handled. func submit(_ intent: PushRegistrationIntent) async { guard intent.generation >= latestGeneration else { return } - let lane = Lane(intent) + let lane = intent.enabled if intent == completedIntent, pendingIntents[lane] == nil, - runningIntents[lane] == nil { + runningTasks[lane] == nil { return } @@ -48,11 +35,11 @@ actor PushRegistrationIntentQueue { pendingIntents.removeAll() pendingIntents[lane] = intent resumeWaiters(before: intent.generation) - for running in runningIntents.values - where running.generation < intent.generation { - running.task.cancel() + for (runningLane, generation) in runningGenerations + where generation < intent.generation { + runningTasks[runningLane]?.cancel() } - } else if runningIntents[lane]?.generation != intent.generation { + } else if runningGenerations[lane] != intent.generation { pendingIntents[lane] = intent } @@ -74,8 +61,8 @@ actor PushRegistrationIntentQueue { }) } - private func startPendingIntentIfNeeded(on lane: Lane) { - guard runningIntents[lane] == nil, + private func startPendingIntentIfNeeded(on lane: Bool) { + guard runningTasks[lane] == nil, let intent = pendingIntents.removeValue(forKey: lane) else { return } let operation = self.operation @@ -83,20 +70,19 @@ actor PushRegistrationIntentQueue { await operation(intent) await self?.intentCompleted(intent, on: lane) } - runningIntents[lane] = RunningIntent( - generation: intent.generation, - task: task - ) + runningGenerations[lane] = intent.generation + runningTasks[lane] = task } private func intentCompleted( _ intent: PushRegistrationIntent, - on lane: Lane + on lane: Bool ) { - guard runningIntents[lane]?.generation == intent.generation else { + guard runningGenerations[lane] == intent.generation else { return } - runningIntents.removeValue(forKey: lane) + runningGenerations.removeValue(forKey: lane) + runningTasks.removeValue(forKey: lane) if let completedIntent { if intent.generation >= completedIntent.generation { self.completedIntent = intent From f3b831f81d4b92f5d3268aeae041b8135f03c7da Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:44:01 -0700 Subject: [PATCH 069/117] test(push): require timed-out intent to reach service --- .../MobilePushCoordinatorLifecycleTests.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index b930e3a86d0..f3518c800e8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -894,10 +894,14 @@ private final class LifecyclePushURLProtocol: URLProtocol, coordinator.setEnabledIntent(true) for _ in 0..<100 { - if await settingsGate.starts > 1 { break } + if await settingsGate.starts > 1, + await registration.snapshot.isEnabled { + break + } await Task.yield() } #expect(await settingsGate.starts == 1) + #expect(await registration.snapshot.isEnabled) coordinator.setEnabledIntent(false) for _ in 0..<100 { From faa340e657175a02f5061b8522b7d2dd76e5ec0f Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 22:50:16 -0700 Subject: [PATCH 070/117] fix(push): commit intents before bounded reconciliation --- .../Push/PushRegistrationIntent.swift | 1 - .../Push/PushRegistrationIntentKind.swift | 8 -- .../Push/PushRegistrationService.swift | 114 +++++++----------- .../MobilePushCoordinator.swift | 24 +++- 4 files changed, 60 insertions(+), 87 deletions(-) delete mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentKind.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift index 5c9ad6d2226..bf457f24054 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift @@ -1,5 +1,4 @@ struct PushRegistrationIntent: Sendable, Equatable { let enabled: Bool - let kind: PushRegistrationIntentKind let generation: UInt64 } diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentKind.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentKind.swift deleted file mode 100644 index ce46df52a6d..00000000000 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentKind.swift +++ /dev/null @@ -1,8 +0,0 @@ -/// The preference mutation operation represented by a service intent. -enum PushRegistrationIntentKind: Sendable, Equatable { - /// The regular `setEnabled(_:)` semantics, including retrying an already - /// disabled owner's pending cleanup instead of inferring a live owner. - case setEnabled - /// The coordinator's local-first opt-out cleanup semantics. - case disableAndUnregister -} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 4d932cfabb9..46bef1ce3db 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -47,6 +47,7 @@ public actor PushRegistrationService: PushRegistering { /// `preferenceIntentGeneration` above. private var coordinatorGenerationInvalidatedThrough: UInt64? private var latestCoordinatorIntent: PushRegistrationIntent? + private var committedPreferenceIntent: PushRegistrationIntent? private var preferenceIntentQueue: PushRegistrationIntentQueue? private var snapshotValue: PushRegistrationSnapshot private var snapshotContinuations: @@ -150,7 +151,7 @@ public actor PushRegistrationService: PushRegistering { /// Persists a preference and reconciles its token registration in order. public func setEnabled(_ enabled: Bool) async { invalidateCoordinatorIntents() - await submitPreferenceIntent(enabled: enabled, kind: .setEnabled) + await submitPreferenceIntent(enabled: enabled) } /// Disables local delivery and removes the owned token from the server. @@ -160,10 +161,7 @@ public actor PushRegistrationService: PushRegistering { /// now-false preference to decide whether a delete is required. public func disableAndUnregister() async { invalidateCoordinatorIntents() - await submitPreferenceIntent( - enabled: false, - kind: .disableAndUnregister - ) + await submitPreferenceIntent(enabled: false) } /// Applies the newest coordinator-owned preference, replacing stale work @@ -190,42 +188,31 @@ public actor PushRegistrationService: PushRegistering { } } coordinatorGeneration = generation - let intent = makePreferenceIntent( - enabled: enabled, - kind: enabled ? .setEnabled : .disableAndUnregister - ) + let intent = makePreferenceIntent(enabled: enabled) latestCoordinatorIntent = intent await submitPreferenceIntent(intent) } - private func submitPreferenceIntent( - enabled: Bool, - kind: PushRegistrationIntentKind - ) async { - await submitPreferenceIntent( - makePreferenceIntent(enabled: enabled, kind: kind) - ) + private func submitPreferenceIntent(enabled: Bool) async { + await submitPreferenceIntent(makePreferenceIntent(enabled: enabled)) } private func submitPreferenceIntent( _ intent: PushRegistrationIntent ) async { + commitPreferenceIntent(intent) if preferenceIntentQueue == nil { preferenceIntentQueue = PushRegistrationIntentQueue { [weak self] intent in - await self?.applyPreferenceIntent(intent) + await self?.reconcilePreferenceIntent(intent) } } await preferenceIntentQueue!.submit(intent) } - private func makePreferenceIntent( - enabled: Bool, - kind: PushRegistrationIntentKind - ) -> PushRegistrationIntent { + private func makePreferenceIntent(enabled: Bool) -> PushRegistrationIntent { preferenceIntentGeneration &+= 1 return PushRegistrationIntent( enabled: enabled, - kind: kind, generation: preferenceIntentGeneration ) } @@ -235,73 +222,56 @@ public actor PushRegistrationService: PushRegistering { latestCoordinatorIntent = nil } - private func applyPreferenceIntent( - _ intent: PushRegistrationIntent - ) async { - guard isCurrentPreferenceIntent(intent.generation) else { + /// Commits the latest user preference before any authentication or network + /// suspension. Same-direction reconciliation can remain bounded behind an + /// older preparation without delaying the durable toggle state. + private func commitPreferenceIntent(_ intent: PushRegistrationIntent) { + guard isCurrentPreferenceIntent(intent.generation), + committedPreferenceIntent != intent else { return } - await applyPreferenceIntentUnlocked(intent) + committedPreferenceIntent = intent + cancelRetry() + defaults.set(intent.enabled, forKey: Self.enabledKey) + if intent.enabled { + let hasToken = cachedTokenHex != nil + publish(PushRegistrationSnapshot( + isEnabled: true, + hasDeviceToken: hasToken, + backendState: hasToken + ? .registrationRequired + : .awaitingDeviceToken + )) + } else { + publish(.disabled) + persistCapturedUnregisterObligation(accountID: nil) + } } - /// Validates and commits the preference in one service-actor turn. Work - /// after the first suspension may be stale, but it can no longer overwrite - /// a newer intent's durable preference. - private func applyPreferenceIntentUnlocked( + private func reconcilePreferenceIntent( _ intent: PushRegistrationIntent ) async { - switch intent.kind { - case .setEnabled: - await setEnabledUnlocked( - intent.enabled, + guard isCurrentPreferenceIntent(intent.generation) else { + return + } + if intent.enabled { + await syncTokenIfPossibleUnlocked() + } else { + await unregisterFromServerUnlocked( preferenceGeneration: intent.generation ) - case .disableAndUnregister: - await disableAndUnregisterUnlocked( + await retryPendingUnregisterIfPossible( preferenceGeneration: intent.generation ) + guard isCurrentOptOut(intent.generation) else { return } + publish(.disabled) } } - private func disableAndUnregisterUnlocked( - preferenceGeneration: UInt64 - ) async { - cancelRetry() - defaults.set(false, forKey: Self.enabledKey) - publish(.disabled) - await unregisterFromServerUnlocked( - preferenceGeneration: preferenceGeneration - ) - publish(.disabled) - } - private func isCurrentPreferenceIntent(_ generation: UInt64) -> Bool { preferenceIntentGeneration == generation } - private func setEnabledUnlocked( - _ enabled: Bool, - preferenceGeneration: UInt64 - ) async { - let wasEnabled = isEnabled - cancelRetry() - defaults.set(enabled, forKey: Self.enabledKey) - if enabled { - await syncTokenIfPossibleUnlocked() - } else { - publish(.disabled) - if wasEnabled { - await unregisterFromServerUnlocked( - preferenceGeneration: preferenceGeneration - ) - } else { - await retryPendingUnregisterIfPossible( - preferenceGeneration: preferenceGeneration - ) - } - } - } - /// Caches an APNs device token and uploads it when push is enabled. public func register(deviceToken: Data) async { await registerUnlocked(deviceToken: deviceToken) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 52ce218dd0b..a8e640079e0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -120,6 +120,7 @@ public final class MobilePushCoordinator { @ObservationIgnored private var registrationRecoveryTask: Task? @ObservationIgnored private var registrationRecoveryToken: UUID? + @ObservationIgnored private var registrationIntentTask: Task? /// Authentication and settings reads may ignore cancellation. Keep one /// owned worker per direction until it actually exits, so an opt-out can /// advance independently without repeated retries accumulating tasks. @@ -251,12 +252,10 @@ public final class MobilePushCoordinator { enabled: Bool, token: UUID, operation: @escaping @MainActor () async -> Bool - ) -> MobilePushMutationWorkers { - if let running = settingsMutationWorkers[enabled] { - if running.token != token { - settingsMutationNeedsRetry = true - } - return running + ) -> MobilePushMutationWorkers? { + guard settingsMutationWorkers[enabled] == nil else { + settingsMutationNeedsRetry = true + return nil } let completion = MobilePushMutationCompletion() let operationTask = Task { @MainActor [weak self] in @@ -335,6 +334,15 @@ public final class MobilePushCoordinator { } else { prepareDisable() } + registrationIntentTask?.cancel() + let registration = self.registration + let registrationGeneration = registrationIntentGeneration + registrationIntentTask = Task { + await registration.applyEnabledIntent( + enabled, + generation: registrationGeneration + ) + } return MobilePushSettingsIntent( token: token, registrationGeneration: registrationIntentGeneration @@ -416,6 +424,7 @@ public final class MobilePushCoordinator { registrationGeneration: intent.registrationGeneration ) } + guard let workers else { return false } return await waitForSettingsMutation(workers) } @@ -441,6 +450,7 @@ public final class MobilePushCoordinator { registrationGeneration: intentGeneration ) } + guard let workers else { return } _ = await waitForSettingsMutation(workers) } @@ -595,6 +605,7 @@ public final class MobilePushCoordinator { ) return self.isCurrentSettingsMutation(intent.token) } + guard let workers else { return } _ = await waitForSettingsMutation(workers) } @@ -999,6 +1010,7 @@ public final class MobilePushCoordinator { workers.operation.cancel() workers.timeout.cancel() } + registrationIntentTask?.cancel() registrationSnapshotTask?.cancel() registrationRecoveryTask?.cancel() } From 1f9c96da50bfaa10e16cda7e82c785776c75db62 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:00:13 -0700 Subject: [PATCH 071/117] test(push): require bounded timeout recovery --- .../MobilePushCoordinatorLifecycleTests.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index f3518c800e8..eb35e5cc05a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -799,8 +799,14 @@ private final class LifecyclePushURLProtocol: URLProtocol, == .failed(.networkUnavailable) ) - await settingsGate.release() coordinator.setEnabledIntent(true) + for _ in 0..<100 { + if await settingsGate.starts == 2 { break } + await Task.yield() + } + #expect(await settingsGate.starts == 2) + + await settingsGate.release() for _ in 0..<100 { if registrationRequests == 1 { break } await Task.yield() @@ -858,7 +864,7 @@ private final class LifecyclePushURLProtocol: URLProtocol, } @MainActor - @Test func timedOutEnableIsDedupedAndCannotBlockOptOut() async { + @Test func timedOutEnableAllowsOneBoundedRecoveryAndCannotBlockOptOut() async { let settingsGate = LifecycleSyncGate() let timeoutGate = LifecycleSyncGate() let timeoutSleeper = LifecycleSettingsMutationSleeper( @@ -894,15 +900,19 @@ private final class LifecyclePushURLProtocol: URLProtocol, coordinator.setEnabledIntent(true) for _ in 0..<100 { - if await settingsGate.starts > 1, + if await settingsGate.starts == 2, await registration.snapshot.isEnabled { break } await Task.yield() } - #expect(await settingsGate.starts == 1) + #expect(await settingsGate.starts == 2) #expect(await registration.snapshot.isEnabled) + coordinator.setEnabledIntent(true) + for _ in 0..<100 { await Task.yield() } + #expect(await settingsGate.starts == 2) + coordinator.setEnabledIntent(false) for _ in 0..<100 { if !coordinator.isEnabled, From 6e98cee5e1fed9d084dddddef3f625734d9d73a5 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:02:12 -0700 Subject: [PATCH 072/117] fix(push): bound timeout recovery lanes --- .../MobilePushCoordinator.swift | 114 +++++++++++++++--- .../MobilePushMutationWorkers.swift | 1 - 2 files changed, 94 insertions(+), 21 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index a8e640079e0..573fe2fd2b6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -121,13 +121,19 @@ public final class MobilePushCoordinator { Task? @ObservationIgnored private var registrationRecoveryToken: UUID? @ObservationIgnored private var registrationIntentTask: Task? - /// Authentication and settings reads may ignore cancellation. Keep one - /// owned worker per direction until it actually exits, so an opt-out can - /// advance independently without repeated retries accumulating tasks. + /// Authentication and settings reads may ignore cancellation. Each + /// direction owns one active worker plus one timed-out quarantine slot. + /// That lets a single recovery advance without repeated retries + /// accumulating abandoned tasks. @ObservationIgnored private var settingsMutationWorkers: [Bool: MobilePushMutationWorkers] = [:] + @ObservationIgnored private var quarantinedSettingsMutationWorkers: + [Bool: MobilePushMutationWorkers] = [:] + @ObservationIgnored private var timedOutSettingsMutationCompletions: + [Bool: MobilePushMutationCompletion] = [:] @ObservationIgnored private var settingsMutationToken = UUID() - @ObservationIgnored private var settingsMutationNeedsRetry = false + @ObservationIgnored private var settingsMutationDirectionsNeedingRetry: + Set = [] @ObservationIgnored private var registrationIntentGeneration: UInt64 = 0 @ObservationIgnored private var workspaceAuthorizationRequestInFlight = false @ObservationIgnored private var hasRequestedRemoteRegistration = false @@ -220,7 +226,8 @@ public final class MobilePushCoordinator { /// coordinator task and starts independently, so an opt-out can preempt an /// authorization prompt or other suspended enable path. public func setEnabledIntent(_ enabled: Bool) { - guard enabled != enabledMirror || settingsMutationNeedsRetry else { + guard enabled != enabledMirror + || settingsMutationDirectionsNeedingRetry.contains(enabled) else { return } let intent = beginSettingsIntent(enabled) @@ -244,9 +251,9 @@ public final class MobilePushCoordinator { } } - /// Starts or reuses the one owned worker for this preference direction. - /// Opposite directions have separate lanes so a non-cooperative enable - /// cannot prevent an opt-out from reaching the registration service. + /// Starts one active worker for this preference direction. A timed-out + /// worker moves to its bounded quarantine slot so one recovery can run. + /// Opposite directions remain independent. @discardableResult private func startSettingsMutation( enabled: Bool, @@ -254,7 +261,7 @@ public final class MobilePushCoordinator { operation: @escaping @MainActor () async -> Bool ) -> MobilePushMutationWorkers? { guard settingsMutationWorkers[enabled] == nil else { - settingsMutationNeedsRetry = true + settingsMutationDirectionsNeedingRetry.insert(enabled) return nil } let completion = MobilePushMutationCompletion() @@ -276,13 +283,16 @@ public final class MobilePushCoordinator { do { try await settingsMutationSleep(Self.settingsMutationTimeout) await completion.resolve(.timedOut) - self?.handleSettingsMutationTimeout(token) + self?.handleSettingsMutationTimeout( + enabled: enabled, + token: token, + completion: completion + ) } catch { // The mutation completed first and cancelled this sleeper. } } let workers = MobilePushMutationWorkers( - token: token, operation: operationTask, timeout: timeoutTask, completion: completion @@ -298,9 +308,30 @@ public final class MobilePushCoordinator { return result.outcome == .completed && result.succeeded } - private func handleSettingsMutationTimeout(_ token: UUID) { - guard isCurrentSettingsMutation(token) else { return } - settingsMutationNeedsRetry = true + private func handleSettingsMutationTimeout( + enabled: Bool, + token: UUID, + completion: MobilePushMutationCompletion + ) { + guard settingsMutationWorkers[enabled]?.completion === completion else { + return + } + settingsMutationDirectionsNeedingRetry.insert(enabled) + let isCurrent = isCurrentSettingsMutation(token) + var releasedLane = false + if quarantinedSettingsMutationWorkers[enabled] == nil, + let workers = settingsMutationWorkers.removeValue(forKey: enabled) { + quarantinedSettingsMutationWorkers[enabled] = workers + timedOutSettingsMutationCompletions.removeValue(forKey: enabled) + releasedLane = true + } else { + timedOutSettingsMutationCompletions[enabled] = completion + } + if releasedLane, !isCurrent, enabledMirror == enabled { + setEnabledIntent(enabled) + return + } + guard isCurrent else { return } if enabledMirror { registrationSnapshot = PushRegistrationSnapshot( isEnabled: true, @@ -325,7 +356,7 @@ public final class MobilePushCoordinator { @discardableResult private func beginSettingsIntent(_ enabled: Bool) -> MobilePushSettingsIntent { cancelSettingsMutation() - settingsMutationNeedsRetry = false + settingsMutationDirectionsNeedingRetry.remove(enabled) let token = UUID() registrationIntentGeneration &+= 1 settingsMutationToken = token @@ -543,7 +574,8 @@ public final class MobilePushCoordinator { // the reconciliation deadline. If it eventually grants after that // deadline, start a fresh, current-generation reconciliation rather // than leaving the persisted opt-in without a service mutation. - if enabledMirror, settingsMutationNeedsRetry { + if enabledMirror, + settingsMutationDirectionsNeedingRetry.contains(true) { setEnabledIntent(true) } return false @@ -613,6 +645,9 @@ public final class MobilePushCoordinator { for workers in settingsMutationWorkers.values { workers.operation.cancel() } + for workers in quarantinedSettingsMutationWorkers.values { + workers.operation.cancel() + } settingsMutationToken = UUID() } @@ -622,17 +657,52 @@ public final class MobilePushCoordinator { completion: MobilePushMutationCompletion, succeeded: Bool ) { - guard settingsMutationWorkers[enabled]?.completion === completion else { + if settingsMutationWorkers[enabled]?.completion === completion { + let workers = settingsMutationWorkers.removeValue(forKey: enabled) + workers?.timeout.cancel() + if timedOutSettingsMutationCompletions[enabled] === completion { + timedOutSettingsMutationCompletions.removeValue(forKey: enabled) + } + finishSettingsMutationState( + enabled: enabled, + token: token, + succeeded: succeeded + ) return } - let workers = settingsMutationWorkers.removeValue(forKey: enabled) + guard quarantinedSettingsMutationWorkers[enabled]?.completion + === completion else { return } + let workers = quarantinedSettingsMutationWorkers.removeValue( + forKey: enabled + ) workers?.timeout.cancel() + if let active = settingsMutationWorkers[enabled], + timedOutSettingsMutationCompletions[enabled] === active.completion { + settingsMutationWorkers.removeValue(forKey: enabled) + timedOutSettingsMutationCompletions.removeValue(forKey: enabled) + quarantinedSettingsMutationWorkers[enabled] = active + } + guard settingsMutationWorkers[enabled] == nil else { return } + finishSettingsMutationState( + enabled: enabled, + token: token, + succeeded: succeeded + ) + } + + private func finishSettingsMutationState( + enabled: Bool, + token: UUID, + succeeded: Bool + ) { guard enabledMirror == enabled else { return } if succeeded, settingsMutationToken == token { - settingsMutationNeedsRetry = false + settingsMutationDirectionsNeedingRetry.remove(enabled) return } - guard settingsMutationNeedsRetry else { return } + guard settingsMutationDirectionsNeedingRetry.contains(enabled), + settingsMutationWorkers[enabled] == nil + else { return } setEnabledIntent(enabled) } @@ -1010,6 +1080,10 @@ public final class MobilePushCoordinator { workers.operation.cancel() workers.timeout.cancel() } + for workers in quarantinedSettingsMutationWorkers.values { + workers.operation.cancel() + workers.timeout.cancel() + } registrationIntentTask?.cancel() registrationSnapshotTask?.cancel() registrationRecoveryTask?.cancel() diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift index 15d8a563bb9..ef31443ed43 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift @@ -4,7 +4,6 @@ import Foundation /// so a superseding intent can cancel every worker that belongs to one /// mutation. struct MobilePushMutationWorkers { - let token: UUID let operation: Task let timeout: Task let completion: MobilePushMutationCompletion From 0cfa6daac2c361439c0cc459567891c7d6bde50f Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:11:01 -0700 Subject: [PATCH 073/117] test(push): cover late intent and timeout winner races --- .../PushRegistrationServiceTests.swift | 21 +++++++++++++++++++ .../MobilePushMutationCompletionTests.swift | 19 +++++++++++++++++ 2 files changed, 40 insertions(+) create mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushMutationCompletionTests.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 6fd5e687db1..a58b780aedc 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1293,6 +1293,27 @@ actor RetryDelayRecorder { ) } + @Test func directOptOutRejectsCoordinatorIntentCreatedBeforeBarrier() async { + await PushRegistrationURLProtocol.script.reset([]) + let (service, defaults) = makeScriptedService(accountID: "account-a") + let staleEpoch = PushRegistrationIntentEpoch() + defaults.set( + staleEpoch.storageValue, + forKey: PushRegistrationIntentEpoch.defaultsKey + ) + + await service.setEnabled(false) + await service.applyEnabledIntent( + true, + generation: 1, + intentEpoch: staleEpoch + ) + + #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) + #expect(await service.snapshot == .disabled) + #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) + } + @Test func cancelledQueuedRegistrationLeavesRecoverableState() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushMutationCompletionTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushMutationCompletionTests.swift new file mode 100644 index 00000000000..0c7ab9a041b --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushMutationCompletionTests.swift @@ -0,0 +1,19 @@ +import Testing + +@testable import CmuxMobileShellUI + +@Suite struct MobilePushMutationCompletionTests { + @Test func onlyTheWinningResolutionReportsSuccess() async { + let completion = MobilePushMutationCompletion() + + #expect(await completion.resolve(.completed, succeeded: true)) + #expect(!(await completion.resolve(.timedOut))) + #expect( + await completion.wait() + == MobilePushMutationResult( + outcome: .completed, + succeeded: true + ) + ) + } +} From 6832304b12eeced036ff6490dc2deab24dff7427 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:16:28 -0700 Subject: [PATCH 074/117] fix(push): fence late intents and timeout races --- .../Push/PushRegistering.swift | 8 ++- .../Push/PushRegistrationIntentEpoch.swift | 24 +++++++ .../Push/PushRegistrationService.swift | 47 +++++++++--- .../MobilePushCoordinator.swift | 72 ++++++++++++++----- .../MobilePushMutationCompletion.swift | 6 +- .../MobilePushSettingsIntent.swift | 2 + .../MobilePushCoordinatorLifecycleTests.swift | 6 +- .../cmuxFeatureTests/cmuxFeatureTests.swift | 6 +- 8 files changed, 139 insertions(+), 32 deletions(-) create mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentEpoch.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift index 1f48aa80566..567febcfc70 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift @@ -31,11 +31,17 @@ public protocol PushRegistering: Sendable { /// - Parameters: /// - enabled: The latest user intent. /// - generation: A monotonically increasing coordinator generation. + /// - intentEpoch: The preference epoch captured before the coordinator + /// created its asynchronous work. /// /// Older queued intents must not run after a newer one. Every conformer /// implements this contract so the coordinator does not depend on a /// particular registration-service implementation for stale-work safety. - func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async + func applyEnabledIntent( + _ enabled: Bool, + generation: UInt64, + intentEpoch: PushRegistrationIntentEpoch + ) async /// Cache and (when opted in) upload a freshly registered APNs device token. func register(deviceToken: Data) async diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentEpoch.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentEpoch.swift new file mode 100644 index 00000000000..de047c34b4c --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentEpoch.swift @@ -0,0 +1,24 @@ +import Foundation + +/// Identifies the coordinator preference that was current when work was made. +/// +/// The value is mirrored through the shared defaults suite before asynchronous +/// work is created. Direct service mutations replace it, so a coordinator task +/// that starts late cannot reverse a newer direct preference. +public struct PushRegistrationIntentEpoch: Sendable, Equatable { + /// The shared-defaults key containing the currently authoritative epoch. + public static let defaultsKey = "cmux.notifications.pushIntentEpoch" + + /// The stable value persisted and carried by asynchronous coordinator work. + public let storageValue: String + + /// Creates a fresh preference epoch. + public init() { + self.storageValue = UUID().uuidString + } + + /// Restores an epoch from its persisted representation. + public init(storageValue: String) { + self.storageValue = storageValue + } +} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 46bef1ce3db..882bdca35d1 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -164,16 +164,28 @@ public actor PushRegistrationService: PushRegistering { await submitPreferenceIntent(enabled: false) } - /// Applies the newest coordinator-owned preference, replacing stale work - /// that has not started and sharing a live mutation with duplicate callers. - /// - /// - Parameters: - /// - enabled: The latest user preference. - /// - generation: The monotonically increasing coordinator generation. - public func applyEnabledIntent( + /// Applies an authoritative intent without an external coordinator epoch. + /// Test and direct in-module callers use this convenience entrypoint. + func applyEnabledIntent( _ enabled: Bool, generation: UInt64 ) async { + let intentEpoch = advancePreferenceIntentEpoch() + await applyEnabledIntent( + enabled, + generation: generation, + intentEpoch: intentEpoch + ) + } + + /// Applies the newest coordinator-owned preference when its creation epoch + /// is still current, replacing stale queued work and sharing duplicates. + public func applyEnabledIntent( + _ enabled: Bool, + generation: UInt64, + intentEpoch: PushRegistrationIntentEpoch + ) async { + guard isCurrentPreferenceIntentEpoch(intentEpoch) else { return } if let invalidatedThrough = coordinatorGenerationInvalidatedThrough, generation <= invalidatedThrough { @@ -218,10 +230,27 @@ public actor PushRegistrationService: PushRegistering { } private func invalidateCoordinatorIntents() { + _ = advancePreferenceIntentEpoch() coordinatorGenerationInvalidatedThrough = coordinatorGeneration latestCoordinatorIntent = nil } + private func advancePreferenceIntentEpoch() -> PushRegistrationIntentEpoch { + let intentEpoch = PushRegistrationIntentEpoch() + defaults.set( + intentEpoch.storageValue, + forKey: PushRegistrationIntentEpoch.defaultsKey + ) + return intentEpoch + } + + private func isCurrentPreferenceIntentEpoch( + _ intentEpoch: PushRegistrationIntentEpoch + ) -> Bool { + defaults.string(forKey: PushRegistrationIntentEpoch.defaultsKey) + == intentEpoch.storageValue + } + /// Commits the latest user preference before any authentication or network /// suspension. Same-direction reconciliation can remain bounded behind an /// older preparation without delaying the durable toggle state. @@ -232,6 +261,9 @@ public actor PushRegistrationService: PushRegistering { } committedPreferenceIntent = intent cancelRetry() + if !intent.enabled { + persistCapturedUnregisterObligation(accountID: nil) + } defaults.set(intent.enabled, forKey: Self.enabledKey) if intent.enabled { let hasToken = cachedTokenHex != nil @@ -244,7 +276,6 @@ public actor PushRegistrationService: PushRegistering { )) } else { publish(.disabled) - persistCapturedUnregisterObligation(accountID: nil) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 573fe2fd2b6..20a069d5c1b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -135,6 +135,8 @@ public final class MobilePushCoordinator { @ObservationIgnored private var settingsMutationDirectionsNeedingRetry: Set = [] @ObservationIgnored private var registrationIntentGeneration: UInt64 = 0 + @ObservationIgnored private var registrationIntentEpoch: + PushRegistrationIntentEpoch @ObservationIgnored private var workspaceAuthorizationRequestInFlight = false @ObservationIgnored private var hasRequestedRemoteRegistration = false @@ -182,13 +184,19 @@ public final class MobilePushCoordinator { try await ContinuousClock().sleep(for: $0) } ) { + let registrationIntentEpoch = PushRegistrationIntentEpoch() self.registration = registration + self.registrationIntentEpoch = registrationIntentEpoch self.replyRetrySleep = replyRetrySleep self.settingsMutationSleep = settingsMutationSleep self.analytics = analytics self.diagnosticLog = diagnosticLog self.phoneAPIOrigin = phoneAPIOrigin self.defaults = defaults + defaults.set( + registrationIntentEpoch.storageValue, + forKey: PushRegistrationIntentEpoch.defaultsKey + ) self.enabledMirror = defaults.bool(forKey: Self.enabledKey) self.deliveredNotificationClearer = deliveredNotificationClearer self.pendingDismissQueue = pendingDismissQueue @@ -240,12 +248,14 @@ public final class MobilePushCoordinator { return await self.enable( trigger: "settings_toggle", settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration + registrationGeneration: intent.registrationGeneration, + registrationIntentEpoch: intent.registrationIntentEpoch ) } await self.finishDisable( settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration + registrationGeneration: intent.registrationGeneration, + registrationIntentEpoch: intent.registrationIntentEpoch ) return self.isCurrentSettingsMutation(intent.token) } @@ -282,7 +292,7 @@ public final class MobilePushCoordinator { let timeoutTask = Task { @MainActor [weak self, settingsMutationSleep] in do { try await settingsMutationSleep(Self.settingsMutationTimeout) - await completion.resolve(.timedOut) + guard await completion.resolve(.timedOut) else { return } self?.handleSettingsMutationTimeout( enabled: enabled, token: token, @@ -359,6 +369,12 @@ public final class MobilePushCoordinator { settingsMutationDirectionsNeedingRetry.remove(enabled) let token = UUID() registrationIntentGeneration &+= 1 + let registrationIntentEpoch = PushRegistrationIntentEpoch() + self.registrationIntentEpoch = registrationIntentEpoch + defaults.set( + registrationIntentEpoch.storageValue, + forKey: PushRegistrationIntentEpoch.defaultsKey + ) settingsMutationToken = token if enabled { persistEnabledIntent() @@ -371,12 +387,14 @@ public final class MobilePushCoordinator { registrationIntentTask = Task { await registration.applyEnabledIntent( enabled, - generation: registrationGeneration + generation: registrationGeneration, + intentEpoch: registrationIntentEpoch ) } return MobilePushSettingsIntent( token: token, - registrationGeneration: registrationIntentGeneration + registrationGeneration: registrationIntentGeneration, + registrationIntentEpoch: registrationIntentEpoch ) } @@ -452,7 +470,8 @@ public final class MobilePushCoordinator { return await self.enable( trigger: "settings_toggle", settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration + registrationGeneration: intent.registrationGeneration, + registrationIntentEpoch: intent.registrationIntentEpoch ) } guard let workers else { return false } @@ -471,6 +490,7 @@ public final class MobilePushCoordinator { } let intentToken = settingsMutationToken let intentGeneration = registrationIntentGeneration + let intentEpoch = registrationIntentEpoch let workers = startSettingsMutation( enabled: true, token: intentToken @@ -478,7 +498,8 @@ public final class MobilePushCoordinator { guard let self else { return false } return await self.reconcileWorkspaceListDidBecomeVisible( settingsMutationToken: intentToken, - registrationGeneration: intentGeneration + registrationGeneration: intentGeneration, + registrationIntentEpoch: intentEpoch ) } guard let workers else { return } @@ -487,7 +508,8 @@ public final class MobilePushCoordinator { private func reconcileWorkspaceListDidBecomeVisible( settingsMutationToken: UUID, - registrationGeneration: UInt64 + registrationGeneration: UInt64, + registrationIntentEpoch: PushRegistrationIntentEpoch ) async -> Bool { let settings = await notificationSettings() guard isCurrentSettingsMutation(settingsMutationToken) else { @@ -502,7 +524,8 @@ public final class MobilePushCoordinator { persistEnabledIntent() await activateRegistrationIfNeeded( settingsMutationToken: settingsMutationToken, - registrationGeneration: registrationGeneration + registrationGeneration: registrationGeneration, + registrationIntentEpoch: registrationIntentEpoch ) await recoverRegistrationIfNeeded( settingsMutationToken: settingsMutationToken @@ -525,7 +548,8 @@ public final class MobilePushCoordinator { return await enable( trigger: "workspace_list", settingsMutationToken: settingsMutationToken, - registrationGeneration: registrationGeneration + registrationGeneration: registrationGeneration, + registrationIntentEpoch: registrationIntentEpoch ) case .unsupported: return true @@ -535,7 +559,8 @@ public final class MobilePushCoordinator { private func enable( trigger: String, settingsMutationToken: UUID, - registrationGeneration: UInt64 + registrationGeneration: UInt64, + registrationIntentEpoch: PushRegistrationIntentEpoch ) async -> Bool { guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { @@ -587,7 +612,8 @@ public final class MobilePushCoordinator { // the denied/unsupported system status below. await registration.applyEnabledIntent( true, - generation: registrationGeneration + generation: registrationGeneration, + intentEpoch: registrationIntentEpoch ) guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { @@ -613,7 +639,8 @@ public final class MobilePushCoordinator { analytics.capture("ios_push_optin_granted", ["trigger": .string(trigger)]) await activateRegistrationIfNeeded( settingsMutationToken: settingsMutationToken, - registrationGeneration: registrationGeneration + registrationGeneration: registrationGeneration, + registrationIntentEpoch: registrationIntentEpoch ) guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { @@ -633,7 +660,8 @@ public final class MobilePushCoordinator { guard let self else { return false } await self.finishDisable( settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration + registrationGeneration: intent.registrationGeneration, + registrationIntentEpoch: intent.registrationIntentEpoch ) return self.isCurrentSettingsMutation(intent.token) } @@ -720,14 +748,16 @@ public final class MobilePushCoordinator { private func finishDisable( settingsMutationToken: UUID, - registrationGeneration: UInt64 + registrationGeneration: UInt64, + registrationIntentEpoch: PushRegistrationIntentEpoch ) async { guard isCurrentSettingsMutation(settingsMutationToken), !enabledMirror else { return } await registration.applyEnabledIntent( false, - generation: registrationGeneration + generation: registrationGeneration, + intentEpoch: registrationIntentEpoch ) guard isCurrentSettingsMutation(settingsMutationToken), !enabledMirror else { return @@ -790,13 +820,15 @@ public final class MobilePushCoordinator { private func refreshReadiness(settingsMutationToken: UUID) async { let registrationGeneration = self.registrationIntentGeneration + let registrationIntentEpoch = self.registrationIntentEpoch let settings = await notificationSettings() guard isCurrentSettingsMutation(settingsMutationToken) else { return } apply(settings: settings) if enabledMirror, Self.permitsDelivery(settings.authorization) { await activateRegistrationIfNeeded( settingsMutationToken: settingsMutationToken, - registrationGeneration: registrationGeneration + registrationGeneration: registrationGeneration, + registrationIntentEpoch: registrationIntentEpoch ) } await recoverRegistrationIfNeeded( @@ -816,7 +848,8 @@ public final class MobilePushCoordinator { private func activateRegistrationIfNeeded( settingsMutationToken: UUID, - registrationGeneration: UInt64 + registrationGeneration: UInt64, + registrationIntentEpoch: PushRegistrationIntentEpoch ) async { guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror, @@ -840,7 +873,8 @@ public final class MobilePushCoordinator { // issuing another registration request. await registration.applyEnabledIntent( true, - generation: registrationGeneration + generation: registrationGeneration, + intentEpoch: registrationIntentEpoch ) guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { return diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift index bfb0a686b03..1589c4efcca 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift @@ -7,11 +7,12 @@ actor MobilePushMutationCompletion { UUID: CheckedContinuation ] = [:] + @discardableResult func resolve( _ outcome: MobilePushMutationOutcome, succeeded: Bool = false - ) { - guard result == nil else { return } + ) -> Bool { + guard result == nil else { return false } let resolved = MobilePushMutationResult( outcome: outcome, succeeded: succeeded @@ -22,6 +23,7 @@ actor MobilePushMutationCompletion { for waiter in waiters { waiter.resume(returning: resolved) } + return true } func wait() async -> MobilePushMutationResult { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift index 4e3f1ea16a2..bb6e5690f0c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift @@ -1,7 +1,9 @@ +import CmuxAuthRuntime import Foundation /// Carries the coordinator token and service generation for one push setting intent. struct MobilePushSettingsIntent { let token: UUID let registrationGeneration: UInt64 + let registrationIntentEpoch: PushRegistrationIntentEpoch } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index eb35e5cc05a..9fffea04b86 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -95,7 +95,11 @@ private actor LifecyclePushRegistration: PushRegistering { value = .disabled } - func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async { + func applyEnabledIntent( + _ enabled: Bool, + generation: UInt64, + intentEpoch: PushRegistrationIntentEpoch + ) async { guard generation >= latestIntentGeneration else { return } latestIntentGeneration = generation if enabled { diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 6e1428db61f..4a633c66ac0 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -4117,7 +4117,11 @@ struct InertPushRegistration: PushRegistering { } func setEnabled(_ enabled: Bool) async {} func disableAndUnregister() async {} - func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async {} + func applyEnabledIntent( + _ enabled: Bool, + generation: UInt64, + intentEpoch: PushRegistrationIntentEpoch + ) async {} func register(deviceToken: Data) async {} func deviceTokenRegistrationFailed() async {} func syncTokenIfPossible() async {} From 5528198cc19438d5fec797ec40849d62713ddeaa Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:30:29 -0700 Subject: [PATCH 075/117] test(push): cover startup drain and same-lane recovery --- .../PushRegistrationServiceTests.swift | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index a58b780aedc..3507f704680 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -811,6 +811,60 @@ actor RetryDelayRecorder { ) } + @Test func startupCleanupCannotDeleteTokenReenabledWhileDrainWaits() async { + let cleanupAuthenticationStarted = TestPhaseSignal() + let cleanupAuthenticationBlocker = TestContinuationBlocker() + let registrationStarted = TestPhaseSignal() + let registrationBlocker = TestContinuationBlocker() + let provider = MutablePushTokenProvider( + accountID: "account-a", + accessToken: "a-access", + refreshToken: "a-refresh" + ) + await provider.blockAuthenticatedSessionSnapshot( + started: cleanupAuthenticationStarted, + until: cleanupAuthenticationBlocker + ) + await PushRegistrationURLProtocol.script.reset([ + .gatedResponse( + 200, + started: registrationStarted, + blocker: registrationBlocker + ), + .response(200), + ]) + let (service, defaults) = makeScriptedService( + tokenProvider: provider, + accountID: nil, + seedDefaults: { defaults in + defaults.set(false, forKey: "cmux.notifications.pushEnabled") + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + defaults.set( + "account-a", + forKey: "cmux.notifications.registeredAccountID" + ) + } + ) + + let snapshots = await service.snapshots() + await cleanupAuthenticationStarted.waitUntilStarted() + let enable = Task { await service.setEnabled(true) } + await registrationStarted.waitUntilStarted() + + await cleanupAuthenticationBlocker.release() + for _ in 0..<1_000 { await Task.yield() } + await registrationBlocker.release() + await enable.value + + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["POST"] + ) + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) + #expect(await service.snapshot.backendState == .registered) + _ = snapshots + } + @Test func absentPreferenceDoesNotScheduleRegistrationCleanup() async { await PushRegistrationURLProtocol.script.reset([.response(200)]) let suite = "push-absent-preference-startup-\(UUID().uuidString)" @@ -1132,6 +1186,51 @@ actor RetryDelayRecorder { #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) } + @Test func stalledEnablePreparationAllowsSameDirectionRecovery() async { + await PushRegistrationURLProtocol.script.reset([.response(200)]) + let provider = MutablePushTokenProvider( + accountID: "account-a", + accessToken: "a-access", + refreshToken: "a-refresh" + ) + let firstEnableStarted = TestPhaseSignal() + let firstEnableBlocker = TestContinuationBlocker() + await provider.blockAuthenticatedSessionSnapshot( + started: firstEnableStarted, + until: firstEnableBlocker + ) + let (service, defaults) = makeScriptedService( + tokenProvider: provider, + accountID: nil + ) + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + + let firstEnable = Task { + await service.applyEnabledIntent(true, generation: 1) + } + await firstEnableStarted.waitUntilStarted() + + let recoveryFinished = TestPhaseSignal() + let recovery = Task { + await service.applyEnabledIntent(true, generation: 2) + await recoveryFinished.markStarted() + } + for _ in 0..<1_000 where !(await recoveryFinished.didStart) { + await Task.yield() + } + + #expect(await recoveryFinished.didStart) + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["POST"] + ) + #expect(await service.snapshot.backendState == .registered) + + await firstEnableBlocker.release() + await firstEnable.value + await recovery.value + } + @Test func inFlightRegistrationPersistsCleanupOwnerBeforePostCompletes() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() From 01fdd8ef3a9522e3cdab6882fd945179dc626a0f Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:33:37 -0700 Subject: [PATCH 076/117] fix(push): bound same-lane recovery and startup cleanup --- .../Push/PushRegistrationIntentQueue.swift | 81 ++++++++++++++----- .../Push/PushRegistrationService.swift | 44 ++++++---- 2 files changed, 90 insertions(+), 35 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift index aed2411e90f..f83b218f5bd 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift @@ -3,15 +3,21 @@ import Foundation /// Runs at most one enable and one disable preparation concurrently. /// /// Authentication and other pre-request work is not guaranteed to cooperate -/// with task cancellation. A stalled enable must therefore remain owned while -/// a newer opt-out advances on the independent disable lane. Repeated intents -/// on either lane replace one pending value instead of accumulating tasks. +/// with task cancellation. Each direction therefore owns one active worker and +/// one quarantined stale worker. This lets one same-direction recovery advance +/// while repeated retries coalesce instead of accumulating unbounded tasks. actor PushRegistrationIntentQueue { + private struct Worker { + let id: UUID + let intent: PushRegistrationIntent + let task: Task + } + private let operation: @Sendable (PushRegistrationIntent) async -> Void private var latestGeneration: UInt64 = 0 private var pendingIntents: [Bool: PushRegistrationIntent] = [:] - private var runningGenerations: [Bool: UInt64] = [:] - private var runningTasks: [Bool: Task] = [:] + private var runningWorkers: [Bool: Worker] = [:] + private var quarantinedWorkers: [Bool: Worker] = [:] private var completedIntent: PushRegistrationIntent? private var waiters: [UInt64: [UUID: CheckedContinuation]] = [:] @@ -26,7 +32,7 @@ actor PushRegistrationIntentQueue { let lane = intent.enabled if intent == completedIntent, pendingIntents[lane] == nil, - runningTasks[lane] == nil { + runningWorkers[lane] == nil { return } @@ -35,11 +41,12 @@ actor PushRegistrationIntentQueue { pendingIntents.removeAll() pendingIntents[lane] = intent resumeWaiters(before: intent.generation) - for (runningLane, generation) in runningGenerations - where generation < intent.generation { - runningTasks[runningLane]?.cancel() + for worker in runningWorkers.values + where worker.intent.generation < intent.generation { + worker.task.cancel() } - } else if runningGenerations[lane] != intent.generation { + quarantineStaleRunningWorkerIfPossible(on: lane) + } else if runningWorkers[lane]?.intent.generation != intent.generation { pendingIntents[lane] = intent } @@ -62,27 +69,47 @@ actor PushRegistrationIntentQueue { } private func startPendingIntentIfNeeded(on lane: Bool) { - guard runningTasks[lane] == nil, + guard runningWorkers[lane] == nil, let intent = pendingIntents.removeValue(forKey: lane) else { return } let operation = self.operation + let workerID = UUID() let task = Task { [weak self] in await operation(intent) - await self?.intentCompleted(intent, on: lane) + await self?.workerCompleted( + id: workerID, + intent: intent, + on: lane + ) } - runningGenerations[lane] = intent.generation - runningTasks[lane] = task + runningWorkers[lane] = Worker( + id: workerID, + intent: intent, + task: task + ) } - private func intentCompleted( - _ intent: PushRegistrationIntent, + private func workerCompleted( + id: UUID, + intent: PushRegistrationIntent, on lane: Bool ) { - guard runningGenerations[lane] == intent.generation else { + if runningWorkers[lane]?.id == id { + runningWorkers.removeValue(forKey: lane) + recordCompletion(intent) + resumeWaiters(for: intent.generation) + startPendingIntentIfNeeded(on: lane) return } - runningGenerations.removeValue(forKey: lane) - runningTasks.removeValue(forKey: lane) + guard quarantinedWorkers[lane]?.id == id else { return } + quarantinedWorkers.removeValue(forKey: lane) + recordCompletion(intent) + resumeWaiters(for: intent.generation) + quarantineStaleRunningWorkerIfPossible(on: lane) + startPendingIntentIfNeeded(on: lane) + } + + private func recordCompletion(_ intent: PushRegistrationIntent) { if let completedIntent { if intent.generation >= completedIntent.generation { self.completedIntent = intent @@ -90,8 +117,20 @@ actor PushRegistrationIntentQueue { } else { completedIntent = intent } - resumeWaiters(for: intent.generation) - startPendingIntentIfNeeded(on: lane) + } + + /// Moves one superseded worker out of the active slot. A second stalled + /// worker stays active until either it or the existing quarantine returns, + /// keeping the lane bounded to two uncooperative operations. + private func quarantineStaleRunningWorkerIfPossible(on lane: Bool) { + guard quarantinedWorkers[lane] == nil, + let pending = pendingIntents[lane], + let running = runningWorkers[lane], + running.intent.generation < pending.generation + else { return } + running.task.cancel() + runningWorkers.removeValue(forKey: lane) + quarantinedWorkers[lane] = running } private func resumeWaiters(before generation: UInt64) { diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 882bdca35d1..a040d4ff19d 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -137,7 +137,9 @@ public actor PushRegistrationService: PushRegistering { public func snapshots() -> AsyncStream { let id = UUID() if !isEnabled, !pendingUnregisters.isEmpty { - schedulePendingUnregisterContinuation() + schedulePendingUnregisterContinuation( + preferenceGeneration: preferenceIntentGeneration + ) } return AsyncStream { continuation in snapshotContinuations[id] = continuation @@ -344,8 +346,11 @@ public actor PushRegistrationService: PushRegistering { } private func syncTokenIfPossibleUnlocked() async { + let preferenceGeneration = preferenceIntentGeneration guard isEnabled else { - await retryPendingUnregisterIfPossible() + await retryPendingUnregisterIfPossible( + preferenceGeneration: preferenceGeneration + ) publish(.disabled) return } @@ -544,19 +549,11 @@ public actor PushRegistrationService: PushRegistering { switch request { case let .success(context): requestSession = context.session - // The POST may commit even if this process is suspended before its - // response arrives. Treat the authenticated owner as a cleanup - // obligation until the acknowledgement clears it. - if let requestSession { - persistPendingUnregister( - tokenHex: tokenHex, - accountID: requestSession.accountID - ) - } result = await performRegistration( context.request, tokenHex: tokenHex, - generation: generation + generation: generation, + cleanupAccountID: requestSession?.accountID ) case let .failure(failure): requestSession = nil @@ -761,8 +758,16 @@ public actor PushRegistrationService: PushRegistering { context.request, preferenceGeneration: preferenceGeneration ) else { return false } + if let preferenceGeneration, + !isCurrentOptOut(preferenceGeneration) { + return false + } if let session = context.session { - return await tokenProvider.isAuthenticatedSessionCurrent(session) + guard await tokenProvider.isAuthenticatedSessionCurrent(session) + else { return false } + if let preferenceGeneration { + return isCurrentOptOut(preferenceGeneration) + } } return true } @@ -818,7 +823,8 @@ public actor PushRegistrationService: PushRegistering { private func performRegistration( _ request: URLRequest, tokenHex: String, - generation: UUID + generation: UUID, + cleanupAccountID: String? ) async -> RegistrationResult { await networkMutationGate.withLock { [self] in guard await self.isCurrentUpload( @@ -827,6 +833,16 @@ public actor PushRegistrationService: PushRegistering { ) else { return .cancelled } + // The POST may commit even if this process is suspended before its + // response arrives. Persist its cleanup owner only after the gate + // admits this still-current request, so a quarantined stale worker + // cannot recreate a tombstone after a newer POST has succeeded. + if let cleanupAccountID { + await self.persistPendingUnregister( + tokenHex: tokenHex, + accountID: cleanupAccountID + ) + } return await self.performRegistrationRequest(request) } ?? .cancelled } From 601904189a0b60c712e595af3cc80b194eafd58c Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:39:00 -0700 Subject: [PATCH 077/117] test(push): wait for same-lane recovery state --- .../PushRegistrationServiceTests.swift | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 3507f704680..74d6afaf418 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1210,16 +1210,11 @@ actor RetryDelayRecorder { } await firstEnableStarted.waitUntilStarted() - let recoveryFinished = TestPhaseSignal() let recovery = Task { await service.applyEnabledIntent(true, generation: 2) - await recoveryFinished.markStarted() - } - for _ in 0..<1_000 where !(await recoveryFinished.didStart) { - await Task.yield() } - #expect(await recoveryFinished.didStart) + #expect(await wait(for: .registered, from: service)) #expect( await PushRegistrationURLProtocol.script.requests .map(\.httpMethod) == ["POST"] From 61b1afca7ad4dd13a197c7a7356c5fca4a260053 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:47:52 -0700 Subject: [PATCH 078/117] test(push): require fresh bounded recovery after timeout --- .../LifecycleSyncGate.swift | 13 ++++++ .../MobilePushCoordinatorLifecycleTests.swift | 45 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift index 8133d68c040..9fdfeb74dd8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift @@ -26,6 +26,19 @@ actor LifecycleSyncGate { } } + func waitUntilStartCount( + _ count: Int, + timeout: Duration = .seconds(1) + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while starts < count { + guard clock.now < deadline else { return false } + try? await clock.sleep(for: .milliseconds(1)) + } + return true + } + func release() { released = true let waiters = releaseWaiters diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 9fffea04b86..feb8ad74ec0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -1119,4 +1119,49 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(await gate.starts == 1) #expect(coordinator.registrationSnapshot.backendState == .registered) } + + @MainActor + @Test func timedOutRegistrationRecoveryStartsOneBoundedFreshRetry() async { + let syncGate = LifecycleSyncGate() + let timeoutGate = LifecycleSyncGate() + let registration = LifecyclePushRegistration( + snapshot: PushRegistrationSnapshot( + isEnabled: true, + hasDeviceToken: true, + backendState: .failed(.networkUnavailable) + ), + syncGate: syncGate + ) + let suiteName = "push-coordinator-recovery-timeout-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + authorizationStatus: { .authorized }, + settingsMutationSleep: { _ in await timeoutGate.pause() } + ) + + let first = Task { @MainActor in await coordinator.enable() } + await syncGate.waitUntilStarted() + await timeoutGate.waitUntilStarted() + await timeoutGate.release() + _ = await first.value + + let second = Task { @MainActor in + await coordinator.networkDidBecomeReachable() + } + let freshRetryStarted = await syncGate.waitUntilStartCount(2) + let third = Task { @MainActor in + await coordinator.networkDidBecomeReachable() + } + for _ in 0..<100 { await Task.yield() } + + #expect(freshRetryStarted) + #expect(await syncGate.starts == 2) + + await syncGate.release() + await second.value + await third.value + } } From 9b0f659a9e631f6eb4c22d11a18f62b464677a14 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:50:29 -0700 Subject: [PATCH 079/117] fix(push): bound cached registration recovery --- .../MobilePushCoordinator.swift | 166 ++++++++++++++---- 1 file changed, 131 insertions(+), 35 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 20a069d5c1b..e917d906c2c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -97,6 +97,7 @@ public final class MobilePushCoordinator { /// app-lifetime settings slot forever. The sleep is injected for /// deterministic timeout tests. private static let settingsMutationTimeout: Duration = .seconds(30) + private static let registrationRecoveryTimeout: Duration = .seconds(30) /// The iOS API endpoint that accepted this installation's APNs token. public let phoneAPIOrigin: String /// Live OS authorization, refreshed at launch, on foreground, and when @@ -117,9 +118,15 @@ public final class MobilePushCoordinator { @ObservationIgnored private let unregisterForRemoteNotifications: @MainActor () -> Void @ObservationIgnored private var registrationSnapshotTask: Task? - @ObservationIgnored private var registrationRecoveryTask: - Task? - @ObservationIgnored private var registrationRecoveryToken: UUID? + /// Recovery has the same bounded ownership model as settings mutations: + /// one active worker and one timed-out or superseded quarantine worker. + @ObservationIgnored private var registrationRecoveryWorkers: + MobilePushMutationWorkers? + @ObservationIgnored private var quarantinedRegistrationRecoveryWorkers: + MobilePushMutationWorkers? + @ObservationIgnored private var timedOutRegistrationRecoveryCompletion: + MobilePushMutationCompletion? + @ObservationIgnored private var registrationRecoverySettingsToken: UUID? @ObservationIgnored private var registrationIntentTask: Task? /// Authentication and settings reads may ignore cancellation. Each /// direction owns one active worker plus one timed-out quarantine slot. @@ -327,6 +334,7 @@ public final class MobilePushCoordinator { return } settingsMutationDirectionsNeedingRetry.insert(enabled) + supersedeRegistrationRecovery(settingsMutationToken: token) let isCurrent = isCurrentSettingsMutation(token) var releasedLane = false if quarantinedSettingsMutationWorkers[enabled] == nil, @@ -365,6 +373,9 @@ public final class MobilePushCoordinator { /// each suspension before an operation publishes or persists state. @discardableResult private func beginSettingsIntent(_ enabled: Bool) -> MobilePushSettingsIntent { + supersedeRegistrationRecovery( + settingsMutationToken: settingsMutationToken + ) cancelSettingsMutation() settingsMutationDirectionsNeedingRetry.remove(enabled) let token = UUID() @@ -740,9 +751,6 @@ public final class MobilePushCoordinator { defaults.set(false, forKey: Self.enabledKey) registrationSnapshot = .disabled hasRequestedRemoteRegistration = false - registrationRecoveryTask?.cancel() - registrationRecoveryTask = nil - registrationRecoveryToken = nil unregisterForRemoteNotifications() } @@ -936,34 +944,12 @@ public final class MobilePushCoordinator { || current.backendState.isRecoverable else { return } - let recovery: Task - let ownsRecovery: Bool - let recoveryToken: UUID? - if let registrationRecoveryTask { - recovery = registrationRecoveryTask - ownsRecovery = false - recoveryToken = nil - } else { - let registration = self.registration - let token = UUID() - recovery = Task { - await registration.syncTokenIfPossible() - return await registration.snapshot - } - registrationRecoveryTask = recovery - registrationRecoveryToken = token - ownsRecovery = true - recoveryToken = token - } - let recovered = await recovery.value - // Clear an owned task before checking the caller's generation or - // cancellation. The recovery worker is independent of the caller's - // waiter, so a cancelled waiter still must release the cached worker - // for the next recovery attempt. - if ownsRecovery, registrationRecoveryToken == recoveryToken { - registrationRecoveryTask = nil - registrationRecoveryToken = nil - } + guard let workers = startRegistrationRecovery( + settingsMutationToken: settingsMutationToken + ) else { return } + let result = await workers.completion.wait() + guard result.outcome == .completed else { return } + let recovered = await registration.snapshot guard isCurrentSettingsMutation(settingsMutationToken) else { return } @@ -974,6 +960,113 @@ public final class MobilePushCoordinator { recordRegistrationOutcome(recovered) } + private func startRegistrationRecovery( + settingsMutationToken: UUID + ) -> MobilePushMutationWorkers? { + if let registrationRecoveryWorkers { + guard registrationRecoverySettingsToken == settingsMutationToken + else { return nil } + return registrationRecoveryWorkers + } + + let completion = MobilePushMutationCompletion() + let registration = self.registration + let operationTask = Task { @MainActor [weak self] in + guard let self else { + await completion.resolve(.cancelled) + return + } + await registration.syncTokenIfPossible() + await completion.resolve(.completed, succeeded: true) + self.finishRegistrationRecovery(completion: completion) + } + let timeoutTask = Task { @MainActor [weak self, settingsMutationSleep] in + do { + try await settingsMutationSleep( + Self.registrationRecoveryTimeout + ) + guard await completion.resolve(.timedOut) else { return } + self?.handleRegistrationRecoveryTimeout( + completion: completion + ) + } catch { + // Recovery completed first and cancelled this sleeper. + } + } + let workers = MobilePushMutationWorkers( + operation: operationTask, + timeout: timeoutTask, + completion: completion + ) + registrationRecoveryWorkers = workers + registrationRecoverySettingsToken = settingsMutationToken + return workers + } + + private func handleRegistrationRecoveryTimeout( + completion: MobilePushMutationCompletion + ) { + guard registrationRecoveryWorkers?.completion === completion else { + return + } + registrationRecoveryWorkers?.operation.cancel() + if quarantinedRegistrationRecoveryWorkers == nil { + quarantinedRegistrationRecoveryWorkers = registrationRecoveryWorkers + registrationRecoveryWorkers = nil + registrationRecoverySettingsToken = nil + timedOutRegistrationRecoveryCompletion = nil + } else { + timedOutRegistrationRecoveryCompletion = completion + } + } + + private func supersedeRegistrationRecovery( + settingsMutationToken: UUID + ) { + guard registrationRecoverySettingsToken == settingsMutationToken, + let workers = registrationRecoveryWorkers else { return } + workers.operation.cancel() + if quarantinedRegistrationRecoveryWorkers == nil { + registrationRecoveryWorkers = nil + registrationRecoverySettingsToken = nil + quarantinedRegistrationRecoveryWorkers = workers + timedOutRegistrationRecoveryCompletion = nil + } else { + // Keep ownership until a quarantine slot opens. A nil token keeps + // future callers from reusing this superseded worker. + registrationRecoverySettingsToken = nil + } + } + + private func finishRegistrationRecovery( + completion: MobilePushMutationCompletion + ) { + if registrationRecoveryWorkers?.completion === completion { + let workers = registrationRecoveryWorkers + registrationRecoveryWorkers = nil + registrationRecoverySettingsToken = nil + workers?.timeout.cancel() + if timedOutRegistrationRecoveryCompletion === completion { + timedOutRegistrationRecoveryCompletion = nil + } + return + } + guard quarantinedRegistrationRecoveryWorkers?.completion + === completion else { return } + let workers = quarantinedRegistrationRecoveryWorkers + quarantinedRegistrationRecoveryWorkers = nil + workers?.timeout.cancel() + + guard let active = registrationRecoveryWorkers, + timedOutRegistrationRecoveryCompletion === active.completion + || registrationRecoverySettingsToken == nil + else { return } + registrationRecoveryWorkers = nil + registrationRecoverySettingsToken = nil + timedOutRegistrationRecoveryCompletion = nil + quarantinedRegistrationRecoveryWorkers = active + } + private func recordRegistrationOutcome(_ snapshot: PushRegistrationSnapshot) { switch snapshot.backendState { case .registered: @@ -1120,7 +1213,10 @@ public final class MobilePushCoordinator { } registrationIntentTask?.cancel() registrationSnapshotTask?.cancel() - registrationRecoveryTask?.cancel() + registrationRecoveryWorkers?.operation.cancel() + registrationRecoveryWorkers?.timeout.cancel() + quarantinedRegistrationRecoveryWorkers?.operation.cancel() + quarantinedRegistrationRecoveryWorkers?.timeout.cancel() } /// Whether to show a banner while the app is foreground. Suppressed when the From 890b6b95316167f479c11ab8b6abe94d60d85d2b Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Thu, 13 Aug 2026 23:51:33 -0700 Subject: [PATCH 080/117] refactor(push): isolate intent worker type --- .../Push/PushRegistrationIntentQueue.swift | 13 ++++--------- .../Push/PushRegistrationIntentWorker.swift | 8 ++++++++ 2 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentWorker.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift index f83b218f5bd..134ba402496 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift @@ -7,17 +7,12 @@ import Foundation /// one quarantined stale worker. This lets one same-direction recovery advance /// while repeated retries coalesce instead of accumulating unbounded tasks. actor PushRegistrationIntentQueue { - private struct Worker { - let id: UUID - let intent: PushRegistrationIntent - let task: Task - } - private let operation: @Sendable (PushRegistrationIntent) async -> Void private var latestGeneration: UInt64 = 0 private var pendingIntents: [Bool: PushRegistrationIntent] = [:] - private var runningWorkers: [Bool: Worker] = [:] - private var quarantinedWorkers: [Bool: Worker] = [:] + private var runningWorkers: [Bool: PushRegistrationIntentWorker] = [:] + private var quarantinedWorkers: + [Bool: PushRegistrationIntentWorker] = [:] private var completedIntent: PushRegistrationIntent? private var waiters: [UInt64: [UUID: CheckedContinuation]] = [:] @@ -82,7 +77,7 @@ actor PushRegistrationIntentQueue { on: lane ) } - runningWorkers[lane] = Worker( + runningWorkers[lane] = PushRegistrationIntentWorker( id: workerID, intent: intent, task: task diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentWorker.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentWorker.swift new file mode 100644 index 00000000000..e4e264f9823 --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentWorker.swift @@ -0,0 +1,8 @@ +import Foundation + +/// Owns one queue worker and the intent generation it may reconcile. +struct PushRegistrationIntentWorker { + let id: UUID + let intent: PushRegistrationIntent + let task: Task +} From 86c89f5515b70f797fe1c85196f7585e7cad6b7e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:11:59 -0700 Subject: [PATCH 081/117] test(push): protect newer registration from stale sign-out --- .../PushRegistrationServiceTests.swift | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 74d6afaf418..64668892a93 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1528,6 +1528,54 @@ actor RetryDelayRecorder { ) } + @Test func delayedSignOutCannotDeleteNewerSameAccountRegistration() async { + let signOutStarted = TestPhaseSignal() + let signOutBlocker = TestContinuationBlocker() + await PushRegistrationURLProtocol.script.reset([ + .response(200), + .response(200), + ]) + let provider = MutablePushTokenProvider( + accountID: "account-a", + accessToken: "a-access", + refreshToken: "a-refresh" + ) + await provider.blockAuthenticatedSessionSnapshot( + started: signOutStarted, + until: signOutBlocker + ) + let (service, defaults) = makeScriptedService( + tokenProvider: provider, + accountID: nil + ) + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") + defaults.set( + "account-a", + forKey: "cmux.notifications.registeredAccountID" + ) + + let delayedSignOut = Task { + await service.unregisterFromServer() + } + await signOutStarted.waitUntilStarted() + + await service.syncTokenIfPossible() + await signOutBlocker.release() + await delayedSignOut.value + + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["POST"] + ) + #expect(await service.snapshot.backendState == .registered) + #expect( + defaults.string( + forKey: "cmux.notifications.registeredAccountID" + ) == "account-a" + ) + } + @Test func oldAccountLatePostCannotTakeTokenBackFromNewAccount() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() From e599af99e56e39b5098ebf757bf2ed192528b691 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:14:36 -0700 Subject: [PATCH 082/117] fix(push): order sign-out with newer registration --- .../Push/PushRegistrationService.swift | 78 ++++++++++++++++--- 1 file changed, 66 insertions(+), 12 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index a040d4ff19d..24c2c73343f 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -36,6 +36,9 @@ public actor PushRegistrationService: PushRegistering { private var retryTask: Task? private var unregisterDrainTask: Task? private var operationGeneration = UUID() + /// Orders registration and direct sign-out mutations before either path + /// can suspend while preparing credentials or waiting for the network gate. + private var serverMutationGeneration: UInt64 = 0 /// Every preference mutation, including the legacy public mutation APIs, /// is assigned one service-owned generation and enters this queue. A /// direct mutation therefore advances the same ordering domain as a @@ -376,14 +379,18 @@ public actor PushRegistrationService: PushRegistering { /// Durably schedules and attempts removal of the currently owned token. public func unregisterFromServer() async { + let serverMutationGeneration = beginServerMutation() persistCapturedUnregisterObligation(accountID: nil) await unregisterIntentGate.withLock { [self] in - await self.unregisterFromServerUnlocked() + await self.unregisterFromServerUnlocked( + serverMutationGeneration: serverMutationGeneration + ) } } private func unregisterFromServerUnlocked( - preferenceGeneration: UInt64? = nil + preferenceGeneration: UInt64? = nil, + serverMutationGeneration: UInt64? = nil ) async { cancelRetry() guard let hex = cachedTokenHex else { return } @@ -405,7 +412,8 @@ public actor PushRegistrationService: PushRegistering { if await sendDelete( tokenHex: hex, sessionSnapshot: session, - preferenceGeneration: preferenceGeneration + preferenceGeneration: preferenceGeneration, + serverMutationGeneration: serverMutationGeneration ) { clearPendingUnregister(tokenHex: hex, accountID: ownerID) clearRegisteredOwner(accountID: ownerID, tokenHex: hex) @@ -420,12 +428,14 @@ public actor PushRegistrationService: PushRegistering { /// - accessToken: The captured (or teardown-minted) access token. /// - refreshToken: The captured refresh token. public func unregisterFromServer(accessToken: String?, refreshToken: String?) async { + let serverMutationGeneration = beginServerMutation() persistCapturedUnregisterObligation(accountID: nil) await unregisterIntentGate.withLock { [self] in await self.unregisterFromServerUnlocked( accountID: nil, accessToken: accessToken, - refreshToken: refreshToken + refreshToken: refreshToken, + serverMutationGeneration: serverMutationGeneration ) } } @@ -436,12 +446,14 @@ public actor PushRegistrationService: PushRegistering { accessToken: String?, refreshToken: String? ) async { + let serverMutationGeneration = beginServerMutation() persistCapturedUnregisterObligation(accountID: capturedAccountID) await unregisterIntentGate.withLock { [self] in await self.unregisterFromServerUnlocked( accountID: capturedAccountID, accessToken: accessToken, - refreshToken: refreshToken + refreshToken: refreshToken, + serverMutationGeneration: serverMutationGeneration ) } } @@ -460,7 +472,8 @@ public actor PushRegistrationService: PushRegistering { private func unregisterFromServerUnlocked( accountID capturedAccountID: String?, accessToken: String?, - refreshToken: String? + refreshToken: String?, + serverMutationGeneration: UInt64 ) async { cancelRetry() guard let hex = cachedTokenHex else { return } @@ -493,7 +506,8 @@ public actor PushRegistrationService: PushRegistering { if await sendDelete( tokenHex: hex, capturedAccessToken: accessToken, - capturedRefreshToken: refreshToken + capturedRefreshToken: refreshToken, + serverMutationGeneration: serverMutationGeneration ), let ownerID { clearPendingUnregister(tokenHex: hex, accountID: ownerID) clearRegisteredOwner(accountID: ownerID, tokenHex: hex) @@ -515,9 +529,11 @@ public actor PushRegistrationService: PushRegistering { private func upload(tokenHex: String) async { operationGeneration = UUID() let generation = operationGeneration + let serverMutationGeneration = beginServerMutation() await attemptUpload( tokenHex: tokenHex, generation: generation, + serverMutationGeneration: serverMutationGeneration, remainingDelays: retryDelays ) } @@ -525,6 +541,7 @@ public actor PushRegistrationService: PushRegistering { private func attemptUpload( tokenHex: String, generation: UUID, + serverMutationGeneration: UInt64, remainingDelays: [Duration] ) async { guard isEnabled, generation == operationGeneration, @@ -553,6 +570,7 @@ public actor PushRegistrationService: PushRegistering { context.request, tokenHex: tokenHex, generation: generation, + serverMutationGeneration: serverMutationGeneration, cleanupAccountID: requestSession?.accountID ) case let .failure(failure): @@ -561,6 +579,7 @@ public actor PushRegistrationService: PushRegistering { } let operationIsCurrent = isEnabled && generation == operationGeneration + && serverMutationGeneration == self.serverMutationGeneration && cachedTokenHex == tokenHex let sessionIsCurrent: Bool if let requestSession { @@ -602,6 +621,7 @@ public actor PushRegistrationService: PushRegistering { retryAfter: nil, tokenHex: tokenHex, generation: generation, + serverMutationGeneration: serverMutationGeneration, remainingDelays: remainingDelays ) return @@ -643,6 +663,7 @@ public actor PushRegistrationService: PushRegistering { retryAfter: nil, tokenHex: tokenHex, generation: generation, + serverMutationGeneration: serverMutationGeneration, remainingDelays: remainingDelays ) } @@ -657,6 +678,7 @@ public actor PushRegistrationService: PushRegistering { retryAfter: retryAfter, tokenHex: tokenHex, generation: generation, + serverMutationGeneration: serverMutationGeneration, remainingDelays: remainingDelays ) } @@ -667,6 +689,7 @@ public actor PushRegistrationService: PushRegistering { retryAfter: Duration?, tokenHex: String, generation: UUID, + serverMutationGeneration: UInt64, remainingDelays: [Duration] ) { guard failure.isRecoverable, !remainingDelays.isEmpty else { return } @@ -686,6 +709,7 @@ public actor PushRegistrationService: PushRegistering { await self?.attemptUpload( tokenHex: tokenHex, generation: generation, + serverMutationGeneration: serverMutationGeneration, remainingDelays: laterDelays ) } @@ -744,7 +768,8 @@ public actor PushRegistrationService: PushRegistering { capturedAccessToken: String? = nil, capturedRefreshToken: String? = nil, sessionSnapshot: AuthenticatedSessionSnapshot? = nil, - preferenceGeneration: UInt64? = nil + preferenceGeneration: UInt64? = nil, + serverMutationGeneration: UInt64? = nil ) async -> Bool { guard case let .success(context) = await makeRequest( method: "DELETE", @@ -756,12 +781,17 @@ public actor PushRegistrationService: PushRegistering { ) else { return false } guard await performDelete( context.request, - preferenceGeneration: preferenceGeneration + preferenceGeneration: preferenceGeneration, + serverMutationGeneration: serverMutationGeneration ) else { return false } if let preferenceGeneration, !isCurrentOptOut(preferenceGeneration) { return false } + if let serverMutationGeneration, + serverMutationGeneration != self.serverMutationGeneration { + return false + } if let session = context.session { guard await tokenProvider.isAuthenticatedSessionCurrent(session) else { return false } @@ -824,12 +854,14 @@ public actor PushRegistrationService: PushRegistering { _ request: URLRequest, tokenHex: String, generation: UUID, + serverMutationGeneration: UInt64, cleanupAccountID: String? ) async -> RegistrationResult { await networkMutationGate.withLock { [self] in guard await self.isCurrentUpload( tokenHex: tokenHex, - generation: generation + generation: generation, + serverMutationGeneration: serverMutationGeneration ) else { return .cancelled } @@ -847,9 +879,14 @@ public actor PushRegistrationService: PushRegistering { } ?? .cancelled } - private func isCurrentUpload(tokenHex: String, generation: UUID) -> Bool { + private func isCurrentUpload( + tokenHex: String, + generation: UUID, + serverMutationGeneration: UInt64 + ) -> Bool { isEnabled && generation == operationGeneration + && serverMutationGeneration == self.serverMutationGeneration && cachedTokenHex == tokenHex } @@ -887,7 +924,8 @@ public actor PushRegistrationService: PushRegistering { private func performDelete( _ request: URLRequest, - preferenceGeneration: UInt64? = nil + preferenceGeneration: UInt64? = nil, + serverMutationGeneration: UInt64? = nil ) async -> Bool { await networkMutationGate.withLock { [self] in if let preferenceGeneration { @@ -895,10 +933,26 @@ public actor PushRegistrationService: PushRegistering { return false } } + if let serverMutationGeneration { + guard await self.isCurrentServerMutation( + serverMutationGeneration + ) else { + return false + } + } return await self.performDeleteRequest(request) } ?? false } + private func beginServerMutation() -> UInt64 { + serverMutationGeneration &+= 1 + return serverMutationGeneration + } + + private func isCurrentServerMutation(_ generation: UInt64) -> Bool { + generation == serverMutationGeneration + } + private func isCurrentOptOut(_ generation: UInt64) -> Bool { preferenceIntentGeneration == generation && !isEnabled } From c33317abdf48b21847f6ac57f9d9e8f4e11b9c9c Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:52:45 -0700 Subject: [PATCH 083/117] refactor(push): use one reconciliation worker --- .../Push/PushRegistrationCleanupRequest.swift | 10 + .../Push/PushRegistrationIntentQueue.swift | 157 -------- .../Push/PushRegistrationIntentWorker.swift | 8 - .../Push/PushRegistrationMutationGate.swift | 68 ---- .../Push/PushRegistrationService.swift | 376 +++++++++++------- .../PushRegistrationMutationGateTests.swift | 32 -- .../PushRegistrationServiceTests.swift | 60 ++- 7 files changed, 274 insertions(+), 437 deletions(-) create mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationCleanupRequest.swift delete mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift delete mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentWorker.swift delete mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift delete mode 100644 Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationMutationGateTests.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationCleanupRequest.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationCleanupRequest.swift new file mode 100644 index 00000000000..2d77c661a56 --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationCleanupRequest.swift @@ -0,0 +1,10 @@ +/// One direct cleanup request folded into the service's single reconciliation loop. +enum PushRegistrationCleanupRequest: Sendable { + case live(serverMutationGeneration: UInt64) + case captured( + accountID: String?, + accessToken: String?, + refreshToken: String?, + serverMutationGeneration: UInt64 + ) +} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift deleted file mode 100644 index 134ba402496..00000000000 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentQueue.swift +++ /dev/null @@ -1,157 +0,0 @@ -import Foundation - -/// Runs at most one enable and one disable preparation concurrently. -/// -/// Authentication and other pre-request work is not guaranteed to cooperate -/// with task cancellation. Each direction therefore owns one active worker and -/// one quarantined stale worker. This lets one same-direction recovery advance -/// while repeated retries coalesce instead of accumulating unbounded tasks. -actor PushRegistrationIntentQueue { - private let operation: @Sendable (PushRegistrationIntent) async -> Void - private var latestGeneration: UInt64 = 0 - private var pendingIntents: [Bool: PushRegistrationIntent] = [:] - private var runningWorkers: [Bool: PushRegistrationIntentWorker] = [:] - private var quarantinedWorkers: - [Bool: PushRegistrationIntentWorker] = [:] - private var completedIntent: PushRegistrationIntent? - private var waiters: [UInt64: [UUID: CheckedContinuation]] = [:] - - /// Creates a queue that delegates each live intent to the registration service. - init(operation: @escaping @Sendable (PushRegistrationIntent) async -> Void) { - self.operation = operation - } - - /// Replaces stale pending work and waits for this intent to be handled. - func submit(_ intent: PushRegistrationIntent) async { - guard intent.generation >= latestGeneration else { return } - let lane = intent.enabled - if intent == completedIntent, - pendingIntents[lane] == nil, - runningWorkers[lane] == nil { - return - } - - if intent.generation > latestGeneration { - latestGeneration = intent.generation - pendingIntents.removeAll() - pendingIntents[lane] = intent - resumeWaiters(before: intent.generation) - for worker in runningWorkers.values - where worker.intent.generation < intent.generation { - worker.task.cancel() - } - quarantineStaleRunningWorkerIfPossible(on: lane) - } else if runningWorkers[lane]?.intent.generation != intent.generation { - pendingIntents[lane] = intent - } - - let waiterID = UUID() - startPendingIntentIfNeeded(on: lane) - await withTaskCancellationHandler(operation: { - await withCheckedContinuation { continuation in - if Task.isCancelled { - continuation.resume() - } else { - waiters[intent.generation, default: [:]][waiterID] = continuation - } - } - }, onCancel: { - Task { await self.cancelWaiter( - generation: intent.generation, - waiterID: waiterID - ) } - }) - } - - private func startPendingIntentIfNeeded(on lane: Bool) { - guard runningWorkers[lane] == nil, - let intent = pendingIntents.removeValue(forKey: lane) - else { return } - let operation = self.operation - let workerID = UUID() - let task = Task { [weak self] in - await operation(intent) - await self?.workerCompleted( - id: workerID, - intent: intent, - on: lane - ) - } - runningWorkers[lane] = PushRegistrationIntentWorker( - id: workerID, - intent: intent, - task: task - ) - } - - private func workerCompleted( - id: UUID, - intent: PushRegistrationIntent, - on lane: Bool - ) { - if runningWorkers[lane]?.id == id { - runningWorkers.removeValue(forKey: lane) - recordCompletion(intent) - resumeWaiters(for: intent.generation) - startPendingIntentIfNeeded(on: lane) - return - } - guard quarantinedWorkers[lane]?.id == id else { return } - quarantinedWorkers.removeValue(forKey: lane) - recordCompletion(intent) - resumeWaiters(for: intent.generation) - quarantineStaleRunningWorkerIfPossible(on: lane) - startPendingIntentIfNeeded(on: lane) - } - - private func recordCompletion(_ intent: PushRegistrationIntent) { - if let completedIntent { - if intent.generation >= completedIntent.generation { - self.completedIntent = intent - } - } else { - completedIntent = intent - } - } - - /// Moves one superseded worker out of the active slot. A second stalled - /// worker stays active until either it or the existing quarantine returns, - /// keeping the lane bounded to two uncooperative operations. - private func quarantineStaleRunningWorkerIfPossible(on lane: Bool) { - guard quarantinedWorkers[lane] == nil, - let pending = pendingIntents[lane], - let running = runningWorkers[lane], - running.intent.generation < pending.generation - else { return } - running.task.cancel() - runningWorkers.removeValue(forKey: lane) - quarantinedWorkers[lane] = running - } - - private func resumeWaiters(before generation: UInt64) { - let staleGenerations = waiters.keys.filter { $0 < generation } - for staleGeneration in staleGenerations { - resumeWaiters(for: staleGeneration) - } - } - - private func resumeWaiters(for generation: UInt64) { - guard let generationWaiters = waiters.removeValue(forKey: generation) - else { return } - for continuation in generationWaiters.values { - continuation.resume() - } - } - - private func cancelWaiter(generation: UInt64, waiterID: UUID) { - guard var generationWaiters = waiters[generation], - let continuation = generationWaiters.removeValue(forKey: waiterID) - else { return } - if generationWaiters.isEmpty { - waiters.removeValue(forKey: generation) - } else { - waiters[generation] = generationWaiters - } - continuation.resume() - } -} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentWorker.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentWorker.swift deleted file mode 100644 index e4e264f9823..00000000000 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentWorker.swift +++ /dev/null @@ -1,8 +0,0 @@ -import Foundation - -/// Owns one queue worker and the intent generation it may reconcile. -struct PushRegistrationIntentWorker { - let id: UUID - let intent: PushRegistrationIntent - let task: Task -} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift deleted file mode 100644 index dc7173230b4..00000000000 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationMutationGate.swift +++ /dev/null @@ -1,68 +0,0 @@ -import Foundation - -/// Serializes registration mutations across an actor's suspension points. -/// An actor alone can re-enter while URLSession is awaiting a response. The -/// operation runs in an independent worker so cancelling a UI waiter cannot -/// abandon a request after the server may have committed it. -actor PushRegistrationMutationGate { - private var isHeld = false - private var waiters: [( - id: UUID, - continuation: CheckedContinuation - )] = [] - - func withLock( - _ operation: @escaping @Sendable () async -> Value - ) async -> Value? { - guard await acquire() else { return nil } - // Cancellation can arrive after `release()` resumes this waiter but - // before its continuation gets scheduled. Give the lock back instead - // of starting a mutation that the caller has already abandoned. - guard !Task.isCancelled else { - release() - return nil - } - let worker = Task { - await operation() - } - defer { release() } - return await worker.value - } - - private func acquire() async -> Bool { - guard !Task.isCancelled else { return false } - guard isHeld else { - isHeld = true - return true - } - let id = UUID() - return await withTaskCancellationHandler(operation: { - await withCheckedContinuation { continuation in - if Task.isCancelled { - continuation.resume(returning: false) - } else { - waiters.append((id: id, continuation: continuation)) - } - } - }, onCancel: { - Task { await self.cancelWaiter(id: id) } - }) - } - - private func cancelWaiter(id: UUID) { - guard let index = waiters.firstIndex(where: { $0.id == id }) else { - return - } - let waiter = waiters.remove(at: index) - waiter.continuation.resume(returning: false) - } - - private func release() { - while !waiters.isEmpty { - let waiter = waiters.removeFirst() - waiter.continuation.resume(returning: true) - return - } - isHeld = false - } -} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 24c2c73343f..5caab9d02d1 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -25,24 +25,27 @@ public actor PushRegistrationService: PushRegistering { private let retryDelays: [Duration] private let retryJitter: @Sendable (ClosedRange) -> Double private let retrySleep: @Sendable (Duration) async throws -> Void - /// Direct sign-out cleanup overloads share captured credentials and remain - /// serialized independently from preference reconciliation. - private let unregisterIntentGate = PushRegistrationMutationGate() - /// The actor itself is re-entrant across URLSession suspension points. - /// Serialize actual POST/DELETE requests so a late response cannot race a - /// newer request. Higher-level reconciliation remains concurrent so account - /// changes can still observe and repair stale acknowledgements. - private let networkMutationGate = PushRegistrationMutationGate() private var retryTask: Task? - private var unregisterDrainTask: Task? + /// One app-lifetime worker owns every POST and DELETE. New events only + /// replace the pending desired state, so task and waiter counts stay flat. + private var reconciliationTask: Task? + private var reconciliationRequested = false + private var preferenceReconciliationRequested = false + private var pendingCleanupRequest: PushRegistrationCleanupRequest? + private var pendingUploadRetry: ( + tokenHex: String, + generation: UUID, + serverMutationGeneration: UInt64, + remainingDelays: [Duration] + )? private var operationGeneration = UUID() /// Orders registration and direct sign-out mutations before either path - /// can suspend while preparing credentials or waiting for the network gate. + /// can suspend while preparing credentials or waiting for the network. private var serverMutationGeneration: UInt64 = 0 /// Every preference mutation, including the legacy public mutation APIs, - /// is assigned one service-owned generation and enters this queue. A - /// direct mutation therefore advances the same ordering domain as a - /// coordinator intent and replaces any coordinator work still pending. + /// is assigned one service-owned generation. A direct mutation therefore + /// advances the same ordering domain as a coordinator intent and replaces + /// any coordinator work still pending. private var preferenceIntentGeneration: UInt64 = 0 private var coordinatorGeneration: UInt64? /// Direct callers invalidate all coordinator generations already admitted. @@ -51,7 +54,6 @@ public actor PushRegistrationService: PushRegistering { private var coordinatorGenerationInvalidatedThrough: UInt64? private var latestCoordinatorIntent: PushRegistrationIntent? private var committedPreferenceIntent: PushRegistrationIntent? - private var preferenceIntentQueue: PushRegistrationIntentQueue? private var snapshotValue: PushRegistrationSnapshot private var snapshotContinuations: [UUID: AsyncStream.Continuation] = [:] @@ -140,9 +142,7 @@ public actor PushRegistrationService: PushRegistering { public func snapshots() -> AsyncStream { let id = UUID() if !isEnabled, !pendingUnregisters.isEmpty { - schedulePendingUnregisterContinuation( - preferenceGeneration: preferenceIntentGeneration - ) + scheduleReconciliation() } return AsyncStream { continuation in snapshotContinuations[id] = continuation @@ -217,13 +217,8 @@ public actor PushRegistrationService: PushRegistering { private func submitPreferenceIntent( _ intent: PushRegistrationIntent ) async { - commitPreferenceIntent(intent) - if preferenceIntentQueue == nil { - preferenceIntentQueue = PushRegistrationIntentQueue { [weak self] intent in - await self?.reconcilePreferenceIntent(intent) - } - } - await preferenceIntentQueue!.submit(intent) + guard commitPreferenceIntent(intent) else { return } + await requestReconciliation() } private func makePreferenceIntent(enabled: Bool) -> PushRegistrationIntent { @@ -259,13 +254,16 @@ public actor PushRegistrationService: PushRegistering { /// Commits the latest user preference before any authentication or network /// suspension. Same-direction reconciliation can remain bounded behind an /// older preparation without delaying the durable toggle state. - private func commitPreferenceIntent(_ intent: PushRegistrationIntent) { + private func commitPreferenceIntent( + _ intent: PushRegistrationIntent + ) -> Bool { guard isCurrentPreferenceIntent(intent.generation), committedPreferenceIntent != intent else { - return + return false } committedPreferenceIntent = intent cancelRetry() + _ = beginServerMutation() if !intent.enabled { persistCapturedUnregisterObligation(accountID: nil) } @@ -282,6 +280,7 @@ public actor PushRegistrationService: PushRegistering { } else { publish(.disabled) } + return true } private func reconcilePreferenceIntent( @@ -293,9 +292,11 @@ public actor PushRegistrationService: PushRegistering { if intent.enabled { await syncTokenIfPossibleUnlocked() } else { - await unregisterFromServerUnlocked( - preferenceGeneration: intent.generation - ) + if defaults.object(forKey: Self.enabledKey) as? Bool == false { + await unregisterFromServerUnlocked( + preferenceGeneration: intent.generation + ) + } await retryPendingUnregisterIfPossible( preferenceGeneration: intent.generation ) @@ -308,12 +309,103 @@ public actor PushRegistrationService: PushRegistering { preferenceIntentGeneration == generation } - /// Caches an APNs device token and uploads it when push is enabled. - public func register(deviceToken: Data) async { - await registerUnlocked(deviceToken: deviceToken) + private func requestReconciliation( + reconcilePreference: Bool = true + ) async { + let startsWorker = reconciliationTask == nil + let task = scheduleReconciliation( + reconcilePreference: reconcilePreference + ) + // Only the caller that created the worker waits for it. Later callers + // coalesce their state into that worker and return, keeping the number + // of suspended callers bounded even when authentication is slow. + guard startsWorker, !Task.isCancelled else { return } + await task.value } - private func registerUnlocked(deviceToken: Data) async { + @discardableResult + private func scheduleReconciliation( + reconcilePreference: Bool = true + ) -> Task { + reconciliationRequested = true + preferenceReconciliationRequested = + preferenceReconciliationRequested || reconcilePreference + if let reconciliationTask { return reconciliationTask } + let task = Task { [weak self] in + guard let self else { return } + await self.drainReconciliation() + } + reconciliationTask = task + return task + } + + private func drainReconciliation() async { + while reconciliationRequested + || preferenceReconciliationRequested + || pendingCleanupRequest != nil + || pendingUploadRetry != nil { + reconciliationRequested = false + + if let cleanup = pendingCleanupRequest { + pendingCleanupRequest = nil + await reconcileCleanupRequest(cleanup) + } + + if let retry = pendingUploadRetry { + pendingUploadRetry = nil + await attemptUpload( + tokenHex: retry.tokenHex, + generation: retry.generation, + serverMutationGeneration: retry.serverMutationGeneration, + remainingDelays: retry.remainingDelays + ) + continue + } + + if preferenceReconciliationRequested { + preferenceReconciliationRequested = false + let intent = committedPreferenceIntent ?? PushRegistrationIntent( + enabled: isEnabled, + generation: preferenceIntentGeneration + ) + await reconcilePreferenceIntent(intent) + } + } + + reconciliationTask = nil + if reconciliationRequested + || preferenceReconciliationRequested + || pendingCleanupRequest != nil + || pendingUploadRetry != nil { + scheduleReconciliation(reconcilePreference: false) + } + } + + private func reconcileCleanupRequest( + _ request: PushRegistrationCleanupRequest + ) async { + switch request { + case let .live(serverMutationGeneration): + await unregisterFromServerUnlocked( + serverMutationGeneration: serverMutationGeneration + ) + case let .captured( + accountID, + accessToken, + refreshToken, + serverMutationGeneration + ): + await unregisterFromServerUnlocked( + accountID: accountID, + accessToken: accessToken, + refreshToken: refreshToken, + serverMutationGeneration: serverMutationGeneration + ) + } + } + + /// Caches an APNs device token and uploads it when push is enabled. + public func register(deviceToken: Data) async { let hex = deviceToken.map { String(format: "%02x", $0) }.joined() let previousToken = cachedTokenHex if let previousToken, @@ -332,20 +424,24 @@ public actor PushRegistrationService: PushRegistering { defaults.removeObject(forKey: Self.registeredAccountIDKey) } defaults.set(hex, forKey: Self.cachedTokenKey) + cancelRetry() + _ = beginServerMutation() guard isEnabled else { publish(.disabled) return } - cancelRetry() - await upload(tokenHex: hex) - if snapshotValue.backendState == .registered { - await retryPendingUnregisterIfPossible() - } + publish(PushRegistrationSnapshot( + isEnabled: true, + hasDeviceToken: true, + backendState: .registrationRequired + )) + await requestReconciliation() } /// Reconciles cached registration and pending cleanup with the current account. public func syncTokenIfPossible() async { - await syncTokenIfPossibleUnlocked() + _ = beginServerMutation() + await requestReconciliation() } private func syncTokenIfPossibleUnlocked() async { @@ -381,11 +477,11 @@ public actor PushRegistrationService: PushRegistering { public func unregisterFromServer() async { let serverMutationGeneration = beginServerMutation() persistCapturedUnregisterObligation(accountID: nil) - await unregisterIntentGate.withLock { [self] in - await self.unregisterFromServerUnlocked( - serverMutationGeneration: serverMutationGeneration - ) - } + guard !Task.isCancelled else { return } + pendingCleanupRequest = .live( + serverMutationGeneration: serverMutationGeneration + ) + await requestReconciliation(reconcilePreference: false) } private func unregisterFromServerUnlocked( @@ -430,14 +526,14 @@ public actor PushRegistrationService: PushRegistering { public func unregisterFromServer(accessToken: String?, refreshToken: String?) async { let serverMutationGeneration = beginServerMutation() persistCapturedUnregisterObligation(accountID: nil) - await unregisterIntentGate.withLock { [self] in - await self.unregisterFromServerUnlocked( - accountID: nil, - accessToken: accessToken, - refreshToken: refreshToken, - serverMutationGeneration: serverMutationGeneration - ) - } + guard !Task.isCancelled else { return } + pendingCleanupRequest = .captured( + accountID: nil, + accessToken: accessToken, + refreshToken: refreshToken, + serverMutationGeneration: serverMutationGeneration + ) + await requestReconciliation(reconcilePreference: false) } /// Sign-out variant with the account id captured before local auth clear. @@ -448,20 +544,19 @@ public actor PushRegistrationService: PushRegistering { ) async { let serverMutationGeneration = beginServerMutation() persistCapturedUnregisterObligation(accountID: capturedAccountID) - await unregisterIntentGate.withLock { [self] in - await self.unregisterFromServerUnlocked( - accountID: capturedAccountID, - accessToken: accessToken, - refreshToken: refreshToken, - serverMutationGeneration: serverMutationGeneration - ) - } + guard !Task.isCancelled else { return } + pendingCleanupRequest = .captured( + accountID: capturedAccountID, + accessToken: accessToken, + refreshToken: refreshToken, + serverMutationGeneration: serverMutationGeneration + ) + await requestReconciliation(reconcilePreference: false) } - /// Records the cleanup obligation before waiting on the mutation gate. - /// Sign-out callers are commonly canceled while an earlier registration is - /// still in flight; the durable tombstone must not depend on admission to - /// that cancellable queue. + /// Records the cleanup obligation before joining reconciliation. Sign-out + /// callers are commonly canceled while an earlier registration is still in + /// flight, so the durable tombstone cannot depend on network completion. private func persistCapturedUnregisterObligation(accountID: String?) { guard let hex = cachedTokenHex else { return } let ownerID = persistedOwnerID(for: hex) ?? accountID @@ -608,9 +703,9 @@ public actor PushRegistrationService: PushRegistering { } switch result { case .cancelled: - // The gate may reject a cancelled waiter before any request starts. - // Leave the enabled token recoverable instead of stranding the - // snapshot in `.registering` with no future reconciliation. + // The reconciler may reject an invalidated operation before any + // request starts. Leave the enabled token recoverable instead of + // stranding the snapshot in `.registering`. publish(PushRegistrationSnapshot( isEnabled: true, hasDeviceToken: true, @@ -706,7 +801,7 @@ public actor PushRegistrationService: PushRegistering { return } guard !Task.isCancelled else { return } - await self?.attemptUpload( + await self?.enqueueUploadRetry( tokenHex: tokenHex, generation: generation, serverMutationGeneration: serverMutationGeneration, @@ -715,6 +810,27 @@ public actor PushRegistrationService: PushRegistering { } } + private func enqueueUploadRetry( + tokenHex: String, + generation: UUID, + serverMutationGeneration: UInt64, + remainingDelays: [Duration] + ) { + retryTask = nil + guard isCurrentUpload( + tokenHex: tokenHex, + generation: generation, + serverMutationGeneration: serverMutationGeneration + ) else { return } + pendingUploadRetry = ( + tokenHex: tokenHex, + generation: generation, + serverMutationGeneration: serverMutationGeneration, + remainingDelays: remainingDelays + ) + scheduleReconciliation(reconcilePreference: false) + } + /// Repairs the backend after an invalidated POST still succeeds. /// /// URLSession cancellation cannot prove that the server did not commit the @@ -755,12 +871,12 @@ public actor PushRegistrationService: PushRegistering { ) } - guard isEnabled, let currentToken = cachedTokenHex, + guard isEnabled, cachedTokenHex != nil, let currentSession = try? await tokenProvider .authenticatedSessionSnapshot(), await tokenProvider.isAuthenticatedSessionCurrent(currentSession) else { return } - await upload(tokenHex: currentToken) + preferenceReconciliationRequested = true } private func sendDelete( @@ -857,26 +973,23 @@ public actor PushRegistrationService: PushRegistering { serverMutationGeneration: UInt64, cleanupAccountID: String? ) async -> RegistrationResult { - await networkMutationGate.withLock { [self] in - guard await self.isCurrentUpload( + guard isCurrentUpload( + tokenHex: tokenHex, + generation: generation, + serverMutationGeneration: serverMutationGeneration + ) else { + return .cancelled + } + // The POST may commit even if this process is suspended before its + // response arrives. Persist its cleanup owner only after the single + // reconciler admits this still-current request. + if let cleanupAccountID { + persistPendingUnregister( tokenHex: tokenHex, - generation: generation, - serverMutationGeneration: serverMutationGeneration - ) else { - return .cancelled - } - // The POST may commit even if this process is suspended before its - // response arrives. Persist its cleanup owner only after the gate - // admits this still-current request, so a quarantined stale worker - // cannot recreate a tombstone after a newer POST has succeeded. - if let cleanupAccountID { - await self.persistPendingUnregister( - tokenHex: tokenHex, - accountID: cleanupAccountID - ) - } - return await self.performRegistrationRequest(request) - } ?? .cancelled + accountID: cleanupAccountID + ) + } + return await performRegistrationRequest(request) } private func isCurrentUpload( @@ -927,21 +1040,15 @@ public actor PushRegistrationService: PushRegistering { preferenceGeneration: UInt64? = nil, serverMutationGeneration: UInt64? = nil ) async -> Bool { - await networkMutationGate.withLock { [self] in - if let preferenceGeneration { - guard await self.isCurrentOptOut(preferenceGeneration) else { - return false - } - } - if let serverMutationGeneration { - guard await self.isCurrentServerMutation( - serverMutationGeneration - ) else { - return false - } - } - return await self.performDeleteRequest(request) - } ?? false + if let preferenceGeneration, + !isCurrentOptOut(preferenceGeneration) { + return false + } + if let serverMutationGeneration, + !isCurrentServerMutation(serverMutationGeneration) { + return false + } + return await performDeleteRequest(request) } private func beginServerMutation() -> UInt64 { @@ -1000,29 +1107,15 @@ public actor PushRegistrationService: PushRegistering { let batch = Array( matching.prefix(Self.pendingUnregisterAttemptBudget) ) - let results = await withTaskGroup( - of: (PendingUnregister, Bool).self, - returning: [(PendingUnregister, Bool)].self - ) { group in - for pending in batch { - group.addTask { [self] in - ( - pending, - await sendDelete( - tokenHex: pending.tokenHex, - sessionSnapshot: session, - preferenceGeneration: preferenceGeneration - ) - ) - } - } - var results: [(PendingUnregister, Bool)] = [] - for await result in group { - results.append(result) - } - return results - } - for (pending, succeeded) in results where succeeded { + var madeProgress = false + for pending in batch { + let succeeded = await sendDelete( + tokenHex: pending.tokenHex, + sessionSnapshot: session, + preferenceGeneration: preferenceGeneration + ) + guard succeeded else { continue } + madeProgress = true clearPendingUnregister( tokenHex: pending.tokenHex, accountID: pending.accountID @@ -1033,10 +1126,8 @@ public actor PushRegistrationService: PushRegistering { ) } if matching.count > batch.count, - results.contains(where: { $0.1 }) { - schedulePendingUnregisterContinuation( - preferenceGeneration: preferenceGeneration - ) + madeProgress { + preferenceReconciliationRequested = true } } @@ -1052,28 +1143,6 @@ public actor PushRegistrationService: PushRegistering { storePendingUnregisters(queue) } - private func schedulePendingUnregisterContinuation( - preferenceGeneration: UInt64? = nil - ) { - guard unregisterDrainTask == nil else { return } - unregisterDrainTask = Task { [weak self] in - await Task.yield() - guard !Task.isCancelled, let self else { return } - await self.runPendingUnregisterContinuation( - preferenceGeneration: preferenceGeneration - ) - } - } - - private func runPendingUnregisterContinuation( - preferenceGeneration: UInt64? - ) async { - unregisterDrainTask = nil - await retryPendingUnregisterIfPossible( - preferenceGeneration: preferenceGeneration - ) - } - private func clearPendingUnregister( tokenHex: String, accountID: String @@ -1189,6 +1258,7 @@ public actor PushRegistrationService: PushRegistering { operationGeneration = UUID() retryTask?.cancel() retryTask = nil + pendingUploadRetry = nil } private func publish(_ snapshot: PushRegistrationSnapshot) { diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationMutationGateTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationMutationGateTests.swift deleted file mode 100644 index 35884a859f6..00000000000 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationMutationGateTests.swift +++ /dev/null @@ -1,32 +0,0 @@ -import Testing -@testable import CmuxAuthRuntime - -@Suite(.serialized) struct PushRegistrationMutationGateTests { - @Test func cancelledQueuedMutationDoesNotRun() async { - let gate = PushRegistrationMutationGate() - let firstStarted = TestPhaseSignal() - let firstBlocker = TestContinuationBlocker() - let secondStarted = TestPhaseSignal() - - let first = Task { - await gate.withLock { - await firstStarted.markStarted() - await firstBlocker.wait() - } - } - await firstStarted.waitUntilStarted() - - let second = Task { - await gate.withLock { - await secondStarted.markStarted() - } - } - second.cancel() - - await firstBlocker.release() - await first.value - _ = await second.value - - #expect(await secondStarted.didStart == false) - } -} diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 64668892a93..57175b9ab57 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -425,9 +425,8 @@ actor RetryDelayRecorder { ) } queuedSignOut.cancel() - // Cancellation must remove the waiter while the first mutation still - // owns the intent gate. If it were allowed to wait for the gate, this - // await would deadlock until the blocker below is released. + // A coalesced call never adds another worker waiter, so cancellation + // returns while the first mutation is still blocked. await queuedSignOut.value await blocker.release() @@ -849,12 +848,21 @@ actor RetryDelayRecorder { let snapshots = await service.snapshots() await cleanupAuthenticationStarted.waitUntilStarted() let enable = Task { await service.setEnabled(true) } - await registrationStarted.waitUntilStarted() + + for _ in 0..<1_000 + where !defaults.bool( + forKey: "cmux.notifications.pushEnabled" + ) { + await Task.yield() + } + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) + #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) await cleanupAuthenticationBlocker.release() - for _ in 0..<1_000 { await Task.yield() } + await registrationStarted.waitUntilStarted() await registrationBlocker.release() await enable.value + #expect(await wait(for: .registered, from: service)) #expect( await PushRegistrationURLProtocol.script.requests @@ -1060,15 +1068,11 @@ actor RetryDelayRecorder { await disable.value let requests = await PushRegistrationURLProtocol.script.requests - #expect(requests.map(\.httpMethod) == ["POST", "DELETE", "DELETE"]) + #expect(requests.map(\.httpMethod) == ["POST", "DELETE"]) #expect( requests.map { $0.value(forHTTPHeaderField: "Authorization") - } == [ - "Bearer a-access", - "Bearer a-access", - "Bearer a-access", - ] + } == ["Bearer a-access", "Bearer a-access"] ) #expect( defaults.data( @@ -1186,7 +1190,7 @@ actor RetryDelayRecorder { #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) } - @Test func stalledEnablePreparationAllowsSameDirectionRecovery() async { + @Test func stalledEnablePreparationCoalescesSameDirectionIntent() async { await PushRegistrationURLProtocol.script.reset([.response(200)]) let provider = MutablePushTokenProvider( accountID: "account-a", @@ -1214,16 +1218,26 @@ actor RetryDelayRecorder { await service.applyEnabledIntent(true, generation: 2) } - #expect(await wait(for: .registered, from: service)) + for _ in 0..<1_000 + where !defaults.bool( + forKey: "cmux.notifications.pushEnabled" + ) { + await Task.yield() + } + #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) #expect( await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["POST"] + .map(\.httpMethod).isEmpty ) - #expect(await service.snapshot.backendState == .registered) await firstEnableBlocker.release() await firstEnable.value await recovery.value + #expect(await service.snapshot.backendState == .registered) + #expect( + await PushRegistrationURLProtocol.script.requests + .map(\.httpMethod) == ["POST"] + ) } @Test func inFlightRegistrationPersistsCleanupOwnerBeforePostCompletes() async { @@ -1370,6 +1384,13 @@ actor RetryDelayRecorder { let queuedOptOut = Task { await service.applyEnabledIntent(false, generation: 2) } + for _ in 0..<1_000 + where defaults.bool( + forKey: "cmux.notifications.pushEnabled" + ) { + await Task.yield() + } + #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) let directReenable = Task { await service.setEnabled(true) } @@ -1517,8 +1538,8 @@ actor RetryDelayRecorder { $0.value(forHTTPHeaderField: "Authorization") } == [ "Bearer a-live-access", - "Bearer a-captured-access", "Bearer a-live-access", + "Bearer a-captured-access", ] ) #expect( @@ -1560,9 +1581,11 @@ actor RetryDelayRecorder { } await signOutStarted.waitUntilStarted() - await service.syncTokenIfPossible() + let sync = Task { await service.syncTokenIfPossible() } + for _ in 0..<1_000 { await Task.yield() } await signOutBlocker.release() await delayedSignOut.value + await sync.value #expect( await PushRegistrationURLProtocol.script.requests @@ -1615,14 +1638,13 @@ actor RetryDelayRecorder { let requests = await PushRegistrationURLProtocol.script.requests #expect( requests.map(\.httpMethod) - == ["POST", "POST", "DELETE", "POST"] + == ["POST", "DELETE", "POST"] ) #expect( requests.map { $0.value(forHTTPHeaderField: "Authorization") } == [ "Bearer a-access", - "Bearer b-access", "Bearer a-access", "Bearer b-access", ] From d7ebaae630c8d6fd761ff634de19bbf932fb9001 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:03:57 -0700 Subject: [PATCH 084/117] test(ios): recover timed-out push opt-out --- .../MobilePushCoordinatorLifecycleTests.swift | 61 ++++++++++++++++++- 1 file changed, 58 insertions(+), 3 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index feb8ad74ec0..1a7de7376fb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -154,13 +154,13 @@ private actor LifecyclePushRegistration: PushRegistering { } private actor LifecycleSetEnabledGate { - private var didStart = false + private(set) var starts = 0 private var released = false private var startWaiters: [CheckedContinuation] = [] private var releaseWaiters: [CheckedContinuation] = [] func pause() async { - didStart = true + starts += 1 let waiters = startWaiters startWaiters.removeAll() for waiter in waiters { @@ -173,12 +173,25 @@ private actor LifecycleSetEnabledGate { } func waitUntilStarted() async { - guard !didStart else { return } + guard starts == 0 else { return } await withCheckedContinuation { continuation in startWaiters.append(continuation) } } + func waitUntilStartCount( + _ count: Int, + timeout: Duration = .seconds(1) + ) async -> Bool { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: timeout) + while starts < count { + guard clock.now < deadline else { return false } + try? await clock.sleep(for: .milliseconds(1)) + } + return true + } + func release() { released = true let waiters = releaseWaiters @@ -867,6 +880,48 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(!(await enabling.value)) } + @MainActor + @Test func timedOutOptOutRetriesAndSurfacesUnconfirmedCleanup() async { + let disableGate = LifecycleSetEnabledGate() + let timeoutGate = LifecycleSyncGate() + let timeoutSleeper = LifecycleSettingsMutationSleeper( + firstGate: timeoutGate + ) + let registration = LifecyclePushRegistration( + enabled: true, + setEnabledGate: disableGate + ) + let suiteName = "push-coordinator-optout-timeout-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + defaults.set(true, forKey: "cmux.notifications.pushEnabled") + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + authorizationStatus: { .authorized }, + settingsMutationSleep: { duration in + try await timeoutSleeper.sleep(for: duration) + } + ) + + coordinator.setEnabledIntent(false) + await disableGate.waitUntilStarted() + await timeoutGate.waitUntilStarted() + await timeoutGate.release() + + #expect(await disableGate.waitUntilStartCount(2)) + #expect(!coordinator.isEnabled) + #expect(coordinator.isDisableCleanupUnconfirmed) + + await disableGate.release() + for _ in 0..<100 where coordinator.isDisableCleanupUnconfirmed { + await Task.yield() + } + + #expect(!coordinator.isDisableCleanupUnconfirmed) + #expect(await registration.snapshot == .disabled) + } + @MainActor @Test func timedOutEnableAllowsOneBoundedRecoveryAndCannotBlockOptOut() async { let settingsGate = LifecycleSyncGate() From 8a8fb0b835fa21579dce92fec8c64f06ef33f565 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:09:28 -0700 Subject: [PATCH 085/117] fix(ios): recover timed-out push cleanup --- .../MobilePushCoordinator.swift | 63 ++++++++++++------- .../MobilePushSettingsIntent.swift | 2 + .../MobileSettingsView.swift | 26 ++++++++ .../MobilePushCoordinatorLifecycleTests.swift | 5 +- ios/cmux/Resources/Localizable.xcstrings | 2 + 5 files changed, 73 insertions(+), 25 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index e917d906c2c..12da775114c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -109,6 +109,8 @@ public final class MobilePushCoordinator { .authorizationOnly(.notDetermined) /// Local/APNs/backend registration stage streamed from the actor service. public private(set) var registrationSnapshot: PushRegistrationSnapshot = .disabled + /// Whether local delivery is off but backend cleanup exceeded its deadline. + public private(set) var isDisableCleanupUnconfirmed = false @ObservationIgnored private let notificationSettings: @MainActor () async -> MobilePushSystemSettings @ObservationIgnored private let requestAuthorization: @@ -251,7 +253,11 @@ public final class MobilePushCoordinator { token: intent.token ) { [weak self] in guard let self else { return false } + await intent.registrationTask.value if enabled { + guard self.isCurrentSettingsMutation(intent.token) else { + return false + } return await self.enable( trigger: "settings_toggle", settingsMutationToken: intent.token, @@ -260,11 +266,11 @@ public final class MobilePushCoordinator { ) } await self.finishDisable( - settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration, - registrationIntentEpoch: intent.registrationIntentEpoch + settingsMutationToken: intent.token ) - return self.isCurrentSettingsMutation(intent.token) + // Completion means the service worker drained the intents coalesced + // behind this opt-out. UI publication remains token-fenced. + return true } } @@ -365,6 +371,20 @@ public final class MobilePushCoordinator { Int(Self.settingsMutationTimeout.components.seconds) ), ]) + } else { + isDisableCleanupUnconfirmed = true + diagnosticLog?.recordAppEvent( + .pushBackendSyncFailed, + failure: .offline + ) + analytics.capture("ios_push_disable_cleanup_timeout", [ + "timeout_seconds": .int( + Int(Self.settingsMutationTimeout.components.seconds) + ), + ]) + if releasedLane { + setEnabledIntent(false) + } } } @@ -395,17 +415,19 @@ public final class MobilePushCoordinator { registrationIntentTask?.cancel() let registration = self.registration let registrationGeneration = registrationIntentGeneration - registrationIntentTask = Task { + let registrationTask = Task { await registration.applyEnabledIntent( enabled, generation: registrationGeneration, intentEpoch: registrationIntentEpoch ) } + registrationIntentTask = registrationTask return MobilePushSettingsIntent( token: token, registrationGeneration: registrationIntentGeneration, - registrationIntentEpoch: registrationIntentEpoch + registrationIntentEpoch: registrationIntentEpoch, + registrationTask: registrationTask ) } @@ -669,12 +691,11 @@ public final class MobilePushCoordinator { token: intent.token ) { [weak self] in guard let self else { return false } + await intent.registrationTask.value await self.finishDisable( - settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration, - registrationIntentEpoch: intent.registrationIntentEpoch + settingsMutationToken: intent.token ) - return self.isCurrentSettingsMutation(intent.token) + return true } guard let workers else { return } _ = await waitForSettingsMutation(workers) @@ -755,18 +776,8 @@ public final class MobilePushCoordinator { } private func finishDisable( - settingsMutationToken: UUID, - registrationGeneration: UInt64, - registrationIntentEpoch: PushRegistrationIntentEpoch + settingsMutationToken: UUID ) async { - guard isCurrentSettingsMutation(settingsMutationToken), !enabledMirror else { - return - } - await registration.applyEnabledIntent( - false, - generation: registrationGeneration, - intentEpoch: registrationIntentEpoch - ) guard isCurrentSettingsMutation(settingsMutationToken), !enabledMirror else { return } @@ -777,6 +788,13 @@ public final class MobilePushCoordinator { registrationSnapshot = snapshot } + /// Retries timed-out backend cleanup without turning local delivery on. + public func retryDisableCleanup() { + guard !enabledMirror else { return } + settingsMutationDirectionsNeedingRetry.insert(false) + setEnabledIntent(false) + } + private func isCurrentSettingsMutation(_ token: UUID) -> Bool { // Task cancellation belongs to the caller's waiter. Preference // mutations live at app scope, and only a newer token supersedes them. @@ -845,6 +863,7 @@ public final class MobilePushCoordinator { } private func persistEnabledIntent() { + isDisableCleanupUnconfirmed = false enabledMirror = true defaults.set(true, forKey: Self.enabledKey) } @@ -877,7 +896,7 @@ public final class MobilePushCoordinator { requestRemoteRegistrationIfNeeded() // Always submit the current generation. The snapshot can still say // enabled while an older disable is queued or suspended; the service - // intent queue coalesces repeated completed generations without + // reconciler coalesces repeated completed generations without // issuing another registration request. await registration.applyEnabledIntent( true, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift index bb6e5690f0c..a9b0b880a07 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift @@ -6,4 +6,6 @@ struct MobilePushSettingsIntent { let token: UUID let registrationGeneration: UInt64 let registrationIntentEpoch: PushRegistrationIntentEpoch + /// The exact service mutation tracked by the coordinator timeout. + let registrationTask: Task } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 19ee81bd82e..155f9aa6db7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -425,6 +425,32 @@ struct MobileSettingsView: View { isUpdating: false ) #endif + if pushCoordinator.isDisableCleanupUnconfirmed { + Text(L10n.string( + "mobile.notifications.disableCleanupUnconfirmed", + defaultValue: "Push alerts are off on this iPhone, but server cleanup could not be confirmed." + )) + .font(.footnote) + .foregroundStyle(.orange) + .accessibilityIdentifier( + "MobileSettingsPushDisableCleanupUnconfirmed" + ) + + Button { + pushCoordinator.retryDisableCleanup() + } label: { + Label( + L10n.string( + "mobile.notifications.disableCleanupRetry", + defaultValue: "Retry Push Alert Cleanup" + ), + systemImage: "arrow.clockwise" + ) + } + .accessibilityIdentifier( + "MobileSettingsPushDisableCleanupRetry" + ) + } } Section { diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 1a7de7376fb..510980fb2f6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -914,11 +914,10 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(coordinator.isDisableCleanupUnconfirmed) await disableGate.release() - for _ in 0..<100 where coordinator.isDisableCleanupUnconfirmed { + for _ in 0..<100 where await registration.snapshot != .disabled { await Task.yield() } - - #expect(!coordinator.isDisableCleanupUnconfirmed) + #expect(coordinator.isDisableCleanupUnconfirmed) #expect(await registration.snapshot == .disabled) } diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index a134785caa0..0c55184a9d6 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -19496,6 +19496,8 @@ "mobile.accessibility.notSelected": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"not selected"}},"ja":{"stringUnit":{"state":"translated","value":"未選択"}}}}, "mobile.accessibility.selected": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"selected"}},"ja":{"stringUnit":{"state":"translated","value":"選択済み"}}}}, "mobile.notifications.awayExplanation": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Only When Away sends after the Mac is locked, asleep, or inactive."}},"ja":{"stringUnit":{"state":"translated","value":"「離席中のみ」では、Macがロック中、スリープ中、または操作されていないときに送信します。"}}}}, + "mobile.notifications.disableCleanupRetry": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Retry Push Alert Cleanup"}},"ja":{"stringUnit":{"state":"translated","value":"プッシュ通知のクリーンアップを再試行"}}}}, + "mobile.notifications.disableCleanupUnconfirmed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Push alerts are off on this iPhone, but server cleanup could not be confirmed."}},"ja":{"stringUnit":{"state":"translated","value":"このiPhoneではプッシュ通知はオフですが、サーバーのクリーンアップを確認できませんでした。"}}}}, "mobile.notifications.hideContent": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Hide Notification Content"}},"ja":{"stringUnit":{"state":"translated","value":"通知内容を非表示"}}}}, "mobile.notifications.macForwarding": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Forward Alerts from This Mac"}},"ja":{"stringUnit":{"state":"translated","value":"このMacから通知を転送"}}}}, "mobile.notifications.macMode": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Forwarding Mode"}},"ja":{"stringUnit":{"state":"translated","value":"転送モード"}}}}, From c9cc7c13093f5442ad97cc3ac3cc325eab33d0db Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:18:46 -0700 Subject: [PATCH 086/117] refactor(ios): reduce push toggle fix scope --- .../Push/PushRegistering.swift | 22 - .../Push/PushRegistrationCleanupRequest.swift | 10 - .../Push/PushRegistrationIntent.swift | 4 - .../Push/PushRegistrationIntentEpoch.swift | 24 - .../Push/PushRegistrationService.swift | 668 ++-------------- .../PushRegistrationServiceTests.swift | 731 +---------------- .../MobilePushReadinessPreviewView.swift | 52 +- .../MobilePushCoordinator.swift | 729 ++--------------- .../MobilePushMutationCompletion.swift | 59 -- .../MobilePushMutationOutcome.swift | 6 - .../MobilePushMutationResult.swift | 6 - .../MobilePushMutationWorkers.swift | 10 - .../MobilePushSettingsContent.swift | 41 +- .../MobilePushSettingsIntent.swift | 11 - .../CmuxMobileShellUI/MobilePushToggle.swift | 24 - .../MobileSettingsView.swift | 88 +-- .../LifecycleCancellationRecorder.swift | 11 - .../LifecycleNotificationDelegate.swift | 4 - .../LifecycleSettingsMutationSleeper.swift | 23 - .../LifecycleSyncGate.swift | 50 -- .../MobilePushCoordinatorLifecycleTests.swift | 733 +----------------- .../MobilePushMutationCompletionTests.swift | 19 - ios/cmux/Resources/Localizable.xcstrings | 19 - .../cmuxFeatureTests/cmuxFeatureTests.swift | 6 - ios/cmuxUITests/PushReadinessUITests.swift | 35 +- 25 files changed, 252 insertions(+), 3133 deletions(-) delete mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationCleanupRequest.swift delete mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift delete mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentEpoch.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationResult.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleCancellationRecorder.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleNotificationDelegate.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSettingsMutationSleeper.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift delete mode 100644 Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushMutationCompletionTests.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift index 567febcfc70..96281023279 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift @@ -21,28 +21,6 @@ public protocol PushRegistering: Sendable { /// removing it server-side on disable. func setEnabled(_ enabled: Bool) async - /// Completes an opt-out after the coordinator has already persisted the - /// user's false intent. The cleanup must not infer whether a server token - /// exists from that now-false preference. - func disableAndUnregister() async - - /// Applies a coordinator-owned intent in generation order. - /// - /// - Parameters: - /// - enabled: The latest user intent. - /// - generation: A monotonically increasing coordinator generation. - /// - intentEpoch: The preference epoch captured before the coordinator - /// created its asynchronous work. - /// - /// Older queued intents must not run after a newer one. Every conformer - /// implements this contract so the coordinator does not depend on a - /// particular registration-service implementation for stale-work safety. - func applyEnabledIntent( - _ enabled: Bool, - generation: UInt64, - intentEpoch: PushRegistrationIntentEpoch - ) async - /// Cache and (when opted in) upload a freshly registered APNs device token. func register(deviceToken: Data) async diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationCleanupRequest.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationCleanupRequest.swift deleted file mode 100644 index 2d77c661a56..00000000000 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationCleanupRequest.swift +++ /dev/null @@ -1,10 +0,0 @@ -/// One direct cleanup request folded into the service's single reconciliation loop. -enum PushRegistrationCleanupRequest: Sendable { - case live(serverMutationGeneration: UInt64) - case captured( - accountID: String?, - accessToken: String?, - refreshToken: String?, - serverMutationGeneration: UInt64 - ) -} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift deleted file mode 100644 index bf457f24054..00000000000 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntent.swift +++ /dev/null @@ -1,4 +0,0 @@ -struct PushRegistrationIntent: Sendable, Equatable { - let enabled: Bool - let generation: UInt64 -} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentEpoch.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentEpoch.swift deleted file mode 100644 index de047c34b4c..00000000000 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationIntentEpoch.swift +++ /dev/null @@ -1,24 +0,0 @@ -import Foundation - -/// Identifies the coordinator preference that was current when work was made. -/// -/// The value is mirrored through the shared defaults suite before asynchronous -/// work is created. Direct service mutations replace it, so a coordinator task -/// that starts late cannot reverse a newer direct preference. -public struct PushRegistrationIntentEpoch: Sendable, Equatable { - /// The shared-defaults key containing the currently authoritative epoch. - public static let defaultsKey = "cmux.notifications.pushIntentEpoch" - - /// The stable value persisted and carried by asynchronous coordinator work. - public let storageValue: String - - /// Creates a fresh preference epoch. - public init() { - self.storageValue = UUID().uuidString - } - - /// Restores an epoch from its persisted representation. - public init(storageValue: String) { - self.storageValue = storageValue - } -} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 5caab9d02d1..ebcea86fa14 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -26,34 +26,8 @@ public actor PushRegistrationService: PushRegistering { private let retryJitter: @Sendable (ClosedRange) -> Double private let retrySleep: @Sendable (Duration) async throws -> Void private var retryTask: Task? - /// One app-lifetime worker owns every POST and DELETE. New events only - /// replace the pending desired state, so task and waiter counts stay flat. - private var reconciliationTask: Task? - private var reconciliationRequested = false - private var preferenceReconciliationRequested = false - private var pendingCleanupRequest: PushRegistrationCleanupRequest? - private var pendingUploadRetry: ( - tokenHex: String, - generation: UUID, - serverMutationGeneration: UInt64, - remainingDelays: [Duration] - )? + private var unregisterDrainTask: Task? private var operationGeneration = UUID() - /// Orders registration and direct sign-out mutations before either path - /// can suspend while preparing credentials or waiting for the network. - private var serverMutationGeneration: UInt64 = 0 - /// Every preference mutation, including the legacy public mutation APIs, - /// is assigned one service-owned generation. A direct mutation therefore - /// advances the same ordering domain as a coordinator intent and replaces - /// any coordinator work still pending. - private var preferenceIntentGeneration: UInt64 = 0 - private var coordinatorGeneration: UInt64? - /// Direct callers invalidate all coordinator generations already admitted. - /// This is validation metadata only; mutation ordering uses - /// `preferenceIntentGeneration` above. - private var coordinatorGenerationInvalidatedThrough: UInt64? - private var latestCoordinatorIntent: PushRegistrationIntent? - private var committedPreferenceIntent: PushRegistrationIntent? private var snapshotValue: PushRegistrationSnapshot private var snapshotContinuations: [UUID: AsyncStream.Continuation] = [:] @@ -110,13 +84,6 @@ public actor PushRegistrationService: PushRegistering { self.defaults = .standard } Self.migrateLegacyPendingUnregisters(in: self.defaults) - persistDisabledPushRegistrationCleanupIfNeeded( - in: self.defaults, - enabledKey: Self.enabledKey, - cachedTokenKey: Self.cachedTokenKey, - registeredAccountIDKey: Self.registeredAccountIDKey, - pendingUnregisterQueueKey: Self.pendingUnregisterQueueKey - ) self.session = session self.retryDelays = retryDelays self.retryJitter = retryJitter @@ -132,18 +99,11 @@ public actor PushRegistrationService: PushRegistering { ) } - /// Whether the persisted user preference permits push registration. public var isEnabled: Bool { defaults.bool(forKey: Self.enabledKey) } - - /// The latest local token and backend-registration state. public var snapshot: PushRegistrationSnapshot { snapshotValue } - /// Streams the current snapshot followed by every meaningful state change. public func snapshots() -> AsyncStream { let id = UUID() - if !isEnabled, !pendingUnregisters.isEmpty { - scheduleReconciliation() - } return AsyncStream { continuation in snapshotContinuations[id] = continuation continuation.yield(snapshotValue) @@ -153,258 +113,22 @@ public actor PushRegistrationService: PushRegistering { } } - /// Persists a preference and reconciles its token registration in order. public func setEnabled(_ enabled: Bool) async { - invalidateCoordinatorIntents() - await submitPreferenceIntent(enabled: enabled) - } - - /// Disables local delivery and removes the owned token from the server. - /// - /// The caller may persist the user's opt-out before invoking this method; - /// cleanup therefore uses the persisted registration owner rather than the - /// now-false preference to decide whether a delete is required. - public func disableAndUnregister() async { - invalidateCoordinatorIntents() - await submitPreferenceIntent(enabled: false) - } - - /// Applies an authoritative intent without an external coordinator epoch. - /// Test and direct in-module callers use this convenience entrypoint. - func applyEnabledIntent( - _ enabled: Bool, - generation: UInt64 - ) async { - let intentEpoch = advancePreferenceIntentEpoch() - await applyEnabledIntent( - enabled, - generation: generation, - intentEpoch: intentEpoch - ) - } - - /// Applies the newest coordinator-owned preference when its creation epoch - /// is still current, replacing stale queued work and sharing duplicates. - public func applyEnabledIntent( - _ enabled: Bool, - generation: UInt64, - intentEpoch: PushRegistrationIntentEpoch - ) async { - guard isCurrentPreferenceIntentEpoch(intentEpoch) else { return } - if let invalidatedThrough = coordinatorGenerationInvalidatedThrough, - generation <= invalidatedThrough - { - return - } - if let currentGeneration = coordinatorGeneration { - guard generation >= currentGeneration else { return } - if generation == currentGeneration { - guard let latestCoordinatorIntent else { return } - await submitPreferenceIntent(latestCoordinatorIntent) - return - } - } - coordinatorGeneration = generation - let intent = makePreferenceIntent(enabled: enabled) - latestCoordinatorIntent = intent - await submitPreferenceIntent(intent) - } - - private func submitPreferenceIntent(enabled: Bool) async { - await submitPreferenceIntent(makePreferenceIntent(enabled: enabled)) - } - - private func submitPreferenceIntent( - _ intent: PushRegistrationIntent - ) async { - guard commitPreferenceIntent(intent) else { return } - await requestReconciliation() - } - - private func makePreferenceIntent(enabled: Bool) -> PushRegistrationIntent { - preferenceIntentGeneration &+= 1 - return PushRegistrationIntent( - enabled: enabled, - generation: preferenceIntentGeneration - ) - } - - private func invalidateCoordinatorIntents() { - _ = advancePreferenceIntentEpoch() - coordinatorGenerationInvalidatedThrough = coordinatorGeneration - latestCoordinatorIntent = nil - } - - private func advancePreferenceIntentEpoch() -> PushRegistrationIntentEpoch { - let intentEpoch = PushRegistrationIntentEpoch() - defaults.set( - intentEpoch.storageValue, - forKey: PushRegistrationIntentEpoch.defaultsKey - ) - return intentEpoch - } - - private func isCurrentPreferenceIntentEpoch( - _ intentEpoch: PushRegistrationIntentEpoch - ) -> Bool { - defaults.string(forKey: PushRegistrationIntentEpoch.defaultsKey) - == intentEpoch.storageValue - } - - /// Commits the latest user preference before any authentication or network - /// suspension. Same-direction reconciliation can remain bounded behind an - /// older preparation without delaying the durable toggle state. - private func commitPreferenceIntent( - _ intent: PushRegistrationIntent - ) -> Bool { - guard isCurrentPreferenceIntent(intent.generation), - committedPreferenceIntent != intent else { - return false - } - committedPreferenceIntent = intent + let wasEnabled = isEnabled cancelRetry() - _ = beginServerMutation() - if !intent.enabled { - persistCapturedUnregisterObligation(accountID: nil) - } - defaults.set(intent.enabled, forKey: Self.enabledKey) - if intent.enabled { - let hasToken = cachedTokenHex != nil - publish(PushRegistrationSnapshot( - isEnabled: true, - hasDeviceToken: hasToken, - backendState: hasToken - ? .registrationRequired - : .awaitingDeviceToken - )) - } else { - publish(.disabled) - } - return true - } - - private func reconcilePreferenceIntent( - _ intent: PushRegistrationIntent - ) async { - guard isCurrentPreferenceIntent(intent.generation) else { - return - } - if intent.enabled { - await syncTokenIfPossibleUnlocked() + defaults.set(enabled, forKey: Self.enabledKey) + if enabled { + await syncTokenIfPossible() } else { - if defaults.object(forKey: Self.enabledKey) as? Bool == false { - await unregisterFromServerUnlocked( - preferenceGeneration: intent.generation - ) - } - await retryPendingUnregisterIfPossible( - preferenceGeneration: intent.generation - ) - guard isCurrentOptOut(intent.generation) else { return } publish(.disabled) - } - } - - private func isCurrentPreferenceIntent(_ generation: UInt64) -> Bool { - preferenceIntentGeneration == generation - } - - private func requestReconciliation( - reconcilePreference: Bool = true - ) async { - let startsWorker = reconciliationTask == nil - let task = scheduleReconciliation( - reconcilePreference: reconcilePreference - ) - // Only the caller that created the worker waits for it. Later callers - // coalesce their state into that worker and return, keeping the number - // of suspended callers bounded even when authentication is slow. - guard startsWorker, !Task.isCancelled else { return } - await task.value - } - - @discardableResult - private func scheduleReconciliation( - reconcilePreference: Bool = true - ) -> Task { - reconciliationRequested = true - preferenceReconciliationRequested = - preferenceReconciliationRequested || reconcilePreference - if let reconciliationTask { return reconciliationTask } - let task = Task { [weak self] in - guard let self else { return } - await self.drainReconciliation() - } - reconciliationTask = task - return task - } - - private func drainReconciliation() async { - while reconciliationRequested - || preferenceReconciliationRequested - || pendingCleanupRequest != nil - || pendingUploadRetry != nil { - reconciliationRequested = false - - if let cleanup = pendingCleanupRequest { - pendingCleanupRequest = nil - await reconcileCleanupRequest(cleanup) - } - - if let retry = pendingUploadRetry { - pendingUploadRetry = nil - await attemptUpload( - tokenHex: retry.tokenHex, - generation: retry.generation, - serverMutationGeneration: retry.serverMutationGeneration, - remainingDelays: retry.remainingDelays - ) - continue - } - - if preferenceReconciliationRequested { - preferenceReconciliationRequested = false - let intent = committedPreferenceIntent ?? PushRegistrationIntent( - enabled: isEnabled, - generation: preferenceIntentGeneration - ) - await reconcilePreferenceIntent(intent) + if wasEnabled { + await unregisterFromServer() + } else { + await retryPendingUnregisterIfPossible() } } - - reconciliationTask = nil - if reconciliationRequested - || preferenceReconciliationRequested - || pendingCleanupRequest != nil - || pendingUploadRetry != nil { - scheduleReconciliation(reconcilePreference: false) - } } - private func reconcileCleanupRequest( - _ request: PushRegistrationCleanupRequest - ) async { - switch request { - case let .live(serverMutationGeneration): - await unregisterFromServerUnlocked( - serverMutationGeneration: serverMutationGeneration - ) - case let .captured( - accountID, - accessToken, - refreshToken, - serverMutationGeneration - ): - await unregisterFromServerUnlocked( - accountID: accountID, - accessToken: accessToken, - refreshToken: refreshToken, - serverMutationGeneration: serverMutationGeneration - ) - } - } - - /// Caches an APNs device token and uploads it when push is enabled. public func register(deviceToken: Data) async { let hex = deviceToken.map { String(format: "%02x", $0) }.joined() let previousToken = cachedTokenHex @@ -424,32 +148,20 @@ public actor PushRegistrationService: PushRegistering { defaults.removeObject(forKey: Self.registeredAccountIDKey) } defaults.set(hex, forKey: Self.cachedTokenKey) - cancelRetry() - _ = beginServerMutation() guard isEnabled else { publish(.disabled) return } - publish(PushRegistrationSnapshot( - isEnabled: true, - hasDeviceToken: true, - backendState: .registrationRequired - )) - await requestReconciliation() + cancelRetry() + await upload(tokenHex: hex) + if snapshotValue.backendState == .registered { + await retryPendingUnregisterIfPossible() + } } - /// Reconciles cached registration and pending cleanup with the current account. public func syncTokenIfPossible() async { - _ = beginServerMutation() - await requestReconciliation() - } - - private func syncTokenIfPossibleUnlocked() async { - let preferenceGeneration = preferenceIntentGeneration guard isEnabled else { - await retryPendingUnregisterIfPossible( - preferenceGeneration: preferenceGeneration - ) + await retryPendingUnregisterIfPossible() publish(.disabled) return } @@ -473,44 +185,21 @@ public actor PushRegistrationService: PushRegistering { } } - /// Durably schedules and attempts removal of the currently owned token. public func unregisterFromServer() async { - let serverMutationGeneration = beginServerMutation() - persistCapturedUnregisterObligation(accountID: nil) - guard !Task.isCancelled else { return } - pendingCleanupRequest = .live( - serverMutationGeneration: serverMutationGeneration - ) - await requestReconciliation(reconcilePreference: false) - } - - private func unregisterFromServerUnlocked( - preferenceGeneration: UInt64? = nil, - serverMutationGeneration: UInt64? = nil - ) async { cancelRetry() guard let hex = cachedTokenHex else { return } - // A live session identifies who is signed in now, not who owns this - // token. During an account switch those can differ, so fail closed - // unless the registration owner is persisted in either the owner - // marker or a durable cleanup obligation. - guard let ownerID = persistedOwnerID(for: hex) else { - pushLog.info("Skipping push-token unregister: persisted owner unavailable") - return - } + let session = try? await tokenProvider.authenticatedSessionSnapshot() + let ownerID = defaults.string( + forKey: Self.registeredAccountIDKey + ) ?? session?.accountID + guard let ownerID, !ownerID.isEmpty else { return } // Persist before requiring live auth. This is the privacy guarantee for // an offline or signed-out opt-out. persistPendingUnregister(tokenHex: hex, accountID: ownerID) - let session = try? await tokenProvider.authenticatedSessionSnapshot() // A token acknowledged for account A must never be deleted using // account B credentials. Its tombstone waits for A to return. guard let session, session.accountID == ownerID else { return } - if await sendDelete( - tokenHex: hex, - sessionSnapshot: session, - preferenceGeneration: preferenceGeneration, - serverMutationGeneration: serverMutationGeneration - ) { + if await sendDelete(tokenHex: hex, sessionSnapshot: session) { clearPendingUnregister(tokenHex: hex, accountID: ownerID) clearRegisteredOwner(accountID: ownerID, tokenHex: hex) } @@ -524,16 +213,11 @@ public actor PushRegistrationService: PushRegistering { /// - accessToken: The captured (or teardown-minted) access token. /// - refreshToken: The captured refresh token. public func unregisterFromServer(accessToken: String?, refreshToken: String?) async { - let serverMutationGeneration = beginServerMutation() - persistCapturedUnregisterObligation(accountID: nil) - guard !Task.isCancelled else { return } - pendingCleanupRequest = .captured( + await unregisterFromServer( accountID: nil, accessToken: accessToken, - refreshToken: refreshToken, - serverMutationGeneration: serverMutationGeneration + refreshToken: refreshToken ) - await requestReconciliation(reconcilePreference: false) } /// Sign-out variant with the account id captured before local auth clear. @@ -541,47 +225,21 @@ public actor PushRegistrationService: PushRegistering { accountID capturedAccountID: String?, accessToken: String?, refreshToken: String? - ) async { - let serverMutationGeneration = beginServerMutation() - persistCapturedUnregisterObligation(accountID: capturedAccountID) - guard !Task.isCancelled else { return } - pendingCleanupRequest = .captured( - accountID: capturedAccountID, - accessToken: accessToken, - refreshToken: refreshToken, - serverMutationGeneration: serverMutationGeneration - ) - await requestReconciliation(reconcilePreference: false) - } - - /// Records the cleanup obligation before joining reconciliation. Sign-out - /// callers are commonly canceled while an earlier registration is still in - /// flight, so the durable tombstone cannot depend on network completion. - private func persistCapturedUnregisterObligation(accountID: String?) { - guard let hex = cachedTokenHex else { return } - let ownerID = persistedOwnerID(for: hex) ?? accountID - guard let ownerID, !ownerID.isEmpty else { return } - persistPendingUnregister(tokenHex: hex, accountID: ownerID) - } - - private func unregisterFromServerUnlocked( - accountID capturedAccountID: String?, - accessToken: String?, - refreshToken: String?, - serverMutationGeneration: UInt64 ) async { cancelRetry() guard let hex = cachedTokenHex else { return } - let persistedOwner = persistedOwnerID(for: hex) - let ownerID = persistedOwner ?? capturedAccountID + let registeredOwnerID = defaults.string( + forKey: Self.registeredAccountIDKey + ) + let ownerID = registeredOwnerID ?? capturedAccountID if let ownerID, !ownerID.isEmpty { // Persist the recovery record before validating credentials. // Offline sign-out commonly has only the refresh token, but a // later sign-in to this same account can safely finish the DELETE. persistPendingUnregister(tokenHex: hex, accountID: ownerID) } - if let persistedOwner, - capturedAccountID != persistedOwner { + if let registeredOwnerID, + capturedAccountID != registeredOwnerID { // The legacy overload has no account identity, and a caller // explicitly carrying B must never apply B's credentials to A's // acknowledged token. Keep A's tombstone until A returns. @@ -601,8 +259,7 @@ public actor PushRegistrationService: PushRegistering { if await sendDelete( tokenHex: hex, capturedAccessToken: accessToken, - capturedRefreshToken: refreshToken, - serverMutationGeneration: serverMutationGeneration + capturedRefreshToken: refreshToken ), let ownerID { clearPendingUnregister(tokenHex: hex, accountID: ownerID) clearRegisteredOwner(accountID: ownerID, tokenHex: hex) @@ -624,11 +281,9 @@ public actor PushRegistrationService: PushRegistering { private func upload(tokenHex: String) async { operationGeneration = UUID() let generation = operationGeneration - let serverMutationGeneration = beginServerMutation() await attemptUpload( tokenHex: tokenHex, generation: generation, - serverMutationGeneration: serverMutationGeneration, remainingDelays: retryDelays ) } @@ -636,7 +291,6 @@ public actor PushRegistrationService: PushRegistering { private func attemptUpload( tokenHex: String, generation: UUID, - serverMutationGeneration: UInt64, remainingDelays: [Duration] ) async { guard isEnabled, generation == operationGeneration, @@ -661,20 +315,13 @@ public actor PushRegistrationService: PushRegistering { switch request { case let .success(context): requestSession = context.session - result = await performRegistration( - context.request, - tokenHex: tokenHex, - generation: generation, - serverMutationGeneration: serverMutationGeneration, - cleanupAccountID: requestSession?.accountID - ) + result = await performRegistration(context.request) case let .failure(failure): requestSession = nil result = .failure(failure, retryAfter: nil) } let operationIsCurrent = isEnabled && generation == operationGeneration - && serverMutationGeneration == self.serverMutationGeneration && cachedTokenHex == tokenHex let sessionIsCurrent: Bool if let requestSession { @@ -702,24 +349,6 @@ public actor PushRegistrationService: PushRegistering { return } switch result { - case .cancelled: - // The reconciler may reject an invalidated operation before any - // request starts. Leave the enabled token recoverable instead of - // stranding the snapshot in `.registering`. - publish(PushRegistrationSnapshot( - isEnabled: true, - hasDeviceToken: true, - backendState: .registrationRequired - )) - scheduleUploadRetry( - failure: .networkUnavailable, - retryAfter: nil, - tokenHex: tokenHex, - generation: generation, - serverMutationGeneration: serverMutationGeneration, - remainingDelays: remainingDelays - ) - return case let .success(pushServiceConfigured): if let requestSession { defaults.set( @@ -758,7 +387,6 @@ public actor PushRegistrationService: PushRegistering { retryAfter: nil, tokenHex: tokenHex, generation: generation, - serverMutationGeneration: serverMutationGeneration, remainingDelays: remainingDelays ) } @@ -773,7 +401,6 @@ public actor PushRegistrationService: PushRegistering { retryAfter: retryAfter, tokenHex: tokenHex, generation: generation, - serverMutationGeneration: serverMutationGeneration, remainingDelays: remainingDelays ) } @@ -784,7 +411,6 @@ public actor PushRegistrationService: PushRegistering { retryAfter: Duration?, tokenHex: String, generation: UUID, - serverMutationGeneration: UInt64, remainingDelays: [Duration] ) { guard failure.isRecoverable, !remainingDelays.isEmpty else { return } @@ -801,36 +427,14 @@ public actor PushRegistrationService: PushRegistering { return } guard !Task.isCancelled else { return } - await self?.enqueueUploadRetry( + await self?.attemptUpload( tokenHex: tokenHex, generation: generation, - serverMutationGeneration: serverMutationGeneration, remainingDelays: laterDelays ) } } - private func enqueueUploadRetry( - tokenHex: String, - generation: UUID, - serverMutationGeneration: UInt64, - remainingDelays: [Duration] - ) { - retryTask = nil - guard isCurrentUpload( - tokenHex: tokenHex, - generation: generation, - serverMutationGeneration: serverMutationGeneration - ) else { return } - pendingUploadRetry = ( - tokenHex: tokenHex, - generation: generation, - serverMutationGeneration: serverMutationGeneration, - remainingDelays: remainingDelays - ) - scheduleReconciliation(reconcilePreference: false) - } - /// Repairs the backend after an invalidated POST still succeeds. /// /// URLSession cancellation cannot prove that the server did not commit the @@ -871,21 +475,19 @@ public actor PushRegistrationService: PushRegistering { ) } - guard isEnabled, cachedTokenHex != nil, + guard isEnabled, let currentToken = cachedTokenHex, let currentSession = try? await tokenProvider .authenticatedSessionSnapshot(), await tokenProvider.isAuthenticatedSessionCurrent(currentSession) else { return } - preferenceReconciliationRequested = true + await upload(tokenHex: currentToken) } private func sendDelete( tokenHex: String, capturedAccessToken: String? = nil, capturedRefreshToken: String? = nil, - sessionSnapshot: AuthenticatedSessionSnapshot? = nil, - preferenceGeneration: UInt64? = nil, - serverMutationGeneration: UInt64? = nil + sessionSnapshot: AuthenticatedSessionSnapshot? = nil ) async -> Bool { guard case let .success(context) = await makeRequest( method: "DELETE", @@ -895,25 +497,9 @@ public actor PushRegistrationService: PushRegistering { capturedRefreshToken: capturedRefreshToken, sessionSnapshot: sessionSnapshot ) else { return false } - guard await performDelete( - context.request, - preferenceGeneration: preferenceGeneration, - serverMutationGeneration: serverMutationGeneration - ) else { return false } - if let preferenceGeneration, - !isCurrentOptOut(preferenceGeneration) { - return false - } - if let serverMutationGeneration, - serverMutationGeneration != self.serverMutationGeneration { - return false - } + guard await performDelete(context.request) else { return false } if let session = context.session { - guard await tokenProvider.isAuthenticatedSessionCurrent(session) - else { return false } - if let preferenceGeneration { - return isCurrentOptOut(preferenceGeneration) - } + return await tokenProvider.isAuthenticatedSessionCurrent(session) } return true } @@ -966,44 +552,7 @@ public actor PushRegistrationService: PushRegistering { )) } - private func performRegistration( - _ request: URLRequest, - tokenHex: String, - generation: UUID, - serverMutationGeneration: UInt64, - cleanupAccountID: String? - ) async -> RegistrationResult { - guard isCurrentUpload( - tokenHex: tokenHex, - generation: generation, - serverMutationGeneration: serverMutationGeneration - ) else { - return .cancelled - } - // The POST may commit even if this process is suspended before its - // response arrives. Persist its cleanup owner only after the single - // reconciler admits this still-current request. - if let cleanupAccountID { - persistPendingUnregister( - tokenHex: tokenHex, - accountID: cleanupAccountID - ) - } - return await performRegistrationRequest(request) - } - - private func isCurrentUpload( - tokenHex: String, - generation: UUID, - serverMutationGeneration: UInt64 - ) -> Bool { - isEnabled - && generation == operationGeneration - && serverMutationGeneration == self.serverMutationGeneration - && cachedTokenHex == tokenHex - } - - private func performRegistrationRequest(_ request: URLRequest) async -> RegistrationResult { + private func performRegistration(_ request: URLRequest) async -> RegistrationResult { let redirectDelegate = RedirectMethodPreservingDelegate() do { let (data, response) = try await session.data( @@ -1035,36 +584,7 @@ public actor PushRegistrationService: PushRegistering { } } - private func performDelete( - _ request: URLRequest, - preferenceGeneration: UInt64? = nil, - serverMutationGeneration: UInt64? = nil - ) async -> Bool { - if let preferenceGeneration, - !isCurrentOptOut(preferenceGeneration) { - return false - } - if let serverMutationGeneration, - !isCurrentServerMutation(serverMutationGeneration) { - return false - } - return await performDeleteRequest(request) - } - - private func beginServerMutation() -> UInt64 { - serverMutationGeneration &+= 1 - return serverMutationGeneration - } - - private func isCurrentServerMutation(_ generation: UInt64) -> Bool { - generation == serverMutationGeneration - } - - private func isCurrentOptOut(_ generation: UInt64) -> Bool { - preferenceIntentGeneration == generation && !isEnabled - } - - private func performDeleteRequest(_ request: URLRequest) async -> Bool { + private func performDelete(_ request: URLRequest) async -> Bool { let redirectDelegate = RedirectMethodPreservingDelegate() do { let (data, response) = try await session.data( @@ -1095,9 +615,7 @@ public actor PushRegistrationService: PushRegistering { } } - private func retryPendingUnregisterIfPossible( - preferenceGeneration: UInt64? = nil - ) async { + private func retryPendingUnregisterIfPossible() async { guard let session = try? await tokenProvider .authenticatedSessionSnapshot() else { return } let currentAccountID = session.accountID @@ -1107,15 +625,28 @@ public actor PushRegistrationService: PushRegistering { let batch = Array( matching.prefix(Self.pendingUnregisterAttemptBudget) ) - var madeProgress = false - for pending in batch { - let succeeded = await sendDelete( - tokenHex: pending.tokenHex, - sessionSnapshot: session, - preferenceGeneration: preferenceGeneration - ) - guard succeeded else { continue } - madeProgress = true + let results = await withTaskGroup( + of: (PendingUnregister, Bool).self, + returning: [(PendingUnregister, Bool)].self + ) { group in + for pending in batch { + group.addTask { [self] in + ( + pending, + await sendDelete( + tokenHex: pending.tokenHex, + sessionSnapshot: session + ) + ) + } + } + var results: [(PendingUnregister, Bool)] = [] + for await result in group { + results.append(result) + } + return results + } + for (pending, succeeded) in results where succeeded { clearPendingUnregister( tokenHex: pending.tokenHex, accountID: pending.accountID @@ -1126,8 +657,8 @@ public actor PushRegistrationService: PushRegistering { ) } if matching.count > batch.count, - madeProgress { - preferenceReconciliationRequested = true + results.contains(where: { $0.1 }) { + schedulePendingUnregisterContinuation() } } @@ -1143,6 +674,20 @@ public actor PushRegistrationService: PushRegistering { storePendingUnregisters(queue) } + private func schedulePendingUnregisterContinuation() { + guard unregisterDrainTask == nil else { return } + unregisterDrainTask = Task { [weak self] in + await Task.yield() + guard !Task.isCancelled, let self else { return } + await self.runPendingUnregisterContinuation() + } + } + + private func runPendingUnregisterContinuation() async { + unregisterDrainTask = nil + await retryPendingUnregisterIfPossible() + } + private func clearPendingUnregister( tokenHex: String, accountID: String @@ -1168,26 +713,6 @@ public actor PushRegistrationService: PushRegistering { return entries.filter { seen.insert($0).inserted } } - /// Returns the only owner that durable local state can prove for a token. - /// A current auth session is deliberately not an ownership proof because - /// it may already belong to the next account after sign-in races opt-out. - private func persistedOwnerID(for tokenHex: String) -> String? { - if let registeredOwnerID = defaults.string( - forKey: Self.registeredAccountIDKey - ), !registeredOwnerID.isEmpty { - return registeredOwnerID - } - let owners = Set( - pendingUnregisters.compactMap { pending in - guard pending.tokenHex == tokenHex, - !pending.accountID.isEmpty else { return nil } - return pending.accountID - } - ) - guard owners.count == 1 else { return nil } - return owners.first - } - private static func migrateLegacyPendingUnregisters( in defaults: UserDefaults ) { @@ -1240,7 +765,6 @@ public actor PushRegistrationService: PushRegistering { defaults.removeObject(forKey: Self.registeredAccountIDKey) } - /// Records that iOS failed to provide a device token for this attempt. public func deviceTokenRegistrationFailed() { cancelRetry() guard isEnabled else { @@ -1258,7 +782,6 @@ public actor PushRegistrationService: PushRegistering { operationGeneration = UUID() retryTask?.cancel() retryTask = nil - pendingUploadRetry = nil } private func publish(_ snapshot: PushRegistrationSnapshot) { @@ -1365,42 +888,7 @@ public actor PushRegistrationService: PushRegistering { } } -/// Converts a persisted opt-out plus known server ownership into a durable -/// cleanup obligation before any asynchronous startup work begins. -private func persistDisabledPushRegistrationCleanupIfNeeded( - in defaults: UserDefaults, - enabledKey: String, - cachedTokenKey: String, - registeredAccountIDKey: String, - pendingUnregisterQueueKey: String -) { - // An absent preference is not an opt-out. Only a durably stored `false` - // authorizes startup cleanup of an otherwise owned registration. - guard defaults.object(forKey: enabledKey) as? Bool == false, - let tokenHex = defaults.string(forKey: cachedTokenKey), - !tokenHex.isEmpty, - let accountID = defaults.string(forKey: registeredAccountIDKey), - !accountID.isEmpty - else { return } - var entries = (defaults.data(forKey: pendingUnregisterQueueKey) - .flatMap { try? JSONDecoder().decode( - [PendingUnregister].self, - from: $0 - ) }) ?? [] - let pending = PendingUnregister( - tokenHex: tokenHex, - accountID: accountID - ) - if !entries.contains(pending) { - entries.append(pending) - } - if let data = try? JSONEncoder().encode(entries) { - defaults.set(data, forKey: pendingUnregisterQueueKey) - } -} - private enum RegistrationResult { - case cancelled case success(pushServiceConfigured: Bool) case failure(PushRegistrationFailure, retryAfter: Duration?) } diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 57175b9ab57..acbaa31061f 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -83,9 +83,6 @@ struct FakeTokenProvider: TokenProviding { actor MutablePushTokenProvider: TokenProviding { private var value: AuthenticatedSessionSnapshot? - private var snapshotBlocker: TestContinuationBlocker? - private var snapshotStarted: TestPhaseSignal? - private var nextSnapshotSignal: TestPhaseSignal? init( accountID: String, @@ -118,30 +115,8 @@ actor MutablePushTokenProvider: TokenProviding { value = nil } - func blockAuthenticatedSessionSnapshot( - started: TestPhaseSignal, - until blocker: TestContinuationBlocker - ) { - snapshotStarted = started - snapshotBlocker = blocker - } - - func signalNextAuthenticatedSessionSnapshot(_ signal: TestPhaseSignal) { - nextSnapshotSignal = signal - } - func authenticatedSessionSnapshot() async throws -> AuthenticatedSessionSnapshot { - let nextSnapshotSignal = self.nextSnapshotSignal - self.nextSnapshotSignal = nil - await nextSnapshotSignal?.markStarted() - if let blocker = snapshotBlocker { - snapshotBlocker = nil - let started = snapshotStarted - snapshotStarted = nil - await started?.markStarted() - await blocker.wait() - } guard let value else { throw AuthError.unauthorized } return value } @@ -388,57 +363,6 @@ actor RetryDelayRecorder { ) } - @Test func cancelledQueuedSignOutStillPersistsCleanupObligation() async { - await PushRegistrationURLProtocol.script.reset([]) - let provider = MutablePushTokenProvider( - accountID: "account-a", - accessToken: "a-access", - refreshToken: "a-refresh" - ) - let started = TestPhaseSignal() - let blocker = TestContinuationBlocker() - await provider.blockAuthenticatedSessionSnapshot( - started: started, - until: blocker - ) - let (service, defaults) = makeScriptedService( - tokenProvider: provider, - accountID: nil - ) - defaults.set("ab", forKey: "cmux.notifications.deviceTokenHex") - defaults.set( - "account-a", - forKey: "cmux.notifications.registeredAccountID" - ) - - let heldMutation = Task { - await service.unregisterFromServer() - } - await started.waitUntilStarted() - await provider.clearSession() - - let queuedSignOut = Task { - await service.unregisterFromServer( - accountID: "account-a", - accessToken: "captured-access", - refreshToken: "captured-refresh" - ) - } - queuedSignOut.cancel() - // A coalesced call never adds another worker waiter, so cancellation - // returns while the first mutation is still blocked. - await queuedSignOut.value - - await blocker.release() - await heldMutation.value - - let queueText = defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v2" - ).flatMap { String(data: $0, encoding: .utf8) } - #expect(queueText?.contains("account-a") == true) - #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) - } - @Test func signOutNeverUsesCapturedAccountBToDeleteRegisteredAccountA() async { await PushRegistrationURLProtocol.script.reset([.response(200)]) let suite = "push-signout-owner-mismatch-\(UUID().uuidString)" @@ -774,133 +698,6 @@ actor RetryDelayRecorder { #expect(await relaunched.snapshot == .disabled) } - @Test func disabledStartupDurablyRecoversOwnedRegistrationCleanup() async { - await PushRegistrationURLProtocol.script.reset([.response(200)]) - let suite = "push-disabled-startup-\(UUID().uuidString)" - let (service, defaults) = makeScriptedService( - suite: suite, - accountID: "account-a", - seedDefaults: { defaults in - defaults.set(false, forKey: "cmux.notifications.pushEnabled") - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - defaults.set( - "account-a", - forKey: "cmux.notifications.registeredAccountID" - ) - } - ) - - let persisted = try? JSONDecoder().decode( - [[String: String]].self, - from: defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v2" - ) ?? Data() - ) - #expect(persisted == [["tokenHex": "aa", "accountID": "account-a"]]) - - await service.syncTokenIfPossible() - - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["DELETE"] - ) - #expect( - defaults.data(forKey: "cmux.notifications.pendingUnregisters.v2") - == nil - ) - } - - @Test func startupCleanupCannotDeleteTokenReenabledWhileDrainWaits() async { - let cleanupAuthenticationStarted = TestPhaseSignal() - let cleanupAuthenticationBlocker = TestContinuationBlocker() - let registrationStarted = TestPhaseSignal() - let registrationBlocker = TestContinuationBlocker() - let provider = MutablePushTokenProvider( - accountID: "account-a", - accessToken: "a-access", - refreshToken: "a-refresh" - ) - await provider.blockAuthenticatedSessionSnapshot( - started: cleanupAuthenticationStarted, - until: cleanupAuthenticationBlocker - ) - await PushRegistrationURLProtocol.script.reset([ - .gatedResponse( - 200, - started: registrationStarted, - blocker: registrationBlocker - ), - .response(200), - ]) - let (service, defaults) = makeScriptedService( - tokenProvider: provider, - accountID: nil, - seedDefaults: { defaults in - defaults.set(false, forKey: "cmux.notifications.pushEnabled") - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - defaults.set( - "account-a", - forKey: "cmux.notifications.registeredAccountID" - ) - } - ) - - let snapshots = await service.snapshots() - await cleanupAuthenticationStarted.waitUntilStarted() - let enable = Task { await service.setEnabled(true) } - - for _ in 0..<1_000 - where !defaults.bool( - forKey: "cmux.notifications.pushEnabled" - ) { - await Task.yield() - } - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) - #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) - - await cleanupAuthenticationBlocker.release() - await registrationStarted.waitUntilStarted() - await registrationBlocker.release() - await enable.value - #expect(await wait(for: .registered, from: service)) - - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["POST"] - ) - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) - #expect(await service.snapshot.backendState == .registered) - _ = snapshots - } - - @Test func absentPreferenceDoesNotScheduleRegistrationCleanup() async { - await PushRegistrationURLProtocol.script.reset([.response(200)]) - let suite = "push-absent-preference-startup-\(UUID().uuidString)" - let (service, defaults) = makeScriptedService( - suite: suite, - accountID: "account-a", - seedDefaults: { defaults in - defaults.removeObject(forKey: "cmux.notifications.pushEnabled") - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - defaults.set( - "account-a", - forKey: "cmux.notifications.registeredAccountID" - ) - } - ) - - #expect( - defaults.object(forKey: "cmux.notifications.pushEnabled") == nil - ) - #expect( - defaults.data(forKey: "cmux.notifications.pendingUnregisters.v2") - == nil - ) - - await service.syncTokenIfPossible() - #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) - } - @Test func optOutWithoutLiveSessionPersistsOwnerBeforeAuthentication() async { await PushRegistrationURLProtocol.script.reset([.response(200)]) let suite = "push-optout-no-session-\(UUID().uuidString)" @@ -970,37 +767,6 @@ actor RetryDelayRecorder { #expect(queueText?.contains("account-b") == false) } - @Test func pendingOwnerWinsWhenRegisteredOwnerMetadataIsMissing() async { - await PushRegistrationURLProtocol.script.reset([.response(200)]) - let suite = "push-optout-pending-owner-\(UUID().uuidString)" - let (service, defaults) = makeScriptedService( - tokenProvider: FakeTokenProvider( - access: "b-access", - refresh: "b-refresh" - ), - suite: suite, - accountID: "account-b" - ) - defaults.set(true, forKey: "cmux.notifications.pushEnabled") - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - defaults.set( - try? JSONEncoder().encode([[ - "tokenHex": "aa", - "accountID": "account-a", - ]]), - forKey: "cmux.notifications.pendingUnregisters.v2" - ) - - await service.setEnabled(false) - - #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) - let queueText = defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v2" - ).flatMap { String(data: $0, encoding: .utf8) } - #expect(queueText?.contains("account-a") == true) - #expect(queueText?.contains("account-b") == false) - } - @Test func malformedDeleteAcknowledgementKeepsDurableTombstone() async { await PushRegistrationURLProtocol.script.reset([ .response(200, json: ""), @@ -1060,19 +826,20 @@ actor RetryDelayRecorder { await service.register(deviceToken: Data([0xAA])) } await started.waitUntilStarted() - let disable = Task { - await service.setEnabled(false) - } + await service.setEnabled(false) await blocker.release() await upload.value - await disable.value let requests = await PushRegistrationURLProtocol.script.requests - #expect(requests.map(\.httpMethod) == ["POST", "DELETE"]) + #expect(requests.map(\.httpMethod) == ["POST", "DELETE", "DELETE"]) #expect( requests.map { $0.value(forHTTPHeaderField: "Authorization") - } == ["Bearer a-access", "Bearer a-access"] + } == [ + "Bearer a-access", + "Bearer a-access", + "Bearer a-access", + ] ) #expect( defaults.data( @@ -1081,417 +848,6 @@ actor RetryDelayRecorder { ) } - @Test func directTokenRegistrationCannotPostAfterOptOut() async { - await PushRegistrationURLProtocol.script.reset([ - .response(200), - .response(200), - ]) - let provider = MutablePushTokenProvider( - accountID: "account-a", - accessToken: "a-access", - refreshToken: "a-refresh" - ) - let authorizationStarted = TestPhaseSignal() - let authorizationBlocker = TestContinuationBlocker() - await provider.blockAuthenticatedSessionSnapshot( - started: authorizationStarted, - until: authorizationBlocker - ) - let (service, defaults) = makeScriptedService( - tokenProvider: provider, - accountID: nil, - seedDefaults: { defaults in - defaults.set(true, forKey: "cmux.notifications.pushEnabled") - } - ) - - let registration = Task { - await service.register(deviceToken: Data([0xAA])) - } - await authorizationStarted.waitUntilStarted() - - let disableStarted = TestPhaseSignal() - await provider.signalNextAuthenticatedSessionSnapshot(disableStarted) - let disable = Task { - await service.setEnabled(false) - } - for _ in 0..<100 where !(await disableStarted.didStart) { - await Task.yield() - } - - await authorizationBlocker.release() - await registration.value - await disable.value - - let methods = await PushRegistrationURLProtocol.script.requests - .compactMap(\.httpMethod) - let postIndex = methods.firstIndex(of: "POST") - let deleteIndex = methods.firstIndex(of: "DELETE") - #expect( - postIndex == nil || (deleteIndex != nil && postIndex! < deleteIndex!) - ) - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) - } - - @Test func stalledEnablePreparationCannotBlockNewerOptOut() async { - await PushRegistrationURLProtocol.script.reset([.response(200)]) - let provider = MutablePushTokenProvider( - accountID: "account-a", - accessToken: "a-access", - refreshToken: "a-refresh" - ) - let enableStarted = TestPhaseSignal() - let enableBlocker = TestContinuationBlocker() - await provider.blockAuthenticatedSessionSnapshot( - started: enableStarted, - until: enableBlocker - ) - let (service, defaults) = makeScriptedService( - tokenProvider: provider, - accountID: nil - ) - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - defaults.set( - "account-a", - forKey: "cmux.notifications.registeredAccountID" - ) - - let enable = Task { - await service.applyEnabledIntent(true, generation: 1) - } - await enableStarted.waitUntilStarted() - - let disableFinished = TestPhaseSignal() - let disable = Task { - await service.applyEnabledIntent(false, generation: 2) - await disableFinished.markStarted() - } - for _ in 0..<1_000 - where defaults.bool( - forKey: "cmux.notifications.pushEnabled" - ) { - await Task.yield() - } - - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) - let pendingText = defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v2" - ).flatMap { String(data: $0, encoding: .utf8) } - #expect(pendingText?.contains("account-a") == true) - - await enableBlocker.release() - await enable.value - await disable.value - #expect(await disableFinished.didStart) - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["DELETE"] - ) - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) - } - - @Test func stalledEnablePreparationCoalescesSameDirectionIntent() async { - await PushRegistrationURLProtocol.script.reset([.response(200)]) - let provider = MutablePushTokenProvider( - accountID: "account-a", - accessToken: "a-access", - refreshToken: "a-refresh" - ) - let firstEnableStarted = TestPhaseSignal() - let firstEnableBlocker = TestContinuationBlocker() - await provider.blockAuthenticatedSessionSnapshot( - started: firstEnableStarted, - until: firstEnableBlocker - ) - let (service, defaults) = makeScriptedService( - tokenProvider: provider, - accountID: nil - ) - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - - let firstEnable = Task { - await service.applyEnabledIntent(true, generation: 1) - } - await firstEnableStarted.waitUntilStarted() - - let recovery = Task { - await service.applyEnabledIntent(true, generation: 2) - } - - for _ in 0..<1_000 - where !defaults.bool( - forKey: "cmux.notifications.pushEnabled" - ) { - await Task.yield() - } - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod).isEmpty - ) - - await firstEnableBlocker.release() - await firstEnable.value - await recovery.value - #expect(await service.snapshot.backendState == .registered) - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["POST"] - ) - } - - @Test func inFlightRegistrationPersistsCleanupOwnerBeforePostCompletes() async { - let started = TestPhaseSignal() - let blocker = TestContinuationBlocker() - await PushRegistrationURLProtocol.script.reset([ - .gatedResponse(200, started: started, blocker: blocker), - .response(200), - ]) - let (service, defaults) = makeScriptedService(accountID: "account-a") - defaults.set(true, forKey: "cmux.notifications.pushEnabled") - - let upload = Task { - await service.register(deviceToken: Data([0xAA])) - } - await started.waitUntilStarted() - - let queueText = defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v2" - ).flatMap { String(data: $0, encoding: .utf8) } - #expect(queueText?.contains("account-a") == true) - - await blocker.release() - await upload.value - #expect( - defaults.data(forKey: "cmux.notifications.pendingUnregisters.v2") - == nil - ) - } - - @Test func disablingDuringInFlightEnableSerializesBackendMutation() async { - let started = TestPhaseSignal() - let blocker = TestContinuationBlocker() - await PushRegistrationURLProtocol.script.reset([ - .gatedResponse(200, started: started, blocker: blocker), - .response(200), - ]) - let (service, defaults) = makeScriptedService(accountID: "account-a") - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - - let enable = Task { await service.setEnabled(true) } - await started.waitUntilStarted() - let disable = Task { await service.setEnabled(false) } - - await blocker.release() - await enable.value - await disable.value - - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["POST", "DELETE"] - ) - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) - } - - @Test func disablePersistsOptOutBeforeServerCleanupCompletes() async { - let started = TestPhaseSignal() - let blocker = TestContinuationBlocker() - await PushRegistrationURLProtocol.script.reset([ - .gatedResponse(200, started: started, blocker: blocker), - ]) - let (service, defaults) = makeScriptedService(accountID: "account-a") - defaults.set(true, forKey: "cmux.notifications.pushEnabled") - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - defaults.set( - "account-a", - forKey: "cmux.notifications.registeredAccountID" - ) - - let disable = Task { - await service.disableAndUnregister() - } - await started.waitUntilStarted() - - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled") == false) - - await blocker.release() - await disable.value - } - - @Test func supersededQueuedOptOutCannotUndoAReenable() async { - let started = TestPhaseSignal() - let blocker = TestContinuationBlocker() - await PushRegistrationURLProtocol.script.reset([ - .gatedResponse(200, started: started, blocker: blocker), - .response(200), - ]) - let (service, defaults) = makeScriptedService(accountID: "account-a") - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - - let firstEnable = Task { - await service.applyEnabledIntent(true, generation: 1) - } - await started.waitUntilStarted() - let optOut = Task { - await service.applyEnabledIntent(false, generation: 2) - } - let reenable = Task { - await service.applyEnabledIntent(true, generation: 3) - } - - await blocker.release() - await firstEnable.value - await optOut.value - await reenable.value - - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) - #expect(await service.snapshot.backendState == .registered) - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["POST", "POST"] - ) - } - - @Test func coordinatorGenerationZeroEnablesPreviouslyAuthorizedStartup() async { - await PushRegistrationURLProtocol.script.reset([.response(200)]) - let (service, defaults) = makeScriptedService(accountID: "account-a") - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - - await service.applyEnabledIntent(true, generation: 0) - - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) - #expect(await service.snapshot.backendState == .registered) - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["POST"] - ) - } - - @Test func directMutationSupersedesQueuedCoordinatorIntent() async { - let started = TestPhaseSignal() - let blocker = TestContinuationBlocker() - await PushRegistrationURLProtocol.script.reset([ - .gatedResponse(200, started: started, blocker: blocker), - .response(200), - ]) - let (service, defaults) = makeScriptedService(accountID: "account-a") - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - - let firstEnable = Task { - await service.applyEnabledIntent(true, generation: 1) - } - await started.waitUntilStarted() - let queuedOptOut = Task { - await service.applyEnabledIntent(false, generation: 2) - } - for _ in 0..<1_000 - where defaults.bool( - forKey: "cmux.notifications.pushEnabled" - ) { - await Task.yield() - } - #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) - let directReenable = Task { - await service.setEnabled(true) - } - - await blocker.release() - await firstEnable.value - await queuedOptOut.value - await directReenable.value - - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) - #expect(await service.snapshot.backendState == .registered) - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["POST", "POST"] - ) - } - - @Test func directOptOutRejectsCoordinatorIntentCreatedBeforeBarrier() async { - await PushRegistrationURLProtocol.script.reset([]) - let (service, defaults) = makeScriptedService(accountID: "account-a") - let staleEpoch = PushRegistrationIntentEpoch() - defaults.set( - staleEpoch.storageValue, - forKey: PushRegistrationIntentEpoch.defaultsKey - ) - - await service.setEnabled(false) - await service.applyEnabledIntent( - true, - generation: 1, - intentEpoch: staleEpoch - ) - - #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) - #expect(await service.snapshot == .disabled) - #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) - } - - @Test func cancelledQueuedRegistrationLeavesRecoverableState() async { - let started = TestPhaseSignal() - let blocker = TestContinuationBlocker() - await PushRegistrationURLProtocol.script.reset([ - .gatedResponse(200, started: started, blocker: blocker), - .response(200), - .response(200), - ]) - let (service, defaults) = makeScriptedService( - retryDelays: [], - accountID: "account-a" - ) - defaults.set(true, forKey: "cmux.notifications.pushEnabled") - - let first = Task { - await service.register(deviceToken: Data([0xAA])) - } - await started.waitUntilStarted() - - let queued = Task { - await service.register(deviceToken: Data([0xAA])) - } - queued.cancel() - - await blocker.release() - await first.value - await queued.value - - #expect( - await service.snapshot.backendState - != PushRegistrationBackendState.registering - ) - } - - @Test func concurrentSameGenerationSharesRegistrationMutation() async { - let started = TestPhaseSignal() - let blocker = TestContinuationBlocker() - await PushRegistrationURLProtocol.script.reset([ - .gatedResponse(200, started: started, blocker: blocker), - ]) - let (service, defaults) = makeScriptedService(accountID: "account-a") - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - - let firstEnable = Task { - await service.applyEnabledIntent(true, generation: 1) - } - await started.waitUntilStarted() - let secondEnable = Task { - await service.applyEnabledIntent(true, generation: 1) - } - - await blocker.release() - await firstEnable.value - await secondEnable.value - await service.applyEnabledIntent(true, generation: 1) - - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["POST"] - ) - #expect(await service.snapshot.backendState == .registered) - } - @Test func signOutDuringInFlightRegistrationDeletesAfterLatePost() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() @@ -1520,16 +876,13 @@ actor RetryDelayRecorder { } await started.waitUntilStarted() await provider.clearSession() - let unregister = Task { - await service.unregisterFromServer( - accountID: "account-a", - accessToken: "a-captured-access", - refreshToken: "a-captured-refresh" - ) - } + await service.unregisterFromServer( + accountID: "account-a", + accessToken: "a-captured-access", + refreshToken: "a-captured-refresh" + ) await blocker.release() await upload.value - await unregister.value let requests = await PushRegistrationURLProtocol.script.requests #expect(requests.map(\.httpMethod) == ["POST", "DELETE", "DELETE"]) @@ -1537,9 +890,9 @@ actor RetryDelayRecorder { requests.map { $0.value(forHTTPHeaderField: "Authorization") } == [ - "Bearer a-live-access", "Bearer a-live-access", "Bearer a-captured-access", + "Bearer a-live-access", ] ) #expect( @@ -1549,56 +902,6 @@ actor RetryDelayRecorder { ) } - @Test func delayedSignOutCannotDeleteNewerSameAccountRegistration() async { - let signOutStarted = TestPhaseSignal() - let signOutBlocker = TestContinuationBlocker() - await PushRegistrationURLProtocol.script.reset([ - .response(200), - .response(200), - ]) - let provider = MutablePushTokenProvider( - accountID: "account-a", - accessToken: "a-access", - refreshToken: "a-refresh" - ) - await provider.blockAuthenticatedSessionSnapshot( - started: signOutStarted, - until: signOutBlocker - ) - let (service, defaults) = makeScriptedService( - tokenProvider: provider, - accountID: nil - ) - defaults.set(true, forKey: "cmux.notifications.pushEnabled") - defaults.set("aa", forKey: "cmux.notifications.deviceTokenHex") - defaults.set( - "account-a", - forKey: "cmux.notifications.registeredAccountID" - ) - - let delayedSignOut = Task { - await service.unregisterFromServer() - } - await signOutStarted.waitUntilStarted() - - let sync = Task { await service.syncTokenIfPossible() } - for _ in 0..<1_000 { await Task.yield() } - await signOutBlocker.release() - await delayedSignOut.value - await sync.value - - #expect( - await PushRegistrationURLProtocol.script.requests - .map(\.httpMethod) == ["POST"] - ) - #expect(await service.snapshot.backendState == .registered) - #expect( - defaults.string( - forKey: "cmux.notifications.registeredAccountID" - ) == "account-a" - ) - } - @Test func oldAccountLatePostCannotTakeTokenBackFromNewAccount() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() @@ -1628,23 +931,21 @@ actor RetryDelayRecorder { accessToken: "b-access", refreshToken: "b-refresh" ) - let newUpload = Task { - await service.syncTokenIfPossible() - } + await service.syncTokenIfPossible() await blocker.release() await oldUpload.value - await newUpload.value let requests = await PushRegistrationURLProtocol.script.requests #expect( requests.map(\.httpMethod) - == ["POST", "DELETE", "POST"] + == ["POST", "POST", "DELETE", "POST"] ) #expect( requests.map { $0.value(forHTTPHeaderField: "Authorization") } == [ "Bearer a-access", + "Bearer b-access", "Bearer a-access", "Bearer b-access", ] diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index 6277920fe83..f17d9d04447 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -12,24 +12,20 @@ import SwiftUI struct MobilePushReadinessPreviewView: View { private let fixture: Fixture private let rejectsMacMutations: Bool - private let delaysPhoneMutation: Bool @State private var phoneEnabled: Bool @State private var authorization: MobilePushAuthorization @State private var registration: PushRegistrationSnapshot @State private var macStatus: MobileHostPhonePushStatus? - @State private var pendingPhoneMutation: Bool? init(state: String, environment: [String: String] = ProcessInfo.processInfo.environment) { let fixture = Fixture(rawValue: state) ?? .healthy self.fixture = fixture self.rejectsMacMutations = environment["CMUX_UITEST_PUSH_MUTATION_FAILURE"] == "1" - self.delaysPhoneMutation = environment["CMUX_UITEST_PUSH_PHONE_MUTATION_DELAY"] == "1" self._phoneEnabled = State(initialValue: fixture.registration.isEnabled) self._authorization = State(initialValue: fixture.authorization) self._registration = State(initialValue: fixture.registration) self._macStatus = State(initialValue: fixture.macStatus) - self._pendingPhoneMutation = State(initialValue: nil) } var body: some View { @@ -41,28 +37,16 @@ struct MobilePushReadinessPreviewView: View { )) { MobilePushSettingsContent( readiness: readiness, - phoneEnabled: phoneEnabledBinding, + phoneEnabled: $phoneEnabled, macStatus: macStatus, supportsMacSettings: macStatus != nil, supportsMacTest: macStatus != nil, canConnectMac: true, + onPhoneEnabledChange: setPhoneEnabled, onRepair: repair, onMacMutation: mutateMac, onSendTest: { .queuedOnMac } ) - - if delaysPhoneMutation, pendingPhoneMutation != nil { - Button { - completePhoneMutation() - } label: { - Text(L10n.string( - "mobile.debug.push.completeMutation", - defaultValue: "Complete Push Mutation" - )) - } - .accessibilityIdentifier("MobilePushReadinessCompletePhoneMutation") - } - } } .navigationTitle(L10n.string( @@ -83,34 +67,12 @@ struct MobilePushReadinessPreviewView: View { ) } - private var phoneEnabledBinding: Binding { - Binding( - get: { phoneEnabled }, - set: { enabled in - phoneEnabled = enabled - if delaysPhoneMutation { - pendingPhoneMutation = enabled - } else { - applyPhoneMutation(enabled) - } - } - ) - } - - private func completePhoneMutation() { - guard let pendingPhoneMutation else { return } - applyPhoneMutation(pendingPhoneMutation) - self.pendingPhoneMutation = nil - } - - private func applyPhoneMutation(_ enabled: Bool) { - registration = enabled ? Self.registered : .disabled - } - @MainActor - private func setPhoneEnabled(_ enabled: Bool) -> Bool { + private func setPhoneEnabled(_ enabled: Bool) async -> Bool { phoneEnabled = enabled - applyPhoneMutation(enabled) + registration = enabled + ? Self.registered + : .disabled return true } @@ -118,7 +80,7 @@ struct MobilePushReadinessPreviewView: View { private func repair(_ repair: MobilePushReadiness.Repair) async -> Bool { switch repair { case .enableOnPhone: - return setPhoneEnabled(true) + return await setPhoneEnabled(true) case .retryDeviceTokenRegistration, .retryRegistration: registration = Self.registered return true diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 12da775114c..152974736a0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -90,14 +90,7 @@ public final class MobilePushCoordinator { /// bounded by the reply lifetime; success or a fresh park cancels it. @ObservationIgnored private var replyRetryTask: Task? @ObservationIgnored private let replyRetrySleep: @Sendable (Duration) async throws -> Void - @ObservationIgnored private let settingsMutationSleep: - @Sendable (Duration) async throws -> Void private static let replyRetryDelay: Duration = .seconds(5) - /// Authorization prompts and backend reconciliation must not hold the - /// app-lifetime settings slot forever. The sleep is injected for - /// deterministic timeout tests. - private static let settingsMutationTimeout: Duration = .seconds(30) - private static let registrationRecoveryTimeout: Duration = .seconds(30) /// The iOS API endpoint that accepted this installation's APNs token. public let phoneAPIOrigin: String /// Live OS authorization, refreshed at launch, on foreground, and when @@ -109,8 +102,6 @@ public final class MobilePushCoordinator { .authorizationOnly(.notDetermined) /// Local/APNs/backend registration stage streamed from the actor service. public private(set) var registrationSnapshot: PushRegistrationSnapshot = .disabled - /// Whether local delivery is off but backend cleanup exceeded its deadline. - public private(set) var isDisableCleanupUnconfirmed = false @ObservationIgnored private let notificationSettings: @MainActor () async -> MobilePushSystemSettings @ObservationIgnored private let requestAuthorization: @@ -120,32 +111,8 @@ public final class MobilePushCoordinator { @ObservationIgnored private let unregisterForRemoteNotifications: @MainActor () -> Void @ObservationIgnored private var registrationSnapshotTask: Task? - /// Recovery has the same bounded ownership model as settings mutations: - /// one active worker and one timed-out or superseded quarantine worker. - @ObservationIgnored private var registrationRecoveryWorkers: - MobilePushMutationWorkers? - @ObservationIgnored private var quarantinedRegistrationRecoveryWorkers: - MobilePushMutationWorkers? - @ObservationIgnored private var timedOutRegistrationRecoveryCompletion: - MobilePushMutationCompletion? - @ObservationIgnored private var registrationRecoverySettingsToken: UUID? - @ObservationIgnored private var registrationIntentTask: Task? - /// Authentication and settings reads may ignore cancellation. Each - /// direction owns one active worker plus one timed-out quarantine slot. - /// That lets a single recovery advance without repeated retries - /// accumulating abandoned tasks. - @ObservationIgnored private var settingsMutationWorkers: - [Bool: MobilePushMutationWorkers] = [:] - @ObservationIgnored private var quarantinedSettingsMutationWorkers: - [Bool: MobilePushMutationWorkers] = [:] - @ObservationIgnored private var timedOutSettingsMutationCompletions: - [Bool: MobilePushMutationCompletion] = [:] - @ObservationIgnored private var settingsMutationToken = UUID() - @ObservationIgnored private var settingsMutationDirectionsNeedingRetry: - Set = [] - @ObservationIgnored private var registrationIntentGeneration: UInt64 = 0 - @ObservationIgnored private var registrationIntentEpoch: - PushRegistrationIntentEpoch + @ObservationIgnored private var registrationRecoveryTask: + Task? @ObservationIgnored private var workspaceAuthorizationRequestInFlight = false @ObservationIgnored private var hasRequestedRemoteRegistration = false @@ -188,24 +155,14 @@ public final class MobilePushCoordinator { }, replyRetrySleep: @escaping @Sendable (Duration) async throws -> Void = { try await ContinuousClock().sleep(for: $0) - }, - settingsMutationSleep: @escaping @Sendable (Duration) async throws -> Void = { - try await ContinuousClock().sleep(for: $0) } ) { - let registrationIntentEpoch = PushRegistrationIntentEpoch() self.registration = registration - self.registrationIntentEpoch = registrationIntentEpoch self.replyRetrySleep = replyRetrySleep - self.settingsMutationSleep = settingsMutationSleep self.analytics = analytics self.diagnosticLog = diagnosticLog self.phoneAPIOrigin = phoneAPIOrigin self.defaults = defaults - defaults.set( - registrationIntentEpoch.storageValue, - forKey: PushRegistrationIntentEpoch.defaultsKey - ) self.enabledMirror = defaults.bool(forKey: Self.enabledKey) self.deliveredNotificationClearer = deliveredNotificationClearer self.pendingDismissQueue = pendingDismissQueue @@ -231,206 +188,9 @@ public final class MobilePushCoordinator { self.unregisterForRemoteNotifications = unregisterForRemoteNotifications } - /// Whether the user has opted into phone notifications. - /// - /// This is an app-lifetime observable mirror, rather than a view-local - /// value. Settings can therefore render the requested value before the - /// backend cleanup finishes. + /// Whether the user has opted into phone notifications (synchronous mirror). public var isEnabled: Bool { enabledMirror } - /// Apply a Settings preference immediately and finish its registration work - /// from the app-lifetime coordinator. A newer intent cancels the old - /// coordinator task and starts independently, so an opt-out can preempt an - /// authorization prompt or other suspended enable path. - public func setEnabledIntent(_ enabled: Bool) { - guard enabled != enabledMirror - || settingsMutationDirectionsNeedingRetry.contains(enabled) else { - return - } - let intent = beginSettingsIntent(enabled) - _ = startSettingsMutation( - enabled: enabled, - token: intent.token - ) { [weak self] in - guard let self else { return false } - await intent.registrationTask.value - if enabled { - guard self.isCurrentSettingsMutation(intent.token) else { - return false - } - return await self.enable( - trigger: "settings_toggle", - settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration, - registrationIntentEpoch: intent.registrationIntentEpoch - ) - } - await self.finishDisable( - settingsMutationToken: intent.token - ) - // Completion means the service worker drained the intents coalesced - // behind this opt-out. UI publication remains token-fenced. - return true - } - } - - /// Starts one active worker for this preference direction. A timed-out - /// worker moves to its bounded quarantine slot so one recovery can run. - /// Opposite directions remain independent. - @discardableResult - private func startSettingsMutation( - enabled: Bool, - token: UUID, - operation: @escaping @MainActor () async -> Bool - ) -> MobilePushMutationWorkers? { - guard settingsMutationWorkers[enabled] == nil else { - settingsMutationDirectionsNeedingRetry.insert(enabled) - return nil - } - let completion = MobilePushMutationCompletion() - let operationTask = Task { @MainActor [weak self] in - guard let self else { - await completion.resolve(.cancelled) - return - } - let succeeded = await operation() - await completion.resolve(.completed, succeeded: succeeded) - self.finishSettingsMutation( - enabled: enabled, - token: token, - completion: completion, - succeeded: succeeded - ) - } - let timeoutTask = Task { @MainActor [weak self, settingsMutationSleep] in - do { - try await settingsMutationSleep(Self.settingsMutationTimeout) - guard await completion.resolve(.timedOut) else { return } - self?.handleSettingsMutationTimeout( - enabled: enabled, - token: token, - completion: completion - ) - } catch { - // The mutation completed first and cancelled this sleeper. - } - } - let workers = MobilePushMutationWorkers( - operation: operationTask, - timeout: timeoutTask, - completion: completion - ) - settingsMutationWorkers[enabled] = workers - return workers - } - - private func waitForSettingsMutation( - _ workers: MobilePushMutationWorkers - ) async -> Bool { - let result = await workers.completion.wait() - return result.outcome == .completed && result.succeeded - } - - private func handleSettingsMutationTimeout( - enabled: Bool, - token: UUID, - completion: MobilePushMutationCompletion - ) { - guard settingsMutationWorkers[enabled]?.completion === completion else { - return - } - settingsMutationDirectionsNeedingRetry.insert(enabled) - supersedeRegistrationRecovery(settingsMutationToken: token) - let isCurrent = isCurrentSettingsMutation(token) - var releasedLane = false - if quarantinedSettingsMutationWorkers[enabled] == nil, - let workers = settingsMutationWorkers.removeValue(forKey: enabled) { - quarantinedSettingsMutationWorkers[enabled] = workers - timedOutSettingsMutationCompletions.removeValue(forKey: enabled) - releasedLane = true - } else { - timedOutSettingsMutationCompletions[enabled] = completion - } - if releasedLane, !isCurrent, enabledMirror == enabled { - setEnabledIntent(enabled) - return - } - guard isCurrent else { return } - if enabledMirror { - registrationSnapshot = PushRegistrationSnapshot( - isEnabled: true, - hasDeviceToken: registrationSnapshot.hasDeviceToken, - backendState: .failed(.networkUnavailable) - ) - diagnosticLog?.recordAppEvent( - .pushBackendSyncFailed, - failure: .offline - ) - analytics.capture("ios_push_settings_timeout", [ - "timeout_seconds": .int( - Int(Self.settingsMutationTimeout.components.seconds) - ), - ]) - } else { - isDisableCleanupUnconfirmed = true - diagnosticLog?.recordAppEvent( - .pushBackendSyncFailed, - failure: .offline - ) - analytics.capture("ios_push_disable_cleanup_timeout", [ - "timeout_seconds": .int( - Int(Self.settingsMutationTimeout.components.seconds) - ), - ]) - if releasedLane { - setEnabledIntent(false) - } - } - } - - /// Starts a new app-lifetime preference intent and invalidates every older - /// lifecycle reconciliation. The returned generation must be checked after - /// each suspension before an operation publishes or persists state. - @discardableResult - private func beginSettingsIntent(_ enabled: Bool) -> MobilePushSettingsIntent { - supersedeRegistrationRecovery( - settingsMutationToken: settingsMutationToken - ) - cancelSettingsMutation() - settingsMutationDirectionsNeedingRetry.remove(enabled) - let token = UUID() - registrationIntentGeneration &+= 1 - let registrationIntentEpoch = PushRegistrationIntentEpoch() - self.registrationIntentEpoch = registrationIntentEpoch - defaults.set( - registrationIntentEpoch.storageValue, - forKey: PushRegistrationIntentEpoch.defaultsKey - ) - settingsMutationToken = token - if enabled { - persistEnabledIntent() - } else { - prepareDisable() - } - registrationIntentTask?.cancel() - let registration = self.registration - let registrationGeneration = registrationIntentGeneration - let registrationTask = Task { - await registration.applyEnabledIntent( - enabled, - generation: registrationGeneration, - intentEpoch: registrationIntentEpoch - ) - } - registrationIntentTask = registrationTask - return MobilePushSettingsIntent( - token: token, - registrationGeneration: registrationIntentGeneration, - registrationIntentEpoch: registrationIntentEpoch, - registrationTask: registrationTask - ) - } - /// Point routing at the active store (called by the root view on appear). public func bind(store: CMUXMobileShellStore) { self.store = store @@ -494,116 +254,38 @@ public final class MobilePushCoordinator { /// and persist the flag. Returns whether authorization was granted. @discardableResult public func enable() async -> Bool { - let intent = beginSettingsIntent(true) - let workers = startSettingsMutation( - enabled: true, - token: intent.token - ) { [weak self] in - guard let self else { return false } - return await self.enable( - trigger: "settings_toggle", - settingsMutationToken: intent.token, - registrationGeneration: intent.registrationGeneration, - registrationIntentEpoch: intent.registrationIntentEpoch - ) - } - guard let workers else { return false } - return await waitForSettingsMutation(workers) + await enable(trigger: "settings_toggle") } /// Requests or recovers push only after the authenticated workspace shell /// is mounted. An explicit app opt-out remains authoritative. public func workspaceListDidBecomeVisible() async { - // A Settings intent is the freshest user decision. Do not let the - // workspace lifecycle reconcile an older persisted value while its - // backend mutation is still draining. - guard settingsMutationWorkers[true] == nil else { return } if defaults.object(forKey: Self.enabledKey) as? Bool == false { return } - let intentToken = settingsMutationToken - let intentGeneration = registrationIntentGeneration - let intentEpoch = registrationIntentEpoch - let workers = startSettingsMutation( - enabled: true, - token: intentToken - ) { [weak self] in - guard let self else { return false } - return await self.reconcileWorkspaceListDidBecomeVisible( - settingsMutationToken: intentToken, - registrationGeneration: intentGeneration, - registrationIntentEpoch: intentEpoch - ) - } - guard let workers else { return } - _ = await waitForSettingsMutation(workers) - } - - private func reconcileWorkspaceListDidBecomeVisible( - settingsMutationToken: UUID, - registrationGeneration: UInt64, - registrationIntentEpoch: PushRegistrationIntentEpoch - ) async -> Bool { let settings = await notificationSettings() - guard isCurrentSettingsMutation(settingsMutationToken) else { - return false - } apply(settings: settings) switch settings.authorization { case .authorized, .provisional, .ephemeral: - guard isCurrentSettingsMutation(settingsMutationToken) else { - return false - } persistEnabledIntent() - await activateRegistrationIfNeeded( - settingsMutationToken: settingsMutationToken, - registrationGeneration: registrationGeneration, - registrationIntentEpoch: registrationIntentEpoch - ) - await recoverRegistrationIfNeeded( - settingsMutationToken: settingsMutationToken - ) - return isCurrentSettingsMutation(settingsMutationToken) + await activateRegistrationIfNeeded() + await recoverRegistrationIfNeeded() case .denied: // Preserve intent so Settings can explain the blocked OS gate and // a later foreground return can recover without another app launch. - guard isCurrentSettingsMutation(settingsMutationToken) else { - return false - } persistEnabledIntent() - return true case .notDetermined: - guard !workspaceAuthorizationRequestInFlight else { - return false - } + guard !workspaceAuthorizationRequestInFlight else { return } workspaceAuthorizationRequestInFlight = true defer { workspaceAuthorizationRequestInFlight = false } - return await enable( - trigger: "workspace_list", - settingsMutationToken: settingsMutationToken, - registrationGeneration: registrationGeneration, - registrationIntentEpoch: registrationIntentEpoch - ) + _ = await enable(trigger: "workspace_list") case .unsupported: - return true + break } } - private func enable( - trigger: String, - settingsMutationToken: UUID, - registrationGeneration: UInt64, - registrationIntentEpoch: PushRegistrationIntentEpoch - ) async -> Bool { - guard isCurrentSettingsMutation(settingsMutationToken), - enabledMirror else { - return false - } + private func enable(trigger: String) async -> Bool { let priorSettings = await notificationSettings() - guard isCurrentSettingsMutation(settingsMutationToken), - enabledMirror else { - return false - } apply(settings: priorSettings) let priorStatus = priorSettings.authorization persistEnabledIntent() @@ -626,33 +308,8 @@ public final class MobilePushCoordinator { case .denied, .unsupported: granted = false } - guard isCurrentSettingsMutation(settingsMutationToken), - enabledMirror else { - // A system authorization prompt is user interaction and may outlive - // the reconciliation deadline. If it eventually grants after that - // deadline, start a fresh, current-generation reconciliation rather - // than leaving the persisted opt-in without a service mutation. - if enabledMirror, - settingsMutationDirectionsNeedingRetry.contains(true) { - setEnabledIntent(true) - } - return false - } guard granted else { - // Authorization is an independent OS gate. The app intent still - // has to reach the service so it can supersede an older disable - // that may be suspended in cleanup; readiness remains blocked by - // the denied/unsupported system status below. - await registration.applyEnabledIntent( - true, - generation: registrationGeneration, - intentEpoch: registrationIntentEpoch - ) - guard isCurrentSettingsMutation(settingsMutationToken), - enabledMirror else { - return false - } - await refreshReadiness(settingsMutationToken: settingsMutationToken) + await refreshReadiness() diagnosticLog?.recordAppEvent(.pushAuthorizationDenied) analytics.capture("ios_push_optin_declined", [ "trigger": .string(trigger), @@ -661,144 +318,29 @@ public final class MobilePushCoordinator { return false } if priorStatus == .notDetermined { - let currentSettings = await notificationSettings() - guard isCurrentSettingsMutation(settingsMutationToken), - enabledMirror else { - return false - } - apply(settings: currentSettings) + apply(settings: await notificationSettings()) } diagnosticLog?.recordAppEvent(.pushAuthorizationGranted) analytics.capture("ios_push_optin_granted", ["trigger": .string(trigger)]) - await activateRegistrationIfNeeded( - settingsMutationToken: settingsMutationToken, - registrationGeneration: registrationGeneration, - registrationIntentEpoch: registrationIntentEpoch - ) - guard isCurrentSettingsMutation(settingsMutationToken), - enabledMirror else { - return false - } - await recoverRegistrationIfNeeded(settingsMutationToken: settingsMutationToken) - return isCurrentSettingsMutation(settingsMutationToken) + await activateRegistrationIfNeeded() + await recoverRegistrationIfNeeded() + return true } /// Opt out: stop receiving pushes and remove the token server-side. public func disable() async { - let intent = beginSettingsIntent(false) - let workers = startSettingsMutation( - enabled: false, - token: intent.token - ) { [weak self] in - guard let self else { return false } - await intent.registrationTask.value - await self.finishDisable( - settingsMutationToken: intent.token - ) - return true - } - guard let workers else { return } - _ = await waitForSettingsMutation(workers) - } - - private func cancelSettingsMutation() { - for workers in settingsMutationWorkers.values { - workers.operation.cancel() - } - for workers in quarantinedSettingsMutationWorkers.values { - workers.operation.cancel() - } - settingsMutationToken = UUID() - } - - private func finishSettingsMutation( - enabled: Bool, - token: UUID, - completion: MobilePushMutationCompletion, - succeeded: Bool - ) { - if settingsMutationWorkers[enabled]?.completion === completion { - let workers = settingsMutationWorkers.removeValue(forKey: enabled) - workers?.timeout.cancel() - if timedOutSettingsMutationCompletions[enabled] === completion { - timedOutSettingsMutationCompletions.removeValue(forKey: enabled) - } - finishSettingsMutationState( - enabled: enabled, - token: token, - succeeded: succeeded - ) - return - } - guard quarantinedSettingsMutationWorkers[enabled]?.completion - === completion else { return } - let workers = quarantinedSettingsMutationWorkers.removeValue( - forKey: enabled - ) - workers?.timeout.cancel() - if let active = settingsMutationWorkers[enabled], - timedOutSettingsMutationCompletions[enabled] === active.completion { - settingsMutationWorkers.removeValue(forKey: enabled) - timedOutSettingsMutationCompletions.removeValue(forKey: enabled) - quarantinedSettingsMutationWorkers[enabled] = active - } - guard settingsMutationWorkers[enabled] == nil else { return } - finishSettingsMutationState( - enabled: enabled, - token: token, - succeeded: succeeded - ) - } - - private func finishSettingsMutationState( - enabled: Bool, - token: UUID, - succeeded: Bool - ) { - guard enabledMirror == enabled else { return } - if succeeded, settingsMutationToken == token { - settingsMutationDirectionsNeedingRetry.remove(enabled) - return - } - guard settingsMutationDirectionsNeedingRetry.contains(enabled), - settingsMutationWorkers[enabled] == nil - else { return } - setEnabledIntent(enabled) - } - - private func prepareDisable() { diagnosticLog?.recordAppEvent(.pushDisabled) enabledMirror = false - defaults.set(false, forKey: Self.enabledKey) registrationSnapshot = .disabled hasRequestedRemoteRegistration = false unregisterForRemoteNotifications() - } - - private func finishDisable( - settingsMutationToken: UUID - ) async { - guard isCurrentSettingsMutation(settingsMutationToken), !enabledMirror else { - return - } - let snapshot = await registration.snapshot - guard isCurrentSettingsMutation(settingsMutationToken), !enabledMirror else { - return - } - registrationSnapshot = snapshot - } - - /// Retries timed-out backend cleanup without turning local delivery on. - public func retryDisableCleanup() { - guard !enabledMirror else { return } - settingsMutationDirectionsNeedingRetry.insert(false) - setEnabledIntent(false) - } - - private func isCurrentSettingsMutation(_ token: UUID) -> Bool { - // Task cancellation belongs to the caller's waiter. Preference - // mutations live at app scope, and only a newer token supersedes them. - return settingsMutationToken == token + // The production registration service owns this same persisted key + // and checks its previous value to decide whether server cleanup is + // required. Let it observe the prior `true` before mirroring the final + // preference here; writing `false` first would skip token removal. + await registration.setEnabled(false) + defaults.set(false, forKey: Self.enabledKey) + registrationSnapshot = await registration.snapshot } /// Hand a freshly-registered APNs token to the network layer. @@ -841,29 +383,15 @@ public final class MobilePushCoordinator { /// Call on every foreground transition because users can revoke permission /// in iOS Settings while cmux is suspended. public func refreshReadiness() async { - await refreshReadiness(settingsMutationToken: settingsMutationToken) - } - - private func refreshReadiness(settingsMutationToken: UUID) async { - let registrationGeneration = self.registrationIntentGeneration - let registrationIntentEpoch = self.registrationIntentEpoch let settings = await notificationSettings() - guard isCurrentSettingsMutation(settingsMutationToken) else { return } apply(settings: settings) if enabledMirror, Self.permitsDelivery(settings.authorization) { - await activateRegistrationIfNeeded( - settingsMutationToken: settingsMutationToken, - registrationGeneration: registrationGeneration, - registrationIntentEpoch: registrationIntentEpoch - ) + await activateRegistrationIfNeeded() } - await recoverRegistrationIfNeeded( - settingsMutationToken: settingsMutationToken - ) + await recoverRegistrationIfNeeded() } private func persistEnabledIntent() { - isDisableCleanupUnconfirmed = false enabledMirror = true defaults.set(true, forKey: Self.enabledKey) } @@ -873,19 +401,9 @@ public final class MobilePushCoordinator { authorization = settings.authorization } - private func activateRegistrationIfNeeded( - settingsMutationToken: UUID, - registrationGeneration: UInt64, - registrationIntentEpoch: PushRegistrationIntentEpoch - ) async { - guard isCurrentSettingsMutation(settingsMutationToken), - enabledMirror, - Self.permitsDelivery(authorization) - else { return } + private func activateRegistrationIfNeeded() async { + guard enabledMirror, Self.permitsDelivery(authorization) else { return } let current = await registration.snapshot - guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { - return - } registrationSnapshot = PushRegistrationSnapshot( isEnabled: true, hasDeviceToken: current.hasDeviceToken, @@ -894,23 +412,10 @@ public final class MobilePushCoordinator { : .awaitingDeviceToken ) requestRemoteRegistrationIfNeeded() - // Always submit the current generation. The snapshot can still say - // enabled while an older disable is queued or suspended; the service - // reconciler coalesces repeated completed generations without - // issuing another registration request. - await registration.applyEnabledIntent( - true, - generation: registrationGeneration, - intentEpoch: registrationIntentEpoch - ) - guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { - return - } - let snapshot = await registration.snapshot - guard isCurrentSettingsMutation(settingsMutationToken), enabledMirror else { - return + if !current.isEnabled { + await registration.setEnabled(true) } - registrationSnapshot = snapshot + registrationSnapshot = await registration.snapshot } private func requestRemoteRegistrationIfNeeded() { @@ -934,158 +439,39 @@ public final class MobilePushCoordinator { /// Retries an exhausted registration when a meaningful network path /// change reports that the API may be reachable again. public func networkDidBecomeReachable() async { - await recoverRegistrationIfNeeded( - settingsMutationToken: settingsMutationToken - ) + await recoverRegistrationIfNeeded() } - private func recoverRegistrationIfNeeded( - settingsMutationToken: UUID - ) async { - guard isCurrentSettingsMutation(settingsMutationToken) else { - return - } - guard enabledMirror else { - registrationSnapshot = .disabled - return - } + private func recoverRegistrationIfNeeded() async { let current = await registration.snapshot - guard isCurrentSettingsMutation(settingsMutationToken) else { - return - } - guard enabledMirror else { - registrationSnapshot = .disabled - return - } registrationSnapshot = current guard current.isEnabled, current.hasDeviceToken, current.backendState == .registrationRequired || current.backendState.isRecoverable else { return } - guard let workers = startRegistrationRecovery( - settingsMutationToken: settingsMutationToken - ) else { return } - let result = await workers.completion.wait() - guard result.outcome == .completed else { return } - let recovered = await registration.snapshot - guard isCurrentSettingsMutation(settingsMutationToken) else { - return + let recovery: Task + let ownsRecovery: Bool + if let registrationRecoveryTask { + recovery = registrationRecoveryTask + ownsRecovery = false + } else { + let registration = self.registration + recovery = Task { + await registration.syncTokenIfPossible() + return await registration.snapshot + } + registrationRecoveryTask = recovery + ownsRecovery = true } - guard enabledMirror else { - return + let recovered = await recovery.value + if ownsRecovery { + registrationRecoveryTask = nil } registrationSnapshot = recovered recordRegistrationOutcome(recovered) } - private func startRegistrationRecovery( - settingsMutationToken: UUID - ) -> MobilePushMutationWorkers? { - if let registrationRecoveryWorkers { - guard registrationRecoverySettingsToken == settingsMutationToken - else { return nil } - return registrationRecoveryWorkers - } - - let completion = MobilePushMutationCompletion() - let registration = self.registration - let operationTask = Task { @MainActor [weak self] in - guard let self else { - await completion.resolve(.cancelled) - return - } - await registration.syncTokenIfPossible() - await completion.resolve(.completed, succeeded: true) - self.finishRegistrationRecovery(completion: completion) - } - let timeoutTask = Task { @MainActor [weak self, settingsMutationSleep] in - do { - try await settingsMutationSleep( - Self.registrationRecoveryTimeout - ) - guard await completion.resolve(.timedOut) else { return } - self?.handleRegistrationRecoveryTimeout( - completion: completion - ) - } catch { - // Recovery completed first and cancelled this sleeper. - } - } - let workers = MobilePushMutationWorkers( - operation: operationTask, - timeout: timeoutTask, - completion: completion - ) - registrationRecoveryWorkers = workers - registrationRecoverySettingsToken = settingsMutationToken - return workers - } - - private func handleRegistrationRecoveryTimeout( - completion: MobilePushMutationCompletion - ) { - guard registrationRecoveryWorkers?.completion === completion else { - return - } - registrationRecoveryWorkers?.operation.cancel() - if quarantinedRegistrationRecoveryWorkers == nil { - quarantinedRegistrationRecoveryWorkers = registrationRecoveryWorkers - registrationRecoveryWorkers = nil - registrationRecoverySettingsToken = nil - timedOutRegistrationRecoveryCompletion = nil - } else { - timedOutRegistrationRecoveryCompletion = completion - } - } - - private func supersedeRegistrationRecovery( - settingsMutationToken: UUID - ) { - guard registrationRecoverySettingsToken == settingsMutationToken, - let workers = registrationRecoveryWorkers else { return } - workers.operation.cancel() - if quarantinedRegistrationRecoveryWorkers == nil { - registrationRecoveryWorkers = nil - registrationRecoverySettingsToken = nil - quarantinedRegistrationRecoveryWorkers = workers - timedOutRegistrationRecoveryCompletion = nil - } else { - // Keep ownership until a quarantine slot opens. A nil token keeps - // future callers from reusing this superseded worker. - registrationRecoverySettingsToken = nil - } - } - - private func finishRegistrationRecovery( - completion: MobilePushMutationCompletion - ) { - if registrationRecoveryWorkers?.completion === completion { - let workers = registrationRecoveryWorkers - registrationRecoveryWorkers = nil - registrationRecoverySettingsToken = nil - workers?.timeout.cancel() - if timedOutRegistrationRecoveryCompletion === completion { - timedOutRegistrationRecoveryCompletion = nil - } - return - } - guard quarantinedRegistrationRecoveryWorkers?.completion - === completion else { return } - let workers = quarantinedRegistrationRecoveryWorkers - quarantinedRegistrationRecoveryWorkers = nil - workers?.timeout.cancel() - - guard let active = registrationRecoveryWorkers, - timedOutRegistrationRecoveryCompletion === active.completion - || registrationRecoverySettingsToken == nil - else { return } - registrationRecoveryWorkers = nil - registrationRecoverySettingsToken = nil - timedOutRegistrationRecoveryCompletion = nil - quarantinedRegistrationRecoveryWorkers = active - } - private func recordRegistrationOutcome(_ snapshot: PushRegistrationSnapshot) { switch snapshot.backendState { case .registered: @@ -1156,10 +542,6 @@ public final class MobilePushCoordinator { let snapshots = await registration.snapshots() for await snapshot in snapshots { guard !Task.isCancelled, let self else { return } - // A service mutation can finish after a newer toggle has - // changed the coordinator mirror. Its opposite-state snapshot - // is stale and must not overwrite the current intent's UI. - guard snapshot.isEnabled == self.enabledMirror else { continue } self.registrationSnapshot = snapshot } } @@ -1221,23 +603,6 @@ public final class MobilePushCoordinator { ) } - deinit { - for workers in settingsMutationWorkers.values { - workers.operation.cancel() - workers.timeout.cancel() - } - for workers in quarantinedSettingsMutationWorkers.values { - workers.operation.cancel() - workers.timeout.cancel() - } - registrationIntentTask?.cancel() - registrationSnapshotTask?.cancel() - registrationRecoveryWorkers?.operation.cancel() - registrationRecoveryWorkers?.timeout.cancel() - quarantinedRegistrationRecoveryWorkers?.operation.cancel() - quarantinedRegistrationRecoveryWorkers?.timeout.cancel() - } - /// Whether to show a banner while the app is foreground. Suppressed when the /// user is already viewing the terminal the notification is about. public func shouldPresentInForeground(workspaceId: String?, surfaceId: String?) -> Bool { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift deleted file mode 100644 index 1589c4efcca..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationCompletion.swift +++ /dev/null @@ -1,59 +0,0 @@ -import Foundation - -/// Resolves the first terminal result of an app-lifetime push mutation. -actor MobilePushMutationCompletion { - private var result: MobilePushMutationResult? - private var waiters: [ - UUID: CheckedContinuation - ] = [:] - - @discardableResult - func resolve( - _ outcome: MobilePushMutationOutcome, - succeeded: Bool = false - ) -> Bool { - guard result == nil else { return false } - let resolved = MobilePushMutationResult( - outcome: outcome, - succeeded: succeeded - ) - result = resolved - let waiters = self.waiters.values - self.waiters.removeAll() - for waiter in waiters { - waiter.resume(returning: resolved) - } - return true - } - - func wait() async -> MobilePushMutationResult { - if let result { return result } - let waiterID = UUID() - return await withTaskCancellationHandler(operation: { - await withCheckedContinuation { continuation in - if let result { - continuation.resume(returning: result) - } else if Task.isCancelled { - continuation.resume(returning: MobilePushMutationResult( - outcome: .cancelled, - succeeded: false - )) - } else { - waiters[waiterID] = continuation - } - } - }, onCancel: { - Task { await self.cancelWaiter(waiterID) } - }) - } - - private func cancelWaiter(_ waiterID: UUID) { - guard let waiter = waiters.removeValue(forKey: waiterID) else { - return - } - waiter.resume(returning: MobilePushMutationResult( - outcome: .cancelled, - succeeded: false - )) - } -} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift deleted file mode 100644 index 22e246a8aec..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationOutcome.swift +++ /dev/null @@ -1,6 +0,0 @@ -/// The terminal result of an app-lifetime push settings mutation. -enum MobilePushMutationOutcome: Sendable, Equatable { - case completed - case timedOut - case cancelled -} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationResult.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationResult.swift deleted file mode 100644 index f3572fb8b1a..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationResult.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Foundation - -struct MobilePushMutationResult: Sendable, Equatable { - let outcome: MobilePushMutationOutcome - let succeeded: Bool -} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift deleted file mode 100644 index ef31443ed43..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushMutationWorkers.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Foundation - -/// Keeps the app-lifetime settings operation, timeout, and completion together -/// so a superseding intent can cancel every worker that belongs to one -/// mutation. -struct MobilePushMutationWorkers { - let operation: Task - let timeout: Task - let completion: MobilePushMutationCompletion -} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift index 0b55c74db6d..adb1adc6e5a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift @@ -24,6 +24,7 @@ struct MobilePushSettingsContent: View { let supportsMacSettings: Bool let supportsMacTest: Bool let canConnectMac: Bool + let onPhoneEnabledChange: @MainActor (Bool) async -> Bool let onRepair: @MainActor (MobilePushReadiness.Repair) async -> Bool let onMacMutation: @MainActor (MobilePushMacMutation) async -> Bool let onSendTest: @MainActor () async -> MobilePhonePushTestStage @@ -45,6 +46,7 @@ struct MobilePushSettingsContent: View { supportsMacSettings: Bool, supportsMacTest: Bool, canConnectMac: Bool, + onPhoneEnabledChange: @escaping @MainActor (Bool) async -> Bool, onRepair: @escaping @MainActor (MobilePushReadiness.Repair) async -> Bool, onMacMutation: @escaping @MainActor (MobilePushMacMutation) async -> Bool, onSendTest: @escaping @MainActor () async -> MobilePhonePushTestStage @@ -55,6 +57,7 @@ struct MobilePushSettingsContent: View { self.supportsMacSettings = supportsMacSettings self.supportsMacTest = supportsMacTest self.canConnectMac = canConnectMac + self.onPhoneEnabledChange = onPhoneEnabledChange self.onRepair = onRepair self.onMacMutation = onMacMutation self.onSendTest = onSendTest @@ -72,10 +75,15 @@ struct MobilePushSettingsContent: View { Group { statusRow - MobilePushToggle( - isEnabled: phoneEnabledBinding, - isUpdating: isMutatingPhone + Toggle( + L10n.string( + "mobile.notifications.phoneEnabled", + defaultValue: "Allow Push Alerts on This iPhone" + ), + isOn: phoneEnabledBinding ) + .accessibilityIdentifier("MobileSettingsNotifications") + .disabled(isMutatingPhone) if let repair = readiness.repair, Self.shouldPresentRepair(repair, canConnectMac: canConnectMac), @@ -240,23 +248,32 @@ struct MobilePushSettingsContent: View { .accessibilityIdentifier("MobileSettingsPushReadinessStatus") } - private var macForwardingBinding: Binding { + private var phoneEnabledBinding: Binding { Binding( - get: { macForwardingEnabled }, + get: { phoneEnabled }, set: { requested in - guard !isMutatingMac else { return } - macForwardingEnabled = requested - performMacMutation(.forwardingEnabled(requested)) + guard !isMutatingPhone else { return } + let confirmed = phoneEnabled + phoneEnabled = requested + isMutatingPhone = true + Task { + let succeeded = await onPhoneEnabledChange(requested) + if !succeeded { + phoneEnabled = confirmed + } + isMutatingPhone = false + } } ) } - private var phoneEnabledBinding: Binding { + private var macForwardingBinding: Binding { Binding( - get: { phoneEnabled }, + get: { macForwardingEnabled }, set: { requested in - guard !isMutatingPhone else { return } - phoneEnabled = requested + guard !isMutatingMac else { return } + macForwardingEnabled = requested + performMacMutation(.forwardingEnabled(requested)) } ) } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift deleted file mode 100644 index a9b0b880a07..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsIntent.swift +++ /dev/null @@ -1,11 +0,0 @@ -import CmuxAuthRuntime -import Foundation - -/// Carries the coordinator token and service generation for one push setting intent. -struct MobilePushSettingsIntent { - let token: UUID - let registrationGeneration: UInt64 - let registrationIntentEpoch: PushRegistrationIntentEpoch - /// The exact service mutation tracked by the coordinator timeout. - let registrationTask: Task -} diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift deleted file mode 100644 index 12df3b52ab0..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ /dev/null @@ -1,24 +0,0 @@ -#if os(iOS) -import CmuxMobileSupport -import SwiftUI - -/// The phone preference is owned by ``MobilePushCoordinator``. This view only -/// renders the binding, so leaving Settings cannot cancel or roll back a -/// requested opt-out. -struct MobilePushToggle: View { - @Binding var isEnabled: Bool - let isUpdating: Bool - - var body: some View { - Toggle( - L10n.string( - "mobile.notifications.phoneEnabled", - defaultValue: "Allow Push Alerts on This iPhone" - ), - isOn: $isEnabled - ) - .accessibilityIdentifier("MobileSettingsNotifications") - .disabled(isUpdating) - } -} -#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index bcc0d0c1b92..3f8ce8e0537 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -41,6 +41,11 @@ struct MobileSettingsView: View { @Environment(\.dismiss) private var dismiss @State private var showingShortcuts = false + /// Mirrors ``MobilePushCoordinator/isEnabled`` so the toggle's label/icon + /// update after the async enable/disable. The coordinator exposes + /// `isEnabled` as a non-observable `UserDefaults` read, so reading it + /// directly in `body` would not re-render when it flips. + @State private var notificationsEnabled = false #if DEBUG @State private var debugReplyScheduled: Bool? #endif @@ -56,9 +61,6 @@ struct MobileSettingsView: View { #endif var body: some View { - // Establish observation in this view as the binding getter is invoked - // by the child toggle rather than directly in this body. - let _ = pushCoordinator.isEnabled @Bindable var displaySettings = displaySettings @Bindable var toasts = toasts return NavigationStack { @@ -236,8 +238,6 @@ struct MobileSettingsView: View { } Section(L10n.string("mobile.settings.betaFeatures", defaultValue: "Beta Features")) { - // The legacy Toasts preference was permanently retired; do - // not expose a control for that disabled setting. Toggle(isOn: $displaySettings.taskComposerEnabled) { Text(L10n.string( "mobile.settings.taskComposer", @@ -245,7 +245,6 @@ struct MobileSettingsView: View { )) } .accessibilityIdentifier("MobileSettingsTaskComposer") - } #if DEBUG @@ -387,11 +386,12 @@ struct MobileSettingsView: View { macStatus: store?.phonePushMacStatus, macAccountMismatch: store?.connectionRequiresReauth == true ), - phoneEnabled: phonePushEnabledBinding, + phoneEnabled: $notificationsEnabled, macStatus: store?.phonePushMacStatus, supportsMacSettings: store?.supportsPhonePushSettings == true, supportsMacTest: store?.supportsPhonePushTest == true, canConnectMac: startPairingScanner != nil, + onPhoneEnabledChange: updatePhonePushEnabled, onRepair: repairPhonePush, onMacMutation: updateMacPhonePush, onSendTest: sendPhonePushTest @@ -421,37 +421,22 @@ struct MobileSettingsView: View { .foregroundStyle(.secondary) } #else - MobilePushToggle( - isEnabled: phonePushEnabledBinding, - isUpdating: false + Toggle( + L10n.string( + "mobile.notifications.phoneEnabled", + defaultValue: "Allow Push Alerts on This iPhone" + ), + isOn: Binding( + get: { notificationsEnabled }, + set: { enabled in + Task { @MainActor in + notificationsEnabled = await updatePhonePushEnabled(enabled) + } + } + ) ) + .accessibilityIdentifier("MobileSettingsNotifications") #endif - if pushCoordinator.isDisableCleanupUnconfirmed { - Text(L10n.string( - "mobile.notifications.disableCleanupUnconfirmed", - defaultValue: "Push alerts are off on this iPhone, but server cleanup could not be confirmed." - )) - .font(.footnote) - .foregroundStyle(.orange) - .accessibilityIdentifier( - "MobileSettingsPushDisableCleanupUnconfirmed" - ) - - Button { - pushCoordinator.retryDisableCleanup() - } label: { - Label( - L10n.string( - "mobile.notifications.disableCleanupRetry", - defaultValue: "Retry Push Alert Cleanup" - ), - systemImage: "arrow.clockwise" - ) - } - .accessibilityIdentifier( - "MobileSettingsPushDisableCleanupRetry" - ) - } } Section { @@ -498,8 +483,12 @@ struct MobileSettingsView: View { } } .task { + notificationsEnabled = pushCoordinator.isEnabled await pushCoordinator.refreshReadiness() } + .onChange(of: pushCoordinator.isEnabled) { _, enabled in + notificationsEnabled = enabled + } .navigationTitle(L10n.string("mobile.workspaces.settings", defaultValue: "Settings")) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -642,24 +631,15 @@ struct MobileSettingsView: View { .notificationPreferenceChanged, count: enabled ? 1 : 0 ) - pushCoordinator.setEnabledIntent(enabled) - // The coordinator owns the intent synchronously. Backend registration - // continues independently so a repair action cannot inherit a view's - // lifecycle or wait for network cleanup. - return pushCoordinator.isEnabled == enabled - } - - private var phonePushEnabledBinding: Binding { - Binding( - get: { pushCoordinator.isEnabled }, - set: { enabled in - diagnosticLog?.recordAppEvent( - .notificationPreferenceChanged, - count: enabled ? 1 : 0 - ) - pushCoordinator.setEnabledIntent(enabled) - } - ) + if enabled { + _ = await pushCoordinator.enable() + // A denied OS authorization still accepts the user's app-level + // intent. Keep the toggle on so readiness can surface the Settings + // recovery action instead of rolling the preference back. + return pushCoordinator.isEnabled + } + await pushCoordinator.disable() + return !pushCoordinator.isEnabled } @MainActor diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleCancellationRecorder.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleCancellationRecorder.swift deleted file mode 100644 index 82695542a84..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleCancellationRecorder.swift +++ /dev/null @@ -1,11 +0,0 @@ -import Foundation - -actor LifecycleCancellationRecorder { - private var authorizationCancelled = false - - func recordAuthorizationCancellation(_ cancelled: Bool) { - authorizationCancelled = cancelled - } - - var didCancelAuthorization: Bool { authorizationCancelled } -} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleNotificationDelegate.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleNotificationDelegate.swift deleted file mode 100644 index 6d3933e83ad..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleNotificationDelegate.swift +++ /dev/null @@ -1,4 +0,0 @@ -import UserNotifications - -final class LifecycleNotificationDelegate: NSObject, - UNUserNotificationCenterDelegate {} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSettingsMutationSleeper.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSettingsMutationSleeper.swift deleted file mode 100644 index 7610ef7409f..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSettingsMutationSleeper.swift +++ /dev/null @@ -1,23 +0,0 @@ -import Foundation - -actor LifecycleSettingsMutationSleeper { - private let firstGate: LifecycleSyncGate - private var invocationCount = 0 - private var firstSleepCancelled = false - - init(firstGate: LifecycleSyncGate) { - self.firstGate = firstGate - } - - func sleep(for duration: Duration) async throws { - invocationCount += 1 - if invocationCount == 1 { - await firstGate.pause() - firstSleepCancelled = Task.isCancelled - return - } - try await ContinuousClock().sleep(for: duration) - } - - var didCancelFirstSleep: Bool { firstSleepCancelled } -} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift deleted file mode 100644 index 9fdfeb74dd8..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/LifecycleSyncGate.swift +++ /dev/null @@ -1,50 +0,0 @@ -import Foundation - -actor LifecycleSyncGate { - private(set) var starts = 0 - private var released = false - private var startWaiters: [CheckedContinuation] = [] - private var releaseWaiters: [CheckedContinuation] = [] - - func pause() async { - starts += 1 - let waiters = startWaiters - startWaiters.removeAll() - for waiter in waiters { - waiter.resume() - } - guard !released else { return } - await withCheckedContinuation { continuation in - releaseWaiters.append(continuation) - } - } - - func waitUntilStarted() async { - guard starts == 0 else { return } - await withCheckedContinuation { continuation in - startWaiters.append(continuation) - } - } - - func waitUntilStartCount( - _ count: Int, - timeout: Duration = .seconds(1) - ) async -> Bool { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - while starts < count { - guard clock.now < deadline else { return false } - try? await clock.sleep(for: .milliseconds(1)) - } - return true - } - - func release() { - released = true - let waiters = releaseWaiters - releaseWaiters.removeAll() - for waiter in waiters { - waiter.resume() - } - } -} diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 510980fb2f6..29e0b5149a2 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -7,12 +7,6 @@ import UserNotifications private actor LifecyclePushRegistration: PushRegistering { private var value: PushRegistrationSnapshot - private var snapshotRead = false - private var snapshotReadWaiters: [CheckedContinuation] = [] - private var snapshotContinuation: - AsyncStream.Continuation? - private var queuedSnapshots: [PushRegistrationSnapshot] = [] - private var latestIntentGeneration: UInt64 = 0 private let setEnabledGate: LifecycleSetEnabledGate? private let syncGate: LifecycleSyncGate? @@ -34,46 +28,12 @@ private actor LifecyclePushRegistration: PushRegistering { } var isEnabled: Bool { value.isEnabled } - var snapshot: PushRegistrationSnapshot { - snapshotRead = true - let waiters = snapshotReadWaiters - snapshotReadWaiters.removeAll() - for waiter in waiters { - waiter.resume() - } - return value - } - - func waitUntilSnapshotRead() async { - guard !snapshotRead else { return } - await withCheckedContinuation { continuation in - snapshotReadWaiters.append(continuation) - } - } + var snapshot: PushRegistrationSnapshot { value } func snapshots() -> AsyncStream { AsyncStream { continuation in - Task { await self.installSnapshotContinuation(continuation) } - } - } - - private func installSnapshotContinuation( - _ continuation: AsyncStream.Continuation - ) { - snapshotContinuation = continuation - continuation.yield(value) - for snapshot in queuedSnapshots { - continuation.yield(snapshot) - } - queuedSnapshots.removeAll() - } - - func emit(_ snapshot: PushRegistrationSnapshot) { - value = snapshot - if let snapshotContinuation { - snapshotContinuation.yield(snapshot) - } else { - queuedSnapshots.append(snapshot) + continuation.yield(value) + continuation.finish() } } @@ -90,35 +50,6 @@ private actor LifecyclePushRegistration: PushRegistering { : .disabled } - func disableAndUnregister() async { - await setEnabledGate?.pause() - value = .disabled - } - - func applyEnabledIntent( - _ enabled: Bool, - generation: UInt64, - intentEpoch: PushRegistrationIntentEpoch - ) async { - guard generation >= latestIntentGeneration else { return } - latestIntentGeneration = generation - if enabled { - await setEnabledGate?.pause() - guard generation == latestIntentGeneration else { return } - value = PushRegistrationSnapshot( - isEnabled: true, - hasDeviceToken: value.hasDeviceToken, - backendState: value.hasDeviceToken - ? .registrationRequired - : .awaitingDeviceToken - ) - } else { - await setEnabledGate?.pause() - guard generation == latestIntentGeneration else { return } - value = .disabled - } - } - func register(deviceToken: Data) { value = PushRegistrationSnapshot( isEnabled: true, @@ -154,13 +85,13 @@ private actor LifecyclePushRegistration: PushRegistering { } private actor LifecycleSetEnabledGate { - private(set) var starts = 0 + private var didStart = false private var released = false private var startWaiters: [CheckedContinuation] = [] private var releaseWaiters: [CheckedContinuation] = [] func pause() async { - starts += 1 + didStart = true let waiters = startWaiters startWaiters.removeAll() for waiter in waiters { @@ -173,23 +104,46 @@ private actor LifecycleSetEnabledGate { } func waitUntilStarted() async { - guard starts == 0 else { return } + guard !didStart else { return } await withCheckedContinuation { continuation in startWaiters.append(continuation) } } - func waitUntilStartCount( - _ count: Int, - timeout: Duration = .seconds(1) - ) async -> Bool { - let clock = ContinuousClock() - let deadline = clock.now.advanced(by: timeout) - while starts < count { - guard clock.now < deadline else { return false } - try? await clock.sleep(for: .milliseconds(1)) + func release() { + released = true + let waiters = releaseWaiters + releaseWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + } +} + +private actor LifecycleSyncGate { + private(set) var starts = 0 + private var released = false + private var startWaiters: [CheckedContinuation] = [] + private var releaseWaiters: [CheckedContinuation] = [] + + func pause() async { + starts += 1 + let waiters = startWaiters + startWaiters.removeAll() + for waiter in waiters { + waiter.resume() + } + guard !released else { return } + await withCheckedContinuation { continuation in + releaseWaiters.append(continuation) + } + } + + func waitUntilStarted() async { + guard starts == 0 else { return } + await withCheckedContinuation { continuation in + startWaiters.append(continuation) } - return true } func release() { @@ -340,40 +294,6 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(await enabling.value) } - @MainActor - @Test func optOutInvalidatesAnEnableSuspendedInNotificationSettings() async { - let settingsGate = LifecycleSyncGate() - let registration = LifecyclePushRegistration(enabled: false) - let suiteName = "push-coordinator-stale-enable-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - notificationSettings: { - await settingsGate.pause() - return .authorizationOnly(.authorized) - }, - registerForRemoteNotifications: {} - ) - - let enabling = Task { await coordinator.enable() } - await settingsGate.waitUntilStarted() - - coordinator.setEnabledIntent(false) - #expect(!coordinator.isEnabled) - #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) - - await settingsGate.release() - #expect(!(await enabling.value)) - for _ in 0..<100 { - if await registration.snapshot == .disabled { break } - await Task.yield() - } - #expect(await registration.snapshot == .disabled) - #expect(!coordinator.isEnabled) - } - @MainActor @Test func authorizedEnableRecoversWithoutRequestingAuthorizationAgain() async { let registration = LifecyclePushRegistration(enabled: false) @@ -605,536 +525,6 @@ private final class LifecyclePushURLProtocol: URLProtocol, await disabling.value } - @MainActor - @Test func settingsOptOutIsVisibleBeforeCoordinatorBackendCleanupCompletes() async { - let gate = LifecycleSetEnabledGate() - let registration = LifecyclePushRegistration( - enabled: true, - setEnabledGate: gate - ) - let suiteName = "push-coordinator-settings-optout-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - defaults.set(true, forKey: "cmux.notifications.pushEnabled") - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - authorizationStatus: { .authorized }, - unregisterForRemoteNotifications: {} - ) - - coordinator.setEnabledIntent(false) - await gate.waitUntilStarted() - await coordinator.workspaceListDidBecomeVisible() - - #expect(!coordinator.isEnabled) - #expect(coordinator.registrationSnapshot == .disabled) - - await gate.release() - for _ in 0..<100 { - if await registration.snapshot == .disabled { break } - await Task.yield() - } - #expect(await registration.snapshot == .disabled) - #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) - } - - @MainActor - @Test func settingsOptOutPreemptsAnInFlightEnable() async { - let gate = LifecycleSetEnabledGate() - let registration = LifecyclePushRegistration( - enabled: false, - setEnabledGate: gate - ) - let suiteName = "push-coordinator-settings-preempt-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - authorizationStatus: { .authorized }, - unregisterForRemoteNotifications: {} - ) - - coordinator.setEnabledIntent(true) - await gate.waitUntilStarted() - - coordinator.setEnabledIntent(false) - #expect(!coordinator.isEnabled) - - await gate.release() - for _ in 0..<100 { - if await registration.snapshot == .disabled { break } - await Task.yield() - } - #expect(await registration.snapshot == .disabled) - #expect(!defaults.bool(forKey: "cmux.notifications.pushEnabled")) - } - - @MainActor - @Test func staleDisabledSnapshotCannotReplaceReenabledIntent() async { - let gate = LifecycleSetEnabledGate() - let registration = LifecyclePushRegistration( - enabled: true, - setEnabledGate: gate - ) - let suiteName = "push-coordinator-stale-snapshot-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - defaults.set(true, forKey: "cmux.notifications.pushEnabled") - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - authorizationStatus: { .authorized } - ) - coordinator.configure(delegate: LifecycleNotificationDelegate()) - for _ in 0..<100 { - if coordinator.registrationSnapshot.isEnabled { break } - await Task.yield() - } - - coordinator.setEnabledIntent(false) - await gate.waitUntilStarted() - coordinator.setEnabledIntent(true) - await registration.emit(.disabled) - for _ in 0..<20 { - await Task.yield() - } - - #expect(coordinator.isEnabled) - #expect(coordinator.registrationSnapshot != .disabled) - - await gate.release() - for _ in 0..<100 { - if await registration.snapshot.isEnabled { break } - await Task.yield() - } - #expect(await registration.snapshot.isEnabled) - } - - @MainActor - @Test func reenableIntentIsSubmittedWhileDisableStillReportsEnabled() async { - let gate = LifecycleSetEnabledGate() - let registration = LifecyclePushRegistration( - enabled: true, - setEnabledGate: gate - ) - let suiteName = "push-coordinator-reenable-generation-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - defaults.set(true, forKey: "cmux.notifications.pushEnabled") - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - authorizationStatus: { .authorized } - ) - - coordinator.setEnabledIntent(false) - await gate.waitUntilStarted() - - coordinator.setEnabledIntent(true) - await registration.waitUntilSnapshotRead() - - await gate.release() - for _ in 0..<100 { - if await registration.snapshot.isEnabled { break } - await Task.yield() - } - - #expect(await registration.snapshot.isEnabled) - #expect(coordinator.isEnabled) - } - - @MainActor - @Test func cancelledEnableStillCompletesCommittedIntent() async { - let authorizationGate = LifecycleSyncGate() - let registration = LifecyclePushRegistration(enabled: false) - let suiteName = "push-coordinator-cancelled-enable-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - authorizationStatus: { .notDetermined }, - requestAuthorization: { - await authorizationGate.pause() - return true - } - ) - - let enabling = Task { @MainActor in - await coordinator.enable() - } - await authorizationGate.waitUntilStarted() - enabling.cancel() - await authorizationGate.release() - _ = await enabling.value - - for _ in 0..<100 { - if await registration.snapshot.isEnabled { break } - await Task.yield() - } - #expect(await registration.snapshot.isEnabled) - #expect(coordinator.isEnabled) - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) - } - - @MainActor - @Test func stalledSettingsMutationTimesOutAndReleasesLifecycleSlot() async { - let settingsGate = LifecycleSyncGate() - let registration = LifecyclePushRegistration(enabled: false) - let suiteName = "push-coordinator-settings-timeout-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - var registrationRequests = 0 - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - notificationSettings: { - await settingsGate.pause() - return .authorizationOnly(.authorized) - }, - registerForRemoteNotifications: { registrationRequests += 1 }, - settingsMutationSleep: { _ in - await settingsGate.waitUntilStarted() - } - ) - - coordinator.setEnabledIntent(true) - await settingsGate.waitUntilStarted() - for _ in 0..<100 { - if coordinator.registrationSnapshot.backendState - == .failed(.networkUnavailable) { - break - } - await Task.yield() - } - - #expect(coordinator.isEnabled) - #expect( - coordinator.registrationSnapshot.backendState - == .failed(.networkUnavailable) - ) - - coordinator.setEnabledIntent(true) - for _ in 0..<100 { - if await settingsGate.starts == 2 { break } - await Task.yield() - } - #expect(await settingsGate.starts == 2) - - await settingsGate.release() - for _ in 0..<100 { - if registrationRequests == 1 { break } - await Task.yield() - } - #expect(registrationRequests == 1) - await coordinator.workspaceListDidBecomeVisible() - #expect(await registration.snapshot.isEnabled) - } - - @MainActor - @Test func publicEnableUsesSettingsMutationTimeout() async { - let settingsGate = LifecycleSyncGate() - let timeoutGate = LifecycleSyncGate() - let timeoutSleeper = LifecycleSettingsMutationSleeper( - firstGate: timeoutGate - ) - let registration = LifecyclePushRegistration(enabled: false) - let suiteName = "push-coordinator-public-enable-timeout-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - notificationSettings: { - await settingsGate.pause() - return .authorizationOnly(.authorized) - }, - settingsMutationSleep: { duration in - try await timeoutSleeper.sleep(for: duration) - } - ) - - let enabling = Task { await coordinator.enable() } - await settingsGate.waitUntilStarted() - for _ in 0..<100 where await timeoutGate.starts == 0 { - await Task.yield() - } - #expect(await timeoutGate.starts == 1) - - await timeoutGate.release() - for _ in 0..<100 { - if coordinator.registrationSnapshot.backendState - == .failed(.networkUnavailable) { - break - } - await Task.yield() - } - #expect( - coordinator.registrationSnapshot.backendState - == .failed(.networkUnavailable) - ) - - await settingsGate.release() - #expect(!(await enabling.value)) - } - - @MainActor - @Test func timedOutOptOutRetriesAndSurfacesUnconfirmedCleanup() async { - let disableGate = LifecycleSetEnabledGate() - let timeoutGate = LifecycleSyncGate() - let timeoutSleeper = LifecycleSettingsMutationSleeper( - firstGate: timeoutGate - ) - let registration = LifecyclePushRegistration( - enabled: true, - setEnabledGate: disableGate - ) - let suiteName = "push-coordinator-optout-timeout-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - defaults.set(true, forKey: "cmux.notifications.pushEnabled") - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - authorizationStatus: { .authorized }, - settingsMutationSleep: { duration in - try await timeoutSleeper.sleep(for: duration) - } - ) - - coordinator.setEnabledIntent(false) - await disableGate.waitUntilStarted() - await timeoutGate.waitUntilStarted() - await timeoutGate.release() - - #expect(await disableGate.waitUntilStartCount(2)) - #expect(!coordinator.isEnabled) - #expect(coordinator.isDisableCleanupUnconfirmed) - - await disableGate.release() - for _ in 0..<100 where await registration.snapshot != .disabled { - await Task.yield() - } - #expect(coordinator.isDisableCleanupUnconfirmed) - #expect(await registration.snapshot == .disabled) - } - - @MainActor - @Test func timedOutEnableAllowsOneBoundedRecoveryAndCannotBlockOptOut() async { - let settingsGate = LifecycleSyncGate() - let timeoutGate = LifecycleSyncGate() - let timeoutSleeper = LifecycleSettingsMutationSleeper( - firstGate: timeoutGate - ) - let registration = LifecyclePushRegistration(enabled: false) - let suiteName = "push-coordinator-timeout-dedup-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - notificationSettings: { - await settingsGate.pause() - return .authorizationOnly(.authorized) - }, - settingsMutationSleep: { duration in - try await timeoutSleeper.sleep(for: duration) - } - ) - - coordinator.setEnabledIntent(true) - await settingsGate.waitUntilStarted() - await timeoutGate.waitUntilStarted() - await timeoutGate.release() - for _ in 0..<100 { - if coordinator.registrationSnapshot.backendState - == .failed(.networkUnavailable) { - break - } - await Task.yield() - } - - coordinator.setEnabledIntent(true) - for _ in 0..<100 { - if await settingsGate.starts == 2, - await registration.snapshot.isEnabled { - break - } - await Task.yield() - } - #expect(await settingsGate.starts == 2) - #expect(await registration.snapshot.isEnabled) - - coordinator.setEnabledIntent(true) - for _ in 0..<100 { await Task.yield() } - #expect(await settingsGate.starts == 2) - - coordinator.setEnabledIntent(false) - for _ in 0..<100 { - if !coordinator.isEnabled, - await registration.snapshot == .disabled { - break - } - await Task.yield() - } - #expect(!coordinator.isEnabled) - #expect(await registration.snapshot == .disabled) - - await settingsGate.release() - for _ in 0..<100 { await Task.yield() } - #expect(!coordinator.isEnabled) - #expect(await registration.snapshot == .disabled) - } - - @MainActor - @Test func lateAuthorizationAfterTimeoutStartsFreshReconciliation() async { - let authorizationGate = LifecycleSyncGate() - let timeoutGate = LifecycleSyncGate() - let timeoutSleeper = LifecycleSettingsMutationSleeper( - firstGate: timeoutGate - ) - let registration = LifecyclePushRegistration(enabled: false) - let suiteName = "push-coordinator-late-authorization-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - var authorization = MobilePushAuthorization.notDetermined - var registrationRequests = 0 - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - notificationSettings: { - .authorizationOnly(authorization) - }, - requestAuthorization: { - await authorizationGate.pause() - authorization = .authorized - return true - }, - registerForRemoteNotifications: { registrationRequests += 1 }, - settingsMutationSleep: { duration in - try await timeoutSleeper.sleep(for: duration) - } - ) - - coordinator.setEnabledIntent(true) - await authorizationGate.waitUntilStarted() - await timeoutGate.waitUntilStarted() - await timeoutGate.release() - for _ in 0..<100 { - if coordinator.registrationSnapshot.backendState - == .failed(.networkUnavailable) { - break - } - await Task.yield() - } - #expect( - coordinator.registrationSnapshot.backendState - == .failed(.networkUnavailable) - ) - - await authorizationGate.release() - for _ in 0..<100 { - if registrationRequests == 1 { break } - await Task.yield() - } - #expect(registrationRequests == 1) - for _ in 0..<100 { - if await registration.snapshot.isEnabled { break } - await Task.yield() - } - #expect(await registration.snapshot.isEnabled) - } - - @MainActor - @Test func supersedingSettingsIntentCancelsMutationWorkers() async { - let authorizationGate = LifecycleSyncGate() - let timeoutGate = LifecycleSyncGate() - let timeoutSleeper = LifecycleSettingsMutationSleeper( - firstGate: timeoutGate - ) - let registration = LifecyclePushRegistration(enabled: false) - let suiteName = "push-coordinator-cancel-workers-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - let cancellationRecorder = LifecycleCancellationRecorder() - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - authorizationStatus: { .notDetermined }, - requestAuthorization: { - await authorizationGate.pause() - await cancellationRecorder.recordAuthorizationCancellation( - Task.isCancelled - ) - return true - }, - settingsMutationSleep: { duration in - try await timeoutSleeper.sleep(for: duration) - } - ) - - coordinator.setEnabledIntent(true) - await authorizationGate.waitUntilStarted() - await timeoutGate.waitUntilStarted() - coordinator.setEnabledIntent(false) - await authorizationGate.release() - await timeoutGate.release() - for _ in 0..<100 { - if !coordinator.isEnabled, await registration.snapshot == .disabled { - break - } - await Task.yield() - } - - #expect(await cancellationRecorder.didCancelAuthorization) - #expect(!coordinator.isEnabled) - #expect(await registration.snapshot == .disabled) - } - - @MainActor - @Test func deniedReenableSupersedesInFlightDisable() async { - let disableGate = LifecycleSetEnabledGate() - let settingsGate = LifecycleSyncGate() - let registration = LifecyclePushRegistration( - enabled: true, - setEnabledGate: disableGate - ) - let suiteName = "push-coordinator-denied-reenable-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - defaults.set(true, forKey: "cmux.notifications.pushEnabled") - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - notificationSettings: { - await settingsGate.pause() - return .authorizationOnly(.denied) - } - ) - - coordinator.setEnabledIntent(false) - await disableGate.waitUntilStarted() - - coordinator.setEnabledIntent(true) - await settingsGate.waitUntilStarted() - await settingsGate.release() - for _ in 0..<20 { - await Task.yield() - } - - await disableGate.release() - for _ in 0..<100 { - if await registration.snapshot.isEnabled { break } - await Task.yield() - } - - #expect(await registration.snapshot.isEnabled) - #expect(coordinator.isEnabled) - #expect(defaults.bool(forKey: "cmux.notifications.pushEnabled")) - } - @MainActor @Test func foregroundAndReachabilityRecoveryShareOneExhaustedRegistrationRetry() async { let gate = LifecycleSyncGate() @@ -1173,49 +563,4 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(await gate.starts == 1) #expect(coordinator.registrationSnapshot.backendState == .registered) } - - @MainActor - @Test func timedOutRegistrationRecoveryStartsOneBoundedFreshRetry() async { - let syncGate = LifecycleSyncGate() - let timeoutGate = LifecycleSyncGate() - let registration = LifecyclePushRegistration( - snapshot: PushRegistrationSnapshot( - isEnabled: true, - hasDeviceToken: true, - backendState: .failed(.networkUnavailable) - ), - syncGate: syncGate - ) - let suiteName = "push-coordinator-recovery-timeout-\(UUID().uuidString)" - let defaults = UserDefaults(suiteName: suiteName)! - defer { defaults.removePersistentDomain(forName: suiteName) } - let coordinator = MobilePushCoordinator( - registration: registration, - defaults: defaults, - authorizationStatus: { .authorized }, - settingsMutationSleep: { _ in await timeoutGate.pause() } - ) - - let first = Task { @MainActor in await coordinator.enable() } - await syncGate.waitUntilStarted() - await timeoutGate.waitUntilStarted() - await timeoutGate.release() - _ = await first.value - - let second = Task { @MainActor in - await coordinator.networkDidBecomeReachable() - } - let freshRetryStarted = await syncGate.waitUntilStartCount(2) - let third = Task { @MainActor in - await coordinator.networkDidBecomeReachable() - } - for _ in 0..<100 { await Task.yield() } - - #expect(freshRetryStarted) - #expect(await syncGate.starts == 2) - - await syncGate.release() - await second.value - await third.value - } } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushMutationCompletionTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushMutationCompletionTests.swift deleted file mode 100644 index 0c7ab9a041b..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushMutationCompletionTests.swift +++ /dev/null @@ -1,19 +0,0 @@ -import Testing - -@testable import CmuxMobileShellUI - -@Suite struct MobilePushMutationCompletionTests { - @Test func onlyTheWinningResolutionReportsSuccess() async { - let completion = MobilePushMutationCompletion() - - #expect(await completion.resolve(.completed, succeeded: true)) - #expect(!(await completion.resolve(.timedOut))) - #expect( - await completion.wait() - == MobilePushMutationResult( - outcome: .completed, - succeeded: true - ) - ) - } -} diff --git a/ios/cmux/Resources/Localizable.xcstrings b/ios/cmux/Resources/Localizable.xcstrings index d5a1a093083..7768c15b161 100644 --- a/ios/cmux/Resources/Localizable.xcstrings +++ b/ios/cmux/Resources/Localizable.xcstrings @@ -1531,23 +1531,6 @@ } } }, - "mobile.debug.push.completeMutation": { - "extractionState": "manual", - "localizations": { - "en": { - "stringUnit": { - "state": "translated", - "value": "Complete Push Mutation" - } - }, - "ja": { - "stringUnit": { - "state": "translated", - "value": "プッシュ変更を完了" - } - } - } - }, "mobile.common.ok": { "extractionState": "manual", "localizations": { @@ -19632,8 +19615,6 @@ "mobile.accessibility.notSelected": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"not selected"}},"ja":{"stringUnit":{"state":"translated","value":"未選択"}}}}, "mobile.accessibility.selected": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"selected"}},"ja":{"stringUnit":{"state":"translated","value":"選択済み"}}}}, "mobile.notifications.awayExplanation": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Only When Away sends after the Mac is locked, asleep, or inactive."}},"ja":{"stringUnit":{"state":"translated","value":"「離席中のみ」では、Macがロック中、スリープ中、または操作されていないときに送信します。"}}}}, - "mobile.notifications.disableCleanupRetry": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Retry Push Alert Cleanup"}},"ja":{"stringUnit":{"state":"translated","value":"プッシュ通知のクリーンアップを再試行"}}}}, - "mobile.notifications.disableCleanupUnconfirmed": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Push alerts are off on this iPhone, but server cleanup could not be confirmed."}},"ja":{"stringUnit":{"state":"translated","value":"このiPhoneではプッシュ通知はオフですが、サーバーのクリーンアップを確認できませんでした。"}}}}, "mobile.notifications.hideContent": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Hide Notification Content"}},"ja":{"stringUnit":{"state":"translated","value":"通知内容を非表示"}}}}, "mobile.notifications.macForwarding": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Forward Alerts from This Mac"}},"ja":{"stringUnit":{"state":"translated","value":"このMacから通知を転送"}}}}, "mobile.notifications.macMode": {"extractionState":"manual","localizations":{"en":{"stringUnit":{"state":"translated","value":"Forwarding Mode"}},"ja":{"stringUnit":{"state":"translated","value":"転送モード"}}}}, diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 4a633c66ac0..b5e76adf17e 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -4116,12 +4116,6 @@ struct InertPushRegistration: PushRegistering { } } func setEnabled(_ enabled: Bool) async {} - func disableAndUnregister() async {} - func applyEnabledIntent( - _ enabled: Bool, - generation: UInt64, - intentEpoch: PushRegistrationIntentEpoch - ) async {} func register(deviceToken: Data) async {} func deviceTokenRegistrationFailed() async {} func syncTokenIfPossible() async {} diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index 95da3339a64..a3c166d8980 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -115,36 +115,6 @@ final class PushReadinessUITests: XCTestCase { waitForValue(forwarding, "0") } - @MainActor - func testPhonePushToggleTurnsOffImmediately() { - let app = launchPreview( - "healthy", - extraEnvironment: ["CMUX_UITEST_PUSH_PHONE_MUTATION_DELAY": "1"] - ) - defer { app.terminate() } - - let phone = app.switches["MobileSettingsNotifications"] - XCTAssertTrue(phone.waitForExistence(timeout: 8)) - XCTAssertEqual(phone.value as? String, "1") - - tapSwitch(phone) - - waitForValue( - phone, - "0", - timeout: 1, - message: "The toggle must reflect the requested opt-out immediately" - ) - XCTAssertEqual(phone.value as? String, "0") - let completeMutation = app.buttons["MobilePushReadinessCompletePhoneMutation"] - XCTAssertTrue(completeMutation.waitForExistence(timeout: 2)) - completeMutation.tap() - let status = app.descendants(matching: .any)[ - "MobileSettingsPushReadinessStatus" - ] - waitForLabel(status, containing: "Blocked, Off on This iPhone") - } - @MainActor func testFailedMacMutationRollsBackAndStaysVisible() { let app = launchPreview( @@ -220,8 +190,7 @@ final class PushReadinessUITests: XCTestCase { private func waitForValue( _ element: XCUIElement, _ expected: String, - timeout: TimeInterval = 4, - message: String? = nil + timeout: TimeInterval = 4 ) { let predicate = NSPredicate(format: "value == %@", expected) let expectation = XCTNSPredicateExpectation( @@ -231,7 +200,7 @@ final class PushReadinessUITests: XCTestCase { XCTAssertEqual( XCTWaiter.wait(for: [expectation], timeout: timeout), .completed, - message ?? "Expected '\(expected)', got '\(String(describing: element.value))'" + "Expected '\(expected)', got '\(String(describing: element.value))'" ) } From 4666aa7b750262c6dc8fb3c0e35ae64080f8bb1b Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:20:09 -0700 Subject: [PATCH 087/117] test(ios): cover pending push opt-out --- .../MobilePushReadinessPreviewView.swift | 5 ++ .../MobilePushSettingsContent.swift | 31 ++----------- .../CmuxMobileShellUI/MobilePushToggle.swift | 40 ++++++++++++++++ .../MobileSettingsView.swift | 19 ++------ ios/cmuxUITests/PushReadinessUITests.swift | 46 ++++++++++++++++++- 5 files changed, 98 insertions(+), 43 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index f17d9d04447..b53538dec96 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -12,6 +12,7 @@ import SwiftUI struct MobilePushReadinessPreviewView: View { private let fixture: Fixture private let rejectsMacMutations: Bool + private let delaysPhoneMutation: Bool @State private var phoneEnabled: Bool @State private var authorization: MobilePushAuthorization @@ -22,6 +23,7 @@ struct MobilePushReadinessPreviewView: View { let fixture = Fixture(rawValue: state) ?? .healthy self.fixture = fixture self.rejectsMacMutations = environment["CMUX_UITEST_PUSH_MUTATION_FAILURE"] == "1" + self.delaysPhoneMutation = environment["CMUX_UITEST_PUSH_PHONE_MUTATION_DELAY"] == "1" self._phoneEnabled = State(initialValue: fixture.registration.isEnabled) self._authorization = State(initialValue: fixture.authorization) self._registration = State(initialValue: fixture.registration) @@ -69,6 +71,9 @@ struct MobilePushReadinessPreviewView: View { @MainActor private func setPhoneEnabled(_ enabled: Bool) async -> Bool { + if delaysPhoneMutation { + try? await Task.sleep(for: .seconds(2)) + } phoneEnabled = enabled registration = enabled ? Self.registered diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift index adb1adc6e5a..77cde93ea23 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift @@ -75,15 +75,11 @@ struct MobilePushSettingsContent: View { Group { statusRow - Toggle( - L10n.string( - "mobile.notifications.phoneEnabled", - defaultValue: "Allow Push Alerts on This iPhone" - ), - isOn: phoneEnabledBinding + MobilePushToggle( + isEnabled: $phoneEnabled, + isUpdating: $isMutatingPhone, + onChange: onPhoneEnabledChange ) - .accessibilityIdentifier("MobileSettingsNotifications") - .disabled(isMutatingPhone) if let repair = readiness.repair, Self.shouldPresentRepair(repair, canConnectMac: canConnectMac), @@ -248,25 +244,6 @@ struct MobilePushSettingsContent: View { .accessibilityIdentifier("MobileSettingsPushReadinessStatus") } - private var phoneEnabledBinding: Binding { - Binding( - get: { phoneEnabled }, - set: { requested in - guard !isMutatingPhone else { return } - let confirmed = phoneEnabled - phoneEnabled = requested - isMutatingPhone = true - Task { - let succeeded = await onPhoneEnabledChange(requested) - if !succeeded { - phoneEnabled = confirmed - } - isMutatingPhone = false - } - } - ) - } - private var macForwardingBinding: Binding { Binding( get: { macForwardingEnabled }, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift new file mode 100644 index 00000000000..53c4fe1399e --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -0,0 +1,40 @@ +#if os(iOS) +import CmuxMobileSupport +import SwiftUI + +/// Shared phone-push toggle used by release and diagnostic Settings. +struct MobilePushToggle: View { + @Binding var isEnabled: Bool + @Binding var isUpdating: Bool + let onChange: @MainActor (Bool) async -> Bool + + var body: some View { + Toggle( + L10n.string( + "mobile.notifications.phoneEnabled", + defaultValue: "Allow Push Alerts on This iPhone" + ), + isOn: binding + ) + .accessibilityIdentifier("MobileSettingsNotifications") + .disabled(isUpdating) + } + + private var binding: Binding { + Binding( + get: { isEnabled }, + set: { requested in + guard !isUpdating else { return } + let previous = isEnabled + isUpdating = true + Task { @MainActor in + defer { isUpdating = false } + if !(await onChange(requested)) { + isEnabled = previous + } + } + } + ) + } +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 3f8ce8e0537..218bcd2ab5f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -46,6 +46,7 @@ struct MobileSettingsView: View { /// `isEnabled` as a non-observable `UserDefaults` read, so reading it /// directly in `body` would not re-render when it flips. @State private var notificationsEnabled = false + @State private var notificationsToggleUpdating = false #if DEBUG @State private var debugReplyScheduled: Bool? #endif @@ -421,21 +422,11 @@ struct MobileSettingsView: View { .foregroundStyle(.secondary) } #else - Toggle( - L10n.string( - "mobile.notifications.phoneEnabled", - defaultValue: "Allow Push Alerts on This iPhone" - ), - isOn: Binding( - get: { notificationsEnabled }, - set: { enabled in - Task { @MainActor in - notificationsEnabled = await updatePhonePushEnabled(enabled) - } - } - ) + MobilePushToggle( + isEnabled: $notificationsEnabled, + isUpdating: $notificationsToggleUpdating, + onChange: updatePhonePushEnabled ) - .accessibilityIdentifier("MobileSettingsNotifications") #endif } diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index a3c166d8980..b413fcfeb39 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -115,6 +115,31 @@ final class PushReadinessUITests: XCTestCase { waitForValue(forwarding, "0") } + @MainActor + func testPhonePushToggleTurnsOffWhileMutationIsPending() { + let app = launchPreview( + "healthy", + extraEnvironment: ["CMUX_UITEST_PUSH_PHONE_MUTATION_DELAY": "1"] + ) + defer { app.terminate() } + + let phone = app.switches["MobileSettingsNotifications"] + XCTAssertTrue(phone.waitForExistence(timeout: 8)) + XCTAssertEqual(phone.value as? String, "1") + + tapSwitch(phone) + + waitForValue( + phone, + "0", + timeout: 1, + message: "The toggle must reflect opt-out before cleanup finishes" + ) + waitForDisabled(phone) + waitForEnabled(phone) + XCTAssertEqual(phone.value as? String, "0") + } + @MainActor func testFailedMacMutationRollsBackAndStaysVisible() { let app = launchPreview( @@ -190,7 +215,8 @@ final class PushReadinessUITests: XCTestCase { private func waitForValue( _ element: XCUIElement, _ expected: String, - timeout: TimeInterval = 4 + timeout: TimeInterval = 4, + message: String? = nil ) { let predicate = NSPredicate(format: "value == %@", expected) let expectation = XCTNSPredicateExpectation( @@ -200,7 +226,7 @@ final class PushReadinessUITests: XCTestCase { XCTAssertEqual( XCTWaiter.wait(for: [expectation], timeout: timeout), .completed, - "Expected '\(expected)', got '\(String(describing: element.value))'" + message ?? "Expected '\(expected)', got '\(String(describing: element.value))'" ) } @@ -220,6 +246,22 @@ final class PushReadinessUITests: XCTestCase { ) } + @MainActor + private func waitForDisabled( + _ element: XCUIElement, + timeout: TimeInterval = 4 + ) { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "enabled == false"), + object: element + ) + XCTAssertEqual( + XCTWaiter.wait(for: [expectation], timeout: timeout), + .completed, + "Expected '\(element.identifier)' to become disabled" + ) + } + @MainActor private func tapSwitch(_ element: XCUIElement) { element.coordinate( From ed6e5e02974ffc134ef48a8d8f928c20c158d0e5 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:20:18 -0700 Subject: [PATCH 088/117] fix(ios): update push toggle optimistically --- .../Sources/CmuxMobileShellUI/MobilePushToggle.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index 53c4fe1399e..580da7ed453 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -26,6 +26,7 @@ struct MobilePushToggle: View { set: { requested in guard !isUpdating else { return } let previous = isEnabled + isEnabled = requested isUpdating = true Task { @MainActor in defer { isUpdating = false } From dc379323d2a5268c011b5e4dbef3886fa7a2d9dd Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:29:30 -0700 Subject: [PATCH 089/117] test(ios): signal pending push mutation --- .../MobilePushReadinessPreviewView.swift | 48 ++++++++++++++++++- ios/cmuxUITests/PushReadinessUITests.swift | 5 ++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index b53538dec96..50ea5af8ac0 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -10,6 +10,11 @@ import SwiftUI /// network/OS seams are fixtures, so accessibility, localization, optimistic /// mutation, rollback, and every rendered repair action remain production code. struct MobilePushReadinessPreviewView: View { + private struct PendingPhoneMutation { + let enabled: Bool + let continuation: CheckedContinuation + } + private let fixture: Fixture private let rejectsMacMutations: Bool private let delaysPhoneMutation: Bool @@ -18,6 +23,7 @@ struct MobilePushReadinessPreviewView: View { @State private var authorization: MobilePushAuthorization @State private var registration: PushRegistrationSnapshot @State private var macStatus: MobileHostPhonePushStatus? + @State private var pendingPhoneMutation: PendingPhoneMutation? init(state: String, environment: [String: String] = ProcessInfo.processInfo.environment) { let fixture = Fixture(rawValue: state) ?? .healthy @@ -28,6 +34,7 @@ struct MobilePushReadinessPreviewView: View { self._authorization = State(initialValue: fixture.authorization) self._registration = State(initialValue: fixture.registration) self._macStatus = State(initialValue: fixture.macStatus) + self._pendingPhoneMutation = State(initialValue: nil) } var body: some View { @@ -49,6 +56,20 @@ struct MobilePushReadinessPreviewView: View { onMacMutation: mutateMac, onSendTest: { .queuedOnMac } ) + + if pendingPhoneMutation != nil { + Button { + completePhoneMutation() + } label: { + Text(L10n.string( + "mobile.settings.done", + defaultValue: "Done" + )) + } + .accessibilityIdentifier( + "MobilePushReadinessCompletePhoneMutation" + ) + } } } .navigationTitle(L10n.string( @@ -57,6 +78,11 @@ struct MobilePushReadinessPreviewView: View { )) } .accessibilityIdentifier("MobilePushReadinessPreview") + .onDisappear { + guard let pendingPhoneMutation else { return } + self.pendingPhoneMutation = nil + pendingPhoneMutation.continuation.resume(returning: false) + } } private var readiness: MobilePushReadiness { @@ -72,13 +98,31 @@ struct MobilePushReadinessPreviewView: View { @MainActor private func setPhoneEnabled(_ enabled: Bool) async -> Bool { if delaysPhoneMutation { - try? await Task.sleep(for: .seconds(2)) + return await withCheckedContinuation { continuation in + pendingPhoneMutation = PendingPhoneMutation( + enabled: enabled, + continuation: continuation + ) + } } + applyPhoneMutation(enabled) + return true + } + + @MainActor + private func completePhoneMutation() { + guard let pendingPhoneMutation else { return } + self.pendingPhoneMutation = nil + applyPhoneMutation(pendingPhoneMutation.enabled) + pendingPhoneMutation.continuation.resume(returning: true) + } + + @MainActor + private func applyPhoneMutation(_ enabled: Bool) { phoneEnabled = enabled registration = enabled ? Self.registered : .disabled - return true } @MainActor diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index b413fcfeb39..75a01e0631c 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -136,6 +136,11 @@ final class PushReadinessUITests: XCTestCase { message: "The toggle must reflect opt-out before cleanup finishes" ) waitForDisabled(phone) + let completeMutation = app.buttons[ + "MobilePushReadinessCompletePhoneMutation" + ] + XCTAssertTrue(completeMutation.waitForExistence(timeout: 2)) + completeMutation.tap() waitForEnabled(phone) XCTAssertEqual(phone.value as? String, "0") } From 253e6707fd4118678276c52e4f6b91961d014893 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:34:57 -0700 Subject: [PATCH 090/117] fix(ios): keep push toggle interactive --- .../MobilePushReadinessPreviewView.swift | 31 ++++++++++++------- .../MobilePushSettingsContent.swift | 31 ++++++++++++++++--- .../CmuxMobileShellUI/MobilePushToggle.swift | 11 +++---- .../MobileSettingsView.swift | 2 -- ios/cmuxUITests/PushReadinessUITests.swift | 18 +---------- 5 files changed, 51 insertions(+), 42 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index 50ea5af8ac0..958371d9d9b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -44,18 +44,25 @@ struct MobilePushReadinessPreviewView: View { "mobile.settings.notifications", defaultValue: "Push Alerts" )) { - MobilePushSettingsContent( - readiness: readiness, - phoneEnabled: $phoneEnabled, - macStatus: macStatus, - supportsMacSettings: macStatus != nil, - supportsMacTest: macStatus != nil, - canConnectMac: true, - onPhoneEnabledChange: setPhoneEnabled, - onRepair: repair, - onMacMutation: mutateMac, - onSendTest: { .queuedOnMac } - ) + if delaysPhoneMutation { + MobilePushToggle( + isEnabled: $phoneEnabled, + onChange: setPhoneEnabled + ) + } else { + MobilePushSettingsContent( + readiness: readiness, + phoneEnabled: $phoneEnabled, + macStatus: macStatus, + supportsMacSettings: macStatus != nil, + supportsMacTest: macStatus != nil, + canConnectMac: true, + onPhoneEnabledChange: setPhoneEnabled, + onRepair: repair, + onMacMutation: mutateMac, + onSendTest: { .queuedOnMac } + ) + } if pendingPhoneMutation != nil { Button { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift index 77cde93ea23..adb1adc6e5a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushSettingsContent.swift @@ -75,11 +75,15 @@ struct MobilePushSettingsContent: View { Group { statusRow - MobilePushToggle( - isEnabled: $phoneEnabled, - isUpdating: $isMutatingPhone, - onChange: onPhoneEnabledChange + Toggle( + L10n.string( + "mobile.notifications.phoneEnabled", + defaultValue: "Allow Push Alerts on This iPhone" + ), + isOn: phoneEnabledBinding ) + .accessibilityIdentifier("MobileSettingsNotifications") + .disabled(isMutatingPhone) if let repair = readiness.repair, Self.shouldPresentRepair(repair, canConnectMac: canConnectMac), @@ -244,6 +248,25 @@ struct MobilePushSettingsContent: View { .accessibilityIdentifier("MobileSettingsPushReadinessStatus") } + private var phoneEnabledBinding: Binding { + Binding( + get: { phoneEnabled }, + set: { requested in + guard !isMutatingPhone else { return } + let confirmed = phoneEnabled + phoneEnabled = requested + isMutatingPhone = true + Task { + let succeeded = await onPhoneEnabledChange(requested) + if !succeeded { + phoneEnabled = confirmed + } + isMutatingPhone = false + } + } + ) + } + private var macForwardingBinding: Binding { Binding( get: { macForwardingEnabled }, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index 580da7ed453..ed816e30b2d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -2,10 +2,9 @@ import CmuxMobileSupport import SwiftUI -/// Shared phone-push toggle used by release and diagnostic Settings. +/// Release phone-push toggle, shared with its diagnostic test harness. struct MobilePushToggle: View { @Binding var isEnabled: Bool - @Binding var isUpdating: Bool let onChange: @MainActor (Bool) async -> Bool var body: some View { @@ -17,20 +16,18 @@ struct MobilePushToggle: View { isOn: binding ) .accessibilityIdentifier("MobileSettingsNotifications") - .disabled(isUpdating) } private var binding: Binding { Binding( get: { isEnabled }, set: { requested in - guard !isUpdating else { return } let previous = isEnabled isEnabled = requested - isUpdating = true Task { @MainActor in - defer { isUpdating = false } - if !(await onChange(requested)) { + let succeeded = await onChange(requested) + guard isEnabled == requested else { return } + if !succeeded { isEnabled = previous } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 218bcd2ab5f..748cf18dff4 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -46,7 +46,6 @@ struct MobileSettingsView: View { /// `isEnabled` as a non-observable `UserDefaults` read, so reading it /// directly in `body` would not re-render when it flips. @State private var notificationsEnabled = false - @State private var notificationsToggleUpdating = false #if DEBUG @State private var debugReplyScheduled: Bool? #endif @@ -424,7 +423,6 @@ struct MobileSettingsView: View { #else MobilePushToggle( isEnabled: $notificationsEnabled, - isUpdating: $notificationsToggleUpdating, onChange: updatePhonePushEnabled ) #endif diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index 75a01e0631c..199fa60e2a1 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -135,7 +135,7 @@ final class PushReadinessUITests: XCTestCase { timeout: 1, message: "The toggle must reflect opt-out before cleanup finishes" ) - waitForDisabled(phone) + XCTAssertTrue(phone.isEnabled) let completeMutation = app.buttons[ "MobilePushReadinessCompletePhoneMutation" ] @@ -251,22 +251,6 @@ final class PushReadinessUITests: XCTestCase { ) } - @MainActor - private func waitForDisabled( - _ element: XCUIElement, - timeout: TimeInterval = 4 - ) { - let expectation = XCTNSPredicateExpectation( - predicate: NSPredicate(format: "enabled == false"), - object: element - ) - XCTAssertEqual( - XCTWaiter.wait(for: [expectation], timeout: timeout), - .completed, - "Expected '\(element.identifier)' to become disabled" - ) - } - @MainActor private func tapSwitch(_ element: XCUIElement) { element.coordinate( From 2f0e084e10d87e84c1fc0c0f714470315b5aa7ee Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:35:46 -0700 Subject: [PATCH 091/117] test(ios): bound pending push fixture --- .../Debug/MobilePushReadinessPreviewView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index 958371d9d9b..7de7ef5338c 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -106,6 +106,9 @@ struct MobilePushReadinessPreviewView: View { private func setPhoneEnabled(_ enabled: Bool) async -> Bool { if delaysPhoneMutation { return await withCheckedContinuation { continuation in + if let pendingPhoneMutation { + pendingPhoneMutation.continuation.resume(returning: false) + } pendingPhoneMutation = PendingPhoneMutation( enabled: enabled, continuation: continuation From 1834813593e2bbaa4de43a60a4d0999667c9c574 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:36:12 -0700 Subject: [PATCH 092/117] test(ios): isolate push fixture state --- .../Debug/MobilePushPreviewPendingMutation.swift | 7 +++++++ .../Debug/MobilePushReadinessPreviewView.swift | 9 ++------- 2 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushPreviewPendingMutation.swift diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushPreviewPendingMutation.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushPreviewPendingMutation.swift new file mode 100644 index 00000000000..3d77033c4ae --- /dev/null +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushPreviewPendingMutation.swift @@ -0,0 +1,7 @@ +#if os(iOS) && DEBUG +/// One deterministic phone-push mutation parked by the UI-test harness. +struct MobilePushPreviewPendingMutation { + let enabled: Bool + let continuation: CheckedContinuation +} +#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index 7de7ef5338c..60ed3e54e53 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -10,11 +10,6 @@ import SwiftUI /// network/OS seams are fixtures, so accessibility, localization, optimistic /// mutation, rollback, and every rendered repair action remain production code. struct MobilePushReadinessPreviewView: View { - private struct PendingPhoneMutation { - let enabled: Bool - let continuation: CheckedContinuation - } - private let fixture: Fixture private let rejectsMacMutations: Bool private let delaysPhoneMutation: Bool @@ -23,7 +18,7 @@ struct MobilePushReadinessPreviewView: View { @State private var authorization: MobilePushAuthorization @State private var registration: PushRegistrationSnapshot @State private var macStatus: MobileHostPhonePushStatus? - @State private var pendingPhoneMutation: PendingPhoneMutation? + @State private var pendingPhoneMutation: MobilePushPreviewPendingMutation? init(state: String, environment: [String: String] = ProcessInfo.processInfo.environment) { let fixture = Fixture(rawValue: state) ?? .healthy @@ -109,7 +104,7 @@ struct MobilePushReadinessPreviewView: View { if let pendingPhoneMutation { pendingPhoneMutation.continuation.resume(returning: false) } - pendingPhoneMutation = PendingPhoneMutation( + pendingPhoneMutation = MobilePushPreviewPendingMutation( enabled: enabled, continuation: continuation ) From 71818d7367be3635c3036d5be7edd29e13a29cb0 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:40:58 -0700 Subject: [PATCH 093/117] fix(ios): serialize push toggle mutations --- .../MobilePushReadinessPreviewView.swift | 5 +-- .../CmuxMobileShellUI/MobilePushToggle.swift | 36 +++++++++++++++---- ios/cmuxUITests/PushReadinessUITests.swift | 28 +++++++++++---- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index 60ed3e54e53..b4f0935012f 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -59,7 +59,7 @@ struct MobilePushReadinessPreviewView: View { ) } - if pendingPhoneMutation != nil { + if let pendingPhoneMutation { Button { completePhoneMutation() } label: { @@ -69,7 +69,8 @@ struct MobilePushReadinessPreviewView: View { )) } .accessibilityIdentifier( - "MobilePushReadinessCompletePhoneMutation" + "MobilePushReadinessCompletePhoneMutation-" + + (pendingPhoneMutation.enabled ? "on" : "off") ) } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index ed816e30b2d..45659b9ee10 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -6,6 +6,9 @@ import SwiftUI struct MobilePushToggle: View { @Binding var isEnabled: Bool let onChange: @MainActor (Bool) async -> Bool + @State private var mutationTask: Task? + @State private var pendingRequest: Bool? + @State private var confirmedValue: Bool? var body: some View { Toggle( @@ -22,17 +25,36 @@ struct MobilePushToggle: View { Binding( get: { isEnabled }, set: { requested in - let previous = isEnabled + if mutationTask == nil { + confirmedValue = isEnabled + } isEnabled = requested - Task { @MainActor in - let succeeded = await onChange(requested) - guard isEnabled == requested else { return } - if !succeeded { - isEnabled = previous + pendingRequest = requested + startMutationWorkerIfNeeded() + } + ) + } + + @MainActor + private func startMutationWorkerIfNeeded() { + guard mutationTask == nil else { return } + mutationTask = Task { @MainActor in + while let requested = pendingRequest { + pendingRequest = nil + let fallback = confirmedValue ?? isEnabled + let succeeded = await onChange(requested) + if succeeded { + confirmedValue = requested + if pendingRequest == requested { + pendingRequest = nil } + } else if pendingRequest == nil, isEnabled == requested { + isEnabled = fallback } } - ) + mutationTask = nil + confirmedValue = nil + } } } #endif diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index 199fa60e2a1..73463260acb 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -116,7 +116,7 @@ final class PushReadinessUITests: XCTestCase { } @MainActor - func testPhonePushToggleTurnsOffWhileMutationIsPending() { + func testPhonePushToggleUpdatesImmediatelyAndSerializesMutations() { let app = launchPreview( "healthy", extraEnvironment: ["CMUX_UITEST_PUSH_PHONE_MUTATION_DELAY": "1"] @@ -136,13 +136,27 @@ final class PushReadinessUITests: XCTestCase { message: "The toggle must reflect opt-out before cleanup finishes" ) XCTAssertTrue(phone.isEnabled) - let completeMutation = app.buttons[ - "MobilePushReadinessCompletePhoneMutation" + + tapSwitch(phone) + waitForValue( + phone, + "1", + timeout: 1, + message: "The toggle must reflect the latest queued intent" + ) + + let completeDisable = app.buttons[ + "MobilePushReadinessCompletePhoneMutation-off" + ] + XCTAssertTrue(completeDisable.waitForExistence(timeout: 2)) + completeDisable.tap() + + let completeEnable = app.buttons[ + "MobilePushReadinessCompletePhoneMutation-on" ] - XCTAssertTrue(completeMutation.waitForExistence(timeout: 2)) - completeMutation.tap() - waitForEnabled(phone) - XCTAssertEqual(phone.value as? String, "0") + XCTAssertTrue(completeEnable.waitForExistence(timeout: 2)) + completeEnable.tap() + waitForValue(phone, "1") } @MainActor From e6d722d5a885c07e0073b7675c75b8861ef92d81 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:49:42 -0700 Subject: [PATCH 094/117] fix(ios): preserve queued push intent --- .../Sources/CmuxMobileShellUI/MobilePushToggle.swift | 3 +++ ios/cmuxUITests/PushReadinessUITests.swift | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index 45659b9ee10..33b67d61c16 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -51,6 +51,9 @@ struct MobilePushToggle: View { } else if pendingRequest == nil, isEnabled == requested { isEnabled = fallback } + if let pendingRequest { + isEnabled = pendingRequest + } } mutationTask = nil confirmedValue = nil diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index 73463260acb..4be5166c823 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -155,6 +155,12 @@ final class PushReadinessUITests: XCTestCase { "MobilePushReadinessCompletePhoneMutation-on" ] XCTAssertTrue(completeEnable.waitForExistence(timeout: 2)) + waitForValue( + phone, + "1", + timeout: 1, + message: "Completed stale work must not replace the queued intent" + ) completeEnable.tap() waitForValue(phone, "1") } From 38ffb32c6852fb6a7ca8a4b1920eda13d3f5d3e4 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:53:47 -0700 Subject: [PATCH 095/117] fix(ios): honor resolved push state --- .../MobilePushReadinessPreviewView.swift | 16 +++++++++----- .../CmuxMobileShellUI/MobilePushToggle.swift | 22 ++++++------------- .../MobileSettingsView.swift | 2 +- ios/cmuxUITests/PushReadinessUITests.swift | 13 +++++++++++ 4 files changed, 32 insertions(+), 21 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index b4f0935012f..aca7706e0eb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -42,7 +42,7 @@ struct MobilePushReadinessPreviewView: View { if delaysPhoneMutation { MobilePushToggle( isEnabled: $phoneEnabled, - onChange: setPhoneEnabled + resolveEnabledState: setPhoneEnabled ) } else { MobilePushSettingsContent( @@ -84,7 +84,9 @@ struct MobilePushReadinessPreviewView: View { .onDisappear { guard let pendingPhoneMutation else { return } self.pendingPhoneMutation = nil - pendingPhoneMutation.continuation.resume(returning: false) + pendingPhoneMutation.continuation.resume( + returning: registration.isEnabled + ) } } @@ -103,7 +105,9 @@ struct MobilePushReadinessPreviewView: View { if delaysPhoneMutation { return await withCheckedContinuation { continuation in if let pendingPhoneMutation { - pendingPhoneMutation.continuation.resume(returning: false) + pendingPhoneMutation.continuation.resume( + returning: registration.isEnabled + ) } pendingPhoneMutation = MobilePushPreviewPendingMutation( enabled: enabled, @@ -112,7 +116,7 @@ struct MobilePushReadinessPreviewView: View { } } applyPhoneMutation(enabled) - return true + return enabled } @MainActor @@ -120,7 +124,9 @@ struct MobilePushReadinessPreviewView: View { guard let pendingPhoneMutation else { return } self.pendingPhoneMutation = nil applyPhoneMutation(pendingPhoneMutation.enabled) - pendingPhoneMutation.continuation.resume(returning: true) + pendingPhoneMutation.continuation.resume( + returning: pendingPhoneMutation.enabled + ) } @MainActor diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index 33b67d61c16..e1c3e0ff585 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -5,10 +5,10 @@ import SwiftUI /// Release phone-push toggle, shared with its diagnostic test harness. struct MobilePushToggle: View { @Binding var isEnabled: Bool - let onChange: @MainActor (Bool) async -> Bool + /// Applies an intent and returns the resulting enabled state. + let resolveEnabledState: @MainActor (Bool) async -> Bool @State private var mutationTask: Task? @State private var pendingRequest: Bool? - @State private var confirmedValue: Bool? var body: some View { Toggle( @@ -25,9 +25,6 @@ struct MobilePushToggle: View { Binding( get: { isEnabled }, set: { requested in - if mutationTask == nil { - confirmedValue = isEnabled - } isEnabled = requested pendingRequest = requested startMutationWorkerIfNeeded() @@ -41,22 +38,17 @@ struct MobilePushToggle: View { mutationTask = Task { @MainActor in while let requested = pendingRequest { pendingRequest = nil - let fallback = confirmedValue ?? isEnabled - let succeeded = await onChange(requested) - if succeeded { - confirmedValue = requested - if pendingRequest == requested { - pendingRequest = nil - } - } else if pendingRequest == nil, isEnabled == requested { - isEnabled = fallback + let resolved = await resolveEnabledState(requested) + if pendingRequest == resolved { + pendingRequest = nil } if let pendingRequest { isEnabled = pendingRequest + } else { + isEnabled = resolved } } mutationTask = nil - confirmedValue = nil } } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index 748cf18dff4..abe8282f7a5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -423,7 +423,7 @@ struct MobileSettingsView: View { #else MobilePushToggle( isEnabled: $notificationsEnabled, - onChange: updatePhonePushEnabled + resolveEnabledState: updatePhonePushEnabled ) #endif } diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index 4be5166c823..b41cb8c6dea 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -163,6 +163,19 @@ final class PushReadinessUITests: XCTestCase { ) completeEnable.tap() waitForValue(phone, "1") + + tapSwitch(phone) + waitForValue(phone, "0") + let finalDisable = app.buttons[ + "MobilePushReadinessCompletePhoneMutation-off" + ] + XCTAssertTrue(finalDisable.waitForExistence(timeout: 2)) + finalDisable.tap() + waitForValue( + phone, + "0", + message: "A resolved false value is a successful opt-out" + ) } @MainActor From 9aae5b34b84701eb212d2cbe97b831377b23aa70 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:08:49 -0700 Subject: [PATCH 096/117] test(ios): cover superseded push intent --- .../MobilePushCoordinatorLifecycleTests.swift | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 29e0b5149a2..8f699ac0a60 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -525,6 +525,41 @@ private final class LifecyclePushURLProtocol: URLProtocol, await disabling.value } + @MainActor + @Test func latestSettingsIntentSupersedesStalledEnable() async { + let settingsGate = LifecycleSetEnabledGate() + let registration = LifecyclePushRegistration(enabled: false) + let suiteName = "push-coordinator-latest-intent-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + notificationSettings: { + await settingsGate.pause() + return .authorizationOnly(.authorized) + }, + requestAuthorization: { true } + ) + + let enabling = coordinator.setEnabledIntent(true) + await settingsGate.waitUntilStarted() + + let disabling = coordinator.setEnabledIntent(false) + #expect(!coordinator.isEnabled) + #expect( + defaults.object(forKey: "cmux.notifications.pushEnabled") as? Bool + == false + ) + + await disabling.value + await settingsGate.release() + await enabling.value + + #expect(!coordinator.isEnabled) + #expect(!(await registration.snapshot.isEnabled)) + } + @MainActor @Test func foregroundAndReachabilityRecoveryShareOneExhaustedRegistrationRetry() async { let gate = LifecycleSyncGate() From c9be5246a73a04beaad3866d5bafd2676ffb2d97 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:15:50 -0700 Subject: [PATCH 097/117] fix(ios): supersede stale push intents --- .../Push/PushRegistrationService.swift | 78 +++++++- .../PushRegistrationServiceTests.swift | 28 +++ .../MobilePushPreviewPendingMutation.swift | 7 - .../MobilePushReadinessPreviewView.swift | 36 +--- .../MobilePushCoordinator.swift | 184 +++++++++++++++--- .../CmuxMobileShellUI/MobilePushToggle.swift | 29 +-- .../MobileSettingsView.swift | 11 +- ios/cmuxUITests/PushReadinessUITests.swift | 19 +- 8 files changed, 286 insertions(+), 106 deletions(-) delete mode 100644 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushPreviewPendingMutation.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index ebcea86fa14..fe3f13ffc87 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -114,17 +114,26 @@ public actor PushRegistrationService: PushRegistering { } public func setEnabled(_ enabled: Bool) async { - let wasEnabled = isEnabled + // The UI commits the shared preference before crossing into this actor. + // Snapshot state therefore carries the prior service intent needed to + // decide whether an opt-out still owes backend cleanup. + let owesBackendCleanup = snapshotValue.isEnabled + || defaults.string(forKey: Self.registeredAccountIDKey) != nil cancelRetry() + let generation = operationGeneration defaults.set(enabled, forKey: Self.enabledKey) if enabled { await syncTokenIfPossible() } else { publish(.disabled) - if wasEnabled { - await unregisterFromServer() + if owesBackendCleanup { + await unregisterFromServer( + preferenceGeneration: generation + ) } else { - await retryPendingUnregisterIfPossible() + await retryPendingUnregisterIfPossible( + preferenceGeneration: generation + ) } } } @@ -186,12 +195,33 @@ public actor PushRegistrationService: PushRegistering { } public func unregisterFromServer() async { - cancelRetry() + await unregisterFromServer(preferenceGeneration: nil) + } + + private func unregisterFromServer( + preferenceGeneration: UUID? + ) async { + if preferenceGeneration == nil { + cancelRetry() + } guard let hex = cachedTokenHex else { return } - let session = try? await tokenProvider.authenticatedSessionSnapshot() - let ownerID = defaults.string( + let registeredOwnerID = defaults.string( forKey: Self.registeredAccountIDKey - ) ?? session?.accountID + ) + if let registeredOwnerID, !registeredOwnerID.isEmpty { + // Record the privacy cleanup before any authentication await. A + // stalled session restore must not lose an already-known owner. + persistPendingUnregister( + tokenHex: hex, + accountID: registeredOwnerID + ) + } + let session = try? await tokenProvider.authenticatedSessionSnapshot() + if let preferenceGeneration, + preferenceGeneration != operationGeneration || isEnabled { + return + } + let ownerID = registeredOwnerID ?? session?.accountID guard let ownerID, !ownerID.isEmpty else { return } // Persist before requiring live auth. This is the privacy guarantee for // an offline or signed-out opt-out. @@ -202,6 +232,15 @@ public actor PushRegistrationService: PushRegistering { if await sendDelete(tokenHex: hex, sessionSnapshot: session) { clearPendingUnregister(tokenHex: hex, accountID: ownerID) clearRegisteredOwner(accountID: ownerID, tokenHex: hex) + if let preferenceGeneration, + preferenceGeneration != operationGeneration || isEnabled, + isEnabled, + cachedTokenHex == hex { + // A newer enable may have posted while this older DELETE was + // already in flight. Re-upsert after the DELETE acknowledgement + // so the latest preference is also the final backend state. + await upload(tokenHex: hex) + } } } @@ -615,9 +654,15 @@ public actor PushRegistrationService: PushRegistering { } } - private func retryPendingUnregisterIfPossible() async { + private func retryPendingUnregisterIfPossible( + preferenceGeneration: UUID? = nil + ) async { guard let session = try? await tokenProvider .authenticatedSessionSnapshot() else { return } + if let preferenceGeneration, + preferenceGeneration != operationGeneration || isEnabled { + return + } let currentAccountID = session.accountID let matching = pendingUnregisters.filter { $0.accountID == currentAccountID @@ -656,6 +701,21 @@ public actor PushRegistrationService: PushRegistering { tokenHex: pending.tokenHex ) } + let preferenceWasSuperseded = preferenceGeneration.map { + $0 != operationGeneration || isEnabled + } ?? false + if preferenceWasSuperseded, + isEnabled, + let currentToken = cachedTokenHex, + results.contains(where: { + $0.0.tokenHex == currentToken && $0.1 + }) { + // A newer enable raced cleanup that was already sent. Restore the + // current token only after every acknowledged DELETE has finished. + await upload(tokenHex: currentToken) + return + } + guard !preferenceWasSuperseded else { return } if matching.count > batch.count, results.contains(where: { $0.1 }) { schedulePendingUnregisterContinuation() diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index acbaa31061f..3ebd378521b 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -848,6 +848,34 @@ actor RetryDelayRecorder { ) } + @Test func enablingDuringInFlightDisableRepostsAfterLateDelete() async { + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + await PushRegistrationURLProtocol.script.reset([ + .response(200), + .gatedResponse(200, started: started, blocker: blocker), + .response(200), + .response(200), + ]) + let (service, _) = makeScriptedService() + await service.register(deviceToken: Data([0xAA])) + await service.setEnabled(true) + + let disabling = Task { + await service.setEnabled(false) + } + await started.waitUntilStarted() + await service.setEnabled(true) + await blocker.release() + await disabling.value + + #expect( + await PushRegistrationURLProtocol.script.requests.map(\.httpMethod) + == ["POST", "DELETE", "POST", "POST"] + ) + #expect(await service.snapshot.backendState == .registered) + } + @Test func signOutDuringInFlightRegistrationDeletesAfterLatePost() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushPreviewPendingMutation.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushPreviewPendingMutation.swift deleted file mode 100644 index 3d77033c4ae..00000000000 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushPreviewPendingMutation.swift +++ /dev/null @@ -1,7 +0,0 @@ -#if os(iOS) && DEBUG -/// One deterministic phone-push mutation parked by the UI-test harness. -struct MobilePushPreviewPendingMutation { - let enabled: Bool - let continuation: CheckedContinuation -} -#endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift index aca7706e0eb..033c1fba827 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/Debug/MobilePushReadinessPreviewView.swift @@ -18,7 +18,7 @@ struct MobilePushReadinessPreviewView: View { @State private var authorization: MobilePushAuthorization @State private var registration: PushRegistrationSnapshot @State private var macStatus: MobileHostPhonePushStatus? - @State private var pendingPhoneMutation: MobilePushPreviewPendingMutation? + @State private var pendingPhoneMutation: Bool? init(state: String, environment: [String: String] = ProcessInfo.processInfo.environment) { let fixture = Fixture(rawValue: state) ?? .healthy @@ -42,7 +42,7 @@ struct MobilePushReadinessPreviewView: View { if delaysPhoneMutation { MobilePushToggle( isEnabled: $phoneEnabled, - resolveEnabledState: setPhoneEnabled + applyEnabledIntent: queuePhoneEnabled ) } else { MobilePushSettingsContent( @@ -70,7 +70,7 @@ struct MobilePushReadinessPreviewView: View { } .accessibilityIdentifier( "MobilePushReadinessCompletePhoneMutation-" - + (pendingPhoneMutation.enabled ? "on" : "off") + + (pendingPhoneMutation ? "on" : "off") ) } } @@ -81,13 +81,6 @@ struct MobilePushReadinessPreviewView: View { )) } .accessibilityIdentifier("MobilePushReadinessPreview") - .onDisappear { - guard let pendingPhoneMutation else { return } - self.pendingPhoneMutation = nil - pendingPhoneMutation.continuation.resume( - returning: registration.isEnabled - ) - } } private var readiness: MobilePushReadiness { @@ -102,31 +95,20 @@ struct MobilePushReadinessPreviewView: View { @MainActor private func setPhoneEnabled(_ enabled: Bool) async -> Bool { - if delaysPhoneMutation { - return await withCheckedContinuation { continuation in - if let pendingPhoneMutation { - pendingPhoneMutation.continuation.resume( - returning: registration.isEnabled - ) - } - pendingPhoneMutation = MobilePushPreviewPendingMutation( - enabled: enabled, - continuation: continuation - ) - } - } applyPhoneMutation(enabled) return enabled } + @MainActor + private func queuePhoneEnabled(_ enabled: Bool) { + pendingPhoneMutation = enabled + } + @MainActor private func completePhoneMutation() { guard let pendingPhoneMutation else { return } self.pendingPhoneMutation = nil - applyPhoneMutation(pendingPhoneMutation.enabled) - pendingPhoneMutation.continuation.resume( - returning: pendingPhoneMutation.enabled - ) + applyPhoneMutation(pendingPhoneMutation) } @MainActor diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 152974736a0..cb524f4dd6a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -45,6 +45,8 @@ public final class MobilePushCoordinator { private nonisolated(unsafe) let defaults: UserDefaults private static let enabledKey = "cmux.notifications.pushEnabled" private var enabledMirror: Bool + @ObservationIgnored private var settingsIntentGeneration: UInt64 = 0 + @ObservationIgnored private var settingsIntentTask: Task? /// Base APNs `aps.category` the web sets on non-replyable cmux terminal /// pushes (see `CMUX_APNS_CATEGORY` in `web/services/apns/payload.ts`). The @@ -191,6 +193,38 @@ public final class MobilePushCoordinator { /// Whether the user has opted into phone notifications (synchronous mirror). public var isEnabled: Bool { enabledMirror } + /// Commits a Settings toggle choice synchronously, then reconciles OS and + /// backend state for that generation. A later choice invalidates every + /// continuation of the older operation, so Settings never waits behind a + /// stalled permission or registration call. + @discardableResult + public func setEnabledIntent(_ enabled: Bool) -> Task { + settingsIntentTask?.cancel() + let generation = beginSettingsIntent(enabled) + let task = Task { @MainActor [weak self] in + guard let self else { return false } + let result: Bool + if enabled { + result = await self.reconcileEnable( + trigger: "settings_toggle", + generation: generation + ) + } else { + await self.reconcileDisable(generation: generation) + result = self.isCurrentSettingsIntent( + generation, + enabled: false + ) + } + if self.settingsIntentGeneration == generation { + self.settingsIntentTask = nil + } + return result + } + settingsIntentTask = task + return task + } + /// Point routing at the active store (called by the root view on appear). public func bind(store: CMUXMobileShellStore) { self.store = store @@ -254,16 +288,26 @@ public final class MobilePushCoordinator { /// and persist the flag. Returns whether authorization was granted. @discardableResult public func enable() async -> Bool { - await enable(trigger: "settings_toggle") + settingsIntentTask?.cancel() + settingsIntentTask = nil + let generation = beginSettingsIntent(true) + return await reconcileEnable( + trigger: "settings_toggle", + generation: generation + ) } /// Requests or recovers push only after the authenticated workspace shell /// is mounted. An explicit app opt-out remains authoritative. public func workspaceListDidBecomeVisible() async { + let initialSettingsGeneration = settingsIntentGeneration if defaults.object(forKey: Self.enabledKey) as? Bool == false { return } let settings = await notificationSettings() + guard settingsIntentGeneration == initialSettingsGeneration, + defaults.object(forKey: Self.enabledKey) as? Bool != false + else { return } apply(settings: settings) switch settings.authorization { case .authorized, .provisional, .ephemeral: @@ -278,17 +322,28 @@ public final class MobilePushCoordinator { guard !workspaceAuthorizationRequestInFlight else { return } workspaceAuthorizationRequestInFlight = true defer { workspaceAuthorizationRequestInFlight = false } - _ = await enable(trigger: "workspace_list") + settingsIntentTask?.cancel() + settingsIntentTask = nil + let generation = beginSettingsIntent(true) + _ = await reconcileEnable( + trigger: "workspace_list", + generation: generation + ) case .unsupported: break } } - private func enable(trigger: String) async -> Bool { + private func reconcileEnable( + trigger: String, + generation: UInt64 + ) async -> Bool { let priorSettings = await notificationSettings() + guard isCurrentSettingsIntent(generation, enabled: true) else { + return false + } apply(settings: priorSettings) let priorStatus = priorSettings.authorization - persistEnabledIntent() // Only an undetermined status produces a real OS prompt; gate the // "shown" event on it so a re-toggle of an already-decided status does // not log a phantom prompt. @@ -308,8 +363,14 @@ public final class MobilePushCoordinator { case .denied, .unsupported: granted = false } + guard isCurrentSettingsIntent(generation, enabled: true) else { + return false + } guard granted else { await refreshReadiness() + guard isCurrentSettingsIntent(generation, enabled: true) else { + return false + } diagnosticLog?.recordAppEvent(.pushAuthorizationDenied) analytics.capture("ios_push_optin_declined", [ "trigger": .string(trigger), @@ -318,29 +379,43 @@ public final class MobilePushCoordinator { return false } if priorStatus == .notDetermined { - apply(settings: await notificationSettings()) + let currentSettings = await notificationSettings() + guard isCurrentSettingsIntent(generation, enabled: true) else { + return false + } + apply(settings: currentSettings) } diagnosticLog?.recordAppEvent(.pushAuthorizationGranted) analytics.capture("ios_push_optin_granted", ["trigger": .string(trigger)]) - await activateRegistrationIfNeeded() - await recoverRegistrationIfNeeded() + await activateRegistrationIfNeeded(settingsGeneration: generation) + guard isCurrentSettingsIntent(generation, enabled: true) else { + return false + } + await recoverRegistrationIfNeeded(settingsGeneration: generation) + guard isCurrentSettingsIntent(generation, enabled: true) else { + return false + } return true } /// Opt out: stop receiving pushes and remove the token server-side. public func disable() async { - diagnosticLog?.recordAppEvent(.pushDisabled) - enabledMirror = false - registrationSnapshot = .disabled - hasRequestedRemoteRegistration = false - unregisterForRemoteNotifications() - // The production registration service owns this same persisted key - // and checks its previous value to decide whether server cleanup is - // required. Let it observe the prior `true` before mirroring the final - // preference here; writing `false` first would skip token removal. + settingsIntentTask?.cancel() + settingsIntentTask = nil + let generation = beginSettingsIntent(false) + await reconcileDisable(generation: generation) + } + + private func reconcileDisable(generation: UInt64) async { await registration.setEnabled(false) - defaults.set(false, forKey: Self.enabledKey) - registrationSnapshot = await registration.snapshot + guard isCurrentSettingsIntent(generation, enabled: false) else { + return + } + let snapshot = await registration.snapshot + guard isCurrentSettingsIntent(generation, enabled: false) else { + return + } + registrationSnapshot = snapshot } /// Hand a freshly-registered APNs token to the network layer. @@ -348,8 +423,10 @@ public final class MobilePushCoordinator { diagnosticLog?.recordAppEvent(.pushDeviceTokenReceived, count: token.count) diagnosticLog?.recordAppEvent(.pushBackendSyncStarted) await registration.register(deviceToken: token) - registrationSnapshot = await registration.snapshot - recordRegistrationOutcome(registrationSnapshot) + let snapshot = await registration.snapshot + guard snapshot.isEnabled == enabledMirror else { return } + registrationSnapshot = snapshot + recordRegistrationOutcome(snapshot) } /// Make the APNs callback failure visible without retaining Apple's @@ -360,7 +437,9 @@ public final class MobilePushCoordinator { failure: error.map(DiagnosticFailureKind.classify) ?? .unknown ) await registration.deviceTokenRegistrationFailed() - registrationSnapshot = await registration.snapshot + let snapshot = await registration.snapshot + guard snapshot.isEnabled == enabledMirror else { return } + registrationSnapshot = snapshot } /// User-triggered repair for a failed APNs token callback. @@ -374,8 +453,10 @@ public final class MobilePushCoordinator { public func syncTokenIfPossible() async { diagnosticLog?.recordAppEvent(.pushBackendSyncStarted) await registration.syncTokenIfPossible() - registrationSnapshot = await registration.snapshot - recordRegistrationOutcome(registrationSnapshot) + let snapshot = await registration.snapshot + guard snapshot.isEnabled == enabledMirror else { return } + registrationSnapshot = snapshot + recordRegistrationOutcome(snapshot) } /// Refreshes live OS authorization and the current registration stage. @@ -396,14 +477,43 @@ public final class MobilePushCoordinator { defaults.set(true, forKey: Self.enabledKey) } + private func beginSettingsIntent(_ enabled: Bool) -> UInt64 { + settingsIntentGeneration &+= 1 + if enabled { + persistEnabledIntent() + } else { + diagnosticLog?.recordAppEvent(.pushDisabled) + enabledMirror = false + defaults.set(false, forKey: Self.enabledKey) + registrationSnapshot = .disabled + hasRequestedRemoteRegistration = false + unregisterForRemoteNotifications() + } + return settingsIntentGeneration + } + + private func isCurrentSettingsIntent( + _ generation: UInt64, + enabled: Bool + ) -> Bool { + settingsIntentGeneration == generation && enabledMirror == enabled + } + private func apply(settings: MobilePushSystemSettings) { systemSettings = settings authorization = settings.authorization } - private func activateRegistrationIfNeeded() async { + private func activateRegistrationIfNeeded( + settingsGeneration: UInt64? = nil + ) async { guard enabledMirror, Self.permitsDelivery(authorization) else { return } let current = await registration.snapshot + guard enabledMirror, + settingsGeneration.map({ + isCurrentSettingsIntent($0, enabled: true) + }) ?? true + else { return } registrationSnapshot = PushRegistrationSnapshot( isEnabled: true, hasDeviceToken: current.hasDeviceToken, @@ -415,7 +525,18 @@ public final class MobilePushCoordinator { if !current.isEnabled { await registration.setEnabled(true) } - registrationSnapshot = await registration.snapshot + guard enabledMirror, + settingsGeneration.map({ + isCurrentSettingsIntent($0, enabled: true) + }) ?? true + else { return } + let snapshot = await registration.snapshot + guard enabledMirror, + settingsGeneration.map({ + isCurrentSettingsIntent($0, enabled: true) + }) ?? true + else { return } + registrationSnapshot = snapshot } private func requestRemoteRegistrationIfNeeded() { @@ -442,8 +563,14 @@ public final class MobilePushCoordinator { await recoverRegistrationIfNeeded() } - private func recoverRegistrationIfNeeded() async { + private func recoverRegistrationIfNeeded( + settingsGeneration: UInt64? = nil + ) async { let current = await registration.snapshot + guard settingsGeneration.map({ + isCurrentSettingsIntent($0, enabled: true) + }) ?? true else { return } + guard current.isEnabled == enabledMirror else { return } registrationSnapshot = current guard current.isEnabled, current.hasDeviceToken, current.backendState == .registrationRequired @@ -468,6 +595,10 @@ public final class MobilePushCoordinator { if ownsRecovery { registrationRecoveryTask = nil } + guard settingsGeneration.map({ + isCurrentSettingsIntent($0, enabled: true) + }) ?? true else { return } + guard recovered.isEnabled == enabledMirror else { return } registrationSnapshot = recovered recordRegistrationOutcome(recovered) } @@ -542,6 +673,7 @@ public final class MobilePushCoordinator { let snapshots = await registration.snapshots() for await snapshot in snapshots { guard !Task.isCancelled, let self else { return } + guard snapshot.isEnabled == self.enabledMirror else { continue } self.registrationSnapshot = snapshot } } diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift index e1c3e0ff585..cbd6ba15feb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushToggle.swift @@ -5,10 +5,8 @@ import SwiftUI /// Release phone-push toggle, shared with its diagnostic test harness. struct MobilePushToggle: View { @Binding var isEnabled: Bool - /// Applies an intent and returns the resulting enabled state. - let resolveEnabledState: @MainActor (Bool) async -> Bool - @State private var mutationTask: Task? - @State private var pendingRequest: Bool? + /// Commits an app-lifetime intent after the binding changes immediately. + let applyEnabledIntent: @MainActor (Bool) -> Void var body: some View { Toggle( @@ -26,30 +24,9 @@ struct MobilePushToggle: View { get: { isEnabled }, set: { requested in isEnabled = requested - pendingRequest = requested - startMutationWorkerIfNeeded() + applyEnabledIntent(requested) } ) } - - @MainActor - private func startMutationWorkerIfNeeded() { - guard mutationTask == nil else { return } - mutationTask = Task { @MainActor in - while let requested = pendingRequest { - pendingRequest = nil - let resolved = await resolveEnabledState(requested) - if pendingRequest == resolved { - pendingRequest = nil - } - if let pendingRequest { - isEnabled = pendingRequest - } else { - isEnabled = resolved - } - } - mutationTask = nil - } - } } #endif diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift index abe8282f7a5..4611aa1d19b 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobileSettingsView.swift @@ -423,7 +423,7 @@ struct MobileSettingsView: View { #else MobilePushToggle( isEnabled: $notificationsEnabled, - resolveEnabledState: updatePhonePushEnabled + applyEnabledIntent: setPhonePushEnabledIntent ) #endif } @@ -614,6 +614,15 @@ struct MobileSettingsView: View { } } + @MainActor + private func setPhonePushEnabledIntent(_ enabled: Bool) { + diagnosticLog?.recordAppEvent( + .notificationPreferenceChanged, + count: enabled ? 1 : 0 + ) + pushCoordinator.setEnabledIntent(enabled) + } + @MainActor private func updatePhonePushEnabled(_ enabled: Bool) async -> Bool { diagnosticLog?.recordAppEvent( diff --git a/ios/cmuxUITests/PushReadinessUITests.swift b/ios/cmuxUITests/PushReadinessUITests.swift index b41cb8c6dea..90782634a79 100644 --- a/ios/cmuxUITests/PushReadinessUITests.swift +++ b/ios/cmuxUITests/PushReadinessUITests.swift @@ -116,7 +116,7 @@ final class PushReadinessUITests: XCTestCase { } @MainActor - func testPhonePushToggleUpdatesImmediatelyAndSerializesMutations() { + func testPhonePushToggleUpdatesImmediatelyAndKeepsLatestIntent() { let app = launchPreview( "healthy", extraEnvironment: ["CMUX_UITEST_PUSH_PHONE_MUTATION_DELAY": "1"] @@ -142,24 +142,23 @@ final class PushReadinessUITests: XCTestCase { phone, "1", timeout: 1, - message: "The toggle must reflect the latest queued intent" + message: "The toggle must reflect the latest intent" ) - let completeDisable = app.buttons[ - "MobilePushReadinessCompletePhoneMutation-off" - ] - XCTAssertTrue(completeDisable.waitForExistence(timeout: 2)) - completeDisable.tap() - let completeEnable = app.buttons[ "MobilePushReadinessCompletePhoneMutation-on" ] XCTAssertTrue(completeEnable.waitForExistence(timeout: 2)) + XCTAssertFalse( + app.buttons["MobilePushReadinessCompletePhoneMutation-off"] + .exists, + "A later intent must replace pending work from the older choice" + ) waitForValue( phone, "1", timeout: 1, - message: "Completed stale work must not replace the queued intent" + message: "Pending work must not replace the latest intent" ) completeEnable.tap() waitForValue(phone, "1") @@ -174,7 +173,7 @@ final class PushReadinessUITests: XCTestCase { waitForValue( phone, "0", - message: "A resolved false value is a successful opt-out" + message: "Completed cleanup must preserve the opt-out" ) } From 3607cc8f69e8d608814f89bc3e8cf7cca9163b21 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:21:29 -0700 Subject: [PATCH 098/117] fix(ios): reconcile started push mutations --- .../MobilePushCoordinator.swift | 30 +++++-------------- 1 file changed, 7 insertions(+), 23 deletions(-) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index cb524f4dd6a..4c632311bac 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -46,7 +46,6 @@ public final class MobilePushCoordinator { private static let enabledKey = "cmux.notifications.pushEnabled" private var enabledMirror: Bool @ObservationIgnored private var settingsIntentGeneration: UInt64 = 0 - @ObservationIgnored private var settingsIntentTask: Task? /// Base APNs `aps.category` the web sets on non-replyable cmux terminal /// pushes (see `CMUX_APNS_CATEGORY` in `web/services/apns/payload.ts`). The @@ -199,30 +198,21 @@ public final class MobilePushCoordinator { /// stalled permission or registration call. @discardableResult public func setEnabledIntent(_ enabled: Bool) -> Task { - settingsIntentTask?.cancel() let generation = beginSettingsIntent(enabled) - let task = Task { @MainActor [weak self] in + return Task { @MainActor [weak self] in guard let self else { return false } - let result: Bool if enabled { - result = await self.reconcileEnable( + return await self.reconcileEnable( trigger: "settings_toggle", generation: generation ) - } else { - await self.reconcileDisable(generation: generation) - result = self.isCurrentSettingsIntent( - generation, - enabled: false - ) } - if self.settingsIntentGeneration == generation { - self.settingsIntentTask = nil - } - return result + await self.reconcileDisable(generation: generation) + return self.isCurrentSettingsIntent( + generation, + enabled: false + ) } - settingsIntentTask = task - return task } /// Point routing at the active store (called by the root view on appear). @@ -288,8 +278,6 @@ public final class MobilePushCoordinator { /// and persist the flag. Returns whether authorization was granted. @discardableResult public func enable() async -> Bool { - settingsIntentTask?.cancel() - settingsIntentTask = nil let generation = beginSettingsIntent(true) return await reconcileEnable( trigger: "settings_toggle", @@ -322,8 +310,6 @@ public final class MobilePushCoordinator { guard !workspaceAuthorizationRequestInFlight else { return } workspaceAuthorizationRequestInFlight = true defer { workspaceAuthorizationRequestInFlight = false } - settingsIntentTask?.cancel() - settingsIntentTask = nil let generation = beginSettingsIntent(true) _ = await reconcileEnable( trigger: "workspace_list", @@ -400,8 +386,6 @@ public final class MobilePushCoordinator { /// Opt out: stop receiving pushes and remove the token server-side. public func disable() async { - settingsIntentTask?.cancel() - settingsIntentTask = nil let generation = beginSettingsIntent(false) await reconcileDisable(generation: generation) } From 1fa42306cfd5153f96b50a3d9cace126dfb3042a Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:37:40 -0700 Subject: [PATCH 099/117] refactor(push): own intent reconciliation --- .../Push/PushRegistering.swift | 4 + .../Push/PushRegistrationService.swift | 130 +++++++++++++++++- .../PushRegistrationServiceTests.swift | 46 +++++++ .../MobilePushCoordinator.swift | 63 +++++++-- .../MobilePushCoordinatorLifecycleTests.swift | 11 ++ .../cmuxFeatureTests/cmuxFeatureTests.swift | 1 + 6 files changed, 236 insertions(+), 19 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift index 96281023279..9413d9938c4 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift @@ -21,6 +21,10 @@ public protocol PushRegistering: Sendable { /// removing it server-side on disable. func setEnabled(_ enabled: Bool) async + /// Commits a coordinator-owned preference in generation order and queues + /// its backend reconciliation without tying that work to the caller task. + func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async + /// Cache and (when opted in) upload a freshly registered APNs device token. func register(deviceToken: Data) async diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index fe3f13ffc87..f7b085e4fb8 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -27,6 +27,12 @@ public actor PushRegistrationService: PushRegistering { private let retrySleep: @Sendable (Duration) async throws -> Void private var retryTask: Task? private var unregisterDrainTask: Task? + /// One app-lifetime worker owns coordinator-triggered POST/DELETE work. + /// New toggle intents only replace its desired state. + private var intentReconciliationTask: Task? + private var intentReconciliationRequested = false + private var coordinatorIntentGeneration: UInt64 = 0 + private var coordinatorIntentEnabled: Bool? private var operationGeneration = UUID() private var snapshotValue: PushRegistrationSnapshot private var snapshotContinuations: @@ -97,6 +103,21 @@ public actor PushRegistrationService: PushRegistering { ? (hasToken ? .registrationRequired : .awaitingDeviceToken) : .awaitingDeviceToken ) + if !enabled, + let tokenHex = self.defaults.string(forKey: Self.cachedTokenKey), + !tokenHex.isEmpty, + let accountID = self.defaults.string( + forKey: Self.registeredAccountIDKey + ), + !accountID.isEmpty { + // The app can terminate after the coordinator persists opt-out but + // before its actor hop arrives. Reconstruct that cleanup on launch. + Self.persistPendingUnregister( + tokenHex: tokenHex, + accountID: accountID, + in: self.defaults + ) + } } public var isEnabled: Bool { defaults.bool(forKey: Self.enabledKey) } @@ -104,6 +125,11 @@ public actor PushRegistrationService: PushRegistering { public func snapshots() -> AsyncStream { let id = UUID() + if !isEnabled, !pendingUnregisters.isEmpty { + coordinatorIntentEnabled = false + intentReconciliationRequested = true + scheduleIntentReconciliation() + } return AsyncStream { continuation in snapshotContinuations[id] = continuation continuation.yield(snapshotValue) @@ -138,6 +164,81 @@ public actor PushRegistrationService: PushRegistering { } } + public func applyEnabledIntent( + _ enabled: Bool, + generation: UInt64 + ) async { + guard generation >= coordinatorIntentGeneration else { return } + if generation == coordinatorIntentGeneration, + coordinatorIntentEnabled == enabled { + return + } + coordinatorIntentGeneration = generation + coordinatorIntentEnabled = enabled + cancelRetry() + defaults.set(enabled, forKey: Self.enabledKey) + if enabled { + let hasToken = cachedTokenHex != nil + publish(PushRegistrationSnapshot( + isEnabled: true, + hasDeviceToken: hasToken, + backendState: hasToken + ? .registrationRequired + : .awaitingDeviceToken + )) + } else { + if let tokenHex = cachedTokenHex, + let accountID = defaults.string( + forKey: Self.registeredAccountIDKey + ), + !accountID.isEmpty { + // Persist the cleanup before the worker can suspend on auth. + persistPendingUnregister( + tokenHex: tokenHex, + accountID: accountID + ) + } + publish(.disabled) + } + intentReconciliationRequested = true + scheduleIntentReconciliation() + } + + private func scheduleIntentReconciliation() { + guard intentReconciliationTask == nil else { return } + intentReconciliationTask = Task { [weak self] in + await self?.drainIntentReconciliation() + } + } + + private func drainIntentReconciliation() async { + while intentReconciliationRequested { + intentReconciliationRequested = false + guard let enabled = coordinatorIntentEnabled else { break } + let generation = coordinatorIntentGeneration + if enabled { + await syncTokenIfPossible() + } else { + let preferenceGeneration = operationGeneration + await unregisterFromServer( + preferenceGeneration: preferenceGeneration + ) + await retryPendingUnregisterIfPossible( + preferenceGeneration: preferenceGeneration + ) + } + guard generation == coordinatorIntentGeneration, + enabled == coordinatorIntentEnabled else { continue } + if !enabled { + publish(.disabled) + } + } + intentReconciliationTask = nil + if intentReconciliationRequested { + scheduleIntentReconciliation() + } + } + public func register(deviceToken: Data) async { let hex = deviceToken.map { String(format: "%02x", $0) }.joined() let previousToken = cachedTokenHex @@ -723,15 +824,34 @@ public actor PushRegistrationService: PushRegistering { } private func persistPendingUnregister(tokenHex: String, accountID: String) { + Self.persistPendingUnregister( + tokenHex: tokenHex, + accountID: accountID, + in: defaults + ) + } + + private static func persistPendingUnregister( + tokenHex: String, + accountID: String, + in defaults: UserDefaults + ) { let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID) - var queue = pendingUnregisters - if !queue.contains(entry) { - queue.append(entry) - } + var queue = (defaults.data(forKey: pendingUnregisterQueueKey) + .flatMap { + try? JSONDecoder().decode([PendingUnregister].self, from: $0) + }) ?? [] + var seen = Set() + queue = queue.filter { seen.insert($0).inserted } + if !queue.contains(entry) { queue.append(entry) } // Never evict a privacy cleanup obligation merely to enforce a local // storage cap. The set is deduplicated by (account, token), and drains // in bounded network batches so size cannot stall current readiness. - storePendingUnregisters(queue) + if let data = try? JSONEncoder().encode(queue) { + defaults.set(data, forKey: pendingUnregisterQueueKey) + } + defaults.removeObject(forKey: pendingUnregisterTokenKey) + defaults.removeObject(forKey: pendingUnregisterAccountIDKey) } private func schedulePendingUnregisterContinuation() { diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 3ebd378521b..737cb3e5b4a 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -742,6 +742,28 @@ actor RetryDelayRecorder { ) } + @Test func relaunchRecoversOptOutCommittedBeforeServiceHandoff() async { + await PushRegistrationURLProtocol.script.reset([.response(200)]) + let (service, _) = makeScriptedService( + seedDefaults: { defaults in + defaults.set(false, forKey: "cmux.notifications.pushEnabled") + defaults.set("ab", forKey: "cmux.notifications.deviceTokenHex") + defaults.set( + "push-user-1", + forKey: "cmux.notifications.registeredAccountID" + ) + } + ) + + _ = await service.snapshots() + await PushRegistrationURLProtocol.script.waitForRequestCount(1) + + #expect( + await PushRegistrationURLProtocol.script.requests.map(\.httpMethod) + == ["DELETE"] + ) + } + @Test func accountBOwnedOptOutNeverDeletesAccountATokenWithBCredentials() async { await PushRegistrationURLProtocol.script.reset([.response(200)]) let suite = "push-optout-owner-mismatch-\(UUID().uuidString)" @@ -876,6 +898,30 @@ actor RetryDelayRecorder { #expect(await service.snapshot.backendState == .registered) } + @Test func coordinatorIntentWorkerDrainsLatestOptOutAfterLatePost() async { + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + await PushRegistrationURLProtocol.script.reset([ + .gatedResponse(200, started: started, blocker: blocker), + .response(200), + .response(200), + ]) + let (service, _) = makeScriptedService() + await service.register(deviceToken: Data([0xAA])) + + await service.applyEnabledIntent(true, generation: 1) + await started.waitUntilStarted() + await service.applyEnabledIntent(false, generation: 2) + await blocker.release() + await PushRegistrationURLProtocol.script.waitForRequestCount(3) + + #expect( + await PushRegistrationURLProtocol.script.requests.map(\.httpMethod) + == ["POST", "DELETE", "DELETE"] + ) + #expect(await service.snapshot == .disabled) + } + @Test func signOutDuringInFlightRegistrationDeletesAfterLatePost() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 4c632311bac..5878a7d00bb 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -46,6 +46,7 @@ public final class MobilePushCoordinator { private static let enabledKey = "cmux.notifications.pushEnabled" private var enabledMirror: Bool @ObservationIgnored private var settingsIntentGeneration: UInt64 = 0 + @ObservationIgnored private var settingsIntentTask: Task? /// Base APNs `aps.category` the web sets on non-replyable cmux terminal /// pushes (see `CMUX_APNS_CATEGORY` in `web/services/apns/payload.ts`). The @@ -198,21 +199,36 @@ public final class MobilePushCoordinator { /// stalled permission or registration call. @discardableResult public func setEnabledIntent(_ enabled: Bool) -> Task { + settingsIntentTask?.cancel() let generation = beginSettingsIntent(enabled) - return Task { @MainActor [weak self] in + let task = Task { @MainActor [weak self] in guard let self else { return false } + await self.registration.applyEnabledIntent( + enabled, + generation: generation + ) + guard !Task.isCancelled, + self.isCurrentSettingsIntent( + generation, + enabled: enabled + ) else { return false } + let result: Bool if enabled { - return await self.reconcileEnable( + result = await self.reconcileEnable( trigger: "settings_toggle", - generation: generation + generation: generation, + registrationIntentOwnedByService: true ) + } else { + result = true } - await self.reconcileDisable(generation: generation) - return self.isCurrentSettingsIntent( - generation, - enabled: false - ) + if self.settingsIntentGeneration == generation { + self.settingsIntentTask = nil + } + return result } + settingsIntentTask = task + return task } /// Point routing at the active store (called by the root view on appear). @@ -322,9 +338,19 @@ public final class MobilePushCoordinator { private func reconcileEnable( trigger: String, - generation: UInt64 + generation: UInt64, + registrationIntentOwnedByService: Bool = false ) async -> Bool { - let priorSettings = await notificationSettings() + // Settings already refreshes this snapshot when it appears. Reuse it + // for its toggle path so repeated taps cannot accumulate behind an + // arbitrary settings read. The only long suspension left is Apple's + // single system authorization prompt, which blocks further app taps. + let priorSettings: MobilePushSystemSettings + if registrationIntentOwnedByService { + priorSettings = systemSettings + } else { + priorSettings = await notificationSettings() + } guard isCurrentSettingsIntent(generation, enabled: true) else { return false } @@ -373,10 +399,16 @@ public final class MobilePushCoordinator { } diagnosticLog?.recordAppEvent(.pushAuthorizationGranted) analytics.capture("ios_push_optin_granted", ["trigger": .string(trigger)]) - await activateRegistrationIfNeeded(settingsGeneration: generation) + await activateRegistrationIfNeeded( + settingsGeneration: generation, + reconcilePreference: !registrationIntentOwnedByService + ) guard isCurrentSettingsIntent(generation, enabled: true) else { return false } + if registrationIntentOwnedByService { + return true + } await recoverRegistrationIfNeeded(settingsGeneration: generation) guard isCurrentSettingsIntent(generation, enabled: true) else { return false @@ -489,7 +521,8 @@ public final class MobilePushCoordinator { } private func activateRegistrationIfNeeded( - settingsGeneration: UInt64? = nil + settingsGeneration: UInt64? = nil, + reconcilePreference: Bool = true ) async { guard enabledMirror, Self.permitsDelivery(authorization) else { return } let current = await registration.snapshot @@ -506,7 +539,7 @@ public final class MobilePushCoordinator { : .awaitingDeviceToken ) requestRemoteRegistrationIfNeeded() - if !current.isEnabled { + if reconcilePreference, !current.isEnabled { await registration.setEnabled(true) } guard enabledMirror, @@ -520,7 +553,9 @@ public final class MobilePushCoordinator { isCurrentSettingsIntent($0, enabled: true) }) ?? true else { return } - registrationSnapshot = snapshot + if snapshot.isEnabled == enabledMirror { + registrationSnapshot = snapshot + } } private func requestRemoteRegistrationIfNeeded() { diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 8f699ac0a60..99415bc1d29 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -7,6 +7,7 @@ import UserNotifications private actor LifecyclePushRegistration: PushRegistering { private var value: PushRegistrationSnapshot + private var intentGeneration: UInt64 = 0 private let setEnabledGate: LifecycleSetEnabledGate? private let syncGate: LifecycleSyncGate? @@ -39,6 +40,16 @@ private actor LifecyclePushRegistration: PushRegistering { func setEnabled(_ enabled: Bool) async { await setEnabledGate?.pause() + apply(enabled) + } + + func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async { + guard generation >= intentGeneration else { return } + intentGeneration = generation + apply(enabled) + } + + private func apply(_ enabled: Bool) { value = enabled ? PushRegistrationSnapshot( isEnabled: true, diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index b5e76adf17e..14e7d157aeb 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -4116,6 +4116,7 @@ struct InertPushRegistration: PushRegistering { } } func setEnabled(_ enabled: Bool) async {} + func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async {} func register(deviceToken: Data) async {} func deviceTokenRegistrationFailed() async {} func syncTokenIfPossible() async {} From 9800bddc17b7cec5f12de59da2d5c506d2901440 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:52:47 -0700 Subject: [PATCH 100/117] test(push): require immediate opt-out cleanup --- .../PushRegistrationServiceTests.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 737cb3e5b4a..8d1f5d2bc60 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -912,6 +912,19 @@ actor RetryDelayRecorder { await service.applyEnabledIntent(true, generation: 1) await started.waitUntilStarted() await service.applyEnabledIntent(false, generation: 2) + + // Opt-out cleanup must start while the superseded POST is still + // parked. Waiting for that POST could leave the backend token active + // indefinitely even though the UI already reports notifications off. + #expect( + await PushRegistrationURLProtocol.script.waitForRequestCount(2) + ) + #expect( + await PushRegistrationURLProtocol.script.requests.map(\.httpMethod) + == ["POST", "DELETE"] + ) + #expect(await service.snapshot == .disabled) + await blocker.release() await PushRegistrationURLProtocol.script.waitForRequestCount(3) From d34c247866ab99c16a4834dcf1e24b31d234dfdb Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 02:54:04 -0700 Subject: [PATCH 101/117] fix(push): let opt-outs bypass stale work --- .../Coordinator/AuthPhase.swift | 2 + .../Push/PushRegistrationService.swift | 124 ++++++++++++------ .../PushRegistrationServiceTests.swift | 90 ++++++++++++- 3 files changed, 174 insertions(+), 42 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthPhase.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthPhase.swift index 602513bafbe..e21b936ae13 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthPhase.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthPhase.swift @@ -14,4 +14,6 @@ enum AuthPhase: String, Sendable, Hashable { case listTeams = "list_teams" case postSignIn = "post_sign_in" case accountDeletion = "account_deletion" + case pushRegistrationSession = "push_registration_session" + case pushUnregistrationSession = "push_unregistration_session" } diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index f7b085e4fb8..62a2d10e0e3 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -25,12 +25,19 @@ public actor PushRegistrationService: PushRegistering { private let retryDelays: [Duration] private let retryJitter: @Sendable (ClosedRange) -> Double private let retrySleep: @Sendable (Duration) async throws -> Void + private let sessionSnapshotTimeout: Duration + private let sessionSnapshotClock: any Clock + private let sessionSnapshotTimeoutRegistry = AuthPhaseTimeoutRegistry() + private let authLog = AuthDebugLog() private var retryTask: Task? private var unregisterDrainTask: Task? - /// One app-lifetime worker owns coordinator-triggered POST/DELETE work. - /// New toggle intents only replace its desired state. - private var intentReconciliationTask: Task? - private var intentReconciliationRequested = false + /// App-lifetime, direction-owned workers let a privacy-sensitive opt-out + /// proceed while an older registration request is still in flight. One + /// stored task per direction bounds concurrency during rapid toggling. + private var enableIntentReconciliationTask: Task? + private var disableIntentReconciliationTask: Task? + private var enableIntentReconciliationRequested = false + private var disableIntentReconciliationRequested = false private var coordinatorIntentGeneration: UInt64 = 0 private var coordinatorIntentEnabled: Bool? private var operationGeneration = UUID() @@ -78,7 +85,9 @@ public actor PushRegistrationService: PushRegistering { }, retrySleep: @escaping @Sendable (Duration) async throws -> Void = { try await ContinuousClock().sleep(for: $0) - } + }, + sessionSnapshotTimeout: Duration = .seconds(15), + sessionSnapshotClock: any Clock = ContinuousClock() ) { self.tokenProvider = tokenProvider self.apiBaseURL = apiBaseURL @@ -94,6 +103,8 @@ public actor PushRegistrationService: PushRegistering { self.retryDelays = retryDelays self.retryJitter = retryJitter self.retrySleep = retrySleep + self.sessionSnapshotTimeout = sessionSnapshotTimeout + self.sessionSnapshotClock = sessionSnapshotClock let enabled = self.defaults.bool(forKey: Self.enabledKey) let hasToken = self.defaults.string(forKey: Self.cachedTokenKey)?.isEmpty == false self.snapshotValue = PushRegistrationSnapshot( @@ -127,8 +138,8 @@ public actor PushRegistrationService: PushRegistering { let id = UUID() if !isEnabled, !pendingUnregisters.isEmpty { coordinatorIntentEnabled = false - intentReconciliationRequested = true - scheduleIntentReconciliation() + disableIntentReconciliationRequested = true + scheduleDisableIntentReconciliation() } return AsyncStream { continuation in snapshotContinuations[id] = continuation @@ -200,42 +211,60 @@ public actor PushRegistrationService: PushRegistering { } publish(.disabled) } - intentReconciliationRequested = true - scheduleIntentReconciliation() + if enabled { + enableIntentReconciliationRequested = true + scheduleEnableIntentReconciliation() + } else { + disableIntentReconciliationRequested = true + scheduleDisableIntentReconciliation() + } + } + + private func scheduleEnableIntentReconciliation() { + guard enableIntentReconciliationTask == nil else { return } + enableIntentReconciliationTask = Task { [weak self] in + await self?.drainEnableIntentReconciliation() + } + } + + private func drainEnableIntentReconciliation() async { + while enableIntentReconciliationRequested { + enableIntentReconciliationRequested = false + guard coordinatorIntentEnabled == true else { continue } + await syncTokenIfPossible() + } + enableIntentReconciliationTask = nil + if enableIntentReconciliationRequested { + scheduleEnableIntentReconciliation() + } } - private func scheduleIntentReconciliation() { - guard intentReconciliationTask == nil else { return } - intentReconciliationTask = Task { [weak self] in - await self?.drainIntentReconciliation() + private func scheduleDisableIntentReconciliation() { + guard disableIntentReconciliationTask == nil else { return } + disableIntentReconciliationTask = Task { [weak self] in + await self?.drainDisableIntentReconciliation() } } - private func drainIntentReconciliation() async { - while intentReconciliationRequested { - intentReconciliationRequested = false - guard let enabled = coordinatorIntentEnabled else { break } + private func drainDisableIntentReconciliation() async { + while disableIntentReconciliationRequested { + disableIntentReconciliationRequested = false + guard coordinatorIntentEnabled == false else { continue } let generation = coordinatorIntentGeneration - if enabled { - await syncTokenIfPossible() - } else { - let preferenceGeneration = operationGeneration - await unregisterFromServer( - preferenceGeneration: preferenceGeneration - ) - await retryPendingUnregisterIfPossible( - preferenceGeneration: preferenceGeneration - ) - } + let preferenceGeneration = operationGeneration + await unregisterFromServer( + preferenceGeneration: preferenceGeneration + ) + await retryPendingUnregisterIfPossible( + preferenceGeneration: preferenceGeneration + ) guard generation == coordinatorIntentGeneration, - enabled == coordinatorIntentEnabled else { continue } - if !enabled { - publish(.disabled) - } + coordinatorIntentEnabled == false else { continue } + publish(.disabled) } - intentReconciliationTask = nil - if intentReconciliationRequested { - scheduleIntentReconciliation() + disableIntentReconciliationTask = nil + if disableIntentReconciliationRequested { + scheduleDisableIntentReconciliation() } } @@ -448,7 +477,8 @@ public actor PushRegistrationService: PushRegistering { "bundleId": bundleID, "environment": apnsEnvironment, "platform": "ios", - ] + ], + authPhase: .pushRegistrationSession ) let result: RegistrationResult let requestSession: AuthenticatedSessionSnapshot? @@ -635,7 +665,8 @@ public actor PushRegistrationService: PushRegistering { body: ["deviceToken": tokenHex], capturedAccessToken: capturedAccessToken, capturedRefreshToken: capturedRefreshToken, - sessionSnapshot: sessionSnapshot + sessionSnapshot: sessionSnapshot, + authPhase: .pushUnregistrationSession ) else { return false } guard await performDelete(context.request) else { return false } if let session = context.session { @@ -650,7 +681,8 @@ public actor PushRegistrationService: PushRegistering { body: [String: String], capturedAccessToken: String? = nil, capturedRefreshToken: String? = nil, - sessionSnapshot: AuthenticatedSessionSnapshot? = nil + sessionSnapshot: AuthenticatedSessionSnapshot? = nil, + authPhase: AuthPhase ) async -> Result { let accessToken: String let refreshToken: String @@ -667,8 +699,20 @@ public actor PushRegistrationService: PushRegistering { authenticatedSession = nil } else { do { - let session = try await tokenProvider - .authenticatedSessionSnapshot() + let tokenProvider = tokenProvider + let session = try await withAuthPhaseTimeout( + authPhase, + duration: sessionSnapshotTimeout, + clock: sessionSnapshotClock, + log: authLog, + registry: sessionSnapshotTimeoutRegistry, + blocksRetriesWhileTimedOutOperationActive: true + ) { + // This provider API only reads a coherent stored token + // pair or awaits bounded launch bootstrap. Cancelling it + // cannot leave an ambiguous server mutation behind. + try await tokenProvider.authenticatedSessionSnapshot() + } accessToken = session.accessToken refreshToken = session.refreshToken authenticatedSession = session diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 8d1f5d2bc60..d83d58db8a1 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -141,6 +141,44 @@ actor MutablePushTokenProvider: TokenProviding { } } +actor CancellationIgnoringPushTokenProvider: TokenProviding { + private let snapshotValue = AuthenticatedSessionSnapshot( + generation: 1, + accountID: "push-user-1", + accessToken: "access", + refreshToken: "refresh" + ) + private let started: TestPhaseSignal + private let blocker: TestContinuationBlocker + private(set) var snapshotRequestCount = 0 + + init(started: TestPhaseSignal, blocker: TestContinuationBlocker) { + self.started = started + self.blocker = blocker + } + + func authenticatedSessionSnapshot() async throws + -> AuthenticatedSessionSnapshot { + snapshotRequestCount += 1 + await started.markStarted() + await blocker.wait() + return snapshotValue + } + + func isAuthenticatedSessionCurrent( + _ snapshot: AuthenticatedSessionSnapshot + ) async -> Bool { + snapshot == snapshotValue + } + + func accessToken() async throws -> String { snapshotValue.accessToken } + func storedAccessToken() async -> String? { snapshotValue.accessToken } + func refreshToken() async -> String? { snapshotValue.refreshToken } + func forceRefreshAccessToken() async throws -> String { + snapshotValue.accessToken + } +} + actor RetryDelayRecorder { private(set) var values: [Duration] = [] @@ -185,7 +223,9 @@ actor RetryDelayRecorder { seedDefaults: (UserDefaults) -> Void = { _ in }, retrySleep: @escaping @Sendable (Duration) async throws -> Void = { try await ContinuousClock().sleep(for: $0) - } + }, + sessionSnapshotTimeout: Duration = .seconds(15), + sessionSnapshotClock: any Clock = ContinuousClock() ) -> (PushRegistrationService, UserDefaults) { let defaults = UserDefaults(suiteName: suite)! seedDefaults(defaults) @@ -212,7 +252,9 @@ actor RetryDelayRecorder { session: URLSession(configuration: configuration), retryDelays: retryDelays, retryJitter: { _ in 1 }, - retrySleep: retrySleep + retrySleep: retrySleep, + sessionSnapshotTimeout: sessionSnapshotTimeout, + sessionSnapshotClock: sessionSnapshotClock ) return (service, defaults) } @@ -935,6 +977,50 @@ actor RetryDelayRecorder { #expect(await service.snapshot == .disabled) } + @Test func coordinatorIntentAuthenticationHasBoundedSingleAttempt() async { + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + let provider = CancellationIgnoringPushTokenProvider( + started: started, + blocker: blocker + ) + let clock = ManualTestClock() + let timeout = Duration.seconds(2) + let (service, _) = makeScriptedService( + tokenProvider: provider, + accountID: nil, + sessionSnapshotTimeout: timeout, + sessionSnapshotClock: clock + ) + await service.register(deviceToken: Data([0xAA])) + + await service.applyEnabledIntent(true, generation: 1) + await started.waitUntilStarted() + await clock.waitUntilSleepers() + clock.advance(by: timeout) + + #expect( + await wait( + for: .failed(.authenticationRequired), + from: service + ) + ) + + // The timed-out provider deliberately ignores cancellation. A newer + // enable intent fails against the active phase instead of accumulating + // another unowned task behind it. + await service.applyEnabledIntent(true, generation: 2) + #expect( + await wait( + for: .failed(.authenticationRequired), + from: service + ) + ) + #expect(await provider.snapshotRequestCount == 1) + + await blocker.release() + } + @Test func signOutDuringInFlightRegistrationDeletesAfterLatePost() async { let started = TestPhaseSignal() let blocker = TestContinuationBlocker() From d9a4ea5747eb7456ebafdc883c8de707f0a30e0e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:08:06 -0700 Subject: [PATCH 102/117] fix(push): authorize before backend reconciliation --- .../Push/PushRegistering.swift | 8 +- .../Push/PushRegistrationService.swift | 131 ++++++++---------- ...ancellationIgnoringPushTokenProvider.swift | 56 ++++++++ .../PushRegistrationServiceTests.swift | 89 +++++++----- .../MobilePushCoordinator.swift | 9 ++ .../MobilePushCoordinatorLifecycleTests.swift | 25 ++++ .../cmuxFeatureTests/cmuxFeatureTests.swift | 1 + 7 files changed, 209 insertions(+), 110 deletions(-) create mode 100644 Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/CancellationIgnoringPushTokenProvider.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift index 9413d9938c4..bacb4260382 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift @@ -22,9 +22,15 @@ public protocol PushRegistering: Sendable { func setEnabled(_ enabled: Bool) async /// Commits a coordinator-owned preference in generation order and queues - /// its backend reconciliation without tying that work to the caller task. + /// opt-out cleanup without tying that work to the caller task. Enabling is + /// persisted here but must wait for ``reconcileEnabledIntent(generation:)`` + /// after iOS notification authorization succeeds. func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async + /// Starts backend registration for the current enabled intent after the + /// coordinator has confirmed that iOS permits notification delivery. + func reconcileEnabledIntent(generation: UInt64) async + /// Cache and (when opted in) upload a freshly registered APNs device token. func register(deviceToken: Data) async diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 62a2d10e0e3..9399e921a24 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -114,21 +114,6 @@ public actor PushRegistrationService: PushRegistering { ? (hasToken ? .registrationRequired : .awaitingDeviceToken) : .awaitingDeviceToken ) - if !enabled, - let tokenHex = self.defaults.string(forKey: Self.cachedTokenKey), - !tokenHex.isEmpty, - let accountID = self.defaults.string( - forKey: Self.registeredAccountIDKey - ), - !accountID.isEmpty { - // The app can terminate after the coordinator persists opt-out but - // before its actor hop arrives. Reconstruct that cleanup on launch. - Self.persistPendingUnregister( - tokenHex: tokenHex, - accountID: accountID, - in: self.defaults - ) - } } public var isEnabled: Bool { defaults.bool(forKey: Self.enabledKey) } @@ -136,7 +121,12 @@ public actor PushRegistrationService: PushRegistering { public func snapshots() -> AsyncStream { let id = UUID() - if !isEnabled, !pendingUnregisters.isEmpty { + let hasKnownRegistration = cachedTokenHex != nil + && defaults.string( + forKey: Self.registeredAccountIDKey + )?.isEmpty == false + if !isEnabled, + !pendingUnregisters.isEmpty || hasKnownRegistration { coordinatorIntentEnabled = false disableIntentReconciliationRequested = true scheduleDisableIntentReconciliation() @@ -175,6 +165,9 @@ public actor PushRegistrationService: PushRegistering { } } + /// Commits the coordinator's latest preference immediately. Disable starts + /// durable backend cleanup now; enable waits for the coordinator's separate + /// post-authorization reconciliation call. public func applyEnabledIntent( _ enabled: Bool, generation: UInt64 @@ -211,15 +204,22 @@ public actor PushRegistrationService: PushRegistering { } publish(.disabled) } - if enabled { - enableIntentReconciliationRequested = true - scheduleEnableIntentReconciliation() - } else { + if !enabled { disableIntentReconciliationRequested = true scheduleDisableIntentReconciliation() } } + /// Reconciles an enabled intent only after iOS authorization has succeeded. + /// Stale generations cannot upload a cached APNs token. + public func reconcileEnabledIntent(generation: UInt64) async { + guard generation == coordinatorIntentGeneration, + coordinatorIntentEnabled == true, + isEnabled else { return } + enableIntentReconciliationRequested = true + scheduleEnableIntentReconciliation() + } + private func scheduleEnableIntentReconciliation() { guard enableIntentReconciliationTask == nil else { return } enableIntentReconciliationTask = Task { [weak self] in @@ -346,7 +346,9 @@ public actor PushRegistrationService: PushRegistering { accountID: registeredOwnerID ) } - let session = try? await tokenProvider.authenticatedSessionSnapshot() + let session = await boundedSessionSnapshot( + phase: .pushUnregistrationSession + ) if let preferenceGeneration, preferenceGeneration != operationGeneration || isEnabled { return @@ -616,8 +618,9 @@ public actor PushRegistrationService: PushRegistering { tokenHex: String, staleSession: AuthenticatedSessionSnapshot ) async { - let currentSession = try? await tokenProvider - .authenticatedSessionSnapshot() + let currentSession = await boundedSessionSnapshot( + phase: .pushRegistrationSession + ) if isEnabled, cachedTokenHex == tokenHex, currentSession?.accountID == staleSession.accountID { @@ -646,8 +649,9 @@ public actor PushRegistrationService: PushRegistering { } guard isEnabled, let currentToken = cachedTokenHex, - let currentSession = try? await tokenProvider - .authenticatedSessionSnapshot(), + let currentSession = await boundedSessionSnapshot( + phase: .pushRegistrationSession + ), await tokenProvider.isAuthenticatedSessionCurrent(currentSession) else { return } await upload(tokenHex: currentToken) @@ -698,27 +702,14 @@ public actor PushRegistrationService: PushRegistering { refreshToken = capturedRefreshToken authenticatedSession = nil } else { - do { - let tokenProvider = tokenProvider - let session = try await withAuthPhaseTimeout( - authPhase, - duration: sessionSnapshotTimeout, - clock: sessionSnapshotClock, - log: authLog, - registry: sessionSnapshotTimeoutRegistry, - blocksRetriesWhileTimedOutOperationActive: true - ) { - // This provider API only reads a coherent stored token - // pair or awaits bounded launch bootstrap. Cancelling it - // cannot leave an ambiguous server mutation behind. - try await tokenProvider.authenticatedSessionSnapshot() - } - accessToken = session.accessToken - refreshToken = session.refreshToken - authenticatedSession = session - } catch { + guard let session = await boundedSessionSnapshot( + phase: authPhase + ) else { return .failure(.authenticationRequired) } + accessToken = session.accessToken + refreshToken = session.refreshToken + authenticatedSession = session } guard let url = URL(string: apiBaseURL + path) else { return .failure(.invalidConfiguration) @@ -802,8 +793,9 @@ public actor PushRegistrationService: PushRegistering { private func retryPendingUnregisterIfPossible( preferenceGeneration: UUID? = nil ) async { - guard let session = try? await tokenProvider - .authenticatedSessionSnapshot() else { return } + guard let session = await boundedSessionSnapshot( + phase: .pushUnregistrationSession + ) else { return } if let preferenceGeneration, preferenceGeneration != operationGeneration || isEnabled { return @@ -867,35 +859,32 @@ public actor PushRegistrationService: PushRegistering { } } - private func persistPendingUnregister(tokenHex: String, accountID: String) { - Self.persistPendingUnregister( - tokenHex: tokenHex, - accountID: accountID, - in: defaults - ) + private func boundedSessionSnapshot( + phase: AuthPhase + ) async -> AuthenticatedSessionSnapshot? { + let tokenProvider = tokenProvider + return try? await withAuthPhaseTimeout( + phase, + duration: sessionSnapshotTimeout, + clock: sessionSnapshotClock, + log: authLog, + registry: sessionSnapshotTimeoutRegistry, + blocksRetriesWhileTimedOutOperationActive: true + ) { + // This provider API only reads a coherent stored token pair or + // awaits bounded launch bootstrap. Cancelling it cannot leave an + // ambiguous server mutation behind. + try await tokenProvider.authenticatedSessionSnapshot() + } } - private static func persistPendingUnregister( - tokenHex: String, - accountID: String, - in defaults: UserDefaults - ) { + private func persistPendingUnregister(tokenHex: String, accountID: String) { let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID) - var queue = (defaults.data(forKey: pendingUnregisterQueueKey) - .flatMap { - try? JSONDecoder().decode([PendingUnregister].self, from: $0) - }) ?? [] - var seen = Set() - queue = queue.filter { seen.insert($0).inserted } - if !queue.contains(entry) { queue.append(entry) } - // Never evict a privacy cleanup obligation merely to enforce a local - // storage cap. The set is deduplicated by (account, token), and drains - // in bounded network batches so size cannot stall current readiness. - if let data = try? JSONEncoder().encode(queue) { - defaults.set(data, forKey: pendingUnregisterQueueKey) + var queue = pendingUnregisters + if !queue.contains(entry) { + queue.append(entry) } - defaults.removeObject(forKey: pendingUnregisterTokenKey) - defaults.removeObject(forKey: pendingUnregisterAccountIDKey) + storePendingUnregisters(queue) } private func schedulePendingUnregisterContinuation() { diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/CancellationIgnoringPushTokenProvider.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/CancellationIgnoringPushTokenProvider.swift new file mode 100644 index 00000000000..9527694f1e4 --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/CancellationIgnoringPushTokenProvider.swift @@ -0,0 +1,56 @@ +import Foundation +@testable import CmuxAuthRuntime + +actor CancellationIgnoringPushTokenProvider: TokenProviding { + private let snapshotValue = AuthenticatedSessionSnapshot( + generation: 1, + accountID: "push-user-1", + accessToken: "access", + refreshToken: "refresh" + ) + private let started: TestPhaseSignal + private let blocker: TestContinuationBlocker + private let cancellationObserved = TestPhaseSignal() + private let completed = TestPhaseSignal() + private(set) var snapshotRequestCount = 0 + + init(started: TestPhaseSignal, blocker: TestContinuationBlocker) { + self.started = started + self.blocker = blocker + } + + func authenticatedSessionSnapshot() async throws + -> AuthenticatedSessionSnapshot { + snapshotRequestCount += 1 + await started.markStarted() + let cancellationObserved = cancellationObserved + return await withTaskCancellationHandler { + await blocker.wait() + await completed.markStarted() + return snapshotValue + } onCancel: { + Task { await cancellationObserved.markStarted() } + } + } + + func waitUntilCancellationObserved() async { + await cancellationObserved.waitUntilStarted() + } + + func waitUntilCompleted() async { + await completed.waitUntilStarted() + } + + func isAuthenticatedSessionCurrent( + _ snapshot: AuthenticatedSessionSnapshot + ) async -> Bool { + snapshot == snapshotValue + } + + func accessToken() async throws -> String { snapshotValue.accessToken } + func storedAccessToken() async -> String? { snapshotValue.accessToken } + func refreshToken() async -> String? { snapshotValue.refreshToken } + func forceRefreshAccessToken() async throws -> String { + snapshotValue.accessToken + } +} diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index d83d58db8a1..097bb833468 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -141,44 +141,6 @@ actor MutablePushTokenProvider: TokenProviding { } } -actor CancellationIgnoringPushTokenProvider: TokenProviding { - private let snapshotValue = AuthenticatedSessionSnapshot( - generation: 1, - accountID: "push-user-1", - accessToken: "access", - refreshToken: "refresh" - ) - private let started: TestPhaseSignal - private let blocker: TestContinuationBlocker - private(set) var snapshotRequestCount = 0 - - init(started: TestPhaseSignal, blocker: TestContinuationBlocker) { - self.started = started - self.blocker = blocker - } - - func authenticatedSessionSnapshot() async throws - -> AuthenticatedSessionSnapshot { - snapshotRequestCount += 1 - await started.markStarted() - await blocker.wait() - return snapshotValue - } - - func isAuthenticatedSessionCurrent( - _ snapshot: AuthenticatedSessionSnapshot - ) async -> Bool { - snapshot == snapshotValue - } - - func accessToken() async throws -> String { snapshotValue.accessToken } - func storedAccessToken() async -> String? { snapshotValue.accessToken } - func refreshToken() async -> String? { snapshotValue.refreshToken } - func forceRefreshAccessToken() async throws -> String { - snapshotValue.accessToken - } -} - actor RetryDelayRecorder { private(set) var values: [Duration] = [] @@ -952,6 +914,7 @@ actor RetryDelayRecorder { await service.register(deviceToken: Data([0xAA])) await service.applyEnabledIntent(true, generation: 1) + await service.reconcileEnabledIntent(generation: 1) await started.waitUntilStarted() await service.applyEnabledIntent(false, generation: 2) @@ -995,6 +958,7 @@ actor RetryDelayRecorder { await service.register(deviceToken: Data([0xAA])) await service.applyEnabledIntent(true, generation: 1) + await service.reconcileEnabledIntent(generation: 1) await started.waitUntilStarted() await clock.waitUntilSleepers() clock.advance(by: timeout) @@ -1010,15 +974,64 @@ actor RetryDelayRecorder { // enable intent fails against the active phase instead of accumulating // another unowned task behind it. await service.applyEnabledIntent(true, generation: 2) + await service.reconcileEnabledIntent(generation: 2) #expect( await wait( for: .failed(.authenticationRequired), from: service ) ) + await provider.waitUntilCancellationObserved() #expect(await provider.snapshotRequestCount == 1) await blocker.release() + await provider.waitUntilCompleted() + } + + @Test func coordinatorOptOutAuthenticationHasBoundedSingleAttempt() async { + let started = TestPhaseSignal() + let blocker = TestContinuationBlocker() + let provider = CancellationIgnoringPushTokenProvider( + started: started, + blocker: blocker + ) + let clock = ManualTestClock() + let timeout = Duration.seconds(2) + let (service, _) = makeScriptedService( + tokenProvider: provider, + accountID: nil, + seedDefaults: { defaults in + defaults.set( + true, + forKey: "cmux.notifications.pushEnabled" + ) + defaults.set( + "aa", + forKey: "cmux.notifications.deviceTokenHex" + ) + defaults.set( + "push-user-1", + forKey: "cmux.notifications.registeredAccountID" + ) + }, + sessionSnapshotTimeout: timeout, + sessionSnapshotClock: clock + ) + + await service.applyEnabledIntent(false, generation: 1) + await started.waitUntilStarted() + await clock.waitUntilSleepers() + clock.advance(by: timeout) + await provider.waitUntilCancellationObserved() + + // A direct cleanup retry must fail against the still-active timed-out + // phase instead of starting a second authentication operation. + await service.unregisterFromServer() + #expect(await provider.snapshotRequestCount == 1) + #expect(await service.snapshot == .disabled) + + await blocker.release() + await provider.waitUntilCompleted() } @Test func signOutDuringInFlightRegistrationDeletesAfterLatePost() async { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 5878a7d00bb..2f925618ad5 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -219,6 +219,15 @@ public final class MobilePushCoordinator { generation: generation, registrationIntentOwnedByService: true ) + if result, + self.isCurrentSettingsIntent( + generation, + enabled: true + ) { + await self.registration.reconcileEnabledIntent( + generation: generation + ) + } } else { result = true } diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 99415bc1d29..918f99d4658 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -8,6 +8,7 @@ import UserNotifications private actor LifecyclePushRegistration: PushRegistering { private var value: PushRegistrationSnapshot private var intentGeneration: UInt64 = 0 + private(set) var enabledReconciliationGenerations: [UInt64] = [] private let setEnabledGate: LifecycleSetEnabledGate? private let syncGate: LifecycleSyncGate? @@ -49,6 +50,11 @@ private actor LifecyclePushRegistration: PushRegistering { apply(enabled) } + func reconcileEnabledIntent(generation: UInt64) { + guard generation == intentGeneration, value.isEnabled else { return } + enabledReconciliationGenerations.append(generation) + } + private func apply(_ enabled: Bool) { value = enabled ? PushRegistrationSnapshot( @@ -361,6 +367,25 @@ private final class LifecyclePushURLProtocol: URLProtocol, ) } + @MainActor + @Test func settingsIntentDoesNotReconcileBackendWhenPermissionIsDenied() async { + let registration = LifecyclePushRegistration(enabled: false) + let suiteName = "push-coordinator-denied-backend-\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: suiteName)! + defer { defaults.removePersistentDomain(forName: suiteName) } + let coordinator = MobilePushCoordinator( + registration: registration, + defaults: defaults, + authorizationStatus: { .denied }, + requestAuthorization: { false } + ) + await coordinator.refreshReadiness() + + #expect(!(await coordinator.setEnabledIntent(true).value)) + #expect(await registration.enabledReconciliationGenerations.isEmpty) + #expect(coordinator.isEnabled) + } + @MainActor @Test func foregroundRefreshRegistersAfterPermissionIsEnabledInSettings() async { let registration = LifecyclePushRegistration(enabled: false) diff --git a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift index 14e7d157aeb..81397d0f9ba 100644 --- a/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift +++ b/ios/cmuxPackage/Tests/cmuxFeatureTests/cmuxFeatureTests.swift @@ -4117,6 +4117,7 @@ struct InertPushRegistration: PushRegistering { } func setEnabled(_ enabled: Bool) async {} func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async {} + func reconcileEnabledIntent(generation: UInt64) async {} func register(deviceToken: Data) async {} func deviceTokenRegistrationFailed() async {} func syncTokenIfPossible() async {} From c32c6311b7e8e9480c88ebdbe51fdec28dd6a25e Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:14:54 -0700 Subject: [PATCH 103/117] test(push): bound pending cleanup storage --- .../PushRegistrationServiceTests.swift | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 097bb833468..31af432fb8c 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1466,6 +1466,49 @@ actor RetryDelayRecorder { #expect(deletedTokens == ["aa", "bb", "aa", "bb"]) } + @Test func pendingCleanupStorageKeepsNewestTwoHundredEntries() async throws { + let existing = (0..<200).map { index in + [ + "tokenHex": String(format: "%064x", index), + "accountID": "historical-account-\(index)", + ] + } + let (service, defaults) = makeScriptedService( + accountID: nil, + seedDefaults: { defaults in + defaults.set( + try? JSONSerialization.data(withJSONObject: existing), + forKey: "cmux.notifications.pendingUnregisters.v2" + ) + defaults.set( + true, + forKey: "cmux.notifications.pushEnabled" + ) + defaults.set( + String(repeating: "f", count: 64), + forKey: "cmux.notifications.deviceTokenHex" + ) + defaults.set( + "current-account", + forKey: "cmux.notifications.registeredAccountID" + ) + } + ) + + await service.applyEnabledIntent(false, generation: 1) + + let data = try #require(defaults.data( + forKey: "cmux.notifications.pendingUnregisters.v2" + )) + let stored = try #require( + JSONSerialization.jsonObject(with: data) + as? [[String: String]] + ) + #expect(stored.count == 200) + #expect(stored.first?["accountID"] == "historical-account-1") + #expect(stored.last?["accountID"] == "current-account") + } + @Test func successfulReassignmentClearsOldTombstoneWithoutLosingNewOwner() async { await PushRegistrationURLProtocol.script.reset([.response(200)]) let suite = "push-owner-reassignment-\(UUID().uuidString)" From 4364c2d23f49806975ebd1270ad747e3fd657053 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:15:56 -0700 Subject: [PATCH 104/117] fix(push): bound pending cleanup state --- .../Push/PushRegistrationService.swift | 43 +++++++++++++++---- .../MobilePushCoordinator.swift | 10 ++--- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 9399e921a24..4f6b1c2e2cd 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -53,6 +53,11 @@ public actor PushRegistrationService: PushRegistering { private static let pendingUnregisterQueueKey = "cmux.notifications.pendingUnregisters.v2" private static let pendingUnregisterAttemptBudget = 4 + // The server accepts at most 200 live tokens per account, while APNs has + // one current token per app installation and server rows are token-unique. + // Keeping the newest 200 covers every potentially live obligation without + // allowing corrupted or adversarial defaults to grow forever. + private static let pendingUnregisterStorageLimit = 200 /// Creates a push registration service. /// @@ -881,9 +886,8 @@ public actor PushRegistrationService: PushRegistering { private func persistPendingUnregister(tokenHex: String, accountID: String) { let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID) var queue = pendingUnregisters - if !queue.contains(entry) { - queue.append(entry) - } + queue.removeAll { $0 == entry } + queue.append(entry) storePendingUnregisters(queue) } @@ -923,7 +927,13 @@ public actor PushRegistrationService: PushRegistering { entries = [] } var seen = Set() - return entries.filter { seen.insert($0).inserted } + var newestFirst: [PendingUnregister] = [] + for entry in entries.reversed() where seen.insert(entry).inserted { + newestFirst.append(entry) + } + return Array( + newestFirst.reversed().suffix(Self.pendingUnregisterStorageLimit) + ) } private static func migrateLegacyPendingUnregisters( @@ -943,8 +953,17 @@ public actor PushRegistrationService: PushRegistering { tokenHex: tokenHex, accountID: accountID ) - if !entries.contains(legacy) { entries.append(legacy) } - if let data = try? JSONEncoder().encode(entries) { + entries.removeAll { $0 == legacy } + entries.append(legacy) + var seen = Set() + var newestFirst: [PendingUnregister] = [] + for entry in entries.reversed() where seen.insert(entry).inserted { + newestFirst.append(entry) + } + let bounded = Array( + newestFirst.reversed().suffix(pendingUnregisterStorageLimit) + ) + if let data = try? JSONEncoder().encode(bounded) { defaults.set(data, forKey: pendingUnregisterQueueKey) } defaults.removeObject(forKey: pendingUnregisterTokenKey) @@ -952,13 +971,21 @@ public actor PushRegistrationService: PushRegistering { } private func storePendingUnregisters(_ entries: [PendingUnregister]) { - if entries.isEmpty { + var seen = Set() + var newestFirst: [PendingUnregister] = [] + for entry in entries.reversed() where seen.insert(entry).inserted { + newestFirst.append(entry) + } + let bounded = Array( + newestFirst.reversed().suffix(Self.pendingUnregisterStorageLimit) + ) + if bounded.isEmpty { defaults.removeObject(forKey: Self.pendingUnregisterQueueKey) defaults.removeObject(forKey: Self.pendingUnregisterTokenKey) defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey) return } - if let data = try? JSONEncoder().encode(entries) { + if let data = try? JSONEncoder().encode(bounded) { defaults.set(data, forKey: Self.pendingUnregisterQueueKey) } defaults.removeObject(forKey: Self.pendingUnregisterTokenKey) diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 2f925618ad5..2ee858439b1 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -504,12 +504,12 @@ public final class MobilePushCoordinator { private func beginSettingsIntent(_ enabled: Bool) -> UInt64 { settingsIntentGeneration &+= 1 - if enabled { - persistEnabledIntent() - } else { + // Commit the synchronous source of truth before any actor hop. The + // Settings binding reads this value again in the same render pass. + enabledMirror = enabled + defaults.set(enabled, forKey: Self.enabledKey) + if !enabled { diagnosticLog?.recordAppEvent(.pushDisabled) - enabledMirror = false - defaults.set(false, forKey: Self.enabledKey) registrationSnapshot = .disabled hasRequestedRemoteRegistration = false unregisterForRemoteNotifications() From 708bc6b89b765399899f1ef7b5142e1623d556c6 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:05:56 -0700 Subject: [PATCH 105/117] fix(push): preserve overflow cleanup obligations --- .../Push/PushRegistrationService.swift | 181 ++++++++++++++---- .../PushRegistrationServiceTests.swift | 8 + 2 files changed, 156 insertions(+), 33 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 4f6b1c2e2cd..9541b3b3646 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -3,6 +3,10 @@ import OSLog private let pushLog = Logger(subsystem: "ai.manaflow.cmux", category: "push") +private func pendingUnregisterOverflowKey(accountID: String) -> String { + "cmux.notifications.pendingUnregisters.v3.overflow.\(accountID)" +} + /// Owns the push opt-in state and the device-token sync with the cmux web API. /// /// Replaces the iOS `NotificationManager.shared` singleton and its @@ -52,12 +56,10 @@ public actor PushRegistrationService: PushRegistering { private static let pendingUnregisterAccountIDKey = "cmux.notifications.pendingUnregisterAccountID" private static let pendingUnregisterQueueKey = "cmux.notifications.pendingUnregisters.v2" + private static let pendingUnregisterOverflowCountKey = + "cmux.notifications.pendingUnregisterOverflowCount.v3" private static let pendingUnregisterAttemptBudget = 4 - // The server accepts at most 200 live tokens per account, while APNs has - // one current token per app installation and server rows are token-unique. - // Keeping the newest 200 covers every potentially live obligation without - // allowing corrupted or adversarial defaults to grow forever. - private static let pendingUnregisterStorageLimit = 200 + private static let pendingUnregisterActiveLimit = 200 /// Creates a push registration service. /// @@ -131,7 +133,9 @@ public actor PushRegistrationService: PushRegistering { forKey: Self.registeredAccountIDKey )?.isEmpty == false if !isEnabled, - !pendingUnregisters.isEmpty || hasKnownRegistration { + !pendingUnregisters.isEmpty + || pendingUnregisterOverflowCount > 0 + || hasKnownRegistration { coordinatorIntentEnabled = false disableIntentReconciliationRequested = true scheduleDisableIntentReconciliation() @@ -527,6 +531,9 @@ public actor PushRegistrationService: PushRegistering { } switch result { case let .success(pushServiceConfigured): + let previousOwnerID = defaults.string( + forKey: Self.registeredAccountIDKey + ) if let requestSession { defaults.set( requestSession.accountID, @@ -537,6 +544,12 @@ public actor PushRegistrationService: PushRegistering { // current account also removes any old-account association, so a // pending tombstone for this token is fulfilled without applying // old credentials. + if let previousOwnerID { + clearPendingUnregister( + tokenHex: tokenHex, + accountID: previousOwnerID + ) + } for pending in pendingUnregisters where pending.tokenHex == tokenHex { clearPendingUnregister( tokenHex: pending.tokenHex, @@ -806,8 +819,12 @@ public actor PushRegistrationService: PushRegistering { return } let currentAccountID = session.accountID - let matching = pendingUnregisters.filter { - $0.accountID == currentAccountID + var seen = Set() + let matching = ( + pendingUnregisters.filter { $0.accountID == currentAccountID } + + pendingUnregisterOverflow(accountID: currentAccountID) + ).filter { + seen.insert($0).inserted } let batch = Array( matching.prefix(Self.pendingUnregisterAttemptBudget) @@ -887,6 +904,9 @@ public actor PushRegistrationService: PushRegistering { let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID) var queue = pendingUnregisters queue.removeAll { $0 == entry } + var overflow = pendingUnregisterOverflow(accountID: accountID) + overflow.removeAll { $0 == entry } + storePendingUnregisterOverflow(overflow, accountID: accountID) queue.append(entry) storePendingUnregisters(queue) } @@ -913,6 +933,10 @@ public actor PushRegistrationService: PushRegistering { entry.tokenHex != tokenHex || entry.accountID != accountID } storePendingUnregisters(filtered) + let overflow = pendingUnregisterOverflow(accountID: accountID).filter { + $0.tokenHex != tokenHex + } + storePendingUnregisterOverflow(overflow, accountID: accountID) } private var pendingUnregisters: [PendingUnregister] { @@ -927,45 +951,74 @@ public actor PushRegistrationService: PushRegistering { entries = [] } var seen = Set() - var newestFirst: [PendingUnregister] = [] - for entry in entries.reversed() where seen.insert(entry).inserted { - newestFirst.append(entry) - } - return Array( - newestFirst.reversed().suffix(Self.pendingUnregisterStorageLimit) - ) + return entries.filter { seen.insert($0).inserted } } private static func migrateLegacyPendingUnregisters( in defaults: UserDefaults ) { - guard let tokenHex = defaults.string( - forKey: pendingUnregisterTokenKey - ), let accountID = defaults.string( - forKey: pendingUnregisterAccountIDKey - ), !tokenHex.isEmpty, !accountID.isEmpty else { return } var entries = (defaults.data(forKey: pendingUnregisterQueueKey) .flatMap { try? JSONDecoder().decode( [PendingUnregister].self, from: $0 ) }) ?? [] - let legacy = PendingUnregister( - tokenHex: tokenHex, - accountID: accountID - ) - entries.removeAll { $0 == legacy } - entries.append(legacy) + if let tokenHex = defaults.string( + forKey: pendingUnregisterTokenKey + ), let accountID = defaults.string( + forKey: pendingUnregisterAccountIDKey + ), !tokenHex.isEmpty, !accountID.isEmpty { + let legacy = PendingUnregister( + tokenHex: tokenHex, + accountID: accountID + ) + entries.removeAll { $0 == legacy } + entries.append(legacy) + } var seen = Set() var newestFirst: [PendingUnregister] = [] for entry in entries.reversed() where seen.insert(entry).inserted { newestFirst.append(entry) } - let bounded = Array( - newestFirst.reversed().suffix(pendingUnregisterStorageLimit) + let normalized = Array(newestFirst.reversed()) + let overflowCount = max( + 0, + normalized.count - pendingUnregisterActiveLimit + ) + let overflow = normalized.prefix(overflowCount) + var storedOverflowCount = defaults.integer( + forKey: pendingUnregisterOverflowCountKey ) - if let data = try? JSONEncoder().encode(bounded) { + for (accountID, accountEntries) in Dictionary( + grouping: overflow, + by: \.accountID + ) { + let key = pendingUnregisterOverflowKey(accountID: accountID) + let existing = (defaults.data(forKey: key).flatMap { + try? JSONDecoder().decode([PendingUnregister].self, from: $0) + }) ?? [] + var bucketSeen = Set() + let merged = (existing + accountEntries).filter { + bucketSeen.insert($0).inserted + } + storedOverflowCount += merged.count - existing.count + if let data = try? JSONEncoder().encode(merged) { + defaults.set(data, forKey: key) + } + } + let active = Array(normalized.suffix(pendingUnregisterActiveLimit)) + if active.isEmpty { + defaults.removeObject(forKey: pendingUnregisterQueueKey) + } else if let data = try? JSONEncoder().encode(active) { defaults.set(data, forKey: pendingUnregisterQueueKey) } + if storedOverflowCount > 0 { + defaults.set( + storedOverflowCount, + forKey: pendingUnregisterOverflowCountKey + ) + } else { + defaults.removeObject(forKey: pendingUnregisterOverflowCountKey) + } defaults.removeObject(forKey: pendingUnregisterTokenKey) defaults.removeObject(forKey: pendingUnregisterAccountIDKey) } @@ -976,22 +1029,84 @@ public actor PushRegistrationService: PushRegistering { for entry in entries.reversed() where seen.insert(entry).inserted { newestFirst.append(entry) } - let bounded = Array( - newestFirst.reversed().suffix(Self.pendingUnregisterStorageLimit) + let normalized = Array(newestFirst.reversed()) + let overflowCount = max( + 0, + normalized.count - Self.pendingUnregisterActiveLimit ) - if bounded.isEmpty { + let overflow = normalized.prefix(overflowCount) + for (accountID, accountEntries) in Dictionary( + grouping: overflow, + by: \.accountID + ) { + var bucket = pendingUnregisterOverflow(accountID: accountID) + bucket.append(contentsOf: accountEntries) + storePendingUnregisterOverflow(bucket, accountID: accountID) + } + let active = Array( + normalized.suffix(Self.pendingUnregisterActiveLimit) + ) + if active.isEmpty { defaults.removeObject(forKey: Self.pendingUnregisterQueueKey) defaults.removeObject(forKey: Self.pendingUnregisterTokenKey) defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey) return } - if let data = try? JSONEncoder().encode(bounded) { + if let data = try? JSONEncoder().encode(active) { defaults.set(data, forKey: Self.pendingUnregisterQueueKey) } defaults.removeObject(forKey: Self.pendingUnregisterTokenKey) defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey) } + private var pendingUnregisterOverflowCount: Int { + defaults.integer(forKey: Self.pendingUnregisterOverflowCountKey) + } + + private func pendingUnregisterOverflow( + accountID: String + ) -> [PendingUnregister] { + let key = pendingUnregisterOverflowKey(accountID: accountID) + guard let data = defaults.data(forKey: key), + let decoded = try? JSONDecoder().decode( + [PendingUnregister].self, + from: data + ) else { return [] } + var seen = Set() + return decoded.filter { seen.insert($0).inserted } + } + + private func storePendingUnregisterOverflow( + _ entries: [PendingUnregister], + accountID: String + ) { + let key = pendingUnregisterOverflowKey(accountID: accountID) + let previousCount = pendingUnregisterOverflow( + accountID: accountID + ).count + var seen = Set() + let normalized = entries.filter { seen.insert($0).inserted } + if normalized.isEmpty { + defaults.removeObject(forKey: key) + } else if let data = try? JSONEncoder().encode(normalized) { + defaults.set(data, forKey: key) + } + let total = max( + 0, + pendingUnregisterOverflowCount + normalized.count - previousCount + ) + if total == 0 { + defaults.removeObject( + forKey: Self.pendingUnregisterOverflowCountKey + ) + } else { + defaults.set( + total, + forKey: Self.pendingUnregisterOverflowCountKey + ) + } + } + private func clearRegisteredOwner( accountID: String, tokenHex: String diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 31af432fb8c..c3b6165339a 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1507,6 +1507,14 @@ actor RetryDelayRecorder { #expect(stored.count == 200) #expect(stored.first?["accountID"] == "historical-account-1") #expect(stored.last?["accountID"] == "current-account") + let overflowData = try #require(defaults.data( + forKey: "cmux.notifications.pendingUnregisters.v3.overflow.historical-account-0" + )) + let overflow = try #require( + JSONSerialization.jsonObject(with: overflowData) + as? [[String: String]] + ) + #expect(overflow.map { $0["accountID"] } == ["historical-account-0"]) } @Test func successfulReassignmentClearsOldTombstoneWithoutLosingNewOwner() async { From 3f6a681ba2a6ff74f398e6362bfbb52eccfcd84a Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:19:38 -0700 Subject: [PATCH 106/117] fix(push): page durable cleanup overflow --- .../Push/PushRegistrationService.swift | 533 +++++++++++++++--- .../PushRegistrationServiceTests.swift | 35 +- 2 files changed, 485 insertions(+), 83 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index f9ab87ef373..a50b5ef0185 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -3,8 +3,30 @@ import OSLog private let pushLog = Logger(subsystem: "ai.manaflow.cmux", category: "push") -private func pendingUnregisterOverflowKey(accountID: String) -> String { - "cmux.notifications.pendingUnregisters.v3.overflow.\(accountID)" +private func pendingUnregisterOverflowPageKey( + accountID: String, + page: Int +) -> String { + let base = "cmux.notifications.pendingUnregisters.v4.overflow.\(accountID)" + return page == 0 ? base : "\(base).page\(page)" +} + +private func pendingUnregisterOverflowPageCountKey(accountID: String) -> String { + "cmux.notifications.pendingUnregisters.v4.overflowPages.\(accountID)" +} + +private func pendingUnregisterOverflowTokenIndexPageKey( + tokenHex: String, + page: Int +) -> String { + let base = "cmux.notifications.pendingUnregisters.v4.token.\(tokenHex)" + return page == 0 ? base : "\(base).page\(page)" +} + +private func pendingUnregisterOverflowTokenIndexPageCountKey( + tokenHex: String +) -> String { + "cmux.notifications.pendingUnregisters.v4.tokenPages.\(tokenHex)" } /// Owns the push opt-in state and the device-token sync with the cmux web API. @@ -67,9 +89,10 @@ public actor PushRegistrationService: PushRegistering { private static let pendingUnregisterQueueKey = "cmux.notifications.pendingUnregisters.v2" private static let pendingUnregisterOverflowCountKey = - "cmux.notifications.pendingUnregisterOverflowCount.v3" + "cmux.notifications.pendingUnregisterOverflowCount.v4" private static let pendingUnregisterAttemptBudget = 4 private static let pendingUnregisterActiveLimit = 200 + private static let pendingUnregisterOverflowPageSize = 200 /// Creates a push registration service. /// @@ -590,17 +613,10 @@ public actor PushRegistrationService: PushRegistering { // current account also removes any old-account association, so a // pending tombstone for this token is fulfilled without applying // old credentials. - if let previousOwnerID { - clearPendingUnregister( - tokenHex: tokenHex, - accountID: previousOwnerID - ) - } - for pending in pendingUnregisters where pending.tokenHex == tokenHex { - clearPendingUnregister( - tokenHex: pending.tokenHex, - accountID: pending.accountID - ) + if previousOwnerID != nil || pendingUnregisters.contains( + where: { $0.tokenHex == tokenHex } + ) || pendingUnregisterOverflowCount > 0 { + clearPendingUnregisterToken(tokenHex: tokenHex) } if pushServiceConfigured { publish(PushRegistrationSnapshot( @@ -869,7 +885,10 @@ public actor PushRegistrationService: PushRegistering { var seen = Set() let matching = ( pendingUnregisters.filter { $0.accountID == currentAccountID } - + pendingUnregisterOverflow(accountID: currentAccountID) + + pendingUnregisterOverflowBatch( + accountID: currentAccountID, + limit: Self.pendingUnregisterAttemptBudget + ) ).filter { seen.insert($0).inserted } @@ -951,9 +970,7 @@ public actor PushRegistrationService: PushRegistering { let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID) var queue = pendingUnregisters queue.removeAll { $0 == entry } - var overflow = pendingUnregisterOverflow(accountID: accountID) - overflow.removeAll { $0 == entry } - storePendingUnregisterOverflow(overflow, accountID: accountID) + removePendingUnregisterOverflow(entry) queue.append(entry) storePendingUnregisters(queue) } @@ -980,10 +997,10 @@ public actor PushRegistrationService: PushRegistering { entry.tokenHex != tokenHex || entry.accountID != accountID } storePendingUnregisters(filtered) - let overflow = pendingUnregisterOverflow(accountID: accountID).filter { - $0.tokenHex != tokenHex - } - storePendingUnregisterOverflow(overflow, accountID: accountID) + removePendingUnregisterOverflow( + tokenHex: tokenHex, + accountID: accountID + ) } private var pendingUnregisters: [PendingUnregister] { @@ -1032,25 +1049,8 @@ public actor PushRegistrationService: PushRegistering { normalized.count - pendingUnregisterActiveLimit ) let overflow = normalized.prefix(overflowCount) - var storedOverflowCount = defaults.integer( - forKey: pendingUnregisterOverflowCountKey - ) - for (accountID, accountEntries) in Dictionary( - grouping: overflow, - by: \.accountID - ) { - let key = pendingUnregisterOverflowKey(accountID: accountID) - let existing = (defaults.data(forKey: key).flatMap { - try? JSONDecoder().decode([PendingUnregister].self, from: $0) - }) ?? [] - var bucketSeen = Set() - let merged = (existing + accountEntries).filter { - bucketSeen.insert($0).inserted - } - storedOverflowCount += merged.count - existing.count - if let data = try? JSONEncoder().encode(merged) { - defaults.set(data, forKey: key) - } + for entry in overflow { + appendPendingUnregisterOverflow(entry, in: defaults) } let active = Array(normalized.suffix(pendingUnregisterActiveLimit)) if active.isEmpty { @@ -1058,14 +1058,6 @@ public actor PushRegistrationService: PushRegistering { } else if let data = try? JSONEncoder().encode(active) { defaults.set(data, forKey: pendingUnregisterQueueKey) } - if storedOverflowCount > 0 { - defaults.set( - storedOverflowCount, - forKey: pendingUnregisterOverflowCountKey - ) - } else { - defaults.removeObject(forKey: pendingUnregisterOverflowCountKey) - } defaults.removeObject(forKey: pendingUnregisterTokenKey) defaults.removeObject(forKey: pendingUnregisterAccountIDKey) } @@ -1082,13 +1074,8 @@ public actor PushRegistrationService: PushRegistering { normalized.count - Self.pendingUnregisterActiveLimit ) let overflow = normalized.prefix(overflowCount) - for (accountID, accountEntries) in Dictionary( - grouping: overflow, - by: \.accountID - ) { - var bucket = pendingUnregisterOverflow(accountID: accountID) - bucket.append(contentsOf: accountEntries) - storePendingUnregisterOverflow(bucket, accountID: accountID) + for entry in overflow { + appendPendingUnregisterOverflow(entry) } let active = Array( normalized.suffix(Self.pendingUnregisterActiveLimit) @@ -1110,46 +1097,428 @@ public actor PushRegistrationService: PushRegistering { defaults.integer(forKey: Self.pendingUnregisterOverflowCountKey) } - private func pendingUnregisterOverflow( - accountID: String + private static func decodeOverflowPage( + accountID: String, + page: Int, + defaults: UserDefaults ) -> [PendingUnregister] { - let key = pendingUnregisterOverflowKey(accountID: accountID) - guard let data = defaults.data(forKey: key), - let decoded = try? JSONDecoder().decode( - [PendingUnregister].self, - from: data - ) else { return [] } + guard let data = defaults.data( + forKey: pendingUnregisterOverflowPageKey( + accountID: accountID, + page: page + ) + ), let decoded = try? JSONDecoder().decode( + [PendingUnregister].self, + from: data + ) else { return [] } var seen = Set() - return decoded.filter { seen.insert($0).inserted } + return Array(decoded.filter { seen.insert($0).inserted }.prefix( + pendingUnregisterOverflowPageSize + )) } - private func storePendingUnregisterOverflow( + private static func storeOverflowPage( _ entries: [PendingUnregister], - accountID: String + accountID: String, + page: Int, + defaults: UserDefaults ) { - let key = pendingUnregisterOverflowKey(accountID: accountID) - let previousCount = pendingUnregisterOverflow( - accountID: accountID - ).count - var seen = Set() - let normalized = entries.filter { seen.insert($0).inserted } - if normalized.isEmpty { + let key = pendingUnregisterOverflowPageKey( + accountID: accountID, + page: page + ) + if entries.isEmpty { defaults.removeObject(forKey: key) - } else if let data = try? JSONEncoder().encode(normalized) { + } else if let data = try? JSONEncoder().encode( + Array(entries.prefix(pendingUnregisterOverflowPageSize)) + ) { defaults.set(data, forKey: key) } + } + + private static func decodeTokenIndexPage( + tokenHex: String, + page: Int, + defaults: UserDefaults + ) -> [String] { + guard let data = defaults.data( + forKey: pendingUnregisterOverflowTokenIndexPageKey( + tokenHex: tokenHex, + page: page + ) + ), let decoded = try? JSONDecoder().decode( + [String].self, + from: data + ) else { return [] } + return Array(decoded.prefix(pendingUnregisterOverflowPageSize)) + } + + private static func storeTokenIndexPage( + _ entries: [String], + tokenHex: String, + page: Int, + defaults: UserDefaults + ) { + let key = pendingUnregisterOverflowTokenIndexPageKey( + tokenHex: tokenHex, + page: page + ) + if entries.isEmpty { + defaults.removeObject(forKey: key) + } else if let data = try? JSONEncoder().encode( + Array(entries.prefix(pendingUnregisterOverflowPageSize)) + ) { + defaults.set(data, forKey: key) + } + } + + private static func incrementOverflowCount( + by delta: Int, + defaults: UserDefaults + ) { let total = max( 0, - pendingUnregisterOverflowCount + normalized.count - previousCount + defaults.integer(forKey: pendingUnregisterOverflowCountKey) + delta ) if total == 0 { - defaults.removeObject( - forKey: Self.pendingUnregisterOverflowCountKey - ) + defaults.removeObject(forKey: pendingUnregisterOverflowCountKey) } else { - defaults.set( - total, - forKey: Self.pendingUnregisterOverflowCountKey + defaults.set(total, forKey: pendingUnregisterOverflowCountKey) + } + } + + private static func overflowPageCount( + accountID: String, + defaults: UserDefaults + ) -> Int { + let stored = defaults.integer( + forKey: pendingUnregisterOverflowPageCountKey(accountID: accountID) + ) + if stored > 0 { return stored } + return defaults.data( + forKey: pendingUnregisterOverflowPageKey( + accountID: accountID, + page: 0 + ) + ) == nil ? 0 : 1 + } + + private static func tokenIndexPageCount( + tokenHex: String, + defaults: UserDefaults + ) -> Int { + let stored = defaults.integer( + forKey: pendingUnregisterOverflowTokenIndexPageCountKey( + tokenHex: tokenHex + ) + ) + if stored > 0 { return stored } + return defaults.data( + forKey: pendingUnregisterOverflowTokenIndexPageKey( + tokenHex: tokenHex, + page: 0 + ) + ) == nil ? 0 : 1 + } + + private static func appendTokenIndex( + tokenHex: String, + accountID: String, + defaults: UserDefaults + ) { + let pageCount = tokenIndexPageCount(tokenHex: tokenHex, defaults: defaults) + for page in 0.. [PendingUnregister] { + guard limit > 0 else { return [] } + let pageCount = Self.overflowPageCount( + accountID: accountID, + defaults: defaults + ) + var result: [PendingUnregister] = [] + var seen = Set() + for page in 0.. 0, + Self.decodeOverflowPage( + accountID: accountID, + page: remainingPages - 1, + defaults: defaults + ).isEmpty { + defaults.removeObject( + forKey: pendingUnregisterOverflowPageKey( + accountID: accountID, + page: remainingPages - 1 + ) + ) + remainingPages -= 1 + } + if remainingPages == 0 { + defaults.removeObject( + forKey: pendingUnregisterOverflowPageCountKey( + accountID: accountID + ) + ) + } else { + defaults.set( + remainingPages, + forKey: pendingUnregisterOverflowPageCountKey( + accountID: accountID + ) + ) + } + if !overflowContains( + tokenHex: tokenHex, + accountID: accountID + ) { + removeTokenIndex(tokenHex: tokenHex, accountID: accountID) + } + Self.incrementOverflowCount(by: -1, defaults: defaults) + return + } + } + + private func overflowContains( + tokenHex: String, + accountID: String + ) -> Bool { + let pageCount = Self.overflowPageCount( + accountID: accountID, + defaults: defaults + ) + for page in 0.. 0, + Self.decodeTokenIndexPage( + tokenHex: tokenHex, + page: remainingPages - 1, + defaults: defaults + ).isEmpty { + defaults.removeObject( + forKey: pendingUnregisterOverflowTokenIndexPageKey( + tokenHex: tokenHex, + page: remainingPages - 1 + ) + ) + remainingPages -= 1 + } + if remainingPages == 0 { + defaults.removeObject( + forKey: pendingUnregisterOverflowTokenIndexPageCountKey( + tokenHex: tokenHex + ) + ) + } else { + defaults.set( + remainingPages, + forKey: pendingUnregisterOverflowTokenIndexPageCountKey( + tokenHex: tokenHex + ) + ) + } + return + } + } + + private func firstOverflowAccount(for tokenHex: String) -> String? { + let pageCount = Self.tokenIndexPageCount( + tokenHex: tokenHex, + defaults: defaults + ) + for page in 0.. Date: Fri, 14 Aug 2026 18:25:39 -0700 Subject: [PATCH 107/117] fix(push): discard stale cleanup index entries --- .../CmuxAuthRuntime/Push/PushRegistrationService.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index a50b5ef0185..afac6c03fcf 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -1421,6 +1421,10 @@ public actor PushRegistrationService: PushRegistering { Self.incrementOverflowCount(by: -1, defaults: defaults) return } + // The index and page are separate UserDefaults writes. If a process + // dies between them, discard the stale index reference so cleanup + // remains finite and the next registration cannot spin forever. + removeTokenIndex(tokenHex: tokenHex, accountID: accountID) } private func overflowContains( From 3a25ecf3d15acabeedd58d4dfd04bbdeb5d7cf21 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:33:45 -0700 Subject: [PATCH 108/117] fix(push): continue paged cleanup drains --- .../CmuxAuthRuntime/Push/PushRegistrationService.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index afac6c03fcf..de69f035435 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -887,7 +887,9 @@ public actor PushRegistrationService: PushRegistering { pendingUnregisters.filter { $0.accountID == currentAccountID } + pendingUnregisterOverflowBatch( accountID: currentAccountID, - limit: Self.pendingUnregisterAttemptBudget + // Keep one lookahead entry so a bounded batch can tell + // whether another continuation is required. + limit: Self.pendingUnregisterAttemptBudget + 1 ) ).filter { seen.insert($0).inserted From d3841ca49359c5277ced2290aa31f9ebb11d7d42 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:39:34 -0700 Subject: [PATCH 109/117] fix(push): supersede direct cleanup retries --- .../CmuxAuthRuntime/Push/PushRegistrationService.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index de69f035435..f0ab34c0405 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -374,7 +374,10 @@ public actor PushRegistrationService: PushRegistering { } public func unregisterFromServer() async { - await unregisterFromServer(preferenceGeneration: nil) + // Treat direct cleanup retries as the current opt-out operation too, + // so a newer enable can supersede an in-flight DELETE and trigger the + // same final re-upload repair as coordinator-owned cleanup. + await unregisterFromServer(preferenceGeneration: operationGeneration) } private func unregisterFromServer( From 8dc9926c38d4cde93e96b85bb76d6ef590285934 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:48:30 -0700 Subject: [PATCH 110/117] fix(push): preserve cleanup intent across retries --- .../Push/PushRegistrationService.swift | 94 +++++++++++++++---- 1 file changed, 74 insertions(+), 20 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index f0ab34c0405..8f99dec334a 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -66,6 +66,8 @@ public actor PushRegistrationService: PushRegistering { private var disableIntentReconciliationRequested = false private var coordinatorIntentGeneration: UInt64 = 0 private var coordinatorIntentEnabled: Bool? + private var coordinatorIntentReconciledGeneration: UInt64? + private var pendingUnregisterRecoveryTask: Task? // Actor reentrancy lets a second lifecycle callback enter while the first // POST is suspended in URLSession. Keep one in-flight upload per token so @@ -189,6 +191,7 @@ public actor PushRegistrationService: PushRegistering { let owesBackendCleanup = snapshotValue.isEnabled || defaults.string(forKey: Self.registeredAccountIDKey) != nil cancelRetry() + operationGeneration = UUID() let generation = operationGeneration defaults.set(enabled, forKey: Self.enabledKey) if enabled { @@ -221,6 +224,8 @@ public actor PushRegistrationService: PushRegistering { } coordinatorIntentGeneration = generation coordinatorIntentEnabled = enabled + coordinatorIntentReconciledGeneration = nil + operationGeneration = UUID() cancelRetry() defaults.set(enabled, forKey: Self.enabledKey) if enabled { @@ -258,6 +263,7 @@ public actor PushRegistrationService: PushRegistering { guard generation == coordinatorIntentGeneration, coordinatorIntentEnabled == true, isEnabled else { return } + coordinatorIntentReconciledGeneration = generation enableIntentReconciliationRequested = true scheduleEnableIntentReconciliation() } @@ -377,11 +383,15 @@ public actor PushRegistrationService: PushRegistering { // Treat direct cleanup retries as the current opt-out operation too, // so a newer enable can supersede an in-flight DELETE and trigger the // same final re-upload repair as coordinator-owned cleanup. - await unregisterFromServer(preferenceGeneration: operationGeneration) + await unregisterFromServer( + preferenceGeneration: operationGeneration, + requiresDisabledPreference: false + ) } private func unregisterFromServer( - preferenceGeneration: UUID? + preferenceGeneration: UUID?, + requiresDisabledPreference: Bool = true ) async { if preferenceGeneration == nil { cancelRetry() @@ -402,7 +412,8 @@ public actor PushRegistrationService: PushRegistering { phase: .pushUnregistrationSession ) if let preferenceGeneration, - preferenceGeneration != operationGeneration || isEnabled { + preferenceGeneration != operationGeneration + || (requiresDisabledPreference && isEnabled) { return } let ownerID = registeredOwnerID ?? session?.accountID @@ -416,10 +427,14 @@ public actor PushRegistrationService: PushRegistering { if await sendDelete(tokenHex: hex, sessionSnapshot: session) { clearPendingUnregister(tokenHex: hex, accountID: ownerID) clearRegisteredOwner(accountID: ownerID, tokenHex: hex) - if let preferenceGeneration, - preferenceGeneration != operationGeneration || isEnabled, - isEnabled, - cachedTokenHex == hex { + let preferenceWasSuperseded = preferenceGeneration.map { + $0 != operationGeneration + || (requiresDisabledPreference && isEnabled) + } ?? false + if preferenceWasSuperseded, + enableIntentIsReconciled, + let currentToken = cachedTokenHex, + currentToken == hex { // A newer enable may have posted while this older DELETE was // already in flight. Re-upsert after the DELETE acknowledgement // so the latest preference is also the final backend state. @@ -935,7 +950,7 @@ public actor PushRegistrationService: PushRegistering { $0 != operationGeneration || isEnabled } ?? false if preferenceWasSuperseded, - isEnabled, + enableIntentIsReconciled, let currentToken = cachedTokenHex, results.contains(where: { $0.0.tokenHex == currentToken && $0.1 @@ -956,21 +971,37 @@ public actor PushRegistrationService: PushRegistering { phase: AuthPhase ) async -> AuthenticatedSessionSnapshot? { let tokenProvider = tokenProvider - return try? await withAuthPhaseTimeout( - phase, - duration: sessionSnapshotTimeout, - clock: sessionSnapshotClock, - log: authLog, - registry: sessionSnapshotTimeoutRegistry, - blocksRetriesWhileTimedOutOperationActive: true - ) { - // This provider API only reads a coherent stored token pair or - // awaits bounded launch bootstrap. Cancelling it cannot leave an - // ambiguous server mutation behind. - try await tokenProvider.authenticatedSessionSnapshot() + do { + return try await withAuthPhaseTimeout( + phase, + duration: sessionSnapshotTimeout, + clock: sessionSnapshotClock, + log: authLog, + registry: sessionSnapshotTimeoutRegistry, + blocksRetriesWhileTimedOutOperationActive: true + ) { + // This provider API only reads a coherent stored token pair or + // awaits bounded launch bootstrap. Cancelling it cannot leave + // an ambiguous server mutation behind. + try await tokenProvider.authenticatedSessionSnapshot() + } + } catch let error as AuthError where error == .timedOut { + schedulePendingUnregisterRecovery() + return nil + } catch { + return nil } } + private var enableIntentIsReconciled: Bool { + guard isEnabled else { return false } + if coordinatorIntentEnabled == true { + return coordinatorIntentReconciledGeneration + == coordinatorIntentGeneration + } + return coordinatorIntentEnabled == nil + } + private func persistPendingUnregister(tokenHex: String, accountID: String) { let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID) var queue = pendingUnregisters @@ -989,6 +1020,29 @@ public actor PushRegistrationService: PushRegistering { } } + private func schedulePendingUnregisterRecovery() { + guard pendingUnregisterRecoveryTask == nil else { return } + let clock = sessionSnapshotClock + pendingUnregisterRecoveryTask = Task { [weak self, clock] in + do { + // AuthPhaseTimeoutRegistry holds a timed-out phase for 30s. + // Wait past that lease before asking the worker to retry. + try await clock.sleep(for: .seconds(31)) + } catch { + return + } + guard !Task.isCancelled, let self else { return } + await self.finishPendingUnregisterRecovery() + } + } + + private func finishPendingUnregisterRecovery() { + pendingUnregisterRecoveryTask = nil + guard !pendingUnregisters.isEmpty + || pendingUnregisterOverflowCount > 0 else { return } + schedulePendingUnregisterContinuation() + } + private func runPendingUnregisterContinuation() async { unregisterDrainTask = nil await retryPendingUnregisterIfPossible() From 514684743a74b6213a5abaa6a24cf0961419bf3f Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:58:21 -0700 Subject: [PATCH 111/117] fix(push): preserve generation through cleanup recovery --- .../Push/PushRegistrationService.swift | 46 +++++++++++++++---- .../MobilePushCoordinator.swift | 15 ++---- 2 files changed, 42 insertions(+), 19 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 8f99dec334a..548fb2e0743 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -68,6 +68,8 @@ public actor PushRegistrationService: PushRegistering { private var coordinatorIntentEnabled: Bool? private var coordinatorIntentReconciledGeneration: UInt64? private var pendingUnregisterRecoveryTask: Task? + private var pendingUnregisterRecoveryGeneration: UUID? + private var unregisterDrainPreferenceGeneration: UUID? // Actor reentrancy lets a second lifecycle callback enter while the first // POST is suspended in URLSession. Keep one in-flight upload per token so @@ -383,6 +385,7 @@ public actor PushRegistrationService: PushRegistering { // Treat direct cleanup retries as the current opt-out operation too, // so a newer enable can supersede an in-flight DELETE and trigger the // same final re-upload repair as coordinator-owned cleanup. + cancelRetry() await unregisterFromServer( preferenceGeneration: operationGeneration, requiresDisabledPreference: false @@ -409,7 +412,8 @@ public actor PushRegistrationService: PushRegistering { ) } let session = await boundedSessionSnapshot( - phase: .pushUnregistrationSession + phase: .pushUnregistrationSession, + recoveryGeneration: preferenceGeneration ) if let preferenceGeneration, preferenceGeneration != operationGeneration @@ -893,7 +897,8 @@ public actor PushRegistrationService: PushRegistering { preferenceGeneration: UUID? = nil ) async { guard let session = await boundedSessionSnapshot( - phase: .pushUnregistrationSession + phase: .pushUnregistrationSession, + recoveryGeneration: preferenceGeneration ) else { return } if let preferenceGeneration, preferenceGeneration != operationGeneration || isEnabled { @@ -963,12 +968,15 @@ public actor PushRegistrationService: PushRegistering { guard !preferenceWasSuperseded else { return } if matching.count > batch.count, results.contains(where: { $0.1 }) { - schedulePendingUnregisterContinuation() + schedulePendingUnregisterContinuation( + preferenceGeneration: preferenceGeneration + ) } } private func boundedSessionSnapshot( - phase: AuthPhase + phase: AuthPhase, + recoveryGeneration: UUID? = nil ) async -> AuthenticatedSessionSnapshot? { let tokenProvider = tokenProvider do { @@ -986,7 +994,9 @@ public actor PushRegistrationService: PushRegistering { try await tokenProvider.authenticatedSessionSnapshot() } } catch let error as AuthError where error == .timedOut { - schedulePendingUnregisterRecovery() + schedulePendingUnregisterRecovery( + preferenceGeneration: recoveryGeneration + ) return nil } catch { return nil @@ -1011,7 +1021,12 @@ public actor PushRegistrationService: PushRegistering { storePendingUnregisters(queue) } - private func schedulePendingUnregisterContinuation() { + private func schedulePendingUnregisterContinuation( + preferenceGeneration: UUID? = nil + ) { + if let preferenceGeneration { + unregisterDrainPreferenceGeneration = preferenceGeneration + } guard unregisterDrainTask == nil else { return } unregisterDrainTask = Task { [weak self] in await Task.yield() @@ -1020,7 +1035,12 @@ public actor PushRegistrationService: PushRegistering { } } - private func schedulePendingUnregisterRecovery() { + private func schedulePendingUnregisterRecovery( + preferenceGeneration: UUID? + ) { + if let preferenceGeneration { + pendingUnregisterRecoveryGeneration = preferenceGeneration + } guard pendingUnregisterRecoveryTask == nil else { return } let clock = sessionSnapshotClock pendingUnregisterRecoveryTask = Task { [weak self, clock] in @@ -1038,14 +1058,22 @@ public actor PushRegistrationService: PushRegistering { private func finishPendingUnregisterRecovery() { pendingUnregisterRecoveryTask = nil + let generation = pendingUnregisterRecoveryGeneration + pendingUnregisterRecoveryGeneration = nil guard !pendingUnregisters.isEmpty || pendingUnregisterOverflowCount > 0 else { return } - schedulePendingUnregisterContinuation() + schedulePendingUnregisterContinuation( + preferenceGeneration: generation + ) } private func runPendingUnregisterContinuation() async { unregisterDrainTask = nil - await retryPendingUnregisterIfPossible() + let generation = unregisterDrainPreferenceGeneration + unregisterDrainPreferenceGeneration = nil + await retryPendingUnregisterIfPossible( + preferenceGeneration: generation + ) } private func clearPendingUnregister( diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index c4dede38c6f..b97cd40d039 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -350,16 +350,11 @@ public final class MobilePushCoordinator { generation: UInt64, registrationIntentOwnedByService: Bool = false ) async -> Bool { - // Settings already refreshes this snapshot when it appears. Reuse it - // for its toggle path so repeated taps cannot accumulate behind an - // arbitrary settings read. The only long suspension left is Apple's - // single system authorization prompt, which blocks further app taps. - let priorSettings: MobilePushSystemSettings - if registrationIntentOwnedByService { - priorSettings = systemSettings - } else { - priorSettings = await notificationSettings() - } + // Read the OS state immediately before reconciling. The cached value + // can be stale when the user changes notification permission in iOS + // Settings while the app is suspended or a readiness refresh is still + // in flight. + let priorSettings = await notificationSettings() guard isCurrentSettingsIntent(generation, enabled: true) else { return false } From d212b57054dc75e8bccc369eb658cbf2f7c13712 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:13:06 -0700 Subject: [PATCH 112/117] fix(push): index durable cleanup obligations --- .../Push/PendingUnregisterStore.swift | 189 ++++++ .../Push/PushRegistrationService.swift | 550 +++--------------- .../PushRegistrationServiceTests.swift | 70 +-- .../MobilePushCoordinator.swift | 21 +- .../MobilePushCoordinatorLifecycleTests.swift | 2 + 5 files changed, 303 insertions(+), 529 deletions(-) create mode 100644 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PendingUnregisterStore.swift diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PendingUnregisterStore.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PendingUnregisterStore.swift new file mode 100644 index 00000000000..3a33bd0678f --- /dev/null +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PendingUnregisterStore.swift @@ -0,0 +1,189 @@ +import Foundation +import SQLite3 + +struct PendingUnregister: Codable, Hashable, Sendable { + let tokenHex: String + let accountID: String +} + +/// Indexed durable storage for privacy-sensitive push cleanup obligations. +/// +/// UserDefaults retains its domain in memory and is a poor fit for a queue that +/// can outlive several accounts. SQLite keeps the working set bounded: retries +/// read at most their requested batch, token reassignment uses an indexed +/// delete, and the uniqueness constraint compacts duplicate obligations. +final class PendingUnregisterStore { + private var database: OpaquePointer? + + init(databaseURL: URL) throws { + try FileManager.default.createDirectory( + at: databaseURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + var opened: OpaquePointer? + let flags = SQLITE_OPEN_CREATE | SQLITE_OPEN_READWRITE | SQLITE_OPEN_FULLMUTEX + guard sqlite3_open_v2(databaseURL.path, &opened, flags, nil) == SQLITE_OK, + let opened else { + if let opened { sqlite3_close_v2(opened) } + throw PendingUnregisterStoreError.openFailed + } + database = opened + do { + try execute("PRAGMA journal_mode=WAL;") + try execute("PRAGMA synchronous=FULL;") + try execute("PRAGMA auto_vacuum=INCREMENTAL;") + try execute( + """ + CREATE TABLE IF NOT EXISTS pending_unregister ( + sequence INTEGER PRIMARY KEY AUTOINCREMENT, + token_hex TEXT NOT NULL, + account_id TEXT NOT NULL, + UNIQUE(token_hex, account_id) + ); + """ + ) + try execute( + """ + CREATE INDEX IF NOT EXISTS pending_unregister_account_sequence + ON pending_unregister(account_id, sequence); + """ + ) + try execute( + """ + CREATE INDEX IF NOT EXISTS pending_unregister_token + ON pending_unregister(token_hex); + """ + ) + } catch { + sqlite3_close_v2(opened) + database = nil + throw error + } + } + + deinit { + if let database { + sqlite3_close_v2(database) + } + } + + @discardableResult + func insert(_ entry: PendingUnregister) -> Bool { + guard let statement = prepare( + """ + INSERT OR IGNORE INTO pending_unregister(token_hex, account_id) + VALUES (?, ?); + """ + ) else { return false } + defer { sqlite3_finalize(statement) } + guard bind(entry.tokenHex, to: statement, at: 1), + bind(entry.accountID, to: statement, at: 2), + sqlite3_step(statement) == SQLITE_DONE else { return false } + return true + } + + func batch(accountID: String, limit: Int) -> [PendingUnregister] { + guard limit > 0, let statement = prepare( + """ + SELECT token_hex, account_id + FROM pending_unregister + WHERE account_id = ? + ORDER BY sequence + LIMIT ?; + """ + ) else { return [] } + defer { sqlite3_finalize(statement) } + guard bind(accountID, to: statement, at: 1), + sqlite3_bind_int64(statement, 2, Int64(limit)) == SQLITE_OK else { + return [] + } + var result: [PendingUnregister] = [] + result.reserveCapacity(limit) + while sqlite3_step(statement) == SQLITE_ROW { + guard let token = sqlite3_column_text(statement, 0), + let account = sqlite3_column_text(statement, 1) else { + continue + } + result.append(PendingUnregister( + tokenHex: String(cString: token), + accountID: String(cString: account) + )) + } + return result + } + + @discardableResult + func remove(tokenHex: String, accountID: String) -> Bool { + guard let statement = prepare( + """ + DELETE FROM pending_unregister + WHERE token_hex = ? AND account_id = ?; + """ + ) else { return false } + defer { sqlite3_finalize(statement) } + guard bind(tokenHex, to: statement, at: 1), + bind(accountID, to: statement, at: 2), + sqlite3_step(statement) == SQLITE_DONE else { return false } + compactFreedPages() + return true + } + + @discardableResult + func removeAll(tokenHex: String) -> Bool { + guard let statement = prepare( + "DELETE FROM pending_unregister WHERE token_hex = ?;" + ) else { return false } + defer { sqlite3_finalize(statement) } + guard bind(tokenHex, to: statement, at: 1), + sqlite3_step(statement) == SQLITE_DONE else { return false } + compactFreedPages() + return true + } + + var hasEntries: Bool { + guard let statement = prepare( + "SELECT 1 FROM pending_unregister LIMIT 1;" + ) else { return false } + defer { sqlite3_finalize(statement) } + return sqlite3_step(statement) == SQLITE_ROW + } + + private func compactFreedPages() { + _ = sqlite3_exec(database, "PRAGMA incremental_vacuum(4);", nil, nil, nil) + } + + private func execute(_ sql: String) throws { + guard sqlite3_exec(database, sql, nil, nil, nil) == SQLITE_OK else { + throw PendingUnregisterStoreError.schemaFailed + } + } + + private func prepare(_ sql: String) -> OpaquePointer? { + var statement: OpaquePointer? + guard sqlite3_prepare_v2(database, sql, -1, &statement, nil) == SQLITE_OK else { + return nil + } + return statement + } + + private func bind( + _ value: String, + to statement: OpaquePointer, + at index: Int32 + ) -> Bool { + value.withCString { pointer in + sqlite3_bind_text( + statement, + index, + pointer, + -1, + unsafeBitCast(-1, to: sqlite3_destructor_type.self) + ) == SQLITE_OK + } + } +} + +private enum PendingUnregisterStoreError: Error { + case openFailed + case schemaFailed +} diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 548fb2e0743..4b4d99b3dfa 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -3,32 +3,6 @@ import OSLog private let pushLog = Logger(subsystem: "ai.manaflow.cmux", category: "push") -private func pendingUnregisterOverflowPageKey( - accountID: String, - page: Int -) -> String { - let base = "cmux.notifications.pendingUnregisters.v4.overflow.\(accountID)" - return page == 0 ? base : "\(base).page\(page)" -} - -private func pendingUnregisterOverflowPageCountKey(accountID: String) -> String { - "cmux.notifications.pendingUnregisters.v4.overflowPages.\(accountID)" -} - -private func pendingUnregisterOverflowTokenIndexPageKey( - tokenHex: String, - page: Int -) -> String { - let base = "cmux.notifications.pendingUnregisters.v4.token.\(tokenHex)" - return page == 0 ? base : "\(base).page\(page)" -} - -private func pendingUnregisterOverflowTokenIndexPageCountKey( - tokenHex: String -) -> String { - "cmux.notifications.pendingUnregisters.v4.tokenPages.\(tokenHex)" -} - /// Owns the push opt-in state and the device-token sync with the cmux web API. /// /// Replaces the iOS `NotificationManager.shared` singleton and its @@ -47,6 +21,7 @@ public actor PushRegistrationService: PushRegistering { private let bundleID: String private let apnsEnvironment: String private let defaults: UserDefaults + private let pendingUnregisterStore: PendingUnregisterStore? private let session: URLSession private let retryDelays: [Duration] private let retryJitter: @Sendable (ClosedRange) -> Double @@ -92,11 +67,28 @@ public actor PushRegistrationService: PushRegistering { private static let pendingUnregisterAccountIDKey = "cmux.notifications.pendingUnregisterAccountID" private static let pendingUnregisterQueueKey = "cmux.notifications.pendingUnregisters.v2" - private static let pendingUnregisterOverflowCountKey = - "cmux.notifications.pendingUnregisterOverflowCount.v4" private static let pendingUnregisterAttemptBudget = 4 private static let pendingUnregisterActiveLimit = 200 - private static let pendingUnregisterOverflowPageSize = 200 + + private static func defaultPendingUnregisterStoreURL( + suiteName: String?, + bundleID: String + ) -> URL { + let namespace = (suiteName ?? bundleID).map { character in + character.isLetter || character.isNumber || character == "-" + ? character + : "_" + } + let root = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first ?? FileManager.default.temporaryDirectory + return root + .appendingPathComponent("cmux", isDirectory: true) + .appendingPathComponent( + "push-cleanup-\(String(namespace)).sqlite3" + ) + } /// Creates a push registration service. /// @@ -117,6 +109,7 @@ public actor PushRegistrationService: PushRegistering { bundleID: String, apnsEnvironment: String, suiteName: String? = nil, + pendingUnregisterStoreURL: URL? = nil, session: sending URLSession = .shared, retryDelays: [Duration] = [ .seconds(1), @@ -142,7 +135,18 @@ public actor PushRegistrationService: PushRegistering { } else { self.defaults = .standard } - Self.migrateLegacyPendingUnregisters(in: self.defaults) + let storeURL = pendingUnregisterStoreURL + ?? Self.defaultPendingUnregisterStoreURL( + suiteName: suiteName, + bundleID: bundleID + ) + self.pendingUnregisterStore = try? PendingUnregisterStore( + databaseURL: storeURL + ) + Self.migrateLegacyPendingUnregisters( + in: self.defaults, + overflowStore: self.pendingUnregisterStore + ) self.session = session self.retryDelays = retryDelays self.retryJitter = retryJitter @@ -171,7 +175,7 @@ public actor PushRegistrationService: PushRegistering { )?.isEmpty == false if !isEnabled, !pendingUnregisters.isEmpty - || pendingUnregisterOverflowCount > 0 + || hasPendingUnregisterOverflow || hasKnownRegistration { coordinatorIntentEnabled = false disableIntentReconciliationRequested = true @@ -337,8 +341,16 @@ public actor PushRegistrationService: PushRegistering { defaults.removeObject(forKey: Self.registeredAccountIDKey) } defaults.set(hex, forKey: Self.cachedTokenKey) - guard isEnabled else { - publish(.disabled) + guard canUploadForCurrentIntent else { + publish( + isEnabled + ? PushRegistrationSnapshot( + isEnabled: true, + hasDeviceToken: true, + backendState: .registrationRequired + ) + : .disabled + ) return } // A repeated callback for the same cached token should cancel only a @@ -358,6 +370,16 @@ public actor PushRegistrationService: PushRegistering { publish(.disabled) return } + guard canUploadForCurrentIntent else { + publish(PushRegistrationSnapshot( + isEnabled: true, + hasDeviceToken: cachedTokenHex != nil, + backendState: cachedTokenHex == nil + ? .awaitingDeviceToken + : .registrationRequired + )) + return + } guard let hex = cachedTokenHex else { publish(PushRegistrationSnapshot( isEnabled: true, @@ -524,6 +546,7 @@ public actor PushRegistrationService: PushRegistering { tokenHex: String, replacingGeneration: UUID? = nil ) async { + guard canUploadForCurrentIntent else { return } let requestedAccountID = (try? await tokenProvider .authenticatedSessionSnapshot())?.accountID if let uploadTask, @@ -563,7 +586,8 @@ public actor PushRegistrationService: PushRegistering { generation: UUID, remainingDelays: [Duration] ) async { - guard isEnabled, generation == operationGeneration, + guard canUploadForCurrentIntent, + generation == operationGeneration, cachedTokenHex == tokenHex else { return } publish(PushRegistrationSnapshot( isEnabled: true, @@ -637,7 +661,7 @@ public actor PushRegistrationService: PushRegistering { // old credentials. if previousOwnerID != nil || pendingUnregisters.contains( where: { $0.tokenHex == tokenHex } - ) || pendingUnregisterOverflowCount > 0 { + ) || hasPendingUnregisterOverflow { clearPendingUnregisterToken(tokenHex: tokenHex) } if pushServiceConfigured { @@ -1012,6 +1036,10 @@ public actor PushRegistrationService: PushRegistering { return coordinatorIntentEnabled == nil } + private var canUploadForCurrentIntent: Bool { + enableIntentIsReconciled + } + private func persistPendingUnregister(tokenHex: String, accountID: String) { let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID) var queue = pendingUnregisters @@ -1061,7 +1089,7 @@ public actor PushRegistrationService: PushRegistering { let generation = pendingUnregisterRecoveryGeneration pendingUnregisterRecoveryGeneration = nil guard !pendingUnregisters.isEmpty - || pendingUnregisterOverflowCount > 0 else { return } + || hasPendingUnregisterOverflow else { return } schedulePendingUnregisterContinuation( preferenceGeneration: generation ) @@ -1106,7 +1134,8 @@ public actor PushRegistrationService: PushRegistering { } private static func migrateLegacyPendingUnregisters( - in defaults: UserDefaults + in defaults: UserDefaults, + overflowStore: PendingUnregisterStore? ) { var entries = (defaults.data(forKey: pendingUnregisterQueueKey) .flatMap { try? JSONDecoder().decode( @@ -1130,16 +1159,12 @@ public actor PushRegistrationService: PushRegistering { for entry in entries.reversed() where seen.insert(entry).inserted { newestFirst.append(entry) } - let normalized = Array(newestFirst.reversed()) - let overflowCount = max( - 0, - normalized.count - pendingUnregisterActiveLimit - ) - let overflow = normalized.prefix(overflowCount) - for entry in overflow { - appendPendingUnregisterOverflow(entry, in: defaults) + var active = Array(newestFirst.reversed()) + while active.count > pendingUnregisterActiveLimit, + let first = active.first, + overflowStore?.insert(first) == true { + active.removeFirst() } - let active = Array(normalized.suffix(pendingUnregisterActiveLimit)) if active.isEmpty { defaults.removeObject(forKey: pendingUnregisterQueueKey) } else if let data = try? JSONEncoder().encode(active) { @@ -1155,18 +1180,12 @@ public actor PushRegistrationService: PushRegistering { for entry in entries.reversed() where seen.insert(entry).inserted { newestFirst.append(entry) } - let normalized = Array(newestFirst.reversed()) - let overflowCount = max( - 0, - normalized.count - Self.pendingUnregisterActiveLimit - ) - let overflow = normalized.prefix(overflowCount) - for entry in overflow { - appendPendingUnregisterOverflow(entry) + var active = Array(newestFirst.reversed()) + while active.count > Self.pendingUnregisterActiveLimit, + let first = active.first, + pendingUnregisterStore?.insert(first) == true { + active.removeFirst() } - let active = Array( - normalized.suffix(Self.pendingUnregisterActiveLimit) - ) if active.isEmpty { defaults.removeObject(forKey: Self.pendingUnregisterQueueKey) defaults.removeObject(forKey: Self.pendingUnregisterTokenKey) @@ -1180,262 +1199,18 @@ public actor PushRegistrationService: PushRegistering { defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey) } - private var pendingUnregisterOverflowCount: Int { - defaults.integer(forKey: Self.pendingUnregisterOverflowCountKey) - } - - private static func decodeOverflowPage( - accountID: String, - page: Int, - defaults: UserDefaults - ) -> [PendingUnregister] { - guard let data = defaults.data( - forKey: pendingUnregisterOverflowPageKey( - accountID: accountID, - page: page - ) - ), let decoded = try? JSONDecoder().decode( - [PendingUnregister].self, - from: data - ) else { return [] } - var seen = Set() - return Array(decoded.filter { seen.insert($0).inserted }.prefix( - pendingUnregisterOverflowPageSize - )) - } - - private static func storeOverflowPage( - _ entries: [PendingUnregister], - accountID: String, - page: Int, - defaults: UserDefaults - ) { - let key = pendingUnregisterOverflowPageKey( - accountID: accountID, - page: page - ) - if entries.isEmpty { - defaults.removeObject(forKey: key) - } else if let data = try? JSONEncoder().encode( - Array(entries.prefix(pendingUnregisterOverflowPageSize)) - ) { - defaults.set(data, forKey: key) - } - } - - private static func decodeTokenIndexPage( - tokenHex: String, - page: Int, - defaults: UserDefaults - ) -> [String] { - guard let data = defaults.data( - forKey: pendingUnregisterOverflowTokenIndexPageKey( - tokenHex: tokenHex, - page: page - ) - ), let decoded = try? JSONDecoder().decode( - [String].self, - from: data - ) else { return [] } - return Array(decoded.prefix(pendingUnregisterOverflowPageSize)) - } - - private static func storeTokenIndexPage( - _ entries: [String], - tokenHex: String, - page: Int, - defaults: UserDefaults - ) { - let key = pendingUnregisterOverflowTokenIndexPageKey( - tokenHex: tokenHex, - page: page - ) - if entries.isEmpty { - defaults.removeObject(forKey: key) - } else if let data = try? JSONEncoder().encode( - Array(entries.prefix(pendingUnregisterOverflowPageSize)) - ) { - defaults.set(data, forKey: key) - } - } - - private static func incrementOverflowCount( - by delta: Int, - defaults: UserDefaults - ) { - let total = max( - 0, - defaults.integer(forKey: pendingUnregisterOverflowCountKey) + delta - ) - if total == 0 { - defaults.removeObject(forKey: pendingUnregisterOverflowCountKey) - } else { - defaults.set(total, forKey: pendingUnregisterOverflowCountKey) - } - } - - private static func overflowPageCount( - accountID: String, - defaults: UserDefaults - ) -> Int { - let stored = defaults.integer( - forKey: pendingUnregisterOverflowPageCountKey(accountID: accountID) - ) - if stored > 0 { return stored } - return defaults.data( - forKey: pendingUnregisterOverflowPageKey( - accountID: accountID, - page: 0 - ) - ) == nil ? 0 : 1 - } - - private static func tokenIndexPageCount( - tokenHex: String, - defaults: UserDefaults - ) -> Int { - let stored = defaults.integer( - forKey: pendingUnregisterOverflowTokenIndexPageCountKey( - tokenHex: tokenHex - ) - ) - if stored > 0 { return stored } - return defaults.data( - forKey: pendingUnregisterOverflowTokenIndexPageKey( - tokenHex: tokenHex, - page: 0 - ) - ) == nil ? 0 : 1 - } - - private static func appendTokenIndex( - tokenHex: String, - accountID: String, - defaults: UserDefaults - ) { - let pageCount = tokenIndexPageCount(tokenHex: tokenHex, defaults: defaults) - for page in 0.. [PendingUnregister] { - guard limit > 0 else { return [] } - let pageCount = Self.overflowPageCount( + pendingUnregisterStore?.batch( accountID: accountID, - defaults: defaults - ) - var result: [PendingUnregister] = [] - var seen = Set() - for page in 0.. 0, - Self.decodeOverflowPage( - accountID: accountID, - page: remainingPages - 1, - defaults: defaults - ).isEmpty { - defaults.removeObject( - forKey: pendingUnregisterOverflowPageKey( - accountID: accountID, - page: remainingPages - 1 - ) - ) - remainingPages -= 1 - } - if remainingPages == 0 { - defaults.removeObject( - forKey: pendingUnregisterOverflowPageCountKey( - accountID: accountID - ) - ) - } else { - defaults.set( - remainingPages, - forKey: pendingUnregisterOverflowPageCountKey( - accountID: accountID - ) - ) - } - if !overflowContains( - tokenHex: tokenHex, - accountID: accountID - ) { - removeTokenIndex(tokenHex: tokenHex, accountID: accountID) - } - Self.incrementOverflowCount(by: -1, defaults: defaults) - return - } - // The index and page are separate UserDefaults writes. If a process - // dies between them, discard the stale index reference so cleanup - // remains finite and the next registration cannot spin forever. - removeTokenIndex(tokenHex: tokenHex, accountID: accountID) - } - - private func overflowContains( - tokenHex: String, - accountID: String - ) -> Bool { - let pageCount = Self.overflowPageCount( - accountID: accountID, - defaults: defaults - ) - for page in 0.. 0, - Self.decodeTokenIndexPage( - tokenHex: tokenHex, - page: remainingPages - 1, - defaults: defaults - ).isEmpty { - defaults.removeObject( - forKey: pendingUnregisterOverflowTokenIndexPageKey( - tokenHex: tokenHex, - page: remainingPages - 1 - ) - ) - remainingPages -= 1 - } - if remainingPages == 0 { - defaults.removeObject( - forKey: pendingUnregisterOverflowTokenIndexPageCountKey( - tokenHex: tokenHex - ) - ) - } else { - defaults.set( - remainingPages, - forKey: pendingUnregisterOverflowTokenIndexPageCountKey( - tokenHex: tokenHex - ) - ) - } - return - } - } - - private func firstOverflowAccount(for tokenHex: String) -> String? { - let pageCount = Self.tokenIndexPageCount( - tokenHex: tokenHex, - defaults: defaults + accountID: accountID ) - for page in 0.. = ContinuousClock() + sessionSnapshotClock: any Clock = ContinuousClock(), + pendingUnregisterStoreURL: URL? = nil ) -> (PushRegistrationService, UserDefaults) { let defaults = UserDefaults(suiteName: suite)! seedDefaults(defaults) @@ -211,6 +212,7 @@ actor RetryDelayRecorder { bundleID: "dev.cmux.ios.push1", apnsEnvironment: "sandbox", suiteName: suite, + pendingUnregisterStoreURL: pendingUnregisterStoreURL, session: URLSession(configuration: configuration), retryDelays: retryDelays, retryJitter: { _ in 1 }, @@ -1492,6 +1494,9 @@ actor RetryDelayRecorder { } @Test func pendingCleanupStorageKeepsNewestTwoHundredEntries() async throws { + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("push-overflow-\(UUID().uuidString).sqlite3") + defer { try? FileManager.default.removeItem(at: storeURL) } let existing = (0..<200).map { index in [ "tokenHex": String(format: "%064x", index), @@ -1517,7 +1522,8 @@ actor RetryDelayRecorder { "current-account", forKey: "cmux.notifications.registeredAccountID" ) - } + }, + pendingUnregisterStoreURL: storeURL ) await service.applyEnabledIntent(false, generation: 1) @@ -1532,19 +1538,26 @@ actor RetryDelayRecorder { #expect(stored.count == 200) #expect(stored.first?["accountID"] == "historical-account-1") #expect(stored.last?["accountID"] == "current-account") - let overflowData = try #require(defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v4.overflow.historical-account-0" - )) - let overflow = try #require( - JSONSerialization.jsonObject(with: overflowData) - as? [[String: String]] + let overflow = try PendingUnregisterStore( + databaseURL: storeURL + ).batch( + accountID: "historical-account-0", + limit: 2 ) - #expect(overflow.map { $0["accountID"] } == ["historical-account-0"]) + #expect(overflow.map(\.accountID) == ["historical-account-0"]) } - @Test func successfulReassignmentClearsOldTombstoneWithoutLosingNewOwner() async { + @Test func successfulReassignmentClearsOldTombstoneWithoutLosingNewOwner() async throws { await PushRegistrationURLProtocol.script.reset([.response(200)]) let suite = "push-owner-reassignment-\(UUID().uuidString)" + let storeURL = FileManager.default.temporaryDirectory + .appendingPathComponent("push-owner-\(UUID().uuidString).sqlite3") + defer { try? FileManager.default.removeItem(at: storeURL) } + let overflowStore = try PendingUnregisterStore(databaseURL: storeURL) + #expect(overflowStore.insert(PendingUnregister( + tokenHex: "ab", + accountID: "old-user" + ))) let (service, defaults) = makeScriptedService( tokenProvider: FakeTokenProvider( access: "new-access", @@ -1566,30 +1579,8 @@ actor RetryDelayRecorder { "old-user", forKey: "cmux.notifications.pendingUnregisterAccountID" ) - defaults.set( - try? JSONSerialization.data(withJSONObject: [[ - "tokenHex": "ab", - "accountID": "old-user", - ]]), - forKey: "cmux.notifications.pendingUnregisters.v4.overflow.old-user" - ) - defaults.set( - 1, - forKey: "cmux.notifications.pendingUnregisters.v4.overflowPages.old-user" - ) - defaults.set( - try? JSONSerialization.data(withJSONObject: ["old-user"]), - forKey: "cmux.notifications.pendingUnregisters.v4.token.ab" - ) - defaults.set( - 1, - forKey: "cmux.notifications.pendingUnregisters.v4.tokenPages.ab" - ) - defaults.set( - 1, - forKey: "cmux.notifications.pendingUnregisterOverflowCount.v4" - ) - } + }, + pendingUnregisterStoreURL: storeURL ) await service.setEnabled(true) @@ -1606,16 +1597,7 @@ actor RetryDelayRecorder { defaults.string(forKey: "cmux.notifications.pendingUnregisterAccountID") == nil ) - #expect( - defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v4.overflow.old-user" - ) == nil - ) - #expect( - defaults.integer( - forKey: "cmux.notifications.pendingUnregisterOverflowCount.v4" - ) == 0 - ) + #expect(!overflowStore.hasEntries) } @Test func legacySingleTombstoneMigratesOnceAndIsRemovedAfterSuccess() async { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index b97cd40d039..faa9346334a 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -303,11 +303,7 @@ public final class MobilePushCoordinator { /// and persist the flag. Returns whether authorization was granted. @discardableResult public func enable() async -> Bool { - let generation = beginSettingsIntent(true) - return await reconcileEnable( - trigger: "settings_toggle", - generation: generation - ) + await setEnabledIntent(true).value } /// Requests or recovers push only after the authenticated workspace shell @@ -422,20 +418,7 @@ public final class MobilePushCoordinator { /// Opt out: stop receiving pushes and remove the token server-side. public func disable() async { - let generation = beginSettingsIntent(false) - await reconcileDisable(generation: generation) - } - - private func reconcileDisable(generation: UInt64) async { - await registration.setEnabled(false) - guard isCurrentSettingsIntent(generation, enabled: false) else { - return - } - let snapshot = await registration.snapshot - guard isCurrentSettingsIntent(generation, enabled: false) else { - return - } - registrationSnapshot = snapshot + _ = await setEnabledIntent(false).value } /// Hand a freshly-registered APNs token to the network layer. diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 37fddb7b62c..30d7f10b5e8 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -46,6 +46,8 @@ private actor LifecyclePushRegistration: PushRegistering { } func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async { + guard generation >= intentGeneration else { return } + await setEnabledGate?.pause() guard generation >= intentGeneration else { return } intentGeneration = generation apply(enabled) From d1ef41b5356579bb2c36e9600137cdf4a4579979 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:36:47 -0700 Subject: [PATCH 113/117] fix(push): make cleanup migration transactional --- .../Push/PendingUnregisterStore.swift | 39 ++++++ .../Push/PushRegistrationService.swift | 90 +++++------- .../PushRegistrationServiceTests.swift | 22 ++- .../MobilePushCoordinator.swift | 128 ++++++++++++++++-- 4 files changed, 203 insertions(+), 76 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PendingUnregisterStore.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PendingUnregisterStore.swift index 3a33bd0678f..bf4074bc2b8 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PendingUnregisterStore.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PendingUnregisterStore.swift @@ -82,6 +82,45 @@ final class PendingUnregisterStore { return true } + /// Inserts a legacy queue in one durable transaction. This keeps launch + /// migration linear and pays at most one FULL-synchronous commit. + @discardableResult + func insertAll(_ entries: [PendingUnregister]) -> Bool { + guard !entries.isEmpty else { return true } + guard sqlite3_exec( + database, + "BEGIN IMMEDIATE;", + nil, + nil, + nil + ) == SQLITE_OK else { return false } + var committed = false + defer { + if !committed { + _ = sqlite3_exec(database, "ROLLBACK;", nil, nil, nil) + } + } + guard let statement = prepare( + """ + INSERT OR IGNORE INTO pending_unregister(token_hex, account_id) + VALUES (?, ?); + """ + ) else { return false } + defer { sqlite3_finalize(statement) } + for entry in entries { + sqlite3_reset(statement) + sqlite3_clear_bindings(statement) + guard bind(entry.tokenHex, to: statement, at: 1), + bind(entry.accountID, to: statement, at: 2), + sqlite3_step(statement) == SQLITE_DONE else { return false } + } + guard sqlite3_exec(database, "COMMIT;", nil, nil, nil) == SQLITE_OK else { + return false + } + committed = true + return true + } + func batch(accountID: String, limit: Int) -> [PendingUnregister] { guard limit > 0, let statement = prepare( """ diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 4b4d99b3dfa..cd79cc805b0 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -68,7 +68,6 @@ public actor PushRegistrationService: PushRegistering { private static let pendingUnregisterQueueKey = "cmux.notifications.pendingUnregisters.v2" private static let pendingUnregisterAttemptBudget = 4 - private static let pendingUnregisterActiveLimit = 200 private static func defaultPendingUnregisterStoreURL( suiteName: String?, @@ -174,9 +173,7 @@ public actor PushRegistrationService: PushRegistering { forKey: Self.registeredAccountIDKey )?.isEmpty == false if !isEnabled, - !pendingUnregisters.isEmpty - || hasPendingUnregisterOverflow - || hasKnownRegistration { + hasPendingUnregisters || hasKnownRegistration { coordinatorIntentEnabled = false disableIntentReconciliationRequested = true scheduleDisableIntentReconciliation() @@ -659,9 +656,7 @@ public actor PushRegistrationService: PushRegistering { // current account also removes any old-account association, so a // pending tombstone for this token is fulfilled without applying // old credentials. - if previousOwnerID != nil || pendingUnregisters.contains( - where: { $0.tokenHex == tokenHex } - ) || hasPendingUnregisterOverflow { + if previousOwnerID != nil || hasPendingUnregisters { clearPendingUnregisterToken(tokenHex: tokenHex) } if pushServiceConfigured { @@ -931,12 +926,14 @@ public actor PushRegistrationService: PushRegistering { let currentAccountID = session.accountID var seen = Set() let matching = ( - pendingUnregisters.filter { $0.accountID == currentAccountID } - + pendingUnregisterOverflowBatch( + pendingUnregisterOverflowBatch( accountID: currentAccountID, // Keep one lookahead entry so a bounded batch can tell // whether another continuation is required. limit: Self.pendingUnregisterAttemptBudget + 1 + ) + pendingUnregisterFallbackBatch( + accountID: currentAccountID, + limit: Self.pendingUnregisterAttemptBudget + 1 ) ).filter { seen.insert($0).inserted @@ -1042,9 +1039,15 @@ public actor PushRegistrationService: PushRegistering { private func persistPendingUnregister(tokenHex: String, accountID: String) { let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID) + if pendingUnregisterStore?.insert(entry) == true { + // SQLite is durable before the legacy fallback is removed. + storePendingUnregisters( + pendingUnregisters.filter { $0 != entry } + ) + return + } var queue = pendingUnregisters queue.removeAll { $0 == entry } - removePendingUnregisterOverflow(entry) queue.append(entry) storePendingUnregisters(queue) } @@ -1088,8 +1091,7 @@ public actor PushRegistrationService: PushRegistering { pendingUnregisterRecoveryTask = nil let generation = pendingUnregisterRecoveryGeneration pendingUnregisterRecoveryGeneration = nil - guard !pendingUnregisters.isEmpty - || hasPendingUnregisterOverflow else { return } + guard hasPendingUnregisters else { return } schedulePendingUnregisterContinuation( preferenceGeneration: generation ) @@ -1108,14 +1110,13 @@ public actor PushRegistrationService: PushRegistering { tokenHex: String, accountID: String ) { - let filtered = pendingUnregisters.filter { entry in - entry.tokenHex != tokenHex || entry.accountID != accountID - } - storePendingUnregisters(filtered) - removePendingUnregisterOverflow( + _ = pendingUnregisterStore?.remove( tokenHex: tokenHex, accountID: accountID ) + storePendingUnregisters(pendingUnregisters.filter { entry in + entry.tokenHex != tokenHex || entry.accountID != accountID + }) } private var pendingUnregisters: [PendingUnregister] { @@ -1159,17 +1160,13 @@ public actor PushRegistrationService: PushRegistering { for entry in entries.reversed() where seen.insert(entry).inserted { newestFirst.append(entry) } - var active = Array(newestFirst.reversed()) - while active.count > pendingUnregisterActiveLimit, - let first = active.first, - overflowStore?.insert(first) == true { - active.removeFirst() - } - if active.isEmpty { - defaults.removeObject(forKey: pendingUnregisterQueueKey) - } else if let data = try? JSONEncoder().encode(active) { - defaults.set(data, forKey: pendingUnregisterQueueKey) + let normalized = Array(newestFirst.reversed()) + guard normalized.isEmpty + || overflowStore?.insertAll(normalized) == true else { + // Keep every legacy key intact when durable migration fails. + return } + defaults.removeObject(forKey: pendingUnregisterQueueKey) defaults.removeObject(forKey: pendingUnregisterTokenKey) defaults.removeObject(forKey: pendingUnregisterAccountIDKey) } @@ -1180,27 +1177,22 @@ public actor PushRegistrationService: PushRegistering { for entry in entries.reversed() where seen.insert(entry).inserted { newestFirst.append(entry) } - var active = Array(newestFirst.reversed()) - while active.count > Self.pendingUnregisterActiveLimit, - let first = active.first, - pendingUnregisterStore?.insert(first) == true { - active.removeFirst() - } - if active.isEmpty { + let normalized = Array(newestFirst.reversed()) + if normalized.isEmpty { defaults.removeObject(forKey: Self.pendingUnregisterQueueKey) defaults.removeObject(forKey: Self.pendingUnregisterTokenKey) defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey) return } - if let data = try? JSONEncoder().encode(active) { + if let data = try? JSONEncoder().encode(normalized) { defaults.set(data, forKey: Self.pendingUnregisterQueueKey) } defaults.removeObject(forKey: Self.pendingUnregisterTokenKey) defaults.removeObject(forKey: Self.pendingUnregisterAccountIDKey) } - private var hasPendingUnregisterOverflow: Bool { - pendingUnregisterStore?.hasEntries == true + private var hasPendingUnregisters: Bool { + pendingUnregisterStore?.hasEntries == true || !pendingUnregisters.isEmpty } private func pendingUnregisterOverflowBatch( @@ -1213,28 +1205,20 @@ public actor PushRegistrationService: PushRegistering { ) ?? [] } - private func removePendingUnregisterOverflow(_ entry: PendingUnregister) { - removePendingUnregisterOverflow( - tokenHex: entry.tokenHex, - accountID: entry.accountID - ) - } - - private func removePendingUnregisterOverflow( - tokenHex: String, - accountID: String - ) { - _ = pendingUnregisterStore?.remove( - tokenHex: tokenHex, - accountID: accountID - ) + private func pendingUnregisterFallbackBatch( + accountID: String, + limit: Int + ) -> [PendingUnregister] { + Array(pendingUnregisters.lazy.filter { + $0.accountID == accountID + }.prefix(limit)) } private func clearPendingUnregisterToken(tokenHex: String) { + _ = pendingUnregisterStore?.removeAll(tokenHex: tokenHex) storePendingUnregisters( pendingUnregisters.filter { $0.tokenHex != tokenHex } ) - _ = pendingUnregisterStore?.removeAll(tokenHex: tokenHex) } private func clearRegisteredOwner( diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index bdc7545bda6..86efc59288d 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1493,7 +1493,7 @@ actor RetryDelayRecorder { #expect(deletedTokens == ["aa", "bb", "aa", "bb"]) } - @Test func pendingCleanupStorageKeepsNewestTwoHundredEntries() async throws { + @Test func pendingCleanupMigrationMovesEveryEntryToIndexedStore() async throws { let storeURL = FileManager.default.temporaryDirectory .appendingPathComponent("push-overflow-\(UUID().uuidString).sqlite3") defer { try? FileManager.default.removeItem(at: storeURL) } @@ -1528,23 +1528,17 @@ actor RetryDelayRecorder { await service.applyEnabledIntent(false, generation: 1) - let data = try #require(defaults.data( + #expect(defaults.data( forKey: "cmux.notifications.pendingUnregisters.v2" - )) - let stored = try #require( - JSONSerialization.jsonObject(with: data) - as? [[String: String]] - ) - #expect(stored.count == 200) - #expect(stored.first?["accountID"] == "historical-account-1") - #expect(stored.last?["accountID"] == "current-account") - let overflow = try PendingUnregisterStore( - databaseURL: storeURL - ).batch( + ) == nil) + let store = try PendingUnregisterStore(databaseURL: storeURL) + let oldest = store.batch( accountID: "historical-account-0", limit: 2 ) - #expect(overflow.map(\.accountID) == ["historical-account-0"]) + let newest = store.batch(accountID: "current-account", limit: 2) + #expect(oldest.map(\.accountID) == ["historical-account-0"]) + #expect(newest.map(\.accountID) == ["current-account"]) } @Test func successfulReassignmentClearsOldTombstoneWithoutLosingNewOwner() async throws { diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index faa9346334a..6172c64a437 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -15,6 +15,16 @@ private let mobilePushLog = Logger( category: "push" ) +private actor MobilePushSettingsRace { + private var hasWinner = false + + func win() -> Bool { + guard !hasWinner else { return false } + hasWinner = true + return true + } +} + /// Bridges APNs push between the app-target `AppDelegate` and the mobile shell /// store: drives opt-in registration, hands device tokens to the injected /// ``CmuxAuthRuntime/PushRegistrationService``, and routes foreground @@ -106,8 +116,17 @@ public final class MobilePushCoordinator { public private(set) var registrationSnapshot: PushRegistrationSnapshot = .disabled @ObservationIgnored private let notificationSettings: @MainActor () async -> MobilePushSystemSettings + @ObservationIgnored private let notificationSettingsClock: + any Clock + @ObservationIgnored private let notificationSettingsTimeout: Duration + @ObservationIgnored private var notificationSettingsReadTask: + Task? + @ObservationIgnored private var notificationSettingsReadID: UUID? @ObservationIgnored private let requestAuthorization: @MainActor () async -> Bool + @ObservationIgnored private let authorizationRequestTimeout: Duration + @ObservationIgnored private var authorizationRequestTask: Task? + @ObservationIgnored private var authorizationRequestID: UUID? @ObservationIgnored private let registerForRemoteNotifications: @MainActor () -> Void @ObservationIgnored private let unregisterForRemoteNotifications: @@ -157,10 +176,16 @@ public final class MobilePushCoordinator { }, replyRetrySleep: @escaping @Sendable (Duration) async throws -> Void = { try await ContinuousClock().sleep(for: $0) - } + }, + notificationSettingsClock: any Clock = ContinuousClock(), + notificationSettingsTimeout: Duration = .seconds(5), + authorizationRequestTimeout: Duration = .seconds(120) ) { self.registration = registration self.replyRetrySleep = replyRetrySleep + self.notificationSettingsClock = notificationSettingsClock + self.notificationSettingsTimeout = notificationSettingsTimeout + self.authorizationRequestTimeout = authorizationRequestTimeout self.analytics = analytics self.diagnosticLog = diagnosticLog self.phoneAPIOrigin = phoneAPIOrigin @@ -201,12 +226,13 @@ public final class MobilePushCoordinator { public func setEnabledIntent(_ enabled: Bool) -> Task { settingsIntentTask?.cancel() let generation = beginSettingsIntent(enabled) - let task = Task { @MainActor [weak self] in - guard let self else { return false } - await self.registration.applyEnabledIntent( + let registration = registration + let task = Task { @MainActor [weak self, registration] in + await registration.applyEnabledIntent( enabled, generation: generation ) + guard let self else { return false } guard !Task.isCancelled, self.isCurrentSettingsIntent( generation, @@ -313,7 +339,9 @@ public final class MobilePushCoordinator { if defaults.object(forKey: Self.enabledKey) as? Bool == false { return } - let settings = await notificationSettings() + guard let settings = await readNotificationSettingsBounded() else { + return + } guard settingsIntentGeneration == initialSettingsGeneration, defaults.object(forKey: Self.enabledKey) as? Bool != false else { return } @@ -350,7 +378,9 @@ public final class MobilePushCoordinator { // can be stale when the user changes notification permission in iOS // Settings while the app is suspended or a readiness refresh is still // in flight. - let priorSettings = await notificationSettings() + guard let priorSettings = await readNotificationSettingsBounded() else { + return false + } guard isCurrentSettingsIntent(generation, enabled: true) else { return false } @@ -371,7 +401,7 @@ public final class MobilePushCoordinator { case .authorized, .provisional, .ephemeral: granted = true case .notDetermined: - granted = await requestAuthorization() + granted = await requestAuthorizationBounded() ?? false case .denied, .unsupported: granted = false } @@ -391,7 +421,8 @@ public final class MobilePushCoordinator { return false } if priorStatus == .notDetermined { - let currentSettings = await notificationSettings() + guard let currentSettings = await readNotificationSettingsBounded() + else { return false } guard isCurrentSettingsIntent(generation, enabled: true) else { return false } @@ -467,7 +498,9 @@ public final class MobilePushCoordinator { /// Call on every foreground transition because users can revoke permission /// in iOS Settings while cmux is suspended. public func refreshReadiness() async { - let settings = await notificationSettings() + guard let settings = await readNotificationSettingsBounded() else { + return + } apply(settings: settings) if enabledMirror, Self.permitsDelivery(settings.authorization) { await activateRegistrationIfNeeded() @@ -507,6 +540,83 @@ public final class MobilePushCoordinator { authorization = settings.authorization } + private func readNotificationSettingsBounded() async + -> MobilePushSystemSettings? { + // One shared read prevents repeated toggles or foreground callbacks + // from accumulating cancellation-ignoring UserNotifications tasks. + guard notificationSettingsReadTask == nil else { return nil } + let id = UUID() + let reader = notificationSettings + let task = Task { @MainActor [weak self, reader] in + let settings = await reader() + if let self, self.notificationSettingsReadID == id { + self.notificationSettingsReadTask = nil + self.notificationSettingsReadID = nil + } + return settings + } + notificationSettingsReadID = id + notificationSettingsReadTask = task + return await waitForTaskValue( + task, + timeout: notificationSettingsTimeout + ) + } + + private func requestAuthorizationBounded() async -> Bool? { + guard authorizationRequestTask == nil else { return nil } + let id = UUID() + let requester = requestAuthorization + let task = Task { @MainActor [weak self, requester] in + let granted = await requester() + if let self, self.authorizationRequestID == id { + self.authorizationRequestTask = nil + self.authorizationRequestID = nil + } + return granted + } + authorizationRequestID = id + authorizationRequestTask = task + return await waitForTaskValue( + task, + timeout: authorizationRequestTimeout + ) + } + + private func waitForTaskValue( + _ task: Task, + timeout: Duration + ) async -> Value? { + let race = MobilePushSettingsRace() + let clock = notificationSettingsClock + let stream = AsyncStream { continuation in + let reader = Task { + let value = await task.value + guard await race.win() else { return } + continuation.yield(value) + continuation.finish() + } + let deadline = Task { + do { + try await clock.sleep(for: timeout) + } catch { + return + } + guard !Task.isCancelled, await race.win() else { return } + continuation.yield(nil) + continuation.finish() + } + continuation.onTermination = { _ in + reader.cancel() + deadline.cancel() + } + } + for await result in stream { + return result + } + return nil + } + private func activateRegistrationIfNeeded( settingsGeneration: UInt64? = nil, reconcilePreference: Bool = true From b4ff55127a2699424e401d480916e36435ce5706 Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:51:57 -0700 Subject: [PATCH 114/117] fix(push): close notification intent races --- .../Push/PushRegistrationService.swift | 5 ++-- .../MobilePushCoordinator.swift | 27 ++++++++++++++++--- .../MobilePushCoordinatorLifecycleTests.swift | 20 +++++++++++--- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index cd79cc805b0..99b888b367f 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -544,8 +544,9 @@ public actor PushRegistrationService: PushRegistering { replacingGeneration: UUID? = nil ) async { guard canUploadForCurrentIntent else { return } - let requestedAccountID = (try? await tokenProvider - .authenticatedSessionSnapshot())?.accountID + let requestedAccountID = await boundedSessionSnapshot( + phase: .pushRegistrationSession + )?.accountID if let uploadTask, uploadTaskTokenHex == tokenHex, uploadTaskGeneration == operationGeneration, diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index 6172c64a437..a813577179d 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -544,7 +544,12 @@ public final class MobilePushCoordinator { -> MobilePushSystemSettings? { // One shared read prevents repeated toggles or foreground callbacks // from accumulating cancellation-ignoring UserNotifications tasks. - guard notificationSettingsReadTask == nil else { return nil } + if let notificationSettingsReadTask { + return await waitForTaskValue( + notificationSettingsReadTask, + timeout: notificationSettingsTimeout + ) + } let id = UUID() let reader = notificationSettings let task = Task { @MainActor [weak self, reader] in @@ -564,7 +569,12 @@ public final class MobilePushCoordinator { } private func requestAuthorizationBounded() async -> Bool? { - guard authorizationRequestTask == nil else { return nil } + if let authorizationRequestTask { + return await waitForTaskValue( + authorizationRequestTask, + timeout: authorizationRequestTimeout + ) + } let id = UUID() let requester = requestAuthorization let task = Task { @MainActor [weak self, requester] in @@ -647,8 +657,17 @@ public final class MobilePushCoordinator { backendState: backendState ) requestRemoteRegistrationIfNeeded() - if reconcilePreference, !current.isEnabled { - await registration.setEnabled(true) + if reconcilePreference { + if current.isEnabled { + // A prior enable can remain intentionally unreconciled while + // iOS permission is denied. Foregrounding after permission is + // granted must release that exact coordinator generation. + await registration.reconcileEnabledIntent( + generation: settingsIntentGeneration + ) + } else { + await registration.setEnabled(true) + } } guard enabledMirror, settingsGeneration.map({ diff --git a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift index 30d7f10b5e8..fe5af21bcfd 100644 --- a/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift +++ b/Packages/iOS/CmuxMobileShellUI/Tests/CmuxMobileShellUITests/MobilePushCoordinatorLifecycleTests.swift @@ -372,15 +372,16 @@ private final class LifecyclePushURLProtocol: URLProtocol, } @MainActor - @Test func settingsIntentDoesNotReconcileBackendWhenPermissionIsDenied() async { + @Test func deniedSettingsIntentReconcilesAfterPermissionIsGranted() async { let registration = LifecyclePushRegistration(enabled: false) let suiteName = "push-coordinator-denied-backend-\(UUID().uuidString)" let defaults = UserDefaults(suiteName: suiteName)! defer { defaults.removePersistentDomain(forName: suiteName) } + var status = UNAuthorizationStatus.denied let coordinator = MobilePushCoordinator( registration: registration, defaults: defaults, - authorizationStatus: { .denied }, + authorizationStatus: { status }, requestAuthorization: { false } ) await coordinator.refreshReadiness() @@ -388,6 +389,13 @@ private final class LifecyclePushURLProtocol: URLProtocol, #expect(!(await coordinator.setEnabledIntent(true).value)) #expect(await registration.enabledReconciliationGenerations.isEmpty) #expect(coordinator.isEnabled) + + status = .authorized + await coordinator.refreshReadiness() + + #expect( + await registration.enabledReconciliationGenerations == [1] + ) } @MainActor @@ -619,11 +627,15 @@ private final class LifecyclePushURLProtocol: URLProtocol, ) await disabling.value + let reenabling = coordinator.setEnabledIntent(true) + #expect(coordinator.isEnabled) await settingsGate.release() await enabling.value + #expect(await reenabling.value) - #expect(!coordinator.isEnabled) - #expect(!(await registration.snapshot.isEnabled)) + #expect(coordinator.isEnabled) + #expect(await registration.snapshot.isEnabled) + #expect(await registration.enabledReconciliationGenerations == [3]) } @MainActor From 8fa502120b495060e9772e4b0f3f449107f01bab Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:15:33 -0700 Subject: [PATCH 115/117] fix(push): bound recovery workers --- .../Push/PushRegistrationService.swift | 123 +++++++++----- .../PushRegistrationServiceTests.swift | 151 +++++++++++++----- .../MobilePushCoordinator.swift | 46 +++--- 3 files changed, 219 insertions(+), 101 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 99b888b367f..0822f5563c4 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -21,7 +21,8 @@ public actor PushRegistrationService: PushRegistering { private let bundleID: String private let apnsEnvironment: String private let defaults: UserDefaults - private let pendingUnregisterStore: PendingUnregisterStore? + private let pendingUnregisterStoreURL: URL + private var pendingUnregisterStore: PendingUnregisterStore? private let session: URLSession private let retryDelays: [Duration] private let retryJitter: @Sendable (ClosedRange) -> Double @@ -54,7 +55,6 @@ public actor PushRegistrationService: PushRegistering { private var uploadTask: Task? private var uploadTaskTokenHex: String? private var uploadTaskGeneration: UUID? - private var uploadTaskAccountID: String? private var operationGeneration = UUID() private var snapshotValue: PushRegistrationSnapshot private var snapshotContinuations: @@ -139,9 +139,15 @@ public actor PushRegistrationService: PushRegistering { suiteName: suiteName, bundleID: bundleID ) - self.pendingUnregisterStore = try? PendingUnregisterStore( - databaseURL: storeURL - ) + self.pendingUnregisterStoreURL = storeURL + do { + self.pendingUnregisterStore = try PendingUnregisterStore( + databaseURL: storeURL + ) + } catch { + self.pendingUnregisterStore = nil + pushLog.error("Unable to open durable push-token cleanup store") + } Self.migrateLegacyPendingUnregisters( in: self.defaults, overflowStore: self.pendingUnregisterStore @@ -543,40 +549,51 @@ public actor PushRegistrationService: PushRegistering { tokenHex: String, replacingGeneration: UUID? = nil ) async { - guard canUploadForCurrentIntent else { return } - let requestedAccountID = await boundedSessionSnapshot( - phase: .pushRegistrationSession - )?.accountID - if let uploadTask, - uploadTaskTokenHex == tokenHex, - uploadTaskGeneration == operationGeneration, - uploadTaskGeneration != replacingGeneration, - uploadTaskAccountID == requestedAccountID { - await uploadTask.value + while canUploadForCurrentIntent, cachedTokenHex == tokenHex { + if let inFlightTask = uploadTask, + uploadTaskGeneration != replacingGeneration { + let inFlightGeneration = uploadTaskGeneration + await inFlightTask.value + if uploadTaskGeneration == inFlightGeneration { + uploadTask = nil + uploadTaskTokenHex = nil + uploadTaskGeneration = nil + } + guard canUploadForCurrentIntent, + cachedTokenHex == tokenHex else { return } + if snapshotValue.backendState == .registered { + return + } + // The mutation already represented the current operation. A + // newer generation loops and starts only after it completes. + if inFlightGeneration == operationGeneration { + return + } + continue + } + + operationGeneration = UUID() + let generation = operationGeneration + let retryDelays = self.retryDelays + let task = Task { [weak self, retryDelays] in + guard let self else { return } + await self.attemptUpload( + tokenHex: tokenHex, + generation: generation, + remainingDelays: retryDelays + ) + } + uploadTask = task + uploadTaskTokenHex = tokenHex + uploadTaskGeneration = generation + await task.value + if uploadTaskGeneration == generation { + uploadTask = nil + uploadTaskTokenHex = nil + uploadTaskGeneration = nil + } return } - operationGeneration = UUID() - let generation = operationGeneration - let retryDelays = self.retryDelays - let task = Task { [weak self, retryDelays] in - guard let self else { return } - await self.attemptUpload( - tokenHex: tokenHex, - generation: generation, - remainingDelays: retryDelays - ) - } - uploadTask = task - uploadTaskTokenHex = tokenHex - uploadTaskGeneration = generation - uploadTaskAccountID = requestedAccountID - await task.value - if uploadTaskGeneration == generation { - uploadTask = nil - uploadTaskTokenHex = nil - uploadTaskGeneration = nil - uploadTaskAccountID = nil - } } private func attemptUpload( @@ -1040,7 +1057,7 @@ public actor PushRegistrationService: PushRegistering { private func persistPendingUnregister(tokenHex: String, accountID: String) { let entry = PendingUnregister(tokenHex: tokenHex, accountID: accountID) - if pendingUnregisterStore?.insert(entry) == true { + if durablePendingUnregisterStore()?.insert(entry) == true { // SQLite is durable before the legacy fallback is removed. storePendingUnregisters( pendingUnregisters.filter { $0 != entry } @@ -1111,7 +1128,7 @@ public actor PushRegistrationService: PushRegistering { tokenHex: String, accountID: String ) { - _ = pendingUnregisterStore?.remove( + _ = durablePendingUnregisterStore()?.remove( tokenHex: tokenHex, accountID: accountID ) @@ -1193,14 +1210,15 @@ public actor PushRegistrationService: PushRegistering { } private var hasPendingUnregisters: Bool { - pendingUnregisterStore?.hasEntries == true || !pendingUnregisters.isEmpty + durablePendingUnregisterStore()?.hasEntries == true + || !pendingUnregisters.isEmpty } private func pendingUnregisterOverflowBatch( accountID: String, limit: Int ) -> [PendingUnregister] { - pendingUnregisterStore?.batch( + durablePendingUnregisterStore()?.batch( accountID: accountID, limit: limit ) ?? [] @@ -1216,12 +1234,33 @@ public actor PushRegistrationService: PushRegistering { } private func clearPendingUnregisterToken(tokenHex: String) { - _ = pendingUnregisterStore?.removeAll(tokenHex: tokenHex) + _ = durablePendingUnregisterStore()?.removeAll(tokenHex: tokenHex) storePendingUnregisters( pendingUnregisters.filter { $0.tokenHex != tokenHex } ) } + private func durablePendingUnregisterStore() -> PendingUnregisterStore? { + if let pendingUnregisterStore { + return pendingUnregisterStore + } + do { + let store = try PendingUnregisterStore( + databaseURL: pendingUnregisterStoreURL + ) + pendingUnregisterStore = store + Self.migrateLegacyPendingUnregisters( + in: defaults, + overflowStore: store + ) + pushLog.info("Recovered durable push-token cleanup store") + return store + } catch { + pushLog.error("Unable to recover durable push-token cleanup store") + return nil + } + } + private func clearRegisteredOwner( accountID: String, tokenHex: String diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 86efc59288d..7ca5abfdf9b 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -159,6 +159,20 @@ actor RetryDelayRecorder { // append to the same singleton between this test's reset and its assertion, // failing nondeterministically. `.serialized` removes that interleaving. @Suite(.serialized) struct PushRegistrationServiceTests { + private func testPendingUnregisterStoreURL(for suite: String) -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("push-cleanup-\(suite).sqlite3") + } + + private func pendingUnregisters( + suite: String, + accountID: String + ) -> [PendingUnregister] { + (try? PendingUnregisterStore( + databaseURL: testPendingUnregisterStoreURL(for: suite) + ).batch(accountID: accountID, limit: 100)) ?? [] + } + private func makeService( tokenProvider: any TokenProviding = FakeTokenProvider() ) -> (PushRegistrationService, UserDefaults) { @@ -172,6 +186,9 @@ actor RetryDelayRecorder { bundleID: "dev.cmux.ios", apnsEnvironment: "sandbox", suiteName: suite, + pendingUnregisterStoreURL: testPendingUnregisterStoreURL( + for: suite + ), session: URLSession(configuration: configuration) ) return (service, defaults) @@ -212,7 +229,8 @@ actor RetryDelayRecorder { bundleID: "dev.cmux.ios.push1", apnsEnvironment: "sandbox", suiteName: suite, - pendingUnregisterStoreURL: pendingUnregisterStoreURL, + pendingUnregisterStoreURL: pendingUnregisterStoreURL + ?? testPendingUnregisterStoreURL(for: suite), session: URLSession(configuration: configuration), retryDelays: retryDelays, retryJitter: { _ in 1 }, @@ -334,14 +352,10 @@ actor RetryDelayRecorder { refreshToken: "captured-refresh" ) - let queueData = defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v2" - ) - let queue = queueData.flatMap { - try? JSONSerialization.jsonObject(with: $0) - as? [[String: String]] - } - #expect(queue == [["tokenHex": "ab", "accountID": "old-user"]]) + #expect(pendingUnregisters( + suite: suite, + accountID: "old-user" + ) == [PendingUnregister(tokenHex: "ab", accountID: "old-user")]) #expect( defaults.string(forKey: "cmux.notifications.pendingUnregisterToken") == nil @@ -398,11 +412,14 @@ actor RetryDelayRecorder { forKey: "cmux.notifications.registeredAccountID" ) == "account-a" ) - let queueText = defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v2" - ).flatMap { String(data: $0, encoding: .utf8) } - #expect(queueText?.contains("account-a") == true) - #expect(queueText?.contains("account-b") == false) + #expect(pendingUnregisters( + suite: suite, + accountID: "account-a" + ) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")]) + #expect(pendingUnregisters( + suite: suite, + accountID: "account-b" + ).isEmpty) } @Test func legacySignOutCannotProveRegisteredOwnerMatchesCredentials() async { @@ -424,10 +441,10 @@ actor RetryDelayRecorder { ) #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) - let queueText = defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v2" - ).flatMap { String(data: $0, encoding: .utf8) } - #expect(queueText?.contains("account-a") == true) + #expect(pendingUnregisters( + suite: suite, + accountID: "account-a" + ) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")]) } @Test func enabledWithoutAPNsTokenReportsAwaitingTokenInsteadOfReady() async { @@ -743,13 +760,10 @@ actor RetryDelayRecorder { await service.setEnabled(false) - let persisted = try? JSONDecoder().decode( - [[String: String]].self, - from: defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v2" - ) ?? Data() - ) - #expect(persisted == [["tokenHex": "ab", "accountID": "account-a"]]) + #expect(pendingUnregisters( + suite: suite, + accountID: "account-a" + ) == [PendingUnregister(tokenHex: "ab", accountID: "account-a")]) #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) let (returned, _) = makeScriptedService( @@ -813,11 +827,14 @@ actor RetryDelayRecorder { await service.setEnabled(false) #expect(await PushRegistrationURLProtocol.script.requests.isEmpty) - let queueText = defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v2" - ).flatMap { String(data: $0, encoding: .utf8) } - #expect(queueText?.contains("account-a") == true) - #expect(queueText?.contains("account-b") == false) + #expect(pendingUnregisters( + suite: suite, + accountID: "account-a" + ) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")]) + #expect(pendingUnregisters( + suite: suite, + accountID: "account-b" + ).isEmpty) } @Test func malformedDeleteAcknowledgementKeepsDurableTombstone() async { @@ -835,10 +852,10 @@ actor RetryDelayRecorder { await service.setEnabled(false) - #expect( - defaults.data(forKey: "cmux.notifications.pendingUnregisters.v2") - != nil - ) + #expect(pendingUnregisters( + suite: suite, + accountID: "account-a" + ) == [PendingUnregister(tokenHex: "ab", accountID: "account-a")]) #expect( defaults.string(forKey: "cmux.notifications.registeredAccountID") == "account-a" @@ -1144,21 +1161,23 @@ actor RetryDelayRecorder { accessToken: "b-access", refreshToken: "b-refresh" ) - await service.syncTokenIfPossible() + let currentSync = Task { + await service.syncTokenIfPossible() + } await blocker.release() await oldUpload.value + await currentSync.value let requests = await PushRegistrationURLProtocol.script.requests #expect( requests.map(\.httpMethod) - == ["POST", "POST", "DELETE", "POST"] + == ["POST", "DELETE", "POST"] ) #expect( requests.map { $0.value(forHTTPHeaderField: "Authorization") } == [ "Bearer a-access", - "Bearer b-access", "Bearer a-access", "Bearer b-access", ] @@ -1339,10 +1358,10 @@ actor RetryDelayRecorder { let firstRequests = await PushRegistrationURLProtocol.script.requests #expect(firstRequests.map(\.httpMethod) == ["POST", "DELETE"]) #expect(await service.snapshot.backendState == .registered) - let pendingText = defaults.data( - forKey: "cmux.notifications.pendingUnregisters.v2" - ).flatMap { String(data: $0, encoding: .utf8) } - #expect(pendingText?.contains("aa") == true) + #expect(pendingUnregisters( + suite: suite, + accountID: "account-a" + ) == [PendingUnregister(tokenHex: "aa", accountID: "account-a")]) await PushRegistrationURLProtocol.script.reset([ .response(200), @@ -1541,6 +1560,56 @@ actor RetryDelayRecorder { #expect(newest.map(\.accountID) == ["current-account"]) } + @Test func durableCleanupStoreReopensAfterLaunchFailure() async throws { + await PushRegistrationURLProtocol.script.reset([.response(200)]) + let fileManager = FileManager.default + let root = fileManager.temporaryDirectory.appendingPathComponent( + "push-store-reopen-\(UUID().uuidString)", + isDirectory: true + ) + let parkedRoot = root.appendingPathExtension("parked") + let storeURL = root.appendingPathComponent("cleanup.sqlite3") + defer { + try? fileManager.removeItem(at: root) + try? fileManager.removeItem(at: parkedRoot) + } + do { + let store = try PendingUnregisterStore(databaseURL: storeURL) + #expect(store.insert(PendingUnregister( + tokenHex: "aa", + accountID: "account-a" + ))) + } + try fileManager.moveItem(at: root, to: parkedRoot) + #expect(fileManager.createFile(atPath: root.path, contents: Data())) + + let (service, _) = makeScriptedService( + accountID: "account-a", + pendingUnregisterStoreURL: storeURL + ) + + try fileManager.removeItem(at: root) + try fileManager.moveItem(at: parkedRoot, to: root) + _ = await service.snapshots() + #expect( + await PushRegistrationURLProtocol.script.waitForRequestCount(1) + ) + #expect( + await PushRegistrationURLProtocol.script.requests.map(\.httpMethod) + == ["DELETE"] + ) + let reopenedStore = try PendingUnregisterStore(databaseURL: storeURL) + var cleanupFinished = false + for _ in 0..<1_000 { + if reopenedStore.batch(accountID: "account-a", limit: 2).isEmpty { + cleanupFinished = true + break + } + await Task.yield() + } + #expect(cleanupFinished) + } + @Test func successfulReassignmentClearsOldTombstoneWithoutLosingNewOwner() async throws { await PushRegistrationURLProtocol.script.reset([.response(200)]) let suite = "push-owner-reassignment-\(UUID().uuidString)" diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index a813577179d..e6974bbbaf6 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -121,11 +121,15 @@ public final class MobilePushCoordinator { @ObservationIgnored private let notificationSettingsTimeout: Duration @ObservationIgnored private var notificationSettingsReadTask: Task? + @ObservationIgnored private var notificationSettingsWaitTask: + Task? @ObservationIgnored private var notificationSettingsReadID: UUID? @ObservationIgnored private let requestAuthorization: @MainActor () async -> Bool @ObservationIgnored private let authorizationRequestTimeout: Duration @ObservationIgnored private var authorizationRequestTask: Task? + @ObservationIgnored private var authorizationRequestWaitTask: + Task? @ObservationIgnored private var authorizationRequestID: UUID? @ObservationIgnored private let registerForRemoteNotifications: @MainActor () -> Void @@ -544,11 +548,8 @@ public final class MobilePushCoordinator { -> MobilePushSystemSettings? { // One shared read prevents repeated toggles or foreground callbacks // from accumulating cancellation-ignoring UserNotifications tasks. - if let notificationSettingsReadTask { - return await waitForTaskValue( - notificationSettingsReadTask, - timeout: notificationSettingsTimeout - ) + if let notificationSettingsWaitTask { + return await notificationSettingsWaitTask.value } let id = UUID() let reader = notificationSettings @@ -557,23 +558,26 @@ public final class MobilePushCoordinator { if let self, self.notificationSettingsReadID == id { self.notificationSettingsReadTask = nil self.notificationSettingsReadID = nil + self.notificationSettingsWaitTask = nil } return settings } notificationSettingsReadID = id notificationSettingsReadTask = task - return await waitForTaskValue( - task, - timeout: notificationSettingsTimeout - ) + let waitTask = Task { [weak self, task] in + guard let self else { return nil } + return await self.waitForTaskValue( + task, + timeout: self.notificationSettingsTimeout + ) + } + notificationSettingsWaitTask = waitTask + return await waitTask.value } private func requestAuthorizationBounded() async -> Bool? { - if let authorizationRequestTask { - return await waitForTaskValue( - authorizationRequestTask, - timeout: authorizationRequestTimeout - ) + if let authorizationRequestWaitTask { + return await authorizationRequestWaitTask.value } let id = UUID() let requester = requestAuthorization @@ -582,15 +586,21 @@ public final class MobilePushCoordinator { if let self, self.authorizationRequestID == id { self.authorizationRequestTask = nil self.authorizationRequestID = nil + self.authorizationRequestWaitTask = nil } return granted } authorizationRequestID = id authorizationRequestTask = task - return await waitForTaskValue( - task, - timeout: authorizationRequestTimeout - ) + let waitTask = Task { [weak self, task] in + guard let self else { return nil } + return await self.waitForTaskValue( + task, + timeout: self.authorizationRequestTimeout + ) + } + authorizationRequestWaitTask = waitTask + return await waitTask.value } private func waitForTaskValue( From 869b9929c5c997dade9721ccec63ef0defbabbae Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:29:37 -0700 Subject: [PATCH 116/117] fix(push): preserve ambiguous cleanup state --- .../Push/PushRegistrationService.swift | 9 ++ .../PushRegistrationServiceTests.swift | 10 +- .../MobilePushCoordinator.swift | 151 +++++++++++------- 3 files changed, 107 insertions(+), 63 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index 0822f5563c4..b99f8a48267 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -625,6 +625,15 @@ public actor PushRegistrationService: PushRegistering { switch request { case let .success(context): requestSession = context.session + // A POST can commit before its response reaches the app. Record + // the owner first so a crash followed by an opt-out relaunch still + // has enough identity to delete that ambiguous registration. + if let requestSession = context.session { + persistPendingUnregister( + tokenHex: tokenHex, + accountID: requestSession.accountID + ) + } result = await performRegistration(context.request) case let .failure(failure): requestSession = nil diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 7ca5abfdf9b..97c9f7e4e90 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -954,12 +954,20 @@ actor RetryDelayRecorder { .response(200), .response(200), ]) - let (service, _) = makeScriptedService() + let suite = "push-ambiguous-post-\(UUID().uuidString)" + let (service, _) = makeScriptedService(suite: suite) await service.register(deviceToken: Data([0xAA])) await service.applyEnabledIntent(true, generation: 1) await service.reconcileEnabledIntent(generation: 1) await started.waitUntilStarted() + #expect(pendingUnregisters( + suite: suite, + accountID: "push-user-1" + ) == [PendingUnregister( + tokenHex: "aa", + accountID: "push-user-1" + )]) await service.applyEnabledIntent(false, generation: 2) // Opt-out cleanup must start while the superseded POST is still diff --git a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift index e6974bbbaf6..f1e1361afa7 100644 --- a/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift +++ b/Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/MobilePushCoordinator.swift @@ -15,13 +15,39 @@ private let mobilePushLog = Logger( category: "push" ) -private actor MobilePushSettingsRace { - private var hasWinner = false +private actor MobilePushSingleFlight { + private var result: Value? + private var waiters: [UUID: CheckedContinuation] = [:] + + func finish(_ value: Value) { + guard result == nil else { return } + result = value + let pending = Array(waiters.values) + waiters.removeAll() + for waiter in pending { + waiter.resume(returning: value) + } + } - func win() -> Bool { - guard !hasWinner else { return false } - hasWinner = true - return true + func wait() async -> Value? { + if let result { return result } + if Task.isCancelled { return nil } + let id = UUID() + return await withTaskCancellationHandler { + await withCheckedContinuation { continuation in + if let result { + continuation.resume(returning: result) + } else { + waiters[id] = continuation + } + } + } onCancel: { + Task { await self.cancelWaiter(id: id) } + } + } + + private func cancelWaiter(id: UUID) { + waiters.removeValue(forKey: id)?.resume(returning: nil) } } @@ -120,16 +146,16 @@ public final class MobilePushCoordinator { any Clock @ObservationIgnored private let notificationSettingsTimeout: Duration @ObservationIgnored private var notificationSettingsReadTask: - Task? - @ObservationIgnored private var notificationSettingsWaitTask: - Task? + Task? + @ObservationIgnored private var notificationSettingsOperation: + MobilePushSingleFlight? @ObservationIgnored private var notificationSettingsReadID: UUID? @ObservationIgnored private let requestAuthorization: @MainActor () async -> Bool @ObservationIgnored private let authorizationRequestTimeout: Duration - @ObservationIgnored private var authorizationRequestTask: Task? - @ObservationIgnored private var authorizationRequestWaitTask: - Task? + @ObservationIgnored private var authorizationRequestTask: Task? + @ObservationIgnored private var authorizationRequestOperation: + MobilePushSingleFlight? @ObservationIgnored private var authorizationRequestID: UUID? @ObservationIgnored private let registerForRemoteNotifications: @MainActor () -> Void @@ -548,93 +574,94 @@ public final class MobilePushCoordinator { -> MobilePushSystemSettings? { // One shared read prevents repeated toggles or foreground callbacks // from accumulating cancellation-ignoring UserNotifications tasks. - if let notificationSettingsWaitTask { - return await notificationSettingsWaitTask.value + if let notificationSettingsOperation { + return await waitForOperationValue( + notificationSettingsOperation, + timeout: notificationSettingsTimeout, + operationName: "notification settings" + ) } let id = UUID() let reader = notificationSettings - let task = Task { @MainActor [weak self, reader] in + let operation = MobilePushSingleFlight() + let task = Task { @MainActor [weak self, reader, operation] in let settings = await reader() + await operation.finish(settings) if let self, self.notificationSettingsReadID == id { self.notificationSettingsReadTask = nil self.notificationSettingsReadID = nil - self.notificationSettingsWaitTask = nil + self.notificationSettingsOperation = nil } - return settings } notificationSettingsReadID = id notificationSettingsReadTask = task - let waitTask = Task { [weak self, task] in - guard let self else { return nil } - return await self.waitForTaskValue( - task, - timeout: self.notificationSettingsTimeout - ) - } - notificationSettingsWaitTask = waitTask - return await waitTask.value + notificationSettingsOperation = operation + return await waitForOperationValue( + operation, + timeout: notificationSettingsTimeout, + operationName: "notification settings" + ) } private func requestAuthorizationBounded() async -> Bool? { - if let authorizationRequestWaitTask { - return await authorizationRequestWaitTask.value + if let authorizationRequestOperation { + return await waitForOperationValue( + authorizationRequestOperation, + timeout: authorizationRequestTimeout, + operationName: "notification authorization" + ) } let id = UUID() let requester = requestAuthorization - let task = Task { @MainActor [weak self, requester] in + let operation = MobilePushSingleFlight() + let task = Task { @MainActor [weak self, requester, operation] in let granted = await requester() + await operation.finish(granted) if let self, self.authorizationRequestID == id { self.authorizationRequestTask = nil self.authorizationRequestID = nil - self.authorizationRequestWaitTask = nil + self.authorizationRequestOperation = nil } - return granted } authorizationRequestID = id authorizationRequestTask = task - let waitTask = Task { [weak self, task] in - guard let self else { return nil } - return await self.waitForTaskValue( - task, - timeout: self.authorizationRequestTimeout - ) - } - authorizationRequestWaitTask = waitTask - return await waitTask.value + authorizationRequestOperation = operation + return await waitForOperationValue( + operation, + timeout: authorizationRequestTimeout, + operationName: "notification authorization" + ) } - private func waitForTaskValue( - _ task: Task, - timeout: Duration + private func waitForOperationValue( + _ operation: MobilePushSingleFlight, + timeout: Duration, + operationName: String ) async -> Value? { - let race = MobilePushSettingsRace() let clock = notificationSettingsClock - let stream = AsyncStream { continuation in - let reader = Task { - let value = await task.value - guard await race.win() else { return } - continuation.yield(value) - continuation.finish() + let result = await withTaskGroup( + of: Value?.self, + returning: Value?.self + ) { group in + group.addTask { + await operation.wait() } - let deadline = Task { + group.addTask { do { try await clock.sleep(for: timeout) } catch { - return + return nil } - guard !Task.isCancelled, await race.win() else { return } - continuation.yield(nil) - continuation.finish() - } - continuation.onTermination = { _ in - reader.cancel() - deadline.cancel() + return nil } + let first = await group.next() ?? nil + group.cancelAll() + return first } - for await result in stream { - return result + if result == nil, !Task.isCancelled { + mobilePushLog.error("Timed out reading \(operationName, privacy: .public)") } - return nil + return result } private func activateRegistrationIfNeeded( From 8677b43be331df4afa2ba2bb428af1a45deaeead Mon Sep 17 00:00:00 2001 From: Abdulaziz Albahar <67667005+azooz2003-bit@users.noreply.github.com> Date: Fri, 14 Aug 2026 20:53:58 -0700 Subject: [PATCH 117/117] fix(push): await bounded opt-out cleanup --- .../Sources/CmuxAuthRuntime/Push/PushRegistering.swift | 5 +++-- .../CmuxAuthRuntime/Push/PushRegistrationService.swift | 9 +++++++-- .../PushRegistrationServiceTests.swift | 5 ++++- 3 files changed, 14 insertions(+), 5 deletions(-) diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift index bacb4260382..9568c7fcb0d 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistering.swift @@ -21,8 +21,9 @@ public protocol PushRegistering: Sendable { /// removing it server-side on disable. func setEnabled(_ enabled: Bool) async - /// Commits a coordinator-owned preference in generation order and queues - /// opt-out cleanup without tying that work to the caller task. Enabling is + /// Commits a coordinator-owned preference in generation order. Opt-out + /// cleanup runs in an app-owned worker and this call awaits its bounded + /// attempt without transferring cancellation ownership. Enabling is /// persisted here but must wait for ``reconcileEnabledIntent(generation:)`` /// after iOS notification authorization succeeds. func applyEnabledIntent(_ enabled: Bool, generation: UInt64) async diff --git a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift index b99f8a48267..df766a0e135 100644 --- a/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift +++ b/Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Push/PushRegistrationService.swift @@ -220,8 +220,8 @@ public actor PushRegistrationService: PushRegistering { } /// Commits the coordinator's latest preference immediately. Disable starts - /// durable backend cleanup now; enable waits for the coordinator's separate - /// post-authorization reconciliation call. + /// app-owned backend cleanup and awaits its bounded attempt; enable waits + /// for the coordinator's separate post-authorization reconciliation call. public func applyEnabledIntent( _ enabled: Bool, generation: UInt64 @@ -263,6 +263,11 @@ public actor PushRegistrationService: PushRegistering { if !enabled { disableIntentReconciliationRequested = true scheduleDisableIntentReconciliation() + // The worker is app-owned, so cancellation of a stale Settings + // task cannot cancel privacy cleanup. Awaiting it preserves the + // public `disable()` completion guarantee for callers that clear + // authentication immediately afterwards. + await disableIntentReconciliationTask?.value } } diff --git a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift index 97c9f7e4e90..6c3a389ef7b 100644 --- a/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift +++ b/Packages/Shared/CmuxAuthRuntime/Tests/CmuxAuthRuntimeTests/PushRegistrationServiceTests.swift @@ -1070,11 +1070,14 @@ actor RetryDelayRecorder { sessionSnapshotClock: clock ) - await service.applyEnabledIntent(false, generation: 1) + let disabling = Task { + await service.applyEnabledIntent(false, generation: 1) + } await started.waitUntilStarted() await clock.waitUntilSleepers() clock.advance(by: timeout) await provider.waitUntilCancellationObserved() + await disabling.value // A direct cleanup retry must fail against the still-active timed-out // phase instead of starting a second authentication operation.