From bf7c24d6a2fa8a595c3aeb849da928587dd70a83 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:34:00 -0700 Subject: [PATCH 1/4] chore: normalize project.pbxproj ordering Canonical sort applied by the xcodeproj tool, so later commits in this stack show only their own file additions. No project changes. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 252 +++++++++--------- 1 file changed, 126 insertions(+), 126 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 10f512fa6..9ccbf443b 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -1344,6 +1344,12 @@ 3C11518C289AF5E800565C41 /* OSModelChangedHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSModelChangedHandler.swift; sourceTree = ""; }; 3C14E39E2AFAE39B006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3C14E3A02AFAE461006ED053 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; + 3C14E3A92FAE54C006ED053 /* IOSLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSLogger.swift; sourceTree = ""; }; + 3C14E3AA2FAE54C006ED053 /* OneSignalLogHttpSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalLogHttpSender.swift; sourceTree = ""; }; + 3C14E3AB2FAE54C006ED053 /* FileLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStore.swift; sourceTree = ""; }; + 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLoggerPlatformProvider.swift; sourceTree = ""; }; + 3C14E3AD2FAE54C006ED053 /* KotlinByteArray+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinByteArray+Data.swift"; sourceTree = ""; }; + 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLoggerAdaptersTests.swift; sourceTree = ""; }; 3C19C6312E919F0C00D6731E /* OSRequestLiveActivityClicked.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSRequestLiveActivityClicked.swift; sourceTree = ""; }; 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalIdentifiersFallbackTests.swift; sourceTree = ""; }; 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSModelStoreRefreshTests.swift; sourceTree = ""; }; @@ -1783,12 +1789,6 @@ DEBAAEB62A4381AE00BF2C1C /* OSInAppMessageMigrationController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OSInAppMessageMigrationController.h; sourceTree = ""; }; DEBAAEB72A4381AE00BF2C1C /* OSInAppMessageMigrationController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OSInAppMessageMigrationController.m; sourceTree = ""; }; DEC08AFF2947D4E900C81DA3 /* OneSignalSwiftInterface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalSwiftInterface.swift; sourceTree = ""; }; - 3C14E3A92FAE54C006ED053 /* IOSLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IOSLogger.swift; sourceTree = ""; }; - 3C14E3AA2FAE54C006ED053 /* OneSignalLogHttpSender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalLogHttpSender.swift; sourceTree = ""; }; - 3C14E3AB2FAE54C006ED053 /* FileLogStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStore.swift; sourceTree = ""; }; - 3C14E3AC2FAE54C006ED053 /* OSLoggerPlatformProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLoggerPlatformProvider.swift; sourceTree = ""; }; - 3C14E3AD2FAE54C006ED053 /* KotlinByteArray+Data.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinByteArray+Data.swift"; sourceTree = ""; }; - 3C14E3AE2FAE54C006ED053 /* OSLoggerAdaptersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLoggerAdaptersTests.swift; sourceTree = ""; }; DEF5CCF12539321A0003E9CC /* UnitTestApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = UnitTestApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; DEF5CCF32539321A0003E9CC /* AppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = AppDelegate.h; sourceTree = ""; }; DEF5CCF42539321A0003E9CC /* AppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = AppDelegate.m; sourceTree = ""; }; @@ -5410,14 +5410,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -5445,6 +5437,14 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu11; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OneSignalOSCoreFramework/Info.plist; @@ -5478,14 +5478,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -5514,6 +5506,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; @@ -6976,14 +6976,6 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -7008,6 +7000,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_USER_SCRIPT_SANDBOXING = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -7030,14 +7030,6 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -7062,6 +7054,14 @@ DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_OPTIMIZATION_LEVEL = 0; @@ -7092,14 +7092,6 @@ buildSettings = { ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; BUNDLE_LOADER = "$(TEST_HOST)"; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; @@ -7124,6 +7116,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_USER_SCRIPT_SANDBOXING = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_C_LANGUAGE_STANDARD = gnu17; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 11.0; @@ -7191,14 +7191,6 @@ CA2951B72167F4120064227A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_GCD_PERFORMANCE = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7208,6 +7200,14 @@ "$(inherited)", "$(PROJECT_DIR)", ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7225,14 +7225,6 @@ CA2951B82167F4120064227A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7249,6 +7241,14 @@ DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -7278,14 +7278,6 @@ CA2951B92167F4120064227A /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; @@ -7297,6 +7289,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; @@ -7395,14 +7395,6 @@ CA2951C32167FB950064227A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_GCD_PERFORMANCE = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7412,6 +7404,14 @@ "$(inherited)", "$(PROJECT_DIR)", ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7429,14 +7429,6 @@ CA2951C42167FB950064227A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_CODE_COVERAGE = YES; @@ -7454,6 +7446,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -7484,14 +7484,6 @@ CA2951C52167FB950064227A /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; @@ -7503,6 +7495,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; @@ -7601,14 +7601,6 @@ DE3D8F3928C15839008C2BBF /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ANALYZER_GCD_PERFORMANCE = YES; CLANG_ANALYZER_SECURITY_FLOATLOOPCOUNTER = YES; CLANG_ENABLE_CODE_COVERAGE = NO; @@ -7618,6 +7610,14 @@ "$(inherited)", "$(PROJECT_DIR)", ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); IPHONEOS_DEPLOYMENT_TARGET = 9.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", @@ -7635,14 +7635,6 @@ DE3D8F3A28C15839008C2BBF /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUILD_LIBRARY_FOR_DISTRIBUTION = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_CODE_COVERAGE = YES; @@ -7660,6 +7652,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_WARN_64_TO_32_BIT_CONVERSION = NO; INFOPLIST_FILE = OneSignalFramework/Info.plist; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; @@ -7690,14 +7690,6 @@ DE3D8F3B28C15839008C2BBF /* Test */ = { isa = XCBuildConfiguration; buildSettings = { - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); BUNDLE_LOADER = "$(TEST_HOST)"; CLANG_ANALYZER_NONNULL = YES; CLANG_ENABLE_MODULES = YES; @@ -7709,6 +7701,14 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = 99SW8E36CT; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_OPTIMIZATION_LEVEL = 0; GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; HEADER_SEARCH_PATHS = $CONFIGURATION_TEMP_DIR/UnitTests.build/DerivedSources; @@ -9205,14 +9205,6 @@ buildSettings = { APPLICATION_EXTENSION_API_ONLY = YES; BUILD_LIBRARY_FOR_DISTRIBUTION = YES; - "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", - ); - "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( - "$(inherited)", - "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", - ); CLANG_ENABLE_MODULES = YES; COPY_PHASE_STRIP = NO; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; @@ -9222,6 +9214,14 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; ENABLE_TESTABILITY = YES; + "FRAMEWORK_SEARCH_PATHS[sdk=iphoneos*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64", + ); + "FRAMEWORK_SEARCH_PATHS[sdk=iphonesimulator*]" = ( + "$(inherited)", + "$(SRCROOT)/../../OneSignal-KMP-SDK/kmp/build/XCFrameworks/release/OneSignalKMP.xcframework/ios-arm64_x86_64-simulator", + ); GCC_PREPROCESSOR_DEFINITIONS = OS_TEST; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OneSignalOSCoreFramework/Info.plist; From 3421d8396efca5984413bdc081ba1e1b9af6addd Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 16:42:20 -0700 Subject: [PATCH 2/4] fix: [PR2] read-your-write consistency defects Four faults in how a fetch waits for its own write to be readable: resolveConditionsWithID looked up waiters by condition id, but they are registered under the id passed to getRywTokenFromAwaitableCondition, so the lookup found nothing and the waiter it meant to release stayed blocked. It now scans every index for waiters on that condition. A waiter blocked on an unbounded semaphore, so a response that never arrived held the calling thread for the life of the process. Waits now time out and deregister rather than leaving an entry that the next token signals to nobody. OSIamFetchReadyCondition was a singleton pinned to the first id it ever saw, so after a user switch a fetch consulted the previous user's tokens. Conditions are now per id, with reset() as the test seam. hasSubscriptionUpdatePending was never lowered, so one in-session subscription change held every later fetch to waiting for a subscription token with no update behind it. The new optional onConditionSatisfied lets a condition lower a bar it raised once its waiter is released. Shared state moves behind the serial queue and locks throughout. Co-authored-by: Cursor --- .../OneSignal.xcodeproj/project.pbxproj | 8 + .../EarlyTriggerTrackingTests.swift | 2 +- .../IAMIntegrationTests.swift | 2 +- .../OSMessagingControllerUserStateTests.swift | 2 +- .../IamFetch/OSIamFetchReadyCondition.swift | 38 ++++- .../Source/Consistency/OSCondition.swift | 4 + .../Consistency/OSConsistencyManager.swift | 82 +++++---- .../ConsistencyManagerTestHelpers.swift | 7 + .../OSConsistencyManagerTests.swift | 103 ++++++++--- .../OSIamFetchReadyConditionTests.swift | 160 ++++++++++++++++++ .../OSOperationRepoFlushTests.swift | 19 --- .../XCTestCase+WaitUntil.swift | 51 ++++++ 12 files changed, 395 insertions(+), 83 deletions(-) create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift create mode 100644 iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/XCTestCase+WaitUntil.swift diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 9ccbf443b..658d2d80d 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -279,6 +279,7 @@ 475F474A2B8E3B4600EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; platformFilter = ios; }; 475F474F2B8E3B5400EC05B3 /* OneSignalLiveActivities.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; }; 475F47502B8E3B5400EC05B3 /* OneSignalLiveActivities.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 475F471E2B8E398D00EC05B3 /* OneSignalLiveActivities.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 50C4F7CFEC46836131011004 /* XCTestCase+WaitUntil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */; }; 5B053FBC2CAE07EB002F30C4 /* OneSignalOSCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3C115161289A259500565C41 /* OneSignalOSCore.framework */; }; 5B053FC32CAE0843002F30C4 /* OSConsistencyManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BC1DE672C90C23E00CA8807 /* OSConsistencyManagerTests.swift */; }; 5B58E4F8237CE7B4009401E0 /* UIDeviceOverrider.m in Sources */ = {isa = PBXBuildFile; fileRef = 5B58E4F6237CE7B4009401E0 /* UIDeviceOverrider.m */; }; @@ -394,6 +395,7 @@ CACBAAAA218A65AE000ACAA5 /* InAppMessagingTests.m in Sources */ = {isa = PBXBuildFile; fileRef = CACBAAA9218A65AE000ACAA5 /* InAppMessagingTests.m */; }; CACBAAAC218A662B000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; CACBAAB4218A7113000ACAA5 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CACBAAAB218A662B000ACAA5 /* WebKit.framework */; }; + DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */; }; DE16C14424D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14524D3724700670EFA /* OneSignalLifecycleObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE16C14324D3724700670EFA /* OneSignalLifecycleObserver.m */; }; DE16C14724D3727200670EFA /* OneSignalLifecycleObserver.h in Headers */ = {isa = PBXBuildFile; fileRef = DE16C14624D3727200670EFA /* OneSignalLifecycleObserver.h */; }; @@ -1319,6 +1321,7 @@ 03866CBC2378A33B0009C1D8 /* OutcomeIntegrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OutcomeIntegrationTests.m; sourceTree = ""; }; 03866CBF2378A67B0009C1D8 /* RestClientAsserts.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RestClientAsserts.h; sourceTree = ""; }; 03866CC02378A67B0009C1D8 /* RestClientAsserts.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = RestClientAsserts.m; sourceTree = ""; }; + 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = "XCTestCase+WaitUntil.swift"; sourceTree = ""; }; 03CCCC7D2835D8CC004BF794 /* OneSignalUNUserNotificationCenterSwizzlingTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OneSignalUNUserNotificationCenterSwizzlingTest.m; sourceTree = ""; }; 03CCCC812835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = OneSignalUNUserNotificationCenterHelper.m; sourceTree = ""; }; 03CCCC822835D90F004BF794 /* OneSignalUNUserNotificationCenterHelper.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OneSignalUNUserNotificationCenterHelper.h; sourceTree = ""; }; @@ -1834,6 +1837,7 @@ DEFB3E622BB731BD00E65DAD /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; }; DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLiveActivities.swift; sourceTree = ""; }; DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSStubLiveActivities.swift; sourceTree = ""; }; + FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = OSIamFetchReadyConditionTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -2560,6 +2564,8 @@ 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, 3C23A21E2FCE0AA1001D32E3 /* OSResilientStorageTests.swift */, 3C23A21C2FCE0A83001D32E3 /* OSModelStoreRefreshTests.swift */, + FF4B19D1EC31C0750F13065A /* OSIamFetchReadyConditionTests.swift */, + 03A81CCA8DFA43C8AEA738B8 /* XCTestCase+WaitUntil.swift */, ); path = OneSignalOSCoreTests; sourceTree = ""; @@ -4585,6 +4591,8 @@ 3C427AC9301BB28A0059B8B7 /* OSOperationRepoFlushTests.swift in Sources */, 3C14E3B52FAE54C006ED053 /* OSLoggerAdaptersTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, + DD2A89A8052E2D1912B0038B /* OSIamFetchReadyConditionTests.swift in Sources */, + 50C4F7CFEC46836131011004 /* XCTestCase+WaitUntil.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift index bedfe909e..d916c1f49 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/EarlyTriggerTrackingTests.swift @@ -49,7 +49,7 @@ final class EarlyTriggerTrackingTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() OSMessagingController.removeInstance() // Set up basic configuration diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index 9efcb6f51..f21259a50 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -41,7 +41,7 @@ final class IAMIntegrationTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() // Temp. logging to help debug during testing OneSignalLog.setLogLevel(.LL_VERBOSE) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift index 8d7ada7cb..9c73924d6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/OSMessagingControllerUserStateTests.swift @@ -50,7 +50,7 @@ final class OSMessagingControllerUserStateTests: XCTestCase { override func setUpWithError() throws { OneSignalCoreMocks.clearUserDefaults() OneSignalUserMocks.reset() - OSConsistencyManager.shared.reset() + ConsistencyManagerTestHelpers.reset() OSMessagingController.removeInstance() // Set up basic configuration diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift index 66751e3a0..3075e9cbb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/IamFetch/OSIamFetchReadyCondition.swift @@ -28,17 +28,31 @@ @objc public class OSIamFetchReadyCondition: NSObject, OSCondition { // the id used to index the token map (e.g. onesignalId) private let id: String + + private let stateLock = NSLock() private var hasSubscriptionUpdatePending: Bool = false - // Singleton shared instance initialized with default empty id - private static var instance: OSIamFetchReadyCondition? + private static let instancesLock = NSLock() + private static var instances: [String: OSIamFetchReadyCondition] = [:] - // Method to get or initialize the shared instance + /** + One condition per id, so a fetch waits on the same object the subscription listener armed, and a + fetch for a user who just switched in is not answered by the previous user's tokens. + */ @objc public static func sharedInstance(withId id: String) -> OSIamFetchReadyCondition { - if instance == nil { - instance = OSIamFetchReadyCondition(id: id) + return instancesLock.withLock { + if let existing = instances[id] { + return existing + } + let condition = OSIamFetchReadyCondition(id: id) + instances[id] = condition + return condition } - return instance! + } + + /// Test seam; the instances otherwise live as long as the process. + @objc public static func reset() { + instancesLock.withLock { instances = [:] } } // Private initializer to prevent external instantiation @@ -53,8 +67,16 @@ return OSIamFetchReadyCondition.CONDITIONID } + /// Raises the bar for the next fetch: an in-session subscription change is only readable once its + /// own token arrives, so waiting on the user token alone would fetch before the server can see it. public func setSubscriptionUpdatePending(value: Bool) { - hasSubscriptionUpdatePending = value + stateLock.withLock { hasSubscriptionUpdatePending = value } + } + + /// The fetch this was raised for has been released, so later fetches stop waiting on a subscription + /// token that has no update behind it. + @objc public func onConditionSatisfied() { + setSubscriptionUpdatePending(value: false) } public func isMet(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> Bool { @@ -71,7 +93,7 @@ return true } - if hasSubscriptionUpdatePending { + if stateLock.withLock({ hasSubscriptionUpdatePending }) { return userUpdateTokenSet && subscriptionTokenSet } return userUpdateTokenSet diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift index c63807819..ffef386ee 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSCondition.swift @@ -32,4 +32,8 @@ import Foundation var conditionId: String { get } func isMet(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> Bool func getNewestToken(indexedTokens: [String: [NSNumber: OSReadYourWriteData]]) -> OSReadYourWriteData? + + /// Called once a waiter on this condition has been released, so a condition that raised its own bar + /// for that wait can lower it again instead of holding every later waiter to it. + @objc optional func onConditionSatisfied() } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift index ac7129de8..cb081c415 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift @@ -32,17 +32,30 @@ import OneSignalCore // Singleton instance @objc public static let shared = OSConsistencyManager() - private let queue = DispatchQueue(label: "com.consistencyManager.queue") + // Serial, and the only place `indexedTokens` and `indexedConditions` may be touched. + // Non-private so test helpers can synchronize with it. + let queue = DispatchQueue(label: "com.consistencyManager.queue") private var indexedTokens: [String: [NSNumber: OSReadYourWriteData]] = [:] - private var indexedConditions: [String: [(OSCondition, DispatchSemaphore)]] = [:] // Index conditions by condition id + // Waiters, indexed by the id passed to getRywTokenFromAwaitableCondition. Non-private for tests. + var indexedConditions: [String: [(OSCondition, DispatchSemaphore)]] = [:] + + /** + How long a waiter blocks before proceeding with whatever token it has. A response that never arrives — + the device is offline, or the endpoint stopped returning `ryw_token` and has no call that resolves the + condition — would otherwise hold the calling thread for the life of the process. + Non-private so tests can shorten it. + */ + static var waitTimeout: DispatchTimeInterval = .seconds(30) // Private initializer to prevent multiple instances private override init() {} // Used for testing public func reset() { - indexedTokens = [:] - indexedConditions = [:] + queue.sync { + self.indexedTokens = [:] + self.indexedConditions = [:] + } } // Function to set the token in a thread-safe manner @@ -57,52 +70,63 @@ import OneSignalCore } } - // Register a condition and block the caller until the condition is met + /// Blocks the caller until the condition is met or `waitTimeout` elapses, then returns the newest + /// token the condition accepts, which is nil when it was released without one. @objc public func getRywTokenFromAwaitableCondition(_ condition: OSCondition, forId id: String) -> OSReadYourWriteData? { let semaphore = DispatchSemaphore(value: 0) queue.sync { - if self.indexedConditions[id] == nil { - self.indexedConditions[id] = [] - } - self.indexedConditions[id]?.append((condition, semaphore)) + self.indexedConditions[id, default: []].append((condition, semaphore)) self.checkConditionsAndComplete(forId: id) } - semaphore.wait() // Block until the condition is met + if semaphore.wait(timeout: .now() + OSConsistencyManager.waitTimeout) == .timedOut { + OneSignalLog.onesignalLog(.LL_WARN, message: "OSConsistencyManager timed out waiting on \(condition.conditionId) for id: \(id)") + // Deregister, or the entry outlives the wait and the next token signals nobody. + queue.sync { + self.indexedConditions[id]?.removeAll { $0.1 === semaphore } + } + } return queue.sync { return condition.getNewestToken(indexedTokens: self.indexedTokens) } } - // Method to resolve conditions by condition ID (e.g. OSIamFetchReadyCondition.ID) - @objc public func resolveConditionsWithID(id: String) { - guard let conditionList = indexedConditions[id] else { return } - var completedConditions: [(OSCondition, DispatchSemaphore)] = [] - for (condition, semaphore) in conditionList { - if condition.conditionId == id { - semaphore.signal() - completedConditions.append((condition, semaphore)) + /** + Releases every waiter on `conditionId`, whichever id it registered under. Callers reach for this when + a response came back with no `ryw_token` at all, which leaves the waiter with nothing left to wait for. + */ + @objc public func resolveConditionsWithID(id conditionId: String) { + queue.sync { + for indexId in Array(self.indexedConditions.keys) { + guard let waiters = self.indexedConditions[indexId] else { + continue + } + for (condition, semaphore) in waiters where condition.conditionId == conditionId { + OneSignalLog.onesignalLog(.LL_INFO, message: "Condition \(conditionId) resolved for id: \(indexId)") + self.release(condition, semaphore) + } + self.indexedConditions[indexId] = waiters.filter { $0.0.conditionId != conditionId } } } - indexedConditions[id]?.removeAll { condition, semaphore in - completedConditions.contains(where: { $0.0 === condition && $0.1 == semaphore }) - } } // Private method to check conditions for a specific id (unique ID like onesignalId) private func checkConditionsAndComplete(forId id: String) { - guard let conditionList = indexedConditions[id] else { return } - var completedConditions: [(OSCondition, DispatchSemaphore)] = [] - for (condition, semaphore) in conditionList { + guard let waiters = indexedConditions[id] else { return } + var stillWaiting: [(OSCondition, DispatchSemaphore)] = [] + for (condition, semaphore) in waiters { if condition.isMet(indexedTokens: indexedTokens) { OneSignalLog.onesignalLog(.LL_INFO, message: "Condition met for id: \(id)") - semaphore.signal() - completedConditions.append((condition, semaphore)) + release(condition, semaphore) } else { OneSignalLog.onesignalLog(.LL_INFO, message: "Condition not met for id: \(id)") + stillWaiting.append((condition, semaphore)) } } - indexedConditions[id]?.removeAll { condition, semaphore in - completedConditions.contains(where: { $0.0 === condition && $0.1 == semaphore }) - } + indexedConditions[id] = stillWaiting + } + + private func release(_ condition: OSCondition, _ semaphore: DispatchSemaphore) { + condition.onConditionSatisfied?() + semaphore.signal() } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift index 53d105943..8649a1a07 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreMocks/ConsistencyManagerTestHelpers.swift @@ -28,6 +28,13 @@ import OneSignalOSCore public class ConsistencyManagerTestHelpers { + /// Clears both halves of the read-your-write state: the manager's tokens and waiters, and the + /// per-id conditions, which otherwise carry a raised subscription bar into the next test. + public static func reset() { + OSConsistencyManager.shared.reset() + OSIamFetchReadyCondition.reset() + } + /// Unblocks the Consistency Manager, which allows fetching of IAMs for example. public static func setDefaultRywToken(id: String) { let key = OSIamFetchOffsetKey.userUpdate diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift index 9a686d644..2d27096d5 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift @@ -8,19 +8,23 @@ import Foundation import XCTest -import OneSignalOSCore +@testable import OneSignalOSCore class OSConsistencyManagerTests: XCTestCase { var consistencyManager: OSConsistencyManager! + private var defaultWaitTimeout: DispatchTimeInterval! override func setUp() { super.setUp() // Use the shared instance of OSConsistencyManager consistencyManager = OSConsistencyManager.shared + defaultWaitTimeout = OSConsistencyManager.waitTimeout } override func tearDown() { + OSConsistencyManager.waitTimeout = defaultWaitTimeout consistencyManager.reset() + OSIamFetchReadyCondition.reset() super.tearDown() } @@ -92,35 +96,28 @@ class OSConsistencyManagerTests: XCTestCase { // Test: registerCondition does not complete when condition is not met func testRegisterConditionDoesNotCompleteWhenConditionIsNotMet() { - // Given a condition that will never be met - let condition = TestUnmetCondition() + OSConsistencyManager.waitTimeout = .milliseconds(500) let id = "test_id" - let rywDelay = 500 as NSNumber - - // Start on a background queue to simulate async behavior - DispatchQueue.global().async { - // Register the condition asynchronously - let rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(condition, forId: id) + let returned = expectation(description: "waiter returned") + var rywData: OSReadYourWriteData? - // Since the condition will never be met, rywToken should remain nil - XCTAssertNil(rywData) + DispatchQueue.global().async { + rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: id) + returned.fulfill() + } + waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } - // Set an unrelated token to verify that the unmet condition still doesn't complete - self.consistencyManager.setRywTokenAndDelay( + // A token for another id must not release this waiter + consistencyManager.setRywTokenAndDelay( id: "unrelated_id", key: OSIamFetchOffsetKey.userUpdate, - value: OSReadYourWriteData(rywToken: "unrelated", rywDelay: rywDelay) - ) - - // newest token should still be nil as the condition is not met - XCTAssertNil(rywData) - } + value: OSReadYourWriteData(rywToken: "unrelated", rywDelay: 500) + ) + XCTAssertEqual(consistencyManager.waiterCount, 1) - // Use a short delay to let the async behavior complete without waiting indefinitely - DispatchQueue.global().asyncAfter(deadline: .now() + 1.0) { - XCTAssertTrue(true) // Simulate some async action completing without hanging - } - } + wait(for: [returned], timeout: 2.0) + XCTAssertNil(rywData) + } func testSetRywTokenWithoutAnyCondition() { // Given @@ -292,6 +289,64 @@ class OSConsistencyManagerTests: XCTestCase { waitForExpectations(timeout: 2.0, handler: nil) } + + // MARK: - Releasing waiters + + /** + The fallback for a response that carried no `ryw_token`: the caller knows only the condition id, + while the waiter registered under the user's id, so resolving has to reach across the index. + */ + func testResolvingByConditionIdReleasesAWaiterRegisteredUnderAnotherId() { + let returned = expectation(description: "waiter returned") + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } + + consistencyManager.resolveConditionsWithID(id: TestUnmetCondition.CONDITIONID) + + wait(for: [returned], timeout: 2.0) + XCTAssertEqual(consistencyManager.waiterCount, 0) + } + + func testResolvingADifferentConditionLeavesTheWaiterWaiting() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let returned = expectation(description: "waiter returned") + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } + + consistencyManager.resolveConditionsWithID(id: "SomeOtherCondition") + + XCTAssertEqual(consistencyManager.waiterCount, 1) + // Let it time out rather than leaving the thread blocked past the end of the test. + wait(for: [returned], timeout: 2.0) + } + + /// A condition nothing ever meets must not hold its thread for the life of the process. + func testAWaiterGivesUpWhenItsConditionIsNeverMet() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let returned = expectation(description: "waiter returned") + var rywData: OSReadYourWriteData? + DispatchQueue.global().async { + rywData = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") + returned.fulfill() + } + + wait(for: [returned], timeout: 2.0) + XCTAssertNil(rywData) + XCTAssertEqual(consistencyManager.waiterCount, 0, "a waiter that gave up has to deregister") + } +} + +extension OSConsistencyManager { + /// Waiters registered right now. Tests poll it while the manager mutates on its own queue. + var waiterCount: Int { + return queue.sync { indexedConditions.values.reduce(0) { $0 + $1.count } } + } } // Mock implementation of OSCondition that simulates a condition that isn't met diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift new file mode 100644 index 000000000..0a7103ffa --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift @@ -0,0 +1,160 @@ +/* + 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 Foundation +import XCTest +@testable import OneSignalOSCore + +/// Covers what an IAM fetch waits for, and for whom. +final class OSIamFetchReadyConditionTests: XCTestCase { + + private let userA = "onesignal-id-a" + private let userB = "onesignal-id-b" + private var defaultWaitTimeout: DispatchTimeInterval! + + override func setUp() { + super.setUp() + defaultWaitTimeout = OSConsistencyManager.waitTimeout + OSConsistencyManager.shared.reset() + OSIamFetchReadyCondition.reset() + } + + override func tearDown() { + OSConsistencyManager.waitTimeout = defaultWaitTimeout + OSConsistencyManager.shared.reset() + OSIamFetchReadyCondition.reset() + super.tearDown() + } + + // MARK: - One condition per id + + func testTheSameIdGetsTheSameCondition() { + let first = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let second = OSIamFetchReadyCondition.sharedInstance(withId: userA) + + XCTAssertTrue(first === second, "the fetch has to wait on the object the listener armed") + } + + /// A user switch must not be answered by the previous user's condition, which reads their tokens. + func testADifferentIdGetsItsOwnCondition() { + let forUserA = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let forUserB = OSIamFetchReadyCondition.sharedInstance(withId: userB) + + XCTAssertFalse(forUserA === forUserB) + XCTAssertTrue(forUserA.isMet(indexedTokens: [userA: userCreateToken()])) + XCTAssertFalse(forUserB.isMet(indexedTokens: [userA: userCreateToken()]), + "the new user's fetch must not be released by the previous user's token") + } + + // MARK: - What the condition waits for + + func testAUserUpdateTokenIsEnoughWithNoSubscriptionUpdatePending() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + } + + func testAPendingSubscriptionUpdateAlsoWaitsForItsToken() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + XCTAssertFalse(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken().merging(subscriptionToken()) { current, _ in current }])) + } + + /// The raised bar belongs to the fetch it was raised for; leaving it up makes every later fetch + /// wait on a subscription token that has no update behind it. + func testTheSubscriptionBarComesBackDownOnceTheFetchIsReleased() { + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + condition.onConditionSatisfied() + + XCTAssertTrue(condition.isMet(indexedTokens: [userA: userUpdateToken()])) + } + + // MARK: - Through the Consistency Manager + + func testTheManagerLowersTheBarWhenItReleasesTheWaiter() { + let manager = OSConsistencyManager.shared + OSIamFetchReadyCondition.sharedInstance(withId: userA).setSubscriptionUpdatePending(value: true) + + let firstReturned = expectation(description: "first fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + firstReturned.fulfill() + } + waitUntil("first fetch waiting") { manager.waiterCount == 1 } + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: token("100")) + XCTAssertEqual(manager.waiterCount, 1, "a pending subscription update still owes a token") + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.subscriptionUpdate, value: token("200")) + wait(for: [firstReturned], timeout: 2.0) + + // The next fetch has no subscription update behind it, so the user token alone releases it. + let secondReturned = expectation(description: "second fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + secondReturned.fulfill() + } + wait(for: [secondReturned], timeout: 2.0) + } + + /// The `ryw_token`-missing fallback the executors call, which names the condition rather than the user. + func testResolvingTheConditionReleasesTheFetch() { + let manager = OSConsistencyManager.shared + let returned = expectation(description: "fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(OSIamFetchReadyCondition.sharedInstance(withId: self.userA), forId: self.userA) + returned.fulfill() + } + waitUntil("fetch waiting") { manager.waiterCount == 1 } + + manager.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + + wait(for: [returned], timeout: 2.0) + } + + // MARK: - Helpers + + private func token(_ value: String) -> OSReadYourWriteData { + return OSReadYourWriteData(rywToken: value, rywDelay: 0) + } + + private func userCreateToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.userCreate.rawValue): token("create")] + } + + private func userUpdateToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.userUpdate.rawValue): token("update")] + } + + private func subscriptionToken() -> [NSNumber: OSReadYourWriteData] { + return [NSNumber(value: OSIamFetchOffsetKey.subscriptionUpdate.rawValue): token("subscription")] + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift index e8519205d..875a26ffb 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSOperationRepoFlushTests.swift @@ -138,25 +138,6 @@ final class OSOperationRepoFlushTests: XCTestCase { value: property ) } - - private func waitUntil( - _ description: String, - timeout: TimeInterval = 2.0, - file: StaticString = #filePath, - line: UInt = #line, - _ condition: @escaping () -> Bool - ) { - let exp = expectation(description: description) - let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in - if condition() { - timer.invalidate() - exp.fulfill() - } - } - let result = XCTWaiter.wait(for: [exp], timeout: timeout) - timer.invalidate() - XCTAssertEqual(result, .completed, "Timed out waiting for: \(description)", file: file, line: line) - } } private final class MockOperationExecutor: OSOperationExecutor { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/XCTestCase+WaitUntil.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/XCTestCase+WaitUntil.swift new file mode 100644 index 000000000..629c7d95e --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/XCTestCase+WaitUntil.swift @@ -0,0 +1,51 @@ +/* + Modified MIT License + + Copyright 2025 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 Foundation +import XCTest + +extension XCTestCase { + /// Polls: the work runs on a private queue with no completion to hook. + func waitUntil( + _ description: String, + timeout: TimeInterval = 2.0, + file: StaticString = #filePath, + line: UInt = #line, + _ condition: @escaping () -> Bool + ) { + let exp = expectation(description: description) + let timer = Timer.scheduledTimer(withTimeInterval: 0.01, repeats: true) { timer in + if condition() { + timer.invalidate() + exp.fulfill() + } + } + let result = XCTWaiter.wait(for: [exp], timeout: timeout) + timer.invalidate() + XCTAssertEqual(result, .completed, "Timed out waiting for: \(description)", file: file, line: line) + } +} From 953ff22f51aaba3298bbe5fa2cc10cc4b6b61c60 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 18:25:54 -0700 Subject: [PATCH 3/4] fix: scope RYW resolve to one user and lower the bar on timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveConditionsWithID walked every index and cleared every matching condition's subscription bar, so a response with no ryw_token for one user could unblock — and disarm — another. Resolve now takes the onesignal id and only touches that bucket. A timed-out waiter only deregistered itself, leaving hasSubscriptionUpdatePending up, so later IAM fetches for that id paid another full wait for a subscription token that was never coming. Timeout now runs the same onConditionSatisfied clear, if the waiter is still registered. Co-authored-by: Cursor --- .../Consistency/OSConsistencyManager.swift | 34 +++++++----- .../OSConsistencyManagerTests.swift | 37 ++++++++++--- .../OSIamFetchReadyConditionTests.swift | 52 ++++++++++++++++++- .../OSPropertyOperationExecutor.swift | 2 +- .../OSSubscriptionOperationExecutor.swift | 4 +- .../Source/Executors/OSUserExecutor.swift | 2 +- 6 files changed, 105 insertions(+), 26 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift index cb081c415..c7b29cb06 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift @@ -80,8 +80,15 @@ import OneSignalCore } if semaphore.wait(timeout: .now() + OSConsistencyManager.waitTimeout) == .timedOut { OneSignalLog.onesignalLog(.LL_WARN, message: "OSConsistencyManager timed out waiting on \(condition.conditionId) for id: \(id)") - // Deregister, or the entry outlives the wait and the next token signals nobody. queue.sync { + // Still registered? Then this timeout owns the clear. A concurrent met-path release + // already ran onConditionSatisfied and removed the entry. + guard self.indexedConditions[id]?.contains(where: { $0.1 === semaphore }) == true else { + return + } + // Lower any bar the condition raised for this wait; otherwise later fetches for this + // id keep paying the full timeout for a subscription token that is never coming. + condition.onConditionSatisfied?() self.indexedConditions[id]?.removeAll { $0.1 === semaphore } } } @@ -91,21 +98,22 @@ import OneSignalCore } /** - Releases every waiter on `conditionId`, whichever id it registered under. Callers reach for this when - a response came back with no `ryw_token` at all, which leaves the waiter with nothing left to wait for. + Releases waiters on `conditionId` that registered under `id` (e.g. onesignalId). Callers reach for + this when a response for that user came back with no `ryw_token`, which leaves those waiters with + nothing left to wait for. Scoped to `id` so a missing token for one user cannot unblock — or clear + the subscription bar of — another. */ - @objc public func resolveConditionsWithID(id conditionId: String) { + @objc(resolveConditionsWithConditionId:forId:) + public func resolveConditions(conditionId: String, forId id: String) { queue.sync { - for indexId in Array(self.indexedConditions.keys) { - guard let waiters = self.indexedConditions[indexId] else { - continue - } - for (condition, semaphore) in waiters where condition.conditionId == conditionId { - OneSignalLog.onesignalLog(.LL_INFO, message: "Condition \(conditionId) resolved for id: \(indexId)") - self.release(condition, semaphore) - } - self.indexedConditions[indexId] = waiters.filter { $0.0.conditionId != conditionId } + guard let waiters = self.indexedConditions[id] else { + return + } + for (condition, semaphore) in waiters where condition.conditionId == conditionId { + OneSignalLog.onesignalLog(.LL_INFO, message: "Condition \(conditionId) resolved for id: \(id)") + self.release(condition, semaphore) } + self.indexedConditions[id] = waiters.filter { $0.0.conditionId != conditionId } } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift index 2d27096d5..bf5c969f6 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift @@ -292,11 +292,8 @@ class OSConsistencyManagerTests: XCTestCase { // MARK: - Releasing waiters - /** - The fallback for a response that carried no `ryw_token`: the caller knows only the condition id, - while the waiter registered under the user's id, so resolving has to reach across the index. - */ - func testResolvingByConditionIdReleasesAWaiterRegisteredUnderAnotherId() { + /// A response with no `ryw_token` releases waiters for that user only. + func testResolvingByConditionIdReleasesWaitersForThatId() { let returned = expectation(description: "waiter returned") DispatchQueue.global().async { _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: "onesignal-id") @@ -304,12 +301,38 @@ class OSConsistencyManagerTests: XCTestCase { } waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } - consistencyManager.resolveConditionsWithID(id: TestUnmetCondition.CONDITIONID) + consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: "onesignal-id") wait(for: [returned], timeout: 2.0) XCTAssertEqual(consistencyManager.waiterCount, 0) } + /// A missing token for one user must not unblock a wait registered under another. + func testResolvingOneIdLeavesAnotherIdsWaiterWaiting() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let userA = "onesignal-id-a" + let userB = "onesignal-id-b" + + let aReturned = expectation(description: "user A waiter returned") + let bReturned = expectation(description: "user B waiter returned") + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: userA) + aReturned.fulfill() + } + DispatchQueue.global().async { + _ = self.consistencyManager.getRywTokenFromAwaitableCondition(TestUnmetCondition(), forId: userB) + bReturned.fulfill() + } + waitUntil("both waiters registered") { self.consistencyManager.waiterCount == 2 } + + consistencyManager.resolveConditions(conditionId: TestUnmetCondition.CONDITIONID, forId: userB) + + wait(for: [bReturned], timeout: 2.0) + XCTAssertEqual(consistencyManager.waiterCount, 1, "user A's waiter must still be registered") + // Let A time out rather than leaving the thread blocked past the end of the test. + wait(for: [aReturned], timeout: 2.0) + } + func testResolvingADifferentConditionLeavesTheWaiterWaiting() { OSConsistencyManager.waitTimeout = .milliseconds(200) let returned = expectation(description: "waiter returned") @@ -319,7 +342,7 @@ class OSConsistencyManagerTests: XCTestCase { } waitUntil("waiter registered") { self.consistencyManager.waiterCount == 1 } - consistencyManager.resolveConditionsWithID(id: "SomeOtherCondition") + consistencyManager.resolveConditions(conditionId: "SomeOtherCondition", forId: "onesignal-id") XCTAssertEqual(consistencyManager.waiterCount, 1) // Let it time out rather than leaving the thread blocked past the end of the test. diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift index 0a7103ffa..e2c7502bc 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift @@ -125,7 +125,7 @@ final class OSIamFetchReadyConditionTests: XCTestCase { wait(for: [secondReturned], timeout: 2.0) } - /// The `ryw_token`-missing fallback the executors call, which names the condition rather than the user. + /// The `ryw_token`-missing fallback the executors call for that user. func testResolvingTheConditionReleasesTheFetch() { let manager = OSConsistencyManager.shared let returned = expectation(description: "fetch released") @@ -135,11 +135,59 @@ final class OSIamFetchReadyConditionTests: XCTestCase { } waitUntil("fetch waiting") { manager.waiterCount == 1 } - manager.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + manager.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: userA) wait(for: [returned], timeout: 2.0) } + /// A missing token for one user must not clear another user's raised subscription bar. + func testResolvingOneUserDoesNotLowerAnotherUsersSubscriptionBar() { + let manager = OSConsistencyManager.shared + let conditionA = OSIamFetchReadyCondition.sharedInstance(withId: userA) + let conditionB = OSIamFetchReadyCondition.sharedInstance(withId: userB) + conditionA.setSubscriptionUpdatePending(value: true) + conditionB.setSubscriptionUpdatePending(value: true) + + let bReturned = expectation(description: "user B fetch released") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(conditionB, forId: self.userB) + bReturned.fulfill() + } + waitUntil("user B waiting") { manager.waiterCount == 1 } + + manager.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: userB) + wait(for: [bReturned], timeout: 2.0) + + XCTAssertFalse(conditionA.isMet(indexedTokens: [userA: userUpdateToken()]), + "user A's subscription bar must still be up") + XCTAssertTrue(conditionB.isMet(indexedTokens: [userB: userUpdateToken()]), + "user B's bar comes down with its own resolve") + } + + /// Timing out must lower the bar, or every later fetch for that id pays another full wait. + func testTimingOutLowersTheSubscriptionBar() { + OSConsistencyManager.waitTimeout = .milliseconds(200) + let manager = OSConsistencyManager.shared + let condition = OSIamFetchReadyCondition.sharedInstance(withId: userA) + condition.setSubscriptionUpdatePending(value: true) + + let firstReturned = expectation(description: "first fetch timed out") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) + firstReturned.fulfill() + } + wait(for: [firstReturned], timeout: 2.0) + + manager.setRywTokenAndDelay(id: userA, key: OSIamFetchOffsetKey.userUpdate, value: token("100")) + + let secondReturned = expectation(description: "second fetch released by user token alone") + DispatchQueue.global().async { + _ = manager.getRywTokenFromAwaitableCondition(condition, forId: self.userA) + secondReturned.fulfill() + } + wait(for: [secondReturned], timeout: 2.0) + } + // MARK: - Helpers private func token(_ value: String) -> OSReadYourWriteData { diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift index 88f29af73..0df165df2 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSPropertyOperationExecutor.swift @@ -276,7 +276,7 @@ class OSPropertyOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } } onFailure: { error in diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift index f100bbfd9..f8c985cce 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSSubscriptionOperationExecutor.swift @@ -311,7 +311,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } @@ -431,7 +431,7 @@ class OSSubscriptionOperationExecutor: OSOperationExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift index 1b8d0e5b3..477f11540 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/Executors/OSUserExecutor.swift @@ -271,7 +271,7 @@ extension OSUserExecutor { ) } else { // handle a potential regression where ryw_token is no longer returned by API - OSConsistencyManager.shared.resolveConditionsWithID(id: OSIamFetchReadyCondition.CONDITIONID) + OSConsistencyManager.shared.resolveConditions(conditionId: OSIamFetchReadyCondition.CONDITIONID, forId: onesignalId) } } } From c09d5a9c3cb977f0b7d90c6835ff81933989032b Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 11 Aug 2026 18:29:58 -0700 Subject: [PATCH 4/4] chore: tighten RYW comments to iOS comment style Cut hazard essays and cross-user narration down to short whys hitched to the action, and trim the resolveConditions doc to the contract. Co-authored-by: Cursor --- .../Source/Consistency/OSConsistencyManager.swift | 12 ++++-------- .../OSConsistencyManagerTests.swift | 6 +++--- .../OSIamFetchReadyConditionTests.swift | 4 ++-- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift index c7b29cb06..50de9c001 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/Consistency/OSConsistencyManager.swift @@ -81,13 +81,11 @@ import OneSignalCore if semaphore.wait(timeout: .now() + OSConsistencyManager.waitTimeout) == .timedOut { OneSignalLog.onesignalLog(.LL_WARN, message: "OSConsistencyManager timed out waiting on \(condition.conditionId) for id: \(id)") queue.sync { - // Still registered? Then this timeout owns the clear. A concurrent met-path release - // already ran onConditionSatisfied and removed the entry. + // Skip if a met-path release already removed this waiter. guard self.indexedConditions[id]?.contains(where: { $0.1 === semaphore }) == true else { return } - // Lower any bar the condition raised for this wait; otherwise later fetches for this - // id keep paying the full timeout for a subscription token that is never coming. + // Clear so later fetches for this id are not held to a subscription token that never arrives. condition.onConditionSatisfied?() self.indexedConditions[id]?.removeAll { $0.1 === semaphore } } @@ -98,10 +96,8 @@ import OneSignalCore } /** - Releases waiters on `conditionId` that registered under `id` (e.g. onesignalId). Callers reach for - this when a response for that user came back with no `ryw_token`, which leaves those waiters with - nothing left to wait for. Scoped to `id` so a missing token for one user cannot unblock — or clear - the subscription bar of — another. + Releases waiters on `conditionId` registered under `id` (e.g. onesignalId). Used when that user's + response carried no `ryw_token`, so those waiters have nothing left to wait for. */ @objc(resolveConditionsWithConditionId:forId:) public func resolveConditions(conditionId: String, forId id: String) { diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift index bf5c969f6..2db1a1378 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSConsistencyManagerTests.swift @@ -307,7 +307,7 @@ class OSConsistencyManagerTests: XCTestCase { XCTAssertEqual(consistencyManager.waiterCount, 0) } - /// A missing token for one user must not unblock a wait registered under another. + /// Resolving user B leaves user A's waiter registered. func testResolvingOneIdLeavesAnotherIdsWaiterWaiting() { OSConsistencyManager.waitTimeout = .milliseconds(200) let userA = "onesignal-id-a" @@ -329,7 +329,7 @@ class OSConsistencyManagerTests: XCTestCase { wait(for: [bReturned], timeout: 2.0) XCTAssertEqual(consistencyManager.waiterCount, 1, "user A's waiter must still be registered") - // Let A time out rather than leaving the thread blocked past the end of the test. + // Drain A's timeout so the thread is not left blocked after the test. wait(for: [aReturned], timeout: 2.0) } @@ -345,7 +345,7 @@ class OSConsistencyManagerTests: XCTestCase { consistencyManager.resolveConditions(conditionId: "SomeOtherCondition", forId: "onesignal-id") XCTAssertEqual(consistencyManager.waiterCount, 1) - // Let it time out rather than leaving the thread blocked past the end of the test. + // Drain the timeout so the thread is not left blocked after the test. wait(for: [returned], timeout: 2.0) } diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift index e2c7502bc..68ea0b19e 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSIamFetchReadyConditionTests.swift @@ -140,7 +140,7 @@ final class OSIamFetchReadyConditionTests: XCTestCase { wait(for: [returned], timeout: 2.0) } - /// A missing token for one user must not clear another user's raised subscription bar. + /// Resolving user B leaves user A's subscription bar raised. func testResolvingOneUserDoesNotLowerAnotherUsersSubscriptionBar() { let manager = OSConsistencyManager.shared let conditionA = OSIamFetchReadyCondition.sharedInstance(withId: userA) @@ -164,7 +164,7 @@ final class OSIamFetchReadyConditionTests: XCTestCase { "user B's bar comes down with its own resolve") } - /// Timing out must lower the bar, or every later fetch for that id pays another full wait. + /// After a timeout, a later fetch for the same id is released by the user token alone. func testTimingOutLowersTheSubscriptionBar() { OSConsistencyManager.waitTimeout = .milliseconds(200) let manager = OSConsistencyManager.shared