Skip to content

Commit a7edf73

Browse files
nan-licursoragent
andauthored
fix: [SDK-4874] single-flight UpdateSubscription to stop in-flight PATCH races (#1689)
Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 82d0a16 commit a7edf73

3 files changed

Lines changed: 177 additions & 6 deletions

File tree

iOS_SDK/OneSignalSDK/OneSignalCoreMocks/MockOneSignalClient.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,15 @@ public class MockOneSignalClient: NSObject, IOneSignalClient {
3636
public var lastHTTPRequest: OneSignalRequest?
3737
public var networkRequestCount = 0
3838
public var executedRequests: [OneSignalRequest] = []
39+
/// Requests that have entered `execute` (including those still held / delayed).
40+
public private(set) var startedRequests: [OneSignalRequest] = []
3941
public var executeInstantaneously = false
4042
/// Set to true to make it unnecessary to setup mock responses for every request possible
4143
public var fireSuccessForAllRequests = false
44+
/// When true, `execute` records the request but does not complete until `releaseHeldResponses()`.
45+
public var holdResponses = false
46+
47+
private var heldExecutions: [(request: OneSignalRequest, onSuccess: OSResultSuccessBlock, onFailure: OSClientFailureBlock)] = []
4248

4349
var remoteParamsResponse: [String: Any]?
4450
var shouldUseProvisionalAuthorization = false // new in iOS 12 (aka Direct to History)
@@ -84,6 +90,9 @@ public class MockOneSignalClient: NSObject, IOneSignalClient {
8490
lastHTTPRequest = nil
8591
networkRequestCount = 0
8692
executedRequests.removeAll()
93+
startedRequests.removeAll()
94+
heldExecutions.removeAll()
95+
holdResponses = false
8796
executeInstantaneously = true
8897
remoteParamsResponse = nil
8998
shouldUseProvisionalAuthorization = false
@@ -93,6 +102,19 @@ public class MockOneSignalClient: NSObject, IOneSignalClient {
93102
public func execute(_ request: OneSignalRequest, onSuccess successBlock: @escaping OSResultSuccessBlock, onFailure failureBlock: @escaping OSClientFailureBlock) {
94103
print("🧪 MockOneSignalClient execute called")
95104

105+
// Check hold + enqueue under one lock so releaseHeldResponses can't miss a callback mid-hold.
106+
let shouldHold = lock.withLock { () -> Bool in
107+
startedRequests.append(request)
108+
guard holdResponses else {
109+
return false
110+
}
111+
heldExecutions.append((request, successBlock, failureBlock))
112+
return true
113+
}
114+
if shouldHold {
115+
return
116+
}
117+
96118
if executeInstantaneously {
97119
finishExecutingRequest(request, onSuccess: successBlock, onFailure: failureBlock)
98120
} else {
@@ -102,6 +124,19 @@ public class MockOneSignalClient: NSObject, IOneSignalClient {
102124
}
103125
}
104126

127+
/// Completes every request currently held by `holdResponses`, and stops holding further executes.
128+
public func releaseHeldResponses() {
129+
let held: [(request: OneSignalRequest, onSuccess: OSResultSuccessBlock, onFailure: OSClientFailureBlock)] = lock.withLock {
130+
holdResponses = false
131+
let copy = heldExecutions
132+
heldExecutions.removeAll()
133+
return copy
134+
}
135+
for item in held {
136+
finishExecutingRequest(item.request, onSuccess: item.onSuccess, onFailure: item.onFailure)
137+
}
138+
}
139+
105140
/// Helper method to stringify the name of a request for identification and comparison
106141
private func stringify(_ request: OneSignalRequest) -> String {
107142
var stringified = request.description

iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,12 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor {
397397
guard !request.sentToClient else {
398398
return
399399
}
400+
// Single-flight: don't send a second UpdateSubscription for the same model while one is in flight.
401+
// A later coalesced request stays queued and is drained when the in-flight one finishes.
402+
let modelId = request.subscriptionModel.modelId
403+
guard !updateRequestQueue.contains(where: { $0 !== request && $0.sentToClient && $0.subscriptionModel.modelId == modelId }) else {
404+
return
405+
}
400406
guard request.prepareForExecution(newRecordsState: newRecordsState) else {
401407
return
402408
}
@@ -413,13 +419,9 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor {
413419
self.dispatchQueue.async {
414420
self.updateRequestQueue.removeAll(where: { $0 == request})
415421
OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue)
416-
if inBackground {
417-
OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier)
418-
}
419-
}
420422

421-
if let onesignalId = OneSignalUserManagerImpl.sharedInstance.onesignalId {
422-
if let rywToken = response?["ryw_token"] as? String
423+
if let onesignalId = OneSignalUserManagerImpl.sharedInstance.onesignalId {
424+
if let rywToken = response?["ryw_token"] as? String
423425
{
424426
let rywDelay = response?["ryw_delay"] as? NSNumber
425427
OSConsistencyManager.shared.setRywTokenAndDelay(
@@ -431,6 +433,12 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor {
431433
// handle a potential regression where ryw_token is no longer returned by API
432434
OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID)
433435
}
436+
}
437+
438+
self.executeNextPendingUpdateSubscription(for: modelId, inBackground: inBackground)
439+
if inBackground {
440+
OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier)
441+
}
434442
}
435443
} onFailure: { error in
436444
OneSignalLog.onesignalLog(.LL_ERROR, message: "OSSubscriptionOperationExecutor update subscription request failed with error: \(error.debugDescription)")
@@ -440,11 +448,27 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor {
440448
// Fail, no retry, remove from cache and queue
441449
self.updateRequestQueue.removeAll(where: { $0 == request})
442450
OneSignalUserDefaults.initShared().saveCodeableData(forKey: OS_SUBSCRIPTION_EXECUTOR_UPDATE_REQUEST_QUEUE_KEY, withValue: self.updateRequestQueue)
451+
self.executeNextPendingUpdateSubscription(for: modelId, inBackground: inBackground)
452+
} else {
453+
// Make the request eligible for the next flush
454+
request.sentToClient = false
443455
}
444456
if inBackground {
445457
OSBackgroundTaskManager.endBackgroundTask(backgroundTaskIdentifier)
446458
}
447459
}
448460
}
449461
}
462+
463+
}
464+
465+
extension OSSubscriptionOperationExecutor {
466+
/// Sends the oldest unsent UpdateSubscription for `modelId`, if any. Caller must be on `dispatchQueue`.
467+
private func executeNextPendingUpdateSubscription(for modelId: String, inBackground: Bool) {
468+
let pending = updateRequestQueue.filter { !$0.sentToClient && $0.subscriptionModel.modelId == modelId }
469+
guard let next = pending.min(by: { $0.timestamp < $1.timestamp }) else {
470+
return
471+
}
472+
executeUpdateSubscriptionRequest(next, inBackground: inBackground)
473+
}
450474
}

iOS_SDK/OneSignalSDK/OneSignalUserTests/Executors/SubscriptionUpdateRaceTests.swift

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,118 @@ final class SubscriptionUpdateRaceTests: XCTestCase {
133133
XCTAssertEqual(updateRequests.count, 1, "Unsent updates for the same subscription should be coalesced")
134134
}
135135

136+
/**
137+
An UpdateSubscription already on the wire must not race a follow-up PATCH.
138+
The follow-up stays queued until the in-flight request completes, then sends live state.
139+
*/
140+
func testInFlightUpdateBlocksFollowUpUntilCompleteThenSendsLiveState() throws {
141+
let client = MockOneSignalClient()
142+
client.holdResponses = true
143+
client.fireSuccessForAllRequests = true
144+
OneSignalCoreImpl.setSharedClient(client)
145+
146+
let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState())
147+
let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId)
148+
let identityModelId = UUID().uuidString
149+
150+
executor.enqueueDelta(OSDelta(
151+
name: OS_UPDATE_SUBSCRIPTION_DELTA,
152+
identityModelId: identityModelId,
153+
model: model,
154+
property: "notificationTypes",
155+
value: promptedNeverAnswered
156+
))
157+
executor.processDeltaQueue(inBackground: false)
158+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2)
159+
160+
XCTAssertEqual(client.startedRequests.count, 1, "First UpdateSubscription should be in flight")
161+
let firstPayload = try XCTUnwrap(
162+
(client.startedRequests[0] as? OSRequestUpdateSubscription)?.parameters?["subscription"] as? [String: Any]
163+
)
164+
XCTAssertEqual(firstPayload["notification_types"] as? Int, promptedNeverAnswered)
165+
XCTAssertEqual(firstPayload["enabled"] as? Bool, false)
166+
167+
// Permission granted while first PATCH is still in flight.
168+
model.notificationTypes = subscribedNotificationTypes
169+
XCTAssertTrue(model.enabled)
170+
171+
executor.enqueueDelta(OSDelta(
172+
name: OS_UPDATE_SUBSCRIPTION_DELTA,
173+
identityModelId: identityModelId,
174+
model: model,
175+
property: "notificationTypes",
176+
value: subscribedNotificationTypes
177+
))
178+
executor.processDeltaQueue(inBackground: false)
179+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.2)
180+
181+
XCTAssertEqual(client.startedRequests.count, 1, "Follow-up must wait for in-flight UpdateSubscription")
182+
183+
client.releaseHeldResponses()
184+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
185+
186+
XCTAssertEqual(client.startedRequests.count, 2, "Pending follow-up should send after in-flight completes")
187+
let secondPayload = try XCTUnwrap(
188+
(client.startedRequests[1] as? OSRequestUpdateSubscription)?.parameters?["subscription"] as? [String: Any]
189+
)
190+
XCTAssertEqual(secondPayload["notification_types"] as? Int, subscribedNotificationTypes)
191+
XCTAssertEqual(secondPayload["enabled"] as? Bool, true)
192+
}
193+
194+
/**
195+
A retryable failure (e.g. 500/timeout) must not leave the single-flight gate locked.
196+
The failed request becomes resendable and later updates for the model still go out.
197+
*/
198+
func testRetryableFailureDoesNotBlockSubsequentUpdates() throws {
199+
let client = MockOneSignalClient()
200+
client.fireSuccessForAllRequests = true
201+
OneSignalCoreImpl.setSharedClient(client)
202+
203+
let executor = OSSubscriptionOperationExecutor(newRecordsState: OSNewRecordsState())
204+
let model = makePushSubscriptionModel(notificationTypes: promptedNeverAnswered, subscriptionId: subscriptionId)
205+
let identityModelId = UUID().uuidString
206+
207+
// Fail the first update with a retryable error (mock responses are keyed by request description).
208+
let requestKey = "OSRequestUpdateSubscription with model: \(model.modelId)"
209+
client.setMockFailureResponseForRequest(
210+
request: requestKey,
211+
error: OneSignalClientError(code: 500, message: "retryable", responseHeaders: nil, response: nil, underlyingError: nil)
212+
)
213+
214+
executor.enqueueDelta(OSDelta(
215+
name: OS_UPDATE_SUBSCRIPTION_DELTA,
216+
identityModelId: identityModelId,
217+
model: model,
218+
property: "notificationTypes",
219+
value: promptedNeverAnswered
220+
))
221+
executor.processDeltaQueue(inBackground: false)
222+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
223+
224+
XCTAssertEqual(client.executedRequests.count, 1, "First update should have been attempted and failed retryably")
225+
226+
// Server recovers; user accepts permission.
227+
client.setMockResponseForRequest(request: requestKey, response: [:])
228+
model.notificationTypes = subscribedNotificationTypes
229+
XCTAssertTrue(model.enabled)
230+
231+
executor.enqueueDelta(OSDelta(
232+
name: OS_UPDATE_SUBSCRIPTION_DELTA,
233+
identityModelId: identityModelId,
234+
model: model,
235+
property: "notificationTypes",
236+
value: subscribedNotificationTypes
237+
))
238+
executor.processDeltaQueue(inBackground: false)
239+
OneSignalCoreMocks.waitForBackgroundThreads(seconds: 0.5)
240+
241+
let updateRequests = client.executedRequests.compactMap { $0 as? OSRequestUpdateSubscription }
242+
XCTAssertEqual(updateRequests.count, 2, "Follow-up update must still send after a retryable failure")
243+
let lastPayload = try XCTUnwrap(updateRequests.last?.parameters?["subscription"] as? [String: Any])
244+
XCTAssertEqual(lastPayload["notification_types"] as? Int, subscribedNotificationTypes)
245+
XCTAssertEqual(lastPayload["enabled"] as? Bool, true)
246+
}
247+
136248
// MARK: - Helpers
137249

138250
private func makePushSubscriptionModel(notificationTypes: Int, subscriptionId: String?) -> OSSubscriptionModel {

0 commit comments

Comments
 (0)