Skip to content

Watch: give GlucoseStore its own Core Data stack - #2475

Open
loopkitdev wants to merge 1 commit into
next-devfrom
fix/watch-glucose-separate-coredata-context
Open

Watch: give GlucoseStore its own Core Data stack#2475
loopkitdev wants to merge 1 commit into
next-devfrom
fix/watch-glucose-separate-coredata-context

Conversation

@loopkitdev

Copy link
Copy Markdown
Contributor

Summary

On the watch, CarbStore and GlucoseStore are constructed with the same PersistenceController (one shared NSManagedObjectContext). But the two stores drive that context with different concurrency models:

  • GlucoseStore → async context.perform { }
  • CarbStorequeue.async { context.performAndWait { } }

Mixing the Swift-async perform with GCD-dispatched performAndWait on a single serial context lets the two overlap, which can produce a torn read of a freshly-inserted CachedGlucoseObject — a required attribute (startDate) reads nil, and the app crashes bridging it:

Foundation: Date._unconditionallyBridgeFromObjectiveC(NSDate?) -> Date   (brk 1)
  ← LoopKit: closure (CachedGlucoseObject) -> StoredGlucoseSample  @ GlucoseStore.swift:324
  ← CoreData: _developerSubmittedBlockToNSManagedObjectContextPerform  (await context.perform)

This was confirmed by two crash reports symbolicated end-to-end (system frames via matching watchOS DeviceSupport, LoopKit frames via the exact build dSYM). It matches the observed behavior: intermittent, and it disappears under the Xcode debugger (the debugger reschedules threads and closes the race window). It also explains the paradox that create() sets startDate and save() (which validates the required attribute) succeeds two lines before the nil read — only a concurrent mutation of the shared context can do that within one block.

Fix

Give GlucoseStore its own Core Data stack on the watch so it never shares a context with CarbStore.

  • LoopCore/PersistenceController.swift: controllerInLocalDirectory(named:) gains a defaulted name parameter ("com.loopkit.LoopKit"), so every existing caller is unchanged.
  • WatchApp Extension/Managers/LoopDataManager.swift: the watch glucose store now uses controllerInLocalDirectory(named: "com.loopkit.LoopKit.Glucose").

After this, the shared context has only CarbStore (all performAndWait), and glucose has a dedicated context driven only by async perform (the HealthKit performAndWait paths are dormant on the watch — no hkSampleStore). Neither context mixes concurrency models, so the cross-store race is gone.

Caveat

Existing watch users' cached glucose lives in the shared com.loopkit.LoopKit store; after this change the glucose store reads from a new com.loopkit.LoopKit.Glucose directory and starts empty, re-backfilling from the phone on next launch. This is harmless — the watch glucose cache is ephemeral (4h) and re-populated via the existing backfill request.

Notes / follow-ups (not in this PR)

  • No defensive/guard code is included here by request — this is purely the structural change.
  • Related hygiene worth a separate look: NSManagedObjectContext.purgeObjects combines a batch delete with mergeChanges(fromRemoteContextSave:) and refreshAllObjects() on the same context; and GlucoseStore.saveSamplesToHealthKit reads managed objects (quantitySample) outside their perform block (iOS-only path).

Test plan

  • Builds clean (Loop scheme).
  • Watch app launches, receives WatchConnectivity context/backfill, renders glucose, and adds new samples without the intermittent startDate crash.

On the watch, CarbStore and GlucoseStore shared a single PersistenceController
(one NSManagedObjectContext). GlucoseStore drives its context exclusively with the
async context.perform API; CarbStore uses performAndWait. Mixing those two
concurrency models on one serial context races, and can produce a torn read of a
freshly-inserted CachedGlucoseObject where a required attribute (startDate) reads
nil — crashing in Date._unconditionallyBridgeFromObjectiveC at
GlucoseStore.addGlucoseSamples.

Give GlucoseStore its own Core Data stack so it never shares a context with
CarbStore. controllerInLocalDirectory gains a defaulted name parameter (existing
callers unchanged); the watch's glucose store uses a dedicated directory.
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.

2 participants