Keep an automatic repair from ever reaching the server

A repair carries the timestamp of the selection it replaced, which is what
stops it outranking a deliberate choice made elsewhere — but it also makes
every ordering comparison read it as that choice. A fetch holding the
selected account's tombstone and an older server context applied them in
that order: the tombstone repaired the selection, and the older context
then lost to it, so the "the server holds the stale copy" path marked the
repair dirty and uploaded an automatic fallback under the deleted choice's
timestamp, over an explicit choice. The stored context now remembers
whether it is a repair, and a repair that wins an ordering pushes nothing
back: the server's context stands remotely, the fallback stays local, and
the next explicit choice on any device re-converges everyone. The bit is
persisted with the context, or a relaunch would forget and publish it on
the first stale record it read.

Three more from the same trace:

- The repair prefers a signed-in login on the same server, decided off
  statuses, and it commits — once the selection no longer names the deleted
  account there is nothing left for a later status to move. Foreground
  activation refreshes statuses ahead of it, but a deletion delivered by a
  silent push arrives outside that sequence, and a credential that reached
  this device over iCloud Keychain while it was in the background announces
  nothing. The refresh is now asked for by the repair itself, which closes
  every entry point rather than each fetch trigger having to remember one.

- A device that meets a tombstone before the account it kills cannot record
  which instance that account lived on, and the account record arriving
  afterwards was discarded — with the only copy of that fact still in it.
  Every later repair of a context naming it then fell to local past a
  usable sibling. The association is kept as the record is dropped.

- Removing an orphaned account deleted its credential before recording the
  deletion, so a crash in between brought the account back on the next
  launch as one nobody can sign into, owing no deletion to any device. The
  outbox goes first, here and in the sign-out and instance cascades; the
  scoped credential key needs only the account's ID, and the store is
  resolved through the instance before anything is removed.
This commit is contained in:
Arkadiusz Fal
2026-09-05 23:06:10 +02:00
parent ca73b68ad9
commit ccf5c51460
7 changed files with 386 additions and 13 deletions

View File

@@ -30,11 +30,19 @@ enum AccountRemoval {
accountsManager: AccountsManager,
discardData: (UUID) -> Void
) {
// The deletion goes into the durable outbox first, for the same reason
// `removeAccount` records it before anything else: a crash after the
// credential was deleted but before the tombstone existed would bring
// the account back on the next launch as one nobody can sign into, with
// no deletion owed to any other device. Nothing here needs the account
// to still exist — the scoped credential key is built from the ID alone,
// which is the whole reason an orphan is removed this way.
accountsManager.removeAccount(accountID)
for store in stores {
store.deleteCredential(forAccountID: accountID)
}
accountsManager.removeAccount(accountID)
// Whatever the account had in memory goes with it, or its subscriptions
// and playlists would keep answering for an account nobody has.
discardData(accountID)

View File

@@ -75,6 +75,17 @@ final class AccountsManager {
/// Whether the selected context still has to reach the server.
private(set) var pendingContextUpload = false
/// Whether `storedContext` holds an automatic repair rather than a choice.
///
/// A repair keeps the timestamp of the selection it replaced, so every
/// ordering comparison reads it as that choice — including the one that
/// decides an older server copy has to be corrected. Without this the
/// fallback would be pushed back over a deliberate choice another device
/// made, which is exactly what not stamping it "now" is meant to prevent.
/// Persisted beside the context, or a relaunch would forget and publish it
/// on the next stale record it saw.
private(set) var storedContextIsRepair = false
/// Instances the user has deleted here.
///
/// Deleting an instance deletes the accounts on it, but those accounts may
@@ -331,6 +342,7 @@ final class AccountsManager {
context: repairTarget(onInstance: instanceID),
changedAt: storedContext.changedAt
)
storedContextIsRepair = true
// Including any upload the deleted choice had queued: sending it now
// would publish this fallback under that choice's timestamp, which is
// exactly what not stamping it "now" is meant to prevent.
@@ -380,6 +392,15 @@ final class AccountsManager {
private func repairTarget(onInstance instanceID: UUID?) -> DataContext {
guard let instanceID else { return .local }
// Asked here rather than at the call sites because a repair commits:
// once the selection no longer names the deleted account there is
// nothing left for a later status to move. A deletion delivered by a
// silent push arrives without any of the events that recompute statuses
// — a credential can reach this device over iCloud Keychain while it is
// in the background — so the sibling would be skipped as signed out
// while its credential sat right there.
refreshStatusesForRepair?()
let sibling = accounts(forInstance: instanceID)
.filter { status(for: $0.id) == .signedIn }
.filter { capabilities(for: $0)?.isSelectableContext == true }
@@ -391,6 +412,16 @@ final class AccountsManager {
return .account(sibling.id)
}
/// Recomputes account statuses from the Keychain, just before a repair
/// decides which sibling — if any — it may land on.
///
/// A hook rather than a dependency for the same reason as
/// `onSuspectedCredentialRejection`: reading the Keychain needs the
/// credential stores, and this manager deliberately knows nothing about
/// them. `nil` — tests, and the window before wiring completes — simply
/// leaves the statuses as they are, which is what the repair did before.
@ObservationIgnored var refreshStatusesForRepair: (@MainActor () -> Void)?
/// Marks a deletion as confirmed by the server. Until then it stays in the
/// outbox and is retried, because a tombstone that never lands lets a stale
/// device resurrect the account.
@@ -437,6 +468,29 @@ final class AccountsManager {
deletedAccountIDs.contains(accountID)
}
/// Remembers which instance a deleted account lived on, learned from a
/// record that arrived after its tombstone.
///
/// A device that meets the tombstone first — a fresh install, or any fetch
/// that stages both — has no account to read the instance off, and the
/// account record that follows is dominated and discarded. That record is
/// the only thing that still knows where the account lived, and without it
/// every later repair of a context naming it falls to local past a
/// perfectly good sibling on the same server.
func recordDeletedAccountInstance(_ instanceID: UUID, for accountID: UUID) {
// The namespace moved and the reload has not refilled memory yet, so
// this would file one identity's deletion under another's.
guard loadedNamespace == namespace else { return }
guard deletedAccountIDs.contains(accountID) else { return }
// The deletion this device performed itself read the instance off the
// account it removed, which is closer to the truth than a record that
// may have been resurrected since.
guard deletedAccountInstanceIDs[accountID] == nil else { return }
deletedAccountInstanceIDs[accountID] = instanceID
save()
}
/// Marks every account record dirty again.
///
/// For a server that holds nothing — sync being enabled for the first time,
@@ -448,7 +502,11 @@ final class AccountsManager {
pendingUploads.formUnion(accounts.map(\.id))
pendingMarkerUploads.formUnion(migratedInstanceIDs)
pendingDeletions.formUnion(deletedAccountIDs)
pendingContextUpload = hasStoredContext
// An empty zone is no reason to publish a fallback nobody chose: a
// repair is never uploaded, however little the server holds. The next
// explicit choice fills the gap, and until then every device keeps
// repairing to whatever it can actually use.
pendingContextUpload = hasStoredContext && !storedContextIsRepair
save()
}
@@ -481,8 +539,13 @@ final class AccountsManager {
@discardableResult
func applySyncedAccount(_ incoming: Account) -> Bool {
// A tombstone dominates the account everywhere. The caller repairs the
// server copy; here it simply never becomes local state again.
guard !deletedAccountIDs.contains(incoming.id) else { return false }
// server copy; here it simply never becomes local state again — except
// for the one thing worth keeping: which instance it lived on, for a
// context repair that has no account left to read it off.
guard !deletedAccountIDs.contains(incoming.id) else {
recordDeletedAccountInstance(incoming.instanceID, for: incoming.id)
return false
}
// Its instance was deleted here while this account was still in
// transit. Deleting an instance deletes its accounts, so this one is
@@ -606,6 +669,8 @@ final class AccountsManager {
func setContext(_ context: DataContext) {
storedContext = StoredDataContext(context: context)
hasStoredContext = true
// Whatever stood here before, this is a choice — and choices publish.
storedContextIsRepair = false
pendingContextUpload = true
save()
NotificationCenter.default.post(name: .dataContextDidChange, object: nil)
@@ -659,6 +724,13 @@ final class AccountsManager {
// repair that lost the ordering never gets to clear what that
// choice still owes the server.
guard incoming.isOutranked(by: storedContext) else { return false }
// Newer, but not a choice. A repair carries the replaced selection's
// timestamp, so it outranks the server's older explicit value while
// having nothing of its own to say; publishing it would push an
// automatic fallback over that value. Nothing is uploaded here: the
// server's context stands remotely, the repair stays local, and the
// next explicit choice on any device re-converges everyone.
guard !storedContextIsRepair else { return false }
pendingContextUpload = true
save()
return true
@@ -677,6 +749,9 @@ final class AccountsManager {
private func adoptSyncedContext(_ incoming: StoredDataContext, wasRepaired: Bool) {
storedContext = incoming
hasStoredContext = true
// A value adopted as it stands is somebody's choice and may be pushed
// back if the server ever holds an older one; a repaired one may not.
storedContextIsRepair = wasRepaired
if wasRepaired {
pendingContextUpload = false
}
@@ -903,6 +978,7 @@ final class AccountsManager {
storedContext = .migrated(context)
hasStoredContext = true
storedContextIsRepair = false
// Published like any other context, but stamped at the epoch: if a real
// choice exists anywhere it outranks this one whichever arrives first.
pendingContextUpload = true
@@ -1019,6 +1095,7 @@ final class AccountsManager {
}
pendingContextUpload = defaults.bool(forKey: key("pendingContextUpload"))
storedContextIsRepair = defaults.bool(forKey: key("contextIsRepair"))
purgeUnresolvedNamespace()
}
@@ -1027,7 +1104,8 @@ final class AccountsManager {
private static let storedKeyNames = [
"accounts", "context", "hasContext", "migrated", "pendingUploads",
"pendingDeletions", "pendingMarkerUploads", "deletedAccounts",
"deletedInstances", "deletedAccountInstances", "pendingContextUpload"
"deletedInstances", "deletedAccountInstances", "pendingContextUpload",
"contextIsRepair"
]
/// Deletes whatever was written while the identity was still unresolved.
@@ -1085,6 +1163,7 @@ final class AccountsManager {
forKey: key("deletedAccountInstances")
)
defaults.set(pendingContextUpload, forKey: key("pendingContextUpload"))
defaults.set(storedContextIsRepair, forKey: key("contextIsRepair"))
}
/// Re-reads storage under the current identity namespace.
@@ -1109,6 +1188,7 @@ final class AccountsManager {
deletedInstanceIDs = []
deletedAccountInstanceIDs = [:]
pendingContextUpload = false
storedContextIsRepair = false
load()
reconcileLegacyAccounts()

View File

@@ -493,6 +493,18 @@ final class AppEnvironment {
// A deletion that arrives from another device takes the credential with
// it. Only a deletion does this — an account merely missing from a
// fetch keeps everything, because a fetch is not an enumeration.
// A context repair reads statuses and commits — there is no second
// chance at it once the selection no longer names the deleted account —
// and the deletion that triggers one can arrive at any moment: a silent
// push handled in the background, long after anything last recomputed
// them. Foreground activation sequences the refresh ahead of the repair
// for exactly this reason; wiring it into the repair itself closes every
// other entry point too, instead of each fetch trigger having to
// remember the order.
accounts.refreshStatusesForRepair = { [weak self] in
self?.refreshAccountStatuses()
}
accountSync.onAccountDeleted = { [weak self] accountID, account in
guard let self else { return }
@@ -868,11 +880,15 @@ final class AppEnvironment {
accountsManager.recordInstanceDeletion(instance.id)
for account in accountsManager.accounts(forInstance: instance.id) {
// Resolved while the instance is still registered — it is removed
// below — and the account's own deletion recorded before its secret
// is touched, so a crash in between cannot leave a signed-out
// account behind with no deletion owed to anybody.
let credentials = accountCredentialsManager(for: account)
accountsManager.removeAccount(account.id)
credentials?.deleteCredential(for: account)
credentials?.deleteLegacyCredentialIfOwned(for: account)
discardAccountData(for: account.id)
accountsManager.removeAccount(account.id)
}
instancesManager.remove(instance)
@@ -884,12 +900,21 @@ final class AppEnvironment {
/// cleared is added here rather than in each screen that lists accounts.
func signOutAndRemoveAccount(_ account: Account) {
let instance = accountsManager.instance(for: account)
// Resolved before the account is removed, though nothing about the
// lookup needs it to still be there: the store is chosen by the
// instance's type, and the instance is not going anywhere here.
let credentials = accountCredentialsManager(for: account)
// The tombstone and its outbox entry go first. A crash after the
// credential was deleted but before the deletion was recorded would
// otherwise leave the account standing on the next launch — signed out,
// and with nothing owed to the devices that still have it.
accountsManager.removeAccount(account.id)
// Deleting the account is what authorizes removing the legacy item, and
// only when this device adopted it and it still holds that same secret.
accountCredentialsManager(for: account)?.deleteCredential(for: account)
accountCredentialsManager(for: account)?.deleteLegacyCredentialIfOwned(for: account)
accountsManager.removeAccount(account.id)
credentials?.deleteCredential(for: account)
credentials?.deleteLegacyCredentialIfOwned(for: account)
// Drop what the account had in memory too, or its subscriptions and
// playlists would keep answering for an account that no longer exists.
discardAccountData(for: account.id)

View File

@@ -496,6 +496,11 @@ final class AccountSyncCoordinator {
// tombstone ignores it locally and deletes it again server-side; the
// window is bounded by fetch propagation, not by trust.
guard !accountsManager.isDeleted(account.id) else {
// The record is discarded, but not before it says where the account
// lived: a device that met the tombstone first has no other source
// for that, and a context repair without it lands on local past a
// signed-in sibling on the same server.
accountsManager.recordDeletedAccountInstance(account.instanceID, for: account.id)
LoggingService.shared.logCloudKit("Ignoring resurrected account \(account.id) and re-deleting it")
enqueueChanges?([.deleteRecord(recordID(for: .account(id: account.id)))])
return
@@ -868,8 +873,14 @@ final class AccountSyncCoordinator {
switch resolved.recordType {
case RecordType.account:
guard let account = try? mapper.toAccount(from: resolved),
!accountsManager.isDeleted(account.id) else { return }
guard let account = try? mapper.toAccount(from: resolved) else { return }
// Dominated by a tombstone, so nothing of it becomes local state —
// beyond the instance it lived on, which a later context repair has
// no other way to learn.
guard !accountsManager.isDeleted(account.id) else {
accountsManager.recordDeletedAccountInstance(account.instanceID, for: account.id)
return
}
accountsManager.applySyncedAccount(account)
case RecordType.dataContext: