Skip to content

Harden bulk sync-apply to prevent SwiftData @Query faulting crash - #42

Merged
mayeack merged 1 commit into
mainfrom
claude/harden-bulk-sync-apply
Jun 1, 2026
Merged

mayeack merged 1 commit into
mainfrom
claude/harden-bulk-sync-apply

Conversation

@mayeack

@mayeack mayeack commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Why

After the 95-message iMessage backfill, the macOS app crash-looped (EXC_BAD_ACCESS deep in SwiftData, 4× in 45s) the instant the interactions bulk-synced in. Root cause: LocalServerSyncService.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(sort:\Contact.relationshipScore) then re-rendered the entire new object graph at once and faulted the Interaction↔Contact relationship 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

  • Apply each dependency layer in chunks of 25 with intermediate saves (applyInChunks) so no single massive 0→N transition occurs.
  • Dependency order, the final save(), and threading (still the main context) are unchanged.
  • Bonus: a mid-pull failure now preserves already-applied chunks instead of losing the whole batch — the idempotent UUID-upsert heals the remainder on the next sync.

Test plan

  • macOS + iOS build clean.
  • 13 Swift Testing tests pass.
  • Fresh-device full sync of a large store with the Dashboard in front → no crash, interactions appear (see 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

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>
@mayeack
mayeack merged commit 86a53c1 into main Jun 1, 2026
2 checks passed
@mayeack
mayeack deleted the claude/harden-bulk-sync-apply branch June 1, 2026 22:15
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>
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