Harden bulk sync-apply to prevent SwiftData @Query faulting crash - #42
Merged
Merged
Conversation
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 <noreply@anthropic.com>
6 tasks
mayeack
added a commit
that referenced
this pull request
Jun 2, 2026
… pulls atomically (#43) 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: Michael Yeack <mayeack@Michaels-Mac-mini.local> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
After the 95-message iMessage backfill, the macOS app crash-looped (
EXC_BAD_ACCESSdeep in SwiftData, 4× in 45s) the instant the interactions bulk-synced in. Root cause:LocalServerSyncService.pullRemoteChangesapplied 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(sort:\Contact.relationshipScore)then re-rendered the entire new object graph at once and faulted theInteraction↔Contactrelationship under Release optimization. The codebase already documents this fragility ("traversing the Interaction→Contact relationship triggers SwiftData inverse faulting and causes EXC_BAD_ACCESS"); with zero interactions before, it never fired.It was a transient race during the one-time bulk insert — Debug builds and settled-data Release builds don't crash, and the user's app recovered once the data committed. But a fresh device's first full sync would re-pull everything at once and could hit it again.
What
applyInChunks) so no single massive 0→N transition occurs.save(), and threading (still the main context) are unchanged.Test plan
docs/test-scenarios/TEST_SCENARIOS.md).Honest caveat
The race is timing/optimization-dependent and could not be reproduced on settled data, so this is defense-in-depth — verified by build + tests + reasoning, not by reproducing the original crash. It's a conservative, low-risk change to the (just-stabilized) sync path: same order, same context, just smaller transactions.
🤖 Generated with Claude Code