From 61ab9359ee9aaeba71563cab3449a95a3558e5e6 Mon Sep 17 00:00:00 2001 From: rzen Date: Sat, 4 Jul 2026 10:04:46 -0400 Subject: [PATCH] Resolve iCloud conflict versions before reads (last-writer-wins) Ports QuickRabbit's resolveConflictsIfAny (R4.3) into the eviction-safe read path: before every coordinated read, pick the NSFileVersion with the latest modification date, promote it to the canonical file inside a coordinated write, mark all conflicts resolved, and prune the losers. Without this, conflict siblings from two-device edits (workout logs edited mid-session are the risky case) accumulate silently and a read returns whichever version the filesystem hands back. Claude-Session: https://claude.ai/code/session_01SY5jsAUf4qoPxSvv8xAqXS --- CHANGELOG.md | 2 + Workouts/Sync/ICloudFileManager.swift | 56 +++++++++++++++++++++++++-- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92e5560..54d0cc7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ Fixed workouts and splits vanishing from the app when iPhone storage ran low: when iOS offloads their files to iCloud to free up space, Workouts now downloads them back automatically instead of treating them as gone. +When the same workout or split is edited on two devices around the same time, all devices now settle on the most recent change instead of some keeping an older version. + **June 2026** Starting a new workout while another is still going now asks whether to end the current one first or run both in parallel. diff --git a/Workouts/Sync/ICloudFileManager.swift b/Workouts/Sync/ICloudFileManager.swift index 7a993cd..a20878a 100644 --- a/Workouts/Sync/ICloudFileManager.swift +++ b/Workouts/Sync/ICloudFileManager.swift @@ -34,11 +34,13 @@ actor ICloudFileManager { if let error = coordError ?? writeError { throw error } } - /// Eviction-safe read: materializes an evicted file (bounded wait) before the - /// coordinated read, so callers never decode a dataless placeholder. Throws on - /// download timeout rather than skipping — a silently dropped evicted file - /// looks to the user like the record was deleted. + /// Eviction-safe, conflict-safe read: resolves any iCloud conflict versions, + /// materializes an evicted file (bounded wait), then does the coordinated + /// read — so callers never decode a dataless placeholder or a stale conflict + /// sibling. Throws on download timeout rather than skipping — a silently + /// dropped evicted file looks to the user like the record was deleted. func read(relativePath: String) async throws -> Data { + resolveConflictsIfAny(at: documentsURL.appendingPathComponent(relativePath)) try await ensureDownloaded(relativePath: relativePath) let fileURL = documentsURL.appendingPathComponent(relativePath) var coordError: NSError? @@ -156,6 +158,52 @@ actor ICloudFileManager { return components.joined(separator: "/") } + // MARK: - Conflict resolution + + /// If iCloud Drive created conflict versions (concurrent writes from + /// different devices), pick whichever has the latest modification date, + /// promote it to the canonical file, and mark every conflict resolved. + /// Whole-file last-writer-wins by mod-date — honest for one-file-per-record + /// data. Left unresolved, conflict siblings accumulate silently and a + /// coordinated read returns whichever version the filesystem hands back. + private func resolveConflictsIfAny(at fileURL: URL) { + let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: fileURL) ?? [] + guard !conflicts.isEmpty else { return } + + let currentDate = NSFileVersion.currentVersionOfItem(at: fileURL)?.modificationDate ?? .distantPast + var winnerDate = currentDate + var winnerVersion: NSFileVersion? + + for version in conflicts { + if let date = version.modificationDate, date > winnerDate { + winnerDate = date + winnerVersion = version + } + } + + // Promote the winner and prune the losers inside a coordinated write — + // these are real file mutations (replaceItem / removeOtherVersions) and + // must not race a concurrent write or the iCloud daemon; NSFileVersion + // does not coordinate internally. + var coordError: NSError? + NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forReplacing, error: &coordError) { url in + if let winner = winnerVersion { + do { + try winner.replaceItem(at: url, options: []) + } catch { + print("[Sync] Conflict promotion failed for \(url.lastPathComponent): \(error)") + } + } + for version in conflicts { + version.isResolved = true + } + try? NSFileVersion.removeOtherVersionsOfItem(at: url) + } + if let coordError { + print("[Sync] Conflict coordination failed for \(fileURL.lastPathComponent): \(coordError)") + } + } + // MARK: - Eviction /// Triggers a download for an evicted file and polls until it materializes.