diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 5c8a9ce677ba..2fe34d23aabb 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -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) diff --git a/apps/ios/Sources/OpenClawApp.swift b/apps/ios/Sources/OpenClawApp.swift index b85ff9e962b6..80a551f374f4 100644 --- a/apps/ios/Sources/OpenClawApp.swift +++ b/apps/ios/Sources/OpenClawApp.swift @@ -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? + private var completion: ((Bool) -> Void)? + + init(wakeTask: Task, 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? + 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)") } diff --git a/apps/ios/Tests/NodeAppModelInvokeTests.swift b/apps/ios/Tests/NodeAppModelInvokeTests.swift index de54132bae98..7780e625892d 100644 --- a/apps/ios/Tests/NodeAppModelInvokeTests.swift +++ b/apps/ios/Tests/NodeAppModelInvokeTests.swift @@ -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 {