Skip to content

Fix Live Activity cooperative-pool starvation deadlock - #2465

Merged
ps2 merged 1 commit into
LoopKit:devfrom
loopkitdev:fix/liveactivity-cooperative-pool-deadlock
Jul 20, 2026
Merged

Fix Live Activity cooperative-pool starvation deadlock#2465
ps2 merged 1 commit into
LoopKit:devfrom
loopkitdev:fix/liveactivity-cooperative-pool-deadlock

Conversation

@loopkitdev

@loopkitdev loopkitdev commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Problem

LiveActivityManager.getGlucoseSample(unit:) and getInsulinOnBoard() bridge async work to sync using DispatchGroup.wait(timeout: .distantFuture). Both are called from update(), whose body runs inside a Task { … } — 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 .distantFuture wait, while the inner Task { await … } that would call .leave() needs a pool thread to run. As update() 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 without await, and it just spawns a detached Task on 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. several glucoseSamplesDidChange from 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:

LiveActivityManager.getGlucoseSample(unit:)
  → DispatchGroup.wait
    → _dispatch_group_wait_slow → __ulock_wait

…with the five RemoteDataServicesManager Nightscout 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

  • Make getGlucoseSample and getInsulinOnBoard async and suspend via withCheckedContinuation instead of blocking. A never-firing completion now merely suspends the task — it can no longer starve the pool.
  • Make getBottomRow async and iterate (instead of .map) so the IOB query stays lazy — issued only for the .iob row, exactly as before.
  • Update the two call sites in update() to await.

No behavior change other than not blocking the cooperative pool.

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.
@marionbarker

Copy link
Copy Markdown
Contributor

Test

✅ successful build with this modification

Start with LoopWorkspace dev branch.
Modify Loop to point to loopkitdev:fix/liveactivity-cooperative-pool-deadlock
Build onto SE 3rd gen test phone running iOS 26.
Live Activity is enabled and works.

@marionbarker marionbarker left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I approve from test. Leaving the code review to the experts.

@ps2
ps2 merged commit fa3b3cd into LoopKit:dev Jul 20, 2026
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.
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.

3 participants