fix(imessage): stop blob sync starving the periodic read cycle

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 <noreply@anthropic.com>
This commit is contained in:
Natalie 2026-06-23 14:37:40 -04:00
parent 464bbbd48d
commit ab44591b8a
2 changed files with 27 additions and 3 deletions

View file

@ -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" : ""

View file

@ -65,6 +65,10 @@ final class SyncManager: BaseSyncManager<SyncStats, SyncError> {
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<SyncStats, SyncError> {
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 {