fix(ios): report no data for throttled silent pushes (#129026)

This commit is contained in:
Peter Steinberger
2026-08-24 23:16:47 -07:00
committed by GitHub
parent 81c0ba0d3b
commit e861bf2cee
3 changed files with 64 additions and 21 deletions
+31 -17
View File
@@ -7737,11 +7737,21 @@ extension NodeAppModel {
}
}
func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> Bool {
enum SilentPushWakeResult {
case unhandled
case noData
case newData
var handled: Bool {
self != .unhandled
}
}
func handleSilentPushWake(_ userInfo: [AnyHashable: Any]) async -> SilentPushWakeResult {
let wakeId = Self.makePushWakeAttemptID()
guard Self.isSilentPushPayload(userInfo) else {
self.pushWakeLogger.info("Ignored APNs payload wakeId=\(wakeId, privacy: .public): not silent push")
return false
return .unhandled
}
let pushKind = Self.openclawPushKind(userInfo)
let receivedMessage =
@@ -7758,7 +7768,7 @@ extension NodeAppModel {
+ "handled=\(handled)"
self.execApprovalNotificationLogger.info(
"\(cleanupMessage, privacy: .public)")
return handled
return handled ? .newData : .unhandled
}
if let push = ApprovalNotificationBridge.parseRequestedPush(userInfo: userInfo) {
@@ -7770,7 +7780,7 @@ extension NodeAppModel {
self.execApprovalNotificationLogger
.info("\(handledMessage, privacy: .public)")
}
return handled
return handled ? .newData : .unhandled
}
let result = await performBackgroundAliveBeaconIfNeeded(
@@ -7783,7 +7793,7 @@ extension NodeAppModel {
+ "reason=\(result.reason) "
+ "durationMs=\(result.durationMs)"
self.pushWakeLogger.info("\(outcomeMessage, privacy: .public)")
return result.handled
return result.disposition
}
func handleBackgroundRefreshWake(trigger: String = "bg_app_refresh") async -> Bool {
@@ -9556,9 +9566,13 @@ extension NodeAppModel {
private struct BackgroundAliveWakeAttemptResult {
var applied: Bool
var handled: Bool
var disposition: SilentPushWakeResult
var reason: String
var durationMs: Int
var handled: Bool {
self.disposition.handled
}
}
private func waitForGatewayConnection(
@@ -9766,22 +9780,22 @@ extension NodeAppModel {
trigger: BackgroundAliveBeacon.Trigger) async -> BackgroundAliveWakeAttemptResult
{
let startedAt = Date()
let makeResult: (Bool, Bool, String) -> BackgroundAliveWakeAttemptResult = { applied, handled, reason in
let makeResult = { (applied: Bool, disposition: SilentPushWakeResult, reason: String) in
let durationMs = Int(Date().timeIntervalSince(startedAt) * 1000)
return BackgroundAliveWakeAttemptResult(
applied: applied,
handled: handled,
disposition: disposition,
reason: reason,
durationMs: max(0, durationMs))
}
guard self.isBackgrounded else {
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): app not backgrounded")
return makeResult(false, false, "not_backgrounded")
return makeResult(false, .unhandled, "not_backgrounded")
}
guard self.gatewayAutoReconnectEnabled else {
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): auto reconnect disabled")
return makeResult(false, false, "auto_reconnect_disabled")
return makeResult(false, .unhandled, "auto_reconnect_disabled")
}
let now = Date()
let gatewayConnected = await isGatewayConnected()
@@ -9790,7 +9804,7 @@ extension NodeAppModel {
if !gatewayConnected {
guard let cfg = activeGatewayConnectConfig else {
self.pushWakeLogger.info("Wake no-op wakeId=\(wakeId, privacy: .public): no active gateway config")
return makeResult(false, false, "no_active_gateway_config")
return makeResult(false, .unhandled, "no_active_gateway_config")
}
let generation = self.gatewayConnectGeneration
self.pushWakeLogger.info(
@@ -9801,7 +9815,7 @@ extension NodeAppModel {
self.gatewayAutoReconnectEnabled,
self.activeGatewayConnectConfig?.hasSameConnectionInputs(as: cfg) == true
else {
return makeResult(false, false, "reconnect_superseded")
return makeResult(false, .unhandled, "reconnect_superseded")
}
self.setOperatorConnected(false)
self.gatewayConnected = false
@@ -9813,17 +9827,17 @@ extension NodeAppModel {
let connected = await waitForGatewayConnection(timeoutMs: 12000, pollMs: 250)
guard connected else {
return makeResult(appliedReconnect, false, "connect_timeout")
return makeResult(appliedReconnect, .unhandled, "connect_timeout")
}
guard generation == self.gatewayConnectGeneration else {
return makeResult(appliedReconnect, false, "reconnect_superseded")
return makeResult(appliedReconnect, .unhandled, "reconnect_superseded")
}
} else if BackgroundAliveBeacon.shouldSkipRecentSuccess(
isGatewayConnected: true,
now: now,
lastSuccessAtMs: UserDefaults.standard.object(forKey: Self.backgroundAliveLastSuccessAtMsKey) as? Double)
{
return makeResult(false, true, "recent_success")
return makeResult(false, .noData, "recent_success")
}
let beacon = await publishBackgroundAliveBeacon(trigger: trigger)
@@ -9831,9 +9845,9 @@ extension NodeAppModel {
let successAtMs = Date().timeIntervalSince1970 * 1000
UserDefaults.standard.set(successAtMs, forKey: Self.backgroundAliveLastSuccessAtMsKey)
UserDefaults.standard.set(trigger.rawValue, forKey: Self.backgroundAliveLastTriggerKey)
return makeResult(appliedReconnect, true, beacon.reason)
return makeResult(appliedReconnect, .newData, beacon.reason)
}
return makeResult(appliedReconnect, false, beacon.reason)
return makeResult(appliedReconnect, .unhandled, beacon.reason)
}
private func publishBackgroundAliveBeacon(
+4 -4
View File
@@ -230,12 +230,12 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
completionHandler(.noData)
return
}
let handled = await appModel.handleSilentPushWake(userInfo)
self.logger.info("APNs wake handled=\(handled, privacy: .public)")
if !handled {
let result = await appModel.handleSilentPushWake(userInfo)
self.logger.info("APNs wake handled=\(result.handled, privacy: .public)")
if !result.handled {
self.scheduleBackgroundWakeRefresh(afterSeconds: 90, reason: "silent_push_not_applied")
}
completionHandler(handled ? .newData : .noData)
completionHandler(result == .newData ? .newData : .noData)
}
}
@@ -1012,6 +1012,35 @@ private final class TimingOutDeviceStatusService: DeviceStatusServicing {
}
@Suite(.serialized) struct NodeAppModelInvokeTests {
@Test @MainActor func `throttled silent push reports no data while background refresh remains successful`() async {
let lastSuccessKey = "gateway.backgroundAlive.lastSuccessAtMs"
let previousSuccess = UserDefaults.standard.object(forKey: lastSuccessKey)
defer {
if let previousSuccess {
UserDefaults.standard.set(previousSuccess, forKey: lastSuccessKey)
} else {
UserDefaults.standard.removeObject(forKey: lastSuccessKey)
}
}
UserDefaults.standard.set(Date().timeIntervalSince1970 * 1000, forKey: lastSuccessKey)
let appModel = NodeAppModel()
appModel.isBackgrounded = true
appModel.gatewayConnected = true
let delegate = OpenClawAppDelegate()
delegate.appModel = appModel
let result = await withCheckedContinuation { continuation in
delegate.application(
UIApplication.shared,
didReceiveRemoteNotification: ["aps": ["content-available": 1]],
fetchCompletionHandler: { continuation.resume(returning: $0) })
}
#expect(result == .noData)
#expect(await appModel.handleBackgroundRefreshWake())
}
@Test func `network status timeout never invents offline network facts`() async {
await #expect(throws: URLError(.timedOut)) {
try await NetworkStatusService().currentStatus(timeoutMs: 0)