Skip to content

Fix iOS launch crash: decouple Dashboard from @Query + atomic pull apply - #43

Merged
mayeack merged 1 commit into
mainfrom
claude/ios-crash-deferred-dashboard-bg-pull
Jun 2, 2026
Merged

mayeack merged 1 commit into
mainfrom
claude/ios-crash-deferred-dashboard-bg-pull

Conversation

@mayeack

@mayeack mayeack commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Summary

  • PR Harden bulk sync-apply to prevent SwiftData @Query faulting crash #42 chunked the bulk pull apply on the main context, but iOS still crash-looped at launch (build 117, EXC_BAD_ACCESS deep in _SwiftData_SwiftUI's @Query forEach, top of the stack in DashboardView's ScrollView). Chunking shrank each refresh but didn't stop @Query from observing partial-state transitions between intermediate saves, and on iOS all TabView children stay alive so multiple @Querys reacted simultaneously and faulted the Contact.interactions inverse during the mid-pull window.
  • Once the iPhone's store held that partial-state row set, even the next launch re-rendered it and crashed before sync could heal anything (1.6s after launch, before the 1s-delayed initial sync had even returned).

This PR ships two changes that together break the loop:

  1. DashboardView no longer uses @Query. Contacts/reminders live in @State, populated by a FetchDescriptor in a .task (with a 500 ms initial delay so any in-flight pull-apply commits first) and re-fetched on .blackbookSyncDidComplete. The 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 any remaining @Querys in other tabs) only ever sees one settled commit; the partial-state window is invisible to the UI. Replaces PR Harden bulk sync-apply to prevent SwiftData @Query faulting crash #42's per-25-record applyInChunks + applyChunkSize.

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.

Post-merge user action

After TestFlight build 118 deploys, delete + reinstall Blackbook on the iPhone. The existing store still holds the bad partial state from earlier crashes; a clean install guarantees the first sync arrives via the new single-atomic-commit path and the dashboard renders against a settled store.

Test plan

  • iOS Simulator (iPhone 17) clean build
  • macOS clean build
  • 13 Swift Testing tests pass
  • Manual on TestFlight 118: delete Blackbook on iPhone → install build 118 → launch → no crash; dashboard shows ProgressView briefly then populates; Michelle Amico contact shows 58 iMessages
  • Manual on macOS: relaunch Blackbook macOS → dashboard renders normally; sync runs every 5 min and dashboard refreshes via .blackbookSyncDidComplete
  • Failure semantics: force airplane-mode mid-pull → bgContext.save() never fires → next sync re-pulls same payload idempotently

🤖 Generated with Claude Code

… pulls atomically

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 <noreply@anthropic.com>
@mayeack
mayeack merged commit 40c9d1b into main Jun 2, 2026
2 checks passed
@mayeack
mayeack deleted the claude/ios-crash-deferred-dashboard-bg-pull branch June 2, 2026 16:49
mayeack added a commit that referenced this pull request Jun 2, 2026
…ashboard doc (#45)

End-to-end review pass. Findings documented in docs/CODE_REVIEW_2026-06-02.md.

Changes (the safe, in-scope fixes):
- ContactListViewModel: drop dead `import SwiftUI` (it used no SwiftUI symbols;
  violated the "ViewModels never import SwiftUI" rule).
- .cursor/pages/Dashboard.md: resync with DashboardView after PR #43 (was stale —
  @query@State manual fetch, title "Dashboard"→"Overview", ProgressView gate,
  .blackbookSyncDidComplete refresh, added the prioritizeCard). Satisfies the
  mandatory page-doc-sync rule that PR #43 missed.
- +24 unit tests for two previously-untested, pure-logic services:
  - ContactDeduplicationServiceTests (14): union-find linking by name/email/phone,
    transitive components, merged-away exclusion, data-richness primary selection,
    and the mergeAll mutation path. This service auto-merges contacts and was
    safety-critical with zero coverage.
  - NetworkGraphEngineTests (10): graph build, dangling-edge rejection, tag
    filtering, simulation convergence + NaN-safety.
  - One test (testNameKeyDoesNotTrimInnerWhitespace) is a regression guard that
    documents a real nameKey limitation flagged in the report (#5).

Test count 195 -> 219, all green on iOS + macOS. No production behavior change
beyond the dead-import removal. The report's remaining findings (service-layer
sync test coverage, .caption2 rule-vs-reality, nameKey whitespace, stale rules.md
Icon-1 section, EntityListRow adoption, dead LocalSyncServer.swift) are left for
follow-up PRs with rationale.

Co-authored-by: Michael Yeack <mayeack@Michaels-Mac-mini.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
mayeack pushed a commit that referenced this pull request Jun 2, 2026
#43/#44)

The sync-apply layer — ContactSyncApply.applyRemoteContact and
ModelSyncApply.applyRemoteInteraction — is the exact code behind the recent drift
(#44) and launch-crash (#43) incidents and had zero unit coverage. These functions
are pure (payload dict + ModelContext), so no protocol seam or network mock is
needed; "remote" payloads are built with the real contactToDict/interactionToDict
serializers so ISO8601 formatting matches the parser.

SyncApplyTests (11):
- insert path: absent record inserted + marked synced; round-trip preserves
  name/emails/phones/score/priority; idempotent re-apply is a UUID upsert (no dup).
- conflict resolution: newer remote overwrites; NEWER+PENDING local is protected
  from a stale remote; NEWER-but-SYNCED local IS clobbered by an older remote
  (regression guard documenting exactly why edits must flip syncStatus to .pending
  via markLocallyEdited — the #44 root cause).
- malformed payloads: missing id / missing updatedAt are ignored without throwing.
- interactions: link to resolved contact; unknown contactId persists with nil
  contact (heals later) instead of crashing; newer pending local survives stale remote.

Network-path integration (URLSession injection into LocalServerSyncService) is the
remaining step noted in docs/CODE_REVIEW_2026-06-02.md finding #6.

Test count 206 -> 217 XCTest (+ 13 Swift Testing), all green iOS + macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mayeack added a commit that referenced this pull request Jun 2, 2026
#43/#44) (#46)

The sync-apply layer — ContactSyncApply.applyRemoteContact and
ModelSyncApply.applyRemoteInteraction — is the exact code behind the recent drift
(#44) and launch-crash (#43) incidents and had zero unit coverage. These functions
are pure (payload dict + ModelContext), so no protocol seam or network mock is
needed; "remote" payloads are built with the real contactToDict/interactionToDict
serializers so ISO8601 formatting matches the parser.

SyncApplyTests (11):
- insert path: absent record inserted + marked synced; round-trip preserves
  name/emails/phones/score/priority; idempotent re-apply is a UUID upsert (no dup).
- conflict resolution: newer remote overwrites; NEWER+PENDING local is protected
  from a stale remote; NEWER-but-SYNCED local IS clobbered by an older remote
  (regression guard documenting exactly why edits must flip syncStatus to .pending
  via markLocallyEdited — the #44 root cause).
- malformed payloads: missing id / missing updatedAt are ignored without throwing.
- interactions: link to resolved contact; unknown contactId persists with nil
  contact (heals later) instead of crashing; newer pending local survives stale remote.

Network-path integration (URLSession injection into LocalServerSyncService) is the
remaining step noted in docs/CODE_REVIEW_2026-06-02.md finding #6.

Test count 206 -> 217 XCTest (+ 13 Swift Testing), all green iOS + macOS.

Co-authored-by: Michael Yeack <mayeack@Michaels-Mac-mini.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
mayeack added a commit that referenced this pull request Jun 4, 2026
…he score (#48)

Two app-breaking issues, shipped together as an urgent hotfix.

1. Import-All crash / app won't reopen
   ContactSyncService imported on the MAIN ModelContext and saved once there.
   "Import All" inserts hundreds of Contacts; that main-context save fires
   ContactListView's @query mid-transaction and faults the Contact.interactions
   inverse (EXC_BAD_ACCESS) — the exact failure PR #43 fixed for the pull path.
   Because startAutoSync re-imports on every .onAppear, it became a crash loop
   so the app couldn't reopen.
   Fix: importContacts/importSelected apply mergeOrInsert on a background
   ModelContext (autosaveEnabled=false) with a single settled save, so the main
   context's @queries only ever see one committed state. startAutoSync also
   defers the launch import by 1s so the first frame renders first. Posts
   .blackbookSyncDidComplete after import so the Dashboard refetches.

2. Recent interactions don't raise the relationship score (Hugo Dooner)
   The score reads the denormalized Contact.lastInteractionDate. Synced
   iMessage interaction *records* arrive cleanly, but the contact-field update
   carrying lastInteractionDate is rejected by conflict resolution when the
   local copy is newer + pending — so recency stays 0 and the score sits at
   priority-only (exactly 20 / "Fading").
   Fix: RelationshipScoreEngine.recalculateAll now re-derives each contact's
   lastInteractionDate from the Interaction records this device already holds
   (single FetchDescriptor<Interaction>, grouped by the to-one
   interaction.contact?.id — the same controlled pattern the server uses; never
   touches the faulting Contact.interactions inverse), taking max() so a newer
   manual date is never lowered. recalculateAll is now also invoked right after
   each sync pull on the background context, so scores refresh regardless of the
   visible tab (on macOS the Dashboard's own recalc wouldn't run otherwise).

Tests: +3 regression cases in RelationshipScoreEngineTests covering the
heal-from-records behavior (stale date healed, priority contact recovers above
the boost, newer manual date preserved). Full macOS suite green (220 tests).

Co-authored-by: Michael Yeack <mayeack@Michaels-Mac-mini.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
mayeack added a commit that referenced this pull request Jun 4, 2026
…kepoint (#51)

* Hotfix: stop Import-All crash loop + make recent interactions raise the score

Two app-breaking issues, shipped together as an urgent hotfix.

1. Import-All crash / app won't reopen
   ContactSyncService imported on the MAIN ModelContext and saved once there.
   "Import All" inserts hundreds of Contacts; that main-context save fires
   ContactListView's @query mid-transaction and faults the Contact.interactions
   inverse (EXC_BAD_ACCESS) — the exact failure PR #43 fixed for the pull path.
   Because startAutoSync re-imports on every .onAppear, it became a crash loop
   so the app couldn't reopen.
   Fix: importContacts/importSelected apply mergeOrInsert on a background
   ModelContext (autosaveEnabled=false) with a single settled save, so the main
   context's @queries only ever see one committed state. startAutoSync also
   defers the launch import by 1s so the first frame renders first. Posts
   .blackbookSyncDidComplete after import so the Dashboard refetches.

2. Recent interactions don't raise the relationship score (Hugo Dooner)
   The score reads the denormalized Contact.lastInteractionDate. Synced
   iMessage interaction *records* arrive cleanly, but the contact-field update
   carrying lastInteractionDate is rejected by conflict resolution when the
   local copy is newer + pending — so recency stays 0 and the score sits at
   priority-only (exactly 20 / "Fading").
   Fix: RelationshipScoreEngine.recalculateAll now re-derives each contact's
   lastInteractionDate from the Interaction records this device already holds
   (single FetchDescriptor<Interaction>, grouped by the to-one
   interaction.contact?.id — the same controlled pattern the server uses; never
   touches the faulting Contact.interactions inverse), taking max() so a newer
   manual date is never lowered. recalculateAll is now also invoked right after
   each sync pull on the background context, so scores refresh regardless of the
   visible tab (on macOS the Dashboard's own recalc wouldn't run otherwise).

Tests: +3 regression cases in RelationshipScoreEngineTests covering the
heal-from-records behavior (stale date healed, priority contact recovers above
the boost, newer manual date preserved). Full macOS suite green (220 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Features: 3 suggested records in pickers, click-to-sort columns, hidden-filter chokepoint

Three of the five requested features (notifications + archive-on-import follow in PR 3).

2a. Three suggested records in contact pickers
New ContactSuggestionEngine ranks candidates by contextual similarity to the subject
— shared tags (x3), groups (x2), locations (x2), plus a per-field signal — and falls
back to relationship score so suggestions are always available. IntroducedToPickerView
and MetViaPickerView now show a "Suggested" section (top 3) when not searching, with the
rest under "All Contacts".

2b. Click column headers to sort
ContactListViewModel gains sortColumn + sortAscending and toggleSort(): clicking a
header sorts by that column; clicking the active column flips direction. The active
column shows a chevron. Sorts Name, Score, Groups, Locations, Tags, Met via, Introduced
to (string columns sort their first value alphabetically with blanks last; numeric
columns default to descending). The sort menu was updated to the same model and keeps
Recent/Added. Replaces the old ContactSortOrder enum.

2e. Hidden contacts excluded from every search surface
Audit found all selection surfaces already filtered !isHidden && !isMergedAway. Added a
single chokepoint — `Sequence<Contact>.selectable` — and adopted it in the contact
pickers and ContactListViewModel so the CLAUDE.md rule has one enforcement point.
(A hidden contact still appearing on another device is sync propagation, not a missing
filter — same conflict-resolution family as the score fix in PR #48.)

Tests: ContactSuggestionEngineTests (ranking, exclusions, score fallback) and
ContactListViewModelTests (+toggle direction, string-column blanks-last, .selectable).
Full macOS suite green (226 tests).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Michael Yeack <mayeack@Michaels-Mac-mini.local>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant