Fix Live Activity cooperative-pool starvation deadlock - #2465
Merged
ps2 merged 1 commit intoJul 20, 2026
Conversation
getGlucoseSample() and getInsulinOnBoard() bridged async work to sync via DispatchGroup.wait(timeout: .distantFuture). Both run from update(), which executes on a Swift Concurrency cooperative-pool thread. Blocking that thread violates the pool's forward-progress guarantee: the pool is sized to the core count, so repeated Live Activity updates park every cooperative thread on the wait and deadlock the whole concurrency runtime (Loop stops looping). Observed on device: all cooperative-pool threads stuck in getGlucoseSample -> dispatch_group_wait, with the Nightscout upload queues blocked behind them. Make both methods async and suspend via withCheckedContinuation instead of blocking. getBottomRow() becomes async and iterates so the IOB query stays lazy (issued only for the .iob row). No behavior change beyond not blocking.
loopkitdev
pushed a commit
to loopkitdev/Loop
that referenced
this pull request
Jul 20, 2026
getGlucoseSample() bridged async work to sync via DispatchGroup.wait(timeout: .distantFuture). It runs from update(), which executes on a Swift Concurrency cooperative-pool thread; blocking that thread starves the (core-count-sized) pool and deadlocks the whole concurrency runtime under repeated Live Activity updates (Loop stops looping). Observed on device as all cooperative threads stuck in getGlucoseSample -> dispatch_group wait, with the Nightscout upload queues blocked behind them. Make getGlucoseSample async and await getGlucoseSamples directly instead of blocking. Upstream fix (both sites) submitted as LoopKit#2465.
Contributor
Test✅ successful build with this modification Start with LoopWorkspace dev branch. |
marionbarker
approved these changes
Jul 20, 2026
marionbarker
left a comment
Contributor
There was a problem hiding this comment.
I approve from test. Leaving the code review to the experts.
loopkitdev
pushed a commit
that referenced
this pull request
Jul 24, 2026
Port of #2465 to next-dev. getGlucoseSample() bridged the already-async glucoseStore call back to sync with DispatchGroup.wait(.distantFuture). update() runs on a Swift Concurrency cooperative-pool thread, so blocking it starves the core-count-sized pool and can deadlock the whole concurrency runtime under repeated Live Activity updates (Loop stops looping). Make getGlucoseSample async and await the store directly. Unlike upstream, next-dev's getInsulinOnBoard() already reads the passed-in activeInsulin value (no doseStore query), so only getGlucoseSample needed the change; getBottomRow and getInsulinOnBoard stay synchronous.
loopkitdev
pushed a commit
to LoopKit/LoopWorkspace
that referenced
this pull request
Jul 24, 2026
Ports LoopKit/Loop#2465 to next-dev. LiveActivityManager blocked a Swift Concurrency cooperative-pool thread with DispatchGroup.wait, which could starve the pool and stall looping under repeated Live Activity updates.
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.
Problem
LiveActivityManager.getGlucoseSample(unit:)andgetInsulinOnBoard()bridge async work to sync usingDispatchGroup.wait(timeout: .distantFuture). Both are called fromupdate(), whose body runs inside aTask { … }— i.e. on a Swift Concurrency cooperative-pool thread.Blocking a cooperative-pool thread violates the pool's forward-progress guarantee. The pool is sized to the CPU core count. Each
update()that reaches one of these methods parks a pool thread on the.distantFuturewait, while the innerTask { await … }that would call.leave()needs a pool thread to run. Asupdate()calls stack up, every cooperative thread ends up parked and no thread is left to complete the inner work — the entire concurrency runtime wedges and Loop stops looping.Note the blocking call is a read of Loop’s local glucose store (
glucoseStore.getGlucoseSamples, backed by Core Data), not a fetch from the CGM — so this is independent of CGM type and glucose-fetch latency.update()is fire-and-forget with no concurrency limit:LoopDataManager.updateDisplayState()calls it withoutawait, and it just spawns a detachedTaskon the cooperative pool. It is driven by store-change notifications (glucoseSamplesDidChange,valuesDidChange,carbEntriesDidChange, preferences), not a timer. The pool self-heals while any thread is free, so the wedge requires a burst of pool-width-many overlapping calls — e.g. severalglucoseSamplesDidChangefrom a glucose backfill, or glucose+dose+carb changing around one loop cycle. That is not a high steady rate; it just has to reach the pool width once. (The captured report showed exactly 6 such threads, matching the device core count.)Evidence (captured on device)
A forced report off a hung install showed all cooperative-pool threads (
com.apple.root.utility-qos.cooperative) stuck identically:…with the five
RemoteDataServicesManagerNightscout upload queues blocked on semaphores behind them (their async completions also needed the starved pool). The main thread was idle in its run loop — the freeze was the concurrency runtime, not the UI thread. (The affected device was running a local BLE Libre CGM at the time; the deadlock does not depend on the CGM in use.)Fix
getGlucoseSampleandgetInsulinOnBoardasyncand suspend viawithCheckedContinuationinstead of blocking. A never-firing completion now merely suspends the task — it can no longer starve the pool.getBottomRowasyncand iterate (instead of.map) so the IOB query stays lazy — issued only for the.iobrow, exactly as before.update()toawait.No behavior change other than not blocking the cooperative pool.