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 {