Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 26 additions & 35 deletions Blackbook/Services/LocalServerSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)")
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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")
}
59 changes: 47 additions & 12 deletions Blackbook/Views/Dashboard/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: [:])
Expand All @@ -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<Contact>(
sortBy: [SortDescriptor(\.relationshipScore, order: .reverse)]
)
let reminderDescriptor = FetchDescriptor<Reminder>(
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") }
Expand Down
31 changes: 31 additions & 0 deletions docs/test-scenarios/TEST_SCENARIOS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Loading