From 17911526a146768fd7b46e9bd2c85ff7f97fc396 Mon Sep 17 00:00:00 2001 From: Michael Yeack Date: Tue, 2 Jun 2026 09:45:19 -0700 Subject: [PATCH] Fix iOS launch crash by decoupling Dashboard from @Query and applying pulls atomically MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit After PR #42 chunked sync-apply landed, the iPhone (TestFlight 117) still crash-looped at launch — EXC_BAD_ACCESS deep in _SwiftData_SwiftUI's @Query forEach, top of the stack inside DashboardView's ScrollView. The macOS chunking fix mitigated the magnitude of each @Query refresh but didn't stop SwiftUI from observing partial-state transitions between intermediate saves. On iOS, all TabView children stay alive and react to every save, so the Contact.interactions inverse faulted during the mid-pull window. Once the iPhone's store held a partial-state row set, even the *next* launch re-rendered it and crashed before sync could heal anything. Two changes: 1. DashboardView no longer uses @Query. Contacts/reminders live in @State, populated by a FetchDescriptor in a .task (with a 500ms initial delay so any in-flight pull-apply commits first) and re-fetched on .blackbookSyncDidComplete. Body is gated on hasLoadedOnce (ProgressView until the first fetch lands). Result: the dashboard never observes a mid-sync transition. 2. pullRemoteChanges applies on a fresh ModelContext(container) with autosave off and commits once at the end. The main context (and remaining @Querys in other tabs) only ever sees one settled commit; the partial-state window is invisible to the UI. Replaces PR #42's per-25-record applyInChunks (and the applyChunkSize constant). Verified: iOS Simulator + macOS clean builds, 13 Swift Testing tests pass. Crash signature confirmed via Blackbook-2026-06-02-093228.ips. After this lands and TestFlight build 118 deploys, the user should delete + reinstall Blackbook on the iPhone — the existing store still holds the bad partial state from earlier crashes, and a clean install guarantees the first sync arrives via the single-atomic-commit path. Co-Authored-By: Claude Opus 4.7 --- .../Services/LocalServerSyncService.swift | 61 ++++++++----------- Blackbook/Views/Dashboard/DashboardView.swift | 59 ++++++++++++++---- docs/test-scenarios/TEST_SCENARIOS.md | 31 ++++++++++ 3 files changed, 104 insertions(+), 47 deletions(-) diff --git a/Blackbook/Services/LocalServerSyncService.swift b/Blackbook/Services/LocalServerSyncService.swift index b174638..3721643 100644 --- a/Blackbook/Services/LocalServerSyncService.swift +++ b/Blackbook/Services/LocalServerSyncService.swift @@ -192,6 +192,8 @@ final class LocalServerSyncService { // Sync succeeded — cancel any pending fast retry from a previous failure. failureRetryTask?.cancel() failureRetryTask = nil + // Tell observers (e.g. DashboardView) to re-fetch from the settled store. + NotificationCenter.default.post(name: .blackbookSyncDidComplete, object: nil) } catch { syncError = error.localizedDescription logger.error("Local sync failed: \(error.localizedDescription)") @@ -408,79 +410,62 @@ final class LocalServerSyncService { guard let top = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return } - // 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). + // Apply on a separate ModelContext, then save once. This keeps the in-flight, partial-state + // working set invisible to the main context's @Queries — which previously crashed during + // SwiftData inverse-relationship faulting on partial pulls (work_log 2026-06-01 macOS, + // 2026-06-02 iOS). With a single atomic commit, the main context only ever observes + // settled state. + let bgContext = ModelContext(context.container) + bgContext.autosaveEnabled = false // Layer 0: Tags, Groups, Locations, RejectedCalendarEvents if let tags = top["tags"] as? [[String: Any]] { logger.info("Pulled \(tags.count) tag(s)") - try applyInChunks(tags, context: context, apply: ModelSyncApply.applyRemoteTag) + for dict in tags { try ModelSyncApply.applyRemoteTag(dict, to: bgContext) } } if let groups = top["groups"] as? [[String: Any]] { logger.info("Pulled \(groups.count) group(s)") - try applyInChunks(groups, context: context, apply: ModelSyncApply.applyRemoteGroup) + for dict in groups { try ModelSyncApply.applyRemoteGroup(dict, to: bgContext) } } if let locations = top["locations"] as? [[String: Any]] { logger.info("Pulled \(locations.count) location(s)") - try applyInChunks(locations, context: context, apply: ModelSyncApply.applyRemoteLocation) + for dict in locations { try ModelSyncApply.applyRemoteLocation(dict, to: bgContext) } } if let events = top["rejectedCalendarEvents"] as? [[String: Any]] { - try applyInChunks(events, context: context, apply: ModelSyncApply.applyRemoteRejectedEvent) + for dict in events { try ModelSyncApply.applyRemoteRejectedEvent(dict, to: bgContext) } } // Layer 1: Activities (references Groups) if let activities = top["activities"] as? [[String: Any]] { logger.info("Pulled \(activities.count) activity(ies)") - try applyInChunks(activities, context: context, apply: ModelSyncApply.applyRemoteActivity) + for dict in activities { try ModelSyncApply.applyRemoteActivity(dict, to: bgContext) } } // Layer 2: Contacts (references Tags, Groups, Locations, Activities) if let contacts = top["contacts"] as? [[String: Any]] { logger.info("Pulled \(contacts.count) contact(s)") - try applyInChunks(contacts, context: context, apply: ContactSyncApply.applyRemoteContact) + for dict in contacts { try ContactSyncApply.applyRemoteContact(dict, to: bgContext) } } // Layer 3: Child entities (reference Contacts) if let interactions = top["interactions"] as? [[String: Any]] { logger.info("Pulled \(interactions.count) interaction(s)") - try applyInChunks(interactions, context: context, apply: ModelSyncApply.applyRemoteInteraction) + for dict in interactions { try ModelSyncApply.applyRemoteInteraction(dict, to: bgContext) } } if let notes = top["notes"] as? [[String: Any]] { logger.info("Pulled \(notes.count) note(s)") - try applyInChunks(notes, context: context, apply: ModelSyncApply.applyRemoteNote) + for dict in notes { try ModelSyncApply.applyRemoteNote(dict, to: bgContext) } } if let reminders = top["reminders"] as? [[String: Any]] { logger.info("Pulled \(reminders.count) reminder(s)") - try applyInChunks(reminders, context: context, apply: ModelSyncApply.applyRemoteReminder) + for dict in reminders { try ModelSyncApply.applyRemoteReminder(dict, to: bgContext) } } if let rels = top["contactRelationships"] as? [[String: Any]] { logger.info("Pulled \(rels.count) relationship(s)") - try applyInChunks(rels, context: context, apply: ModelSyncApply.applyRemoteContactRelationship) + for dict in rels { try ModelSyncApply.applyRemoteContactRelationship(dict, to: bgContext) } } - 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() - } - } + try bgContext.save() } private func fetchPendingContacts(context: ModelContext) throws -> [Contact] { @@ -563,3 +548,9 @@ final class LocalServerSyncService { lastSyncDate = UserDefaults.standard.object(forKey: Self.lastSyncKey) as? Date } } + +extension Notification.Name { + /// Posted by `LocalServerSyncService` after a full sync completes successfully. Views that + /// snapshot the store on demand (e.g. `DashboardView`) can re-fetch in response. + static let blackbookSyncDidComplete = Notification.Name("com.blackbookdevelopment.sync.didComplete") +} diff --git a/Blackbook/Views/Dashboard/DashboardView.swift b/Blackbook/Views/Dashboard/DashboardView.swift index 85ac67a..eabd975 100644 --- a/Blackbook/Views/Dashboard/DashboardView.swift +++ b/Blackbook/Views/Dashboard/DashboardView.swift @@ -3,8 +3,15 @@ import SwiftData struct DashboardView: View { @Environment(\.modelContext) private var modelContext - @Query(sort: \Contact.relationshipScore, order: .reverse) private var allContacts: [Contact] - @Query(sort: \Reminder.dueDate) private var reminders: [Reminder] + // @Query was previously used here for `allContacts` and `reminders`. It auto-refreshed on + // every ModelContext save — including mid-pull saves — and the _SwiftData_SwiftUI forEach + // can fault inverse relationships (`Contact.interactions`) during a partial-state window, + // crashing under Release optimization (work_log 2026-06-01 macOS; 2026-06-02 iOS). + // Replaced with a manual fetch on launch + after sync completes, so the dashboard only + // ever sees settled state and never observes an in-flight transition. + @State private var allContacts: [Contact] = [] + @State private var reminders: [Reminder] = [] + @State private var hasLoadedOnce = false @State private var viewModel = DashboardViewModel() @State private var showingPrioritizePicker = false @State private var weeklyStats = WeeklyStats(totalInteractions: 0, uniqueContacts: 0, byType: [:]) @@ -15,31 +22,59 @@ struct DashboardView: View { } var body: some View { - ScrollView { - VStack(spacing: 20) { - weeklyStatsCard; prioritizeCard; fadingCard; remindersCard; aiCard; topContactsCard - }.padding() + SwiftUI.Group { + if hasLoadedOnce { + ScrollView { + VStack(spacing: 20) { + weeklyStatsCard; prioritizeCard; fadingCard; remindersCard; aiCard; topContactsCard + }.padding() + } + } else { + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + } } .navigationTitle("Overview") .navigationDestination(for: UUID.self) { id in if let c = contactsByID[id] { ContactDetailView(contact: c) } } - .onAppear { - weeklyStats = viewModel.computeWeeklyStats(context: modelContext) - // Defer score recalculation to avoid SwiftData relationship - // faulting during initial view load which can crash. + .task { + // Defer the first fetch briefly so any in-flight pull-apply finishes saving + // before we touch SwiftData on the main thread. + if !hasLoadedOnce { + try? await Task.sleep(for: .milliseconds(500)) + } + await refreshFromStore() + hasLoadedOnce = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { [viewModel, modelContext] in viewModel.recalculateScoresIfNeeded(context: modelContext) } } - .onChange(of: allContacts.count) { _, _ in - weeklyStats = viewModel.computeWeeklyStats(context: modelContext) + .onReceive(NotificationCenter.default.publisher(for: .blackbookSyncDidComplete)) { _ in + Task { await refreshFromStore() } } .sheet(isPresented: $showingPrioritizePicker) { PrioritizeContactPicker(contacts: contacts.filter { !$0.isPriority }) } } + @MainActor + private func refreshFromStore() async { + let contactDescriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.relationshipScore, order: .reverse)] + ) + let reminderDescriptor = FetchDescriptor( + sortBy: [SortDescriptor(\.dueDate)] + ) + if let contacts = try? modelContext.fetch(contactDescriptor) { + allContacts = contacts + } + if let reminders = try? modelContext.fetch(reminderDescriptor) { + self.reminders = reminders + } + weeklyStats = viewModel.computeWeeklyStats(context: modelContext) + } + private var weeklyStatsCard: some View { DashboardCard(title: "This Week", icon: "chart.bar.fill") { HStack(spacing: 24) { StatBubble(value: "\(weeklyStats.totalInteractions)", label: "Interactions"); StatBubble(value: "\(weeklyStats.uniqueContacts)", label: "People") } diff --git a/docs/test-scenarios/TEST_SCENARIOS.md b/docs/test-scenarios/TEST_SCENARIOS.md index f21e918..5bc8104 100644 --- a/docs/test-scenarios/TEST_SCENARIOS.md +++ b/docs/test-scenarios/TEST_SCENARIOS.md @@ -405,3 +405,34 @@ The console only runs while the sync server is running (it starts/stops with the - 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. + +## 2026-06-02 — iOS crash-on-load after partial pull: deferred Dashboard fetch + background-context pull + +**Context:** Day after the macOS chunking fix (PR #42), the iPhone (TestFlight build 117) crash-looped at launch (~1.6s in, `EXC_BAD_ACCESS / SIGSEGV` deep in `SwiftData → _SwiftData_SwiftUI → Sequence.forEach`, top of stack inside the Dashboard's `ScrollView`). Chunking on the main context still let SwiftUI `@Query` observe partial-state transitions between intermediate saves; on iOS, all `TabView` children stay alive so multiple `@Query`s reacted simultaneously and faulted the `Contact.interactions` inverse during the bulk apply. Once the iPhone's store was in a partial state, even the next *launch* re-rendered the bad state and crashed before sync could heal it. + +Two changes ship together: +1. **`DashboardView` no longer uses `@Query`.** It now keeps `allContacts` / `reminders` in `@State`, fetches via `FetchDescriptor` in a `.task` (with a 500 ms initial delay so any in-flight pull settles first), and re-fetches on a `.blackbookSyncDidComplete` notification. Body is gated on `hasLoadedOnce` (shows `ProgressView` until the first fetch). Result: the dashboard never observes a mid-sync transition. +2. **`pullRemoteChanges` applies on a fresh `ModelContext(container)` with autosave off** and saves once at the end. The main context (and its remaining `@Query`s in other tabs) only sees one settled commit, never partial state. Replaces the per-25-record `applyInChunks` from PR #42. + +### Scenario A — iPhone recovery from existing partial-state crash loop +1. After build 118 lands in TestFlight, install it on the iPhone. +2. **Delete the existing Blackbook app first**, then install build 118 fresh (the existing store may still hold partial-state interactions from earlier crashes). +3. Open the app, sign in, let it sync. +4. Expect: dashboard shows `ProgressView` briefly, then the Overview cards render. No crash. Contacts arrive; interactions on a contact's Interactions tab show the iMessage history. +5. Background the app, foreground it — sync runs again, dashboard updates without flicker. + +### Scenario B — fresh-device large bulk sync +1. On an iPhone or Mac with an empty store, sign in. +2. Watch the Overview tab during the first full sync (1300 contacts + 171 interactions). +3. Expect: `ProgressView` until ~500ms after first render; then the cards populate from the just-completed background apply. No `EXC_BAD_ACCESS`. + +### Scenario C — pull failure mid-apply +1. Force a network failure mid-pull (e.g. airplane-mode toggle during a backfill push from the server). +2. Expect: `bgContext.save()` is never called → the main store stays at its previous settled state, no partial records visible to the UI. On the next sync, the same payload is re-pulled (idempotent UUID upsert in `applyRemote*`) and applied atomically. + +### Notes / caveats +- The fix removes `applyInChunks` and the `applyChunkSize` constant from PR #42 — superseded by the single-atomic-commit approach. +- Dashboard auto-refresh on store mutations is gone; refresh happens on launch, on sync completion, and on a `.blackbookSyncDidComplete` notification. If you mutate the store from a sheet (e.g. logging an interaction), call `NotificationCenter.default.post(name: .blackbookSyncDidComplete, object: nil)` or accept that the dashboard updates on the next sync tick. +- Other views still use `@Query` — they're rendered only when their tab is selected, so a settled main store at that point keeps them safe. +- Verified by: iOS Simulator + macOS clean builds; 13 Swift Testing tests pass. +- Crash signature confirmed via `~/Downloads/Blackbook-2026-06-02-093228.ips` — frames 12-13 in `_SwiftData_SwiftUI` (`@Query`), frame 18 in `ScrollView.init`, frame 5 `Sequence.forEach`, into SwiftData fault on tagged address `0x8000000000000010`.