Let a wiped zone be reseeded instead of crashing the messenger

A zone deleted from another device — a reset, an iCloud data purge — arrives
as an event on every other device, and the handler answered it by calling back
into the sync engine from inside the engine's own callback. CloudKit traps on
that, in release, so the app died a second after the push; and because the
handler destroyed the persisted sync state before doing the work, the relaunch
saw a zone that existed again, no local sign anything was lost, and never
noticed the wipe. Both devices then reported being in sync with an empty zone.

The handler now only writes a durable marker and mutates engine state: the zone
is recreated through pending database changes — which also gets past the zone
manager's memo — and the re-upload is registered, never sent. The engine sends
on its own schedule. The marker is written before any of it and retired only
after the engine serializes the state the recovery registered, so a crash
anywhere in the middle is redone on the next launch.

Accounts lead the re-seed now instead of trailing nine categories behind it.
They carry the logins, the tombstones and the selected context, and going last
is how a wipe took them with it.

A purge is not a reset: CloudKit requires that data deleted that way is not
sent back, so those reasons drop what was queued rather than republish it.
Local data stays — it is this app's store, not a cache of the zone.

And the reset that promises "your local data has been re-uploaded" now does it.
This commit is contained in:
Arkadiusz Fal
2026-09-09 23:59:58 +02:00
parent 0c6c7763db
commit a3e395fe4c
5 changed files with 681 additions and 86 deletions

View File

@@ -520,6 +520,21 @@ final class AccountsManager {
save()
}
/// Forgets what the server was still owed.
///
/// The counterpart to `markAllRecordsForUpload()`, for a zone whose contents
/// the account owner purged from iCloud: CloudKit requires that data deleted
/// that way is not sent back, and every entry here is a record waiting to be
/// sent. Nothing local is deleted — only the intention to upload it, which
/// the next edit, or a later full seed, writes again.
func clearPendingUploads() {
pendingUploads = []
pendingDeletions = []
pendingMarkerUploads = []
pendingContextUpload = false
save()
}
// MARK: - Upload ordering
/// Whether an instance's marker may be published yet.

View File

