From d9cc69675780bc8655b210325566b5d22053d10b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 10 Oct 2026 11:11:36 +0200 Subject: [PATCH 1/2] Coalesce Live Activity refreshes into one call per 20 s window The first refresh request schedules the update 20 s later, and requests that arrive while it is pending join it without moving the deadline. When BG or device status is polled every 5 or 10 s, the snapshot is still saved and the Live Activity updated within 20 s. --- .../LiveActivity/LiveActivityManager.swift | 16 ++-- .../LiveActivity/RefreshCoalescer.swift | 54 +++++++++++ Tests/RefreshCoalescerTests.swift | 96 +++++++++++++++++++ 3 files changed, 158 insertions(+), 8 deletions(-) create mode 100644 LoopFollow/LiveActivity/RefreshCoalescer.swift create mode 100644 Tests/RefreshCoalescerTests.swift diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 125ba4e09..01515ad9a 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -176,8 +176,7 @@ final class LiveActivityManager { @objc private func handleWillResignActive() { guard Storage.shared.laEnabled.value, let activity = current else { return } - refreshWorkItem?.cancel() - refreshWorkItem = nil + refreshCoalescer.cancel() let provider = StorageCurrentGlucoseStateProvider() guard let snapshot = GlucoseSnapshotBuilder.build(from: provider) else { return } @@ -437,7 +436,9 @@ final class LiveActivityManager { private var lastUpdateTime: Date? private var pushToken: String? private var tokenObservationTask: Task? - private var refreshWorkItem: DispatchWorkItem? + private lazy var refreshCoalescer = RefreshCoalescer(delay: 20) { [weak self] reason in + self?.performRefresh(reason: reason) + } /// Set when the user manually swipes away the LA. Blocks auto-restart until /// an explicit user action (Restart button, App Intent) clears it. /// In-memory only — resets to false on app relaunch, so a kill + relaunch @@ -640,12 +641,11 @@ final class LiveActivityManager { func refreshFromCurrentState(reason: String) { // No LA guard here — Watch and store must update regardless of LA state. // LA-specific gating (laEnabled, dismissedByUser) is applied inside performRefresh. - refreshWorkItem?.cancel() - let workItem = DispatchWorkItem { [weak self] in - self?.performRefresh(reason: reason) + guard Thread.isMainThread else { + DispatchQueue.main.async { self.refreshFromCurrentState(reason: reason) } + return } - refreshWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 20.0, execute: workItem) + refreshCoalescer.request(reason: reason) } // MARK: - Renewal diff --git a/LoopFollow/LiveActivity/RefreshCoalescer.swift b/LoopFollow/LiveActivity/RefreshCoalescer.swift new file mode 100644 index 000000000..85af21c95 --- /dev/null +++ b/LoopFollow/LiveActivity/RefreshCoalescer.swift @@ -0,0 +1,54 @@ +// LoopFollow +// RefreshCoalescer.swift + +import Foundation + +/// Coalesces refresh requests into one trailing call per window. +/// +/// The first request schedules the call; requests that arrive while it is +/// pending join it without moving its deadline. A steady stream of requests +/// therefore flushes once per window. Not thread-safe: use from one queue. +final class RefreshCoalescer { + typealias Scheduler = (TimeInterval, @escaping () -> Void) -> Void + + private let delay: TimeInterval + private let scheduler: Scheduler + private let action: (String) -> Void + private var pendingReasons: [String] = [] + private var generation = 0 + + init( + delay: TimeInterval, + scheduler: @escaping Scheduler = { delay, work in + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + }, + action: @escaping (String) -> Void + ) { + self.delay = delay + self.scheduler = scheduler + self.action = action + } + + var isPending: Bool { !pendingReasons.isEmpty } + + func request(reason: String) { + let wasPending = isPending + if !pendingReasons.contains(reason) { + pendingReasons.append(reason) + } + guard !wasPending else { return } + + let scheduledGeneration = generation + scheduler(delay) { [weak self] in + guard let self, self.generation == scheduledGeneration else { return } + let reason = self.pendingReasons.joined(separator: ",") + self.cancel() + self.action(reason) + } + } + + func cancel() { + pendingReasons.removeAll() + generation += 1 + } +} diff --git a/Tests/RefreshCoalescerTests.swift b/Tests/RefreshCoalescerTests.swift new file mode 100644 index 000000000..046287b08 --- /dev/null +++ b/Tests/RefreshCoalescerTests.swift @@ -0,0 +1,96 @@ +// LoopFollow +// RefreshCoalescerTests.swift + +import Foundation +@testable import LoopFollow +import Testing + +struct RefreshCoalescerTests { + final class ManualScheduler { + var scheduled: [(delay: TimeInterval, work: () -> Void)] = [] + + func schedule(_ delay: TimeInterval, _ work: @escaping () -> Void) { + scheduled.append((delay, work)) + } + + func runAll() { + let pending = scheduled + scheduled.removeAll() + pending.forEach { $0.work() } + } + } + + @Test("first request schedules one call after the delay") + func firstRequestSchedules() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + + #expect(scheduler.scheduled.count == 1) + #expect(scheduler.scheduled.first?.delay == 20) + #expect(fired.isEmpty) + scheduler.runAll() + #expect(fired == ["bg"]) + #expect(!coalescer.isPending) + } + + @Test("repeated requests while pending keep the original deadline") + func repeatedRequestsDoNotReschedule() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + for _ in 0 ..< 10 { + coalescer.request(reason: "bg") + } + coalescer.request(reason: "overrideChanged") + + #expect(scheduler.scheduled.count == 1) + scheduler.runAll() + #expect(fired == ["bg,overrideChanged"]) + } + + @Test("a request after a flush opens a new window") + func requestAfterFlushSchedulesAgain() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + scheduler.runAll() + coalescer.request(reason: "bg") + + #expect(scheduler.scheduled.count == 1) + scheduler.runAll() + #expect(fired == ["bg", "bg"]) + } + + @Test("cancel drops the pending call") + func cancelDropsPending() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + coalescer.cancel() + #expect(!coalescer.isPending) + scheduler.runAll() + #expect(fired.isEmpty) + } + + @Test("a request after cancel fires only once") + func requestAfterCancelFiresOnce() { + let scheduler = ManualScheduler() + var fired: [String] = [] + let coalescer = RefreshCoalescer(delay: 20, scheduler: scheduler.schedule) { fired.append($0) } + + coalescer.request(reason: "bg") + coalescer.cancel() + coalescer.request(reason: "notLooping") + scheduler.runAll() + + #expect(fired == ["notLooping"]) + } +} From 7149990e2f476d104dd293c319a851beec3cb985 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jonas=20Bj=C3=B6rkert?= Date: Sat, 10 Oct 2026 11:17:30 +0200 Subject: [PATCH 2/2] Add blank line after refreshCoalescer declaration --- LoopFollow/LiveActivity/LiveActivityManager.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/LoopFollow/LiveActivity/LiveActivityManager.swift b/LoopFollow/LiveActivity/LiveActivityManager.swift index 01515ad9a..b310b2fe3 100644 --- a/LoopFollow/LiveActivity/LiveActivityManager.swift +++ b/LoopFollow/LiveActivity/LiveActivityManager.swift @@ -439,6 +439,7 @@ final class LiveActivityManager { private lazy var refreshCoalescer = RefreshCoalescer(delay: 20) { [weak self] reason in self?.performRefresh(reason: reason) } + /// Set when the user manually swipes away the LA. Blocks auto-restart until /// an explicit user action (Restart button, App Intent) clears it. /// In-memory only — resets to false on app relaunch, so a kill + relaunch