From ab44591b8acfcaa6f9c60c36dc5d0a88a8b11196 Mon Sep 17 00:00:00 2001 From: Natalie Date: Tue, 23 Jun 2026 14:37:40 -0400 Subject: [PATCH] fix(imessage): stop blob sync starving the periodic read cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The iMessage read cycle is driven by BaseSyncManager's 30s timer → syncNow(), which is gated by 'guard !isSyncing'. performSync awaited blobSyncManager.syncBlobs() inline, and that blob pass infinite-loops when the upload backend is failing: /attachments/missing has no cursor, so a full page of perpetually-failing uploads is re-fetched and re-failed forever, the loop only breaking on a < pageSize page. performSync never returned → isSyncing stuck true → every 30s read tick swallowed. Net effect: messages only synced on app launch, drifting hours behind between restarts (send-queue timers are independent, so they kept polling — the tell that the timer fired but syncNow was gated). Two fixes: - Decouple the blob pass: fire it detached + in-flight-guarded instead of awaiting it on the read cycle, so a slow/failing blob backend can never hold isSyncing. - Bound the blob loop: stop a pass after any full page that produced zero successful uploads (the same missing set would be re-fetched), instead of spinning forever. Verified: read cycle now fires every ~30s on the live process without a restart; blob pass logs 'stopping pass' and returns; store lag ~7s. Co-Authored-By: Claude Opus 4.8 --- .../IMessageSync/BlobSyncManager.swift | 10 ++++++++++ .../Sources/IMessageSync/SyncManager.swift | 20 ++++++++++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/@packages/imessage/Sources/IMessageSync/BlobSyncManager.swift b/@packages/imessage/Sources/IMessageSync/BlobSyncManager.swift index ded3795..fdbce0c 100644 --- a/@packages/imessage/Sources/IMessageSync/BlobSyncManager.swift +++ b/@packages/imessage/Sources/IMessageSync/BlobSyncManager.swift @@ -66,6 +66,7 @@ final class BlobSyncManager: @unchecked Sendable { page += 1 log.info("BlobSyncManager: page \(page), uploading \(items.count) blobs") + let uploadedBefore = totalUploaded for item in items { guard let (data, contentType) = readAttachmentFile(item: item, queuedCount: &totalQueued) else { totalSkipped += 1 @@ -87,6 +88,15 @@ final class BlobSyncManager: @unchecked Sendable { } if items.count < pageSize { break } + // No cursor on the missing-attachments endpoint: a full page that + // produced zero successful uploads returns identically on the next + // fetch (failed uploads don't shrink the missing set). Stop the pass + // to avoid an infinite re-fetch loop that wedges sync; next pass retries. + if totalUploaded == uploadedBefore { + log.warning("BlobSyncManager: full page, 0 uploads — stopping pass to avoid re-fetch loop") + await MainActor.run { activityLog.warning("Blob sync: backend not accepting uploads — stopping pass") } + break + } } let queuedNote = totalQueued > 0 ? ", \(totalQueued) queued for iCloud download" : "" diff --git a/@packages/imessage/Sources/IMessageSync/SyncManager.swift b/@packages/imessage/Sources/IMessageSync/SyncManager.swift index 4173f05..c270e8c 100644 --- a/@packages/imessage/Sources/IMessageSync/SyncManager.swift +++ b/@packages/imessage/Sources/IMessageSync/SyncManager.swift @@ -65,6 +65,10 @@ final class SyncManager: BaseSyncManager { private let sendService = SendService.shared private let activityLog = ActivityLog.shared private let blobSyncManager: BlobSyncManager + /// Guards the out-of-band blob pass so passes never overlap. The blob pass is + /// fired detached from the read cycle (see performSync) — it must never be + /// awaited there, or a failing blob backend wedges the base `isSyncing` flag. + private var blobSyncInFlight = false /// First-cycle bootstrap (DB connect, schema-version reset, contacts load) is /// done lazily inside `performSync` since the base's `startSync` is final. @@ -220,9 +224,19 @@ final class SyncManager: BaseSyncManager { await runReadCycle() } - // Blob pass runs after every read cycle; uploads any messages whose - // attachment bytes haven't been pushed yet. - await blobSyncManager.syncBlobs() + // Blob pass runs OUT OF BAND — never awaited on the read cycle. The + // missing-attachments endpoint has no cursor, so a failing blob backend + // returns the same full page every fetch; awaiting that here held the + // base `isSyncing` flag indefinitely and starved message ingestion (every + // 30s read-timer tick got swallowed by syncNow's guard). Fire it detached, + // guarded so passes don't overlap. + if !blobSyncInFlight { + blobSyncInFlight = true + Task { [weak self, blobSyncManager] in + await blobSyncManager.syncBlobs() + self?.blobSyncInFlight = false + } + } } private func performInitialSync() async {