From 6ad47279599e91e11c1702f9c29eb4f2cd736f38 Mon Sep 17 00:00:00 2001 From: Michael Yeack Date: Mon, 1 Jun 2026 15:12:38 -0700 Subject: [PATCH] Harden bulk sync-apply to prevent SwiftData @Query faulting crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After a 95-message iMessage backfill, the macOS app crash-looped (EXC_BAD_ACCESS in SwiftData, 4x in 45s) the moment the interactions bulk-synced in. pullRemoteChanges applied every pulled record to the main context and saved once, so a fresh/large sync committed a single 0→N transaction; the dashboard's @Query then re-rendered the entire new object graph at once and faulted the Interaction↔Contact relationship under Release optimization. (Debug and settled-data Release builds don't crash — it was a transient race during the one-time bulk insert.) Apply each dependency layer in chunks of 25 with intermediate saves (applyInChunks) so no single massive 0→N transition occurs. Dependency order and the final save are unchanged; threading is unchanged (still the main context). Bonus: a mid-pull failure now preserves already-applied chunks instead of losing the whole batch (idempotent UUID-upsert heals the rest next sync). Defense-in-depth: the race is timing/optimization-dependent and could not be reproduced on settled data, so this is verified by build + tests + reasoning, not by reproducing the original crash. Co-Authored-By: Claude Opus 4.8 --- .../Services/LocalServerSyncService.swift | 45 ++++++++++++++----- docs/test-scenarios/TEST_SCENARIOS.md | 15 +++++++ 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/Blackbook/Services/LocalServerSyncService.swift b/Blackbook/Services/LocalServerSyncService.swift index 88c2d81..b174638 100644 --- a/Blackbook/Services/LocalServerSyncService.swift +++ b/Blackbook/Services/LocalServerSyncService.swift @@ -408,58 +408,81 @@ final class LocalServerSyncService { guard let top = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } - // Apply in dependency order: leaf entities first + // Apply in dependency order: leaf entities first. Each layer is applied in chunks with + // intermediate saves (see `applyInChunks`) so a fresh full sync never commits one massive + // 0→N transaction — that made the UI's @Query re-render the entire new object graph at once + // and faulted SwiftData relationships, crashing under Release optimization (work_log 2026-06-01). // Layer 0: Tags, Groups, Locations, RejectedCalendarEvents if let tags = top["tags"] as? [[String: Any]] { logger.info("Pulled \(tags.count) tag(s)") - for dict in tags { try ModelSyncApply.applyRemoteTag(dict, to: context) } + try applyInChunks(tags, context: context, apply: ModelSyncApply.applyRemoteTag) } if let groups = top["groups"] as? [[String: Any]] { logger.info("Pulled \(groups.count) group(s)") - for dict in groups { try ModelSyncApply.applyRemoteGroup(dict, to: context) } + try applyInChunks(groups, context: context, apply: ModelSyncApply.applyRemoteGroup) } if let locations = top["locations"] as? [[String: Any]] { logger.info("Pulled \(locations.count) location(s)") - for dict in locations { try ModelSyncApply.applyRemoteLocation(dict, to: context) } + try applyInChunks(locations, context: context, apply: ModelSyncApply.applyRemoteLocation) } if let events = top["rejectedCalendarEvents"] as? [[String: Any]] { - for dict in events { try ModelSyncApply.applyRemoteRejectedEvent(dict, to: context) } + try applyInChunks(events, context: context, apply: ModelSyncApply.applyRemoteRejectedEvent) } // Layer 1: Activities (references Groups) if let activities = top["activities"] as? [[String: Any]] { logger.info("Pulled \(activities.count) activity(ies)") - for dict in activities { try ModelSyncApply.applyRemoteActivity(dict, to: context) } + try applyInChunks(activities, context: context, apply: ModelSyncApply.applyRemoteActivity) } // Layer 2: Contacts (references Tags, Groups, Locations, Activities) if let contacts = top["contacts"] as? [[String: Any]] { logger.info("Pulled \(contacts.count) contact(s)") - for dict in contacts { try ContactSyncApply.applyRemoteContact(dict, to: context) } + try applyInChunks(contacts, context: context, apply: ContactSyncApply.applyRemoteContact) } // Layer 3: Child entities (reference Contacts) if let interactions = top["interactions"] as? [[String: Any]] { logger.info("Pulled \(interactions.count) interaction(s)") - for dict in interactions { try ModelSyncApply.applyRemoteInteraction(dict, to: context) } + try applyInChunks(interactions, context: context, apply: ModelSyncApply.applyRemoteInteraction) } if let notes = top["notes"] as? [[String: Any]] { logger.info("Pulled \(notes.count) note(s)") - for dict in notes { try ModelSyncApply.applyRemoteNote(dict, to: context) } + try applyInChunks(notes, context: context, apply: ModelSyncApply.applyRemoteNote) } if let reminders = top["reminders"] as? [[String: Any]] { logger.info("Pulled \(reminders.count) reminder(s)") - for dict in reminders { try ModelSyncApply.applyRemoteReminder(dict, to: context) } + try applyInChunks(reminders, context: context, apply: ModelSyncApply.applyRemoteReminder) } if let rels = top["contactRelationships"] as? [[String: Any]] { logger.info("Pulled \(rels.count) relationship(s)") - for dict in rels { try ModelSyncApply.applyRemoteContactRelationship(dict, to: context) } + try applyInChunks(rels, context: context, apply: ModelSyncApply.applyRemoteContactRelationship) } try context.save() } + /// Number of pulled records applied per intermediate save during a pull. + private static let applyChunkSize = 25 + + /// Applies pulled record dicts via `apply`, saving after every `applyChunkSize` records so no + /// single transaction commits a massive 0→N change (which can fault SwiftData relationships + /// during the subsequent @Query re-render and crash the UI under Release optimization — observed + /// 2026-06-01 after a 95-message iMessage backfill). The caller saves once more for the remainder. + private func applyInChunks( + _ items: [[String: Any]], + context: ModelContext, + apply: (_ dict: [String: Any], _ context: ModelContext) throws -> Void + ) throws { + for (index, dict) in items.enumerated() { + try apply(dict, context) + if (index + 1) % Self.applyChunkSize == 0 { + try context.save() + } + } + } + private func fetchPendingContacts(context: ModelContext) throws -> [Contact] { let syncedStatus = SyncStatus.synced.rawValue let predicate = #Predicate { $0.syncStatus != syncedStatus } diff --git a/docs/test-scenarios/TEST_SCENARIOS.md b/docs/test-scenarios/TEST_SCENARIOS.md index 9b085cb..f21e918 100644 --- a/docs/test-scenarios/TEST_SCENARIOS.md +++ b/docs/test-scenarios/TEST_SCENARIOS.md @@ -390,3 +390,18 @@ Then re-confirm Full Disk Access for "Blackbook Server" and toggle "Log iMessage ### Note The console only runs while the sync server is running (it starts/stops with the main listener). The main Blackbook app is unaffected — these are BlackbookServer-only changes, deployed locally (not TestFlight). + +## 2026-06-01 — Harden bulk sync-apply against the SwiftData @Query faulting crash + +**Context:** After the 95-message iMessage backfill, the macOS app crash-looped (4× `EXC_BAD_ACCESS` in 45s) the moment the 95 interactions bulk-synced in. Root cause: `LocalServerSyncService.pullRemoteChanges` applied all pulled records to the main context and saved once — a single 0→95 transaction made the dashboard's `@Query` re-render the entire new object graph at once, faulting the `Interaction↔Contact` relationship under Release optimization. (Debug + settled-data Release builds do NOT crash; it was a transient race during the one-time bulk insert.) Fix: apply each layer in chunks of 25 with intermediate saves (`applyInChunks`) so no single massive transition occurs. + +### Scenario — fresh-device full sync of a large store +1. On a device with an empty/reset store, sign in and let the first full sync run (pulls all contacts + the 95+ interactions). +2. Keep the **Overview/Dashboard** in front during the sync. +3. Expect: no crash; interactions appear (possibly in visible chunks of ~25 as each intermediate save lands); relationship scores update. +4. Open a contact → Interactions tab → messages render. + +### Notes / caveats +- The crash is timing/optimization-dependent and could not be reproduced on settled data, so this hardening is **defense-in-depth** — verified by build + test + reasoning, not by reproducing the original race. +- Chunked saves also improve failure semantics: a mid-pull error now preserves already-applied chunks (idempotent UUID-upsert heals the rest on the next sync) instead of losing the whole batch. +- Verified: macOS + iOS build clean; 13 Swift Testing tests pass.