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:
parent
464bbbd48d
commit
ab44591b8a
2 changed files with 27 additions and 3 deletions
|
|
@ -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" : ""
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue