From 02a19e538b5637c300259e5606da35b2c22a4685 Mon Sep 17 00:00:00 2001 From: Nan Date: Wed, 23 Sep 2026 16:35:47 -0700 Subject: [PATCH 1/2] fix: [SDK-5313] run in-app message trigger and pause changes on the main queue addTriggers, removeTriggers, clearTriggers and unpausing read and mutate the controller's message collections on the caller's thread, while the fetch completion and the dismiss path write them on the main queue. A wrapper that calls these off main, such as React Native, can crash inside evaluateRedisplayedInAppMessages when the two overlap. Hop the bodies onto the main queue when called off it and run them inline when already there. The pause flag is still written before the hop so the getter reflects the call immediately, and clearTriggers reads the keys on the main queue so a trigger queued just ahead of it is cleared too. The pause branch now checks isInAppMessageShowing on the main queue, and messageIsNotActive deletes the message on the main queue as well, since the HTML request failure that reaches it arrives on the session queue. --- .../OneSignal.xcodeproj/project.pbxproj | 4 + .../Controller/OSMessagingController.m | 58 ++++-- .../TriggerThreadingTests.swift | 171 ++++++++++++++++++ 3 files changed, 217 insertions(+), 16 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/TriggerThreadingTests.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 0a578d582..e4d55d76e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -108,6 +108,7 @@ 3C2DB2F12DE6CB5E0006B905 /* OneSignalBadgeHelpers.h in Headers */ = {isa = PBXBuildFile; fileRef = 3C2DB2EF2DE6CB5E0006B905 /* OneSignalBadgeHelpers.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3C2DB2F22DE6CB5E0006B905 /* OneSignalBadgeHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = 3C2DB2F02DE6CB5E0006B905 /* OneSignalBadgeHelpers.m */; }; 3C30FE362F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */; }; + 3C8B41D82F7E9A6C001B9C25 /* TriggerThreadingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */; }; 3C3D34E92E95EAA5006A2924 /* LiveActivityConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */; }; 3C3D8D782E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */; }; 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */; }; @@ -1382,6 +1383,7 @@ 3C2DB2EF2DE6CB5E0006B905 /* OneSignalBadgeHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OneSignalBadgeHelpers.h; sourceTree = ""; }; 3C2DB2F02DE6CB5E0006B905 /* OneSignalBadgeHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OneSignalBadgeHelpers.m; sourceTree = ""; }; 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EarlyTriggerTrackingTests.swift; sourceTree = ""; }; + 3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TriggerThreadingTests.swift; sourceTree = ""; }; 3C3D34E82E95EAA5006A2924 /* LiveActivityConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveActivityConstants.swift; sourceTree = ""; }; 3C3D8D772E92DB7500C3E977 /* OSLiveActivityViewExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivityViewExtensions.swift; sourceTree = ""; }; 3C427AC8301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSOperationRepoFlushTests.swift; sourceTree = ""; }; @@ -2281,6 +2283,7 @@ 3C7021E82ECF0CF4001768C6 /* IAMIntegrationTests.swift */, 3CAA4BB62F0BAFBA00A16682 /* TriggerTests.swift */, 3C30FE352F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift */, + 3C8B41D72F7E9A6C001B9C25 /* TriggerThreadingTests.swift */, 3CB35FCA2F0FA20B000E6E0F /* OSMessagingControllerUserStateTests.swift */, 3C7021E72ECF0CF3001768C6 /* OneSignalInAppMessagesTests-Bridging-Header.h */, ); @@ -4486,6 +4489,7 @@ buildActionMask = 2147483647; files = ( 3C30FE362F21FBE1001B9C25 /* EarlyTriggerTrackingTests.swift in Sources */, + 3C8B41D82F7E9A6C001B9C25 /* TriggerThreadingTests.swift in Sources */, 3CAA4BB72F0BAFBA00A16682 /* TriggerTests.swift in Sources */, 3C7021E92ECF0CF4001768C6 /* IAMIntegrationTests.swift in Sources */, 3C01519C2C2E29F90079E076 /* IAMRequestTests.m in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index 5e1256482..6a43a9ab5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -190,15 +190,32 @@ + (void)start { - (BOOL)isInAppMessagingPaused { return _isInAppMessagingPaused; } + +/// The message collections and trigger evaluation belong to the main queue. Entry points that can +/// arrive on any thread go through here: inline on the main thread, dispatched asynchronously from +/// anywhere else. +- (void)runOnMainQueue:(void (^)(void))block { + if ([NSThread isMainThread]) { + block(); + } else { + dispatch_async(dispatch_get_main_queue(), block); + } +} + - (void)setInAppMessagingPaused:(BOOL)pause { + // Written on the calling thread so a read right after this call sees the new value. _isInAppMessagingPaused = pause; // If IAM are not paused, try to evaluate and show IAMs if (!pause) { - [self evaluateMessages]; - } else if (self.isInAppMessageShowing) { + [self runOnMainQueue:^{ + [self evaluateMessages]; + }]; + } else { dispatch_async(dispatch_get_main_queue(), ^{ - [self.viewController dismissCurrentInAppMessage]; + if (self.isInAppMessageShowing) { + [self.viewController dismissCurrentInAppMessage]; + } }); } } @@ -828,25 +845,34 @@ - (void)evaluateRedisplayedInAppMessages:(NSArray *)newTriggersKeys #pragma mark Trigger Methods - (void)addTriggers:(NSDictionary *)triggers { - [self evaluateRedisplayedInAppMessages:triggers.allKeys]; - - // Track triggers added early on cold start (before first fetch completes) for redisplay logic - if (!self.hasCompletedFirstFetch) { - [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"Tracking triggers added early on cold start: %@", triggers]]; - [self.earlySessionTriggers addObjectsFromArray:triggers.allKeys]; - } + NSDictionary *newTriggers = [triggers copy]; + [self runOnMainQueue:^{ + [self evaluateRedisplayedInAppMessages:newTriggers.allKeys]; + + // Track triggers added early on cold start (before first fetch completes) for redisplay logic + if (!self.hasCompletedFirstFetch) { + [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"Tracking triggers added early on cold start: %@", newTriggers]]; + [self.earlySessionTriggers addObjectsFromArray:newTriggers.allKeys]; + } - [self.triggerController addTriggers:triggers]; + [self.triggerController addTriggers:newTriggers]; + }]; } - (void)removeTriggersForKeys:(NSArray *)keys { - [self evaluateRedisplayedInAppMessages:keys]; - [self.triggerController removeTriggersForKeys:keys]; + NSArray *removedKeys = [keys copy]; + [self runOnMainQueue:^{ + [self evaluateRedisplayedInAppMessages:removedKeys]; + [self.triggerController removeTriggersForKeys:removedKeys]; + }]; } - (void)clearTriggers { - NSDictionary *allTriggers = [self getTriggers]; - [self removeTriggersForKeys:allTriggers.allKeys]; + [self runOnMainQueue:^{ + // Read the keys on the main queue so a trigger queued before this call is cleared too. + NSDictionary *allTriggers = [self getTriggers]; + [self removeTriggersForKeys:allTriggers.allKeys]; + }]; } - (NSDictionary *)getTriggers { @@ -1044,8 +1070,8 @@ - (void)messageViewDidDisplayPage:(OSInAppMessageInternal *)message withPageId:( } - (void)messageIsNotActive:(OSInAppMessageInternal *)message { - [self deleteInactiveMessage:message]; dispatch_async(dispatch_get_main_queue(), ^{ + [self deleteInactiveMessage:message]; [self cleanUpInAppWindow]; }); } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/TriggerThreadingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/TriggerThreadingTests.swift new file mode 100644 index 000000000..56ec81058 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/TriggerThreadingTests.swift @@ -0,0 +1,171 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection +with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +import OneSignalOSCore +import OneSignalCoreMocks +import OneSignalOSCoreMocks +import OneSignalUserMocks +import OneSignalInAppMessagesMocks + +/** + The trigger and pause entry points accept calls from any thread, while the controller's message + state lives on the main queue. Each test drives an entry point from a background queue with the + main thread held, then lets the main queue drain and checks where the work ran. + */ +final class TriggerThreadingTests: XCTestCase { + + /// Records the thread each call arrives on. Matching and sharing always fail so evaluation stops there. + private final class RecordingTriggerController: OSTriggerController { + var addCallsOnMain: [Bool] = [] + var removeCallsOnMain: [Bool] = [] + var matchCallsOnMain: [Bool] = [] + var sharedTriggerCallsOnMain: [Bool] = [] + + override func addTriggers(_ triggers: [String: Any]) { + addCallsOnMain.append(Thread.isMainThread) + super.addTriggers(triggers) + } + + override func removeTriggers(forKeys keys: [String]) { + removeCallsOnMain.append(Thread.isMainThread) + super.removeTriggers(forKeys: keys) + } + + override func messageMatchesTriggers(_ message: OSInAppMessageInternal) -> Bool { + matchCallsOnMain.append(Thread.isMainThread) + return false + } + + override func hasSharedTriggers(_ message: OSInAppMessageInternal, newTriggersKeys: [String]) -> Bool { + sharedTriggerCallsOnMain.append(Thread.isMainThread) + return false + } + } + + private var controller: OSMessagingController! + private var triggerController: RecordingTriggerController! + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + OSMessagingController.removeInstance() + OneSignalIdentifiers.currentAppId = "test-app-id" + + controller = OSMessagingController.sharedInstance() + triggerController = RecordingTriggerController() + triggerController.delegate = controller + controller.triggerController = triggerController + + // One message with redisplay state so trigger changes reach the redisplay check + let message = OSInAppMessageInternal.instance(withJson: IAMTestHelpers.testDefaultMessageJson())! + controller.messages = NSMutableArray(array: [message]) + controller.redisplayedInAppMessages[message.messageId] = message + } + + override func tearDownWithError() throws { + OSMessagingController.removeInstance() + } + + /// Runs `work` on a background queue while the main thread waits, so nothing handed to the + /// main queue can run until `drainMainQueue` is called. + private func runOffMain(_ work: @escaping () -> Void) { + let finished = DispatchSemaphore(value: 0) + DispatchQueue.global().async { + work() + finished.signal() + } + XCTAssertEqual(finished.wait(timeout: .now() + 5), .success, "background call did not return") + } + + private func drainMainQueue() { + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 5) + } + + func testAddTriggersOffMainRunsOnMainQueue() { + runOffMain { self.controller.addTriggers(["key": "value"]) } + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [], "redisplay state was read on the calling thread") + XCTAssertEqual(triggerController.addCallsOnMain, [], "trigger state was touched on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [true]) + XCTAssertEqual(triggerController.addCallsOnMain, [true]) + XCTAssertEqual(triggerController.getTriggers()["key"] as? String, "value") + XCTAssertTrue(controller.earlySessionTriggers.contains("key")) + } + + func testRemoveTriggersOffMainRunsOnMainQueue() { + controller.addTriggers(["key": "value"]) + triggerController.sharedTriggerCallsOnMain.removeAll() + + runOffMain { self.controller.removeTriggers(forKeys: ["key"]) } + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [], "redisplay state was read on the calling thread") + XCTAssertEqual(triggerController.removeCallsOnMain, [], "trigger state was touched on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.sharedTriggerCallsOnMain, [true]) + XCTAssertEqual(triggerController.removeCallsOnMain, [true]) + XCTAssertNil(triggerController.getTriggers()["key"]) + } + + func testClearTriggersOffMainRunsOnMainQueueAndClearsATriggerQueuedBefore() { + runOffMain { + self.controller.addTriggers(["key": "value"]) + self.controller.clearTriggers() + } + + XCTAssertEqual(triggerController.removeCallsOnMain, [], "trigger state was touched on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.removeCallsOnMain, [true]) + XCTAssertTrue(triggerController.getTriggers().isEmpty) + } + + func testUnpausingOffMainEvaluatesOnMainQueueAndFlagIsImmediate() { + var pausedSeenByCaller = false + + runOffMain { + self.controller.setInAppMessagingPaused(true) + pausedSeenByCaller = self.controller.isInAppMessagingPaused() + self.controller.setInAppMessagingPaused(false) + } + + XCTAssertTrue(pausedSeenByCaller) + XCTAssertEqual(triggerController.matchCallsOnMain, [], "messages were evaluated on the calling thread") + + drainMainQueue() + + XCTAssertEqual(triggerController.matchCallsOnMain, [true]) + } +} From 8454479a2523f86c9fceb48c24a1cb9f438a3297 Mon Sep 17 00:00:00 2001 From: Nan Date: Thu, 24 Sep 2026 09:24:10 -0700 Subject: [PATCH 2/2] fix: [SDK-5313] check for a showing message before queuing the pause dismiss Pausing queued the showing check itself on the main queue, so a pause with nothing showing followed by a message or preview in the same run-loop turn dismissed that message before it rendered and marked it seen. Run the check through the main-queue helper instead, so a main-thread caller checks at call time as before and a background caller checks on main, and keep only the dismiss deferred. The preview-on-pause integration test now drains the main queue before its assertion so this cannot regress. --- .../Controller/OSMessagingController.m | 10 ++++++---- .../IAMIntegrationTests.swift | 7 ++++++- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m index 6a43a9ab5..0eb2f7664 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/Controller/OSMessagingController.m @@ -192,7 +192,7 @@ - (BOOL)isInAppMessagingPaused { } /// The message collections and trigger evaluation belong to the main queue. Entry points that can -/// arrive on any thread go through here: inline on the main thread, dispatched asynchronously from +/// arrive on any thread go through here, inline on the main thread and dispatched asynchronously from /// anywhere else. - (void)runOnMainQueue:(void (^)(void))block { if ([NSThread isMainThread]) { @@ -212,11 +212,13 @@ - (void)setInAppMessagingPaused:(BOOL)pause { [self evaluateMessages]; }]; } else { - dispatch_async(dispatch_get_main_queue(), ^{ + [self runOnMainQueue:^{ if (self.isInAppMessageShowing) { - [self.viewController dismissCurrentInAppMessage]; + dispatch_async(dispatch_get_main_queue(), ^{ + [self.viewController dismissCurrentInAppMessage]; + }); } - }); + }]; } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index ff466300f..6532a647f 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -101,7 +101,12 @@ final class IAMIntegrationTests: XCTestCase { // 3. Present the preview message OSMessagingController.sharedInstance().present(inAppPreviewMessage: message) - // 4. Verify that the preview IAM is showing even when paused + // 4. Let anything the pause queued on the main queue run first + let drained = expectation(description: "main queue drained") + DispatchQueue.main.async { drained.fulfill() } + wait(for: [drained], timeout: 5) + + // 5. Verify that the preview IAM is showing even when paused XCTAssertTrue(OSMessagingController.sharedInstance().isInAppMessageShowing) }