fix(ios): settle expired background refreshes exactly once (#129097)

This commit is contained in:
Peter Steinberger
2026-08-25 00:35:51 -07:00
committed by GitHub
parent 50b3e4bc5b
commit 0528f44ce4
3 changed files with 108 additions and 8 deletions
+11 -3
View File
@@ -7797,6 +7797,7 @@ extension NodeAppModel {
}
func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool {
guard !Task.isCancelled else { return false }
let wakeId = Self.makePushWakeAttemptID()
let normalizedTrigger = BackgroundAliveBeacon.normalizeTrigger(trigger)
let receivedMessage =
@@ -7815,7 +7816,7 @@ extension NodeAppModel {
+ "reason=\(result.reason) "
+ "durationMs=\(result.durationMs)"
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
return result.handled
return !Task.isCancelled && result.handled
}
func handleSignificantLocationWakeIfNeeded() async {
@@ -9799,6 +9800,9 @@ extension NodeAppModel {
}
let now = Date()
let gatewayConnected = await isGatewayConnected()
guard !Task.isCancelled else {
return makeResult(false, .unhandled, "cancelled")
}
var appliedReconnect = false
if !gatewayConnected {
@@ -9811,7 +9815,8 @@ extension NodeAppModel {
"Wake reconnect begin wakeId=\(wakeId, privacy: .public) stableID=\(cfg.stableID, privacy: .public)")
self.grantBackgroundReconnectLease(seconds: 30, reason: "wake_\(wakeId)")
await self.resetGatewaySessionsForForcedReconnect()
guard generation == self.gatewayConnectGeneration,
guard !Task.isCancelled,
generation == self.gatewayConnectGeneration,
self.gatewayAutoReconnectEnabled,
self.activeGatewayConnectConfig?.hasSameConnectionInputs(as: cfg) == true
else {
@@ -9826,7 +9831,7 @@ extension NodeAppModel {
self.pushWakeLogger.info("Wake reconnect trigger applied wakeId=\(wakeId, privacy: .public)")
let connected = await waitForGatewayConnection(timeoutMs: 12000, pollMs: 250)
guard connected else {
guard !Task.isCancelled, connected else {
return makeResult(appliedReconnect, .unhandled, "connect_timeout")
}
guard generation == self.gatewayConnectGeneration else {
@@ -9841,6 +9846,9 @@ extension NodeAppModel {
}
let beacon = await publishBackgroundAliveBeacon(trigger: trigger)
guard !Task.isCancelled else {
return makeResult(appliedReconnect, .unhandled, "cancelled")
}
if beacon.handled {
let successAtMs = Date().timeIntervalSince1970 * 1000
UserDefaults.standard.set(successAtMs, forKey: Self.backgroundAliveLastSuccessAtMsKey)
+42 -5
View File
@@ -16,6 +16,40 @@ private struct PendingWatchPromptAction {
private typealias PendingExecApprovalPrompt = ApprovalNotificationPrompt
/// BackgroundTasks expires on a background queue; settle there before a delayed
/// main-actor waiter can report success or complete the same delivery twice.
final class BackgroundWakeRefreshAttempt: @unchecked Sendable {
private let lock = NSLock()
private var wakeTask: Task<Bool, Never>?
private var completion: ((Bool) -> Void)?
init(wakeTask: Task<Bool, Never>, completion: @escaping (Bool) -> Void) {
self.wakeTask = wakeTask
self.completion = completion
}
func complete(success: Bool) {
self.lock.lock()
guard let completion = self.completion else {
self.lock.unlock()
return
}
self.completion = nil
let wakeTask = self.wakeTask
self.wakeTask = nil
self.lock.unlock()
if !success {
wakeTask?.cancel()
}
completion(success)
}
func expire() {
self.complete(success: false)
}
}
@MainActor
enum OpenClawAppModelRegistry {
static var appModel: NodeAppModel?
@@ -39,7 +73,7 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
return bundleId
}
private var backgroundWakeTask: Task<Bool, Never>?
private var backgroundWakeAttempt: BackgroundWakeRefreshAttempt?
private var pendingAPNsDeviceToken: Data?
private var pendingWatchPromptActions: [PendingWatchPromptAction] = []
private var pendingExecApprovalPrompts: [PendingExecApprovalPrompt] = []
@@ -280,19 +314,22 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
private func handleBackgroundWakeRefresh(task: BGAppRefreshTask) {
self.scheduleBackgroundWakeRefresh(afterSeconds: 15 * 60, reason: "reschedule")
self.backgroundWakeTask?.cancel()
self.backgroundWakeAttempt?.expire()
let wakeTask = Task { @MainActor [weak self] in
guard let self, let appModel = self.resolvedAppModel() else { return false }
return await appModel.handleBackgroundRefreshWake(trigger: "bg_app_refresh")
}
self.backgroundWakeTask = wakeTask
let attempt = BackgroundWakeRefreshAttempt(wakeTask: wakeTask) { success in
task.setTaskCompleted(success: success)
}
self.backgroundWakeAttempt = attempt
task.expirationHandler = {
wakeTask.cancel()
attempt.expire()
}
Task {
let applied = await wakeTask.value
task.setTaskCompleted(success: applied)
attempt.complete(success: applied)
self.backgroundWakeLogger.info(
"Background wake refresh finished applied=\(applied, privacy: .public)")
}
@@ -1039,6 +1039,61 @@ private final class TimingOutDeviceStatusService: DeviceStatusServicing {
#expect(result == .noData)
#expect(await appModel.handleBackgroundRefreshWake())
let expiredRefresh = Task { @MainActor in
await appModel.handleBackgroundRefreshWake()
}
expiredRefresh.cancel()
#expect(await expiredRefresh.value == false)
}
@Test @MainActor func `expired background refresh settles unsuccessful exactly once`() {
let wakeTask = Task { true }
var completions: [Bool] = []
let attempt = BackgroundWakeRefreshAttempt(wakeTask: wakeTask) {
completions.append($0)
}
attempt.expire()
attempt.complete(success: true)
attempt.expire()
#expect(completions == [false])
#expect(wakeTask.isCancelled)
}
@Test @MainActor func `completed background refresh ignores later expiration`() {
let wakeTask = Task { true }
var completions: [Bool] = []
let attempt = BackgroundWakeRefreshAttempt(wakeTask: wakeTask) {
completions.append($0)
}
attempt.complete(success: true)
attempt.expire()
#expect(completions == [true])
#expect(!wakeTask.isCancelled)
}
@Test @MainActor func `replaced background refresh settles before its successor`() {
let replacedTask = Task { true }
let replacementTask = Task { true }
var completions: [Bool] = []
let replaced = BackgroundWakeRefreshAttempt(wakeTask: replacedTask) {
completions.append($0)
}
let replacement = BackgroundWakeRefreshAttempt(wakeTask: replacementTask) {
completions.append($0)
}
replaced.expire()
replacement.complete(success: true)
replaced.complete(success: true)
#expect(completions == [false, true])
#expect(replacedTask.isCancelled)
#expect(!replacementTask.isCancelled)
}
@Test func `network status timeout never invents offline network facts`() async {