@@ -737,18 +737,54 @@ final class AccountSyncCoordinator {
/// Registers every account record for upload, for a zone with nothing in it
/// — first enable, or a zone recreated after being deleted elsewhere.
func uploadEverything() {
///
/// - Returns: whether the outbox was re-primed. `false` means this device
/// cannot say what its accounts owe right now — the identity is simulated,
/// unresolved, or the accounts in memory still belong to the previous one
/// — and the caller has to come back to it rather than treat the re-seed
/// as done.
@discardableResult
func uploadEverything() -> Bool {
// A simulated identity has no zone to fill, and marking its accounts
// dirty would only leave an outbox nothing can ever settle.
guard !debugSimulationActive else { return }
guard !debugSimulationActive else { return false }
// Marking everything dirty commits the *in-memory* list to this
// identity's zone, so it must not run against a snapshot loaded under
// another one.
guard let namespace, isManagerLoaded(for: namespace) else { return }
guard let namespace, isManagerLoaded(for: namespace) else { return false }
accountsManager.markAllRecordsForUpload()
enqueueDirtyRecords()
return true
}
/// Drops what this device still owed the zone, and what it had cached from
/// it.
///
/// For a zone the account owner purged: CloudKit requires that data deleted
/// that way is not sent back, and the outbox is exactly a list of records
/// to send. The accounts themselves stay — they are this device's own, and
/// the purge was about the copy in iCloud — so what is lost is the intent to
/// upload them, which the next edit or the next full seed re-creates.
///
/// - Returns: whether the outbox was actually emptied, on the same terms as
/// `uploadEverything()` — a caller that has to record the purge as handled
/// must come back to it otherwise.
@discardableResult
func discardWhatTheZoneHeld() -> Bool {
// A simulated identity's zone is nobody's, and the outbox it would
// empty belongs to the identity actually signed in.
guard !debugSimulationActive, let namespace else { return false }
store.clearStagedChanges(in: namespace)
// Same fence as `uploadEverything()`, for the same reason: during the
// reload gap the outbox in memory is still the previous identity's, and
// emptying it would drop uploads the purge said nothing about.
guard isManagerLoaded(for: namespace) else { return false }
accountsManager.clearPendingUploads()
return true
}
/// Whether the account manager's in-memory state belongs to the namespace

View File

@@ -129,6 +129,10 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// UserDefaults key for CloudKit sync state.
private let syncStateKey = "cloudKitSyncState"
/// Durable note that the zone was deleted on the server and this device has
/// not finished answering it.
private let zoneRecovery = CloudKitZoneRecoveryMarker()
/// UserDefaults key for the record schema version this app last synced with.
private let lastSchemaVersionKey = "cloudKitLastSchemaVersion"
@@ -409,6 +413,12 @@ final class CloudKitSyncEngine: @unchecked Sendable {
}
await accountSync?.activate()
// A zone deletion noticed in an earlier run may not have been
// answered in full — the marker outlives whatever interrupted it,
// and this is the first point where the engine exists and the
// accounts are loaded, outside any delegate callback.
await resumeZoneRecoveryIfNeeded()
if accountChanged {
LoggingService.shared.logCloudKit("CloudKit sync engine initialized with new account (fresh sync state)")
} else {
@@ -657,11 +667,24 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Upload all existing local subscriptions to CloudKit (for initial sync).
/// Call this when enabling iCloud sync for the first time.
func uploadAllLocalSubscriptions() async {
guard enqueueAllLocalSubscriptions() else { return }
// Trigger immediate sync (no debounce for initial upload)
await sync()
}
/// Registers every local subscription for upload without sending.
///
/// The registration half of `uploadAllLocalSubscriptions()`, for callers
/// that must not re-enter the engine — a sync-engine delegate callback may
/// mutate engine state but may not ask the engine to fetch or send.
/// - Returns: whether anything was registered.
@discardableResult
private func enqueueAllLocalSubscriptions() -> Bool {
guard isSyncEnabled, let dataManager else {
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
return
return false
}
// Update progress
uploadProgress?.currentCategory = "Subscriptions"
@@ -672,7 +695,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
guard !subscriptions.isEmpty else {
LoggingService.shared.logCloudKit("No local subscriptions to upload")
return
return false
}
// Track total operations
@@ -686,9 +709,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
addPendingChanges(changes, debounce: false)
LoggingService.shared.logCloudKit("Queued \(subscriptions.count) existing subscriptions for initial upload")
// Trigger immediate sync (no debounce for initial upload)
await sync()
return true
}
// MARK: - WatchEntry Sync Operations
@@ -729,11 +750,20 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Upload all existing local watch history to CloudKit (for initial sync).
/// Call this when enabling iCloud sync for the first time.
func uploadAllLocalWatchHistory() async {
guard enqueueAllLocalWatchHistory() else { return }
// Trigger immediate sync (no debounce for initial upload)
await sync()
}
/// Registers all local watch history for upload without sending.
/// - Returns: whether anything was registered.
@discardableResult
private func enqueueAllLocalWatchHistory() -> Bool {
guard isSyncEnabled, let dataManager else {
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
return
return false
}
// Update progress
uploadProgress?.currentCategory = "Watch History"
@@ -745,7 +775,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
guard !watchHistory.isEmpty else {
LoggingService.shared.logCloudKit("No local watch history to upload")
return
return false
}
// Track total operations
@@ -757,9 +787,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
addPendingChanges(changes, debounce: false)
LoggingService.shared.logCloudKit("Queued \(watchHistory.count) existing watch entries for initial upload")
// Trigger immediate sync (no debounce for initial upload)
await sync()
return true
}
// MARK: - Bookmark Sync Operations
@@ -785,11 +813,20 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Upload all existing local bookmarks to CloudKit (for initial sync).
/// Call this when enabling iCloud sync for the first time.
func uploadAllLocalBookmarks() async {
guard enqueueAllLocalBookmarks() else { return }
// Trigger immediate sync (no debounce for initial upload)
await sync()
}
/// Registers all local bookmarks for upload without sending.
/// - Returns: whether anything was registered.
@discardableResult
private func enqueueAllLocalBookmarks() -> Bool {
guard isSyncEnabled, let dataManager else {
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
return
return false
}
// Update progress
uploadProgress?.currentCategory = "Bookmarks"
@@ -800,7 +837,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
guard !bookmarks.isEmpty else {
LoggingService.shared.logCloudKit("No local bookmarks to upload")
return
return false
}
// Track total operations
@@ -812,9 +849,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
addPendingChanges(changes, debounce: false)
LoggingService.shared.logCloudKit("Queued \(bookmarks.count) existing bookmarks for initial upload")
// Trigger immediate sync (no debounce for initial upload)
await sync()
return true
}
// MARK: - Playlist Sync Operations
@@ -856,11 +891,19 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Upload all existing local playlists to CloudKit (for initial sync).
func uploadAllLocalPlaylists() async {
guard enqueueAllLocalPlaylists() else { return }
await sync()
}
/// Registers all local playlists and their items for upload without sending.
/// - Returns: whether anything was registered.
@discardableResult
private func enqueueAllLocalPlaylists() -> Bool {
guard isSyncEnabled, let dataManager else {
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
return
return false
}
// Update progress
uploadProgress?.currentCategory = "Playlists"
@@ -869,7 +912,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
let playlists = dataManager.playlists()
guard !playlists.isEmpty else {
LoggingService.shared.logCloudKit("No local playlists to upload")
return
return false
}
// Register all playlists and their items with the sync engine
@@ -889,7 +932,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
uploadProgress?.totalOperations += playlists.count + totalItems
LoggingService.shared.logCloudKit("Queued \(playlists.count) existing playlists for initial upload")
await sync()
return true
}
// MARK: - SearchHistory Sync Operations
@@ -914,11 +957,19 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Upload all existing local search history to CloudKit (for initial sync).
func uploadAllLocalSearchHistory() async {
guard enqueueAllLocalSearchHistory() else { return }
await sync()
}
/// Registers all local search history for upload without sending.
/// - Returns: whether anything was registered.
@discardableResult
private func enqueueAllLocalSearchHistory() -> Bool {
guard isSyncEnabled, let dataManager else {
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
return
return false
}
// Update progress
uploadProgress?.currentCategory = "Search History"
@@ -927,7 +978,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
let searchHistory = dataManager.allSearchHistory()
guard !searchHistory.isEmpty else {
LoggingService.shared.logCloudKit("No local search history to upload")
return
return false
}
// Track total operations
@@ -939,7 +990,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
addPendingChanges(changes, debounce: false)
LoggingService.shared.logCloudKit("Queued \(searchHistory.count) search history entries for initial upload")
await sync()
return true
}
// MARK: - RecentChannel Sync Operations
@@ -964,11 +1015,19 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Upload all existing recent channels to CloudKit (for initial sync).
func uploadAllRecentChannels() async {
guard enqueueAllRecentChannels() else { return }
await sync()
}
/// Registers all recent channels for upload without sending.
/// - Returns: whether anything was registered.
@discardableResult
private func enqueueAllRecentChannels() -> Bool {
guard isSyncEnabled, let dataManager else {
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
return
return false
}
// Update progress
uploadProgress?.currentCategory = "Recent Channels"
@@ -977,7 +1036,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
let recentChannels = dataManager.allRecentChannels()
guard !recentChannels.isEmpty else {
LoggingService.shared.logCloudKit("No recent channels to upload")
return
return false
}
// Track total operations
@@ -989,7 +1048,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
addPendingChanges(changes, debounce: false)
LoggingService.shared.logCloudKit("Queued \(recentChannels.count) recent channels for initial upload")
await sync()
return true
}
// MARK: - RecentPlaylist Sync Operations
@@ -1014,11 +1073,19 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Upload all existing recent playlists to CloudKit (for initial sync).
func uploadAllRecentPlaylists() async {
guard enqueueAllRecentPlaylists() else { return }
await sync()
}
/// Registers all recent playlists for upload without sending.
/// - Returns: whether anything was registered.
@discardableResult
private func enqueueAllRecentPlaylists() -> Bool {
guard isSyncEnabled, let dataManager else {
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
return
return false
}
// Update progress
uploadProgress?.currentCategory = "Recent Playlists"
@@ -1027,7 +1094,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
let recentPlaylists = dataManager.allRecentPlaylists()
guard !recentPlaylists.isEmpty else {
LoggingService.shared.logCloudKit("No recent playlists to upload")
return
return false
}
// Track total operations
@@ -1039,7 +1106,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
addPendingChanges(changes, debounce: false)
LoggingService.shared.logCloudKit("Queued \(recentPlaylists.count) recent playlists for initial upload")
await sync()
return true
}
// MARK: - ChannelNotificationSettings Sync Operations
@@ -1064,11 +1131,19 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// Upload all existing channel notification settings to CloudKit (for initial sync).
func uploadAllLocalChannelNotificationSettings() async {
guard enqueueAllLocalChannelNotificationSettings() else { return }
await sync()
}
/// Registers all channel notification settings for upload without sending.
/// - Returns: whether anything was registered.
@discardableResult
private func enqueueAllLocalChannelNotificationSettings() -> Bool {
guard isSyncEnabled, let dataManager else {
LoggingService.shared.logCloudKit("Cannot upload: sync disabled or data manager unavailable")
return
return false
}
// Update progress
uploadProgress?.currentCategory = "Notification Settings"
@@ -1077,7 +1152,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
let allSettings = dataManager.allChannelNotificationSettings()
guard !allSettings.isEmpty else {
LoggingService.shared.logCloudKit("No channel notification settings to upload")
return
return false
}
// Track total operations
@@ -1089,18 +1164,26 @@ final class CloudKitSyncEngine: @unchecked Sendable {
addPendingChanges(changes, debounce: false)
LoggingService.shared.logCloudKit("Queued \(allSettings.count) channel notification settings for initial upload")
await sync()
return true
}
// MARK: - ControlsPreset Sync Operations
/// Upload all local controls presets to CloudKit (for initial sync when enabling category).
func uploadAllLocalControlsPresets() async {
guard await enqueueAllLocalControlsPresets() else { return }
await sync()
}
/// Registers all local controls presets for upload without sending.
/// - Returns: whether anything was registered.
@discardableResult
private func enqueueAllLocalControlsPresets() async -> Bool {
guard canSyncControlsPresets else {
LoggingService.shared.logCloudKit("Cannot upload controls presets: sync disabled or settings category disabled")
return
return false
}
// Update progress
uploadProgress?.currentCategory = "Controls Presets"
@@ -1111,7 +1194,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
guard !presets.isEmpty else {
LoggingService.shared.logCloudKit("No controls presets to upload")
return
return false
}
// Track total operations
@@ -1123,7 +1206,7 @@ final class CloudKitSyncEngine: @unchecked Sendable {
addPendingChanges(changes, debounce: false)
LoggingService.shared.logCloudKit("Queued \(presets.count) controls presets for upload")
await sync()
return true
}
/// Queue a controls preset for sync (debounced).
@@ -1493,10 +1576,16 @@ final class CloudKitSyncEngine: @unchecked Sendable {
// Recreate zone
try await zoneManager.createZoneIfNeeded()
// Reinitialize sync engine
await setupSyncEngine()
// And put the local data back. The confirmation this action shows says
// the data "has been re-uploaded"; until now nothing was, and the
// device performing the reset left the zone as empty as the devices
// watching it.
await performInitialUpload()
LoggingService.shared.logCloudKit("Sync reset completed")
}
@@ -1574,33 +1663,74 @@ final class CloudKitSyncEngine: @unchecked Sendable {
/// deleted while account sync is off, and marking the records dirty is what
/// lets re-enabling restore them. The outbox is durable and nothing is sent
/// while the category is off, so priming it costs nothing.
func uploadAllAccounts() {
/// - Returns: whether the outbox was actually re-primed. It is not when the
/// accounts in memory belong to another identity, or to none — the
/// re-seed that asked for this has to be retried rather than considered
/// done. A build with no account sync at all owes nothing and is done.
@discardableResult
func uploadAllAccounts() -> Bool {
uploadProgress?.currentCategory = "Accounts"
accountSync?.uploadEverything()
return accountSync?.uploadEverything() ?? true
}
/// Registers every category of local data for upload, in re-seed order,
/// without asking the engine to send anything.
///
/// This is the half of the initial upload a `CKSyncEngineDelegate` callback
/// may run: it only mutates engine state, which the engine drains on its
/// own schedule. Asking the engine to fetch or send from inside one of its
/// event callbacks is a programmer error CloudKit traps on.
/// - Returns: whether every category registered what it owed.
@discardableResult
private func enqueueFullUpload() async -> Bool {
var complete = true
for category in ReseedCategory.reseedOrder {
switch category {
case .accounts:
// The one category whose registration can be refused, and the
// one that must not be quietly skipped: it carries the logins,
// the tombstones and the selected context.
complete = uploadAllAccounts() && complete
case .subscriptions:
enqueueAllLocalSubscriptions()
case .watchHistory:
enqueueAllLocalWatchHistory()
case .bookmarks:
enqueueAllLocalBookmarks()
case .playlists:
enqueueAllLocalPlaylists()
case .searchHistory:
enqueueAllLocalSearchHistory()
case .recentChannels:
enqueueAllRecentChannels()
case .recentPlaylists:
enqueueAllRecentPlaylists()
case .channelNotificationSettings:
enqueueAllLocalChannelNotificationSettings()
case .controlsPresets:
await enqueueAllLocalControlsPresets()
}
}
return complete
}
/// Performs full initial upload of all local data with progress tracking
func performInitialUpload() async {
guard isSyncEnabled else { return }
uploadProgress = UploadProgress(
currentCategory: "Starting...",
isComplete: false,
totalOperations: 0
)
// Upload each category sequentially with progress updates
await uploadAllLocalSubscriptions()
await uploadAllLocalWatchHistory()
await uploadAllLocalBookmarks()
await uploadAllLocalPlaylists()
await uploadAllLocalSearchHistory()
await uploadAllRecentChannels()
await uploadAllRecentPlaylists()
await uploadAllLocalChannelNotificationSettings()
await uploadAllLocalControlsPresets()
uploadAllAccounts()
// Register every category first, then send once: nine sends in a row
// bought nothing but round trips, and the accounts went last, which is
// how an interrupted upload lost them.
await enqueueFullUpload()
await sync()
// Mark complete
uploadProgress = UploadProgress(
@@ -1637,12 +1767,13 @@ final class CloudKitSyncEngine: @unchecked Sendable {
syncError = CloudKitError.notAuthenticated
case .zoneNotFound:
LoggingService.shared.logCloudKit("Zone not found, recreating...")
Task {
try? await zoneManager?.createZoneIfNeeded()
await sync()
}
// Through the engine's state: this can be reached from inside a
// delegate callback (`sync()` is called from several), where asking
// the engine to sync is a trap, and where the zone manager's
// memo would refuse to recreate anything anyway.
LoggingService.shared.logCloudKit("Zone not found, asking the engine to recreate it")
requestZoneRecreation()
case .partialFailure:
LoggingService.shared.logCloudKitError("Partial sync failure", error: error)
if let partialErrors = ckError.partialErrorsByItemID {
@@ -1668,9 +1799,14 @@ final class CloudKitSyncEngine: @unchecked Sendable {
}
private func scheduleRetry(delay: TimeInterval) {
Task {
// Detached, not a plain `Task`: an unstructured task copies the task
// locals of whatever created it, and this can be created from inside a
// sync-engine event callback — where CloudKit traps on a call back into
// the engine, and reads "inside the callback" from exactly those
// locals.
Task.detached { [weak self] in
try? await Task.sleep(for: .seconds(delay))
await sync()
await self?.sync()
}
}
}
@@ -1710,6 +1846,10 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
let stateSize = (try? encoder.encode(update.stateSerialization))?.count ?? 0
LoggingService.shared.logCloudKit("Sync state updated and saved (\(stateSize) bytes)")
saveSyncState(update.stateSerialization)
// The state just written is what a zone recovery registered
// itself into, so the note that it still owes that work can
// go — and not one moment earlier.
zoneRecovery.stateDidPersist()
updatePendingCount()
case .accountChange:
@@ -2052,11 +2192,11 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
LoggingService.shared.logCloudKit("Record \(recordName) missing on server, retrying as insert")
case .zoneNotFound:
// Zone was deleted — recreate it and retry the save
// Zone was deleted — recreate it and retry the save. The engine
// saves a pending zone ahead of the records that need it, and
// unlike the zone manager it has no memo saying it already did.
syncEngine?.state.add(pendingRecordZoneChanges: [.saveRecord(recordID)])
Task {
try? await zoneManager?.createZoneIfNeeded()
}
requestZoneRecreation()
LoggingService.shared.logCloudKit("Zone missing for \(recordName), recreating and retrying")
case .zoneBusy, .serviceUnavailable, .requestRateLimited, .networkFailure, .networkUnavailable,
@@ -2126,32 +2266,118 @@ extension CloudKitSyncEngine: CKSyncEngineDelegate {
}
/// Handle fetched database-level changes (zone creations/deletions).
/// Now that the engine resumes from persisted state, a remote deletion of our
/// zone (reset from another device, iCloud data purge, encrypted data reset)
/// would leave sync silently dead - recreate the zone and re-seed it from
/// local data.
///
/// A remote deletion of our zone — a reset from another device, an iCloud
/// data purge, an encrypted-data reset — would otherwise leave sync
/// silently dead against a zone that holds nothing.
///
/// Runs inside `handleEvent`, so it may only write to durable storage and
/// to the engine's state. It must never call `fetchChanges()` or
/// `sendChanges()`, directly or through anything it awaits: CloudKit traps
/// on re-entrant engine calls, in release builds, and the trap is what
/// killed the app on device A in the S14 campaign.
private func handleFetchedDatabaseChanges(_ changes: CKSyncEngine.Event.FetchedDatabaseChanges) async {
LoggingService.shared.logCloudKit("Fetched database changes")
guard changes.deletions.contains(where: { $0.zoneID.zoneName == RecordType.zoneName }) else {
guard let deletion = changes.deletions.first(where: { $0.zoneID.zoneName == RecordType.zoneName }) else {
return
}
LoggingService.shared.logCloudKit("Zone '\(RecordType.zoneName)' was deleted remotely - recreating and re-uploading local data")
UserDefaults.standard.removeObject(forKey: syncStateKey)
await answerZoneDeletion(ZoneDeletionResponse(reason: deletion.reason))
}
/// Answers a deletion of our zone, using nothing but durable storage and
/// the engine's own state.
///
/// Safe to run twice: re-registering pending changes collapses in the
/// engine state, and marking the account outbox dirty is idempotent. That
/// is what lets an interrupted recovery simply be redone on the next
/// launch.
private func answerZoneDeletion(_ response: ZoneDeletionResponse) async {
// Durable first. Everything below can be interrupted, and a relaunch
// that finds the zone recreated and no marker cannot tell a wiped zone
// from a healthy one — which is how the wipe became permanent on
// device A.
zoneRecovery.begin(response)
// Cached conflict records hold change tags from the deleted zone
conflictResolvedRecords.removeAll()
retryCount.removeAll()
do {
try await zoneManager?.createZoneIfNeeded()
await performInitialUpload()
} catch {
LoggingService.shared.logCloudKitError("Failed to recreate zone after remote deletion", error: error)
switch response {
case .reseed:
LoggingService.shared.logCloudKit(
"Zone '\(RecordType.zoneName)' was deleted remotely - recreating it and re-registering local data"
)
// Through the engine, not the zone manager: the manager remembers
// having created the zone this session and would do nothing, and
// the engine saves the zone ahead of the records that need it.
requestZoneRecreation()
finish(zoneRecovery, ifComplete: await enqueueFullUpload())
case .discard:
LoggingService.shared.logCloudKit(
"Zone '\(RecordType.zoneName)' was purged by the account owner - dropping what it held, nothing is resent"
)
finish(zoneRecovery, ifComplete: discardWhatTheZoneHeld())
}
}
/// Lets the marker be retired once the engine serializes its state — but
/// only if the recovery reached every category. The account half can refuse
/// while the identity it belongs to has not loaded, and a recovery that
/// left the accounts out is one the next launch has to redo.
private func finish(_ marker: CloudKitZoneRecoveryMarker, ifComplete complete: Bool) {
guard complete else {
LoggingService.shared.logCloudKit(
"Zone recovery could not reach the account records yet - keeping the marker for the next launch"
)
return
}
marker.readyToClear()
}
/// Re-registers the recovery a previous run recorded but may not have
/// finished. Called during setup, outside any delegate callback.
private func resumeZoneRecoveryIfNeeded() async {
guard let response = zoneRecovery.pending else { return }
LoggingService.shared.logCloudKit(
"A zone deletion from an earlier run was not fully answered (\(response.rawValue)) - answering it now"
)
await answerZoneDeletion(response)
}
/// Asks the engine to recreate the zone as part of its next send.
private func requestZoneRecreation() {
guard let syncEngine else { return }
syncEngine.state.add(pendingDatabaseChanges: [.saveZone(zone)])
}
/// Drops everything this device still owed the zone, and everything it had
/// cached from it.
///
/// For the purge reasons only. CloudKit is explicit that data deleted that
/// way must not be sent back, and a pending change is precisely a record
/// waiting to be sent. Local data itself is untouched: it is this app's own
/// store, and the purge was about the copy in iCloud.
/// - Returns: whether the account category could be cleared too. It cannot
/// while the accounts in memory belong to another identity, and a purge
/// half-answered has to be answered again.
private func discardWhatTheZoneHeld() -> Bool {
if let syncEngine {
let pending = syncEngine.state.pendingRecordZoneChanges
if !pending.isEmpty {
syncEngine.state.remove(pendingRecordZoneChanges: pending)
}
}
pendingChangesBuffer.removeAll()
clearDeferredItems()
let accountsCleared = accountSync?.discardWhatTheZoneHeld() ?? true
updatePendingCount()
return accountsCleared
}
/// Handle fetched changes from CloudKit.
private func handleFetchedChanges(_ changes: CKSyncEngine.Event.FetchedRecordZoneChanges) async {
// Staged first, and before any early return: the engine may persist its

View File

@@ -0,0 +1,139 @@
//
// CloudKitZoneRecovery.swift
// Yattee
//
// Deciding — and remembering — what to do when the record zone is deleted out
// from under this device.
//
import CloudKit
import Foundation
/// What a remote deletion of the app's record zone requires of this device.
enum ZoneDeletionResponse: String, Sendable {
/// Recreate the zone and register every local record for upload again.
///
/// The zone was deleted by this app — "Reset iCloud Sync Data" on another
/// device — so local data is still the truth and iCloud is the copy that
/// went missing.
case reseed
/// Drop what the zone held, and never send it back.
///
/// The account owner asked iCloud to forget this app's data (Settings →
/// iCloud → Manage Storage) or reset their encrypted data. CloudKit is
/// explicit that data deleted for either reason must not be resent, so
/// nothing is republished and everything still queued from before the
/// deletion is dropped. Local data is left alone: it is this app's own
/// store, not a cache of the zone.
case discard
/// The response CloudKit's deletion reason calls for.
///
/// A reason this build does not recognise is answered as a purge:
/// republishing data the server deliberately dropped is the one mistake
/// here that cannot be taken back.
init(reason: CKDatabase.DatabaseChange.Deletion.Reason) {
switch reason {
case .deleted:
self = .reseed
case .purged, .encryptedDataReset:
self = .discard
@unknown default:
self = .discard
}
}
}
/// One category of local data a re-seed publishes.
enum ReseedCategory: String, CaseIterable, Sendable {
case accounts
case subscriptions
case watchHistory
case bookmarks
case playlists
case searchHistory
case recentChannels
case recentPlaylists
case channelNotificationSettings
case controlsPresets
/// The order a re-seed registers categories in.
///
/// Accounts lead. They carry the logins, the deletion tombstones and the
/// selected context — the records every other category is read through —
/// and a re-seed can be cut short at any point by the same backgrounding,
/// sign-out or crash that interrupts any other upload. Going last, as the
/// initial upload used to, is how a wipe took the accounts with it.
static let reseedOrder: [ReseedCategory] = [
.accounts,
.subscriptions,
.watchHistory,
.bookmarks,
.playlists,
.searchHistory,
.recentChannels,
.recentPlaylists,
.channelNotificationSettings,
.controlsPresets
]
}
/// Remembers that the zone was wiped, until the answer to it is durably queued.
///
/// Answering a wipe takes several steps — recreate the zone, re-register every
/// local record — and the process can die between any two of them. Without a
/// marker written *before* the first step, a relaunch finds a zone that exists
/// again and no local sign that anything was lost, and the wipe is invisible
/// for good.
///
/// Durability rests on what the engine's own change token rests on: both are
/// `UserDefaults` writes, so a crash that preserved the token preserved a
/// marker written before it too.
@MainActor
final class CloudKitZoneRecoveryMarker {
private let defaults: UserDefaults
private let key: String
/// Whether the work this marker stands for is registered with the engine
/// and only waiting for the engine to serialize its state.
private var awaitingStatePersistence = false
init(defaults: UserDefaults = .standard, key: String = "cloudKitZoneNeedsReseed") {
self.defaults = defaults
self.key = key
}
/// The response a run recorded and may not have finished.
var pending: ZoneDeletionResponse? {
defaults.string(forKey: key).flatMap(ZoneDeletionResponse.init(rawValue:))
}
/// Records what has to happen — before any of it does.
func begin(_ response: ZoneDeletionResponse) {
defaults.set(response.rawValue, forKey: key)
awaitingStatePersistence = false
}
/// Says the recovery is fully registered with the engine, so the next
/// serialization of that state may retire the marker.
///
/// Deliberately separate from `begin`: a recovery that could not register
/// everything — accounts belonging to an identity that has not loaded yet —
/// keeps its marker and is redone on the next launch.
func readyToClear() {
awaitingStatePersistence = true
}
/// Retires the marker once the engine has persisted the state the recovery
/// registered, and only then.
///
/// State updates arrive for every other reason too, and one of those must
/// not retire a marker whose work has not been redone — that is exactly the
/// state a relaunch after an interrupted recovery starts in.
func stateDidPersist() {
guard awaitingStatePersistence else { return }
awaitingStatePersistence = false
defaults.removeObject(forKey: key)
}
}

View File

@@ -0,0 +1,179 @@
//
// CloudKitZoneRecoveryTests.swift
// YatteeTests
//
// Answering a record zone that was deleted on the server.
//
import CloudKit
import Foundation
import Testing
@testable import Yattee
@Suite("CloudKit Zone Recovery")
@MainActor
struct CloudKitZoneRecoveryTests {
private func makeDefaults() -> UserDefaults {
UserDefaults(suiteName: "yattee.tests.\(UUID().uuidString)")!
}
// MARK: - Reason → behaviour
@Suite("Deletion reason")
@MainActor
struct DeletionReasonTests {
@Test("Our own reset elsewhere re-seeds the zone")
func appDeletionReseeds() {
#expect(ZoneDeletionResponse(reason: .deleted) == .reseed)
}
@Test("A purge from iCloud settings is never resent")
func purgeDiscards() {
#expect(ZoneDeletionResponse(reason: .purged) == .discard)
}
@Test("An encrypted-data reset is never resent")
func encryptedDataResetDiscards() {
#expect(ZoneDeletionResponse(reason: .encryptedDataReset) == .discard)
}
}
// MARK: - Re-seed composition
@Suite("Re-seed order")
@MainActor
struct ReseedOrderTests {
@Test("Accounts are registered before anything else")
func accountsGoFirst() {
#expect(ReseedCategory.reseedOrder.first == .accounts)
}
@Test("Every category is re-seeded, exactly once")
func orderCoversEveryCategory() {
#expect(Set(ReseedCategory.reseedOrder) == Set(ReseedCategory.allCases))
#expect(ReseedCategory.reseedOrder.count == ReseedCategory.allCases.count)
}
}
// MARK: - Marker lifecycle
@Test("Nothing is pending until a deletion is recorded")
func noMarkerByDefault() {
let marker = CloudKitZoneRecoveryMarker(defaults: makeDefaults())
#expect(marker.pending == nil)
}
@Test("The marker is written before the work and outlives the process")
func markerSurvivesRelaunch() {
let defaults = makeDefaults()
CloudKitZoneRecoveryMarker(defaults: defaults).begin(.reseed)
// A new instance is what a relaunch sees.
#expect(CloudKitZoneRecoveryMarker(defaults: defaults).pending == .reseed)
}
@Test("A state update retires the marker only once the work is registered")
func markerClearsAfterStatePersists() {
let defaults = makeDefaults()
let marker = CloudKitZoneRecoveryMarker(defaults: defaults)
marker.begin(.reseed)
// The engine serializes its state for all sorts of reasons; one that
// arrives before the re-seed is registered must leave the marker alone.
marker.stateDidPersist()
#expect(marker.pending == .reseed)
marker.readyToClear()
marker.stateDidPersist()
#expect(marker.pending == nil)
}
@Test("A recovery interrupted before it registered is still pending next launch")
func interruptedRecoveryIsRetried() {
let defaults = makeDefaults()
let crashed = CloudKitZoneRecoveryMarker(defaults: defaults)
crashed.begin(.reseed)
// ...process dies here: nothing was registered, nothing was cleared.
let relaunched = CloudKitZoneRecoveryMarker(defaults: defaults)
#expect(relaunched.pending == .reseed)
// A state update in the new process, before the recovery is redone,
// must not retire the marker it inherited.
relaunched.stateDidPersist()
#expect(relaunched.pending == .reseed)
relaunched.begin(.reseed)
relaunched.readyToClear()
relaunched.stateDidPersist()
#expect(relaunched.pending == nil)
}
@Test("Retiring the marker twice is harmless")
func clearingIsIdempotent() {
let defaults = makeDefaults()
let marker = CloudKitZoneRecoveryMarker(defaults: defaults)
marker.begin(.discard)
marker.readyToClear()
marker.stateDidPersist()
marker.stateDidPersist()
#expect(marker.pending == nil)
}
@Test("A fresh deletion is not retired by the previous one's state update")
func newDeletionRearms() {
let defaults = makeDefaults()
let marker = CloudKitZoneRecoveryMarker(defaults: defaults)
marker.begin(.reseed)
marker.readyToClear()
// A second wipe arrives before the first one's state was serialized.
marker.begin(.discard)
marker.stateDidPersist()
#expect(marker.pending == .discard)
}
@Test("A marker written by a build that meant something else is ignored")
func unknownMarkerValueIsIgnored() {
let defaults = makeDefaults()
defaults.set("something-else", forKey: "cloudKitZoneNeedsReseed")
#expect(CloudKitZoneRecoveryMarker(defaults: defaults).pending == nil)
}
// MARK: - Account side of a re-seed
@Test("A re-seed re-primes the account outbox, a purge empties it")
func accountOutboxFollowsTheResponse() {
let defaults = makeDefaults()
let accounts = AccountsManager(
identityScope: CredentialIdentityScope(fixedScope: "identity-A"),
instancesManager: nil,
defaults: defaults
)
let account = accounts.addAccount(instanceID: UUID(), displayName: "Main")
accounts.confirmUpload(account.id)
#expect(accounts.pendingUploads.isEmpty)
// What a re-seed does: the server holds nothing, so everything is owed
// again even though nothing changed locally.
accounts.markAllRecordsForUpload()
#expect(accounts.pendingUploads.contains(account.id))
// What a purge does: the account stays, the intent to upload it does not.
accounts.clearPendingUploads()
#expect(accounts.pendingUploads.isEmpty)
#expect(accounts.account(id: account.id) != nil)
// And it is durable, so a relaunch does not resend what was purged.
let relaunched = AccountsManager(
identityScope: CredentialIdentityScope(fixedScope: "identity-A"),
instancesManager: nil,
defaults: defaults
)
#expect(relaunched.pendingUploads.isEmpty)
#expect(relaunched.account(id: account.id) != nil)
}
}